| author | |
| committer | |
| log | d8e0ba3ea02dc70874897655b2551ea72c1c738c |
| tree | 20969537c4d5d949baaa4b9c96227b6717d63fdf |
| parent | 32e7844837b35ca0a0ff038997a45ea75cb7103f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>64 files changed, 5735 insertions(+), 812 deletions(-)
apps/mobile/app.config.ts+6| ... | @@ -161,6 +161,9 @@ const config: ExpoConfig = { | ... | @@ -161,6 +161,9 @@ const config: ExpoConfig = { |
| 161 | bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, | 161 | bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, |
| 162 | groupIdentifier: `group.${variant.iosBundleIdentifier}`, | 162 | groupIdentifier: `group.${variant.iosBundleIdentifier}`, |
| 163 | enablePushNotifications: true, | 163 | enablePushNotifications: true, |
| 164 | // Agent activity can update many times an hour; without the | ||
| 165 | // frequent-updates entitlement iOS throttles the update budget sooner. | ||
| 166 | frequentUpdates: true, | ||
| 164 | widgets: [ | 167 | widgets: [ |
| 165 | { | 168 | { |
| 166 | name: "AgentActivity", | 169 | name: "AgentActivity", |
| ... | @@ -171,6 +174,9 @@ const config: ExpoConfig = { | ... | @@ -171,6 +174,9 @@ const config: ExpoConfig = { |
| 171 | ], | 174 | ], |
| 172 | }, | 175 | }, |
| 173 | ], | 176 | ], |
| 177 | // Must run after expo-widgets so the widget target exists when it wires in | ||
| 178 | // the branded logo asset catalog. | ||
| 179 | "./plugins/withWidgetLogoAsset.cjs", | ||
| 174 | "./plugins/withIosSceneLifecycle.cjs", | 180 | "./plugins/withIosSceneLifecycle.cjs", |
| 175 | "./plugins/withAndroidCleartextTraffic.cjs", | 181 | "./plugins/withAndroidCleartextTraffic.cjs", |
| 176 | ], | 182 | ], |
apps/mobile/assets/widget/T3Mark.svg created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <svg width="111" height="74" viewBox="7 29 111 74" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||
| 2 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="black"/> | ||
| 3 | </svg> | ||
apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | "use strict"; | ||
| 2 | |||
| 3 | // Bundle the widget asset catalog into an app-extension (widget) target. | ||
| 4 | // | ||
| 5 | // expo-widgets generates the widget target without a Resources build phase, and | ||
| 6 | // hand-adding a PBXResourcesBuildPhase does NOT get picked up — xcodebuild's | ||
| 7 | // planner never schedules `actool` for it (verified: even a full re-plan skips | ||
| 8 | // it). So instead we add a shell-script phase that runs `actool` directly and | ||
| 9 | // drops the compiled Assets.car into the extension bundle. Marked | ||
| 10 | // alwaysOutOfDate so the build system always runs it. | ||
| 11 | // | ||
| 12 | // Idempotent across re-runs. Returns true when it added the phase, false when it | ||
| 13 | // was already present or the target was not found. | ||
| 14 | |||
| 15 | const PHASE_NAME = "Compile Widget Assets"; | ||
| 16 | |||
| 17 | // Compiles ExpoWidgetsTarget/Assets.xcassets into the extension's resources dir. | ||
| 18 | // Uses only Xcode-provided build settings so it works for device + simulator. | ||
| 19 | const ACTOOL_SCRIPT = [ | ||
| 20 | "set -e", | ||
| 21 | 'CATALOG="${SRCROOT}/ExpoWidgetsTarget/Assets.xcassets"', | ||
| 22 | 'if [ ! -d "$CATALOG" ]; then', | ||
| 23 | ' echo "warning: widget asset catalog not found at $CATALOG"', | ||
| 24 | " exit 0", | ||
| 25 | "fi", | ||
| 26 | 'DEST="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"', | ||
| 27 | 'mkdir -p "$DEST"', | ||
| 28 | 'xcrun actool "$CATALOG" --compile "$DEST" --platform "${PLATFORM_NAME}" --minimum-deployment-target "${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" --output-format human-readable-text', | ||
| 29 | ].join("\n"); | ||
| 30 | |||
| 31 | function stripComments(map) { | ||
| 32 | const out = {}; | ||
| 33 | for (const key of Object.keys(map || {})) { | ||
| 34 | if (key.endsWith("_comment")) continue; | ||
| 35 | out[key] = map[key]; | ||
| 36 | } | ||
| 37 | return out; | ||
| 38 | } | ||
| 39 | |||
| 40 | function findByName(map, name) { | ||
| 41 | for (const [uuid, value] of Object.entries(stripComments(map))) { | ||
| 42 | if (value && value.name === name) return { uuid, value }; | ||
| 43 | } | ||
| 44 | return null; | ||
| 45 | } | ||
| 46 | |||
| 47 | /** | ||
| 48 | * @param {import('xcode').XcodeProject} proj | ||
| 49 | * @param {{ targetName: string }} opts | ||
| 50 | */ | ||
| 51 | function addWidgetAssetCatalog(proj, opts) { | ||
| 52 | const objects = proj.hash.project.objects; | ||
| 53 | const target = findByName(objects.PBXNativeTarget, opts.targetName); | ||
| 54 | if (!target) return false; | ||
| 55 | |||
| 56 | const phases = target.value.buildPhases || []; | ||
| 57 | const existing = objects.PBXShellScriptBuildPhase || {}; | ||
| 58 | const already = Object.entries(stripComments(existing)).some( | ||
| 59 | ([uuid, value]) => | ||
| 60 | value && value.name === `"${PHASE_NAME}"` && phases.some((p) => p.value === uuid), | ||
| 61 | ); | ||
| 62 | if (already) return false; | ||
| 63 | |||
| 64 | const { uuid } = proj.addBuildPhase([], "PBXShellScriptBuildPhase", PHASE_NAME, target.uuid, { | ||
| 65 | shellPath: "/bin/sh", | ||
| 66 | shellScript: ACTOOL_SCRIPT, | ||
| 67 | }); | ||
| 68 | // Always run: input-analysis is exactly what skipped the Resources phase. | ||
| 69 | objects.PBXShellScriptBuildPhase[uuid].alwaysOutOfDate = 1; | ||
| 70 | return true; | ||
| 71 | } | ||
| 72 | |||
| 73 | module.exports = { addWidgetAssetCatalog }; | ||
apps/mobile/plugins/withWidgetLogoAsset.cjs created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | "use strict"; | ||
| 2 | |||
| 3 | // Ships the branded T3 mark to the Live Activity / widget extension. | ||
| 4 | // | ||
| 5 | // expo-widgets generates ExpoWidgetsTarget without a Resources build phase and | ||
| 6 | // has no asset support, so this plugin (a) writes an SVG template image set into | ||
| 7 | // the generated widget asset catalog and (b) wires that catalog into the widget | ||
| 8 | // target with a dedicated Resources build phase. Both steps are idempotent and | ||
| 9 | // survive `expo prebuild --clean`. Must be listed AFTER "expo-widgets" in the | ||
| 10 | // plugins array so the widget target exists when this runs. | ||
| 11 | |||
| 12 | const path = require("path"); | ||
| 13 | const fs = require("fs"); | ||
| 14 | const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); | ||
| 15 | const { addWidgetAssetCatalog } = require("./lib/addWidgetAssetCatalog.cjs"); | ||
| 16 | |||
| 17 | const TARGET_NAME = "ExpoWidgetsTarget"; | ||
| 18 | const CATALOG_NAME = "Assets.xcassets"; | ||
| 19 | const IMAGE_SET = "T3Mark.imageset"; | ||
| 20 | const SVG_NAME = "T3Mark.svg"; | ||
| 21 | |||
| 22 | const CATALOG_CONTENTS = JSON.stringify({ info: { author: "expo", version: 1 } }, null, 2) + "\n"; | ||
| 23 | const IMAGE_SET_CONTENTS = | ||
| 24 | JSON.stringify( | ||
| 25 | { | ||
| 26 | images: [{ idiom: "universal", filename: SVG_NAME }], | ||
| 27 | info: { author: "expo", version: 1 }, | ||
| 28 | properties: { | ||
| 29 | "preserves-vector-representation": true, | ||
| 30 | "template-rendering-intent": "template", | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | null, | ||
| 34 | 2, | ||
| 35 | ) + "\n"; | ||
| 36 | |||
| 37 | function withAssetFiles(config) { | ||
| 38 | return withDangerousMod(config, [ | ||
| 39 | "ios", | ||
| 40 | (cfg) => { | ||
| 41 | const source = path.join(cfg.modRequest.projectRoot, "assets", "widget", SVG_NAME); | ||
| 42 | const catalogDir = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, CATALOG_NAME); | ||
| 43 | const imageSetDir = path.join(catalogDir, IMAGE_SET); | ||
| 44 | fs.mkdirSync(imageSetDir, { recursive: true }); | ||
| 45 | fs.writeFileSync(path.join(catalogDir, "Contents.json"), CATALOG_CONTENTS); | ||
| 46 | fs.writeFileSync(path.join(imageSetDir, "Contents.json"), IMAGE_SET_CONTENTS); | ||
| 47 | fs.copyFileSync(source, path.join(imageSetDir, SVG_NAME)); | ||
| 48 | return cfg; | ||
| 49 | }, | ||
| 50 | ]); | ||
| 51 | } | ||
| 52 | |||
| 53 | function withAssetWiring(config) { | ||
| 54 | return withXcodeProject(config, (cfg) => { | ||
| 55 | addWidgetAssetCatalog(cfg.modResults, { targetName: TARGET_NAME }); | ||
| 56 | return cfg; | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | |||
| 60 | module.exports = function withWidgetLogoAsset(config) { | ||
| 61 | return withAssetWiring(withAssetFiles(config)); | ||
| 62 | }; | ||
apps/mobile/scripts/wire-widget-asset-catalog.cjs created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | "use strict"; | ||
| 2 | |||
| 3 | // One-off: apply the widget asset-catalog wiring to the already-generated | ||
| 4 | // ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets | ||
| 5 | // without a full `expo prebuild`. The durable equivalent lives in | ||
| 6 | // plugins/withWidgetLogoAsset.cjs and runs on prebuild. | ||
| 7 | |||
| 8 | const path = require("path"); | ||
| 9 | const fs = require("fs"); | ||
| 10 | |||
| 11 | const xcodePath = require.resolve("xcode", { | ||
| 12 | paths: [ | ||
| 13 | require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }), | ||
| 14 | ], | ||
| 15 | }); | ||
| 16 | const xcode = require(xcodePath); | ||
| 17 | const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs"); | ||
| 18 | |||
| 19 | const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj"); | ||
| 20 | const proj = xcode.project(pbxprojPath); | ||
| 21 | proj.parseSync(); | ||
| 22 | |||
| 23 | const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" }); | ||
| 24 | |||
| 25 | if (added) { | ||
| 26 | fs.writeFileSync(pbxprojPath, proj.writeSync()); | ||
| 27 | console.log("Added widget asset-compile phase to ExpoWidgetsTarget."); | ||
| 28 | } else { | ||
| 29 | console.log("No change: phase already present or target not found."); | ||
| 30 | } | ||
apps/mobile/src/features/agent-awareness/registrationPayload.ts+11| ... | @@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; | ... | @@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; |
| 2 | 2 | ||
| 3 | import type { Preferences } from "../../lib/storage"; | 3 | import type { Preferences } from "../../lib/storage"; |
| 4 | 4 | ||
| 5 | // Development builds are Xcode-signed and receive sandbox APNs tokens; | ||
| 6 | // preview and production builds are distribution-signed and use production | ||
| 7 | // APNs. The relay routes each device's pushes accordingly. | ||
| 8 | export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" { | ||
| 9 | return appVariant === "development" ? "sandbox" : "production"; | ||
| 10 | } | ||
| 11 | |||
| 5 | export function makeRelayDeviceRegistrationRequest(input: { | 12 | export function makeRelayDeviceRegistrationRequest(input: { |
| 6 | readonly deviceId: string; | 13 | readonly deviceId: string; |
| 7 | readonly label: string; | 14 | readonly label: string; |
| 8 | readonly iosMajorVersion: number; | 15 | readonly iosMajorVersion: number; |
| 9 | readonly appVersion?: string; | 16 | readonly appVersion?: string; |
| 17 | readonly bundleId?: string; | ||
| 18 | readonly apsEnvironment?: "sandbox" | "production"; | ||
| 10 | readonly pushToken?: string; | 19 | readonly pushToken?: string; |
| 11 | readonly pushToStartToken?: string; | 20 | readonly pushToStartToken?: string; |
| 12 | readonly notificationsEnabled: boolean; | 21 | readonly notificationsEnabled: boolean; |
| ... | @@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: { | ... | @@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: { |
| 19 | platform: "ios", | 28 | platform: "ios", |
| 20 | iosMajorVersion: input.iosMajorVersion, | 29 | iosMajorVersion: input.iosMajorVersion, |
| 21 | appVersion: input.appVersion, | 30 | appVersion: input.appVersion, |
| 31 | ...(input.bundleId ? { bundleId: input.bundleId } : {}), | ||
| 32 | ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), | ||
| 22 | ...(input.pushToken ? { pushToken: input.pushToken } : {}), | 33 | ...(input.pushToken ? { pushToken: input.pushToken } : {}), |
| 23 | ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), | 34 | ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), |
| 24 | preferences: { | 35 | preferences: { |
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+290-2| ... | @@ -16,15 +16,23 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; | ... | @@ -16,15 +16,23 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; |
| 16 | import type { SavedRemoteConnection } from "../../lib/connection"; | 16 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 17 | import { cryptoLayer } from "../cloud/dpop"; | 17 | import { cryptoLayer } from "../cloud/dpop"; |
| 18 | import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; | 18 | import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; |
| 19 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; | 19 | import { |
| 20 | clearAgentAwarenessRegistrationRecord, | ||
| 21 | loadAgentAwarenessRegistrationRecord, | ||
| 22 | loadOrCreateAgentAwarenessDeviceId, | ||
| 23 | saveAgentAwarenessRegistrationRecord, | ||
| 24 | } from "../../lib/storage"; | ||
| 25 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; | ||
| 20 | import { | 26 | import { |
| 21 | AgentAwarenessOperationError, | 27 | AgentAwarenessOperationError, |
| 22 | __resetAgentAwarenessRemoteRegistrationForTest, | 28 | __resetAgentAwarenessRemoteRegistrationForTest, |
| 29 | getAgentAwarenessRegistrationStatus, | ||
| 23 | refreshActiveLiveActivityRemoteRegistration, | 30 | refreshActiveLiveActivityRemoteRegistration, |
| 24 | refreshAgentAwarenessRegistration, | 31 | refreshAgentAwarenessRegistration, |
| 25 | normalizeAgentAwarenessRelayBaseUrl, | 32 | normalizeAgentAwarenessRelayBaseUrl, |
| 26 | registerAgentAwarenessConnection, | 33 | registerAgentAwarenessConnection, |
| 27 | registerLiveActivityPushToken, | 34 | registerLiveActivityPushToken, |
| 35 | releaseAgentAwarenessRelayTokenProvider, | ||
| 28 | setAgentAwarenessRelayTokenProvider, | 36 | setAgentAwarenessRelayTokenProvider, |
| 29 | shouldRegisterAgentAwarenessDeviceForProvider, | 37 | shouldRegisterAgentAwarenessDeviceForProvider, |
| 30 | unregisterAgentAwarenessConnection, | 38 | unregisterAgentAwarenessConnection, |
| ... | @@ -41,6 +49,16 @@ const backgroundRuntime = vi.hoisted(() => ({ | ... | @@ -41,6 +49,16 @@ const backgroundRuntime = vi.hoisted(() => ({ |
| 41 | readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void; | 49 | readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void; |
| 42 | }>, | 50 | }>, |
| 43 | })); | 51 | })); |
| 52 | const appStateMock = vi.hoisted(() => ({ | ||
| 53 | listeners: [] as Array<(state: string) => void>, | ||
| 54 | })); | ||
| 55 | const registrationRecordStore = vi.hoisted(() => ({ | ||
| 56 | current: null as { | ||
| 57 | readonly identity: string; | ||
| 58 | readonly signature: string; | ||
| 59 | readonly pushToStartToken?: string; | ||
| 60 | } | null, | ||
| 61 | })); | ||
| 44 | 62 | ||
| 45 | vi.mock("expo-constants", () => ({ | 63 | vi.mock("expo-constants", () => ({ |
| 46 | default: { | 64 | default: { |
| ... | @@ -100,6 +118,19 @@ vi.mock("react-native", () => ({ | ... | @@ -100,6 +118,19 @@ vi.mock("react-native", () => ({ |
| 100 | OS: "ios", | 118 | OS: "ios", |
| 101 | Version: "18.0", | 119 | Version: "18.0", |
| 102 | }, | 120 | }, |
| 121 | AppState: { | ||
| 122 | addEventListener: (_event: string, listener: (state: string) => void) => { | ||
| 123 | appStateMock.listeners.push(listener); | ||
| 124 | return { | ||
| 125 | remove: () => { | ||
| 126 | const index = appStateMock.listeners.indexOf(listener); | ||
| 127 | if (index >= 0) { | ||
| 128 | appStateMock.listeners.splice(index, 1); | ||
| 129 | } | ||
| 130 | }, | ||
| 131 | }; | ||
| 132 | }, | ||
| 133 | }, | ||
| 103 | })); | 134 | })); |
| 104 | 135 | ||
| 105 | vi.mock("../../lib/runtime", () => ({ | 136 | vi.mock("../../lib/runtime", () => ({ |
| ... | @@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({ | ... | @@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({ |
| 114 | vi.mock("../../lib/storage", () => ({ | 145 | vi.mock("../../lib/storage", () => ({ |
| 115 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | 146 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 116 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | 147 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 117 | loadPreferences: vi.fn(() => Promise.resolve({})), | 148 | loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), |
| 149 | loadAgentAwarenessRegistrationRecord: vi.fn(() => | ||
| 150 | Promise.resolve(registrationRecordStore.current), | ||
| 151 | ), | ||
| 152 | saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => { | ||
| 153 | registrationRecordStore.current = record; | ||
| 154 | return Promise.resolve(); | ||
| 155 | }), | ||
| 156 | clearAgentAwarenessRegistrationRecord: vi.fn(() => { | ||
| 157 | registrationRecordStore.current = null; | ||
| 158 | return Promise.resolve(); | ||
| 159 | }), | ||
| 118 | })); | 160 | })); |
| 119 | 161 | ||
| 120 | function proofIat(proof: string): number { | 162 | function proofIat(proof: string): number { |
| ... | @@ -176,6 +218,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -176,6 +218,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 176 | backgroundRuntime.pending.length = 0; | 218 | backgroundRuntime.pending.length = 0; |
| 177 | Constants.expoConfig!.extra = {}; | 219 | Constants.expoConfig!.extra = {}; |
| 178 | __resetAgentAwarenessRemoteRegistrationForTest(); | 220 | __resetAgentAwarenessRemoteRegistrationForTest(); |
| 221 | appStateMock.listeners.length = 0; | ||
| 222 | registrationRecordStore.current = null; | ||
| 223 | vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); | ||
| 224 | vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); | ||
| 225 | vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); | ||
| 226 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); | ||
| 179 | widgetMocks.getInstances.mockReset(); | 227 | widgetMocks.getInstances.mockReset(); |
| 180 | widgetMocks.getInstances.mockReturnValue([]); | 228 | widgetMocks.getInstances.mockReturnValue([]); |
| 181 | }); | 229 | }); |
| ... | @@ -213,6 +261,31 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -213,6 +261,31 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 213 | }); | 261 | }); |
| 214 | }); | 262 | }); |
| 215 | 263 | ||
| 264 | it("registers the app's APNs routing so the relay targets the right bundle", () => { | ||
| 265 | expect( | ||
| 266 | makeRelayDeviceRegistrationRequest({ | ||
| 267 | deviceId: "device-1", | ||
| 268 | label: "Julius's iPhone", | ||
| 269 | iosMajorVersion: 18, | ||
| 270 | appVersion: "1.0.0", | ||
| 271 | bundleId: "com.t3tools.t3code.preview", | ||
| 272 | apsEnvironment: resolveApsEnvironment("preview"), | ||
| 273 | notificationsEnabled: true, | ||
| 274 | preferences: {}, | ||
| 275 | }), | ||
| 276 | ).toMatchObject({ | ||
| 277 | bundleId: "com.t3tools.t3code.preview", | ||
| 278 | apsEnvironment: "production", | ||
| 279 | }); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("routes development builds to the APNs sandbox", () => { | ||
| 283 | expect(resolveApsEnvironment("development")).toBe("sandbox"); | ||
| 284 | expect(resolveApsEnvironment("preview")).toBe("production"); | ||
| 285 | expect(resolveApsEnvironment("production")).toBe("production"); | ||
| 286 | expect(resolveApsEnvironment(undefined)).toBe("production"); | ||
| 287 | }); | ||
| 288 | |||
| 216 | it("marks notification delivery disabled when APNs permission is unavailable", () => { | 289 | it("marks notification delivery disabled when APNs permission is unavailable", () => { |
| 217 | expect( | 290 | expect( |
| 218 | makeRelayDeviceRegistrationRequest({ | 291 | makeRelayDeviceRegistrationRequest({ |
| ... | @@ -329,6 +402,53 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -329,6 +402,53 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 329 | }, | 402 | }, |
| 330 | ); | 403 | ); |
| 331 | 404 | ||
| 405 | it.effect( | ||
| 406 | "re-registers active Live Activity tokens when the app returns to the foreground", | ||
| 407 | () => { | ||
| 408 | const activity = { | ||
| 409 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 410 | addPushTokenListener: vi.fn(), | ||
| 411 | }; | ||
| 412 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 413 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 414 | |||
| 415 | return Effect.gen(function* () { | ||
| 416 | yield* runBackgroundOperations(); | ||
| 417 | activity.getPushToken.mockClear(); | ||
| 418 | |||
| 419 | expect(appStateMock.listeners).toHaveLength(1); | ||
| 420 | for (const listener of appStateMock.listeners) { | ||
| 421 | listener("background"); | ||
| 422 | } | ||
| 423 | yield* runBackgroundOperations(); | ||
| 424 | expect(activity.getPushToken).not.toHaveBeenCalled(); | ||
| 425 | |||
| 426 | for (const listener of appStateMock.listeners) { | ||
| 427 | listener("active"); | ||
| 428 | } | ||
| 429 | yield* runBackgroundOperations(); | ||
| 430 | expect(activity.getPushToken).toHaveBeenCalled(); | ||
| 431 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 432 | }, | ||
| 433 | ); | ||
| 434 | |||
| 435 | it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { | ||
| 436 | const end = vi.fn(() => Promise.resolve()); | ||
| 437 | const activity = { | ||
| 438 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 439 | addPushTokenListener: vi.fn(), | ||
| 440 | end, | ||
| 441 | }; | ||
| 442 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 443 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 444 | expect(appStateMock.listeners).toHaveLength(1); | ||
| 445 | |||
| 446 | setAgentAwarenessRelayTokenProvider(null); | ||
| 447 | |||
| 448 | expect(end).toHaveBeenCalledWith("immediate"); | ||
| 449 | expect(appStateMock.listeners).toHaveLength(0); | ||
| 450 | }); | ||
| 451 | |||
| 332 | it.effect("refreshes APNs registration for connected environments after settings changes", () => { | 452 | it.effect("refreshes APNs registration for connected environments after settings changes", () => { |
| 333 | registerAgentAwarenessConnection(savedConnection()); | 453 | registerAgentAwarenessConnection(savedConnection()); |
| 334 | return Effect.gen(function* () { | 454 | return Effect.gen(function* () { |
| ... | @@ -395,6 +515,174 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -395,6 +515,174 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 395 | nowEpochSeconds: proofIat(dpop), | 515 | nowEpochSeconds: proofIat(dpop), |
| 396 | }), | 516 | }), |
| 397 | ).toMatchObject({ ok: true }); | 517 | ).toMatchObject({ ok: true }); |
| 518 | expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); | ||
| 519 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 520 | }); | ||
| 521 | |||
| 522 | it.effect("marks registration failed when device registration cannot complete", () => { | ||
| 523 | Constants.expoConfig!.extra = { | ||
| 524 | relay: { | ||
| 525 | url: "https://relay.example.test/", | ||
| 526 | }, | ||
| 527 | }; | ||
| 528 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( | ||
| 529 | new Error("registration failed"), | ||
| 530 | ); | ||
| 531 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 532 | |||
| 533 | return Effect.gen(function* () { | ||
| 534 | // Drive the registration directly so the assertion does not depend on the | ||
| 535 | // background queue draining; refreshAgentAwarenessRegistration swallows the | ||
| 536 | // error but must record the failed status so the settings toggles cannot | ||
| 537 | // read as enabled. | ||
| 538 | yield* refreshAgentAwarenessRegistration(); | ||
| 539 | expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); | ||
| 540 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 541 | }); | ||
| 542 | |||
| 543 | it("clears registration status on cloud sign-out", () => { | ||
| 544 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 545 | setAgentAwarenessRelayTokenProvider(null); | ||
| 546 | expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); | ||
| 547 | expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); | ||
| 548 | }); | ||
| 549 | |||
| 550 | it("releases the provider without ending activities or clearing the registration", () => { | ||
| 551 | const end = vi.fn(() => Promise.resolve()); | ||
| 552 | const activity = { | ||
| 553 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 554 | addPushTokenListener: vi.fn(), | ||
| 555 | end, | ||
| 556 | }; | ||
| 557 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 558 | registrationRecordStore.current = { identity: "", signature: "sig" }; | ||
| 559 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 560 | expect(appStateMock.listeners).toHaveLength(1); | ||
| 561 | |||
| 562 | releaseAgentAwarenessRelayTokenProvider(); | ||
| 563 | |||
| 564 | expect(appStateMock.listeners).toHaveLength(0); | ||
| 565 | expect(end).not.toHaveBeenCalled(); | ||
| 566 | expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); | ||
| 567 | expect(registrationRecordStore.current).not.toBeNull(); | ||
| 568 | }); | ||
| 569 | |||
| 570 | it.effect("resets a pending status to unknown when relay config is missing", () => { | ||
| 571 | // No relay url configured: registration can neither run nor ever succeed, | ||
| 572 | // so the status must not stick at "pending". | ||
| 573 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 574 | |||
| 575 | return Effect.gen(function* () { | ||
| 576 | yield* runBackgroundOperations(); | ||
| 577 | expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); | ||
| 578 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 579 | }); | ||
| 580 | |||
| 581 | it.effect("keeps a registered status when a later refresh fails", () => { | ||
| 582 | Constants.expoConfig!.extra = { | ||
| 583 | relay: { | ||
| 584 | url: "https://relay.example.test/", | ||
| 585 | }, | ||
| 586 | }; | ||
| 587 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 588 | |||
| 589 | return Effect.gen(function* () { | ||
| 590 | yield* runBackgroundOperations(); | ||
| 591 | expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); | ||
| 592 | |||
| 593 | // The relay still holds the accepted registration; a transient refresh | ||
| 594 | // failure must not flip the settings toggles off. | ||
| 595 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( | ||
| 596 | new Error("transient failure"), | ||
| 597 | ); | ||
| 598 | yield* refreshAgentAwarenessRegistration(); | ||
| 599 | expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); | ||
| 600 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 601 | }); | ||
| 602 | |||
| 603 | it.effect("does not re-register the same account when nothing has changed", () => { | ||
| 604 | Constants.expoConfig!.extra = { | ||
| 605 | relay: { | ||
| 606 | url: "https://relay.example.test/", | ||
| 607 | }, | ||
| 608 | }; | ||
| 609 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 610 | |||
| 611 | return Effect.gen(function* () { | ||
| 612 | yield* refreshAgentAwarenessRegistration(); | ||
| 613 | expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); | ||
| 614 | expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); | ||
| 615 | expect(registrationRecordStore.current).not.toBeNull(); | ||
| 616 | |||
| 617 | // Second attempt with an identical payload must skip the relay entirely, | ||
| 618 | // so no new registration record is written. | ||
| 619 | vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); | ||
| 620 | yield* refreshAgentAwarenessRegistration(); | ||
| 621 | expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); | ||
| 622 | expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); | ||
| 623 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 624 | }); | ||
| 625 | |||
| 626 | it.effect("dedupes rapid activity-token re-registrations within the replay window", () => { | ||
| 627 | // Fetch counts are unreliable here (the module-level relay layer captures | ||
| 628 | // the first test's fetch), so assert on the flow's own seams: a real | ||
| 629 | // registration attempt loads the device id, a deduped one short-circuits | ||
| 630 | // before it. | ||
| 631 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | ||
| 632 | const url = request instanceof Request ? request.url : String(request); | ||
| 633 | return Promise.resolve( | ||
| 634 | Response.json( | ||
| 635 | url.endsWith("/v1/client/dpop-token") | ||
| 636 | ? { | ||
| 637 | access_token: "relay-dpop-token", | ||
| 638 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 639 | token_type: "DPoP", | ||
| 640 | expires_in: 300, | ||
| 641 | scope: "mobile:registration", | ||
| 642 | } | ||
| 643 | : { ok: true }, | ||
| 644 | ), | ||
| 645 | ); | ||
| 646 | }); | ||
| 647 | vi.stubGlobal("fetch", fetchMock); | ||
| 648 | Constants.expoConfig!.extra = { | ||
| 649 | relay: { | ||
| 650 | url: "https://relay.example.test/", | ||
| 651 | }, | ||
| 652 | }; | ||
| 653 | const activity = { | ||
| 654 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 655 | addPushTokenListener: vi.fn(), | ||
| 656 | }; | ||
| 657 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 658 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 659 | |||
| 660 | return Effect.gen(function* () { | ||
| 661 | // Drains the sign-in refresh, which registers the activity token. | ||
| 662 | yield* runBackgroundOperations(); | ||
| 663 | expect(activity.getPushToken).toHaveBeenCalled(); | ||
| 664 | |||
| 665 | // A burst refresh (foreground / connection update seconds later) must | ||
| 666 | // dedupe: it reads the token but never proceeds to a registration | ||
| 667 | // attempt (which would load the device id first). | ||
| 668 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear(); | ||
| 669 | yield* refreshActiveLiveActivityRemoteRegistration(); | ||
| 670 | expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled(); | ||
| 671 | }).pipe(Effect.provide(relayTestLayer)); | ||
| 672 | }); | ||
| 673 | |||
| 674 | it.effect("re-registers when the stored account identity differs", () => { | ||
| 675 | Constants.expoConfig!.extra = { | ||
| 676 | relay: { | ||
| 677 | url: "https://relay.example.test/", | ||
| 678 | }, | ||
| 679 | }; | ||
| 680 | registrationRecordStore.current = { identity: "someone-else", signature: "stale" }; | ||
| 681 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 682 | |||
| 683 | return Effect.gen(function* () { | ||
| 684 | yield* refreshAgentAwarenessRegistration(); | ||
| 685 | expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); | ||
| 398 | }).pipe(Effect.provide(relayTestLayer)); | 686 | }).pipe(Effect.provide(relayTestLayer)); |
| 399 | }); | 687 | }); |
| 400 | 688 |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+406-49| ... | @@ -1,12 +1,13 @@ | ... | @@ -1,12 +1,13 @@ |
| 1 | import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; | 1 | import { type LiveActivity } from "expo-widgets"; |
| 2 | import Constants from "expo-constants"; | 2 | import Constants from "expo-constants"; |
| 3 | import * as Notifications from "expo-notifications"; | 3 | import * as Notifications from "expo-notifications"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import { Platform } from "react-native"; | 6 | import { AppState, Platform } from "react-native"; |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | 7 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 8 | import { | 8 | import { |
| 9 | type RelayDeviceRegistrationRequest, | 9 | type RelayDeviceRegistrationRequest, |
| 10 | type RelayAgentActivitySnapshotResponse, | ||
| 10 | type RelayLiveActivityRegistrationRequest, | 11 | type RelayLiveActivityRegistrationRequest, |
| 11 | } from "@t3tools/contracts/relay"; | 12 | } from "@t3tools/contracts/relay"; |
| 12 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | 13 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| ... | @@ -20,13 +21,16 @@ import { | ... | @@ -20,13 +21,16 @@ import { |
| 20 | import type { SavedRemoteConnection } from "../../lib/connection"; | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 21 | import { runtime } from "../../lib/runtime"; | 22 | import { runtime } from "../../lib/runtime"; |
| 22 | import { | 23 | import { |
| 24 | clearAgentAwarenessRegistrationRecord, | ||
| 23 | loadAgentAwarenessDeviceId, | 25 | loadAgentAwarenessDeviceId, |
| 26 | loadAgentAwarenessRegistrationRecord, | ||
| 24 | loadOrCreateAgentAwarenessDeviceId, | 27 | loadOrCreateAgentAwarenessDeviceId, |
| 25 | loadPreferences, | 28 | loadPreferences, |
| 29 | saveAgentAwarenessRegistrationRecord, | ||
| 26 | } from "../../lib/storage"; | 30 | } from "../../lib/storage"; |
| 27 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; | 31 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; |
| 28 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; | 32 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; |
| 29 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; | 33 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 30 | 34 | ||
| 31 | const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; | 35 | const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; |
| 32 | 36 | ||
| ... | @@ -42,6 +46,8 @@ const AgentAwarenessOperation = Schema.Literals([ | ... | @@ -42,6 +46,8 @@ const AgentAwarenessOperation = Schema.Literals([ |
| 42 | "read-live-activity-push-token", | 46 | "read-live-activity-push-token", |
| 43 | "load-live-activity-registration-identifier", | 47 | "load-live-activity-registration-identifier", |
| 44 | "list-active-live-activities", | 48 | "list-active-live-activities", |
| 49 | "load-live-activity-prime-preferences", | ||
| 50 | "prime-live-activity", | ||
| 45 | ]); | 51 | ]); |
| 46 | 52 | ||
| 47 | export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentAwarenessOperationError>()( | 53 | export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentAwarenessOperationError>()( |
| ... | @@ -58,8 +64,49 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentA | ... | @@ -58,8 +64,49 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentA |
| 58 | 64 | ||
| 59 | const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>(); | 65 | const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>(); |
| 60 | const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>(); | 66 | const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>(); |
| 61 | let pushToStartSubscription: { remove: () => void } | null = null; | 67 | // Activity tokens the relay recently accepted, by acceptance time. The refresh |
| 68 | // runs on sign-in, every app foreground, and every environment-connection | ||
| 69 | // update, which arrive in bursts and spammed identical registrations. But the | ||
| 70 | // registration is not a pure no-op: the relay replays the current aggregate to | ||
| 71 | // this device on every accepted registration, and that replay is the | ||
| 72 | // foreground reconciliation that repairs drifted or orphaned activities. So | ||
| 73 | // dedupe only within a short window — bursts collapse to one request, while a | ||
| 74 | // foreground after real time away still triggers a replay. Cleared on | ||
| 75 | // sign-out/identity change alongside the device registration state. | ||
| 76 | const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; | ||
| 77 | const registeredActivityPushTokens = new Map<string, number>(); | ||
| 62 | let pushTokenSubscription: { remove: () => void } | null = null; | 78 | let pushTokenSubscription: { remove: () => void } | null = null; |
| 79 | let appStateSubscription: { remove: () => void } | null = null; | ||
| 80 | |||
| 81 | // Whether the relay has actually accepted this device's registration. The | ||
| 82 | // notification/Live Activity settings toggles must reflect this rather than | ||
| 83 | // only local iOS permission or saved preferences: if the registration request | ||
| 84 | // never succeeded, the device cannot receive anything, so the switches must | ||
| 85 | // not read as enabled. | ||
| 86 | export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed"; | ||
| 87 | let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; | ||
| 88 | const registrationStatusListeners = new Set<() => void>(); | ||
| 89 | |||
| 90 | function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { | ||
| 91 | if (registrationStatus === next) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | registrationStatus = next; | ||
| 95 | for (const listener of registrationStatusListeners) { | ||
| 96 | listener(); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus { | ||
| 101 | return registrationStatus; | ||
| 102 | } | ||
| 103 | |||
| 104 | export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { | ||
| 105 | registrationStatusListeners.add(listener); | ||
| 106 | return () => { | ||
| 107 | registrationStatusListeners.delete(listener); | ||
| 108 | }; | ||
| 109 | } | ||
| 63 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; | 110 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; |
| 64 | let relayTokenProvider: (() => Promise<string | null>) | null = null; | 111 | let relayTokenProvider: (() => Promise<string | null>) | null = null; |
| 65 | let relayTokenProviderIdentity: string | null = null; | 112 | let relayTokenProviderIdentity: string | null = null; |
| ... | @@ -74,7 +121,6 @@ let pendingDeviceRegistration: { | ... | @@ -74,7 +121,6 @@ let pendingDeviceRegistration: { |
| 74 | } | null = null; | 121 | } | null = null; |
| 75 | 122 | ||
| 76 | interface DeviceRegistrationInput { | 123 | interface DeviceRegistrationInput { |
| 77 | readonly pushToStartToken?: string; | ||
| 78 | readonly observedPushToken?: string; | 124 | readonly observedPushToken?: string; |
| 79 | } | 125 | } |
| 80 | 126 | ||
| ... | @@ -120,32 +166,66 @@ export function setAgentAwarenessRelayTokenProvider( | ... | @@ -120,32 +166,66 @@ export function setAgentAwarenessRelayTokenProvider( |
| 120 | deviceRegistrationGeneration++; | 166 | deviceRegistrationGeneration++; |
| 121 | activeDeviceRegistration = null; | 167 | activeDeviceRegistration = null; |
| 122 | pendingDeviceRegistration = null; | 168 | pendingDeviceRegistration = null; |
| 169 | registeredActivityPushTokens.clear(); | ||
| 123 | } | 170 | } |
| 124 | relayTokenProvider = provider; | 171 | relayTokenProvider = provider; |
| 125 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; | 172 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; |
| 126 | if (!provider) { | 173 | if (!provider) { |
| 127 | pushToStartSubscription?.remove(); | ||
| 128 | pushToStartSubscription = null; | ||
| 129 | pushTokenSubscription?.remove(); | 174 | pushTokenSubscription?.remove(); |
| 130 | pushTokenSubscription = null; | 175 | pushTokenSubscription = null; |
| 176 | appStateSubscription?.remove(); | ||
| 177 | appStateSubscription = null; | ||
| 131 | if (activeLiveActivityRegistrationRetry) { | 178 | if (activeLiveActivityRegistrationRetry) { |
| 132 | clearTimeout(activeLiveActivityRegistrationRetry); | 179 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 133 | activeLiveActivityRegistrationRetry = null; | 180 | activeLiveActivityRegistrationRetry = null; |
| 134 | } | 181 | } |
| 182 | // Without a signed-in user the relay can no longer update or end these | ||
| 183 | // activities, so they would sit orphaned on the lock screen. | ||
| 184 | endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); | ||
| 185 | setRegistrationStatus("unknown"); | ||
| 186 | // Sign-out is the only thing that invalidates a stored registration, so the | ||
| 187 | // next sign-in re-registers. | ||
| 188 | void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { | ||
| 189 | logRegistrationError("clear registration record on sign-out failed", error); | ||
| 190 | }); | ||
| 135 | return; | 191 | return; |
| 136 | } | 192 | } |
| 137 | ensurePushToStartListener(); | ||
| 138 | ensurePushTokenListener(); | 193 | ensurePushTokenListener(); |
| 194 | ensureAppStateListener(); | ||
| 139 | runRegistrationInBackground( | 195 | runRegistrationInBackground( |
| 140 | refreshActiveLiveActivityRemoteRegistration(), | 196 | refreshActiveLiveActivityRemoteRegistration(), |
| 141 | "active live activity registration after cloud sign-in failed", | 197 | "active live activity registration after cloud sign-in failed", |
| 142 | ); | 198 | ); |
| 143 | if (isExistingIdentity) { | 199 | if (isExistingIdentity) { |
| 200 | // Same account re-activating (e.g. Clerk token refresh) normally needs no | ||
| 201 | // re-registration — but if the previous attempt never succeeded, this is | ||
| 202 | // the only trigger that will retry it before the next cold start. | ||
| 203 | if (registrationStatus !== "registered") { | ||
| 204 | enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed"); | ||
| 205 | } | ||
| 144 | return; | 206 | return; |
| 145 | } | 207 | } |
| 146 | enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); | 208 | enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); |
| 147 | } | 209 | } |
| 148 | 210 | ||
| 211 | // Detach the provider and native listeners without the destructive sign-out | ||
| 212 | // cleanup. For provider teardown while the user is still signed in (e.g. the | ||
| 213 | // auth bridge unmounting/remounting), ending lock-screen activities and wiping | ||
| 214 | // the persisted registration would be wrong — the relay still holds a valid | ||
| 215 | // registration and the next mount reuses it. | ||
| 216 | export function releaseAgentAwarenessRelayTokenProvider(): void { | ||
| 217 | relayTokenProvider = null; | ||
| 218 | relayTokenProviderIdentity = null; | ||
| 219 | pushTokenSubscription?.remove(); | ||
| 220 | pushTokenSubscription = null; | ||
| 221 | appStateSubscription?.remove(); | ||
| 222 | appStateSubscription = null; | ||
| 223 | if (activeLiveActivityRegistrationRetry) { | ||
| 224 | clearTimeout(activeLiveActivityRegistrationRetry); | ||
| 225 | activeLiveActivityRegistrationRetry = null; | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 149 | function iosMajorVersion(): number { | 229 | function iosMajorVersion(): number { |
| 150 | const version = Platform.Version; | 230 | const version = Platform.Version; |
| 151 | if (typeof version === "number") { | 231 | if (typeof version === "number") { |
| ... | @@ -211,6 +291,27 @@ const relayToken = ( | ... | @@ -211,6 +291,27 @@ const relayToken = ( |
| 211 | }); | 291 | }); |
| 212 | }); | 292 | }); |
| 213 | 293 | ||
| 294 | // Stable fingerprint of everything the relay stores for this device. When it | ||
| 295 | // matches the last accepted registration for the same account, re-registering | ||
| 296 | // is a no-op, so a launch that changed nothing skips the request entirely. | ||
| 297 | function registrationSignature(body: RelayDeviceRegistrationRequest): string { | ||
| 298 | return [ | ||
| 299 | body.deviceId, | ||
| 300 | body.pushToken ?? "", | ||
| 301 | body.bundleId ?? "", | ||
| 302 | body.apsEnvironment ?? "", | ||
| 303 | body.appVersion ?? "", | ||
| 304 | body.label, | ||
| 305 | body.iosMajorVersion, | ||
| 306 | body.preferences.notificationsEnabled, | ||
| 307 | body.preferences.liveActivitiesEnabled, | ||
| 308 | body.preferences.notifyOnApproval, | ||
| 309 | body.preferences.notifyOnInput, | ||
| 310 | body.preferences.notifyOnCompletion, | ||
| 311 | body.preferences.notifyOnFailure, | ||
| 312 | ].join("|"); | ||
| 313 | } | ||
| 314 | |||
| 214 | function registerDeviceWithRelay( | 315 | function registerDeviceWithRelay( |
| 215 | body: RelayDeviceRegistrationRequest, | 316 | body: RelayDeviceRegistrationRequest, |
| 216 | expectedGeneration: number, | 317 | expectedGeneration: number, |
| ... | @@ -223,7 +324,13 @@ function registerDeviceWithRelay( | ... | @@ -223,7 +324,13 @@ function registerDeviceWithRelay( |
| 223 | }); | 324 | }); |
| 224 | return; | 325 | return; |
| 225 | } | 326 | } |
| 226 | if (!readRelayConfig()) return; | 327 | const relayConfig = readRelayConfig(); |
| 328 | if (!relayConfig) { | ||
| 329 | // Nothing is in flight and nothing can succeed until configuration | ||
| 330 | // appears; "pending" would otherwise stick forever. | ||
| 331 | setRegistrationStatus("unknown"); | ||
| 332 | return; | ||
| 333 | } | ||
| 227 | const token = yield* relayToken("read-device-registration-relay-token"); | 334 | const token = yield* relayToken("read-device-registration-relay-token"); |
| 228 | if (expectedGeneration !== deviceRegistrationGeneration) { | 335 | if (expectedGeneration !== deviceRegistrationGeneration) { |
| 229 | logRegistrationDebug("device registration cancelled after auth lookup", { | 336 | logRegistrationDebug("device registration cancelled after auth lookup", { |
| ... | @@ -234,6 +341,37 @@ function registerDeviceWithRelay( | ... | @@ -234,6 +341,37 @@ function registerDeviceWithRelay( |
| 234 | } | 341 | } |
| 235 | if (!token) { | 342 | if (!token) { |
| 236 | logRegistrationDebug("relay device registration skipped; user is not signed in"); | 343 | logRegistrationDebug("relay device registration skipped; user is not signed in"); |
| 344 | setRegistrationStatus("unknown"); | ||
| 345 | return; | ||
| 346 | } | ||
| 347 | |||
| 348 | // Skip the request when this account already registered an identical | ||
| 349 | // payload; the relay upsert would be a no-op. The record is only cleared on | ||
| 350 | // sign-out, so a device stays registered across launches without re-hitting | ||
| 351 | // the relay every time the app opens. | ||
| 352 | const identity = relayTokenProviderIdentity ?? ""; | ||
| 353 | const persisted = yield* Effect.tryPromise({ | ||
| 354 | try: () => loadAgentAwarenessRegistrationRecord(), | ||
| 355 | catch: (cause) => cause, | ||
| 356 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 357 | if (expectedGeneration !== deviceRegistrationGeneration) { | ||
| 358 | // Signed out while the record loaded — do not resurrect the cleared | ||
| 359 | // record or report the previous account's registration as current. | ||
| 360 | logRegistrationDebug("device registration cancelled after record lookup", { | ||
| 361 | expectedGeneration, | ||
| 362 | currentGeneration: deviceRegistrationGeneration, | ||
| 363 | }); | ||
| 364 | return; | ||
| 365 | } | ||
| 366 | const payload = body; | ||
| 367 | // The relay URL participates so pointing the app at a different relay | ||
| 368 | // invalidates the record and re-registers there. | ||
| 369 | const signature = `${relayConfig.url}|${registrationSignature(payload)}`; | ||
| 370 | if (persisted && persisted.identity === identity && persisted.signature === signature) { | ||
| 371 | setRegistrationStatus("registered"); | ||
| 372 | logRegistrationDebug("relay device registration skipped; already registered for account", { | ||
| 373 | expectedGeneration, | ||
| 374 | }); | ||
| 237 | return; | 375 | return; |
| 238 | } | 376 | } |
| 239 | 377 | ||
| ... | @@ -243,8 +381,27 @@ function registerDeviceWithRelay( | ... | @@ -243,8 +381,27 @@ function registerDeviceWithRelay( |
| 243 | }); | 381 | }); |
| 244 | yield* client.registerDevice({ | 382 | yield* client.registerDevice({ |
| 245 | clerkToken: token, | 383 | clerkToken: token, |
| 246 | payload: body, | 384 | payload, |
| 247 | }); | 385 | }); |
| 386 | if (expectedGeneration !== deviceRegistrationGeneration) { | ||
| 387 | // Signed out while the request was in flight: the sign-out path already | ||
| 388 | // reset the status and cleared the record for the next account, so a | ||
| 389 | // stale success must not overwrite either. | ||
| 390 | logRegistrationDebug("device registration completed after sign-out; result discarded", { | ||
| 391 | expectedGeneration, | ||
| 392 | currentGeneration: deviceRegistrationGeneration, | ||
| 393 | }); | ||
| 394 | return; | ||
| 395 | } | ||
| 396 | setRegistrationStatus("registered"); | ||
| 397 | yield* Effect.promise(() => | ||
| 398 | saveAgentAwarenessRegistrationRecord({ | ||
| 399 | identity, | ||
| 400 | signature, | ||
| 401 | }).catch((error: unknown) => { | ||
| 402 | logRegistrationError("persist registration record failed", error); | ||
| 403 | }), | ||
| 404 | ); | ||
| 248 | logRegistrationDebug("relay device registration request completed", { | 405 | logRegistrationDebug("relay device registration request completed", { |
| 249 | expectedGeneration, | 406 | expectedGeneration, |
| 250 | }); | 407 | }); |
| ... | @@ -278,6 +435,91 @@ function unregisterDeviceWithRelay(input: { | ... | @@ -278,6 +435,91 @@ function unregisterDeviceWithRelay(input: { |
| 278 | }); | 435 | }); |
| 279 | } | 436 | } |
| 280 | 437 | ||
| 438 | // Arms the lock-screen card the moment the user starts agent work from this | ||
| 439 | // phone, while the app is still foregrounded and the fresh activity's token | ||
| 440 | // can be registered immediately. The seeded row is a best-effort placeholder; | ||
| 441 | // the relay's registration replay repaints it with the authoritative | ||
| 442 | // aggregate within seconds. No-ops when a card is already armed. | ||
| 443 | export function armAgentAwarenessLiveActivityForLocalWork(input: { | ||
| 444 | readonly threadTitle: string; | ||
| 445 | readonly projectTitle: string; | ||
| 446 | }): void { | ||
| 447 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { | ||
| 448 | return; | ||
| 449 | } | ||
| 450 | void loadPreferences() | ||
| 451 | .catch(() => null) | ||
| 452 | .then((preferences) => { | ||
| 453 | if (preferences?.liveActivitiesEnabled === false) { | ||
| 454 | return; | ||
| 455 | } | ||
| 456 | armAgentAwarenessLiveActivityForLocalWorkNow(input); | ||
| 457 | }); | ||
| 458 | } | ||
| 459 | |||
| 460 | function armAgentAwarenessLiveActivityForLocalWorkNow(input: { | ||
| 461 | readonly threadTitle: string; | ||
| 462 | readonly projectTitle: string; | ||
| 463 | }): void { | ||
| 464 | try { | ||
| 465 | if (AgentActivity.getInstances().length > 0) { | ||
| 466 | return; | ||
| 467 | } | ||
| 468 | const nowIso = new Date(Date.now()).toISOString(); | ||
| 469 | const activity = AgentActivity.start({ | ||
| 470 | title: "T3 Code", | ||
| 471 | subtitle: "Agent work in progress", | ||
| 472 | activeCount: 1, | ||
| 473 | updatedAt: nowIso, | ||
| 474 | activities: [ | ||
| 475 | { | ||
| 476 | environmentId: "", | ||
| 477 | threadId: "", | ||
| 478 | projectTitle: input.projectTitle, | ||
| 479 | threadTitle: input.threadTitle, | ||
| 480 | modelTitle: "", | ||
| 481 | phase: "starting", | ||
| 482 | status: "Connecting", | ||
| 483 | updatedAt: nowIso, | ||
| 484 | deepLink: "/", | ||
| 485 | }, | ||
| 486 | ], | ||
| 487 | }); | ||
| 488 | logRegistrationDebug("live activity card armed for local work", { | ||
| 489 | threadTitle: input.threadTitle, | ||
| 490 | }); | ||
| 491 | runRegistrationInBackground( | ||
| 492 | registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), | ||
| 493 | "live activity arming after local task start failed", | ||
| 494 | ); | ||
| 495 | } catch (error) { | ||
| 496 | logRegistrationError("live activity arming failed", error); | ||
| 497 | } | ||
| 498 | } | ||
| 499 | |||
| 500 | function readAgentActivitySnapshot(): Effect.Effect< | ||
| 501 | RelayAgentActivitySnapshotResponse | null, | ||
| 502 | never, | ||
| 503 | ManagedRelay.ManagedRelayClient | ||
| 504 | > { | ||
| 505 | return Effect.gen(function* () { | ||
| 506 | if (!readRelayConfig()) return null; | ||
| 507 | const token = yield* relayToken("read-live-activity-registration-relay-token"); | ||
| 508 | if (!token) { | ||
| 509 | return null; | ||
| 510 | } | ||
| 511 | const client = yield* ManagedRelay.ManagedRelayClient; | ||
| 512 | return yield* client.getAgentActivitySnapshot({ clerkToken: token }); | ||
| 513 | }).pipe( | ||
| 514 | Effect.catch((error) => | ||
| 515 | Effect.sync(() => { | ||
| 516 | logRegistrationError("agent activity snapshot read failed", error); | ||
| 517 | return null; | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ); | ||
| 521 | } | ||
| 522 | |||
| 281 | function registerLiveActivityWithRelay( | 523 | function registerLiveActivityWithRelay( |
| 282 | body: RelayLiveActivityRegistrationRequest, | 524 | body: RelayLiveActivityRegistrationRequest, |
| 283 | ): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { | 525 | ): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { |
| ... | @@ -332,14 +574,8 @@ function mergeDeviceRegistrationInput( | ... | @@ -332,14 +574,8 @@ function mergeDeviceRegistrationInput( |
| 332 | current: DeviceRegistrationInput, | 574 | current: DeviceRegistrationInput, |
| 333 | next: DeviceRegistrationInput, | 575 | next: DeviceRegistrationInput, |
| 334 | ): DeviceRegistrationInput { | 576 | ): DeviceRegistrationInput { |
| 335 | return { | 577 | const observedPushToken = next.observedPushToken ?? current.observedPushToken; |
| 336 | ...((next.pushToStartToken ?? current.pushToStartToken) | 578 | return observedPushToken ? { observedPushToken } : {}; |
| 337 | ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } | ||
| 338 | : {}), | ||
| 339 | ...((next.observedPushToken ?? current.observedPushToken) | ||
| 340 | ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } | ||
| 341 | : {}), | ||
| 342 | }; | ||
| 343 | } | 579 | } |
| 344 | 580 | ||
| 345 | function registrationAddsInformation( | 581 | function registrationAddsInformation( |
| ... | @@ -347,8 +583,7 @@ function registrationAddsInformation( | ... | @@ -347,8 +583,7 @@ function registrationAddsInformation( |
| 347 | next: DeviceRegistrationInput, | 583 | next: DeviceRegistrationInput, |
| 348 | ): boolean { | 584 | ): boolean { |
| 349 | return ( | 585 | return ( |
| 350 | (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || | 586 | next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken |
| 351 | (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) | ||
| 352 | ); | 587 | ); |
| 353 | } | 588 | } |
| 354 | 589 | ||
| ... | @@ -363,8 +598,10 @@ function startPendingDeviceRegistration(): void { | ... | @@ -363,8 +598,10 @@ function startPendingDeviceRegistration(): void { |
| 363 | logRegistrationDebug("device registration started", { | 598 | logRegistrationDebug("device registration started", { |
| 364 | generation, | 599 | generation, |
| 365 | hasObservedPushToken: next.input.observedPushToken !== undefined, | 600 | hasObservedPushToken: next.input.observedPushToken !== undefined, |
| 366 | hasPushToStartToken: next.input.pushToStartToken !== undefined, | ||
| 367 | }); | 601 | }); |
| 602 | if (registrationStatus !== "registered") { | ||
| 603 | setRegistrationStatus("pending"); | ||
| 604 | } | ||
| 368 | const registration = { | 605 | const registration = { |
| 369 | input: next.input, | 606 | input: next.input, |
| 370 | operation: Promise.resolve(), | 607 | operation: Promise.resolve(), |
| ... | @@ -375,6 +612,13 @@ function startPendingDeviceRegistration(): void { | ... | @@ -375,6 +612,13 @@ function startPendingDeviceRegistration(): void { |
| 375 | runtime.runPromiseExit(registerDevice(next.input, generation)), | 612 | runtime.runPromiseExit(registerDevice(next.input, generation)), |
| 376 | ); | 613 | ); |
| 377 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 614 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 615 | // A transient failure on a later refresh (e.g. token rotation) leaves | ||
| 616 | // the prior accepted registration intact on the relay, so an already | ||
| 617 | // registered device stays "registered" rather than flipping the | ||
| 618 | // settings toggles off. | ||
| 619 | if (registrationStatus !== "registered") { | ||
| 620 | setRegistrationStatus("failed"); | ||
| 621 | } | ||
| 378 | logRegistrationError(next.context, squashAtomCommandFailure(result)); | 622 | logRegistrationError(next.context, squashAtomCommandFailure(result)); |
| 379 | } | 623 | } |
| 380 | logRegistrationDebug("device registration finished", { generation }); | 624 | logRegistrationDebug("device registration finished", { generation }); |
| ... | @@ -444,14 +688,16 @@ function registerDevice( | ... | @@ -444,14 +688,16 @@ function registerDevice( |
| 444 | expectedGeneration, | 688 | expectedGeneration, |
| 445 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, | 689 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, |
| 446 | }); | 690 | }); |
| 691 | const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim(); | ||
| 447 | yield* registerDeviceWithRelay( | 692 | yield* registerDeviceWithRelay( |
| 448 | makeRelayDeviceRegistrationRequest({ | 693 | makeRelayDeviceRegistrationRequest({ |
| 449 | deviceId, | 694 | deviceId, |
| 450 | label: Constants.deviceName?.trim() || "iOS device", | 695 | label: Constants.deviceName?.trim() || "iOS device", |
| 451 | iosMajorVersion: iosMajorVersion(), | 696 | iosMajorVersion: iosMajorVersion(), |
| 452 | appVersion: Constants.expoConfig?.version, | 697 | appVersion: Constants.expoConfig?.version, |
| 698 | ...(bundleId ? { bundleId } : {}), | ||
| 699 | apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant), | ||
| 453 | ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), | 700 | ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), |
| 454 | ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), | ||
| 455 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, | 701 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, |
| 456 | preferences, | 702 | preferences, |
| 457 | }), | 703 | }), |
| ... | @@ -460,27 +706,12 @@ function registerDevice( | ... | @@ -460,27 +706,12 @@ function registerDevice( |
| 460 | }); | 706 | }); |
| 461 | } | 707 | } |
| 462 | 708 | ||
| 463 | function registerDeviceForCurrentUser( | 709 | function registerDeviceForCurrentUser(): Effect.Effect< |
| 464 | pushToStartToken?: string, | 710 | void, |
| 465 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { | 711 | unknown, |
| 466 | return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); | 712 | ManagedRelay.ManagedRelayClient |
| 467 | } | 713 | > { |
| 468 | 714 | return registerDevice(undefined); | |
| 469 | function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { | ||
| 470 | enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); | ||
| 471 | } | ||
| 472 | |||
| 473 | function ensurePushToStartListener(): void { | ||
| 474 | if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) { | ||
| 475 | return; | ||
| 476 | } | ||
| 477 | |||
| 478 | pushToStartSubscription = addPushToStartTokenListener((event) => { | ||
| 479 | const token = event.activityPushToStartToken; | ||
| 480 | if (token) { | ||
| 481 | registerPushToStartTokenForCurrentUser(token); | ||
| 482 | } | ||
| 483 | }); | ||
| 484 | } | 715 | } |
| 485 | 716 | ||
| 486 | function ensurePushTokenListener(): void { | 717 | function ensurePushTokenListener(): void { |
| ... | @@ -498,14 +729,51 @@ function ensurePushTokenListener(): void { | ... | @@ -498,14 +729,51 @@ function ensurePushTokenListener(): void { |
| 498 | }); | 729 | }); |
| 499 | } | 730 | } |
| 500 | 731 | ||
| 732 | // Re-registering activity tokens on foreground makes the relay replay the | ||
| 733 | // current aggregate to this device, which updates content that drifted while | ||
| 734 | // pushes could not be delivered and ends orphaned activities whose end push | ||
| 735 | // never arrived. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid | ||
| 736 | // foreground/sign-in bursts collapse to one registration, but returning after | ||
| 737 | // real time away still replays.) | ||
| 738 | function ensureAppStateListener(): void { | ||
| 739 | if (appStateSubscription || !canRegisterRemoteLiveActivities()) { | ||
| 740 | return; | ||
| 741 | } | ||
| 742 | |||
| 743 | appStateSubscription = AppState.addEventListener("change", (state) => { | ||
| 744 | if (state !== "active") { | ||
| 745 | return; | ||
| 746 | } | ||
| 747 | runRegistrationInBackground( | ||
| 748 | refreshActiveLiveActivityRemoteRegistration(), | ||
| 749 | "active live activity reconciliation after app foreground failed", | ||
| 750 | ); | ||
| 751 | }); | ||
| 752 | } | ||
| 753 | |||
| 754 | function endLocalLiveActivities(context: string): void { | ||
| 755 | if (!canRegisterRemoteLiveActivities()) { | ||
| 756 | return; | ||
| 757 | } | ||
| 758 | try { | ||
| 759 | for (const activity of AgentActivity.getInstances()) { | ||
| 760 | activity.end("immediate").catch((error: unknown) => { | ||
| 761 | logRegistrationError(context, error); | ||
| 762 | }); | ||
| 763 | } | ||
| 764 | } catch (error) { | ||
| 765 | logRegistrationError(context, error); | ||
| 766 | } | ||
| 767 | } | ||
| 768 | |||
| 501 | export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { | 769 | export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { |
| 502 | if (!canRegisterRemoteLiveActivities()) { | 770 | if (!canRegisterRemoteLiveActivities()) { |
| 503 | return; | 771 | return; |
| 504 | } | 772 | } |
| 505 | 773 | ||
| 506 | environmentConnections.set(connection.environmentId, connection); | 774 | environmentConnections.set(connection.environmentId, connection); |
| 507 | ensurePushToStartListener(); | ||
| 508 | ensurePushTokenListener(); | 775 | ensurePushTokenListener(); |
| 776 | ensureAppStateListener(); | ||
| 509 | enqueueDeviceRegistration({}, "device registration failed"); | 777 | enqueueDeviceRegistration({}, "device registration failed"); |
| 510 | runRegistrationInBackground( | 778 | runRegistrationInBackground( |
| 511 | refreshActiveLiveActivityRemoteRegistration(), | 779 | refreshActiveLiveActivityRemoteRegistration(), |
| ... | @@ -523,10 +791,10 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) | ... | @@ -523,10 +791,10 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) |
| 523 | 791 | ||
| 524 | export function unregisterAllAgentAwarenessConnections(): void { | 792 | export function unregisterAllAgentAwarenessConnections(): void { |
| 525 | environmentConnections.clear(); | 793 | environmentConnections.clear(); |
| 526 | pushToStartSubscription?.remove(); | ||
| 527 | pushToStartSubscription = null; | ||
| 528 | pushTokenSubscription?.remove(); | 794 | pushTokenSubscription?.remove(); |
| 529 | pushTokenSubscription = null; | 795 | pushTokenSubscription = null; |
| 796 | appStateSubscription?.remove(); | ||
| 797 | appStateSubscription = null; | ||
| 530 | if (activeLiveActivityRegistrationRetry) { | 798 | if (activeLiveActivityRegistrationRetry) { |
| 531 | clearTimeout(activeLiveActivityRegistrationRetry); | 799 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 532 | activeLiveActivityRegistrationRetry = null; | 800 | activeLiveActivityRegistrationRetry = null; |
| ... | @@ -541,6 +809,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< | ... | @@ -541,6 +809,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< |
| 541 | return registerDeviceForCurrentUser().pipe( | 809 | return registerDeviceForCurrentUser().pipe( |
| 542 | Effect.catch((error) => | 810 | Effect.catch((error) => |
| 543 | Effect.sync(() => { | 811 | Effect.sync(() => { |
| 812 | // Same rationale as the queued path: a failed refresh does not undo an | ||
| 813 | // already accepted registration. | ||
| 814 | if (registrationStatus !== "registered") { | ||
| 815 | setRegistrationStatus("failed"); | ||
| 816 | } | ||
| 544 | logRegistrationError("device registration refresh failed", error); | 817 | logRegistrationError("device registration refresh failed", error); |
| 545 | }), | 818 | }), |
| 546 | ), | 819 | ), |
| ... | @@ -549,10 +822,10 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< | ... | @@ -549,10 +822,10 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< |
| 549 | 822 | ||
| 550 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { | 823 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 551 | environmentConnections.clear(); | 824 | environmentConnections.clear(); |
| 552 | pushToStartSubscription?.remove(); | ||
| 553 | pushToStartSubscription = null; | ||
| 554 | pushTokenSubscription?.remove(); | 825 | pushTokenSubscription?.remove(); |
| 555 | pushTokenSubscription = null; | 826 | pushTokenSubscription = null; |
| 827 | appStateSubscription?.remove(); | ||
| 828 | appStateSubscription = null; | ||
| 556 | if (activeLiveActivityRegistrationRetry) { | 829 | if (activeLiveActivityRegistrationRetry) { |
| 557 | clearTimeout(activeLiveActivityRegistrationRetry); | 830 | clearTimeout(activeLiveActivityRegistrationRetry); |
| 558 | activeLiveActivityRegistrationRetry = null; | 831 | activeLiveActivityRegistrationRetry = null; |
| ... | @@ -562,6 +835,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { | ... | @@ -562,6 +835,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 562 | deviceRegistrationGeneration++; | 835 | deviceRegistrationGeneration++; |
| 563 | activeDeviceRegistration = null; | 836 | activeDeviceRegistration = null; |
| 564 | pendingDeviceRegistration = null; | 837 | pendingDeviceRegistration = null; |
| 838 | registrationStatus = "unknown"; | ||
| 839 | registrationStatusListeners.clear(); | ||
| 840 | registeredActivityPushTokens.clear(); | ||
| 565 | } | 841 | } |
| 566 | 842 | ||
| 567 | export function unregisterAgentAwarenessDeviceForCurrentUser( | 843 | export function unregisterAgentAwarenessDeviceForCurrentUser( |
| ... | @@ -649,6 +925,13 @@ function registerLiveActivityPushTokenValue(input: { | ... | @@ -649,6 +925,13 @@ function registerLiveActivityPushTokenValue(input: { |
| 649 | readonly activityPushToken: string; | 925 | readonly activityPushToken: string; |
| 650 | }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { | 926 | }): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> { |
| 651 | return Effect.gen(function* () { | 927 | return Effect.gen(function* () { |
| 928 | const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); | ||
| 929 | if ( | ||
| 930 | acceptedAt !== undefined && | ||
| 931 | Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS | ||
| 932 | ) { | ||
| 933 | return true; | ||
| 934 | } | ||
| 652 | const deviceId = yield* Effect.tryPromise({ | 935 | const deviceId = yield* Effect.tryPromise({ |
| 653 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 936 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 654 | catch: (cause) => | 937 | catch: (cause) => |
| ... | @@ -662,6 +945,7 @@ function registerLiveActivityPushTokenValue(input: { | ... | @@ -662,6 +945,7 @@ function registerLiveActivityPushTokenValue(input: { |
| 662 | activityPushToken: input.activityPushToken, | 945 | activityPushToken: input.activityPushToken, |
| 663 | }); | 946 | }); |
| 664 | if (registered) { | 947 | if (registered) { |
| 948 | registeredActivityPushTokens.set(input.activityPushToken, Date.now()); | ||
| 665 | logRegistrationDebug("live activity push token registered", { | 949 | logRegistrationDebug("live activity push token registered", { |
| 666 | tokenSuffix: input.activityPushToken.slice(-8), | 950 | tokenSuffix: input.activityPushToken.slice(-8), |
| 667 | }); | 951 | }); |
| ... | @@ -694,7 +978,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< | ... | @@ -694,7 +978,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< |
| 694 | return; | 978 | return; |
| 695 | } | 979 | } |
| 696 | 980 | ||
| 697 | const activities = yield* Effect.try({ | 981 | let activities = yield* Effect.try({ |
| 698 | try: () => AgentActivity.getInstances(), | 982 | try: () => AgentActivity.getInstances(), |
| 699 | catch: (cause) => | 983 | catch: (cause) => |
| 700 | new AgentAwarenessOperationError({ | 984 | new AgentAwarenessOperationError({ |
| ... | @@ -710,6 +994,79 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< | ... | @@ -710,6 +994,79 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< |
| 710 | ), | 994 | ), |
| 711 | ); | 995 | ); |
| 712 | 996 | ||
| 997 | // The relay tracks exactly one card per device; if concurrent arming ever | ||
| 998 | // produced extras, end them so only one keeps receiving updates. | ||
| 999 | if (activities.length > 1) { | ||
| 1000 | for (const extra of activities.slice(1)) { | ||
| 1001 | extra.end("immediate").catch((error: unknown) => { | ||
| 1002 | logRegistrationError("duplicate live activity cleanup failed", error); | ||
| 1003 | }); | ||
| 1004 | } | ||
| 1005 | activities = activities.slice(0, 1); | ||
| 1006 | } | ||
| 1007 | |||
| 1008 | // Activities are only ever created here, in the foreground, where the | ||
| 1009 | // update token can be observed and registered immediately — the relay | ||
| 1010 | // never remote-starts one (background push-to-start wakes proved too | ||
| 1011 | // unreliable to hand the token over). Arming is conditional: the relay is | ||
| 1012 | // asked what the card would show first, so an idle open never creates an | ||
| 1013 | // empty lock-screen card, and an armed card is born with the real | ||
| 1014 | // aggregate instead of a placeholder. | ||
| 1015 | if (activities.length === 0) { | ||
| 1016 | const preferences = yield* Effect.tryPromise({ | ||
| 1017 | try: () => loadPreferences(), | ||
| 1018 | catch: (cause) => | ||
| 1019 | new AgentAwarenessOperationError({ | ||
| 1020 | operation: "load-live-activity-prime-preferences", | ||
| 1021 | cause, | ||
| 1022 | }), | ||
| 1023 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 1024 | // The toggle defaults to on: an unset preference (fresh install) must | ||
| 1025 | // prime, so only an explicit false blocks it. | ||
| 1026 | if (preferences?.liveActivitiesEnabled !== false) { | ||
| 1027 | const snapshot = yield* readAgentActivitySnapshot(); | ||
| 1028 | // The snapshot request yields; an arm-on-send may have created the | ||
| 1029 | // card in the meantime. Re-check so two cards are never started. | ||
| 1030 | const armedMeanwhile = yield* Effect.try({ | ||
| 1031 | try: () => AgentActivity.getInstances(), | ||
| 1032 | catch: () => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>, | ||
| 1033 | }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>)); | ||
| 1034 | if (armedMeanwhile.length > 0) { | ||
| 1035 | activities = [...armedMeanwhile]; | ||
| 1036 | } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { | ||
| 1037 | const aggregate = snapshot.aggregate; | ||
| 1038 | const primed = yield* Effect.try({ | ||
| 1039 | try: () => | ||
| 1040 | AgentActivity.start({ | ||
| 1041 | title: aggregate.title, | ||
| 1042 | subtitle: aggregate.subtitle, | ||
| 1043 | activeCount: aggregate.activeCount, | ||
| 1044 | updatedAt: aggregate.updatedAt, | ||
| 1045 | activities: aggregate.activities, | ||
| 1046 | }), | ||
| 1047 | catch: (cause) => | ||
| 1048 | new AgentAwarenessOperationError({ | ||
| 1049 | operation: "prime-live-activity", | ||
| 1050 | cause, | ||
| 1051 | }), | ||
| 1052 | }).pipe( | ||
| 1053 | Effect.catch((error) => | ||
| 1054 | Effect.sync(() => { | ||
| 1055 | logRegistrationError("live activity priming failed", error); | ||
| 1056 | return null; | ||
| 1057 | }), | ||
| 1058 | ), | ||
| 1059 | ); | ||
| 1060 | if (primed) { | ||
| 1061 | logRegistrationDebug("live activity card primed", { | ||
| 1062 | activeCount: aggregate.activeCount, | ||
| 1063 | }); | ||
| 1064 | activities = [primed]; | ||
| 1065 | } | ||
| 1066 | } | ||
| 1067 | } | ||
| 1068 | } | ||
| 1069 | |||
| 713 | const registrationResults = yield* Effect.forEach(activities, (activity) => | 1070 | const registrationResults = yield* Effect.forEach(activities, (activity) => |
| 714 | registerLiveActivityPushToken({ activity }).pipe( | 1071 | registerLiveActivityPushToken({ activity }).pipe( |
| 715 | Effect.map((registered) => !registered), | 1072 | Effect.map((registered) => !registered), |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+6-1| ... | @@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime"; | ... | @@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime"; |
| 14 | import { appAtomRegistry } from "../../state/atom-registry"; | 14 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | 15 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { | 16 | import { |
| 17 | releaseAgentAwarenessRelayTokenProvider, | ||
| 17 | setAgentAwarenessRelayTokenProvider, | 18 | setAgentAwarenessRelayTokenProvider, |
| 18 | unregisterAgentAwarenessDeviceForCurrentUser, | 19 | unregisterAgentAwarenessDeviceForCurrentUser, |
| 19 | } from "../agent-awareness/remoteRegistration"; | 20 | } from "../agent-awareness/remoteRegistration"; |
| ... | @@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 143 | useEffect( | 144 | useEffect( |
| 144 | () => () => { | 145 | () => () => { |
| 145 | previousTokenProviderRef.current = null; | 146 | previousTokenProviderRef.current = null; |
| 146 | deactivateCloudRelayAccount(); | 147 | // Unmounting is not a sign-out: the user is usually still signed in, so |
| 148 | // detach the provider without ending lock-screen activities or wiping the | ||
| 149 | // persisted registration (a remount reuses both). | ||
| 150 | releaseAgentAwarenessRelayTokenProvider(); | ||
| 151 | setManagedRelaySession(appAtomRegistry, null); | ||
| 147 | }, | 152 | }, |
| 148 | [], | 153 | [], |
| 149 | ); | 154 | ); |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+59-14| ... | @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native"; | ... | @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native"; |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { SymbolView } from "expo-symbols"; | 7 | import { SymbolView } from "expo-symbols"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import { useCallback, useEffect, useMemo, useState } from "react"; | 9 | import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; |
| 10 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | 10 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; |
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 12 | 12 | ||
| ... | @@ -20,7 +20,11 @@ import { | ... | @@ -20,7 +20,11 @@ import { |
| 20 | import { AppText as Text } from "../../components/AppText"; | 20 | import { AppText as Text } from "../../components/AppText"; |
| 21 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; | 21 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; |
| 22 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; | 22 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; |
| 23 | import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration"; | 23 | import { |
| 24 | getAgentAwarenessRegistrationStatus, | ||
| 25 | refreshAgentAwarenessRegistration, | ||
| 26 | subscribeAgentAwarenessRegistrationStatus, | ||
| 27 | } from "../agent-awareness/remoteRegistration"; | ||
| 24 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | 28 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; |
| 25 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | 29 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; |
| 26 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | 30 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; |
| ... | @@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | ... | @@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 37 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | 41 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| 38 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | 42 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; |
| 39 | 43 | ||
| 44 | // Reflects whether the relay actually accepted this device's registration. | ||
| 45 | // The notification and Live Activity switches are gated on this so they can | ||
| 46 | // never read as enabled when the device cannot receive anything (e.g. the | ||
| 47 | // registration request timed out). | ||
| 48 | function useDeviceRegistered(): boolean { | ||
| 49 | const status = useSyncExternalStore( | ||
| 50 | subscribeAgentAwarenessRegistrationStatus, | ||
| 51 | getAgentAwarenessRegistrationStatus, | ||
| 52 | () => "unknown" as const, | ||
| 53 | ); | ||
| 54 | return status === "registered"; | ||
| 55 | } | ||
| 56 | |||
| 40 | export function SettingsRouteScreen() { | 57 | export function SettingsRouteScreen() { |
| 41 | const navigation = useNavigation(); | 58 | const navigation = useNavigation(); |
| 42 | 59 | ||
| ... | @@ -113,6 +130,7 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -113,6 +130,7 @@ function ConfiguredSettingsRouteScreen() { |
| 113 | const { savedConnectionsById } = useSavedRemoteConnections(); | 130 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 114 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | 131 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); |
| 115 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | 132 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); |
| 133 | const deviceRegistered = useDeviceRegistered(); | ||
| 116 | 134 | ||
| 117 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | 135 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); |
| 118 | const environmentCount = connections.length; | 136 | const environmentCount = connections.length; |
| ... | @@ -182,10 +200,19 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -182,10 +200,19 @@ function ConfiguredSettingsRouteScreen() { |
| 182 | } | 200 | } |
| 183 | if (result.value.type === "granted") { | 201 | if (result.value.type === "granted") { |
| 184 | setNotificationStatus("enabled"); | 202 | setNotificationStatus("enabled"); |
| 185 | Alert.alert( | 203 | // Permission alone is not enough: the switch stays off until the relay |
| 186 | "Notifications enabled", | 204 | // registration succeeds, so tell the user the truth about which happened. |
| 187 | "Live Activity notifications are enabled for this device.", | 205 | if (getAgentAwarenessRegistrationStatus() === "registered") { |
| 188 | ); | 206 | Alert.alert( |
| 207 | "Notifications enabled", | ||
| 208 | "Live Activity notifications are enabled for this device.", | ||
| 209 | ); | ||
| 210 | } else { | ||
| 211 | Alert.alert( | ||
| 212 | "Couldn't finish enabling notifications", | ||
| 213 | "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", | ||
| 214 | ); | ||
| 215 | } | ||
| 189 | return; | 216 | return; |
| 190 | } | 217 | } |
| 191 | if (result.value.type === "unsupported") { | 218 | if (result.value.type === "unsupported") { |
| ... | @@ -271,12 +298,22 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -271,12 +298,22 @@ function ConfiguredSettingsRouteScreen() { |
| 271 | 298 | ||
| 272 | refreshManagedRelayEnvironments(); | 299 | refreshManagedRelayEnvironments(); |
| 273 | setLiveActivityStatus("enabled"); | 300 | setLiveActivityStatus("enabled"); |
| 274 | Alert.alert( | 301 | // The environment link can succeed while this device's own registration |
| 275 | "Live Activities enabled", | 302 | // (the push-to-start token the relay needs) has not — don't claim Live |
| 276 | environmentCount > 0 | 303 | // Activities are live until the device is actually registered. |
| 277 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | 304 | if (getAgentAwarenessRegistrationStatus() === "registered") { |
| 278 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | 305 | Alert.alert( |
| 279 | ); | 306 | "Live Activities enabled", |
| 307 | environmentCount > 0 | ||
| 308 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 309 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 310 | ); | ||
| 311 | } else { | ||
| 312 | Alert.alert( | ||
| 313 | "Couldn't finish enabling Live Activities", | ||
| 314 | "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", | ||
| 315 | ); | ||
| 316 | } | ||
| 280 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | 317 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); |
| 281 | 318 | ||
| 282 | const handleDeviceNotificationsChange = useCallback( | 319 | const handleDeviceNotificationsChange = useCallback( |
| ... | @@ -395,7 +432,10 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -395,7 +432,10 @@ function ConfiguredSettingsRouteScreen() { |
| 395 | icon="bell.badge" | 432 | icon="bell.badge" |
| 396 | label="Device Notifications" | 433 | label="Device Notifications" |
| 397 | disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} | 434 | disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} |
| 398 | value={notificationStatus === "enabled"} | 435 | // Only reads as on when this device is actually registered with the |
| 436 | // relay; otherwise notifications cannot be delivered regardless of | ||
| 437 | // the local iOS permission. | ||
| 438 | value={notificationStatus === "enabled" && deviceRegistered} | ||
| 399 | onValueChange={handleDeviceNotificationsChange} | 439 | onValueChange={handleDeviceNotificationsChange} |
| 400 | /> | 440 | /> |
| 401 | <SettingsSwitchRow | 441 | <SettingsSwitchRow |
| ... | @@ -404,7 +444,12 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -404,7 +444,12 @@ function ConfiguredSettingsRouteScreen() { |
| 404 | } | 444 | } |
| 405 | icon="bolt.circle" | 445 | icon="bolt.circle" |
| 406 | label="Live Activity Updates" | 446 | label="Live Activity Updates" |
| 407 | value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"} | 447 | // Same gate: a saved preference is meaningless until the device |
| 448 | // registration the relay needs to push updates has succeeded. | ||
| 449 | value={ | ||
| 450 | (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && | ||
| 451 | deviceRegistered | ||
| 452 | } | ||
| 408 | onValueChange={handleLiveActivitiesChange} | 453 | onValueChange={handleLiveActivitiesChange} |
| 409 | /> | 454 | /> |
| 410 | </SettingsSection> | 455 | </SettingsSection> |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+10| ... | @@ -34,6 +34,8 @@ import { | ... | @@ -34,6 +34,8 @@ import { |
| 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; |
| 36 | import { useProjects } from "../../state/entities"; | 36 | import { useProjects } from "../../state/entities"; |
| 37 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | ||
| 38 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | ||
| 37 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | 39 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| 38 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | 40 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 39 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 41 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| ... | @@ -524,6 +526,14 @@ export function NewTaskDraftScreen(props: { | ... | @@ -524,6 +526,14 @@ export function NewTaskDraftScreen(props: { |
| 524 | } | 526 | } |
| 525 | 527 | ||
| 526 | flow.setSubmitting(true); | 528 | flow.setSubmitting(true); |
| 529 | // Arm the lock-screen card before the async thread creation: backgrounding | ||
| 530 | // the app right after tapping submit would otherwise reject the foreground | ||
| 531 | // -only Activity start. If creation fails, the token registration's replay | ||
| 532 | // finds no work and ends the card within seconds. | ||
| 533 | armAgentAwarenessLiveActivityForLocalWork({ | ||
| 534 | threadTitle: deriveThreadTitleFromPrompt(initialMessageText), | ||
| 535 | projectTitle: selectedProject.title, | ||
| 536 | }); | ||
| 527 | const result = await createProjectThread({ | 537 | const result = await createProjectThread({ |
| 528 | project: selectedProject, | 538 | project: selectedProject, |
| 529 | modelSelection, | 539 | modelSelection, |
apps/mobile/src/features/threads/ThreadComposer.tsx+14-1| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | import ImageViewing from "react-native-image-viewing"; | 27 | import ImageViewing from "react-native-image-viewing"; |
| 28 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 28 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 29 | import { useThemeColor } from "../../lib/useThemeColor"; | 29 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 30 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | ||
| 30 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 31 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 31 | 32 | ||
| 32 | import { AppText as Text } from "../../components/AppText"; | 33 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -499,12 +500,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -499,12 +500,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 499 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 500 | const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 500 | if (inFlightThreadIdsRef.current.has(threadKey)) return; | 501 | if (inFlightThreadIdsRef.current.has(threadKey)) return; |
| 501 | inFlightThreadIdsRef.current.add(threadKey); | 502 | inFlightThreadIdsRef.current.add(threadKey); |
| 503 | // Sending a prompt starts agent work: arm the lock-screen card now, while | ||
| 504 | // the app is foregrounded and the activity token can be registered. | ||
| 505 | armAgentAwarenessLiveActivityForLocalWork({ | ||
| 506 | threadTitle: props.selectedThread.title, | ||
| 507 | projectTitle: props.environmentLabel ?? "T3 Code", | ||
| 508 | }); | ||
| 502 | try { | 509 | try { |
| 503 | await onSendMessage(); | 510 | await onSendMessage(); |
| 504 | } finally { | 511 | } finally { |
| 505 | inFlightThreadIdsRef.current.delete(threadKey); | 512 | inFlightThreadIdsRef.current.delete(threadKey); |
| 506 | } | 513 | } |
| 507 | }, [onSendMessage, props.environmentId, props.selectedThread.id]); | 514 | }, [ |
| 515 | onSendMessage, | ||
| 516 | props.environmentId, | ||
| 517 | props.environmentLabel, | ||
| 518 | props.selectedThread.id, | ||
| 519 | props.selectedThread.title, | ||
| 520 | ]); | ||
| 508 | const handleCommandSelect = useCallback( | 521 | const handleCommandSelect = useCallback( |
| 509 | (item: ComposerCommandItem) => { | 522 | (item: ComposerCommandItem) => { |
| 510 | if (!composerTrigger) return; | 523 | if (!composerTrigger) return; |
apps/mobile/src/lib/storage.ts+45| ... | @@ -13,10 +13,12 @@ import { | ... | @@ -13,10 +13,12 @@ import { |
| 13 | const CONNECTIONS_KEY = "t3code.connections"; | 13 | const CONNECTIONS_KEY = "t3code.connections"; |
| 14 | const PREFERENCES_KEY = "t3code.preferences"; | 14 | const PREFERENCES_KEY = "t3code.preferences"; |
| 15 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | 15 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; |
| 16 | const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; | ||
| 16 | const MobileStorageKey = Schema.Literals([ | 17 | const MobileStorageKey = Schema.Literals([ |
| 17 | CONNECTIONS_KEY, | 18 | CONNECTIONS_KEY, |
| 18 | PREFERENCES_KEY, | 19 | PREFERENCES_KEY, |
| 19 | AGENT_AWARENESS_DEVICE_ID_KEY, | 20 | AGENT_AWARENESS_DEVICE_ID_KEY, |
| 21 | AGENT_AWARENESS_REGISTRATION_KEY, | ||
| 20 | ]); | 22 | ]); |
| 21 | type MobileStorageKeyValue = typeof MobileStorageKey.Type; | 23 | type MobileStorageKeyValue = typeof MobileStorageKey.Type; |
| 22 | 24 | ||
| ... | @@ -222,3 +224,46 @@ export async function loadAgentAwarenessDeviceId(): Promise<string | null> { | ... | @@ -222,3 +224,46 @@ export async function loadAgentAwarenessDeviceId(): Promise<string | null> { |
| 222 | const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); | 224 | const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); |
| 223 | return existing?.trim() ? existing : null; | 225 | return existing?.trim() ? existing : null; |
| 224 | } | 226 | } |
| 227 | |||
| 228 | export interface AgentAwarenessRegistrationRecord { | ||
| 229 | readonly identity: string; | ||
| 230 | readonly signature: string; | ||
| 231 | // Last push-to-start token the relay accepted. Registrations triggered | ||
| 232 | // without a token event merge it back in so token absence never reads as a | ||
| 233 | // change (which would defeat the register-once skip every launch). | ||
| 234 | readonly pushToStartToken?: string; | ||
| 235 | } | ||
| 236 | |||
| 237 | // Remembers the account identity and payload signature the relay last accepted | ||
| 238 | // so the app does not re-register on every launch while nothing has changed. | ||
| 239 | // Cleared only on sign-out. | ||
| 240 | export async function loadAgentAwarenessRegistrationRecord(): Promise<AgentAwarenessRegistrationRecord | null> { | ||
| 241 | const parsed = await readJsonStorageItem<AgentAwarenessRegistrationRecord>( | ||
| 242 | AGENT_AWARENESS_REGISTRATION_KEY, | ||
| 243 | ); | ||
| 244 | if ( | ||
| 245 | !parsed || | ||
| 246 | typeof parsed !== "object" || | ||
| 247 | typeof parsed.identity !== "string" || | ||
| 248 | typeof parsed.signature !== "string" | ||
| 249 | ) { | ||
| 250 | return null; | ||
| 251 | } | ||
| 252 | return { | ||
| 253 | identity: parsed.identity, | ||
| 254 | signature: parsed.signature, | ||
| 255 | ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken | ||
| 256 | ? { pushToStartToken: parsed.pushToStartToken } | ||
| 257 | : {}), | ||
| 258 | }; | ||
| 259 | } | ||
| 260 | |||
| 261 | export async function saveAgentAwarenessRegistrationRecord( | ||
| 262 | record: AgentAwarenessRegistrationRecord, | ||
| 263 | ): Promise<void> { | ||
| 264 | await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); | ||
| 265 | } | ||
| 266 | |||
| 267 | export async function clearAgentAwarenessRegistrationRecord(): Promise<void> { | ||
| 268 | await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, ""); | ||
| 269 | } |
apps/mobile/src/widgets/AgentActivity.test.ts+178-8| ... | @@ -2,23 +2,48 @@ import { describe, expect, it, vi } from "vite-plus/test"; | ... | @@ -2,23 +2,48 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | vi.mock("@expo/ui/swift-ui", () => ({ | 3 | vi.mock("@expo/ui/swift-ui", () => ({ |
| 4 | HStack: "HStack", | 4 | HStack: "HStack", |
| 5 | Image: "Image", | ||
| 5 | Spacer: "Spacer", | 6 | Spacer: "Spacer", |
| 6 | Text: "Text", | 7 | Text: "Text", |
| 7 | VStack: "VStack", | 8 | VStack: "VStack", |
| 9 | ZStack: "ZStack", | ||
| 8 | })); | 10 | })); |
| 9 | 11 | ||
| 10 | vi.mock("@expo/ui/swift-ui/modifiers", () => ({ | 12 | vi.mock("@expo/ui/swift-ui/modifiers", () => ({ |
| 11 | font: (value: unknown) => value, | 13 | font: (value: unknown) => value, |
| 12 | foregroundStyle: (value: unknown) => value, | 14 | foregroundStyle: (value: unknown) => value, |
| 15 | frame: (value: unknown) => value, | ||
| 16 | layoutPriority: (value: unknown) => value, | ||
| 13 | lineLimit: (value: unknown) => value, | 17 | lineLimit: (value: unknown) => value, |
| 14 | padding: (value: unknown) => value, | 18 | padding: (value: unknown) => value, |
| 19 | resizable: (value: unknown) => value, | ||
| 20 | widgetURL: (value: unknown) => ({ widgetURL: value }), | ||
| 15 | })); | 21 | })); |
| 16 | 22 | ||
| 17 | vi.mock("expo-widgets", () => ({ | 23 | vi.mock("expo-widgets", () => ({ |
| 18 | createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), | 24 | createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), |
| 19 | })); | 25 | })); |
| 20 | 26 | ||
| 21 | import { AgentActivity, type AgentActivityProps } from "./AgentActivity"; | 27 | import { |
| 28 | AgentActivity, | ||
| 29 | type AgentActivityProps, | ||
| 30 | type AgentActivityRowProps, | ||
| 31 | } from "./AgentActivity"; | ||
| 32 | |||
| 33 | function makeRow(overrides: Partial<AgentActivityRowProps>): AgentActivityRowProps { | ||
| 34 | return { | ||
| 35 | environmentId: "env-1", | ||
| 36 | threadId: "thread-1", | ||
| 37 | projectTitle: "Project", | ||
| 38 | threadTitle: "Thread", | ||
| 39 | modelTitle: "gpt-5.4", | ||
| 40 | phase: "running", | ||
| 41 | status: "Working", | ||
| 42 | updatedAt: "2026-05-25T13:07:00.000Z", | ||
| 43 | deepLink: "/threads/env-1/thread-1", | ||
| 44 | ...overrides, | ||
| 45 | }; | ||
| 46 | } | ||
| 22 | 47 | ||
| 23 | const props = { | 48 | const props = { |
| 24 | title: "T3 Code", | 49 | title: "T3 Code", |
| ... | @@ -33,17 +58,162 @@ const environment = { | ... | @@ -33,17 +58,162 @@ const environment = { |
| 33 | isLuminanceReduced: false, | 58 | isLuminanceReduced: false, |
| 34 | } as const; | 59 | } as const; |
| 35 | 60 | ||
| 61 | const lightEnvironment = { | ||
| 62 | colorScheme: "light", | ||
| 63 | isLuminanceReduced: false, | ||
| 64 | } as const; | ||
| 65 | |||
| 36 | describe("AgentActivity widget layout", () => { | 66 | describe("AgentActivity widget layout", () => { |
| 37 | it("formats its updated-at label without app-runtime helper references", () => { | 67 | it("tints each row by its own phase using the web sidebar's dark palette", () => { |
| 38 | expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( | 68 | const layout = AgentActivity( |
| 39 | '"children":["Updated ","1:07"]', | 69 | { |
| 70 | ...props, | ||
| 71 | activeCount: 2, | ||
| 72 | activities: [ | ||
| 73 | makeRow({}), | ||
| 74 | makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), | ||
| 75 | ], | ||
| 76 | }, | ||
| 77 | environment as never, | ||
| 78 | ); | ||
| 79 | const banner = JSON.stringify(layout.banner); | ||
| 80 | expect(banner).toContain("#7dd3fc"); // sky-300: running | ||
| 81 | expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval | ||
| 82 | }); | ||
| 83 | |||
| 84 | it("switches to the web sidebar's light palette when the scheme is light", () => { | ||
| 85 | // macOS (iPhone Mirroring / Mac notification center) renders the activity | ||
| 86 | // on a light background; the dark-material palette is illegible there. | ||
| 87 | const layout = AgentActivity( | ||
| 88 | { | ||
| 89 | ...props, | ||
| 90 | activeCount: 2, | ||
| 91 | activities: [ | ||
| 92 | makeRow({}), | ||
| 93 | makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), | ||
| 94 | ], | ||
| 95 | }, | ||
| 96 | lightEnvironment as never, | ||
| 97 | ); | ||
| 98 | const banner = JSON.stringify(layout.banner); | ||
| 99 | expect(banner).toContain("#0284c7"); // sky-600: running | ||
| 100 | expect(banner).toContain("#d97706"); // amber-600: waiting_for_approval | ||
| 101 | expect(banner).not.toContain("#7dd3fc"); | ||
| 102 | expect(banner).not.toContain("#fcd34d"); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("orders rows attention-first in the banner", () => { | ||
| 106 | const layout = AgentActivity( | ||
| 107 | { | ||
| 108 | ...props, | ||
| 109 | activeCount: 2, | ||
| 110 | activities: [ | ||
| 111 | makeRow({ threadTitle: "Working thread" }), | ||
| 112 | makeRow({ | ||
| 113 | threadId: "thread-2", | ||
| 114 | threadTitle: "Blocked thread", | ||
| 115 | phase: "waiting_for_approval", | ||
| 116 | status: "Approval", | ||
| 117 | }), | ||
| 118 | ], | ||
| 119 | }, | ||
| 120 | environment as never, | ||
| 121 | ); | ||
| 122 | const banner = JSON.stringify(layout.banner); | ||
| 123 | expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1); | ||
| 124 | expect(banner.indexOf("Blocked thread")).toBeLessThan(banner.indexOf("Working thread")); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("summarizes the attention count in the banner header", () => { | ||
| 128 | const layout = AgentActivity( | ||
| 129 | { | ||
| 130 | ...props, | ||
| 131 | activeCount: 3, | ||
| 132 | activities: [ | ||
| 133 | makeRow({}), | ||
| 134 | makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), | ||
| 135 | ], | ||
| 136 | }, | ||
| 137 | environment as never, | ||
| 40 | ); | 138 | ); |
| 41 | expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); | 139 | const banner = JSON.stringify(layout.banner); |
| 140 | expect(banner).toContain("3 active agents"); | ||
| 141 | expect(banner).toContain("1 needs attention"); | ||
| 42 | }); | 142 | }); |
| 43 | 143 | ||
| 44 | it("uses now when the updated-at timestamp is malformed", () => { | 144 | it("uses the attention tint for the compact presentations when a row needs input", () => { |
| 145 | const layout = AgentActivity( | ||
| 146 | { | ||
| 147 | ...props, | ||
| 148 | activeCount: 2, | ||
| 149 | activities: [ | ||
| 150 | makeRow({}), | ||
| 151 | makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), | ||
| 152 | ], | ||
| 153 | }, | ||
| 154 | environment as never, | ||
| 155 | ); | ||
| 156 | expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300 | ||
| 157 | expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); | ||
| 158 | expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc"); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("deep links the banner to the row that needs attention", () => { | ||
| 162 | const layout = AgentActivity( | ||
| 163 | { | ||
| 164 | ...props, | ||
| 165 | activeCount: 2, | ||
| 166 | activities: [ | ||
| 167 | makeRow({}), | ||
| 168 | makeRow({ | ||
| 169 | threadId: "thread-2", | ||
| 170 | phase: "waiting_for_approval", | ||
| 171 | status: "Approval", | ||
| 172 | deepLink: "/threads/env-1/thread-2", | ||
| 173 | }), | ||
| 174 | ], | ||
| 175 | }, | ||
| 176 | environment as never, | ||
| 177 | ); | ||
| 178 | expect(JSON.stringify(layout.banner)).toContain( | ||
| 179 | '"widgetURL":"t3code://threads/env-1/thread-2"', | ||
| 180 | ); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("deep links the banner to the first row when nothing needs attention", () => { | ||
| 184 | const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); | ||
| 185 | expect(JSON.stringify(layout.banner)).toContain( | ||
| 186 | '"widgetURL":"t3code://threads/env-1/thread-1"', | ||
| 187 | ); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("omits the deep link for unsafe paths and empty aggregates", () => { | ||
| 191 | expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL"); | ||
| 45 | expect( | 192 | expect( |
| 46 | JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)), | 193 | JSON.stringify( |
| 47 | ).toContain('"children":["Updated ","now"]'); | 194 | AgentActivity( |
| 195 | { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, | ||
| 196 | environment as never, | ||
| 197 | ), | ||
| 198 | ), | ||
| 199 | ).not.toContain("widgetURL"); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("renders up to five rows in the banner", () => { | ||
| 203 | const layout = AgentActivity( | ||
| 204 | { | ||
| 205 | ...props, | ||
| 206 | activeCount: 6, | ||
| 207 | activities: [1, 2, 3, 4, 5, 6].map((n) => | ||
| 208 | makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), | ||
| 209 | ), | ||
| 210 | }, | ||
| 211 | environment as never, | ||
| 212 | ); | ||
| 213 | const banner = JSON.stringify(layout.banner); | ||
| 214 | for (const visible of [1, 2, 3, 4, 5]) { | ||
| 215 | expect(banner).toContain(`Thread ${visible}`); | ||
| 216 | } | ||
| 217 | expect(banner).not.toContain("Thread 6"); | ||
| 48 | }); | 218 | }); |
| 49 | }); | 219 | }); |
apps/mobile/src/widgets/AgentActivity.tsx+276-229| ... | @@ -1,5 +1,15 @@ | ... | @@ -1,5 +1,15 @@ |
| 1 | import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui"; | 1 | import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; |
| 2 | import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers"; | 2 | import type { ComponentProps } from "react"; |
| 3 | import { | ||
| 4 | font, | ||
| 5 | foregroundStyle, | ||
| 6 | frame, | ||
| 7 | layoutPriority, | ||
| 8 | lineLimit, | ||
| 9 | padding, | ||
| 10 | resizable, | ||
| 11 | widgetURL, | ||
| 12 | } from "@expo/ui/swift-ui/modifiers"; | ||
| 3 | import { | 13 | import { |
| 4 | createLiveActivity, | 14 | createLiveActivity, |
| 5 | type LiveActivityComponent, | 15 | type LiveActivityComponent, |
| ... | @@ -37,282 +47,319 @@ export interface AgentActivityProps { | ... | @@ -37,282 +47,319 @@ export interface AgentActivityProps { |
| 37 | readonly activities: ReadonlyArray<AgentActivityRowProps>; | 47 | readonly activities: ReadonlyArray<AgentActivityRowProps>; |
| 38 | } | 48 | } |
| 39 | 49 | ||
| 50 | // This function is serialized into the widget extension's JS bundle, so it | ||
| 51 | // must stay self-contained: no references to module-scope helpers, only the | ||
| 52 | // imported view/modifier factories. | ||
| 40 | export function AgentActivity( | 53 | export function AgentActivity( |
| 41 | props: AgentActivityProps, | 54 | props: AgentActivityProps, |
| 42 | environment: LiveActivityEnvironment, | 55 | environment: LiveActivityEnvironment, |
| 43 | ): LiveActivityLayout { | 56 | ): LiveActivityLayout { |
| 44 | "widget"; | 57 | "widget"; |
| 45 | 58 | ||
| 46 | const row0 = props.activities[0]; | 59 | // Use SwiftUI's semantic label colors rather than fixed hex keyed off the |
| 47 | const row1 = props.activities[1]; | 60 | // device color scheme. A Live Activity banner always renders over a dark |
| 48 | const row2 = props.activities[2]; | 61 | // system material regardless of the device's light/dark setting, so |
| 49 | const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt); | 62 | // scheme-derived dark text read as unreadable dark-on-dark on the lock |
| 50 | const updatedAtHours24 = Number(updatedAtMatch?.[1]); | 63 | // screen. Semantic colors adapt to whatever material the OS places them on: |
| 51 | const updatedAtMinutes = updatedAtMatch?.[2]; | 64 | // the dark LA banner and the (light or dark) home-screen widget alike. |
| 52 | const updatedAt = | 65 | const primaryForeground = "primary"; |
| 53 | Number.isInteger(updatedAtHours24) && | 66 | const secondaryForeground = "secondary"; |
| 54 | updatedAtHours24 >= 0 && | 67 | |
| 55 | updatedAtHours24 <= 23 && | 68 | // Status tints mirror the web sidebar's pills |
| 56 | updatedAtMinutes | 69 | // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber |
| 57 | ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}` | 70 | // for approval, indigo for input, sky for working, emerald for completed. |
| 58 | : "now"; | 71 | // On iPhone the LA sits on a dark material, but macOS (iPhone Mirroring / |
| 72 | // Mac notification center) renders it on a light one — so pick the web | ||
| 73 | // palette's light (-600) or dark (-300) variant off the color scheme. | ||
| 74 | const isLightScheme = environment.colorScheme === "light"; | ||
| 75 | const phaseTint = (phase: AgentActivityPhase | undefined): string => { | ||
| 76 | if (environment.isLuminanceReduced) { | ||
| 77 | return secondaryForeground; | ||
| 78 | } | ||
| 79 | switch (phase) { | ||
| 80 | case "waiting_for_approval": | ||
| 81 | return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 | ||
| 82 | case "waiting_for_input": | ||
| 83 | return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300 | ||
| 84 | case "failed": | ||
| 85 | return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 | ||
| 86 | case "completed": | ||
| 87 | return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 | ||
| 88 | case "starting": | ||
| 89 | case "running": | ||
| 90 | default: | ||
| 91 | return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300 | ||
| 92 | } | ||
| 93 | }; | ||
| 94 | |||
| 95 | // Order attention-first so whatever needs the user floats to the top of every | ||
| 96 | // presentation, then failures, then in-flight work, then finished/stale. | ||
| 97 | const phasePriority = (phase: AgentActivityPhase): number => { | ||
| 98 | if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; | ||
| 99 | if (phase === "failed") return 1; | ||
| 100 | if (phase === "running" || phase === "starting") return 2; | ||
| 101 | return 3; | ||
| 102 | }; | ||
| 103 | const ordered = [...props.activities].sort( | ||
| 104 | (a, b) => phasePriority(a.phase) - phasePriority(b.phase), | ||
| 105 | ); | ||
| 106 | const row0 = ordered[0]; | ||
| 107 | const row1 = ordered[1]; | ||
| 108 | const row2 = ordered[2]; | ||
| 109 | const row3 = ordered[3]; | ||
| 110 | const row4 = ordered[4]; | ||
| 111 | |||
| 112 | const attentionRows = props.activities.filter( | ||
| 113 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", | ||
| 114 | ); | ||
| 115 | const attentionRow = attentionRows[0]; | ||
| 116 | const failedRow = props.activities.find((row) => row.phase === "failed"); | ||
| 117 | const heroRow = attentionRow ?? failedRow ?? row0; | ||
| 118 | const tint = phaseTint(heroRow?.phase); | ||
| 119 | // Headline count leans on the accent when a human is actually blocked. | ||
| 120 | const headerTint = attentionRow | ||
| 121 | ? phaseTint(attentionRow.phase) | ||
| 122 | : failedRow | ||
| 123 | ? phaseTint(failedRow.phase) | ||
| 124 | : tint; | ||
| 125 | |||
| 126 | // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders | ||
| 127 | // the two parts in-line so the attention half can carry the accent color; | ||
| 128 | // `summary` is the short form for tight spots (expanded center, watch card). | ||
| 129 | const agentWord = props.activeCount === 1 ? "agent" : "agents"; | ||
| 130 | const agentsLabel = `${props.activeCount} active ${agentWord}`; | ||
| 131 | const attentionSuffix = | ||
| 132 | attentionRows.length > 0 | ||
| 133 | ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` | ||
| 134 | : ""; | ||
| 135 | const summary = attentionSuffix || `${props.activeCount} active`; | ||
| 136 | |||
| 137 | // Any registered scheme variant routes back to this app; taps are delivered | ||
| 138 | // to the widget's containing app, so the prod scheme is safe for all builds. | ||
| 139 | const deepLinkRow = attentionRow ?? row0; | ||
| 140 | const deepLink = | ||
| 141 | deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") | ||
| 142 | ? `t3code://${deepLinkRow.deepLink.slice(1)}` | ||
| 143 | : null; | ||
| 144 | |||
| 59 | const activeLabel = `${props.activeCount} active`; | 145 | const activeLabel = `${props.activeCount} active`; |
| 60 | const isLight = environment.colorScheme === "light"; | 146 | |
| 61 | const primaryForeground = isLight ? "#262626" : "#f5f5f5"; | 147 | // A scannable status glyph per phase — reads faster than colored words and |
| 62 | const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; | 148 | // ties the compact / expanded / banner / watch presentations together. |
| 63 | const mutedForeground = isLight ? "#737373" : "#8e8e93"; | 149 | type SFName = NonNullable<ComponentProps<typeof Image>["systemName"]>; |
| 64 | const tint = environment.isLuminanceReduced | 150 | const phaseSymbol = (phase: AgentActivityPhase): SFName => { |
| 65 | ? secondaryForeground | 151 | switch (phase) { |
| 66 | : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" | 152 | case "waiting_for_approval": |
| 67 | ? "#f97316" | 153 | return "exclamationmark.circle.fill"; |
| 68 | : row0?.phase === "failed" | 154 | case "waiting_for_input": |
| 69 | ? "#ef4444" | 155 | return "questionmark.circle.fill"; |
| 70 | : "#14b8a6"; | 156 | case "failed": |
| 157 | return "xmark.octagon.fill"; | ||
| 158 | case "completed": | ||
| 159 | return "checkmark.circle.fill"; | ||
| 160 | case "starting": | ||
| 161 | return "circle.dotted"; | ||
| 162 | case "stale": | ||
| 163 | return "clock.arrow.circlepath"; | ||
| 164 | case "running": | ||
| 165 | default: | ||
| 166 | return "arrow.triangle.2.circlepath"; | ||
| 167 | } | ||
| 168 | }; | ||
| 169 | |||
| 170 | // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to | ||
| 171 | // the image; size + tint them through a container the resizable symbol fills. | ||
| 172 | const renderGlyph = (systemName: SFName, size: number, color: string) => ( | ||
| 173 | <HStack modifiers={[frame({ width: size, height: size }), foregroundStyle(color)]}> | ||
| 174 | <Image systemName={systemName} modifiers={[resizable()]} /> | ||
| 175 | </HStack> | ||
| 176 | ); | ||
| 177 | |||
| 178 | // Single-line row used by every presentation: glyph, title, inline project, | ||
| 179 | // status. The project and status carry layoutPriority(1) so when space runs | ||
| 180 | // out it's the title that truncates, never the (short) project name or the | ||
| 181 | // status label. Single-line keeps rows inside the expanded island's hard | ||
| 182 | // height budget (~160pt) and lets the banner fit more agents. | ||
| 183 | const renderCompactRow = (row: AgentActivityRowProps) => ( | ||
| 184 | <HStack spacing={7} alignment="center"> | ||
| 185 | <Text | ||
| 186 | modifiers={[ | ||
| 187 | font({ weight: "semibold", size: 13 }), | ||
| 188 | foregroundStyle(primaryForeground), | ||
| 189 | lineLimit(1), | ||
| 190 | ]} | ||
| 191 | > | ||
| 192 | {row.threadTitle} | ||
| 193 | </Text> | ||
| 194 | {/* No layoutPriority and no frame on the project: two bare texts take | ||
| 195 | their ideal width when it fits and shrink proportionally only when it | ||
| 196 | doesn't — so short rows never truncate, and long title + long project | ||
| 197 | truncate together. (A maxWidth frame is greedy and reserved its full | ||
| 198 | width even for short names; layoutPriority let the project starve the | ||
| 199 | title.) */} | ||
| 200 | <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}> | ||
| 201 | {row.projectTitle} | ||
| 202 | </Text> | ||
| 203 | <Spacer minLength={8} /> | ||
| 204 | <Text | ||
| 205 | modifiers={[ | ||
| 206 | font({ weight: "semibold", size: 11 }), | ||
| 207 | foregroundStyle(phaseTint(row.phase)), | ||
| 208 | layoutPriority(1), | ||
| 209 | ]} | ||
| 210 | > | ||
| 211 | {row.status} | ||
| 212 | </Text> | ||
| 213 | </HStack> | ||
| 214 | ); | ||
| 215 | |||
| 216 | // The branded T3 mark. `assetName` resolves the template image set bundled in | ||
| 217 | // the widget extension's asset catalog. Image views only honor `resizable` | ||
| 218 | // directly (frame/foregroundStyle are dropped), so we size it via a container | ||
| 219 | // frame the resizable image fills and tint it through the container's | ||
| 220 | // foreground style, which the template image inherits. The 3:2 frame matches | ||
| 221 | // the glyph's aspect ratio so it never distorts. | ||
| 222 | const renderLogo = (height: number, color: string) => ( | ||
| 223 | <HStack modifiers={[frame({ width: height * 1.5, height }), foregroundStyle(color)]}> | ||
| 224 | <Image assetName="T3Mark" modifiers={[resizable()]} /> | ||
| 225 | </HStack> | ||
| 226 | ); | ||
| 71 | 227 | ||
| 72 | return { | 228 | return { |
| 73 | banner: ( | 229 | banner: ( |
| 74 | <VStack modifiers={[padding({ all: 14 })]}> | 230 | <VStack |
| 75 | <HStack> | 231 | alignment="leading" |
| 76 | <VStack> | 232 | spacing={6} |
| 77 | <Text | 233 | modifiers={deepLink ? [padding({ all: 14 }), widgetURL(deepLink)] : [padding({ all: 14 })]} |
| 78 | modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle(primaryForeground)]} | 234 | > |
| 79 | > | 235 | {/* Logo pinned to the leading edge; the status texts centered across the |
| 80 | {props.title} | 236 | full width (ZStack so the logo doesn't skew the centering). No footer — |
| 81 | </Text> | 237 | overflow beyond the visible rows is inferable from the count. */} |
| 238 | <ZStack> | ||
| 239 | <HStack spacing={0} alignment="center"> | ||
| 240 | {renderLogo(13, primaryForeground)} | ||
| 241 | <Spacer minLength={0} /> | ||
| 242 | </HStack> | ||
| 243 | <HStack spacing={6} alignment="center"> | ||
| 244 | <Spacer minLength={0} /> | ||
| 82 | <Text | 245 | <Text |
| 83 | modifiers={[font({ size: 12 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | 246 | modifiers={[ |
| 247 | font({ weight: "semibold", size: 13 }), | ||
| 248 | foregroundStyle(primaryForeground), | ||
| 249 | lineLimit(1), | ||
| 250 | ]} | ||
| 84 | > | 251 | > |
| 85 | {props.subtitle} | 252 | {agentsLabel} |
| 86 | </Text> | 253 | </Text> |
| 87 | </VStack> | 254 | {attentionSuffix ? ( |
| 88 | <Spacer minLength={8} /> | 255 | <Text modifiers={[font({ size: 13 }), foregroundStyle(secondaryForeground)]}>·</Text> |
| 89 | <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}> | 256 | ) : null} |
| 90 | {activeLabel} | 257 | {attentionSuffix ? ( |
| 91 | </Text> | ||
| 92 | </HStack> | ||
| 93 | {row0 ? ( | ||
| 94 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 95 | <VStack> | ||
| 96 | <Text | 258 | <Text |
| 97 | modifiers={[ | 259 | modifiers={[ |
| 98 | font({ weight: "bold", size: 13 }), | 260 | font({ weight: "semibold", size: 13 }), |
| 99 | foregroundStyle(primaryForeground), | 261 | foregroundStyle(headerTint), |
| 100 | lineLimit(1), | 262 | lineLimit(1), |
| 101 | ]} | 263 | ]} |
| 102 | > | 264 | > |
| 103 | {row0.threadTitle} | 265 | {attentionSuffix} |
| 104 | </Text> | ||
| 105 | <Text | ||
| 106 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 107 | > | ||
| 108 | {row0.projectTitle} - {row0.modelTitle} | ||
| 109 | </Text> | ||
| 110 | </VStack> | ||
| 111 | <Spacer minLength={8} /> | ||
| 112 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 113 | {row0.status} | ||
| 114 | </Text> | ||
| 115 | </HStack> | ||
| 116 | ) : null} | ||
| 117 | {row1 ? ( | ||
| 118 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 119 | <VStack> | ||
| 120 | <Text | ||
| 121 | modifiers={[ | ||
| 122 | font({ weight: "bold", size: 13 }), | ||
| 123 | foregroundStyle(primaryForeground), | ||
| 124 | lineLimit(1), | ||
| 125 | ]} | ||
| 126 | > | ||
| 127 | {row1.threadTitle} | ||
| 128 | </Text> | ||
| 129 | <Text | ||
| 130 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 131 | > | ||
| 132 | {row1.projectTitle} - {row1.modelTitle} | ||
| 133 | </Text> | ||
| 134 | </VStack> | ||
| 135 | <Spacer minLength={8} /> | ||
| 136 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 137 | {row1.status} | ||
| 138 | </Text> | ||
| 139 | </HStack> | ||
| 140 | ) : null} | ||
| 141 | {row2 ? ( | ||
| 142 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 143 | <VStack> | ||
| 144 | <Text | ||
| 145 | modifiers={[ | ||
| 146 | font({ weight: "bold", size: 13 }), | ||
| 147 | foregroundStyle(primaryForeground), | ||
| 148 | lineLimit(1), | ||
| 149 | ]} | ||
| 150 | > | ||
| 151 | {row2.threadTitle} | ||
| 152 | </Text> | ||
| 153 | <Text | ||
| 154 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 155 | > | ||
| 156 | {row2.projectTitle} - {row2.modelTitle} | ||
| 157 | </Text> | 266 | </Text> |
| 158 | </VStack> | 267 | ) : null} |
| 159 | <Spacer minLength={8} /> | 268 | <Spacer minLength={0} /> |
| 160 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 161 | {row2.status} | ||
| 162 | </Text> | ||
| 163 | </HStack> | 269 | </HStack> |
| 164 | ) : null} | 270 | </ZStack> |
| 165 | <Text modifiers={[font({ size: 11 }), foregroundStyle(mutedForeground)]}> | 271 | {row0 ? renderCompactRow(row0) : null} |
| 166 | Updated {updatedAt} | 272 | {row1 ? renderCompactRow(row1) : null} |
| 167 | </Text> | 273 | {row2 ? renderCompactRow(row2) : null} |
| 274 | {row3 ? renderCompactRow(row3) : null} | ||
| 275 | {row4 ? renderCompactRow(row4) : null} | ||
| 168 | </VStack> | 276 | </VStack> |
| 169 | ), | 277 | ), |
| 278 | // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family): | ||
| 279 | // brand + count, then the single most important agent with its status glyph. | ||
| 170 | bannerSmall: ( | 280 | bannerSmall: ( |
| 171 | <VStack modifiers={[padding({ all: 12 })]}> | 281 | <VStack alignment="leading" spacing={5} modifiers={[padding({ all: 10 })]}> |
| 172 | <HStack> | 282 | <HStack spacing={7} alignment="center"> |
| 283 | {renderLogo(14, primaryForeground)} | ||
| 173 | <Text | 284 | <Text |
| 174 | modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(primaryForeground)]} | 285 | modifiers={[ |
| 286 | font({ weight: "bold", size: 13 }), | ||
| 287 | foregroundStyle(headerTint), | ||
| 288 | lineLimit(1), | ||
| 289 | ]} | ||
| 175 | > | 290 | > |
| 176 | {props.title} | 291 | {attentionRows.length > 0 ? summary : activeLabel} |
| 177 | </Text> | 292 | </Text> |
| 178 | <Spacer minLength={6} /> | 293 | <Spacer minLength={6} /> |
| 179 | <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}> | ||
| 180 | {activeLabel} | ||
| 181 | </Text> | ||
| 182 | </HStack> | 294 | </HStack> |
| 183 | {row0 ? ( | 295 | {row0 ? ( |
| 184 | <VStack> | 296 | <HStack spacing={7} alignment="center"> |
| 185 | <Text | 297 | <Text |
| 186 | modifiers={[ | 298 | modifiers={[ |
| 187 | font({ weight: "bold", size: 12 }), | 299 | font({ weight: "semibold", size: 12 }), |
| 188 | foregroundStyle(primaryForeground), | 300 | foregroundStyle(primaryForeground), |
| 189 | lineLimit(1), | 301 | lineLimit(1), |
| 190 | ]} | 302 | ]} |
| 191 | > | 303 | > |
| 192 | {row0.threadTitle} | 304 | {row0.threadTitle} |
| 193 | </Text> | 305 | </Text> |
| 194 | <Text | 306 | <Spacer minLength={6} /> |
| 195 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | 307 | <Text modifiers={[font({ size: 11 }), foregroundStyle(phaseTint(row0.phase))]}> |
| 196 | > | 308 | {row0.status} |
| 197 | {row0.projectTitle} - {row0.status} | ||
| 198 | </Text> | 309 | </Text> |
| 199 | </VStack> | 310 | </HStack> |
| 200 | ) : null} | 311 | ) : null} |
| 201 | </VStack> | 312 | </VStack> |
| 202 | ), | 313 | ), |
| 203 | compactLeading: ( | 314 | compactLeading: renderLogo(14, tint), |
| 204 | <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text> | ||
| 205 | ), | ||
| 206 | compactTrailing: ( | 315 | compactTrailing: ( |
| 207 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | 316 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> |
| 208 | {activeLabel} | 317 | {attentionRow |
| 318 | ? attentionRow.phase === "waiting_for_approval" | ||
| 319 | ? "Approval" | ||
| 320 | : "Input" | ||
| 321 | : activeLabel} | ||
| 209 | </Text> | 322 | </Text> |
| 210 | ), | 323 | ), |
| 211 | minimal: ( | 324 | // The shared/minimal form is a ~22pt circle — a single signal reads there, |
| 212 | <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text> | 325 | // the wordmark does not. Show the blocking phase glyph, else the mark. |
| 213 | ), | 326 | minimal: |
| 327 | attentionRow || failedRow | ||
| 328 | ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase)) | ||
| 329 | : renderLogo(11, tint), | ||
| 214 | expandedLeading: ( | 330 | expandedLeading: ( |
| 215 | <VStack modifiers={[padding({ all: 8 })]}> | 331 | <HStack spacing={5} alignment="center" modifiers={[padding({ leading: 4, vertical: 4 })]}> |
| 216 | <Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle(tint)]}> | 332 | {renderLogo(15, tint)} |
| 217 | {activeLabel} | 333 | <Text modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(tint)]}> |
| 218 | </Text> | 334 | {`${props.activeCount}`} |
| 219 | </VStack> | ||
| 220 | ), | ||
| 221 | expandedCenter: row0 ? ( | ||
| 222 | <VStack> | ||
| 223 | <Text | ||
| 224 | modifiers={[ | ||
| 225 | font({ weight: "bold", size: 12 }), | ||
| 226 | foregroundStyle(primaryForeground), | ||
| 227 | lineLimit(1), | ||
| 228 | ]} | ||
| 229 | > | ||
| 230 | {row0.threadTitle} | ||
| 231 | </Text> | 335 | </Text> |
| 232 | <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}> | 336 | </HStack> |
| 233 | {row0.projectTitle} - {row0.status} | ||
| 234 | </Text> | ||
| 235 | </VStack> | ||
| 236 | ) : null, | ||
| 237 | expandedTrailing: ( | ||
| 238 | <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground)]}> | ||
| 239 | Updated {updatedAt} | ||
| 240 | </Text> | ||
| 241 | ), | 337 | ), |
| 338 | // No center content: the phase glyphs + statuses in expandedBottom already | ||
| 339 | // carry the attention signal, and the expanded island's height budget is | ||
| 340 | // tight enough that a summary line there pushed the third row off. | ||
| 341 | expandedCenter: null, | ||
| 342 | // No trailing content: a timestamp is glanceable-lock-screen info, not | ||
| 343 | // useful in a view the user is actively holding open — and the trailing | ||
| 344 | // region hugs the island's corner radius, which clipped it anyway. | ||
| 345 | expandedTrailing: null, | ||
| 242 | expandedBottom: ( | 346 | expandedBottom: ( |
| 243 | <VStack modifiers={[padding({ all: 8 })]}> | 347 | // Vertical padding only: the expanded region provides its own horizontal |
| 244 | {row0 ? ( | 348 | // content margins, so `all` padding double-indented the rows. |
| 245 | <HStack modifiers={[padding({ vertical: 4 })]}> | 349 | // Horizontal padding keeps both edges clear of the island's corner |
| 246 | <VStack> | 350 | // curvature (right edge clipped status labels; titles hugged the left). |
| 247 | <Text | 351 | <VStack |
| 248 | modifiers={[ | 352 | alignment="leading" |
| 249 | font({ weight: "bold", size: 13 }), | 353 | spacing={5} |
| 250 | foregroundStyle(primaryForeground), | 354 | modifiers={ |
| 251 | lineLimit(1), | 355 | deepLink |
| 252 | ]} | 356 | ? [padding({ vertical: 2, horizontal: 8 }), widgetURL(deepLink)] |
| 253 | > | 357 | : [padding({ vertical: 2, horizontal: 8 })] |
| 254 | {row0.threadTitle} | 358 | } |
| 255 | </Text> | 359 | > |
| 256 | <Text | 360 | {row0 ? renderCompactRow(row0) : null} |
| 257 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | 361 | {row1 ? renderCompactRow(row1) : null} |
| 258 | > | 362 | {row2 ? renderCompactRow(row2) : null} |
| 259 | {row0.projectTitle} - {row0.modelTitle} | ||
| 260 | </Text> | ||
| 261 | </VStack> | ||
| 262 | <Spacer minLength={8} /> | ||
| 263 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 264 | {row0.status} | ||
| 265 | </Text> | ||
| 266 | </HStack> | ||
| 267 | ) : null} | ||
| 268 | {row1 ? ( | ||
| 269 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 270 | <VStack> | ||
| 271 | <Text | ||
| 272 | modifiers={[ | ||
| 273 | font({ weight: "bold", size: 13 }), | ||
| 274 | foregroundStyle(primaryForeground), | ||
| 275 | lineLimit(1), | ||
| 276 | ]} | ||
| 277 | > | ||
| 278 | {row1.threadTitle} | ||
| 279 | </Text> | ||
| 280 | <Text | ||
| 281 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 282 | > | ||
| 283 | {row1.projectTitle} - {row1.modelTitle} | ||
| 284 | </Text> | ||
| 285 | </VStack> | ||
| 286 | <Spacer minLength={8} /> | ||
| 287 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 288 | {row1.status} | ||
| 289 | </Text> | ||
| 290 | </HStack> | ||
| 291 | ) : null} | ||
| 292 | {row2 ? ( | ||
| 293 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 294 | <VStack> | ||
| 295 | <Text | ||
| 296 | modifiers={[ | ||
| 297 | font({ weight: "bold", size: 13 }), | ||
| 298 | foregroundStyle(primaryForeground), | ||
| 299 | lineLimit(1), | ||
| 300 | ]} | ||
| 301 | > | ||
| 302 | {row2.threadTitle} | ||
| 303 | </Text> | ||
| 304 | <Text | ||
| 305 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 306 | > | ||
| 307 | {row2.projectTitle} - {row2.modelTitle} | ||
| 308 | </Text> | ||
| 309 | </VStack> | ||
| 310 | <Spacer minLength={8} /> | ||
| 311 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 312 | {row2.status} | ||
| 313 | </Text> | ||
| 314 | </HStack> | ||
| 315 | ) : null} | ||
| 316 | </VStack> | 363 | </VStack> |
| 317 | ), | 364 | ), |
| 318 | }; | 365 | }; |
apps/server/src/cli/connect.test.ts+12-1| ... | @@ -8,7 +8,11 @@ import * as Logger from "effect/Logger"; | ... | @@ -8,7 +8,11 @@ import * as Logger from "effect/Logger"; |
| 8 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 9 | import * as References from "effect/References"; | 9 | import * as References from "effect/References"; |
| 10 | 10 | ||
| 11 | import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts"; | 11 | import { |
| 12 | acquireRelayClientForLink, | ||
| 13 | isPublishAgentActivityEnabledValue, | ||
| 14 | reportCloudDisconnectResults, | ||
| 15 | } from "./connect.ts"; | ||
| 12 | 16 | ||
| 13 | const managedExecutable = { | 17 | const managedExecutable = { |
| 14 | status: "available", | 18 | status: "available", |
| ... | @@ -153,3 +157,10 @@ it.effect("keeps disconnect causes in structured logs and out of console warning | ... | @@ -153,3 +157,10 @@ it.effect("keeps disconnect causes in structured logs and out of console warning |
| 153 | ), | 157 | ), |
| 154 | ); | 158 | ); |
| 155 | }); | 159 | }); |
| 160 | |||
| 161 | it("treats only the literal 'true' as publish-enabled", () => { | ||
| 162 | assert.equal(isPublishAgentActivityEnabledValue("true"), true); | ||
| 163 | assert.equal(isPublishAgentActivityEnabledValue("false"), false); | ||
| 164 | assert.equal(isPublishAgentActivityEnabledValue(null), false); | ||
| 165 | assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); | ||
| 166 | }); |
apps/server/src/cli/connect.ts+136-25| ... | @@ -29,7 +29,11 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ... | @@ -29,7 +29,11 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 29 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 29 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 30 | import * as CliState from "../cloud/CliState.ts"; | 30 | import * as CliState from "../cloud/CliState.ts"; |
| 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 32 | import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; | 32 | import { |
| 33 | CLOUD_LINKED_USER_ID, | ||
| 34 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 35 | RELAY_URL_SECRET, | ||
| 36 | } from "../cloud/config.ts"; | ||
| 33 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; | 37 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; |
| 34 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; | 38 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; |
| 35 | import * as ServerConfig from "../config.ts"; | 39 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -46,12 +50,21 @@ function bytesToString(value: Uint8Array): string { | ... | @@ -46,12 +50,21 @@ function bytesToString(value: Uint8Array): string { |
| 46 | return new TextDecoder().decode(value); | 50 | return new TextDecoder().decode(value); |
| 47 | } | 51 | } |
| 48 | 52 | ||
| 53 | function stringToBytes(value: string): Uint8Array { | ||
| 54 | return new TextEncoder().encode(value); | ||
| 55 | } | ||
| 56 | |||
| 57 | export function isPublishAgentActivityEnabledValue(value: string | null): boolean { | ||
| 58 | return value === "true"; | ||
| 59 | } | ||
| 60 | |||
| 49 | interface CloudCliStatus { | 61 | interface CloudCliStatus { |
| 50 | readonly desired: boolean; | 62 | readonly desired: boolean; |
| 51 | readonly authenticated: boolean; | 63 | readonly authenticated: boolean; |
| 52 | readonly linked: boolean; | 64 | readonly linked: boolean; |
| 53 | readonly cloudUserId: string | null; | 65 | readonly cloudUserId: string | null; |
| 54 | readonly relayUrl: string | null; | 66 | readonly relayUrl: string | null; |
| 67 | readonly publishAgentActivity: boolean; | ||
| 55 | readonly relayClient: RelayClient.RelayClientStatus; | 68 | readonly relayClient: RelayClient.RelayClientStatus; |
| 56 | } | 69 | } |
| 57 | 70 | ||
| ... | @@ -104,6 +117,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b | ... | @@ -104,6 +117,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b |
| 104 | ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, | 117 | ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, |
| 105 | ` Environment link: ${provisioned}`, | 118 | ` Environment link: ${provisioned}`, |
| 106 | ` Relay: ${status.relayUrl ?? "not provisioned"}`, | 119 | ` Relay: ${status.relayUrl ?? "not provisioned"}`, |
| 120 | ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, | ||
| 107 | ...formatRelayClientStatus(status.relayClient), | 121 | ...formatRelayClientStatus(status.relayClient), |
| 108 | ...(nextStep ? ["", `Next: ${nextStep}`] : []), | 122 | ...(nextStep ? ["", `Next: ${nextStep}`] : []), |
| 109 | ].join("\n"); | 123 | ].join("\n"); |
| ... | @@ -368,31 +382,52 @@ const connectLoginCommand = Command.make("login", { | ... | @@ -368,31 +382,52 @@ const connectLoginCommand = Command.make("login", { |
| 368 | 382 | ||
| 369 | const connectLinkCommand = Command.make("link", { | 383 | const connectLinkCommand = Command.make("link", { |
| 370 | ...projectLocationFlags, | 384 | ...projectLocationFlags, |
| 385 | publishOnly: Flag.boolean("publish-only").pipe( | ||
| 386 | Flag.withDescription( | ||
| 387 | "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).", | ||
| 388 | ), | ||
| 389 | Flag.withDefault(false), | ||
| 390 | ), | ||
| 371 | }).pipe( | 391 | }).pipe( |
| 372 | Command.withDescription("Authorize this environment for T3 Connect on next start."), | 392 | Command.withDescription("Authorize this environment for T3 Connect on next start."), |
| 373 | Command.withHandler((flags) => | 393 | Command.withHandler((flags) => |
| 374 | runCloudCommand( | 394 | runCloudCommand( |
| 375 | flags, | 395 | flags, |
| 376 | Effect.gen(function* () { | 396 | Effect.gen(function* () { |
| 377 | const relayClient = yield* RelayClient.RelayClient; | 397 | // A publish-only link needs no Cloudflare tunnel, so skip installing the |
| 378 | const installed = yield* acquireRelayClientForLink( | 398 | // relay client entirely. |
| 379 | relayClient, | 399 | if (!flags.publishOnly) { |
| 380 | confirmRelayClientInstall, | 400 | const relayClient = yield* RelayClient.RelayClient; |
| 381 | reportRelayClientInstallProgress, | 401 | const installed = yield* acquireRelayClientForLink( |
| 382 | ); | 402 | relayClient, |
| 383 | if (Option.isNone(installed)) { | 403 | confirmRelayClientInstall, |
| 384 | yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); | 404 | reportRelayClientInstallProgress, |
| 385 | return; | 405 | ); |
| 406 | if (Option.isNone(installed)) { | ||
| 407 | yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); | ||
| 408 | return; | ||
| 409 | } | ||
| 410 | yield* Console.log( | ||
| 411 | `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, | ||
| 412 | ); | ||
| 386 | } | 413 | } |
| 387 | yield* Console.log( | ||
| 388 | `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, | ||
| 389 | ); | ||
| 390 | 414 | ||
| 391 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | 415 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 392 | yield* tokens.get; | 416 | yield* tokens.get; |
| 393 | yield* CliState.setCliDesiredCloudLink(true); | 417 | yield* CliState.setCliDesiredCloudLink( |
| 418 | true, | ||
| 419 | flags.publishOnly ? "publish_only" : "managed", | ||
| 420 | ); | ||
| 421 | if (flags.publishOnly) { | ||
| 422 | // A publish-only link exists solely to publish; without the publish | ||
| 423 | // flag the link would be inert and the success message a lie. | ||
| 424 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 425 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); | ||
| 426 | } | ||
| 394 | yield* Console.log( | 427 | yield* Console.log( |
| 395 | "This T3 environment will be available through T3 Connect the next time T3 starts.", | 428 | flags.publishOnly |
| 429 | ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)." | ||
| 430 | : "This T3 environment will be available through T3 Connect the next time T3 starts.", | ||
| 396 | ); | 431 | ); |
| 397 | }), | 432 | }), |
| 398 | ), | 433 | ), |
| ... | @@ -411,22 +446,27 @@ const connectStatusCommand = Command.make("status", { | ... | @@ -411,22 +446,27 @@ const connectStatusCommand = Command.make("status", { |
| 411 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 446 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 412 | const relayClient = yield* RelayClient.RelayClient; | 447 | const relayClient = yield* RelayClient.RelayClient; |
| 413 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | 448 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 414 | const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( | 449 | const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] = |
| 415 | [ | 450 | yield* Effect.all( |
| 416 | CliState.readCliDesiredCloudLink, | 451 | [ |
| 417 | tokens.hasCredential, | 452 | CliState.readCliDesiredCloudLink, |
| 418 | secrets.get(CLOUD_LINKED_USER_ID), | 453 | tokens.hasCredential, |
| 419 | secrets.get(RELAY_URL_SECRET), | 454 | secrets.get(CLOUD_LINKED_USER_ID), |
| 420 | relayClient.resolve, | 455 | secrets.get(RELAY_URL_SECRET), |
| 421 | ], | 456 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 422 | { concurrency: "unbounded" }, | 457 | relayClient.resolve, |
| 423 | ); | 458 | ], |
| 459 | { concurrency: "unbounded" }, | ||
| 460 | ); | ||
| 424 | const status: CloudCliStatus = { | 461 | const status: CloudCliStatus = { |
| 425 | desired, | 462 | desired, |
| 426 | authenticated, | 463 | authenticated, |
| 427 | linked: Option.isSome(cloudUserId), | 464 | linked: Option.isSome(cloudUserId), |
| 428 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, | 465 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 429 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, | 466 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 467 | publishAgentActivity: isPublishAgentActivityEnabledValue( | ||
| 468 | Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, | ||
| 469 | ), | ||
| 430 | relayClient: executable, | 470 | relayClient: executable, |
| 431 | }; | 471 | }; |
| 432 | yield* Console.log(formatCloudStatus(status, { json: flags.json })); | 472 | yield* Console.log(formatCloudStatus(status, { json: flags.json })); |
| ... | @@ -438,6 +478,76 @@ const connectStatusCommand = Command.make("status", { | ... | @@ -438,6 +478,76 @@ const connectStatusCommand = Command.make("status", { |
| 438 | ), | 478 | ), |
| 439 | ); | 479 | ); |
| 440 | 480 | ||
| 481 | const connectPublishCommand = Command.make("publish", { | ||
| 482 | ...projectLocationFlags, | ||
| 483 | disable: Flag.boolean("disable").pipe( | ||
| 484 | Flag.withDescription("Stop publishing agent activity to your mobile clients."), | ||
| 485 | Flag.withDefault(false), | ||
| 486 | ), | ||
| 487 | }).pipe( | ||
| 488 | Command.withDescription( | ||
| 489 | "Toggle publishing agent activity (push notifications and Live Activities) to your mobile clients.", | ||
| 490 | ), | ||
| 491 | Command.withHandler((flags) => | ||
| 492 | runCloudCommand( | ||
| 493 | flags, | ||
| 494 | Effect.gen(function* () { | ||
| 495 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 496 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 497 | const enabled = !flags.disable; | ||
| 498 | yield* secrets.set( | ||
| 499 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 500 | stringToBytes(enabled ? "true" : "false"), | ||
| 501 | ); | ||
| 502 | if (!enabled) { | ||
| 503 | // If enabling scheduled a publish-only link that hasn't been | ||
| 504 | // provisioned yet, disabling must cancel it too — otherwise the next | ||
| 505 | // start still links an environment whose only purpose was publishing. | ||
| 506 | // A pending managed link is left alone; it exists for the tunnel. | ||
| 507 | const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)); | ||
| 508 | if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") { | ||
| 509 | yield* CliState.setCliDesiredCloudLink(false); | ||
| 510 | yield* Console.log("Cancelled the pending publish-only T3 Connect link."); | ||
| 511 | } | ||
| 512 | yield* Console.log("Publishing agent activity disabled."); | ||
| 513 | return; | ||
| 514 | } | ||
| 515 | |||
| 516 | yield* Console.log("Publishing agent activity enabled."); | ||
| 517 | const linked = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)); | ||
| 518 | if (linked) { | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | |||
| 522 | // Publishing needs the relay to know this environment belongs to you. | ||
| 523 | // Establish a tunnel-free publish-only link automatically so signing in | ||
| 524 | // is all it takes — the mobile client can still reach the environment | ||
| 525 | // out of band without T3 Connect. | ||
| 526 | if (!(yield* tokens.hasCredential)) { | ||
| 527 | yield* Console.log( | ||
| 528 | "Run `t3 connect login` first so this environment can be authorized to publish.", | ||
| 529 | ); | ||
| 530 | return; | ||
| 531 | } | ||
| 532 | // A link may already be desired (e.g. `t3 connect link` before the | ||
| 533 | // server's first start). Never downgrade it: a desired managed link | ||
| 534 | // also covers publishing, so only request a publish-only link when no | ||
| 535 | // link is pending at all. | ||
| 536 | if (yield* CliState.readCliDesiredCloudLink) { | ||
| 537 | yield* Console.log( | ||
| 538 | "A T3 Connect link is already pending. Start T3 to finish provisioning it; publishing starts once it links.", | ||
| 539 | ); | ||
| 540 | return; | ||
| 541 | } | ||
| 542 | yield* CliState.setCliDesiredCloudLink(true, "publish_only"); | ||
| 543 | yield* Console.log( | ||
| 544 | "Restart T3 to finish authorizing this environment to publish (no managed tunnel is created).", | ||
| 545 | ); | ||
| 546 | }), | ||
| 547 | ), | ||
| 548 | ), | ||
| 549 | ); | ||
| 550 | |||
| 441 | const connectUnlinkCommand = Command.make("unlink", { | 551 | const connectUnlinkCommand = Command.make("unlink", { |
| 442 | ...projectLocationFlags, | 552 | ...projectLocationFlags, |
| 443 | }).pipe( | 553 | }).pipe( |
| ... | @@ -461,6 +571,7 @@ export const connectCommand = Command.make("connect").pipe( | ... | @@ -461,6 +571,7 @@ export const connectCommand = Command.make("connect").pipe( |
| 461 | Command.withSubcommands([ | 571 | Command.withSubcommands([ |
| 462 | connectLoginCommand, | 572 | connectLoginCommand, |
| 463 | connectLinkCommand, | 573 | connectLinkCommand, |
| 574 | connectPublishCommand, | ||
| 464 | connectStatusCommand, | 575 | connectStatusCommand, |
| 465 | connectUnlinkCommand, | 576 | connectUnlinkCommand, |
| 466 | connectLogoutCommand, | 577 | connectLogoutCommand, |
apps/server/src/cloud/CliState.test.ts+23| ... | @@ -56,4 +56,27 @@ it.layer(NodeServices.layer)("CliState", (it) => { | ... | @@ -56,4 +56,27 @@ it.layer(NodeServices.layer)("CliState", (it) => { |
| 56 | } | 56 | } |
| 57 | }).pipe(Effect.provide(makeTestLayer())), | 57 | }).pipe(Effect.provide(makeTestLayer())), |
| 58 | ); | 58 | ); |
| 59 | |||
| 60 | it.effect("round-trips the desired link mode and defaults legacy links to managed", () => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 63 | |||
| 64 | assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); | ||
| 65 | |||
| 66 | yield* CliState.setCliDesiredCloudLink(true, "publish_only"); | ||
| 67 | assert.isTrue(yield* CliState.readCliDesiredCloudLink); | ||
| 68 | assert.equal(yield* CliState.readCliDesiredLinkMode, "publish_only"); | ||
| 69 | |||
| 70 | yield* CliState.setCliDesiredCloudLink(true, "managed"); | ||
| 71 | assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); | ||
| 72 | |||
| 73 | // A pre-existing link persisted the literal "true"; treat it as managed. | ||
| 74 | yield* secrets.set(CliState.CLOUD_CLI_DESIRED_LINK_SECRET, new TextEncoder().encode("true")); | ||
| 75 | assert.isTrue(yield* CliState.readCliDesiredCloudLink); | ||
| 76 | assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); | ||
| 77 | |||
| 78 | yield* CliState.setCliDesiredCloudLink(false); | ||
| 79 | assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); | ||
| 80 | }).pipe(Effect.provide(makeTestLayer())), | ||
| 81 | ); | ||
| 59 | }); | 82 | }); |
apps/server/src/cloud/CliState.ts+26-2| ... | @@ -14,19 +14,43 @@ import { | ... | @@ -14,19 +14,43 @@ import { |
| 14 | 14 | ||
| 15 | export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link"; | 15 | export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link"; |
| 16 | 16 | ||
| 17 | const TRUE_BYTES = new TextEncoder().encode("true"); | 17 | // "managed" provisions a Cloudflare tunnel (default, legacy value "true"). |
| 18 | // "publish_only" links the environment to the relay purely to publish agent | ||
| 19 | // activity — no tunnel, no relay-advertised endpoint — so activity can flow to | ||
| 20 | // mobile clients even when they reach the environment out of band (Tailscale, | ||
| 21 | // direct pairing) without T3 Connect. | ||
| 22 | export type CliDesiredLinkMode = "managed" | "publish_only"; | ||
| 23 | |||
| 24 | const MANAGED_BYTES = new TextEncoder().encode("managed"); | ||
| 25 | const PUBLISH_ONLY_BYTES = new TextEncoder().encode("publish_only"); | ||
| 18 | 26 | ||
| 19 | export const readCliDesiredCloudLink = Effect.gen(function* () { | 27 | export const readCliDesiredCloudLink = Effect.gen(function* () { |
| 20 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 28 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 21 | return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)); | 29 | return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)); |
| 22 | }); | 30 | }); |
| 23 | 31 | ||
| 32 | export const readCliDesiredLinkMode = Effect.gen(function* () { | ||
| 33 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 34 | const value = yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET); | ||
| 35 | if (Option.isNone(value)) { | ||
| 36 | return "managed" as CliDesiredLinkMode; | ||
| 37 | } | ||
| 38 | // Legacy links stored the literal "true" and are always managed. | ||
| 39 | return new TextDecoder().decode(value.value) === "publish_only" | ||
| 40 | ? ("publish_only" as CliDesiredLinkMode) | ||
| 41 | : ("managed" as CliDesiredLinkMode); | ||
| 42 | }); | ||
| 43 | |||
| 24 | export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( | 44 | export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( |
| 25 | desired: boolean, | 45 | desired: boolean, |
| 46 | mode: CliDesiredLinkMode = "managed", | ||
| 26 | ) { | 47 | ) { |
| 27 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 48 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 28 | if (desired) { | 49 | if (desired) { |
| 29 | yield* secrets.set(CLOUD_CLI_DESIRED_LINK_SECRET, TRUE_BYTES); | 50 | yield* secrets.set( |
| 51 | CLOUD_CLI_DESIRED_LINK_SECRET, | ||
| 52 | mode === "publish_only" ? PUBLISH_ONLY_BYTES : MANAGED_BYTES, | ||
| 53 | ); | ||
| 30 | } else { | 54 | } else { |
| 31 | yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); | 55 | yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); |
| 32 | } | 56 | } |
apps/server/src/cloud/http.test.ts+36-1| ... | @@ -11,7 +11,13 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ... | @@ -11,7 +11,13 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 12 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 13 | import * as CliTokenManager from "./CliTokenManager.ts"; | 13 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; | 14 | import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; |
| 15 | import { | ||
| 16 | consumeCloudReplayGuards, | ||
| 17 | isSupportedLinkProviderKind, | ||
| 18 | linkProofScopes, | ||
| 19 | reconcileDesiredCloudLink, | ||
| 20 | } from "./http.ts"; | ||
| 15 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 21 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 16 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; | 22 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; |
| 17 | 23 | ||
| ... | @@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => { | ... | @@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => { |
| 205 | ), | 211 | ), |
| 206 | ); | 212 | ); |
| 207 | }); | 213 | }); |
| 214 | |||
| 215 | describe("link proof provider kinds", () => { | ||
| 216 | const proofRequest = ( | ||
| 217 | providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], | ||
| 218 | ): RelayLinkProofRequest => ({ | ||
| 219 | challenge: "challenge", | ||
| 220 | relayIssuer: "https://relay.example.test", | ||
| 221 | endpoint: { | ||
| 222 | httpBaseUrl: "http://127.0.0.1:7331", | ||
| 223 | wsBaseUrl: "ws://127.0.0.1:7331", | ||
| 224 | providerKind, | ||
| 225 | }, | ||
| 226 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 7331 }, | ||
| 227 | }); | ||
| 228 | |||
| 229 | it("accepts managed and manual endpoints but not t3_relay", () => { | ||
| 230 | expect(isSupportedLinkProviderKind(proofRequest("cloudflare_tunnel"))).toBe(true); | ||
| 231 | expect(isSupportedLinkProviderKind(proofRequest("manual"))).toBe(true); | ||
| 232 | expect(isSupportedLinkProviderKind(proofRequest("t3_relay"))).toBe(false); | ||
| 233 | }); | ||
| 234 | |||
| 235 | it("only claims the managed-tunnel scope for tunnel links", () => { | ||
| 236 | expect(linkProofScopes(proofRequest("cloudflare_tunnel"))).toEqual([ | ||
| 237 | "agent_activity_notifications", | ||
| 238 | "managed_tunnels", | ||
| 239 | ]); | ||
| 240 | expect(linkProofScopes(proofRequest("manual"))).toEqual(["agent_activity_notifications"]); | ||
| 241 | }); | ||
| 242 | }); |
apps/server/src/cloud/http.ts+40-18| ... | @@ -68,7 +68,7 @@ import { | ... | @@ -68,7 +68,7 @@ import { |
| 68 | RELAY_URL_SECRET, | 68 | RELAY_URL_SECRET, |
| 69 | } from "./config.ts"; | 69 | } from "./config.ts"; |
| 70 | import { relayUrlConfig } from "./publicConfig.ts"; | 70 | import { relayUrlConfig } from "./publicConfig.ts"; |
| 71 | import { setCliDesiredCloudLink } from "./CliState.ts"; | 71 | import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts"; |
| 72 | import * as CliTokenManager from "./CliTokenManager.ts"; | 72 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 73 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 73 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 74 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | 74 | import { traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | @@ -299,8 +299,23 @@ function isAllowedEndpointOrigin(input: { | ... | @@ -299,8 +299,23 @@ function isAllowedEndpointOrigin(input: { |
| 299 | return input.origin.localHttpPort === endpointRequestPort(url); | 299 | return input.origin.localHttpPort === endpointRequestPort(url); |
| 300 | } | 300 | } |
| 301 | 301 | ||
| 302 | function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean { | 302 | // A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must |
| 303 | return request.endpoint.providerKind === "cloudflare_tunnel"; | 303 | // point at a loopback origin. A manual endpoint is reached out of band (e.g. |
| 304 | // Tailscale) or not advertised at all for publish-only links, so it is not | ||
| 305 | // tied to the managed-tunnel scope. | ||
| 306 | export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean { | ||
| 307 | return ( | ||
| 308 | request.endpoint.providerKind === "cloudflare_tunnel" || | ||
| 309 | request.endpoint.providerKind === "manual" | ||
| 310 | ); | ||
| 311 | } | ||
| 312 | |||
| 313 | export function linkProofScopes( | ||
| 314 | request: RelayLinkProofRequest, | ||
| 315 | ): RelayEnvironmentLinkProofPayload["scopes"] { | ||
| 316 | return request.endpoint.providerKind === "cloudflare_tunnel" | ||
| 317 | ? ["agent_activity_notifications", "managed_tunnels"] | ||
| 318 | : ["agent_activity_notifications"]; | ||
| 304 | } | 319 | } |
| 305 | 320 | ||
| 306 | function hasExactScope(input: { | 321 | function hasExactScope(input: { |
| ... | @@ -352,7 +367,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function | ... | @@ -352,7 +367,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function |
| 352 | ) { | 367 | ) { |
| 353 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); | 368 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); |
| 354 | if ( | 369 | if ( |
| 355 | !providerKindMatchesRequestedLinkScopes(request) || | 370 | !isSupportedLinkProviderKind(request) || |
| 356 | !isAllowedEndpointOrigin({ | 371 | !isAllowedEndpointOrigin({ |
| 357 | origin: request.origin, | 372 | origin: request.origin, |
| 358 | requestUrl, | 373 | requestUrl, |
| ... | @@ -379,7 +394,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function | ... | @@ -379,7 +394,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function |
| 379 | environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), | 394 | environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), |
| 380 | endpoint: request.endpoint, | 395 | endpoint: request.endpoint, |
| 381 | origin: request.origin, | 396 | origin: request.origin, |
| 382 | scopes: ["agent_activity_notifications", "managed_tunnels"], | 397 | scopes: linkProofScopes(request), |
| 383 | } satisfies RelayEnvironmentLinkProofPayload; | 398 | } satisfies RelayEnvironmentLinkProofPayload; |
| 384 | return yield* signRelayJwt({ | 399 | return yield* signRelayJwt({ |
| 385 | privateKey: keyPair.privateKey, | 400 | privateKey: keyPair.privateKey, |
| ... | @@ -537,6 +552,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -537,6 +552,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 537 | }), | 552 | }), |
| 538 | ), | 553 | ), |
| 539 | ); | 554 | ); |
| 555 | const mode = yield* readCliDesiredLinkMode; | ||
| 556 | const managedTunnelsEnabled = mode !== "publish_only"; | ||
| 540 | const relayUrl = yield* requireRelayUrl; | 557 | const relayUrl = yield* requireRelayUrl; |
| 541 | const challenge = yield* relayClientRequest(dependencies, { | 558 | const challenge = yield* relayClientRequest(dependencies, { |
| 542 | url: `${relayUrl}/v1/client/environment-link-challenges`, | 559 | url: `${relayUrl}/v1/client/environment-link-challenges`, |
| ... | @@ -544,7 +561,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -544,7 +561,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 544 | payload: { | 561 | payload: { |
| 545 | notificationsEnabled: true, | 562 | notificationsEnabled: true, |
| 546 | liveActivitiesEnabled: true, | 563 | liveActivitiesEnabled: true, |
| 547 | managedTunnelsEnabled: true, | 564 | managedTunnelsEnabled, |
| 548 | }, | 565 | }, |
| 549 | schema: RelayEnvironmentLinkChallengeResponse, | 566 | schema: RelayEnvironmentLinkChallengeResponse, |
| 550 | }); | 567 | }); |
| ... | @@ -556,7 +573,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -556,7 +573,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 556 | endpoint: { | 573 | endpoint: { |
| 557 | httpBaseUrl: localOrigin, | 574 | httpBaseUrl: localOrigin, |
| 558 | wsBaseUrl: localWsOrigin, | 575 | wsBaseUrl: localWsOrigin, |
| 559 | providerKind: "cloudflare_tunnel", | 576 | providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", |
| 560 | }, | 577 | }, |
| 561 | origin: { | 578 | origin: { |
| 562 | localHttpHost: localUrl.hostname, | 579 | localHttpHost: localUrl.hostname, |
| ... | @@ -572,11 +589,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -572,11 +589,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 572 | proof, | 589 | proof, |
| 573 | notificationsEnabled: true, | 590 | notificationsEnabled: true, |
| 574 | liveActivitiesEnabled: true, | 591 | liveActivitiesEnabled: true, |
| 575 | managedTunnelsEnabled: true, | 592 | managedTunnelsEnabled, |
| 576 | }, | 593 | }, |
| 577 | schema: RelayEnvironmentLinkResponse, | 594 | schema: RelayEnvironmentLinkResponse, |
| 578 | }); | 595 | }); |
| 579 | yield* setCliDesiredCloudLink(true); | 596 | yield* setCliDesiredCloudLink(true, mode); |
| 580 | return yield* applyCloudRelayConfig(dependencies, { | 597 | return yield* applyCloudRelayConfig(dependencies, { |
| 581 | relayUrl, | 598 | relayUrl, |
| 582 | relayIssuer: link.relayIssuer, | 599 | relayIssuer: link.relayIssuer, |
| ... | @@ -608,20 +625,25 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD | ... | @@ -608,20 +625,25 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD |
| 608 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( | 625 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( |
| 609 | dependencies: CloudHttpDependencies, | 626 | dependencies: CloudHttpDependencies, |
| 610 | ) { | 627 | ) { |
| 611 | const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all( | 628 | const [cloudUserId, relayUrl, relayIssuer, endpointRuntimeConfig, publishAgentActivity] = |
| 612 | [ | 629 | yield* Effect.all( |
| 613 | dependencies.secrets.get(CLOUD_LINKED_USER_ID), | 630 | [ |
| 614 | dependencies.secrets.get(RELAY_URL_SECRET), | 631 | dependencies.secrets.get(CLOUD_LINKED_USER_ID), |
| 615 | dependencies.secrets.get(RELAY_ISSUER_SECRET), | 632 | dependencies.secrets.get(RELAY_URL_SECRET), |
| 616 | dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), | 633 | dependencies.secrets.get(RELAY_ISSUER_SECRET), |
| 617 | ], | 634 | dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), |
| 618 | { concurrency: 4 }, | 635 | dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 619 | ); | 636 | ], |
| 637 | { concurrency: 5 }, | ||
| 638 | ); | ||
| 620 | return { | 639 | return { |
| 621 | linked: Option.isSome(cloudUserId), | 640 | linked: Option.isSome(cloudUserId), |
| 622 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, | 641 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 623 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, | 642 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 624 | relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, | 643 | relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, |
| 644 | // The managed tunnel runtime config is only stored for managed links; a | ||
| 645 | // publish-only link leaves it absent. | ||
| 646 | managedTunnelActive: Option.isSome(endpointRuntimeConfig), | ||
| 625 | publishAgentActivity: Option.isSome(publishAgentActivity) | 647 | publishAgentActivity: Option.isSome(publishAgentActivity) |
| 626 | ? bytesToString(publishAgentActivity.value) === "true" | 648 | ? bytesToString(publishAgentActivity.value) === "true" |
| 627 | : false, | 649 | : false, |
apps/server/src/relay/AgentAwarenessRelay.ts+90| ... | @@ -203,6 +203,26 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { | ... | @@ -203,6 +203,26 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { |
| 203 | return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); | 203 | return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); |
| 204 | }); | 204 | }); |
| 205 | 205 | ||
| 206 | // Compact, log-safe view of the fields the awareness phase ladder reads. | ||
| 207 | export function describeThreadShellForAwareness( | ||
| 208 | thread: Option.Option<OrchestrationThreadShell>, | ||
| 209 | ): Record<string, unknown> { | ||
| 210 | if (Option.isNone(thread)) { | ||
| 211 | return { found: false }; | ||
| 212 | } | ||
| 213 | const shell = thread.value; | ||
| 214 | return { | ||
| 215 | found: true, | ||
| 216 | sessionStatus: shell.session?.status ?? null, | ||
| 217 | sessionActiveTurnId: shell.session?.activeTurnId ?? null, | ||
| 218 | latestTurnId: shell.latestTurn?.turnId ?? null, | ||
| 219 | latestTurnState: shell.latestTurn?.state ?? null, | ||
| 220 | latestTurnCompletedAt: shell.latestTurn?.completedAt ?? null, | ||
| 221 | hasPendingApprovals: shell.hasPendingApprovals, | ||
| 222 | hasPendingUserInput: shell.hasPendingUserInput, | ||
| 223 | }; | ||
| 224 | } | ||
| 225 | |||
| 206 | export function resolveAgentAwarenessRelayPublishSnapshot(input: { | 226 | export function resolveAgentAwarenessRelayPublishSnapshot(input: { |
| 207 | readonly environmentId: EnvironmentId; | 227 | readonly environmentId: EnvironmentId; |
| 208 | readonly threadId: ThreadId; | 228 | readonly threadId: ThreadId; |
| ... | @@ -306,6 +326,14 @@ export const make = Effect.gen(function* () { | ... | @@ -306,6 +326,14 @@ export const make = Effect.gen(function* () { |
| 306 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), | 326 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), |
| 307 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 327 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 308 | 328 | ||
| 329 | // Deadlines for publishes that need confirmation (tombstones and | ||
| 330 | // first-state completions). The confirming publish is re-enqueued through | ||
| 331 | // the same drainable worker as every other publish, so a confirmed | ||
| 332 | // tombstone can never race an in-flight live update; a recovered state | ||
| 333 | // clears the deadline. Assigned after the worker exists. | ||
| 334 | const publishConfirmDeadlines = new Map<ThreadId, number>(); | ||
| 335 | let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void; | ||
| 336 | |||
| 309 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { | 337 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { |
| 310 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | 338 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( |
| 311 | Effect.orElseSucceed(() => false), | 339 | Effect.orElseSucceed(() => false), |
| ... | @@ -382,6 +410,11 @@ export const make = Effect.gen(function* () { | ... | @@ -382,6 +410,11 @@ export const make = Effect.gen(function* () { |
| 382 | const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); | 410 | const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); |
| 383 | const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); | 411 | const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); |
| 384 | if (publishedStateByThread.get(threadId) === publishIdentity) { | 412 | if (publishedStateByThread.get(threadId) === publishIdentity) { |
| 413 | // The projection is back at (or never left) the last published state, so | ||
| 414 | // any pending deferred confirmation is moot. Leaving the deadline in | ||
| 415 | // place would let a much later transient null find it already expired | ||
| 416 | // and publish a tombstone immediately, skipping the deferral window. | ||
| 417 | publishConfirmDeadlines.delete(threadId); | ||
| 385 | yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { | 418 | yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { |
| 386 | environmentId, | 419 | environmentId, |
| 387 | threadId, | 420 | threadId, |
| ... | @@ -390,6 +423,50 @@ export const make = Effect.gen(function* () { | ... | @@ -390,6 +423,50 @@ export const make = Effect.gen(function* () { |
| 390 | return; | 423 | return; |
| 391 | } | 424 | } |
| 392 | 425 | ||
| 426 | // Two projections need confirmation before publishing, because both can | ||
| 427 | // appear transiently while the projector is mid-write and publishing them | ||
| 428 | // immediately is destructive or noisy: | ||
| 429 | // - null (tombstone) while the previous published state was live: deletes | ||
| 430 | // the thread from every armed card mid-conversation. | ||
| 431 | // - completed as the thread's FIRST published state: sessions boot at | ||
| 432 | // "ready" before their first turn, which projects as completed for an | ||
| 433 | // instant and sends a spurious Done notification at thread birth. | ||
| 434 | // Defer, schedule a re-publish through the ordinary worker queue, and | ||
| 435 | // only publish if the projection still holds when it drains. | ||
| 436 | const requiresConfirmation = | ||
| 437 | (snapshot.state === null && | ||
| 438 | publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || | ||
| 439 | (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); | ||
| 440 | if (requiresConfirmation) { | ||
| 441 | const nowMs = (yield* DateTime.now).epochMilliseconds; | ||
| 442 | const deadline = publishConfirmDeadlines.get(threadId); | ||
| 443 | if (deadline === undefined) { | ||
| 444 | publishConfirmDeadlines.set(threadId, nowMs + 5_000); | ||
| 445 | yield* Effect.logInfo("agent activity publish deferred pending confirmation", { | ||
| 446 | environmentId, | ||
| 447 | threadId, | ||
| 448 | reason: snapshot.reason, | ||
| 449 | statePhase: snapshot.state?.phase ?? null, | ||
| 450 | shell: describeThreadShellForAwareness(thread), | ||
| 451 | }); | ||
| 452 | yield* schedulePublishConfirm(threadId); | ||
| 453 | return; | ||
| 454 | } | ||
| 455 | if (nowMs < deadline) { | ||
| 456 | return; | ||
| 457 | } | ||
| 458 | publishConfirmDeadlines.delete(threadId); | ||
| 459 | yield* Effect.logInfo("agent activity deferred publish confirmed", { | ||
| 460 | environmentId, | ||
| 461 | threadId, | ||
| 462 | reason: snapshot.reason, | ||
| 463 | statePhase: snapshot.state?.phase ?? null, | ||
| 464 | shell: describeThreadShellForAwareness(thread), | ||
| 465 | }); | ||
| 466 | } else { | ||
| 467 | publishConfirmDeadlines.delete(threadId); | ||
| 468 | } | ||
| 469 | |||
| 393 | if (snapshot.reason === "thread-not-found") { | 470 | if (snapshot.reason === "thread-not-found") { |
| 394 | yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { | 471 | yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { |
| 395 | environmentId, | 472 | environmentId, |
| ... | @@ -478,6 +555,19 @@ export const make = Effect.gen(function* () { | ... | @@ -478,6 +555,19 @@ export const make = Effect.gen(function* () { |
| 478 | 555 | ||
| 479 | const worker = yield* makeDrainableWorker(publishThread); | 556 | const worker = yield* makeDrainableWorker(publishThread); |
| 480 | 557 | ||
| 558 | schedulePublishConfirm = (threadId) => | ||
| 559 | Effect.forkDetach( | ||
| 560 | Effect.sleep("5 seconds").pipe( | ||
| 561 | Effect.andThen(worker.enqueue(threadId)), | ||
| 562 | Effect.catchCause((cause) => | ||
| 563 | Effect.logWarning("deferred agent activity confirmation failed", { | ||
| 564 | threadId, | ||
| 565 | cause: Cause.pretty(cause), | ||
| 566 | }), | ||
| 567 | ), | ||
| 568 | ), | ||
| 569 | ).pipe(Effect.asVoid); | ||
| 570 | |||
| 481 | const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( | 571 | const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( |
| 482 | function* () { | 572 | function* () { |
| 483 | const [relayConfig, publishEnabled] = yield* Effect.all([ | 573 | const [relayConfig, publishEnabled] = yield* Effect.all([ |
apps/server/src/server.test.ts+3-2| ... | @@ -1700,7 +1700,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1700,7 +1700,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1700 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1700 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1701 | ); | 1701 | ); |
| 1702 | 1702 | ||
| 1703 | it.effect("rejects managed cloud link proofs for manual endpoint providers", () => | 1703 | it.effect("rejects cloud link proofs for unsupported endpoint providers", () => |
| 1704 | Effect.gen(function* () { | 1704 | Effect.gen(function* () { |
| 1705 | yield* buildAppUnderTest(); | 1705 | yield* buildAppUnderTest(); |
| 1706 | 1706 | ||
| ... | @@ -1721,7 +1721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1721,7 +1721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1721 | wsBaseUrl: linkProofUrl | 1721 | wsBaseUrl: linkProofUrl |
| 1722 | .replace("http://", "ws://") | 1722 | .replace("http://", "ws://") |
| 1723 | .replace("/api/connect/link-proof", "/ws"), | 1723 | .replace("/api/connect/link-proof", "/ws"), |
| 1724 | providerKind: "manual", | 1724 | // "manual" and "cloudflare_tunnel" are supported; "t3_relay" is not. |
| 1725 | providerKind: "t3_relay", | ||
| 1725 | }, | 1726 | }, |
| 1726 | origin: { | 1727 | origin: { |
| 1727 | localHttpHost: "127.0.0.1", | 1728 | localHttpHost: "127.0.0.1", |
apps/web/src/cloud/linkEnvironment.test.ts+55| ... | @@ -204,6 +204,7 @@ describe("web cloud link environment client", () => { | ... | @@ -204,6 +204,7 @@ describe("web cloud link environment client", () => { |
| 204 | cloudUserId: "user-1", | 204 | cloudUserId: "user-1", |
| 205 | relayUrl: "https://relay.example.test", | 205 | relayUrl: "https://relay.example.test", |
| 206 | relayIssuer: "https://relay.example.test", | 206 | relayIssuer: "https://relay.example.test", |
| 207 | managedTunnelActive: true, | ||
| 207 | publishAgentActivity: false, | 208 | publishAgentActivity: false, |
| 208 | }), | 209 | }), |
| 209 | ); | 210 | ); |
| ... | @@ -217,6 +218,7 @@ describe("web cloud link environment client", () => { | ... | @@ -217,6 +218,7 @@ describe("web cloud link environment client", () => { |
| 217 | cloudUserId: "user-1", | 218 | cloudUserId: "user-1", |
| 218 | relayUrl: "https://relay.example.test", | 219 | relayUrl: "https://relay.example.test", |
| 219 | relayIssuer: "https://relay.example.test", | 220 | relayIssuer: "https://relay.example.test", |
| 221 | managedTunnelActive: true, | ||
| 220 | publishAgentActivity: false, | 222 | publishAgentActivity: false, |
| 221 | }), | 223 | }), |
| 222 | ); | 224 | ); |
| ... | @@ -234,6 +236,7 @@ describe("web cloud link environment client", () => { | ... | @@ -234,6 +236,7 @@ describe("web cloud link environment client", () => { |
| 234 | cloudUserId: "user-1", | 236 | cloudUserId: "user-1", |
| 235 | relayUrl: "https://relay.example.test", | 237 | relayUrl: "https://relay.example.test", |
| 236 | relayIssuer: "https://relay.example.test", | 238 | relayIssuer: "https://relay.example.test", |
| 239 | managedTunnelActive: true, | ||
| 237 | publishAgentActivity: false, | 240 | publishAgentActivity: false, |
| 238 | }), | 241 | }), |
| 239 | ); | 242 | ); |
| ... | @@ -261,6 +264,7 @@ describe("web cloud link environment client", () => { | ... | @@ -261,6 +264,7 @@ describe("web cloud link environment client", () => { |
| 261 | cloudUserId: "user-1", | 264 | cloudUserId: "user-1", |
| 262 | relayUrl: "https://relay.example.test", | 265 | relayUrl: "https://relay.example.test", |
| 263 | relayIssuer: "https://relay.example.test", | 266 | relayIssuer: "https://relay.example.test", |
| 267 | managedTunnelActive: true, | ||
| 264 | publishAgentActivity: true, | 268 | publishAgentActivity: true, |
| 265 | }), | 269 | }), |
| 266 | ); | 270 | ); |
| ... | @@ -339,6 +343,57 @@ describe("web cloud link environment client", () => { | ... | @@ -339,6 +343,57 @@ describe("web cloud link environment client", () => { |
| 339 | }), | 343 | }), |
| 340 | ); | 344 | ); |
| 341 | 345 | ||
| 346 | it.effect("links publish-only without a managed tunnel", () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const fetchMock = vi | ||
| 349 | .fn() | ||
| 350 | .mockResolvedValueOnce( | ||
| 351 | Response.json({ | ||
| 352 | challenge: "challenge", | ||
| 353 | expiresAt: "2026-06-06T00:05:00.000Z", | ||
| 354 | }), | ||
| 355 | ) | ||
| 356 | .mockResolvedValueOnce(Response.json("signed-proof")) | ||
| 357 | .mockResolvedValueOnce( | ||
| 358 | Response.json({ | ||
| 359 | ok: true, | ||
| 360 | environmentId: TARGET.environmentId, | ||
| 361 | endpoint: { | ||
| 362 | httpBaseUrl: TARGET.httpBaseUrl, | ||
| 363 | wsBaseUrl: TARGET.wsBaseUrl, | ||
| 364 | providerKind: "manual", | ||
| 365 | }, | ||
| 366 | endpointRuntime: null, | ||
| 367 | relayIssuer: "https://relay.example.test", | ||
| 368 | cloudUserId: "user-1", | ||
| 369 | environmentCredential: "environment-credential", | ||
| 370 | cloudMintPublicKey: "public-key", | ||
| 371 | }), | ||
| 372 | ) | ||
| 373 | .mockResolvedValueOnce( | ||
| 374 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | ||
| 375 | ); | ||
| 376 | vi.stubGlobal("fetch", fetchMock); | ||
| 377 | |||
| 378 | yield* withServices( | ||
| 379 | linkPrimaryEnvironmentToCloud({ | ||
| 380 | target: TARGET, | ||
| 381 | clerkToken: "clerk-token", | ||
| 382 | mode: "publish_only", | ||
| 383 | }), | ||
| 384 | ); | ||
| 385 | |||
| 386 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 387 | expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ | ||
| 388 | managedTunnelsEnabled: false, | ||
| 389 | }); | ||
| 390 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 391 | expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ | ||
| 392 | endpoint: { providerKind: "manual" }, | ||
| 393 | }); | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | |||
| 342 | it.effect("installs a missing relay client before linking", () => | 397 | it.effect("installs a missing relay client before linking", () => |
| 343 | Effect.gen(function* () { | 398 | Effect.gen(function* () { |
| 344 | vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); | 399 | vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); |
apps/web/src/cloud/linkEnvironment.ts+19-5| ... | @@ -383,9 +383,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { | ... | @@ -383,9 +383,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { |
| 383 | }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); | 383 | }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); |
| 384 | } | 384 | } |
| 385 | 385 | ||
| 386 | // "publish_only" links the environment to the relay for agent-activity | ||
| 387 | // publishing alone: no managed tunnel is provisioned, so it can be toggled | ||
| 388 | // independently of T3 Connect while clients reach the environment out of band. | ||
| 389 | export type CloudLinkMode = "managed" | "publish_only"; | ||
| 390 | |||
| 391 | const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; | ||
| 392 | |||
| 386 | export function linkPrimaryEnvironmentToCloud(input: { | 393 | export function linkPrimaryEnvironmentToCloud(input: { |
| 387 | readonly target: CloudLinkTarget; | 394 | readonly target: CloudLinkTarget; |
| 388 | readonly clerkToken: string; | 395 | readonly clerkToken: string; |
| 396 | readonly mode?: CloudLinkMode; | ||
| 389 | }): Effect.Effect< | 397 | }): Effect.Effect< |
| 390 | void, | 398 | void, |
| 391 | CloudEnvironmentLinkError, | 399 | CloudEnvironmentLinkError, |
| ... | @@ -398,9 +406,15 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -398,9 +406,15 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 398 | message: "T3CODE_RELAY_URL is not configured.", | 406 | message: "T3CODE_RELAY_URL is not configured.", |
| 399 | }); | 407 | }); |
| 400 | } | 408 | } |
| 409 | const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; | ||
| 410 | const providerKind = managedTunnelsEnabled | ||
| 411 | ? MANAGED_ENDPOINT_PROVIDER_KIND | ||
| 412 | : PUBLISH_ONLY_PROVIDER_KIND; | ||
| 401 | const relayClient = yield* ManagedRelay.ManagedRelayClient; | 413 | const relayClient = yield* ManagedRelay.ManagedRelayClient; |
| 402 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); | 414 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); |
| 403 | yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); | 415 | if (managedTunnelsEnabled) { |
| 416 | yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); | ||
| 417 | } | ||
| 404 | 418 | ||
| 405 | const challenge = yield* relayClient | 419 | const challenge = yield* relayClient |
| 406 | .createEnvironmentLinkChallenge({ | 420 | .createEnvironmentLinkChallenge({ |
| ... | @@ -408,7 +422,7 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -408,7 +422,7 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 408 | payload: { | 422 | payload: { |
| 409 | notificationsEnabled: true, | 423 | notificationsEnabled: true, |
| 410 | liveActivitiesEnabled: true, | 424 | liveActivitiesEnabled: true, |
| 411 | managedTunnelsEnabled: true, | 425 | managedTunnelsEnabled, |
| 412 | }, | 426 | }, |
| 413 | }) | 427 | }) |
| 414 | .pipe( | 428 | .pipe( |
| ... | @@ -427,7 +441,7 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -427,7 +441,7 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 427 | endpoint: { | 441 | endpoint: { |
| 428 | httpBaseUrl: input.target.httpBaseUrl, | 442 | httpBaseUrl: input.target.httpBaseUrl, |
| 429 | wsBaseUrl: input.target.wsBaseUrl, | 443 | wsBaseUrl: input.target.wsBaseUrl, |
| 430 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | 444 | providerKind, |
| 431 | }, | 445 | }, |
| 432 | origin: endpointOrigin(input.target.httpBaseUrl), | 446 | origin: endpointOrigin(input.target.httpBaseUrl), |
| 433 | }, | 447 | }, |
| ... | @@ -440,7 +454,7 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -440,7 +454,7 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 440 | proof, | 454 | proof, |
| 441 | notificationsEnabled: true, | 455 | notificationsEnabled: true, |
| 442 | liveActivitiesEnabled: true, | 456 | liveActivitiesEnabled: true, |
| 443 | managedTunnelsEnabled: true, | 457 | managedTunnelsEnabled, |
| 444 | }, | 458 | }, |
| 445 | }) | 459 | }) |
| 446 | .pipe( | 460 | .pipe( |
| ... | @@ -450,7 +464,7 @@ export function linkPrimaryEnvironmentToCloud(input: { | ... | @@ -450,7 +464,7 @@ export function linkPrimaryEnvironmentToCloud(input: { |
| 450 | ); | 464 | ); |
| 451 | yield* ensureLinkedEnvironmentMatches({ | 465 | yield* ensureLinkedEnvironmentMatches({ |
| 452 | expectedEnvironmentId: input.target.environmentId, | 466 | expectedEnvironmentId: input.target.environmentId, |
| 453 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | 467 | expectedProviderKind: providerKind, |
| 454 | link, | 468 | link, |
| 455 | }); | 469 | }); |
| 456 | 470 |
apps/web/src/cloud/linkEnvironmentAtoms.ts+6-2| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | import { connectionAtomRuntime } from "../connection/runtime"; | 6 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 7 | import { | 7 | import { |
| 8 | linkPrimaryEnvironmentToCloud, | 8 | linkPrimaryEnvironmentToCloud, |
| 9 | type CloudLinkMode, | ||
| 9 | type CloudLinkTarget, | 10 | type CloudLinkTarget, |
| 10 | unlinkPrimaryEnvironmentFromCloud, | 11 | unlinkPrimaryEnvironmentFromCloud, |
| 11 | updatePrimaryCloudPreferences, | 12 | updatePrimaryCloudPreferences, |
| ... | @@ -21,8 +22,11 @@ export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime | ... | @@ -21,8 +22,11 @@ export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime |
| 21 | label: "web:cloud:link-primary-environment", | 22 | label: "web:cloud:link-primary-environment", |
| 22 | scheduler: cloudLinkScheduler, | 23 | scheduler: cloudLinkScheduler, |
| 23 | concurrency: cloudLinkConcurrency, | 24 | concurrency: cloudLinkConcurrency, |
| 24 | execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) => | 25 | execute: (input: { |
| 25 | linkPrimaryEnvironmentToCloud(input), | 26 | readonly target: CloudLinkTarget; |
| 27 | readonly clerkToken: string; | ||
| 28 | readonly mode?: CloudLinkMode; | ||
| 29 | }) => linkPrimaryEnvironmentToCloud(input), | ||
| 26 | }); | 30 | }); |
| 27 | 31 | ||
| 28 | export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { | 32 | export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { |
apps/web/src/components/settings/ConnectionsSettings.tsx+129-104| ... | @@ -1602,15 +1602,17 @@ function CloudLinkSwitch({ | ... | @@ -1602,15 +1602,17 @@ function CloudLinkSwitch({ |
| 1602 | disabled, | 1602 | disabled, |
| 1603 | disabledReason, | 1603 | disabledReason, |
| 1604 | onCheckedChange, | 1604 | onCheckedChange, |
| 1605 | ariaLabel = "Enable T3 Connect", | ||
| 1605 | }: { | 1606 | }: { |
| 1606 | readonly checked: boolean; | 1607 | readonly checked: boolean; |
| 1607 | readonly disabled: boolean; | 1608 | readonly disabled: boolean; |
| 1608 | readonly disabledReason: string | null; | 1609 | readonly disabledReason: string | null; |
| 1609 | readonly onCheckedChange?: (enabled: boolean) => void; | 1610 | readonly onCheckedChange?: (enabled: boolean) => void; |
| 1611 | readonly ariaLabel?: string; | ||
| 1610 | }) { | 1612 | }) { |
| 1611 | const control = ( | 1613 | const control = ( |
| 1612 | <Switch | 1614 | <Switch |
| 1613 | aria-label="Enable T3 Connect" | 1615 | aria-label={ariaLabel} |
| 1614 | checked={checked} | 1616 | checked={checked} |
| 1615 | disabled={disabled} | 1617 | disabled={disabled} |
| 1616 | {...(onCheckedChange ? { onCheckedChange } : {})} | 1618 | {...(onCheckedChange ? { onCheckedChange } : {})} |
| ... | @@ -1666,146 +1668,169 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1666,146 +1668,169 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1666 | }); | 1668 | }); |
| 1667 | }; | 1669 | }; |
| 1668 | 1670 | ||
| 1669 | const updateLink = async (enabled: boolean) => { | 1671 | // Older environment servers predate the managedTunnelActive field; for them a |
| 1670 | setIsUpdating(true); | 1672 | // link always implies a managed tunnel, so fall back to `linked`. |
| 1673 | const managedTunnelActive = | ||
| 1674 | primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; | ||
| 1675 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; | ||
| 1676 | const linked = primaryCloudLinkState.data?.linked ?? false; | ||
| 1677 | const disabledReason = !isSignedIn | ||
| 1678 | ? "Sign in to T3 Connect to manage this environment." | ||
| 1679 | : !canManageRelay | ||
| 1680 | ? "Your session does not have permission to manage T3 Connect access." | ||
| 1681 | : null; | ||
| 1682 | const isBusy = isUpdating || isUpdatingPreference; | ||
| 1683 | |||
| 1684 | // T3 Connect (managed tunnel) and publishing are independent capabilities | ||
| 1685 | // backed by a single relay link. Reconcile the whole desired state: unlink | ||
| 1686 | // when neither is wanted, otherwise (re)link with the mode the managed-tunnel | ||
| 1687 | // bit implies and set the publish preference. Re-linking only happens when the | ||
| 1688 | // managed-tunnel mode actually changes, so flipping publish alone is cheap. | ||
| 1689 | const reconcileCloudState = async (desired: { | ||
| 1690 | readonly managedTunnel: boolean; | ||
| 1691 | readonly publish: boolean; | ||
| 1692 | }): Promise<boolean> => { | ||
| 1671 | setOperationError(null); | 1693 | setOperationError(null); |
| 1672 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | ||
| 1673 | if (tokenResult._tag === "Failure") { | ||
| 1674 | reportUpdateFailure(squashAtomCommandFailure(tokenResult)); | ||
| 1675 | setIsUpdating(false); | ||
| 1676 | return; | ||
| 1677 | } | ||
| 1678 | |||
| 1679 | const target = primaryCloudLinkState.target; | 1694 | const target = primaryCloudLinkState.target; |
| 1680 | if (!target) { | 1695 | if (!target) { |
| 1681 | reportUpdateFailure(new Error("Local environment is not ready yet.")); | 1696 | reportUpdateFailure(new Error("Local environment is not ready yet.")); |
| 1682 | setIsUpdating(false); | 1697 | return false; |
| 1683 | return; | ||
| 1684 | } | 1698 | } |
| 1685 | if (enabled && !tokenResult.value) { | 1699 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); |
| 1686 | reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); | 1700 | if (tokenResult._tag === "Failure") { |
| 1687 | setIsUpdating(false); | 1701 | reportUpdateFailure(squashAtomCommandFailure(tokenResult)); |
| 1688 | return; | 1702 | return false; |
| 1689 | } | 1703 | } |
| 1690 | 1704 | const clerkToken = tokenResult.value; | |
| 1691 | const linkResult = | 1705 | const wantsLink = desired.managedTunnel || desired.publish; |
| 1692 | enabled && tokenResult.value | 1706 | |
| 1693 | ? await linkPrimaryEnvironment({ | 1707 | // A failure after this point may follow a partially applied mutation (e.g. |
| 1694 | target, | 1708 | // the link succeeded but the preference update did not), so every exit — |
| 1695 | clerkToken: tokenResult.value, | 1709 | // success or failure — refreshes the rendered state to whatever the server |
| 1696 | }) | 1710 | // actually holds now. |
| 1697 | : await unlinkPrimaryEnvironment({ | 1711 | if (!wantsLink) { |
| 1698 | target, | 1712 | const unlinkResult = await unlinkPrimaryEnvironment({ |
| 1699 | clerkToken: tokenResult.value ?? null, | 1713 | target, |
| 1700 | }); | 1714 | clerkToken: clerkToken ?? null, |
| 1701 | if (linkResult._tag === "Failure") { | 1715 | }); |
| 1702 | if (!isAtomCommandInterrupted(linkResult)) { | 1716 | if (unlinkResult._tag === "Failure") { |
| 1703 | reportUpdateFailure(squashAtomCommandFailure(linkResult)); | 1717 | if (!isAtomCommandInterrupted(unlinkResult)) { |
| 1718 | reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); | ||
| 1719 | } | ||
| 1720 | primaryCloudLinkState.refresh(); | ||
| 1721 | return false; | ||
| 1722 | } | ||
| 1723 | } else { | ||
| 1724 | if (!clerkToken) { | ||
| 1725 | reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); | ||
| 1726 | return false; | ||
| 1727 | } | ||
| 1728 | if (!linked || managedTunnelActive !== desired.managedTunnel) { | ||
| 1729 | const linkResult = await linkPrimaryEnvironment({ | ||
| 1730 | target, | ||
| 1731 | clerkToken, | ||
| 1732 | mode: desired.managedTunnel ? "managed" : "publish_only", | ||
| 1733 | }); | ||
| 1734 | if (linkResult._tag === "Failure") { | ||
| 1735 | if (!isAtomCommandInterrupted(linkResult)) { | ||
| 1736 | reportUpdateFailure(squashAtomCommandFailure(linkResult)); | ||
| 1737 | } | ||
| 1738 | primaryCloudLinkState.refresh(); | ||
| 1739 | return false; | ||
| 1740 | } | ||
| 1741 | } | ||
| 1742 | const prefResult = await updatePrimaryEnvironmentPreferences({ | ||
| 1743 | target, | ||
| 1744 | publishAgentActivity: desired.publish, | ||
| 1745 | }); | ||
| 1746 | if (prefResult._tag === "Failure") { | ||
| 1747 | if (!isAtomCommandInterrupted(prefResult)) { | ||
| 1748 | reportUpdateFailure(squashAtomCommandFailure(prefResult)); | ||
| 1749 | } | ||
| 1750 | primaryCloudLinkState.refresh(); | ||
| 1751 | return false; | ||
| 1704 | } | 1752 | } |
| 1705 | setIsUpdating(false); | ||
| 1706 | return; | ||
| 1707 | } | 1753 | } |
| 1708 | 1754 | ||
| 1709 | primaryCloudLinkState.refresh(); | 1755 | primaryCloudLinkState.refresh(); |
| 1710 | const refreshResult = await refreshRelayEnvironments(); | 1756 | const refreshResult = await refreshRelayEnvironments(); |
| 1711 | if (refreshResult._tag === "Failure") { | 1757 | if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { |
| 1712 | if (!isAtomCommandInterrupted(refreshResult)) { | 1758 | reportUpdateFailure(squashAtomCommandFailure(refreshResult)); |
| 1713 | reportUpdateFailure(squashAtomCommandFailure(refreshResult)); | 1759 | return false; |
| 1714 | } | ||
| 1715 | setIsUpdating(false); | ||
| 1716 | return; | ||
| 1717 | } | 1760 | } |
| 1761 | return true; | ||
| 1762 | }; | ||
| 1718 | 1763 | ||
| 1719 | toastManager.add({ | 1764 | const updateManagedTunnel = async (enabled: boolean) => { |
| 1720 | type: "success", | 1765 | setIsUpdating(true); |
| 1721 | title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", | 1766 | const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); |
| 1722 | description: enabled | 1767 | if (ok) { |
| 1723 | ? "This environment is available through T3 Connect." | 1768 | // Turning the tunnel off while publishing stays on downgrades the link |
| 1724 | : "This environment is no longer available through T3 Connect.", | 1769 | // rather than removing it — say so instead of claiming an unlink. |
| 1725 | }); | 1770 | toastManager.add({ |
| 1771 | type: "success", | ||
| 1772 | title: enabled | ||
| 1773 | ? "T3 Connect linked" | ||
| 1774 | : publishAgentActivity | ||
| 1775 | ? "T3 Connect tunnel disabled" | ||
| 1776 | : "T3 Connect unlinked", | ||
| 1777 | description: enabled | ||
| 1778 | ? "This environment is available through T3 Connect." | ||
| 1779 | : publishAgentActivity | ||
| 1780 | ? "The managed tunnel was removed. Agent activity publishing stays on." | ||
| 1781 | : "This environment is no longer available through T3 Connect.", | ||
| 1782 | }); | ||
| 1783 | } | ||
| 1726 | setIsUpdating(false); | 1784 | setIsUpdating(false); |
| 1727 | }; | 1785 | }; |
| 1728 | 1786 | ||
| 1729 | const updatePublishAgentActivity = async (enabled: boolean) => { | 1787 | const updatePublishAgentActivity = async (enabled: boolean) => { |
| 1730 | const target = primaryCloudLinkState.target; | ||
| 1731 | if (!target) { | ||
| 1732 | reportUpdateFailure(new Error("Local environment is not ready yet.")); | ||
| 1733 | return; | ||
| 1734 | } | ||
| 1735 | |||
| 1736 | setIsUpdatingPreference(true); | 1788 | setIsUpdatingPreference(true); |
| 1737 | setOperationError(null); | 1789 | const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled }); |
| 1738 | const updateResult = await updatePrimaryEnvironmentPreferences({ | 1790 | if (ok) { |
| 1739 | target, | 1791 | toastManager.add({ |
| 1740 | publishAgentActivity: enabled, | 1792 | type: "success", |
| 1741 | }); | 1793 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", |
| 1742 | if (updateResult._tag === "Failure") { | 1794 | description: enabled |
| 1743 | if (!isAtomCommandInterrupted(updateResult)) { | 1795 | ? "This environment publishes agent activity to your mobile clients." |
| 1744 | reportUpdateFailure(squashAtomCommandFailure(updateResult)); | 1796 | : "This environment will stop publishing agent activity.", |
| 1745 | } | 1797 | }); |
| 1746 | setIsUpdatingPreference(false); | ||
| 1747 | return; | ||
| 1748 | } | 1798 | } |
| 1749 | |||
| 1750 | primaryCloudLinkState.refresh(); | ||
| 1751 | toastManager.add({ | ||
| 1752 | type: "success", | ||
| 1753 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", | ||
| 1754 | description: enabled | ||
| 1755 | ? "This environment can publish agent activity to your mobile clients." | ||
| 1756 | : "This environment will stop publishing agent activity.", | ||
| 1757 | }); | ||
| 1758 | setIsUpdatingPreference(false); | 1799 | setIsUpdatingPreference(false); |
| 1759 | }; | 1800 | }; |
| 1760 | const disabledReason = !isSignedIn | ||
| 1761 | ? "Sign in to T3 Connect to manage this environment." | ||
| 1762 | : !canManageRelay | ||
| 1763 | ? "Your session does not have permission to manage T3 Connect access." | ||
| 1764 | : null; | ||
| 1765 | const linked = primaryCloudLinkState.data?.linked ?? false; | ||
| 1766 | 1801 | ||
| 1767 | return ( | 1802 | return ( |
| 1768 | <> | 1803 | <> |
| 1769 | <SettingsRow | 1804 | <SettingsRow |
| 1770 | title="T3 Connect" | 1805 | title="T3 Connect" |
| 1771 | description={ | 1806 | description={ |
| 1772 | linked | 1807 | managedTunnelActive |
| 1773 | ? "This environment is available to your other devices through T3 Connect." | 1808 | ? "This environment is available to your other devices through T3 Connect." |
| 1774 | : "Make this environment available to your other devices through T3 Connect." | 1809 | : "Make this environment available to your other devices through T3 Connect." |
| 1775 | } | 1810 | } |
| 1776 | status={operationError ?? primaryCloudLinkState.error} | 1811 | status={operationError ?? primaryCloudLinkState.error} |
| 1777 | control={ | 1812 | control={ |
| 1778 | <CloudLinkSwitch | 1813 | <CloudLinkSwitch |
| 1779 | checked={linked} | 1814 | checked={managedTunnelActive} |
| 1780 | disabled={ | 1815 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy} |
| 1781 | !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating | ||
| 1782 | } | ||
| 1783 | disabledReason={disabledReason} | 1816 | disabledReason={disabledReason} |
| 1784 | onCheckedChange={(enabled) => void updateLink(enabled)} | 1817 | onCheckedChange={(enabled) => void updateManagedTunnel(enabled)} |
| 1818 | /> | ||
| 1819 | } | ||
| 1820 | /> | ||
| 1821 | <SettingsRow | ||
| 1822 | title="Publish agent activity" | ||
| 1823 | description="Send activity from this environment to your mobile clients for push notifications and Live Activities. Works without a T3 Connect tunnel." | ||
| 1824 | control={ | ||
| 1825 | <CloudLinkSwitch | ||
| 1826 | ariaLabel="Publish agent activity to mobile clients" | ||
| 1827 | checked={publishAgentActivity} | ||
| 1828 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy} | ||
| 1829 | disabledReason={disabledReason} | ||
| 1830 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} | ||
| 1785 | /> | 1831 | /> |
| 1786 | } | 1832 | } |
| 1787 | /> | 1833 | /> |
| 1788 | {linked ? ( | ||
| 1789 | <SettingsRow | ||
| 1790 | title="Publish agent activity" | ||
| 1791 | description="Send activity from this environment to your mobile clients for push notifications and Live Activities." | ||
| 1792 | className="bg-muted/20 pl-7 sm:pl-8" | ||
| 1793 | control={ | ||
| 1794 | <Switch | ||
| 1795 | aria-label="Publish agent activity to mobile clients" | ||
| 1796 | checked={primaryCloudLinkState.data?.publishAgentActivity ?? false} | ||
| 1797 | disabled={ | ||
| 1798 | !canManageRelay || | ||
| 1799 | !isSignedIn || | ||
| 1800 | primaryCloudLinkState.isPending || | ||
| 1801 | isUpdating || | ||
| 1802 | isUpdatingPreference | ||
| 1803 | } | ||
| 1804 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} | ||
| 1805 | /> | ||
| 1806 | } | ||
| 1807 | /> | ||
| 1808 | ) : null} | ||
| 1809 | </> | 1834 | </> |
| 1810 | ); | 1835 | ); |
| 1811 | } | 1836 | } |
infra/relay/migrations/postgres/20260706061126_migration/migration.sql created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255); | ||
| 2 | --> statement-breakpoint | ||
| 3 | ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16); | ||
infra/relay/migrations/postgres/20260706061126_migration/snapshot.json created+1479| ... | @@ -0,0 +1,1479 @@ | ||
| 1 | { | ||
| 2 | "dialect": "postgres", | ||
| 3 | "id": "f4151a65-091a-4601-bde8-029b155255b0", | ||
| 4 | "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"], | ||
| 5 | "version": "8", | ||
| 6 | "ddl": [ | ||
| 7 | { | ||
| 8 | "isRlsEnabled": false, | ||
| 9 | "name": "relay_agent_activity_rows", | ||
| 10 | "entityType": "tables", | ||
| 11 | "schema": "public" | ||
| 12 | }, | ||
| 13 | { | ||
| 14 | "isRlsEnabled": false, | ||
| 15 | "name": "relay_delivery_attempts", | ||
| 16 | "entityType": "tables", | ||
| 17 | "schema": "public" | ||
| 18 | }, | ||
| 19 | { | ||
| 20 | "isRlsEnabled": false, | ||
| 21 | "name": "relay_dpop_proofs", | ||
| 22 | "entityType": "tables", | ||
| 23 | "schema": "public" | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "isRlsEnabled": false, | ||
| 27 | "name": "relay_environment_credentials", | ||
| 28 | "entityType": "tables", | ||
| 29 | "schema": "public" | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | "isRlsEnabled": false, | ||
| 33 | "name": "relay_environment_links", | ||
| 34 | "entityType": "tables", | ||
| 35 | "schema": "public" | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | "isRlsEnabled": false, | ||
| 39 | "name": "relay_live_activities", | ||
| 40 | "entityType": "tables", | ||
| 41 | "schema": "public" | ||
| 42 | }, | ||
| 43 | { | ||
| 44 | "isRlsEnabled": false, | ||
| 45 | "name": "relay_managed_endpoint_allocations", | ||
| 46 | "entityType": "tables", | ||
| 47 | "schema": "public" | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | "isRlsEnabled": false, | ||
| 51 | "name": "relay_mobile_devices", | ||
| 52 | "entityType": "tables", | ||
| 53 | "schema": "public" | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | "type": "varchar(191)", | ||
| 57 | "typeSchema": null, | ||
| 58 | "notNull": true, | ||
| 59 | "dimensions": 0, | ||
| 60 | "default": null, | ||
| 61 | "generated": null, | ||
| 62 | "identity": null, | ||
| 63 | "name": "environment_id", | ||
| 64 | "entityType": "columns", | ||
| 65 | "schema": "public", | ||
| 66 | "table": "relay_agent_activity_rows" | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | "type": "text", | ||
| 70 | "typeSchema": null, | ||
| 71 | "notNull": true, | ||
| 72 | "dimensions": 0, | ||
| 73 | "default": null, | ||
| 74 | "generated": null, | ||
| 75 | "identity": null, | ||
| 76 | "name": "environment_public_key", | ||
| 77 | "entityType": "columns", | ||
| 78 | "schema": "public", | ||
| 79 | "table": "relay_agent_activity_rows" | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | "type": "varchar(191)", | ||
| 83 | "typeSchema": null, | ||
| 84 | "notNull": true, | ||
| 85 | "dimensions": 0, | ||
| 86 | "default": null, | ||
| 87 | "generated": null, | ||
| 88 | "identity": null, | ||
| 89 | "name": "thread_id", | ||
| 90 | "entityType": "columns", | ||
| 91 | "schema": "public", | ||
| 92 | "table": "relay_agent_activity_rows" | ||
| 93 | }, | ||
| 94 | { | ||
| 95 | "type": "jsonb", | ||
| 96 | "typeSchema": null, | ||
| 97 | "notNull": true, | ||
| 98 | "dimensions": 0, | ||
| 99 | "default": null, | ||
| 100 | "generated": null, | ||
| 101 | "identity": null, | ||
| 102 | "name": "state_json", | ||
| 103 | "entityType": "columns", | ||
| 104 | "schema": "public", | ||
| 105 | "table": "relay_agent_activity_rows" | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | "type": "varchar(64)", | ||
| 109 | "typeSchema": null, | ||
| 110 | "notNull": true, | ||
| 111 | "dimensions": 0, | ||
| 112 | "default": null, | ||
| 113 | "generated": null, | ||
| 114 | "identity": null, | ||
| 115 | "name": "updated_at", | ||
| 116 | "entityType": "columns", | ||
| 117 | "schema": "public", | ||
| 118 | "table": "relay_agent_activity_rows" | ||
| 119 | }, | ||
| 120 | { | ||
| 121 | "type": "varchar(64)", | ||
| 122 | "typeSchema": null, | ||
| 123 | "notNull": true, | ||
| 124 | "dimensions": 0, | ||
| 125 | "default": null, | ||
| 126 | "generated": null, | ||
| 127 | "identity": null, | ||
| 128 | "name": "created_at", | ||
| 129 | "entityType": "columns", | ||
| 130 | "schema": "public", | ||
| 131 | "table": "relay_agent_activity_rows" | ||
| 132 | }, | ||
| 133 | { | ||
| 134 | "type": "varchar(36)", | ||
| 135 | "typeSchema": null, | ||
| 136 | "notNull": true, | ||
| 137 | "dimensions": 0, | ||
| 138 | "default": null, | ||
| 139 | "generated": null, | ||
| 140 | "identity": null, | ||
| 141 | "name": "id", | ||
| 142 | "entityType": "columns", | ||
| 143 | "schema": "public", | ||
| 144 | "table": "relay_delivery_attempts" | ||
| 145 | }, | ||
| 146 | { | ||
| 147 | "type": "varchar(64)", | ||
| 148 | "typeSchema": null, | ||
| 149 | "notNull": true, | ||
| 150 | "dimensions": 0, | ||
| 151 | "default": null, | ||
| 152 | "generated": null, | ||
| 153 | "identity": null, | ||
| 154 | "name": "created_at", | ||
| 155 | "entityType": "columns", | ||
| 156 | "schema": "public", | ||
| 157 | "table": "relay_delivery_attempts" | ||
| 158 | }, | ||
| 159 | { | ||
| 160 | "type": "varchar(255)", | ||
| 161 | "typeSchema": null, | ||
| 162 | "notNull": false, | ||
| 163 | "dimensions": 0, | ||
| 164 | "default": null, | ||
| 165 | "generated": null, | ||
| 166 | "identity": null, | ||
| 167 | "name": "user_id", | ||
| 168 | "entityType": "columns", | ||
| 169 | "schema": "public", | ||
| 170 | "table": "relay_delivery_attempts" | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | "type": "varchar(191)", | ||
| 174 | "typeSchema": null, | ||
| 175 | "notNull": false, | ||
| 176 | "dimensions": 0, | ||
| 177 | "default": null, | ||
| 178 | "generated": null, | ||
| 179 | "identity": null, | ||
| 180 | "name": "environment_id", | ||
| 181 | "entityType": "columns", | ||
| 182 | "schema": "public", | ||
| 183 | "table": "relay_delivery_attempts" | ||
| 184 | }, | ||
| 185 | { | ||
| 186 | "type": "varchar(191)", | ||
| 187 | "typeSchema": null, | ||
| 188 | "notNull": false, | ||
| 189 | "dimensions": 0, | ||
| 190 | "default": null, | ||
| 191 | "generated": null, | ||
| 192 | "identity": null, | ||
| 193 | "name": "thread_id", | ||
| 194 | "entityType": "columns", | ||
| 195 | "schema": "public", | ||
| 196 | "table": "relay_delivery_attempts" | ||
| 197 | }, | ||
| 198 | { | ||
| 199 | "type": "varchar(255)", | ||
| 200 | "typeSchema": null, | ||
| 201 | "notNull": false, | ||
| 202 | "dimensions": 0, | ||
| 203 | "default": null, | ||
| 204 | "generated": null, | ||
| 205 | "identity": null, | ||
| 206 | "name": "device_id", | ||
| 207 | "entityType": "columns", | ||
| 208 | "schema": "public", | ||
| 209 | "table": "relay_delivery_attempts" | ||
| 210 | }, | ||
| 211 | { | ||
| 212 | "type": "varchar(64)", | ||
| 213 | "typeSchema": null, | ||
| 214 | "notNull": true, | ||
| 215 | "dimensions": 0, | ||
| 216 | "default": null, | ||
| 217 | "generated": null, | ||
| 218 | "identity": null, | ||
| 219 | "name": "kind", | ||
| 220 | "entityType": "columns", | ||
| 221 | "schema": "public", | ||
| 222 | "table": "relay_delivery_attempts" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "type": "varchar(64)", | ||
| 226 | "typeSchema": null, | ||
| 227 | "notNull": false, | ||
| 228 | "dimensions": 0, | ||
| 229 | "default": null, | ||
| 230 | "generated": null, | ||
| 231 | "identity": null, | ||
| 232 | "name": "source_job_id", | ||
| 233 | "entityType": "columns", | ||
| 234 | "schema": "public", | ||
| 235 | "table": "relay_delivery_attempts" | ||
| 236 | }, | ||
| 237 | { | ||
| 238 | "type": "varchar(16)", | ||
| 239 | "typeSchema": null, | ||
| 240 | "notNull": false, | ||
| 241 | "dimensions": 0, | ||
| 242 | "default": null, | ||
| 243 | "generated": null, | ||
| 244 | "identity": null, | ||
| 245 | "name": "token_suffix", | ||
| 246 | "entityType": "columns", | ||
| 247 | "schema": "public", | ||
| 248 | "table": "relay_delivery_attempts" | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | "type": "integer", | ||
| 252 | "typeSchema": null, | ||
| 253 | "notNull": false, | ||
| 254 | "dimensions": 0, | ||
| 255 | "default": null, | ||
| 256 | "generated": null, | ||
| 257 | "identity": null, | ||
| 258 | "name": "apns_status", | ||
| 259 | "entityType": "columns", | ||
| 260 | "schema": "public", | ||
| 261 | "table": "relay_delivery_attempts" | ||
| 262 | }, | ||
| 263 | { | ||
| 264 | "type": "text", | ||
| 265 | "typeSchema": null, | ||
| 266 | "notNull": false, | ||
| 267 | "dimensions": 0, | ||
| 268 | "default": null, | ||
| 269 | "generated": null, | ||
| 270 | "identity": null, | ||
| 271 | "name": "apns_reason", | ||
| 272 | "entityType": "columns", | ||
| 273 | "schema": "public", | ||
| 274 | "table": "relay_delivery_attempts" | ||
| 275 | }, | ||
| 276 | { | ||
| 277 | "type": "varchar(128)", | ||
| 278 | "typeSchema": null, | ||
| 279 | "notNull": false, | ||
| 280 | "dimensions": 0, | ||
| 281 | "default": null, | ||
| 282 | "generated": null, | ||
| 283 | "identity": null, | ||
| 284 | "name": "apns_id", | ||
| 285 | "entityType": "columns", | ||
| 286 | "schema": "public", | ||
| 287 | "table": "relay_delivery_attempts" | ||
| 288 | }, | ||
| 289 | { | ||
| 290 | "type": "text", | ||
| 291 | "typeSchema": null, | ||
| 292 | "notNull": false, | ||
| 293 | "dimensions": 0, | ||
| 294 | "default": null, | ||
| 295 | "generated": null, | ||
| 296 | "identity": null, | ||
| 297 | "name": "transport_error", | ||
| 298 | "entityType": "columns", | ||
| 299 | "schema": "public", | ||
| 300 | "table": "relay_delivery_attempts" | ||
| 301 | }, | ||
| 302 | { | ||
| 303 | "type": "varchar(128)", | ||
| 304 | "typeSchema": null, | ||
| 305 | "notNull": true, | ||
| 306 | "dimensions": 0, | ||
| 307 | "default": null, | ||
| 308 | "generated": null, | ||
| 309 | "identity": null, | ||
| 310 | "name": "thumbprint", | ||
| 311 | "entityType": "columns", | ||
| 312 | "schema": "public", | ||
| 313 | "table": "relay_dpop_proofs" | ||
| 314 | }, | ||
| 315 | { | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "typeSchema": null, | ||
| 318 | "notNull": true, | ||
| 319 | "dimensions": 0, | ||
| 320 | "default": null, | ||
| 321 | "generated": null, | ||
| 322 | "identity": null, | ||
| 323 | "name": "jti", | ||
| 324 | "entityType": "columns", | ||
| 325 | "schema": "public", | ||
| 326 | "table": "relay_dpop_proofs" | ||
| 327 | }, | ||
| 328 | { | ||
| 329 | "type": "integer", | ||
| 330 | "typeSchema": null, | ||
| 331 | "notNull": true, | ||
| 332 | "dimensions": 0, | ||
| 333 | "default": null, | ||
| 334 | "generated": null, | ||
| 335 | "identity": null, | ||
| 336 | "name": "iat", | ||
| 337 | "entityType": "columns", | ||
| 338 | "schema": "public", | ||
| 339 | "table": "relay_dpop_proofs" | ||
| 340 | }, | ||
| 341 | { | ||
| 342 | "type": "varchar(64)", | ||
| 343 | "typeSchema": null, | ||
| 344 | "notNull": true, | ||
| 345 | "dimensions": 0, | ||
| 346 | "default": null, | ||
| 347 | "generated": null, | ||
| 348 | "identity": null, | ||
| 349 | "name": "expires_at", | ||
| 350 | "entityType": "columns", | ||
| 351 | "schema": "public", | ||
| 352 | "table": "relay_dpop_proofs" | ||
| 353 | }, | ||
| 354 | { | ||
| 355 | "type": "varchar(64)", | ||
| 356 | "typeSchema": null, | ||
| 357 | "notNull": true, | ||
| 358 | "dimensions": 0, | ||
| 359 | "default": null, | ||
| 360 | "generated": null, | ||
| 361 | "identity": null, | ||
| 362 | "name": "created_at", | ||
| 363 | "entityType": "columns", | ||
| 364 | "schema": "public", | ||
| 365 | "table": "relay_dpop_proofs" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "type": "varchar(64)", | ||
| 369 | "typeSchema": null, | ||
| 370 | "notNull": true, | ||
| 371 | "dimensions": 0, | ||
| 372 | "default": null, | ||
| 373 | "generated": null, | ||
| 374 | "identity": null, | ||
| 375 | "name": "credential_id", | ||
| 376 | "entityType": "columns", | ||
| 377 | "schema": "public", | ||
| 378 | "table": "relay_environment_credentials" | ||
| 379 | }, | ||
| 380 | { | ||
| 381 | "type": "varchar(191)", | ||
| 382 | "typeSchema": null, | ||
| 383 | "notNull": true, | ||
| 384 | "dimensions": 0, | ||
| 385 | "default": null, | ||
| 386 | "generated": null, | ||
| 387 | "identity": null, | ||
| 388 | "name": "environment_id", | ||
| 389 | "entityType": "columns", | ||
| 390 | "schema": "public", | ||
| 391 | "table": "relay_environment_credentials" | ||
| 392 | }, | ||
| 393 | { | ||
| 394 | "type": "text", | ||
| 395 | "typeSchema": null, | ||
| 396 | "notNull": true, | ||
| 397 | "dimensions": 0, | ||
| 398 | "default": null, | ||
| 399 | "generated": null, | ||
| 400 | "identity": null, | ||
| 401 | "name": "environment_public_key", | ||
| 402 | "entityType": "columns", | ||
| 403 | "schema": "public", | ||
| 404 | "table": "relay_environment_credentials" | ||
| 405 | }, | ||
| 406 | { | ||
| 407 | "type": "varchar(191)", | ||
| 408 | "typeSchema": null, | ||
| 409 | "notNull": true, | ||
| 410 | "dimensions": 0, | ||
| 411 | "default": null, | ||
| 412 | "generated": null, | ||
| 413 | "identity": null, | ||
| 414 | "name": "credential_hash", | ||
| 415 | "entityType": "columns", | ||
| 416 | "schema": "public", | ||
| 417 | "table": "relay_environment_credentials" | ||
| 418 | }, | ||
| 419 | { | ||
| 420 | "type": "varchar(64)", | ||
| 421 | "typeSchema": null, | ||
| 422 | "notNull": false, | ||
| 423 | "dimensions": 0, | ||
| 424 | "default": null, | ||
| 425 | "generated": null, | ||
| 426 | "identity": null, | ||
| 427 | "name": "revoked_at", | ||
| 428 | "entityType": "columns", | ||
| 429 | "schema": "public", | ||
| 430 | "table": "relay_environment_credentials" | ||
| 431 | }, | ||
| 432 | { | ||
| 433 | "type": "varchar(64)", | ||
| 434 | "typeSchema": null, | ||
| 435 | "notNull": true, | ||
| 436 | "dimensions": 0, | ||
| 437 | "default": null, | ||
| 438 | "generated": null, | ||
| 439 | "identity": null, | ||
| 440 | "name": "created_at", | ||
| 441 | "entityType": "columns", | ||
| 442 | "schema": "public", | ||
| 443 | "table": "relay_environment_credentials" | ||
| 444 | }, | ||
| 445 | { | ||
| 446 | "type": "varchar(64)", | ||
| 447 | "typeSchema": null, | ||
| 448 | "notNull": true, | ||
| 449 | "dimensions": 0, | ||
| 450 | "default": null, | ||
| 451 | "generated": null, | ||
| 452 | "identity": null, | ||
| 453 | "name": "updated_at", | ||
| 454 | "entityType": "columns", | ||
| 455 | "schema": "public", | ||
| 456 | "table": "relay_environment_credentials" | ||
| 457 | }, | ||
| 458 | { | ||
| 459 | "type": "varchar(191)", | ||
| 460 | "typeSchema": null, | ||
| 461 | "notNull": true, | ||
| 462 | "dimensions": 0, | ||
| 463 | "default": null, | ||
| 464 | "generated": null, | ||
| 465 | "identity": null, | ||
| 466 | "name": "user_id", | ||
| 467 | "entityType": "columns", | ||
| 468 | "schema": "public", | ||
| 469 | "table": "relay_environment_links" | ||
| 470 | }, | ||
| 471 | { | ||
| 472 | "type": "varchar(191)", | ||
| 473 | "typeSchema": null, | ||
| 474 | "notNull": true, | ||
| 475 | "dimensions": 0, | ||
| 476 | "default": null, | ||
| 477 | "generated": null, | ||
| 478 | "identity": null, | ||
| 479 | "name": "environment_id", | ||
| 480 | "entityType": "columns", | ||
| 481 | "schema": "public", | ||
| 482 | "table": "relay_environment_links" | ||
| 483 | }, | ||
| 484 | { | ||
| 485 | "type": "text", | ||
| 486 | "typeSchema": null, | ||
| 487 | "notNull": true, | ||
| 488 | "dimensions": 0, | ||
| 489 | "default": "'T3 Environment'", | ||
| 490 | "generated": null, | ||
| 491 | "identity": null, | ||
| 492 | "name": "environment_label", | ||
| 493 | "entityType": "columns", | ||
| 494 | "schema": "public", | ||
| 495 | "table": "relay_environment_links" | ||
| 496 | }, | ||
| 497 | { | ||
| 498 | "type": "text", | ||
| 499 | "typeSchema": null, | ||
| 500 | "notNull": true, | ||
| 501 | "dimensions": 0, | ||
| 502 | "default": null, | ||
| 503 | "generated": null, | ||
| 504 | "identity": null, | ||
| 505 | "name": "environment_public_key", | ||
| 506 | "entityType": "columns", | ||
| 507 | "schema": "public", | ||
| 508 | "table": "relay_environment_links" | ||
| 509 | }, | ||
| 510 | { | ||
| 511 | "type": "text", | ||
| 512 | "typeSchema": null, | ||
| 513 | "notNull": true, | ||
| 514 | "dimensions": 0, | ||
| 515 | "default": null, | ||
| 516 | "generated": null, | ||
| 517 | "identity": null, | ||
| 518 | "name": "endpoint_http_base_url", | ||
| 519 | "entityType": "columns", | ||
| 520 | "schema": "public", | ||
| 521 | "table": "relay_environment_links" | ||
| 522 | }, | ||
| 523 | { | ||
| 524 | "type": "text", | ||
| 525 | "typeSchema": null, | ||
| 526 | "notNull": true, | ||
| 527 | "dimensions": 0, | ||
| 528 | "default": null, | ||
| 529 | "generated": null, | ||
| 530 | "identity": null, | ||
| 531 | "name": "endpoint_ws_base_url", | ||
| 532 | "entityType": "columns", | ||
| 533 | "schema": "public", | ||
| 534 | "table": "relay_environment_links" | ||
| 535 | }, | ||
| 536 | { | ||
| 537 | "type": "varchar(32)", | ||
| 538 | "typeSchema": null, | ||
| 539 | "notNull": true, | ||
| 540 | "dimensions": 0, | ||
| 541 | "default": null, | ||
| 542 | "generated": null, | ||
| 543 | "identity": null, | ||
| 544 | "name": "endpoint_provider_kind", | ||
| 545 | "entityType": "columns", | ||
| 546 | "schema": "public", | ||
| 547 | "table": "relay_environment_links" | ||
| 548 | }, | ||
| 549 | { | ||
| 550 | "type": "boolean", | ||
| 551 | "typeSchema": null, | ||
| 552 | "notNull": true, | ||
| 553 | "dimensions": 0, | ||
| 554 | "default": "true", | ||
| 555 | "generated": null, | ||
| 556 | "identity": null, | ||
| 557 | "name": "notifications_enabled", | ||
| 558 | "entityType": "columns", | ||
| 559 | "schema": "public", | ||
| 560 | "table": "relay_environment_links" | ||
| 561 | }, | ||
| 562 | { | ||
| 563 | "type": "boolean", | ||
| 564 | "typeSchema": null, | ||
| 565 | "notNull": true, | ||
| 566 | "dimensions": 0, | ||
| 567 | "default": "true", | ||
| 568 | "generated": null, | ||
| 569 | "identity": null, | ||
| 570 | "name": "live_activities_enabled", | ||
| 571 | "entityType": "columns", | ||
| 572 | "schema": "public", | ||
| 573 | "table": "relay_environment_links" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "type": "boolean", | ||
| 577 | "typeSchema": null, | ||
| 578 | "notNull": true, | ||
| 579 | "dimensions": 0, | ||
| 580 | "default": "false", | ||
| 581 | "generated": null, | ||
| 582 | "identity": null, | ||
| 583 | "name": "managed_tunnels_enabled", | ||
| 584 | "entityType": "columns", | ||
| 585 | "schema": "public", | ||
| 586 | "table": "relay_environment_links" | ||
| 587 | }, | ||
| 588 | { | ||
| 589 | "type": "varchar(191)", | ||
| 590 | "typeSchema": null, | ||
| 591 | "notNull": false, | ||
| 592 | "dimensions": 0, | ||
| 593 | "default": null, | ||
| 594 | "generated": null, | ||
| 595 | "identity": null, | ||
| 596 | "name": "created_by_device_id", | ||
| 597 | "entityType": "columns", | ||
| 598 | "schema": "public", | ||
| 599 | "table": "relay_environment_links" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "type": "varchar(64)", | ||
| 603 | "typeSchema": null, | ||
| 604 | "notNull": false, | ||
| 605 | "dimensions": 0, | ||
| 606 | "default": null, | ||
| 607 | "generated": null, | ||
| 608 | "identity": null, | ||
| 609 | "name": "revoked_at", | ||
| 610 | "entityType": "columns", | ||
| 611 | "schema": "public", | ||
| 612 | "table": "relay_environment_links" | ||
| 613 | }, | ||
| 614 | { | ||
| 615 | "type": "varchar(64)", | ||
| 616 | "typeSchema": null, | ||
| 617 | "notNull": true, | ||
| 618 | "dimensions": 0, | ||
| 619 | "default": null, | ||
| 620 | "generated": null, | ||
| 621 | "identity": null, | ||
| 622 | "name": "created_at", | ||
| 623 | "entityType": "columns", | ||
| 624 | "schema": "public", | ||
| 625 | "table": "relay_environment_links" | ||
| 626 | }, | ||
| 627 | { | ||
| 628 | "type": "varchar(64)", | ||
| 629 | "typeSchema": null, | ||
| 630 | "notNull": true, | ||
| 631 | "dimensions": 0, | ||
| 632 | "default": null, | ||
| 633 | "generated": null, | ||
| 634 | "identity": null, | ||
| 635 | "name": "updated_at", | ||
| 636 | "entityType": "columns", | ||
| 637 | "schema": "public", | ||
| 638 | "table": "relay_environment_links" | ||
| 639 | }, | ||
| 640 | { | ||
| 641 | "type": "varchar(255)", | ||
| 642 | "typeSchema": null, | ||
| 643 | "notNull": true, | ||
| 644 | "dimensions": 0, | ||
| 645 | "default": null, | ||
| 646 | "generated": null, | ||
| 647 | "identity": null, | ||
| 648 | "name": "user_id", | ||
| 649 | "entityType": "columns", | ||
| 650 | "schema": "public", | ||
| 651 | "table": "relay_live_activities" | ||
| 652 | }, | ||
| 653 | { | ||
| 654 | "type": "varchar(255)", | ||
| 655 | "typeSchema": null, | ||
| 656 | "notNull": true, | ||
| 657 | "dimensions": 0, | ||
| 658 | "default": null, | ||
| 659 | "generated": null, | ||
| 660 | "identity": null, | ||
| 661 | "name": "device_id", | ||
| 662 | "entityType": "columns", | ||
| 663 | "schema": "public", | ||
| 664 | "table": "relay_live_activities" | ||
| 665 | }, | ||
| 666 | { | ||
| 667 | "type": "text", | ||
| 668 | "typeSchema": null, | ||
| 669 | "notNull": false, | ||
| 670 | "dimensions": 0, | ||
| 671 | "default": null, | ||
| 672 | "generated": null, | ||
| 673 | "identity": null, | ||
| 674 | "name": "activity_push_token", | ||
| 675 | "entityType": "columns", | ||
| 676 | "schema": "public", | ||
| 677 | "table": "relay_live_activities" | ||
| 678 | }, | ||
| 679 | { | ||
| 680 | "type": "varchar(64)", | ||
| 681 | "typeSchema": null, | ||
| 682 | "notNull": false, | ||
| 683 | "dimensions": 0, | ||
| 684 | "default": null, | ||
| 685 | "generated": null, | ||
| 686 | "identity": null, | ||
| 687 | "name": "remote_start_queued_at", | ||
| 688 | "entityType": "columns", | ||
| 689 | "schema": "public", | ||
| 690 | "table": "relay_live_activities" | ||
| 691 | }, | ||
| 692 | { | ||
| 693 | "type": "varchar(64)", | ||
| 694 | "typeSchema": null, | ||
| 695 | "notNull": false, | ||
| 696 | "dimensions": 0, | ||
| 697 | "default": null, | ||
| 698 | "generated": null, | ||
| 699 | "identity": null, | ||
| 700 | "name": "remote_started_at", | ||
| 701 | "entityType": "columns", | ||
| 702 | "schema": "public", | ||
| 703 | "table": "relay_live_activities" | ||
| 704 | }, | ||
| 705 | { | ||
| 706 | "type": "varchar(64)", | ||
| 707 | "typeSchema": null, | ||
| 708 | "notNull": false, | ||
| 709 | "dimensions": 0, | ||
| 710 | "default": null, | ||
| 711 | "generated": null, | ||
| 712 | "identity": null, | ||
| 713 | "name": "ended_at", | ||
| 714 | "entityType": "columns", | ||
| 715 | "schema": "public", | ||
| 716 | "table": "relay_live_activities" | ||
| 717 | }, | ||
| 718 | { | ||
| 719 | "type": "jsonb", | ||
| 720 | "typeSchema": null, | ||
| 721 | "notNull": false, | ||
| 722 | "dimensions": 0, | ||
| 723 | "default": null, | ||
| 724 | "generated": null, | ||
| 725 | "identity": null, | ||
| 726 | "name": "last_aggregate_json", | ||
| 727 | "entityType": "columns", | ||
| 728 | "schema": "public", | ||
| 729 | "table": "relay_live_activities" | ||
| 730 | }, | ||
| 731 | { | ||
| 732 | "type": "varchar(64)", | ||
| 733 | "typeSchema": null, | ||
| 734 | "notNull": false, | ||
| 735 | "dimensions": 0, | ||
| 736 | "default": null, | ||
| 737 | "generated": null, | ||
| 738 | "identity": null, | ||
| 739 | "name": "last_live_activity_delivery_at", | ||
| 740 | "entityType": "columns", | ||
| 741 | "schema": "public", | ||
| 742 | "table": "relay_live_activities" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "type": "varchar(64)", | ||
| 746 | "typeSchema": null, | ||
| 747 | "notNull": true, | ||
| 748 | "dimensions": 0, | ||
| 749 | "default": null, | ||
| 750 | "generated": null, | ||
| 751 | "identity": null, | ||
| 752 | "name": "created_at", | ||
| 753 | "entityType": "columns", | ||
| 754 | "schema": "public", | ||
| 755 | "table": "relay_live_activities" | ||
| 756 | }, | ||
| 757 | { | ||
| 758 | "type": "varchar(64)", | ||
| 759 | "typeSchema": null, | ||
| 760 | "notNull": true, | ||
| 761 | "dimensions": 0, | ||
| 762 | "default": null, | ||
| 763 | "generated": null, | ||
| 764 | "identity": null, | ||
| 765 | "name": "updated_at", | ||
| 766 | "entityType": "columns", | ||
| 767 | "schema": "public", | ||
| 768 | "table": "relay_live_activities" | ||
| 769 | }, | ||
| 770 | { | ||
| 771 | "type": "varchar(191)", | ||
| 772 | "typeSchema": null, | ||
| 773 | "notNull": true, | ||
| 774 | "dimensions": 0, | ||
| 775 | "default": null, | ||
| 776 | "generated": null, | ||
| 777 | "identity": null, | ||
| 778 | "name": "user_id", | ||
| 779 | "entityType": "columns", | ||
| 780 | "schema": "public", | ||
| 781 | "table": "relay_managed_endpoint_allocations" | ||
| 782 | }, | ||
| 783 | { | ||
| 784 | "type": "varchar(191)", | ||
| 785 | "typeSchema": null, | ||
| 786 | "notNull": true, | ||
| 787 | "dimensions": 0, | ||
| 788 | "default": null, | ||
| 789 | "generated": null, | ||
| 790 | "identity": null, | ||
| 791 | "name": "environment_id", | ||
| 792 | "entityType": "columns", | ||
| 793 | "schema": "public", | ||
| 794 | "table": "relay_managed_endpoint_allocations" | ||
| 795 | }, | ||
| 796 | { | ||
| 797 | "type": "text", | ||
| 798 | "typeSchema": null, | ||
| 799 | "notNull": true, | ||
| 800 | "dimensions": 0, | ||
| 801 | "default": null, | ||
| 802 | "generated": null, | ||
| 803 | "identity": null, | ||
| 804 | "name": "hostname", | ||
| 805 | "entityType": "columns", | ||
| 806 | "schema": "public", | ||
| 807 | "table": "relay_managed_endpoint_allocations" | ||
| 808 | }, | ||
| 809 | { | ||
| 810 | "type": "varchar(191)", | ||
| 811 | "typeSchema": null, | ||
| 812 | "notNull": false, | ||
| 813 | "dimensions": 0, | ||
| 814 | "default": null, | ||
| 815 | "generated": null, | ||
| 816 | "identity": null, | ||
| 817 | "name": "tunnel_id", | ||
| 818 | "entityType": "columns", | ||
| 819 | "schema": "public", | ||
| 820 | "table": "relay_managed_endpoint_allocations" | ||
| 821 | }, | ||
| 822 | { | ||
| 823 | "type": "text", | ||
| 824 | "typeSchema": null, | ||
| 825 | "notNull": true, | ||
| 826 | "dimensions": 0, | ||
| 827 | "default": null, | ||
| 828 | "generated": null, | ||
| 829 | "identity": null, | ||
| 830 | "name": "tunnel_name", | ||
| 831 | "entityType": "columns", | ||
| 832 | "schema": "public", | ||
| 833 | "table": "relay_managed_endpoint_allocations" | ||
| 834 | }, | ||
| 835 | { | ||
| 836 | "type": "varchar(191)", | ||
| 837 | "typeSchema": null, | ||
| 838 | "notNull": false, | ||
| 839 | "dimensions": 0, | ||
| 840 | "default": null, | ||
| 841 | "generated": null, | ||
| 842 | "identity": null, | ||
| 843 | "name": "dns_record_id", | ||
| 844 | "entityType": "columns", | ||
| 845 | "schema": "public", | ||
| 846 | "table": "relay_managed_endpoint_allocations" | ||
| 847 | }, | ||
| 848 | { | ||
| 849 | "type": "varchar(64)", | ||
| 850 | "typeSchema": null, | ||
| 851 | "notNull": false, | ||
| 852 | "dimensions": 0, | ||
| 853 | "default": null, | ||
| 854 | "generated": null, | ||
| 855 | "identity": null, | ||
| 856 | "name": "ready_at", | ||
| 857 | "entityType": "columns", | ||
| 858 | "schema": "public", | ||
| 859 | "table": "relay_managed_endpoint_allocations" | ||
| 860 | }, | ||
| 861 | { | ||
| 862 | "type": "varchar(64)", | ||
| 863 | "typeSchema": null, | ||
| 864 | "notNull": true, | ||
| 865 | "dimensions": 0, | ||
| 866 | "default": null, | ||
| 867 | "generated": null, | ||
| 868 | "identity": null, | ||
| 869 | "name": "created_at", | ||
| 870 | "entityType": "columns", | ||
| 871 | "schema": "public", | ||
| 872 | "table": "relay_managed_endpoint_allocations" | ||
| 873 | }, | ||
| 874 | { | ||
| 875 | "type": "varchar(64)", | ||
| 876 | "typeSchema": null, | ||
| 877 | "notNull": true, | ||
| 878 | "dimensions": 0, | ||
| 879 | "default": null, | ||
| 880 | "generated": null, | ||
| 881 | "identity": null, | ||
| 882 | "name": "updated_at", | ||
| 883 | "entityType": "columns", | ||
| 884 | "schema": "public", | ||
| 885 | "table": "relay_managed_endpoint_allocations" | ||
| 886 | }, | ||
| 887 | { | ||
| 888 | "type": "varchar(255)", | ||
| 889 | "typeSchema": null, | ||
| 890 | "notNull": true, | ||
| 891 | "dimensions": 0, | ||
| 892 | "default": null, | ||
| 893 | "generated": null, | ||
| 894 | "identity": null, | ||
| 895 | "name": "user_id", | ||
| 896 | "entityType": "columns", | ||
| 897 | "schema": "public", | ||
| 898 | "table": "relay_mobile_devices" | ||
| 899 | }, | ||
| 900 | { | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "typeSchema": null, | ||
| 903 | "notNull": true, | ||
| 904 | "dimensions": 0, | ||
| 905 | "default": null, | ||
| 906 | "generated": null, | ||
| 907 | "identity": null, | ||
| 908 | "name": "device_id", | ||
| 909 | "entityType": "columns", | ||
| 910 | "schema": "public", | ||
| 911 | "table": "relay_mobile_devices" | ||
| 912 | }, | ||
| 913 | { | ||
| 914 | "type": "text", | ||
| 915 | "typeSchema": null, | ||
| 916 | "notNull": true, | ||
| 917 | "dimensions": 0, | ||
| 918 | "default": "'iOS device'", | ||
| 919 | "generated": null, | ||
| 920 | "identity": null, | ||
| 921 | "name": "label", | ||
| 922 | "entityType": "columns", | ||
| 923 | "schema": "public", | ||
| 924 | "table": "relay_mobile_devices" | ||
| 925 | }, | ||
| 926 | { | ||
| 927 | "type": "varchar(16)", | ||
| 928 | "typeSchema": null, | ||
| 929 | "notNull": true, | ||
| 930 | "dimensions": 0, | ||
| 931 | "default": null, | ||
| 932 | "generated": null, | ||
| 933 | "identity": null, | ||
| 934 | "name": "platform", | ||
| 935 | "entityType": "columns", | ||
| 936 | "schema": "public", | ||
| 937 | "table": "relay_mobile_devices" | ||
| 938 | }, | ||
| 939 | { | ||
| 940 | "type": "integer", | ||
| 941 | "typeSchema": null, | ||
| 942 | "notNull": true, | ||
| 943 | "dimensions": 0, | ||
| 944 | "default": null, | ||
| 945 | "generated": null, | ||
| 946 | "identity": null, | ||
| 947 | "name": "ios_major_version", | ||
| 948 | "entityType": "columns", | ||
| 949 | "schema": "public", | ||
| 950 | "table": "relay_mobile_devices" | ||
| 951 | }, | ||
| 952 | { | ||
| 953 | "type": "varchar(64)", | ||
| 954 | "typeSchema": null, | ||
| 955 | "notNull": false, | ||
| 956 | "dimensions": 0, | ||
| 957 | "default": null, | ||
| 958 | "generated": null, | ||
| 959 | "identity": null, | ||
| 960 | "name": "app_version", | ||
| 961 | "entityType": "columns", | ||
| 962 | "schema": "public", | ||
| 963 | "table": "relay_mobile_devices" | ||
| 964 | }, | ||
| 965 | { | ||
| 966 | "type": "varchar(255)", | ||
| 967 | "typeSchema": null, | ||
| 968 | "notNull": false, | ||
| 969 | "dimensions": 0, | ||
| 970 | "default": null, | ||
| 971 | "generated": null, | ||
| 972 | "identity": null, | ||
| 973 | "name": "bundle_id", | ||
| 974 | "entityType": "columns", | ||
| 975 | "schema": "public", | ||
| 976 | "table": "relay_mobile_devices" | ||
| 977 | }, | ||
| 978 | { | ||
| 979 | "type": "varchar(16)", | ||
| 980 | "typeSchema": null, | ||
| 981 | "notNull": false, | ||
| 982 | "dimensions": 0, | ||
| 983 | "default": null, | ||
| 984 | "generated": null, | ||
| 985 | "identity": null, | ||
| 986 | "name": "aps_environment", | ||
| 987 | "entityType": "columns", | ||
| 988 | "schema": "public", | ||
| 989 | "table": "relay_mobile_devices" | ||
| 990 | }, | ||
| 991 | { | ||
| 992 | "type": "text", | ||
| 993 | "typeSchema": null, | ||
| 994 | "notNull": false, | ||
| 995 | "dimensions": 0, | ||
| 996 | "default": null, | ||
| 997 | "generated": null, | ||
| 998 | "identity": null, | ||
| 999 | "name": "push_token", | ||
| 1000 | "entityType": "columns", | ||
| 1001 | "schema": "public", | ||
| 1002 | "table": "relay_mobile_devices" | ||
| 1003 | }, | ||
| 1004 | { | ||
| 1005 | "type": "text", | ||
| 1006 | "typeSchema": null, | ||
| 1007 | "notNull": false, | ||
| 1008 | "dimensions": 0, | ||
| 1009 | "default": null, | ||
| 1010 | "generated": null, | ||
| 1011 | "identity": null, | ||
| 1012 | "name": "push_to_start_token", | ||
| 1013 | "entityType": "columns", | ||
| 1014 | "schema": "public", | ||
| 1015 | "table": "relay_mobile_devices" | ||
| 1016 | }, | ||
| 1017 | { | ||
| 1018 | "type": "jsonb", | ||
| 1019 | "typeSchema": null, | ||
| 1020 | "notNull": true, | ||
| 1021 | "dimensions": 0, | ||
| 1022 | "default": null, | ||
| 1023 | "generated": null, | ||
| 1024 | "identity": null, | ||
| 1025 | "name": "preferences_json", | ||
| 1026 | "entityType": "columns", | ||
| 1027 | "schema": "public", | ||
| 1028 | "table": "relay_mobile_devices" | ||
| 1029 | }, | ||
| 1030 | { | ||
| 1031 | "type": "varchar(64)", | ||
| 1032 | "typeSchema": null, | ||
| 1033 | "notNull": true, | ||
| 1034 | "dimensions": 0, | ||
| 1035 | "default": null, | ||
| 1036 | "generated": null, | ||
| 1037 | "identity": null, | ||
| 1038 | "name": "created_at", | ||
| 1039 | "entityType": "columns", | ||
| 1040 | "schema": "public", | ||
| 1041 | "table": "relay_mobile_devices" | ||
| 1042 | }, | ||
| 1043 | { | ||
| 1044 | "type": "varchar(64)", | ||
| 1045 | "typeSchema": null, | ||
| 1046 | "notNull": true, | ||
| 1047 | "dimensions": 0, | ||
| 1048 | "default": null, | ||
| 1049 | "generated": null, | ||
| 1050 | "identity": null, | ||
| 1051 | "name": "updated_at", | ||
| 1052 | "entityType": "columns", | ||
| 1053 | "schema": "public", | ||
| 1054 | "table": "relay_mobile_devices" | ||
| 1055 | }, | ||
| 1056 | { | ||
| 1057 | "nameExplicit": true, | ||
| 1058 | "columns": [ | ||
| 1059 | { | ||
| 1060 | "value": "updated_at", | ||
| 1061 | "isExpression": false, | ||
| 1062 | "asc": true, | ||
| 1063 | "nullsFirst": false, | ||
| 1064 | "opclass": null | ||
| 1065 | } | ||
| 1066 | ], | ||
| 1067 | "isUnique": false, | ||
| 1068 | "where": null, | ||
| 1069 | "with": "", | ||
| 1070 | "method": "btree", | ||
| 1071 | "concurrently": false, | ||
| 1072 | "name": "idx_relay_agent_activity_rows_updated", | ||
| 1073 | "entityType": "indexes", | ||
| 1074 | "schema": "public", | ||
| 1075 | "table": "relay_agent_activity_rows" | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | "nameExplicit": true, | ||
| 1079 | "columns": [ | ||
| 1080 | { | ||
| 1081 | "value": "environment_id", | ||
| 1082 | "isExpression": false, | ||
| 1083 | "asc": true, | ||
| 1084 | "nullsFirst": false, | ||
| 1085 | "opclass": null | ||
| 1086 | }, | ||
| 1087 | { | ||
| 1088 | "value": "thread_id", | ||
| 1089 | "isExpression": false, | ||
| 1090 | "asc": true, | ||
| 1091 | "nullsFirst": false, | ||
| 1092 | "opclass": null | ||
| 1093 | }, | ||
| 1094 | { | ||
| 1095 | "value": "created_at", | ||
| 1096 | "isExpression": false, | ||
| 1097 | "asc": true, | ||
| 1098 | "nullsFirst": false, | ||
| 1099 | "opclass": null | ||
| 1100 | } | ||
| 1101 | ], | ||
| 1102 | "isUnique": false, | ||
| 1103 | "where": null, | ||
| 1104 | "with": "", | ||
| 1105 | "method": "btree", | ||
| 1106 | "concurrently": false, | ||
| 1107 | "name": "idx_relay_delivery_attempts_environment", | ||
| 1108 | "entityType": "indexes", | ||
| 1109 | "schema": "public", | ||
| 1110 | "table": "relay_delivery_attempts" | ||
| 1111 | }, | ||
| 1112 | { | ||
| 1113 | "nameExplicit": true, | ||
| 1114 | "columns": [ | ||
| 1115 | { | ||
| 1116 | "value": "source_job_id", | ||
| 1117 | "isExpression": false, | ||
| 1118 | "asc": true, | ||
| 1119 | "nullsFirst": false, | ||
| 1120 | "opclass": null | ||
| 1121 | } | ||
| 1122 | ], | ||
| 1123 | "isUnique": true, | ||
| 1124 | "where": null, | ||
| 1125 | "with": "", | ||
| 1126 | "method": "btree", | ||
| 1127 | "concurrently": false, | ||
| 1128 | "name": "idx_relay_delivery_attempts_source_job", | ||
| 1129 | "entityType": "indexes", | ||
| 1130 | "schema": "public", | ||
| 1131 | "table": "relay_delivery_attempts" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "nameExplicit": true, | ||
| 1135 | "columns": [ | ||
| 1136 | { | ||
| 1137 | "value": "expires_at", | ||
| 1138 | "isExpression": false, | ||
| 1139 | "asc": true, | ||
| 1140 | "nullsFirst": false, | ||
| 1141 | "opclass": null | ||
| 1142 | } | ||
| 1143 | ], | ||
| 1144 | "isUnique": false, | ||
| 1145 | "where": null, | ||
| 1146 | "with": "", | ||
| 1147 | "method": "btree", | ||
| 1148 | "concurrently": false, | ||
| 1149 | "name": "idx_relay_dpop_proofs_expires_at", | ||
| 1150 | "entityType": "indexes", | ||
| 1151 | "schema": "public", | ||
| 1152 | "table": "relay_dpop_proofs" | ||
| 1153 | }, | ||
| 1154 | { | ||
| 1155 | "nameExplicit": true, | ||
| 1156 | "columns": [ | ||
| 1157 | { | ||
| 1158 | "value": "credential_hash", | ||
| 1159 | "isExpression": false, | ||
| 1160 | "asc": true, | ||
| 1161 | "nullsFirst": false, | ||
| 1162 | "opclass": null | ||
| 1163 | } | ||
| 1164 | ], | ||
| 1165 | "isUnique": true, | ||
| 1166 | "where": null, | ||
| 1167 | "with": "", | ||
| 1168 | "method": "btree", | ||
| 1169 | "concurrently": false, | ||
| 1170 | "name": "idx_relay_environment_credentials_hash", | ||
| 1171 | "entityType": "indexes", | ||
| 1172 | "schema": "public", | ||
| 1173 | "table": "relay_environment_credentials" | ||
| 1174 | }, | ||
| 1175 | { | ||
| 1176 | "nameExplicit": true, | ||
| 1177 | "columns": [ | ||
| 1178 | { | ||
| 1179 | "value": "environment_id", | ||
| 1180 | "isExpression": false, | ||
| 1181 | "asc": true, | ||
| 1182 | "nullsFirst": false, | ||
| 1183 | "opclass": null | ||
| 1184 | }, | ||
| 1185 | { | ||
| 1186 | "value": "revoked_at", | ||
| 1187 | "isExpression": false, | ||
| 1188 | "asc": true, | ||
| 1189 | "nullsFirst": false, | ||
| 1190 | "opclass": null | ||
| 1191 | } | ||
| 1192 | ], | ||
| 1193 | "isUnique": false, | ||
| 1194 | "where": null, | ||
| 1195 | "with": "", | ||
| 1196 | "method": "btree", | ||
| 1197 | "concurrently": false, | ||
| 1198 | "name": "idx_relay_environment_credentials_environment", | ||
| 1199 | "entityType": "indexes", | ||
| 1200 | "schema": "public", | ||
| 1201 | "table": "relay_environment_credentials" | ||
| 1202 | }, | ||
| 1203 | { | ||
| 1204 | "nameExplicit": true, | ||
| 1205 | "columns": [ | ||
| 1206 | { | ||
| 1207 | "value": "environment_id", | ||
| 1208 | "isExpression": false, | ||
| 1209 | "asc": true, | ||
| 1210 | "nullsFirst": false, | ||
| 1211 | "opclass": null | ||
| 1212 | }, | ||
| 1213 | { | ||
| 1214 | "value": "environment_public_key", | ||
| 1215 | "isExpression": false, | ||
| 1216 | "asc": true, | ||
| 1217 | "nullsFirst": false, | ||
| 1218 | "opclass": null | ||
| 1219 | }, | ||
| 1220 | { | ||
| 1221 | "value": "revoked_at", | ||
| 1222 | "isExpression": false, | ||
| 1223 | "asc": true, | ||
| 1224 | "nullsFirst": false, | ||
| 1225 | "opclass": null | ||
| 1226 | } | ||
| 1227 | ], | ||
| 1228 | "isUnique": false, | ||
| 1229 | "where": null, | ||
| 1230 | "with": "", | ||
| 1231 | "method": "btree", | ||
| 1232 | "concurrently": false, | ||
| 1233 | "name": "idx_relay_environment_credentials_environment_key", | ||
| 1234 | "entityType": "indexes", | ||
| 1235 | "schema": "public", | ||
| 1236 | "table": "relay_environment_credentials" | ||
| 1237 | }, | ||
| 1238 | { | ||
| 1239 | "nameExplicit": true, | ||
| 1240 | "columns": [ | ||
| 1241 | { | ||
| 1242 | "value": "environment_id", | ||
| 1243 | "isExpression": false, | ||
| 1244 | "asc": true, | ||
| 1245 | "nullsFirst": false, | ||
| 1246 | "opclass": null | ||
| 1247 | }, | ||
| 1248 | { | ||
| 1249 | "value": "revoked_at", | ||
| 1250 | "isExpression": false, | ||
| 1251 | "asc": true, | ||
| 1252 | "nullsFirst": false, | ||
| 1253 | "opclass": null | ||
| 1254 | } | ||
| 1255 | ], | ||
| 1256 | "isUnique": false, | ||
| 1257 | "where": null, | ||
| 1258 | "with": "", | ||
| 1259 | "method": "btree", | ||
| 1260 | "concurrently": false, | ||
| 1261 | "name": "idx_relay_environment_links_environment", | ||
| 1262 | "entityType": "indexes", | ||
| 1263 | "schema": "public", | ||
| 1264 | "table": "relay_environment_links" | ||
| 1265 | }, | ||
| 1266 | { | ||
| 1267 | "nameExplicit": true, | ||
| 1268 | "columns": [ | ||
| 1269 | { | ||
| 1270 | "value": "user_id", | ||
| 1271 | "isExpression": false, | ||
| 1272 | "asc": true, | ||
| 1273 | "nullsFirst": false, | ||
| 1274 | "opclass": null | ||
| 1275 | } | ||
| 1276 | ], | ||
| 1277 | "isUnique": false, | ||
| 1278 | "where": null, | ||
| 1279 | "with": "", | ||
| 1280 | "method": "btree", | ||
| 1281 | "concurrently": false, | ||
| 1282 | "name": "idx_relay_live_activities_user", | ||
| 1283 | "entityType": "indexes", | ||
| 1284 | "schema": "public", | ||
| 1285 | "table": "relay_live_activities" | ||
| 1286 | }, | ||
| 1287 | { | ||
| 1288 | "nameExplicit": true, | ||
| 1289 | "columns": [ | ||
| 1290 | { | ||
| 1291 | "value": "activity_push_token", | ||
| 1292 | "isExpression": false, | ||
| 1293 | "asc": true, | ||
| 1294 | "nullsFirst": false, | ||
| 1295 | "opclass": null | ||
| 1296 | } | ||
| 1297 | ], | ||
| 1298 | "isUnique": true, | ||
| 1299 | "where": null, | ||
| 1300 | "with": "", | ||
| 1301 | "method": "btree", | ||
| 1302 | "concurrently": false, | ||
| 1303 | "name": "idx_relay_live_activities_activity_push_token", | ||
| 1304 | "entityType": "indexes", | ||
| 1305 | "schema": "public", | ||
| 1306 | "table": "relay_live_activities" | ||
| 1307 | }, | ||
| 1308 | { | ||
| 1309 | "nameExplicit": true, | ||
| 1310 | "columns": [ | ||
| 1311 | { | ||
| 1312 | "value": "hostname", | ||
| 1313 | "isExpression": false, | ||
| 1314 | "asc": true, | ||
| 1315 | "nullsFirst": false, | ||
| 1316 | "opclass": null | ||
| 1317 | } | ||
| 1318 | ], | ||
| 1319 | "isUnique": true, | ||
| 1320 | "where": null, | ||
| 1321 | "with": "", | ||
| 1322 | "method": "btree", | ||
| 1323 | "concurrently": false, | ||
| 1324 | "name": "idx_relay_managed_endpoint_allocations_hostname", | ||
| 1325 | "entityType": "indexes", | ||
| 1326 | "schema": "public", | ||
| 1327 | "table": "relay_managed_endpoint_allocations" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "nameExplicit": true, | ||
| 1331 | "columns": [ | ||
| 1332 | { | ||
| 1333 | "value": "tunnel_name", | ||
| 1334 | "isExpression": false, | ||
| 1335 | "asc": true, | ||
| 1336 | "nullsFirst": false, | ||
| 1337 | "opclass": null | ||
| 1338 | } | ||
| 1339 | ], | ||
| 1340 | "isUnique": true, | ||
| 1341 | "where": null, | ||
| 1342 | "with": "", | ||
| 1343 | "method": "btree", | ||
| 1344 | "concurrently": false, | ||
| 1345 | "name": "idx_relay_managed_endpoint_allocations_tunnel_name", | ||
| 1346 | "entityType": "indexes", | ||
| 1347 | "schema": "public", | ||
| 1348 | "table": "relay_managed_endpoint_allocations" | ||
| 1349 | }, | ||
| 1350 | { | ||
| 1351 | "nameExplicit": true, | ||
| 1352 | "columns": [ | ||
| 1353 | { | ||
| 1354 | "value": "user_id", | ||
| 1355 | "isExpression": false, | ||
| 1356 | "asc": true, | ||
| 1357 | "nullsFirst": false, | ||
| 1358 | "opclass": null | ||
| 1359 | } | ||
| 1360 | ], | ||
| 1361 | "isUnique": false, | ||
| 1362 | "where": null, | ||
| 1363 | "with": "", | ||
| 1364 | "method": "btree", | ||
| 1365 | "concurrently": false, | ||
| 1366 | "name": "idx_relay_mobile_devices_user", | ||
| 1367 | "entityType": "indexes", | ||
| 1368 | "schema": "public", | ||
| 1369 | "table": "relay_mobile_devices" | ||
| 1370 | }, | ||
| 1371 | { | ||
| 1372 | "nameExplicit": true, | ||
| 1373 | "columns": [ | ||
| 1374 | { | ||
| 1375 | "value": "push_token", | ||
| 1376 | "isExpression": false, | ||
| 1377 | "asc": true, | ||
| 1378 | "nullsFirst": false, | ||
| 1379 | "opclass": null | ||
| 1380 | } | ||
| 1381 | ], | ||
| 1382 | "isUnique": true, | ||
| 1383 | "where": null, | ||
| 1384 | "with": "", | ||
| 1385 | "method": "btree", | ||
| 1386 | "concurrently": false, | ||
| 1387 | "name": "idx_relay_mobile_devices_push_token", | ||
| 1388 | "entityType": "indexes", | ||
| 1389 | "schema": "public", | ||
| 1390 | "table": "relay_mobile_devices" | ||
| 1391 | }, | ||
| 1392 | { | ||
| 1393 | "nameExplicit": true, | ||
| 1394 | "columns": [ | ||
| 1395 | { | ||
| 1396 | "value": "push_to_start_token", | ||
| 1397 | "isExpression": false, | ||
| 1398 | "asc": true, | ||
| 1399 | "nullsFirst": false, | ||
| 1400 | "opclass": null | ||
| 1401 | } | ||
| 1402 | ], | ||
| 1403 | "isUnique": true, | ||
| 1404 | "where": null, | ||
| 1405 | "with": "", | ||
| 1406 | "method": "btree", | ||
| 1407 | "concurrently": false, | ||
| 1408 | "name": "idx_relay_mobile_devices_push_to_start_token", | ||
| 1409 | "entityType": "indexes", | ||
| 1410 | "schema": "public", | ||
| 1411 | "table": "relay_mobile_devices" | ||
| 1412 | }, | ||
| 1413 | { | ||
| 1414 | "columns": ["environment_id", "environment_public_key", "thread_id"], | ||
| 1415 | "nameExplicit": false, | ||
| 1416 | "name": "relay_agent_activity_rows_pkey", | ||
| 1417 | "entityType": "pks", | ||
| 1418 | "schema": "public", | ||
| 1419 | "table": "relay_agent_activity_rows" | ||
| 1420 | }, | ||
| 1421 | { | ||
| 1422 | "columns": ["thumbprint", "jti"], | ||
| 1423 | "nameExplicit": false, | ||
| 1424 | "name": "relay_dpop_proofs_pkey", | ||
| 1425 | "entityType": "pks", | ||
| 1426 | "schema": "public", | ||
| 1427 | "table": "relay_dpop_proofs" | ||
| 1428 | }, | ||
| 1429 | { | ||
| 1430 | "columns": ["user_id", "environment_id"], | ||
| 1431 | "nameExplicit": false, | ||
| 1432 | "name": "relay_environment_links_pkey", | ||
| 1433 | "entityType": "pks", | ||
| 1434 | "schema": "public", | ||
| 1435 | "table": "relay_environment_links" | ||
| 1436 | }, | ||
| 1437 | { | ||
| 1438 | "columns": ["user_id", "device_id"], | ||
| 1439 | "nameExplicit": false, | ||
| 1440 | "name": "relay_live_activities_pkey", | ||
| 1441 | "entityType": "pks", | ||
| 1442 | "schema": "public", | ||
| 1443 | "table": "relay_live_activities" | ||
| 1444 | }, | ||
| 1445 | { | ||
| 1446 | "columns": ["user_id", "environment_id"], | ||
| 1447 | "nameExplicit": false, | ||
| 1448 | "name": "relay_managed_endpoint_allocations_pkey", | ||
| 1449 | "entityType": "pks", | ||
| 1450 | "schema": "public", | ||
| 1451 | "table": "relay_managed_endpoint_allocations" | ||
| 1452 | }, | ||
| 1453 | { | ||
| 1454 | "columns": ["user_id", "device_id"], | ||
| 1455 | "nameExplicit": false, | ||
| 1456 | "name": "relay_mobile_devices_pkey", | ||
| 1457 | "entityType": "pks", | ||
| 1458 | "schema": "public", | ||
| 1459 | "table": "relay_mobile_devices" | ||
| 1460 | }, | ||
| 1461 | { | ||
| 1462 | "columns": ["id"], | ||
| 1463 | "nameExplicit": false, | ||
| 1464 | "name": "relay_delivery_attempts_pkey", | ||
| 1465 | "schema": "public", | ||
| 1466 | "table": "relay_delivery_attempts", | ||
| 1467 | "entityType": "pks" | ||
| 1468 | }, | ||
| 1469 | { | ||
| 1470 | "columns": ["credential_id"], | ||
| 1471 | "nameExplicit": false, | ||
| 1472 | "name": "relay_environment_credentials_pkey", | ||
| 1473 | "schema": "public", | ||
| 1474 | "table": "relay_environment_credentials", | ||
| 1475 | "entityType": "pks" | ||
| 1476 | } | ||
| 1477 | ], | ||
| 1478 | "renames": [] | ||
| 1479 | } | ||
infra/relay/package.json+2| ... | @@ -11,6 +11,8 @@ | ... | @@ -11,6 +11,8 @@ |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@clerk/backend": "catalog:", | 12 | "@clerk/backend": "catalog:", |
| 13 | "@effect/sql-pg": "catalog:", | 13 | "@effect/sql-pg": "catalog:", |
| 14 | "@noble/curves": "catalog:", | ||
| 15 | "@noble/hashes": "catalog:", | ||
| 14 | "@t3tools/client-runtime": "workspace:*", | 16 | "@t3tools/client-runtime": "workspace:*", |
| 15 | "@t3tools/contracts": "workspace:*", | 17 | "@t3tools/contracts": "workspace:*", |
| 16 | "@t3tools/shared": "workspace:*", | 18 | "@t3tools/shared": "workspace:*", |
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+180-6| ... | @@ -28,6 +28,8 @@ function target(deviceId: string): LiveActivities.TargetRow { | ... | @@ -28,6 +28,8 @@ function target(deviceId: string): LiveActivities.TargetRow { |
| 28 | platform: "ios", | 28 | platform: "ios", |
| 29 | ios_major_version: 18, | 29 | ios_major_version: 18, |
| 30 | app_version: "1.0.0", | 30 | app_version: "1.0.0", |
| 31 | bundle_id: null, | ||
| 32 | aps_environment: null, | ||
| 31 | push_token: null, | 33 | push_token: null, |
| 32 | push_to_start_token: "start-token", | 34 | push_to_start_token: "start-token", |
| 33 | preferences_json: "{}", | 35 | preferences_json: "{}", |
| ... | @@ -60,6 +62,7 @@ function makeAgentActivityRows( | ... | @@ -60,6 +62,7 @@ function makeAgentActivityRows( |
| 60 | return { | 62 | return { |
| 61 | upsert: () => Effect.void, | 63 | upsert: () => Effect.void, |
| 62 | remove: () => Effect.void, | 64 | remove: () => Effect.void, |
| 65 | pruneTerminal: () => Effect.void, | ||
| 63 | listForUser: () => Effect.succeed([state]), | 66 | listForUser: () => Effect.succeed([state]), |
| 64 | ...overrides, | 67 | ...overrides, |
| 65 | }; | 68 | }; |
| ... | @@ -306,7 +309,7 @@ describe("AgentActivityPublisher", () => { | ... | @@ -306,7 +309,7 @@ describe("AgentActivityPublisher", () => { |
| 306 | const sentAggregates: Array< | 309 | const sentAggregates: Array< |
| 307 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0] | 310 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0] |
| 308 | > = []; | 311 | > = []; |
| 309 | const removals: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["remove"]>[0]> = | 312 | const upserts: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["upsert"]>[0]> = |
| 310 | []; | 313 | []; |
| 311 | 314 | ||
| 312 | return Effect.gen(function* () { | 315 | return Effect.gen(function* () { |
| ... | @@ -326,9 +329,9 @@ describe("AgentActivityPublisher", () => { | ... | @@ -326,9 +329,9 @@ describe("AgentActivityPublisher", () => { |
| 326 | Layer.succeed( | 329 | Layer.succeed( |
| 327 | AgentActivityRows.AgentActivityRows, | 330 | AgentActivityRows.AgentActivityRows, |
| 328 | makeAgentActivityRows({ | 331 | makeAgentActivityRows({ |
| 329 | remove: (input) => | 332 | upsert: (input) => |
| 330 | Effect.sync(() => { | 333 | Effect.sync(() => { |
| 331 | removals.push(input); | 334 | upserts.push(input); |
| 332 | }), | 335 | }), |
| 333 | listForUser: () => Effect.succeed([]), | 336 | listForUser: () => Effect.succeed([]), |
| 334 | }), | 337 | }), |
| ... | @@ -378,11 +381,12 @@ describe("AgentActivityPublisher", () => { | ... | @@ -378,11 +381,12 @@ describe("AgentActivityPublisher", () => { |
| 378 | ok: true, | 381 | ok: true, |
| 379 | }, | 382 | }, |
| 380 | ]); | 383 | ]); |
| 381 | expect(removals).toEqual([ | 384 | // Terminal states are persisted (and later pruned by the cron) so the |
| 385 | // finished thread can keep a Done row in later aggregates. | ||
| 386 | expect(upserts).toEqual([ | ||
| 382 | { | 387 | { |
| 383 | environmentId: "env", | ||
| 384 | environmentPublicKey: "environment-public-key", | 388 | environmentPublicKey: "environment-public-key", |
| 385 | threadId: "thread", | 389 | state: completedState, |
| 386 | }, | 390 | }, |
| 387 | ]); | 391 | ]); |
| 388 | expect(sentAggregates).toHaveLength(1); | 392 | expect(sentAggregates).toHaveLength(1); |
| ... | @@ -628,3 +632,173 @@ describe("AgentActivityPublisher", () => { | ... | @@ -628,3 +632,173 @@ describe("AgentActivityPublisher", () => { |
| 628 | }, | 632 | }, |
| 629 | ); | 633 | ); |
| 630 | }); | 634 | }); |
| 635 | |||
| 636 | describe("isExpiredAgentActivityState", () => { | ||
| 637 | const hourMs = 60 * 60 * 1_000; | ||
| 638 | |||
| 639 | it("expires running rows after two hours without an update", () => { | ||
| 640 | expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs - 1)).toBe(false); | ||
| 641 | expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs + 1)).toBe(true); | ||
| 642 | }); | ||
| 643 | |||
| 644 | it("keeps waiting rows for a day", () => { | ||
| 645 | const waiting: RelayAgentActivityState = { ...state, phase: "waiting_for_approval" }; | ||
| 646 | expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 23 * hourMs)).toBe(false); | ||
| 647 | expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 25 * hourMs)).toBe(true); | ||
| 648 | }); | ||
| 649 | |||
| 650 | it("treats rows with unparseable timestamps as expired", () => { | ||
| 651 | expect( | ||
| 652 | AgentActivityPublisher.isExpiredAgentActivityState({ ...state, updatedAt: "not-a-date" }, 0), | ||
| 653 | ).toBe(true); | ||
| 654 | }); | ||
| 655 | }); | ||
| 656 | |||
| 657 | describe("makeAggregateState", () => { | ||
| 658 | const hourMs = 60 * 60 * 1_000; | ||
| 659 | |||
| 660 | it("drops expired rows from the aggregate", () => { | ||
| 661 | const fresh: RelayAgentActivityState = { | ||
| 662 | ...state, | ||
| 663 | threadId: "thread-fresh" as RelayAgentActivityState["threadId"], | ||
| 664 | updatedAt: "1970-01-01T03:00:00.000Z", | ||
| 665 | }; | ||
| 666 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 667 | activeStates: [state, fresh], | ||
| 668 | terminalState: null, | ||
| 669 | nowMs: 3 * hourMs, | ||
| 670 | }); | ||
| 671 | |||
| 672 | expect(aggregate?.activeCount).toBe(1); | ||
| 673 | expect(aggregate?.activities).toMatchObject([{ threadId: "thread-fresh" }]); | ||
| 674 | }); | ||
| 675 | |||
| 676 | it("returns null when every row has expired and nothing terminal remains", () => { | ||
| 677 | expect( | ||
| 678 | AgentActivityPublisher.makeAggregateState({ | ||
| 679 | activeStates: [state], | ||
| 680 | terminalState: null, | ||
| 681 | nowMs: 3 * hourMs, | ||
| 682 | }), | ||
| 683 | ).toBeNull(); | ||
| 684 | }); | ||
| 685 | |||
| 686 | it("still reports the terminal state when active rows have expired", () => { | ||
| 687 | const terminalState: RelayAgentActivityState = { | ||
| 688 | ...state, | ||
| 689 | phase: "completed", | ||
| 690 | updatedAt: "1970-01-01T03:00:00.000Z", | ||
| 691 | }; | ||
| 692 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 693 | activeStates: [state], | ||
| 694 | terminalState, | ||
| 695 | nowMs: 3 * hourMs, | ||
| 696 | }); | ||
| 697 | |||
| 698 | expect(aggregate?.activeCount).toBe(0); | ||
| 699 | expect(aggregate?.activities).toMatchObject([{ phase: "completed" }]); | ||
| 700 | }); | ||
| 701 | |||
| 702 | it("keeps a recently finished thread visible as Done beside active agents", () => { | ||
| 703 | const active: RelayAgentActivityState = { | ||
| 704 | ...state, | ||
| 705 | threadId: "thread-active" as RelayAgentActivityState["threadId"], | ||
| 706 | updatedAt: "1970-01-01T00:58:00.000Z", | ||
| 707 | }; | ||
| 708 | const justCompleted: RelayAgentActivityState = { | ||
| 709 | ...state, | ||
| 710 | threadId: "thread-done" as RelayAgentActivityState["threadId"], | ||
| 711 | phase: "completed", | ||
| 712 | updatedAt: "1970-01-01T00:59:00.000Z", | ||
| 713 | }; | ||
| 714 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 715 | activeStates: [active, justCompleted], | ||
| 716 | terminalState: null, | ||
| 717 | nowMs: hourMs, | ||
| 718 | }); | ||
| 719 | |||
| 720 | expect(aggregate?.activeCount).toBe(1); | ||
| 721 | expect(aggregate?.subtitle).toBe("Agent work in progress"); | ||
| 722 | expect(aggregate?.activities).toMatchObject([ | ||
| 723 | { threadId: "thread-active", phase: "running" }, | ||
| 724 | { threadId: "thread-done", phase: "completed", status: "Done" }, | ||
| 725 | ]); | ||
| 726 | expect(aggregate?.updatedAt).toBe("1970-01-01T00:59:00.000Z"); | ||
| 727 | }); | ||
| 728 | |||
| 729 | it("drops finished threads from the aggregate after the display window", () => { | ||
| 730 | const active: RelayAgentActivityState = { | ||
| 731 | ...state, | ||
| 732 | threadId: "thread-active" as RelayAgentActivityState["threadId"], | ||
| 733 | updatedAt: "1970-01-01T00:58:00.000Z", | ||
| 734 | }; | ||
| 735 | const staleCompleted: RelayAgentActivityState = { | ||
| 736 | ...state, | ||
| 737 | threadId: "thread-done" as RelayAgentActivityState["threadId"], | ||
| 738 | phase: "completed", | ||
| 739 | updatedAt: "1970-01-01T00:50:00.000Z", | ||
| 740 | }; | ||
| 741 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 742 | activeStates: [active, staleCompleted], | ||
| 743 | terminalState: null, | ||
| 744 | nowMs: hourMs, | ||
| 745 | }); | ||
| 746 | |||
| 747 | expect(aggregate?.activities).toMatchObject([{ threadId: "thread-active" }]); | ||
| 748 | }); | ||
| 749 | |||
| 750 | it("keeps showing recently finished work when nothing is active", () => { | ||
| 751 | const lingeringCompleted: RelayAgentActivityState = { | ||
| 752 | ...state, | ||
| 753 | phase: "completed", | ||
| 754 | updatedAt: "1970-01-01T00:59:00.000Z", | ||
| 755 | }; | ||
| 756 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 757 | activeStates: [lingeringCompleted], | ||
| 758 | terminalState: null, | ||
| 759 | nowMs: hourMs, | ||
| 760 | }); | ||
| 761 | |||
| 762 | // An armed card never renders an empty state: recently finished threads | ||
| 763 | // keep Done content on it, and once they age out the aggregate becomes | ||
| 764 | // null and the delivery layer ends the card. | ||
| 765 | expect(aggregate).toMatchObject({ | ||
| 766 | activeCount: 0, | ||
| 767 | subtitle: "Agent work completed", | ||
| 768 | activities: [{ phase: "completed", status: "Done" }], | ||
| 769 | }); | ||
| 770 | expect( | ||
| 771 | AgentActivityPublisher.makeAggregateState({ | ||
| 772 | activeStates: [{ ...lingeringCompleted, updatedAt: "1970-01-01T00:50:00.000Z" }], | ||
| 773 | terminalState: null, | ||
| 774 | nowMs: hourMs, | ||
| 775 | }), | ||
| 776 | ).toBeNull(); | ||
| 777 | }); | ||
| 778 | |||
| 779 | it("gives active agents the display slots before finished ones", () => { | ||
| 780 | const mkActive = (id: string): RelayAgentActivityState => ({ | ||
| 781 | ...state, | ||
| 782 | threadId: id as RelayAgentActivityState["threadId"], | ||
| 783 | updatedAt: "1970-01-01T00:58:00.000Z", | ||
| 784 | }); | ||
| 785 | const justCompleted: RelayAgentActivityState = { | ||
| 786 | ...state, | ||
| 787 | threadId: "thread-done" as RelayAgentActivityState["threadId"], | ||
| 788 | phase: "completed", | ||
| 789 | updatedAt: "1970-01-01T00:59:00.000Z", | ||
| 790 | }; | ||
| 791 | const aggregate = AgentActivityPublisher.makeAggregateState({ | ||
| 792 | activeStates: [mkActive("a-1"), mkActive("a-2"), mkActive("a-3"), justCompleted], | ||
| 793 | terminalState: null, | ||
| 794 | nowMs: hourMs, | ||
| 795 | }); | ||
| 796 | |||
| 797 | expect(aggregate?.activeCount).toBe(3); | ||
| 798 | expect(aggregate?.activities).toMatchObject([ | ||
| 799 | { threadId: "a-1" }, | ||
| 800 | { threadId: "a-2" }, | ||
| 801 | { threadId: "a-3" }, | ||
| 802 | ]); | ||
| 803 | }); | ||
| 804 | }); |
infra/relay/src/agentActivity/AgentActivityPublisher.ts+78-13| ... | @@ -8,8 +8,15 @@ import * as Context from "effect/Context"; | ... | @@ -8,8 +8,15 @@ import * as Context from "effect/Context"; |
| 8 | import * as DateTime from "effect/DateTime"; | 8 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | ||
| 11 | 12 | ||
| 12 | import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; | 13 | import { |
| 14 | isExpiredAgentActivityState, | ||
| 15 | isTerminalPhase, | ||
| 16 | sanitizeAgentActivityAggregateState, | ||
| 17 | } from "./agentActivityPayloads.ts"; | ||
| 18 | |||
| 19 | export { isExpiredAgentActivityState } from "./agentActivityPayloads.ts"; | ||
| 13 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | 20 | import * as AgentActivityRows from "./AgentActivityRows.ts"; |
| 14 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | 21 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; |
| 15 | import * as LiveActivities from "./LiveActivities.ts"; | 22 | import * as LiveActivities from "./LiveActivities.ts"; |
| ... | @@ -55,6 +62,7 @@ export const make = Effect.gen(function* () { | ... | @@ -55,6 +62,7 @@ export const make = Effect.gen(function* () { |
| 55 | ? makeAggregateState({ | 62 | ? makeAggregateState({ |
| 56 | activeStates, | 63 | activeStates, |
| 57 | terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, | 64 | terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, |
| 65 | nowMs: input.nowMs, | ||
| 58 | }) | 66 | }) |
| 59 | : null; | 67 | : null; |
| 60 | const notificationOnlyAggregate = | 68 | const notificationOnlyAggregate = |
| ... | @@ -64,6 +72,7 @@ export const make = Effect.gen(function* () { | ... | @@ -64,6 +72,7 @@ export const make = Effect.gen(function* () { |
| 64 | ? makeAggregateState({ | 72 | ? makeAggregateState({ |
| 65 | activeStates: isTerminalPhase(input.state) ? [] : [input.state], | 73 | activeStates: isTerminalPhase(input.state) ? [] : [input.state], |
| 66 | terminalState: isTerminalPhase(input.state) ? input.state : null, | 74 | terminalState: isTerminalPhase(input.state) ? input.state : null, |
| 75 | nowMs: input.nowMs, | ||
| 67 | }) | 76 | }) |
| 68 | : null; | 77 | : null; |
| 69 | const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); | 78 | const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); |
| ... | @@ -110,8 +119,12 @@ export const make = Effect.gen(function* () { | ... | @@ -110,8 +119,12 @@ export const make = Effect.gen(function* () { |
| 110 | if (target === null) { | 119 | if (target === null) { |
| 111 | return null; | 120 | return null; |
| 112 | } | 121 | } |
| 113 | const aggregate = makeAggregateState({ activeStates, terminalState: null }); | ||
| 114 | const now = yield* DateTime.now; | 122 | const now = yield* DateTime.now; |
| 123 | const aggregate = makeAggregateState({ | ||
| 124 | activeStates, | ||
| 125 | terminalState: null, | ||
| 126 | nowMs: now.epochMilliseconds, | ||
| 127 | }); | ||
| 115 | return yield* apnsDeliveries.sendForTarget({ | 128 | return yield* apnsDeliveries.sendForTarget({ |
| 116 | target, | 129 | target, |
| 117 | aggregate, | 130 | aggregate, |
| ... | @@ -124,7 +137,11 @@ export const make = Effect.gen(function* () { | ... | @@ -124,7 +137,11 @@ export const make = Effect.gen(function* () { |
| 124 | "relay.thread_id": input.threadId, | 137 | "relay.thread_id": input.threadId, |
| 125 | "relay.agent_activity.phase": input.state?.phase ?? "deleted", | 138 | "relay.agent_activity.phase": input.state?.phase ?? "deleted", |
| 126 | }); | 139 | }); |
| 127 | if (input.state && !isTerminalPhase(input.state)) { | 140 | if (input.state) { |
| 141 | // Terminal states are persisted too (pruned by the cron after they | ||
| 142 | // age out) so a thread that finishes while other agents are active | ||
| 143 | // stays visible as Done/Failed in subsequent aggregates instead of | ||
| 144 | // silently vanishing from the Live Activity. | ||
| 128 | yield* rows.upsert({ | 145 | yield* rows.upsert({ |
| 129 | environmentPublicKey: input.environmentPublicKey, | 146 | environmentPublicKey: input.environmentPublicKey, |
| 130 | state: input.state, | 147 | state: input.state, |
| ... | @@ -174,7 +191,9 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { | ... | @@ -174,7 +191,9 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { |
| 174 | case "failed": | 191 | case "failed": |
| 175 | return "Failed"; | 192 | return "Failed"; |
| 176 | case "starting": | 193 | case "starting": |
| 177 | return "Starting"; | 194 | // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same |
| 195 | // thread reads the same across surfaces. | ||
| 196 | return "Connecting"; | ||
| 178 | case "running": | 197 | case "running": |
| 179 | return "Working"; | 198 | return "Working"; |
| 180 | case "stale": | 199 | case "stale": |
| ... | @@ -182,10 +201,6 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { | ... | @@ -182,10 +201,6 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { |
| 182 | } | 201 | } |
| 183 | } | 202 | } |
| 184 | 203 | ||
| 185 | function isTerminalPhase(state: RelayAgentActivityState): boolean { | ||
| 186 | return state.phase === "completed" || state.phase === "failed"; | ||
| 187 | } | ||
| 188 | |||
| 189 | function aggregateRowForState(state: RelayAgentActivityState) { | 204 | function aggregateRowForState(state: RelayAgentActivityState) { |
| 190 | return { | 205 | return { |
| 191 | environmentId: state.environmentId, | 206 | environmentId: state.environmentId, |
| ... | @@ -210,15 +225,65 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv | ... | @@ -210,15 +225,65 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv |
| 210 | }); | 225 | }); |
| 211 | } | 226 | } |
| 212 | 227 | ||
| 213 | function makeAggregateState(input: { | 228 | // How long a finished thread keeps its Done/Failed row in the aggregate while |
| 229 | // other agents are still active. Long enough to be seen on the lock screen, | ||
| 230 | // short enough that the activity list stays about live work. | ||
| 231 | export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 5 * 60 * 1_000; | ||
| 232 | |||
| 233 | function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { | ||
| 234 | if (!isTerminalPhase(state)) { | ||
| 235 | return false; | ||
| 236 | } | ||
| 237 | const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { | ||
| 238 | onNone: () => Number.NaN, | ||
| 239 | onSome: (dt) => dt.epochMilliseconds, | ||
| 240 | }); | ||
| 241 | if (Number.isNaN(updatedAtMs)) { | ||
| 242 | return false; | ||
| 243 | } | ||
| 244 | return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; | ||
| 245 | } | ||
| 246 | |||
| 247 | export function makeAggregateState(input: { | ||
| 214 | readonly activeStates: ReadonlyArray<RelayAgentActivityState>; | 248 | readonly activeStates: ReadonlyArray<RelayAgentActivityState>; |
| 215 | readonly terminalState: RelayAgentActivityState | null; | 249 | readonly terminalState: RelayAgentActivityState | null; |
| 250 | readonly nowMs: number; | ||
| 216 | }): RelayAgentActivityAggregateState | null { | 251 | }): RelayAgentActivityAggregateState | null { |
| 217 | const activeStates = input.activeStates.filter((state) => !isTerminalPhase(state)); | 252 | const activeStates = input.activeStates.filter( |
| 253 | (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), | ||
| 254 | ); | ||
| 218 | if (activeStates.length === 0) { | 255 | if (activeStates.length === 0) { |
| 219 | return input.terminalState === null ? null : terminalAggregateState(input.terminalState); | 256 | if (input.terminalState !== null) { |
| 257 | return terminalAggregateState(input.terminalState); | ||
| 258 | } | ||
| 259 | // With no live work, recently finished threads keep the card showing | ||
| 260 | // Done/Failed content (an armed card never renders an empty state). The | ||
| 261 | // newly-terminal alert rules key off the previously delivered aggregate, | ||
| 262 | // so replays repaint this without buzzing. Once the terminal rows age | ||
| 263 | // out, the aggregate is null and the delivery layer ends the card. | ||
| 264 | const recentTerminal = input.activeStates | ||
| 265 | .filter((state) => isRecentTerminalState(state, input.nowMs)) | ||
| 266 | .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); | ||
| 267 | const newest = recentTerminal[0]; | ||
| 268 | if (!newest) { | ||
| 269 | return null; | ||
| 270 | } | ||
| 271 | return sanitizeAgentActivityAggregateState({ | ||
| 272 | title: "T3 Code", | ||
| 273 | subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", | ||
| 274 | activeCount: 0, | ||
| 275 | updatedAt: newest.updatedAt, | ||
| 276 | activities: recentTerminal.slice(0, 3).map(aggregateRowForState), | ||
| 277 | }); | ||
| 220 | } | 278 | } |
| 221 | const updatedAt = activeStates.reduce((latest, state) => | 279 | // Recently finished threads ride along after the active ones (display slots |
| 280 | // permitting) so a completion is visible as Done/Failed instead of the row | ||
| 281 | // silently vanishing while other agents keep the activity alive. | ||
| 282 | const recentTerminalStates = input.activeStates | ||
| 283 | .filter((state) => isRecentTerminalState(state, input.nowMs)) | ||
| 284 | .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); | ||
| 285 | const displayedStates = [...activeStates.slice(0, 3), ...recentTerminalStates].slice(0, 3); | ||
| 286 | const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => | ||
| 222 | state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, | 287 | state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, |
| 223 | ).updatedAt; | 288 | ).updatedAt; |
| 224 | return sanitizeAgentActivityAggregateState({ | 289 | return sanitizeAgentActivityAggregateState({ |
| ... | @@ -226,7 +291,7 @@ function makeAggregateState(input: { | ... | @@ -226,7 +291,7 @@ function makeAggregateState(input: { |
| 226 | subtitle: "Agent work in progress", | 291 | subtitle: "Agent work in progress", |
| 227 | activeCount: activeStates.length, | 292 | activeCount: activeStates.length, |
| 228 | updatedAt, | 293 | updatedAt, |
| 229 | activities: activeStates.slice(0, 3).map(aggregateRowForState), | 294 | activities: displayedStates.map(aggregateRowForState), |
| 230 | }); | 295 | }); |
| 231 | } | 296 | } |
| 232 | 297 |
infra/relay/src/agentActivity/AgentActivityRows.ts+39-1| ... | @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; | ... | @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; |
| 7 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import { and, desc, eq, isNull } from "drizzle-orm"; | 10 | import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; |
| 11 | 11 | ||
| 12 | import * as RelayDb from "../db.ts"; | 12 | import * as RelayDb from "../db.ts"; |
| 13 | import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; | 13 | import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; |
| ... | @@ -38,6 +38,18 @@ export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorCl | ... | @@ -38,6 +38,18 @@ export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorCl |
| 38 | } | 38 | } |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | export class AgentActivityRowPruneTerminalPersistenceError extends Schema.TaggedErrorClass<AgentActivityRowPruneTerminalPersistenceError>()( | ||
| 42 | "AgentActivityRowPruneTerminalPersistenceError", | ||
| 43 | { | ||
| 44 | updatedBefore: Schema.String, | ||
| 45 | cause: Schema.Defect(), | ||
| 46 | }, | ||
| 47 | ) { | ||
| 48 | override get message(): string { | ||
| 49 | return `Failed to prune terminal agent activity rows updated before ${this.updatedBefore}.`; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 41 | export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass<AgentActivityRowListPersistenceError>()( | 53 | export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass<AgentActivityRowListPersistenceError>()( |
| 42 | "AgentActivityRowListPersistenceError", | 54 | "AgentActivityRowListPersistenceError", |
| 43 | { | 55 | { |
| ... | @@ -57,6 +69,9 @@ export class AgentActivityRows extends Context.Service< | ... | @@ -57,6 +69,9 @@ export class AgentActivityRows extends Context.Service< |
| 57 | readonly environmentPublicKey: string; | 69 | readonly environmentPublicKey: string; |
| 58 | readonly state: RelayAgentActivityState; | 70 | readonly state: RelayAgentActivityState; |
| 59 | }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>; | 71 | }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>; |
| 72 | readonly pruneTerminal: (input: { | ||
| 73 | readonly updatedBefore: string; | ||
| 74 | }) => Effect.Effect<void, AgentActivityRowPruneTerminalPersistenceError>; | ||
| 60 | readonly remove: (input: { | 75 | readonly remove: (input: { |
| 61 | readonly environmentId: string; | 76 | readonly environmentId: string; |
| 62 | readonly environmentPublicKey: string; | 77 | readonly environmentPublicKey: string; |
| ... | @@ -163,6 +178,29 @@ export const make = Effect.gen(function* () { | ... | @@ -163,6 +178,29 @@ export const make = Effect.gen(function* () { |
| 163 | ); | 178 | ); |
| 164 | }), | 179 | }), |
| 165 | 180 | ||
| 181 | pruneTerminal: Effect.fn("relay.agent_activity_rows.prune_terminal")(function* (input) { | ||
| 182 | yield* Effect.annotateCurrentSpan({ | ||
| 183 | "relay.agent_activity_prune.before": input.updatedBefore, | ||
| 184 | }); | ||
| 185 | yield* db | ||
| 186 | .delete(relayAgentActivityRows) | ||
| 187 | .where( | ||
| 188 | and( | ||
| 189 | sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, | ||
| 190 | lt(relayAgentActivityRows.updatedAt, input.updatedBefore), | ||
| 191 | ), | ||
| 192 | ) | ||
| 193 | .pipe( | ||
| 194 | Effect.mapError( | ||
| 195 | (cause) => | ||
| 196 | new AgentActivityRowPruneTerminalPersistenceError({ | ||
| 197 | updatedBefore: input.updatedBefore, | ||
| 198 | cause, | ||
| 199 | }), | ||
| 200 | ), | ||
| 201 | ); | ||
| 202 | }), | ||
| 203 | |||
| 166 | listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { | 204 | listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { |
| 167 | return yield* db | 205 | return yield* db |
| 168 | .select({ stateJson: relayAgentActivityRows.stateJson }) | 206 | .select({ stateJson: relayAgentActivityRows.stateJson }) |
infra/relay/src/agentActivity/ApnsClient.test.ts+108-1| ... | @@ -10,14 +10,17 @@ import * as Redacted from "effect/Redacted"; | ... | @@ -10,14 +10,17 @@ import * as Redacted from "effect/Redacted"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 11 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 12 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | 12 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; |
| 13 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | ||
| 13 | 14 | ||
| 14 | import type { ApnsCredentials } from "../Config.ts"; | 15 | import type { ApnsCredentials } from "../Config.ts"; |
| 15 | import * as ApnsClient from "./ApnsClient.ts"; | 16 | import * as ApnsClient from "./ApnsClient.ts"; |
| 17 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; | ||
| 16 | 18 | ||
| 17 | const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); | 19 | const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); |
| 18 | const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); | 20 | const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); |
| 19 | 21 | ||
| 20 | const TestLayer = ApnsClient.layer.pipe( | 22 | const TestLayer = ApnsClient.layer.pipe( |
| 23 | Layer.provide(ApnsProviderTokens.layer), | ||
| 21 | Layer.provide( | 24 | Layer.provide( |
| 22 | Layer.succeed( | 25 | Layer.succeed( |
| 23 | HttpClient.HttpClient, | 26 | HttpClient.HttpClient, |
| ... | @@ -96,6 +99,32 @@ describe("ApnsClient", () => { | ... | @@ -96,6 +99,32 @@ describe("ApnsClient", () => { |
| 96 | }).pipe(Effect.provide(TestLayer)), | 99 | }).pipe(Effect.provide(TestLayer)), |
| 97 | ); | 100 | ); |
| 98 | 101 | ||
| 102 | it.effect("builds a high-priority alerting update payload when an alert is attached", () => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const apns = yield* ApnsClient.ApnsClient; | ||
| 105 | const request = apns.makeLiveActivityRequest({ | ||
| 106 | event: "update", | ||
| 107 | token: "token", | ||
| 108 | state, | ||
| 109 | alert: { title: "Thread", body: "Approval: Project" }, | ||
| 110 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 111 | nowIso: DateTime.formatIso(now), | ||
| 112 | }); | ||
| 113 | |||
| 114 | expect(request.priority).toBe("10"); | ||
| 115 | expect(request.payload).toMatchObject({ | ||
| 116 | aps: { | ||
| 117 | event: "update", | ||
| 118 | alert: { | ||
| 119 | title: "Thread", | ||
| 120 | body: "Approval: Project", | ||
| 121 | sound: "default", | ||
| 122 | }, | ||
| 123 | }, | ||
| 124 | }); | ||
| 125 | }).pipe(Effect.provide(TestLayer)), | ||
| 126 | ); | ||
| 127 | |||
| 99 | it.effect("builds an end payload with a dismissal date", () => | 128 | it.effect("builds an end payload with a dismissal date", () => |
| 100 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 101 | const apns = yield* ApnsClient.ApnsClient; | 130 | const apns = yield* ApnsClient.ApnsClient; |
| ... | @@ -114,6 +143,22 @@ describe("ApnsClient", () => { | ... | @@ -114,6 +143,22 @@ describe("ApnsClient", () => { |
| 114 | "dismissal-date": 300, | 143 | "dismissal-date": 300, |
| 115 | }, | 144 | }, |
| 116 | }); | 145 | }); |
| 146 | |||
| 147 | // Without final content the card would freeze on its previous state; | ||
| 148 | // contentless ends dismiss quickly instead. | ||
| 149 | const contentless = apns.makeLiveActivityRequest({ | ||
| 150 | event: "end", | ||
| 151 | token: "token", | ||
| 152 | state: null, | ||
| 153 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 154 | nowIso: DateTime.formatIso(now), | ||
| 155 | }); | ||
| 156 | expect(contentless.payload).toMatchObject({ | ||
| 157 | aps: { | ||
| 158 | event: "end", | ||
| 159 | "dismissal-date": 15, | ||
| 160 | }, | ||
| 161 | }); | ||
| 117 | }).pipe(Effect.provide(TestLayer)), | 162 | }).pipe(Effect.provide(TestLayer)), |
| 118 | ); | 163 | ); |
| 119 | 164 | ||
| ... | @@ -181,7 +226,10 @@ describe("ApnsClient", () => { | ... | @@ -181,7 +226,10 @@ describe("ApnsClient", () => { |
| 181 | expect(error).toMatchObject({ | 226 | expect(error).toMatchObject({ |
| 182 | teamId: "team-1", | 227 | teamId: "team-1", |
| 183 | keyId: "key-1", | 228 | keyId: "key-1", |
| 184 | issuedAtUnixSeconds: 123, | 229 | // The provider-token service quantizes iat to the reuse window, so |
| 230 | // the signing context carries the window start rather than the raw | ||
| 231 | // request time. | ||
| 232 | issuedAtUnixSeconds: 0, | ||
| 185 | cause: expect.any(Error), | 233 | cause: expect.any(Error), |
| 186 | message: "Failed to sign APNs JWT for key key-1.", | 234 | message: "Failed to sign APNs JWT for key key-1.", |
| 187 | }); | 235 | }); |
| ... | @@ -210,6 +258,7 @@ describe("ApnsClient", () => { | ... | @@ -210,6 +258,7 @@ describe("ApnsClient", () => { |
| 210 | ), | 258 | ), |
| 211 | ); | 259 | ); |
| 212 | const layer = ApnsClient.layer.pipe( | 260 | const layer = ApnsClient.layer.pipe( |
| 261 | Layer.provide(ApnsProviderTokens.layer), | ||
| 213 | Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), | 262 | Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), |
| 214 | ); | 263 | ); |
| 215 | 264 | ||
| ... | @@ -254,4 +303,62 @@ describe("ApnsClient", () => { | ... | @@ -254,4 +303,62 @@ describe("ApnsClient", () => { |
| 254 | }); | 303 | }); |
| 255 | }).pipe(Effect.provide(layer)); | 304 | }).pipe(Effect.provide(layer)); |
| 256 | }); | 305 | }); |
| 306 | |||
| 307 | it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { | ||
| 308 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 309 | const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { | ||
| 310 | namedCurve: "prime256v1", | ||
| 311 | privateKeyEncoding: { type: "pkcs8", format: "pem" }, | ||
| 312 | publicKeyEncoding: { type: "spki", format: "pem" }, | ||
| 313 | }); | ||
| 314 | const credentials = { | ||
| 315 | teamId: "team-jwt-cache", | ||
| 316 | keyId: "key-jwt-cache", | ||
| 317 | privateKey: Redacted.make(privateKey), | ||
| 318 | bundleId: "com.t3tools.test", | ||
| 319 | environment: "sandbox", | ||
| 320 | } satisfies ApnsCredentials; | ||
| 321 | const authorizations: Array<string> = []; | ||
| 322 | const capturingHttpClient = HttpClient.make((request) => { | ||
| 323 | authorizations.push(request.headers.authorization ?? ""); | ||
| 324 | return Effect.succeed( | ||
| 325 | HttpClientResponse.fromWeb( | ||
| 326 | request, | ||
| 327 | new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }), | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | }); | ||
| 331 | const layer = ApnsClient.layer.pipe( | ||
| 332 | Layer.provide(ApnsProviderTokens.layer), | ||
| 333 | Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), | ||
| 334 | ); | ||
| 335 | |||
| 336 | return Effect.gen(function* () { | ||
| 337 | const apns = yield* ApnsClient.ApnsClient; | ||
| 338 | const request = apns.makePushNotificationRequest({ | ||
| 339 | token: "push-token", | ||
| 340 | notification: { | ||
| 341 | title: "Thread", | ||
| 342 | body: "Input: Project", | ||
| 343 | environmentId: "env", | ||
| 344 | threadId: "thread", | ||
| 345 | deepLink: "/threads/env/thread", | ||
| 346 | }, | ||
| 347 | }); | ||
| 348 | const send = (issuedAtUnixSeconds: number) => | ||
| 349 | apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); | ||
| 350 | |||
| 351 | const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; | ||
| 352 | yield* send(window + 10); | ||
| 353 | yield* send(window * 2 - 1); | ||
| 354 | yield* send(window * 2); | ||
| 355 | |||
| 356 | expect(authorizations).toHaveLength(3); | ||
| 357 | // Within the 45-minute window APNs must see the byte-identical token; | ||
| 358 | // refreshing it per push trips TooManyProviderTokenUpdates. | ||
| 359 | expect(authorizations[1]).toBe(authorizations[0]); | ||
| 360 | expect(authorizations[2]).not.toBe(authorizations[0]); | ||
| 361 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 362 | }).pipe(Effect.provide(layer)); | ||
| 363 | }); | ||
| 257 | }); | 364 | }); |
infra/relay/src/agentActivity/ApnsClient.ts+39-115| ... | @@ -1,22 +1,30 @@ | ... | @@ -1,22 +1,30 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; | 1 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; |
| 4 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 9 | import * as Redacted from "effect/Redacted"; | ||
| 10 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 11 | import * as Headers from "effect/unstable/http/Headers"; | 7 | import * as Headers from "effect/unstable/http/Headers"; |
| 12 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 8 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 13 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | 9 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; |
| 14 | import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; | 10 | import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; |
| 15 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; | 11 | import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 12 | import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; | ||
| 13 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; | ||
| 14 | |||
| 15 | export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; | ||
| 16 | 16 | ||
| 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; | 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; |
| 18 | const STALE_AFTER_SECONDS = 2 * 60; | 18 | // Updates only flow on domain events, so a healthy agent can be silent for |
| 19 | // minutes (long tool calls, pending approvals). Two minutes made iOS dim | ||
| 20 | // perfectly healthy activities; ten minutes still bounds how long a dead | ||
| 21 | // environment can look alive. | ||
| 22 | const STALE_AFTER_SECONDS = 10 * 60; | ||
| 19 | const DISMISS_AFTER_SECONDS = 5 * 60; | 23 | const DISMISS_AFTER_SECONDS = 5 * 60; |
| 24 | // An end without a final content-state leaves whatever the card last showed | ||
| 25 | // frozen on the lock screen until dismissal — get it off quickly instead of | ||
| 26 | // parading stale state for the full window. | ||
| 27 | const CONTENTLESS_DISMISS_AFTER_SECONDS = 15; | ||
| 20 | 28 | ||
| 21 | const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); | 29 | const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); |
| 22 | export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; | 30 | export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; |
| ... | @@ -43,35 +51,6 @@ export interface ApnsDeliveryResult { | ... | @@ -43,35 +51,6 @@ export interface ApnsDeliveryResult { |
| 43 | readonly apnsId: string | null; | 51 | readonly apnsId: string | null; |
| 44 | } | 52 | } |
| 45 | 53 | ||
| 46 | export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()( | ||
| 47 | "ApnsJwtEncodingError", | ||
| 48 | { | ||
| 49 | component: Schema.Literals(["header", "payload"]), | ||
| 50 | teamId: Schema.String, | ||
| 51 | keyId: Schema.String, | ||
| 52 | issuedAtUnixSeconds: Schema.Number, | ||
| 53 | cause: Schema.Defect(), | ||
| 54 | }, | ||
| 55 | ) { | ||
| 56 | override get message(): string { | ||
| 57 | return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()( | ||
| 62 | "ApnsJwtSigningError", | ||
| 63 | { | ||
| 64 | teamId: Schema.String, | ||
| 65 | keyId: Schema.String, | ||
| 66 | issuedAtUnixSeconds: Schema.Number, | ||
| 67 | cause: Schema.Defect(), | ||
| 68 | }, | ||
| 69 | ) { | ||
| 70 | override get message(): string { | ||
| 71 | return `Failed to sign APNs JWT for key ${this.keyId}.`; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()( | 54 | export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()( |
| 76 | "ApnsHttpRequestError", | 55 | "ApnsHttpRequestError", |
| 77 | { | 56 | { |
| ... | @@ -104,82 +83,6 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( | ... | @@ -104,82 +83,6 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( |
| 104 | }), | 83 | }), |
| 105 | ), | 84 | ), |
| 106 | ); | 85 | ); |
| 107 | const encodeApnsJwtHeaderJson = Schema.encodeEffect( | ||
| 108 | Schema.fromJsonString( | ||
| 109 | Schema.Struct({ | ||
| 110 | alg: Schema.Literal("ES256"), | ||
| 111 | kid: Schema.String, | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ); | ||
| 115 | const encodeApnsJwtPayloadJson = Schema.encodeEffect( | ||
| 116 | Schema.fromJsonString( | ||
| 117 | Schema.Struct({ | ||
| 118 | iss: Schema.String, | ||
| 119 | iat: Schema.Number, | ||
| 120 | }), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | |||
| 124 | const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { | ||
| 125 | readonly teamId: ApnsCredentials["teamId"]; | ||
| 126 | readonly keyId: ApnsCredentials["keyId"]; | ||
| 127 | readonly privateKey: ApnsCredentials["privateKey"]; | ||
| 128 | readonly issuedAtUnixSeconds: number; | ||
| 129 | }) { | ||
| 130 | const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( | ||
| 131 | Effect.mapError( | ||
| 132 | (cause) => | ||
| 133 | new ApnsJwtEncodingError({ | ||
| 134 | component: "header", | ||
| 135 | teamId: input.teamId, | ||
| 136 | keyId: input.keyId, | ||
| 137 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 138 | cause, | ||
| 139 | }), | ||
| 140 | ), | ||
| 141 | ); | ||
| 142 | const payloadJson = yield* encodeApnsJwtPayloadJson({ | ||
| 143 | iss: input.teamId, | ||
| 144 | iat: input.issuedAtUnixSeconds, | ||
| 145 | }).pipe( | ||
| 146 | Effect.mapError( | ||
| 147 | (cause) => | ||
| 148 | new ApnsJwtEncodingError({ | ||
| 149 | component: "payload", | ||
| 150 | teamId: input.teamId, | ||
| 151 | keyId: input.keyId, | ||
| 152 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 153 | cause, | ||
| 154 | }), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | |||
| 158 | const privateKey = Redacted.value(input.privateKey); | ||
| 159 | const header = Encoding.encodeBase64Url(headerJson); | ||
| 160 | const payload = Encoding.encodeBase64Url(payloadJson); | ||
| 161 | const signingInput = `${header}.${payload}`; | ||
| 162 | |||
| 163 | return yield* Effect.try({ | ||
| 164 | try: () => { | ||
| 165 | const signature = NodeCrypto.createSign("sha256") | ||
| 166 | .update(signingInput) | ||
| 167 | .sign({ | ||
| 168 | key: privateKey.replace(/\\n/g, "\n"), | ||
| 169 | dsaEncoding: "ieee-p1363", | ||
| 170 | }); | ||
| 171 | return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; | ||
| 172 | }, | ||
| 173 | catch: (cause) => | ||
| 174 | new ApnsJwtSigningError({ | ||
| 175 | teamId: input.teamId, | ||
| 176 | keyId: input.keyId, | ||
| 177 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 178 | cause, | ||
| 179 | }), | ||
| 180 | }); | ||
| 181 | }); | ||
| 182 | |||
| 183 | function contentState(state: RelayAgentActivityAggregateState) { | 86 | function contentState(state: RelayAgentActivityAggregateState) { |
| 184 | return { | 87 | return { |
| 185 | name: LIVE_ACTIVITY_NAME, | 88 | name: LIVE_ACTIVITY_NAME, |
| ... | @@ -197,12 +100,27 @@ type MakeLiveActivityRequestInput = | ... | @@ -197,12 +100,27 @@ type MakeLiveActivityRequestInput = |
| 197 | | (LiveActivityRequestBase & { | 100 | | (LiveActivityRequestBase & { |
| 198 | readonly event: "end"; | 101 | readonly event: "end"; |
| 199 | readonly state: RelayAgentActivityAggregateState | null; | 102 | readonly state: RelayAgentActivityAggregateState | null; |
| 103 | readonly alert?: ApnsLiveActivityAlert | null; | ||
| 200 | }) | 104 | }) |
| 201 | | (LiveActivityRequestBase & { | 105 | | (LiveActivityRequestBase & { |
| 202 | readonly event: "start" | "update"; | 106 | readonly event: "start" | "update"; |
| 203 | readonly state: RelayAgentActivityAggregateState; | 107 | readonly state: RelayAgentActivityAggregateState; |
| 108 | readonly alert?: ApnsLiveActivityAlert | null; | ||
| 204 | }); | 109 | }); |
| 205 | 110 | ||
| 111 | // An alert dict on an update/end makes it an "alerting" update: iOS wakes the | ||
| 112 | // screen and plays the haptic (the Apple Sports score-change behavior) instead | ||
| 113 | // of silently redrawing the activity. | ||
| 114 | function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) { | ||
| 115 | return { | ||
| 116 | alert: { | ||
| 117 | title: alert.title, | ||
| 118 | body: alert.body, | ||
| 119 | sound: "default", | ||
| 120 | }, | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | |||
| 206 | function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { | 124 | function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { |
| 207 | const timestamp = input.nowEpochSeconds; | 125 | const timestamp = input.nowEpochSeconds; |
| 208 | if (input.event === "end") { | 126 | if (input.event === "end") { |
| ... | @@ -215,7 +133,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA | ... | @@ -215,7 +133,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA |
| 215 | timestamp, | 133 | timestamp, |
| 216 | event: "end", | 134 | event: "end", |
| 217 | ...(input.state ? { "content-state": contentState(input.state) } : {}), | 135 | ...(input.state ? { "content-state": contentState(input.state) } : {}), |
| 218 | "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, | 136 | ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), |
| 137 | "dismissal-date": | ||
| 138 | timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS), | ||
| 219 | }, | 139 | }, |
| 220 | }, | 140 | }, |
| 221 | }; | 141 | }; |
| ... | @@ -225,7 +145,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA | ... | @@ -225,7 +145,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA |
| 225 | return { | 145 | return { |
| 226 | token: input.token, | 146 | token: input.token, |
| 227 | event: input.event, | 147 | event: input.event, |
| 228 | priority: input.event === "update" ? "5" : "10", | 148 | // Alerting updates must land immediately; routine redraws stay at the |
| 149 | // budget-friendly low priority. | ||
| 150 | priority: input.event === "update" && !input.alert ? "5" : "10", | ||
| 229 | payload: { | 151 | payload: { |
| 230 | aps: { | 152 | aps: { |
| 231 | timestamp, | 153 | timestamp, |
| ... | @@ -241,6 +163,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA | ... | @@ -241,6 +163,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA |
| 241 | }, | 163 | }, |
| 242 | } | 164 | } |
| 243 | : {}), | 165 | : {}), |
| 166 | ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}), | ||
| 244 | "content-state": contentState(state), | 167 | "content-state": contentState(state), |
| 245 | "stale-date": timestamp + STALE_AFTER_SECONDS, | 168 | "stale-date": timestamp + STALE_AFTER_SECONDS, |
| 246 | }, | 169 | }, |
| ... | @@ -300,12 +223,13 @@ export class ApnsClient extends Context.Service< | ... | @@ -300,12 +223,13 @@ export class ApnsClient extends Context.Service< |
| 300 | 223 | ||
| 301 | export const make = Effect.gen(function* () { | 224 | export const make = Effect.gen(function* () { |
| 302 | const httpClient = yield* HttpClient.HttpClient; | 225 | const httpClient = yield* HttpClient.HttpClient; |
| 226 | const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens; | ||
| 303 | 227 | ||
| 304 | const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( | 228 | const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( |
| 305 | "relay.apns.send_live_activity_request", | 229 | "relay.apns.send_live_activity_request", |
| 306 | )(function* (input) { | 230 | )(function* (input) { |
| 307 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); | 231 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); |
| 308 | const jwt = yield* makeApnsJwt({ | 232 | const jwt = yield* providerTokens.getJwt({ |
| 309 | ...input.credentials, | 233 | ...input.credentials, |
| 310 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | 234 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, |
| 311 | }); | 235 | }); |
| ... | @@ -363,7 +287,7 @@ export const make = Effect.gen(function* () { | ... | @@ -363,7 +287,7 @@ export const make = Effect.gen(function* () { |
| 363 | const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = | 287 | const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = |
| 364 | Effect.fn("relay.apns.send_push_notification_request")(function* (input) { | 288 | Effect.fn("relay.apns.send_push_notification_request")(function* (input) { |
| 365 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); | 289 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); |
| 366 | const jwt = yield* makeApnsJwt({ | 290 | const jwt = yield* providerTokens.getJwt({ |
| 367 | ...input.credentials, | 291 | ...input.credentials, |
| 368 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | 292 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, |
| 369 | }); | 293 | }); |
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+447-32| ... | @@ -5,6 +5,7 @@ import type { | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | 5 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as NodeCrypto from "node:crypto"; | 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 10 | import * as Logger from "effect/Logger"; | 11 | import * as Logger from "effect/Logger"; |
| ... | @@ -26,8 +27,10 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | ... | @@ -26,8 +27,10 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts"; |
| 26 | import * as LiveActivities from "./LiveActivities.ts"; | 27 | import * as LiveActivities from "./LiveActivities.ts"; |
| 27 | import * as RelayConfiguration from "../Config.ts"; | 28 | import * as RelayConfiguration from "../Config.ts"; |
| 28 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | 29 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; |
| 30 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 29 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | 31 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; |
| 30 | import * as ApnsClient from "./ApnsClient.ts"; | 32 | import * as ApnsClient from "./ApnsClient.ts"; |
| 33 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; | ||
| 31 | 34 | ||
| 32 | const config = RelayConfiguration.RelayConfiguration.of({ | 35 | const config = RelayConfiguration.RelayConfiguration.of({ |
| 33 | relayIssuer: "https://relay.example.test", | 36 | relayIssuer: "https://relay.example.test", |
| ... | @@ -127,6 +130,8 @@ const target: LiveActivities.TargetRow = { | ... | @@ -127,6 +130,8 @@ const target: LiveActivities.TargetRow = { |
| 127 | platform: "ios", | 130 | platform: "ios", |
| 128 | ios_major_version: 18, | 131 | ios_major_version: 18, |
| 129 | app_version: "1.0.0", | 132 | app_version: "1.0.0", |
| 133 | bundle_id: null, | ||
| 134 | aps_environment: null, | ||
| 130 | push_token: null, | 135 | push_token: null, |
| 131 | push_to_start_token: "start-token", | 136 | push_to_start_token: "start-token", |
| 132 | preferences_json: enabledPreferences, | 137 | preferences_json: enabledPreferences, |
| ... | @@ -156,15 +161,31 @@ function makeLayer(input: { | ... | @@ -156,15 +161,31 @@ function makeLayer(input: { |
| 156 | >; | 161 | >; |
| 157 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; | 162 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; |
| 158 | readonly config?: RelayConfiguration.RelayConfiguration["Service"]; | 163 | readonly config?: RelayConfiguration.RelayConfiguration["Service"]; |
| 164 | // Live agent-activity rows returned by the delivery-time start recheck. | ||
| 165 | // Defaults to one freshly-updated running thread so queued starts stay | ||
| 166 | // deliverable in tests that don't care about the recheck. | ||
| 167 | readonly activityStates?: ReadonlyArray<RelayAgentActivityState>; | ||
| 159 | readonly execute?: ( | 168 | readonly execute?: ( |
| 160 | request: HttpClientRequest.HttpClientRequest, | 169 | request: HttpClientRequest.HttpClientRequest, |
| 161 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>; | 170 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>; |
| 162 | }) { | 171 | }) { |
| 163 | return ApnsDeliveries.layer.pipe( | 172 | return ApnsDeliveries.layer.pipe( |
| 164 | Layer.provide(ApnsClient.layer), | 173 | Layer.provide(ApnsClient.layer), |
| 174 | Layer.provide(ApnsProviderTokens.layer), | ||
| 165 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), | 175 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), |
| 166 | Layer.provide( | 176 | Layer.provide( |
| 167 | Layer.mergeAll( | 177 | Layer.mergeAll( |
| 178 | Layer.succeed(AgentActivityRows.AgentActivityRows, { | ||
| 179 | upsert: () => Effect.void, | ||
| 180 | remove: () => Effect.void, | ||
| 181 | pruneTerminal: () => Effect.void, | ||
| 182 | listForUser: () => | ||
| 183 | input.activityStates !== undefined | ||
| 184 | ? Effect.succeed([...input.activityStates]) | ||
| 185 | : DateTime.now.pipe( | ||
| 186 | Effect.map((now) => [{ ...state, updatedAt: DateTime.formatIso(now) }]), | ||
| 187 | ), | ||
| 188 | } satisfies AgentActivityRows.AgentActivityRows["Service"]), | ||
| 168 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { | 189 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { |
| 169 | send: (body) => | 190 | send: (body) => |
| 170 | Effect.sync(() => { | 191 | Effect.sync(() => { |
| ... | @@ -225,62 +246,61 @@ function makeLayer(input: { | ... | @@ -225,62 +246,61 @@ function makeLayer(input: { |
| 225 | } | 246 | } |
| 226 | 247 | ||
| 227 | describe("ApnsDeliveries", () => { | 248 | describe("ApnsDeliveries", () => { |
| 228 | it.effect("queues a restart using the push-to-start token", () => { | 249 | it.effect("never starts an activity remotely when no update token is registered", () => { |
| 229 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 250 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 230 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | 251 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| 231 | const queuedStarts: Array< | 252 | const queuedStarts: Array< |
| 232 | Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0] | 253 | Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0] |
| 233 | > = []; | 254 | > = []; |
| 234 | const markedDeliveries: Array< | ||
| 235 | Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0] | ||
| 236 | > = []; | ||
| 237 | 255 | ||
| 238 | return Effect.gen(function* () { | 256 | return Effect.gen(function* () { |
| 239 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | 257 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; |
| 240 | const result = yield* deliveries.sendForTarget({ | 258 | const result = yield* deliveries.sendForTarget({ |
| 241 | target: { | 259 | target: { |
| 242 | ...target, | 260 | ...target, |
| 261 | activity_push_token: null, | ||
| 262 | remote_started_at: null, | ||
| 243 | ended_at: "1970-01-01T00:00:05.000Z", | 263 | ended_at: "1970-01-01T00:00:05.000Z", |
| 244 | }, | 264 | }, |
| 245 | aggregate, | 265 | aggregate, |
| 246 | nowMs: 10_000, | 266 | nowMs: 10_000, |
| 247 | }); | 267 | }); |
| 248 | 268 | ||
| 249 | expect(result?.kind).toBe("live_activity_start"); | 269 | // Activities are armed by the app in the foreground; the relay never |
| 250 | expect(result?.ok).toBe(true); | 270 | // uses the push-to-start token, so the only fallback is the push |
| 251 | expect(queuedJobs).toMatchObject([ | 271 | // notification channel (none here: the aggregate is not an attention |
| 252 | { | 272 | // phase). |
| 253 | payload: { | 273 | expect(result).toBeNull(); |
| 254 | kind: "live_activity_start", | 274 | expect(queuedJobs).toEqual([]); |
| 255 | target: { | 275 | expect(queuedStarts).toEqual([]); |
| 256 | token: "start-token", | ||
| 257 | }, | ||
| 258 | }, | ||
| 259 | }, | ||
| 260 | ]); | ||
| 261 | expect(attempts).toEqual([]); | 276 | expect(attempts).toEqual([]); |
| 262 | expect(queuedStarts).toMatchObject([ | 277 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts }))); |
| 263 | { | ||
| 264 | userId: target.user_id, | ||
| 265 | deviceId: target.device_id, | ||
| 266 | }, | ||
| 267 | ]); | ||
| 268 | expect(markedDeliveries).toEqual([]); | ||
| 269 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts, markedDeliveries }))); | ||
| 270 | }); | 278 | }); |
| 271 | 279 | ||
| 272 | it.effect("queues an end using the activity token", () => { | 280 | it.effect("ends the armed card when nothing remains to show", () => { |
| 273 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 281 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 274 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | 282 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| 275 | 283 | ||
| 276 | return Effect.gen(function* () { | 284 | return Effect.gen(function* () { |
| 277 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | 285 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; |
| 278 | const result = yield* deliveries.sendForTarget({ | 286 | // Within the freshly-armed grace window an empty aggregate delivers |
| 287 | // nothing: the environment's first publish may still be in flight. | ||
| 288 | const graced = yield* deliveries.sendForTarget({ | ||
| 279 | target, | 289 | target, |
| 280 | aggregate: null, | 290 | aggregate: null, |
| 281 | nowMs: 5_000, | 291 | nowMs: 5_000, |
| 282 | }); | 292 | }); |
| 293 | expect(graced).toBeNull(); | ||
| 283 | 294 | ||
| 295 | const result = yield* deliveries.sendForTarget({ | ||
| 296 | target, | ||
| 297 | aggregate: null, | ||
| 298 | nowMs: 5_000 + 3 * 60 * 1_000, | ||
| 299 | }); | ||
| 300 | |||
| 301 | // An armed card always shows content (live or recently finished work); | ||
| 302 | // once the aggregate is empty the card ends rather than rendering an | ||
| 303 | // empty state. The app re-arms on the next open with content. | ||
| 284 | expect(result?.kind).toBe("live_activity_end"); | 304 | expect(result?.kind).toBe("live_activity_end"); |
| 285 | expect(result?.ok).toBe(true); | 305 | expect(result?.ok).toBe(true); |
| 286 | expect(queuedJobs).toMatchObject([ | 306 | expect(queuedJobs).toMatchObject([ |
| ... | @@ -368,12 +388,7 @@ describe("ApnsDeliveries", () => { | ... | @@ -368,12 +388,7 @@ describe("ApnsDeliveries", () => { |
| 368 | return Effect.gen(function* () { | 388 | return Effect.gen(function* () { |
| 369 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | 389 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; |
| 370 | yield* deliveries.sendForTarget({ | 390 | yield* deliveries.sendForTarget({ |
| 371 | target: { | 391 | target, |
| 372 | ...target, | ||
| 373 | activity_push_token: null, | ||
| 374 | remote_started_at: null, | ||
| 375 | ended_at: "1970-01-01T00:00:05.000Z", | ||
| 376 | }, | ||
| 377 | aggregate: inputAggregate, | 392 | aggregate: inputAggregate, |
| 378 | nowMs: 10_000, | 393 | nowMs: 10_000, |
| 379 | }); | 394 | }); |
| ... | @@ -389,6 +404,196 @@ describe("ApnsDeliveries", () => { | ... | @@ -389,6 +404,196 @@ describe("ApnsDeliveries", () => { |
| 389 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | 404 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); |
| 390 | }); | 405 | }); |
| 391 | 406 | ||
| 407 | it.effect("queues Live Activity jobs with the device's APNs routing", () => { | ||
| 408 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 409 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 410 | |||
| 411 | return Effect.gen(function* () { | ||
| 412 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 413 | yield* deliveries.sendForTarget({ | ||
| 414 | target: { | ||
| 415 | ...target, | ||
| 416 | bundle_id: "com.t3tools.t3code.preview", | ||
| 417 | aps_environment: "production", | ||
| 418 | ended_at: "1970-01-01T00:00:05.000Z", | ||
| 419 | }, | ||
| 420 | aggregate, | ||
| 421 | nowMs: 10_000, | ||
| 422 | }); | ||
| 423 | |||
| 424 | expect(queuedJobs).toMatchObject([ | ||
| 425 | { | ||
| 426 | payload: { | ||
| 427 | kind: "live_activity_update", | ||
| 428 | target: { | ||
| 429 | token: "activity-token", | ||
| 430 | bundleId: "com.t3tools.t3code.preview", | ||
| 431 | apsEnvironment: "production", | ||
| 432 | }, | ||
| 433 | }, | ||
| 434 | }, | ||
| 435 | ]); | ||
| 436 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 437 | }); | ||
| 438 | |||
| 439 | it.effect("sends signed jobs to the device's APNs environment and bundle topic", () => { | ||
| 440 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 441 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | ||
| 442 | const payload = makeApnsDeliveryJobPayload({ | ||
| 443 | kind: "live_activity_update", | ||
| 444 | userId: target.user_id, | ||
| 445 | deviceId: target.device_id, | ||
| 446 | token: "activity-token", | ||
| 447 | bundleId: "com.t3tools.t3code.preview", | ||
| 448 | apsEnvironment: "sandbox", | ||
| 449 | aggregate, | ||
| 450 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 451 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 452 | jobId: "job-routing-1", | ||
| 453 | }); | ||
| 454 | const signed = signApnsDeliveryJob({ | ||
| 455 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 456 | payload, | ||
| 457 | }); | ||
| 458 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 459 | Effect.sync(() => { | ||
| 460 | requests.push(request); | ||
| 461 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 462 | }); | ||
| 463 | |||
| 464 | return Effect.gen(function* () { | ||
| 465 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 466 | const result = yield* deliveries.processSignedJob(signed); | ||
| 467 | |||
| 468 | expect(result.ok).toBe(true); | ||
| 469 | expect(requests).toHaveLength(1); | ||
| 470 | expect(requests[0]?.url).toBe("https://api.sandbox.push.apple.com/3/device/activity-token"); | ||
| 471 | expect(requests[0]?.headers["apns-topic"]).toBe( | ||
| 472 | "com.t3tools.t3code.preview.push-type.liveactivity", | ||
| 473 | ); | ||
| 474 | }).pipe( | ||
| 475 | Effect.provide( | ||
| 476 | makeLayer({ | ||
| 477 | attempts, | ||
| 478 | config: signingConfig, | ||
| 479 | execute, | ||
| 480 | }), | ||
| 481 | ), | ||
| 482 | ); | ||
| 483 | }); | ||
| 484 | |||
| 485 | it.effect( | ||
| 486 | "suppresses all deliveries when the aggregate is unchanged while a row awaits input", | ||
| 487 | () => { | ||
| 488 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 489 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 490 | const waitingAggregate: RelayAgentActivityAggregateState = { | ||
| 491 | ...aggregate, | ||
| 492 | activities: [ | ||
| 493 | { | ||
| 494 | ...aggregate.activities[0]!, | ||
| 495 | phase: "waiting_for_input", | ||
| 496 | status: "Input", | ||
| 497 | }, | ||
| 498 | ], | ||
| 499 | }; | ||
| 500 | const waitingAggregateJson = JSON.stringify(waitingAggregate); | ||
| 501 | |||
| 502 | return Effect.gen(function* () { | ||
| 503 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 504 | const result = yield* deliveries.sendForTarget({ | ||
| 505 | target: { | ||
| 506 | ...target, | ||
| 507 | // A registered alert token must not turn the suppressed Live | ||
| 508 | // Activity update into an alert push on every republish. | ||
| 509 | push_token: "apns-device-token", | ||
| 510 | last_aggregate_json: waitingAggregateJson, | ||
| 511 | last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", | ||
| 512 | }, | ||
| 513 | aggregate: waitingAggregate, | ||
| 514 | nowMs: 5_000, | ||
| 515 | }); | ||
| 516 | |||
| 517 | expect(result).toBeNull(); | ||
| 518 | expect(queuedJobs).toEqual([]); | ||
| 519 | expect(attempts).toEqual([]); | ||
| 520 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 521 | }, | ||
| 522 | ); | ||
| 523 | |||
| 524 | it.effect( | ||
| 525 | "queues an update inside the throttle window when a changed aggregate awaits input", | ||
| 526 | () => { | ||
| 527 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 528 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 529 | const waitingAggregate: RelayAgentActivityAggregateState = { | ||
| 530 | ...aggregate, | ||
| 531 | activities: [ | ||
| 532 | { | ||
| 533 | ...aggregate.activities[0]!, | ||
| 534 | phase: "waiting_for_input", | ||
| 535 | status: "Input", | ||
| 536 | }, | ||
| 537 | ], | ||
| 538 | }; | ||
| 539 | const previousAggregateJson = JSON.stringify(aggregate); | ||
| 540 | |||
| 541 | return Effect.gen(function* () { | ||
| 542 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 543 | const result = yield* deliveries.sendForTarget({ | ||
| 544 | target: { | ||
| 545 | ...target, | ||
| 546 | last_aggregate_json: previousAggregateJson, | ||
| 547 | last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", | ||
| 548 | }, | ||
| 549 | aggregate: waitingAggregate, | ||
| 550 | nowMs: 5_000, | ||
| 551 | }); | ||
| 552 | |||
| 553 | expect(result?.kind).toBe("live_activity_update"); | ||
| 554 | expect(queuedJobs).toMatchObject([ | ||
| 555 | { | ||
| 556 | payload: { | ||
| 557 | kind: "live_activity_update", | ||
| 558 | target: { | ||
| 559 | token: "activity-token", | ||
| 560 | }, | ||
| 561 | }, | ||
| 562 | }, | ||
| 563 | ]); | ||
| 564 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 565 | }, | ||
| 566 | ); | ||
| 567 | |||
| 568 | it.effect( | ||
| 569 | "throttles updates for changed aggregates with stable counts and no pending attention", | ||
| 570 | () => { | ||
| 571 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 572 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 573 | const changedAggregate: RelayAgentActivityAggregateState = { | ||
| 574 | ...aggregate, | ||
| 575 | updatedAt: "1970-01-01T00:00:04.000Z", | ||
| 576 | }; | ||
| 577 | const previousAggregateJson = JSON.stringify(aggregate); | ||
| 578 | |||
| 579 | return Effect.gen(function* () { | ||
| 580 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 581 | const result = yield* deliveries.sendForTarget({ | ||
| 582 | target: { | ||
| 583 | ...target, | ||
| 584 | last_aggregate_json: previousAggregateJson, | ||
| 585 | last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", | ||
| 586 | }, | ||
| 587 | aggregate: changedAggregate, | ||
| 588 | nowMs: 5_000, | ||
| 589 | }); | ||
| 590 | |||
| 591 | expect(result).toBeNull(); | ||
| 592 | expect(queuedJobs).toEqual([]); | ||
| 593 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 594 | }, | ||
| 595 | ); | ||
| 596 | |||
| 392 | it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => { | 597 | it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => { |
| 393 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 598 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 394 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | 599 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| ... | @@ -629,6 +834,48 @@ describe("ApnsDeliveries", () => { | ... | @@ -629,6 +834,48 @@ describe("ApnsDeliveries", () => { |
| 629 | }).pipe(Effect.provide(makeLayer({ attempts }))); | 834 | }).pipe(Effect.provide(makeLayer({ attempts }))); |
| 630 | }); | 835 | }); |
| 631 | 836 | ||
| 837 | it.effect("skips a queued start when the user no longer has live work", () => { | ||
| 838 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 839 | const clearedStarts: Array< | ||
| 840 | Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0] | ||
| 841 | > = []; | ||
| 842 | const payload = makeApnsDeliveryJobPayload({ | ||
| 843 | kind: "live_activity_start", | ||
| 844 | userId: target.user_id, | ||
| 845 | deviceId: target.device_id, | ||
| 846 | token: target.push_to_start_token ?? "start-token", | ||
| 847 | aggregate, | ||
| 848 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 849 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 850 | jobId: "job-start-1", | ||
| 851 | }); | ||
| 852 | const signed = signApnsDeliveryJob({ | ||
| 853 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 854 | payload, | ||
| 855 | }); | ||
| 856 | |||
| 857 | return Effect.gen(function* () { | ||
| 858 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 859 | const result = yield* deliveries.processSignedJob(signed); | ||
| 860 | |||
| 861 | // The start was decided from an aggregate that a newer terminal publish | ||
| 862 | // has since invalidated; delivering it would birth an orphan activity. | ||
| 863 | expect(result).toMatchObject({ | ||
| 864 | kind: "live_activity_start", | ||
| 865 | ok: true, | ||
| 866 | apnsStatus: null, | ||
| 867 | }); | ||
| 868 | expect(attempts).toMatchObject([ | ||
| 869 | { | ||
| 870 | kind: "live_activity_start", | ||
| 871 | sourceJobId: "job-start-1", | ||
| 872 | apnsReason: "Stale APNs start job skipped.", | ||
| 873 | }, | ||
| 874 | ]); | ||
| 875 | expect(clearedStarts).toMatchObject([{ userId: target.user_id, deviceId: target.device_id }]); | ||
| 876 | }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts, activityStates: [] }))); | ||
| 877 | }); | ||
| 878 | |||
| 632 | it.effect("processes signed jobs through APNs and records attempts", () => { | 879 | it.effect("processes signed jobs through APNs and records attempts", () => { |
| 633 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 880 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 634 | const transportErrors: Array<ApnsDeliveries.ApnsDeliveryTransportError> = []; | 881 | const transportErrors: Array<ApnsDeliveries.ApnsDeliveryTransportError> = []; |
| ... | @@ -1177,3 +1424,171 @@ describe("ApnsDeliveries", () => { | ... | @@ -1177,3 +1424,171 @@ describe("ApnsDeliveries", () => { |
| 1177 | ); | 1424 | ); |
| 1178 | }); | 1425 | }); |
| 1179 | }); | 1426 | }); |
| 1427 | |||
| 1428 | describe("live activity alert decisions", () => { | ||
| 1429 | const preferences = { | ||
| 1430 | liveActivitiesEnabled: true, | ||
| 1431 | notificationsEnabled: true, | ||
| 1432 | notifyOnApproval: true, | ||
| 1433 | notifyOnInput: true, | ||
| 1434 | notifyOnCompletion: true, | ||
| 1435 | notifyOnFailure: true, | ||
| 1436 | }; | ||
| 1437 | |||
| 1438 | const attentionRow = { | ||
| 1439 | ...aggregate.activities[0]!, | ||
| 1440 | threadId: "thread-2" as RelayAgentActivityState["threadId"], | ||
| 1441 | threadTitle: "Blocked thread", | ||
| 1442 | phase: "waiting_for_approval" as const, | ||
| 1443 | status: "Approval", | ||
| 1444 | }; | ||
| 1445 | |||
| 1446 | it("alerts when a thread newly enters an attention phase", () => { | ||
| 1447 | const alert = ApnsDeliveries.alertForAttentionTransition({ | ||
| 1448 | previousAggregate: aggregate, | ||
| 1449 | nextAggregate: { | ||
| 1450 | ...aggregate, | ||
| 1451 | activeCount: 2, | ||
| 1452 | activities: [...aggregate.activities, attentionRow], | ||
| 1453 | }, | ||
| 1454 | preferences, | ||
| 1455 | }); | ||
| 1456 | expect(alert).toEqual({ title: "Blocked thread", body: "Approval: Project" }); | ||
| 1457 | }); | ||
| 1458 | |||
| 1459 | it("stays silent when the attention phase was already delivered", () => { | ||
| 1460 | const withAttention = { | ||
| 1461 | ...aggregate, | ||
| 1462 | activeCount: 2, | ||
| 1463 | activities: [...aggregate.activities, attentionRow], | ||
| 1464 | }; | ||
| 1465 | expect( | ||
| 1466 | ApnsDeliveries.alertForAttentionTransition({ | ||
| 1467 | previousAggregate: withAttention, | ||
| 1468 | nextAggregate: withAttention, | ||
| 1469 | preferences, | ||
| 1470 | }), | ||
| 1471 | ).toBeNull(); | ||
| 1472 | }); | ||
| 1473 | |||
| 1474 | it("stays silent without a delivered baseline so replays cannot buzz", () => { | ||
| 1475 | expect( | ||
| 1476 | ApnsDeliveries.alertForAttentionTransition({ | ||
| 1477 | previousAggregate: null, | ||
| 1478 | nextAggregate: { ...aggregate, activities: [attentionRow] }, | ||
| 1479 | preferences, | ||
| 1480 | }), | ||
| 1481 | ).toBeNull(); | ||
| 1482 | }); | ||
| 1483 | |||
| 1484 | it("honors the per-event notification switch for attention alerts", () => { | ||
| 1485 | expect( | ||
| 1486 | ApnsDeliveries.alertForAttentionTransition({ | ||
| 1487 | previousAggregate: aggregate, | ||
| 1488 | nextAggregate: { | ||
| 1489 | ...aggregate, | ||
| 1490 | activeCount: 2, | ||
| 1491 | activities: [...aggregate.activities, attentionRow], | ||
| 1492 | }, | ||
| 1493 | preferences: { ...preferences, notifyOnApproval: false }, | ||
| 1494 | }), | ||
| 1495 | ).toBeNull(); | ||
| 1496 | }); | ||
| 1497 | |||
| 1498 | it("summarizes multiple newly blocked threads in one alert", () => { | ||
| 1499 | const secondAttentionRow = { | ||
| 1500 | ...attentionRow, | ||
| 1501 | threadId: "thread-3" as RelayAgentActivityState["threadId"], | ||
| 1502 | threadTitle: "Other blocked thread", | ||
| 1503 | phase: "waiting_for_input" as const, | ||
| 1504 | status: "Input", | ||
| 1505 | }; | ||
| 1506 | const alert = ApnsDeliveries.alertForAttentionTransition({ | ||
| 1507 | previousAggregate: aggregate, | ||
| 1508 | nextAggregate: { | ||
| 1509 | ...aggregate, | ||
| 1510 | activeCount: 3, | ||
| 1511 | activities: [...aggregate.activities, attentionRow, secondAttentionRow], | ||
| 1512 | }, | ||
| 1513 | preferences, | ||
| 1514 | }); | ||
| 1515 | expect(alert).toEqual({ | ||
| 1516 | title: "2 agents need attention", | ||
| 1517 | body: "Blocked thread, Other blocked thread", | ||
| 1518 | }); | ||
| 1519 | }); | ||
| 1520 | |||
| 1521 | it("alerts for a terminal aggregate and honors the completion switch", () => { | ||
| 1522 | const terminalAggregate = { | ||
| 1523 | ...aggregate, | ||
| 1524 | activeCount: 0, | ||
| 1525 | activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }], | ||
| 1526 | }; | ||
| 1527 | expect( | ||
| 1528 | ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }), | ||
| 1529 | ).toEqual({ title: "Thread", body: "Done: Project" }); | ||
| 1530 | expect( | ||
| 1531 | ApnsDeliveries.alertForTerminalAggregate({ | ||
| 1532 | aggregate: terminalAggregate, | ||
| 1533 | preferences: { ...preferences, notifyOnCompletion: false }, | ||
| 1534 | }), | ||
| 1535 | ).toBeNull(); | ||
| 1536 | expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull(); | ||
| 1537 | }); | ||
| 1538 | |||
| 1539 | it("alerts when a previously active thread finishes mid-flight", () => { | ||
| 1540 | const doneRow = { | ||
| 1541 | ...aggregate.activities[0]!, | ||
| 1542 | phase: "completed" as const, | ||
| 1543 | status: "Done", | ||
| 1544 | }; | ||
| 1545 | const next = { | ||
| 1546 | ...aggregate, | ||
| 1547 | activeCount: 0, | ||
| 1548 | activities: [attentionRow, doneRow], | ||
| 1549 | }; | ||
| 1550 | expect( | ||
| 1551 | ApnsDeliveries.alertForNewlyTerminal({ | ||
| 1552 | previousAggregate: aggregate, | ||
| 1553 | nextAggregate: next, | ||
| 1554 | preferences, | ||
| 1555 | nowMs: 0, | ||
| 1556 | }), | ||
| 1557 | ).toEqual({ title: "Thread", body: "Done: Project" }); | ||
| 1558 | // The completion switch mutes it. | ||
| 1559 | expect( | ||
| 1560 | ApnsDeliveries.alertForNewlyTerminal({ | ||
| 1561 | previousAggregate: aggregate, | ||
| 1562 | nextAggregate: next, | ||
| 1563 | preferences: { ...preferences, notifyOnCompletion: false }, | ||
| 1564 | nowMs: 0, | ||
| 1565 | }), | ||
| 1566 | ).toBeNull(); | ||
| 1567 | // No baseline means no transition to ring on. | ||
| 1568 | expect( | ||
| 1569 | ApnsDeliveries.alertForNewlyTerminal({ | ||
| 1570 | previousAggregate: null, | ||
| 1571 | nextAggregate: next, | ||
| 1572 | preferences, | ||
| 1573 | nowMs: 0, | ||
| 1574 | }), | ||
| 1575 | ).toBeNull(); | ||
| 1576 | // A Done row that was already terminal (or absent) before stays silent. | ||
| 1577 | expect( | ||
| 1578 | ApnsDeliveries.alertForNewlyTerminal({ | ||
| 1579 | previousAggregate: next, | ||
| 1580 | nextAggregate: next, | ||
| 1581 | preferences, | ||
| 1582 | nowMs: 0, | ||
| 1583 | }), | ||
| 1584 | ).toBeNull(); | ||
| 1585 | expect( | ||
| 1586 | ApnsDeliveries.alertForNewlyTerminal({ | ||
| 1587 | previousAggregate: { ...aggregate, activities: [attentionRow] }, | ||
| 1588 | nextAggregate: next, | ||
| 1589 | preferences, | ||
| 1590 | nowMs: 0, | ||
| 1591 | }), | ||
| 1592 | ).toBeNull(); | ||
| 1593 | }); | ||
| 1594 | }); |
infra/relay/src/agentActivity/ApnsDeliveries.ts+348-42| ... | @@ -18,6 +18,8 @@ import * as Redacted from "effect/Redacted"; | ... | @@ -18,6 +18,8 @@ import * as Redacted from "effect/Redacted"; |
| 18 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 19 | 19 | ||
| 20 | import { | 20 | import { |
| 21 | isExpiredAgentActivityState, | ||
| 22 | isTerminalPhase, | ||
| 21 | sanitizeAgentActivityAggregateState, | 23 | sanitizeAgentActivityAggregateState, |
| 22 | sanitizeApnsNotificationPayload, | 24 | sanitizeApnsNotificationPayload, |
| 23 | } from "./agentActivityPayloads.ts"; | 25 | } from "./agentActivityPayloads.ts"; |
| ... | @@ -26,12 +28,14 @@ import { | ... | @@ -26,12 +28,14 @@ import { |
| 26 | ApnsDeliveryJobLiveActivityAggregateMissing, | 28 | ApnsDeliveryJobLiveActivityAggregateMissing, |
| 27 | ApnsDeliveryJobPushNotificationMissing, | 29 | ApnsDeliveryJobPushNotificationMissing, |
| 28 | ApnsDeliveryJobQueuePayloadInvalid, | 30 | ApnsDeliveryJobQueuePayloadInvalid, |
| 31 | type ApnsLiveActivityAlert, | ||
| 29 | type ApnsNotificationPayload, | 32 | type ApnsNotificationPayload, |
| 30 | SignedApnsDeliveryJob, | 33 | SignedApnsDeliveryJob, |
| 31 | isApnsDeliveryJobVerificationError, | 34 | isApnsDeliveryJobVerificationError, |
| 32 | verifySignedApnsDeliveryJob, | 35 | verifySignedApnsDeliveryJob, |
| 33 | type ApnsDeliveryJobVerificationError, | 36 | type ApnsDeliveryJobVerificationError, |
| 34 | } from "./apnsDeliveryJobs.ts"; | 37 | } from "./apnsDeliveryJobs.ts"; |
| 38 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 35 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | 39 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; |
| 36 | import * as LiveActivities from "./LiveActivities.ts"; | 40 | import * as LiveActivities from "./LiveActivities.ts"; |
| 37 | import * as RelayConfiguration from "../Config.ts"; | 41 | import * as RelayConfiguration from "../Config.ts"; |
| ... | @@ -39,6 +43,10 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | ... | @@ -39,6 +43,10 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; |
| 39 | import { withSpanAttributes } from "../observability.ts"; | 43 | import { withSpanAttributes } from "../observability.ts"; |
| 40 | 44 | ||
| 41 | const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; | 45 | const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; |
| 46 | // How long a just-armed card may sit with an empty aggregate before an end is | ||
| 47 | // warranted; covers the gap between arming on send and the environment's | ||
| 48 | // first publish reaching the relay. | ||
| 49 | const FRESHLY_ARMED_GRACE_MS = 2 * 60 * 1_000; | ||
| 42 | const PERMANENT_APNS_TOKEN_REASONS = new Set([ | 50 | const PERMANENT_APNS_TOKEN_REASONS = new Set([ |
| 43 | "BadDeviceToken", | 51 | "BadDeviceToken", |
| 44 | "DeviceTokenNotForTopic", | 52 | "DeviceTokenNotForTopic", |
| ... | @@ -55,11 +63,13 @@ type ChosenLiveActivityDelivery = | ... | @@ -55,11 +63,13 @@ type ChosenLiveActivityDelivery = |
| 55 | readonly kind: "live_activity_start" | "live_activity_update"; | 63 | readonly kind: "live_activity_start" | "live_activity_update"; |
| 56 | readonly token: string; | 64 | readonly token: string; |
| 57 | readonly aggregate: RelayAgentActivityAggregateState; | 65 | readonly aggregate: RelayAgentActivityAggregateState; |
| 66 | readonly alert: ApnsLiveActivityAlert | null; | ||
| 58 | } | 67 | } |
| 59 | | { | 68 | | { |
| 60 | readonly kind: "live_activity_end"; | 69 | readonly kind: "live_activity_end"; |
| 61 | readonly token: string; | 70 | readonly token: string; |
| 62 | readonly aggregate: RelayAgentActivityAggregateState | null; | 71 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 72 | readonly alert: ApnsLiveActivityAlert | null; | ||
| 63 | }; | 73 | }; |
| 64 | 74 | ||
| 65 | type ChosenPushNotificationDelivery = { | 75 | type ChosenPushNotificationDelivery = { |
| ... | @@ -130,6 +140,154 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null | ... | @@ -130,6 +140,154 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null |
| 130 | return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value)); | 140 | return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value)); |
| 131 | } | 141 | } |
| 132 | 142 | ||
| 143 | function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): boolean { | ||
| 144 | return aggregate.activities.some( | ||
| 145 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | function isAttentionPhase(phase: string): boolean { | ||
| 150 | return phase === "waiting_for_approval" || phase === "waiting_for_input"; | ||
| 151 | } | ||
| 152 | |||
| 153 | // Honors the same per-event notification switches the push channel uses; a | ||
| 154 | // missing/corrupt preferences blob only disables nothing (matching how the | ||
| 155 | // liveActivitiesEnabled check treats it), since every registration writes one. | ||
| 156 | function alertAllowedForPhase( | ||
| 157 | preferences: RelayAgentAwarenessPreferences | null, | ||
| 158 | phase: string, | ||
| 159 | ): boolean { | ||
| 160 | if (preferences === null) { | ||
| 161 | return true; | ||
| 162 | } | ||
| 163 | switch (phase) { | ||
| 164 | case "waiting_for_approval": | ||
| 165 | return preferences.notifyOnApproval; | ||
| 166 | case "waiting_for_input": | ||
| 167 | return preferences.notifyOnInput; | ||
| 168 | case "completed": | ||
| 169 | return preferences.notifyOnCompletion; | ||
| 170 | case "failed": | ||
| 171 | return preferences.notifyOnFailure; | ||
| 172 | default: | ||
| 173 | return false; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | // Alert copy for an update whose aggregate contains threads that were NOT in an | ||
| 178 | // attention phase in the previously delivered aggregate. A null previous | ||
| 179 | // aggregate means there is no known baseline (fresh registration, replay after | ||
| 180 | // data loss) — alerting there would buzz on reconnect, not on a transition. | ||
| 181 | export function alertForAttentionTransition(input: { | ||
| 182 | readonly previousAggregate: RelayAgentActivityAggregateState | null; | ||
| 183 | readonly nextAggregate: RelayAgentActivityAggregateState; | ||
| 184 | readonly preferences: RelayAgentAwarenessPreferences | null; | ||
| 185 | }): ApnsLiveActivityAlert | null { | ||
| 186 | if (input.previousAggregate === null) { | ||
| 187 | return null; | ||
| 188 | } | ||
| 189 | const previouslyAttention = new Set( | ||
| 190 | input.previousAggregate.activities | ||
| 191 | .filter((row) => isAttentionPhase(row.phase)) | ||
| 192 | .map((row) => row.threadId), | ||
| 193 | ); | ||
| 194 | const newlyAttention = input.nextAggregate.activities.filter( | ||
| 195 | (row) => | ||
| 196 | isAttentionPhase(row.phase) && | ||
| 197 | !previouslyAttention.has(row.threadId) && | ||
| 198 | alertAllowedForPhase(input.preferences, row.phase), | ||
| 199 | ); | ||
| 200 | const first = newlyAttention[0]; | ||
| 201 | if (!first) { | ||
| 202 | return null; | ||
| 203 | } | ||
| 204 | if (newlyAttention.length === 1) { | ||
| 205 | return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; | ||
| 206 | } | ||
| 207 | return { | ||
| 208 | title: `${newlyAttention.length} agents need attention`, | ||
| 209 | body: newlyAttention.map((row) => row.threadTitle).join(", "), | ||
| 210 | }; | ||
| 211 | } | ||
| 212 | |||
| 213 | // Alert copy for an update whose aggregate contains threads that finished | ||
| 214 | // (Done/Failed) since the previously delivered aggregate — the mid-flight | ||
| 215 | // completion buzz while other agents keep the activity alive. Requires the | ||
| 216 | // thread to have been present and non-terminal before, so a baseline-less | ||
| 217 | // replay or a row that merely fell off the display cap never rings. | ||
| 218 | function newlyTerminalRows( | ||
| 219 | previousAggregate: RelayAgentActivityAggregateState | null, | ||
| 220 | nextAggregate: RelayAgentActivityAggregateState, | ||
| 221 | ): ReadonlyArray<RelayAgentActivityAggregateState["activities"][number]> { | ||
| 222 | if (previousAggregate === null) { | ||
| 223 | return []; | ||
| 224 | } | ||
| 225 | const previousPhases = new Map( | ||
| 226 | previousAggregate.activities.map((row) => [row.threadId, row.phase]), | ||
| 227 | ); | ||
| 228 | return nextAggregate.activities.filter((row) => { | ||
| 229 | if (row.phase !== "completed" && row.phase !== "failed") { | ||
| 230 | return false; | ||
| 231 | } | ||
| 232 | const previousPhase = previousPhases.get(row.threadId); | ||
| 233 | return ( | ||
| 234 | previousPhase !== undefined && previousPhase !== "completed" && previousPhase !== "failed" | ||
| 235 | ); | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | function isFreshTerminalRow( | ||
| 240 | row: RelayAgentActivityAggregateState["activities"][number], | ||
| 241 | nowMs: number, | ||
| 242 | ): boolean { | ||
| 243 | const updatedAtMs = Option.match(DateTime.make(row.updatedAt), { | ||
| 244 | onNone: () => null, | ||
| 245 | onSome: (dt) => dt.epochMilliseconds, | ||
| 246 | }); | ||
| 247 | return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function alertForNewlyTerminal(input: { | ||
| 251 | readonly previousAggregate: RelayAgentActivityAggregateState | null; | ||
| 252 | readonly nextAggregate: RelayAgentActivityAggregateState; | ||
| 253 | readonly preferences: RelayAgentAwarenessPreferences | null; | ||
| 254 | readonly nowMs: number; | ||
| 255 | }): ApnsLiveActivityAlert | null { | ||
| 256 | const newlyTerminal = newlyTerminalRows(input.previousAggregate, input.nextAggregate).filter( | ||
| 257 | (row) => | ||
| 258 | alertAllowedForPhase(input.preferences, row.phase) && | ||
| 259 | // Replays of old aggregates (server restarts, redeliveries) repaint | ||
| 260 | // state without ringing; only fresh completions buzz. | ||
| 261 | isFreshTerminalRow(row, input.nowMs), | ||
| 262 | ); | ||
| 263 | const first = newlyTerminal[0]; | ||
| 264 | if (!first) { | ||
| 265 | return null; | ||
| 266 | } | ||
| 267 | if (newlyTerminal.length === 1) { | ||
| 268 | return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; | ||
| 269 | } | ||
| 270 | return { | ||
| 271 | title: `${newlyTerminal.length} agents finished`, | ||
| 272 | body: newlyTerminal.map((row) => row.threadTitle).join(", "), | ||
| 273 | }; | ||
| 274 | } | ||
| 275 | |||
| 276 | // Alert copy for an end event carrying a terminal (Done/Failed) aggregate. | ||
| 277 | export function alertForTerminalAggregate(input: { | ||
| 278 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 279 | readonly preferences: RelayAgentAwarenessPreferences | null; | ||
| 280 | }): ApnsLiveActivityAlert | null { | ||
| 281 | const row = input.aggregate?.activities[0]; | ||
| 282 | if (!row || (row.phase !== "completed" && row.phase !== "failed")) { | ||
| 283 | return null; | ||
| 284 | } | ||
| 285 | if (!alertAllowedForPhase(input.preferences, row.phase)) { | ||
| 286 | return null; | ||
| 287 | } | ||
| 288 | return { title: row.threadTitle, body: `${row.status}: ${row.projectTitle}` }; | ||
| 289 | } | ||
| 290 | |||
| 133 | function shouldUpdateLiveActivity(input: { | 291 | function shouldUpdateLiveActivity(input: { |
| 134 | readonly previousAggregate: RelayAgentActivityAggregateState | null; | 292 | readonly previousAggregate: RelayAgentActivityAggregateState | null; |
| 135 | readonly nextAggregate: RelayAgentActivityAggregateState; | 293 | readonly nextAggregate: RelayAgentActivityAggregateState; |
| ... | @@ -139,11 +297,20 @@ function shouldUpdateLiveActivity(input: { | ... | @@ -139,11 +297,20 @@ function shouldUpdateLiveActivity(input: { |
| 139 | if (!input.previousAggregate) { | 297 | if (!input.previousAggregate) { |
| 140 | return true; | 298 | return true; |
| 141 | } | 299 | } |
| 300 | if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { | ||
| 301 | return false; | ||
| 302 | } | ||
| 142 | if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { | 303 | if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { |
| 143 | return true; | 304 | return true; |
| 144 | } | 305 | } |
| 145 | if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { | 306 | if (aggregateNeedsAttention(input.nextAggregate)) { |
| 146 | return false; | 307 | return true; |
| 308 | } | ||
| 309 | // A thread finishing must never be throttled away: when a completion and a | ||
| 310 | // new start land in the same window, activeCount is unchanged and the Done | ||
| 311 | // transition (and its alert) would otherwise be suppressed. | ||
| 312 | if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) { | ||
| 313 | return true; | ||
| 147 | } | 314 | } |
| 148 | const lastDeliveryAtMs = | 315 | const lastDeliveryAtMs = |
| 149 | input.lastDeliveryAt === null | 316 | input.lastDeliveryAt === null |
| ... | @@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: { | ... | @@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: { |
| 159 | ); | 326 | ); |
| 160 | } | 327 | } |
| 161 | 328 | ||
| 329 | // Completions replayed long after the fact (server restarts republish every | ||
| 330 | // recently-finished thread) must not ring the device again. | ||
| 331 | const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000; | ||
| 332 | |||
| 162 | function notificationForAggregate(input: { | 333 | function notificationForAggregate(input: { |
| 163 | readonly target: LiveActivities.TargetRow; | 334 | readonly target: LiveActivities.TargetRow; |
| 164 | readonly aggregate: RelayAgentActivityAggregateState | null; | 335 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 336 | readonly nowMs: number; | ||
| 165 | }): ApnsNotificationPayload | null { | 337 | }): ApnsNotificationPayload | null { |
| 166 | if (!input.target.push_token || input.aggregate === null) { | 338 | if (!input.target.push_token || input.aggregate === null) { |
| 167 | return null; | 339 | return null; |
| ... | @@ -174,6 +346,15 @@ function notificationForAggregate(input: { | ... | @@ -174,6 +346,15 @@ function notificationForAggregate(input: { |
| 174 | if (!activity) { | 346 | if (!activity) { |
| 175 | return null; | 347 | return null; |
| 176 | } | 348 | } |
| 349 | if (activity.phase === "completed" || activity.phase === "failed") { | ||
| 350 | const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), { | ||
| 351 | onNone: () => null, | ||
| 352 | onSome: (dt) => dt.epochMilliseconds, | ||
| 353 | }); | ||
| 354 | if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) { | ||
| 355 | return null; | ||
| 356 | } | ||
| 357 | } | ||
| 177 | const enabled = | 358 | const enabled = |
| 178 | (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || | 359 | (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || |
| 179 | (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || | 360 | (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || |
| ... | @@ -191,62 +372,85 @@ function notificationForAggregate(input: { | ... | @@ -191,62 +372,85 @@ function notificationForAggregate(input: { |
| 191 | }; | 372 | }; |
| 192 | } | 373 | } |
| 193 | 374 | ||
| 375 | // "suppressed" means a Live Activity owns this state but no update is due | ||
| 376 | // (unchanged or throttled); callers must not fall back to an alert push, or | ||
| 377 | // every republish of a waiting aggregate would ring the device. | ||
| 194 | function chooseLiveActivityDelivery(input: { | 378 | function chooseLiveActivityDelivery(input: { |
| 195 | readonly target: LiveActivities.TargetRow; | 379 | readonly target: LiveActivities.TargetRow; |
| 196 | readonly aggregate: RelayAgentActivityAggregateState | null; | 380 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 197 | readonly nowMs: number; | 381 | readonly nowMs: number; |
| 198 | }): ChosenLiveActivityDelivery | null { | 382 | }): ChosenLiveActivityDelivery | "suppressed" | null { |
| 199 | const hasActiveActivity = | ||
| 200 | input.target.ended_at === null && | ||
| 201 | (input.target.remote_start_queued_at !== null || | ||
| 202 | input.target.remote_started_at !== null || | ||
| 203 | input.target.activity_push_token !== null); | ||
| 204 | const preferences = parsePreferences(input.target.preferences_json); | 383 | const preferences = parsePreferences(input.target.preferences_json); |
| 205 | if (preferences?.liveActivitiesEnabled === false) { | 384 | if (preferences?.liveActivitiesEnabled === false) { |
| 206 | return hasActiveActivity && input.target.activity_push_token | 385 | return input.target.activity_push_token |
| 207 | ? { | 386 | ? { |
| 208 | kind: "live_activity_end", | 387 | kind: "live_activity_end", |
| 209 | token: input.target.activity_push_token, | 388 | token: input.target.activity_push_token, |
| 210 | aggregate: null, | 389 | aggregate: null, |
| 390 | alert: null, | ||
| 211 | } | 391 | } |
| 212 | : null; | 392 | : null; |
| 213 | } | 393 | } |
| 214 | if (input.aggregate === null || input.aggregate.activeCount === 0) { | 394 | // Activities are started by the app in the foreground, never remotely. |
| 215 | return hasActiveActivity && input.target.activity_push_token | 395 | // Without a registered token there is nothing addressable; attention |
| 216 | ? { | 396 | // transitions fall back to the push notification channel until the user |
| 217 | kind: "live_activity_end", | 397 | // next arms the card from the app. |
| 218 | token: input.target.activity_push_token, | ||
| 219 | aggregate: input.aggregate, | ||
| 220 | } | ||
| 221 | : null; | ||
| 222 | } | ||
| 223 | if (!hasActiveActivity) { | ||
| 224 | return input.target.push_to_start_token | ||
| 225 | ? { | ||
| 226 | kind: "live_activity_start", | ||
| 227 | token: input.target.push_to_start_token, | ||
| 228 | aggregate: input.aggregate, | ||
| 229 | } | ||
| 230 | : null; | ||
| 231 | } | ||
| 232 | if (!input.target.activity_push_token) { | 398 | if (!input.target.activity_push_token) { |
| 233 | return null; | 399 | return null; |
| 234 | } | 400 | } |
| 401 | // An armed card always shows content: live agents, or recently finished | ||
| 402 | // ones (the publisher keeps Done/Failed rows in the aggregate for a | ||
| 403 | // while). A null aggregate means there is truly nothing left to show, so | ||
| 404 | // the card ends — arming is cheap now that the app re-arms on any open | ||
| 405 | // with content. | ||
| 406 | if (input.aggregate === null) { | ||
| 407 | // Except right after arming: the app arms the card the moment the user | ||
| 408 | // starts work, and the token registration's replay can land before the | ||
| 409 | // environment's first publish for the brand-new thread. Ending here | ||
| 410 | // would retire the token and orphan the card at its seed content, so a | ||
| 411 | // freshly armed card keeps its seed until real state arrives. | ||
| 412 | const armedAtMs = Option.match( | ||
| 413 | input.target.remote_started_at === null | ||
| 414 | ? Option.none() | ||
| 415 | : DateTime.make(input.target.remote_started_at), | ||
| 416 | { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, | ||
| 417 | ); | ||
| 418 | if (armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS) { | ||
| 419 | return null; | ||
| 420 | } | ||
| 421 | return { | ||
| 422 | kind: "live_activity_end", | ||
| 423 | token: input.target.activity_push_token, | ||
| 424 | aggregate: null, | ||
| 425 | alert: null, | ||
| 426 | }; | ||
| 427 | } | ||
| 428 | const nextAggregate = input.aggregate; | ||
| 429 | const previousAggregate = parseAggregate(input.target.last_aggregate_json); | ||
| 235 | return shouldUpdateLiveActivity({ | 430 | return shouldUpdateLiveActivity({ |
| 236 | previousAggregate: parseAggregate(input.target.last_aggregate_json), | 431 | previousAggregate, |
| 237 | nextAggregate: input.aggregate, | 432 | nextAggregate, |
| 238 | lastDeliveryAt: input.target.last_live_activity_delivery_at, | 433 | lastDeliveryAt: input.target.last_live_activity_delivery_at, |
| 239 | nowMs: input.nowMs, | 434 | nowMs: input.nowMs, |
| 240 | }) || | 435 | }) |
| 241 | input.aggregate.activities.some( | ||
| 242 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", | ||
| 243 | ) | ||
| 244 | ? { | 436 | ? { |
| 245 | kind: "live_activity_update", | 437 | kind: "live_activity_update", |
| 246 | token: input.target.activity_push_token, | 438 | token: input.target.activity_push_token, |
| 247 | aggregate: input.aggregate, | 439 | aggregate: nextAggregate, |
| 440 | alert: | ||
| 441 | alertForAttentionTransition({ | ||
| 442 | previousAggregate, | ||
| 443 | nextAggregate, | ||
| 444 | preferences, | ||
| 445 | }) ?? | ||
| 446 | alertForNewlyTerminal({ | ||
| 447 | previousAggregate, | ||
| 448 | nextAggregate, | ||
| 449 | preferences, | ||
| 450 | nowMs: input.nowMs, | ||
| 451 | }), | ||
| 248 | } | 452 | } |
| 249 | : null; | 453 | : "suppressed"; |
| 250 | } | 454 | } |
| 251 | 455 | ||
| 252 | function chooseDelivery(input: { | 456 | function chooseDelivery(input: { |
| ... | @@ -255,6 +459,9 @@ function chooseDelivery(input: { | ... | @@ -255,6 +459,9 @@ function chooseDelivery(input: { |
| 255 | readonly nowMs: number; | 459 | readonly nowMs: number; |
| 256 | }): ChosenDelivery | null { | 460 | }): ChosenDelivery | null { |
| 257 | const liveActivityDelivery = chooseLiveActivityDelivery(input); | 461 | const liveActivityDelivery = chooseLiveActivityDelivery(input); |
| 462 | if (liveActivityDelivery === "suppressed") { | ||
| 463 | return null; | ||
| 464 | } | ||
| 258 | if (liveActivityDelivery) { | 465 | if (liveActivityDelivery) { |
| 259 | return liveActivityDelivery; | 466 | return liveActivityDelivery; |
| 260 | } | 467 | } |
| ... | @@ -365,6 +572,24 @@ const recoverApnsDeliveryTransportError = ( | ... | @@ -365,6 +572,24 @@ const recoverApnsDeliveryTransportError = ( |
| 365 | interface LiveActivityDeliveryTarget { | 572 | interface LiveActivityDeliveryTarget { |
| 366 | readonly user_id: string; | 573 | readonly user_id: string; |
| 367 | readonly device_id: string; | 574 | readonly device_id: string; |
| 575 | readonly bundle_id?: string | null; | ||
| 576 | readonly aps_environment?: "sandbox" | "production" | null; | ||
| 577 | } | ||
| 578 | |||
| 579 | // Devices register the bundle id and APS environment of the build they run | ||
| 580 | // (dev/preview/prod variants have distinct bundle ids; development-signed | ||
| 581 | // builds get sandbox tokens). Sending with mismatched routing yields | ||
| 582 | // DeviceTokenNotForTopic/BadDeviceToken, so per-device values override the | ||
| 583 | // relay-wide defaults when present. | ||
| 584 | function credentialsForTarget( | ||
| 585 | credentials: RelayConfiguration.RelayConfiguration["Service"]["apns"], | ||
| 586 | target: LiveActivityDeliveryTarget, | ||
| 587 | ): RelayConfiguration.RelayConfiguration["Service"]["apns"] { | ||
| 588 | return { | ||
| 589 | ...credentials, | ||
| 590 | ...(target.bundle_id ? { bundleId: target.bundle_id } : {}), | ||
| 591 | ...(target.aps_environment ? { environment: target.aps_environment } : {}), | ||
| 592 | }; | ||
| 368 | } | 593 | } |
| 369 | 594 | ||
| 370 | function expectedCurrentToken(input: { | 595 | function expectedCurrentToken(input: { |
| ... | @@ -392,10 +617,12 @@ export type SendLiveActivityDeliveryInput = | ... | @@ -392,10 +617,12 @@ export type SendLiveActivityDeliveryInput = |
| 392 | | (SendLiveActivityDeliveryInputBase & { | 617 | | (SendLiveActivityDeliveryInputBase & { |
| 393 | readonly kind: "live_activity_start" | "live_activity_update"; | 618 | readonly kind: "live_activity_start" | "live_activity_update"; |
| 394 | readonly aggregate: RelayAgentActivityAggregateState; | 619 | readonly aggregate: RelayAgentActivityAggregateState; |
| 620 | readonly alert?: ApnsLiveActivityAlert | null; | ||
| 395 | }) | 621 | }) |
| 396 | | (SendLiveActivityDeliveryInputBase & { | 622 | | (SendLiveActivityDeliveryInputBase & { |
| 397 | readonly kind: "live_activity_end"; | 623 | readonly kind: "live_activity_end"; |
| 398 | readonly aggregate: RelayAgentActivityAggregateState | null; | 624 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 625 | readonly alert?: ApnsLiveActivityAlert | null; | ||
| 399 | }); | 626 | }); |
| 400 | 627 | ||
| 401 | function makeLiveActivityDeliveryRequest( | 628 | function makeLiveActivityDeliveryRequest( |
| ... | @@ -419,6 +646,7 @@ function makeLiveActivityDeliveryRequest( | ... | @@ -419,6 +646,7 @@ function makeLiveActivityDeliveryRequest( |
| 419 | ...base, | 646 | ...base, |
| 420 | event: deliveryEvent(input.kind), | 647 | event: deliveryEvent(input.kind), |
| 421 | state: input.aggregate, | 648 | state: input.aggregate, |
| 649 | alert: input.alert ?? null, | ||
| 422 | }), | 650 | }), |
| 423 | }; | 651 | }; |
| 424 | case "live_activity_end": | 652 | case "live_activity_end": |
| ... | @@ -429,6 +657,7 @@ function makeLiveActivityDeliveryRequest( | ... | @@ -429,6 +657,7 @@ function makeLiveActivityDeliveryRequest( |
| 429 | ...base, | 657 | ...base, |
| 430 | event: "end", | 658 | event: "end", |
| 431 | state: input.aggregate, | 659 | state: input.aggregate, |
| 660 | alert: input.alert ?? null, | ||
| 432 | }), | 661 | }), |
| 433 | }; | 662 | }; |
| 434 | } | 663 | } |
| ... | @@ -467,6 +696,31 @@ export const make = Effect.gen(function* () { | ... | @@ -467,6 +696,31 @@ export const make = Effect.gen(function* () { |
| 467 | const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; | 696 | const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; |
| 468 | const config = yield* RelayConfiguration.RelayConfiguration; | 697 | const config = yield* RelayConfiguration.RelayConfiguration; |
| 469 | const apns = yield* Apns.ApnsClient; | 698 | const apns = yield* Apns.ApnsClient; |
| 699 | const activityRows = yield* AgentActivityRows.AgentActivityRows; | ||
| 700 | |||
| 701 | // Start jobs are decided at publish time, but consecutive publishes land in | ||
| 702 | // the same queue batch: a start chosen from a running aggregate can be | ||
| 703 | // delivered moments after a newer terminal publish already ended the user's | ||
| 704 | // work, birthing an orphan activity that shows stale content forever (no | ||
| 705 | // token is ever registered for it, so nothing can update or end it). | ||
| 706 | // Re-validate at delivery time that the user still has live work; fail open | ||
| 707 | // on persistence errors so a database hiccup never drops a legitimate start. | ||
| 708 | const userStillHasLiveWork = Effect.fnUntraced(function* (userId: string) { | ||
| 709 | const now = yield* DateTime.now; | ||
| 710 | return yield* activityRows.listForUser({ userId }).pipe( | ||
| 711 | Effect.map((states) => | ||
| 712 | states.some( | ||
| 713 | (state) => | ||
| 714 | !isTerminalPhase(state) && !isExpiredAgentActivityState(state, now.epochMilliseconds), | ||
| 715 | ), | ||
| 716 | ), | ||
| 717 | Effect.catchCause((cause) => | ||
| 718 | Effect.logWarning("live-work recheck failed; allowing queued start", { cause }).pipe( | ||
| 719 | Effect.as(true), | ||
| 720 | ), | ||
| 721 | ), | ||
| 722 | ); | ||
| 723 | }); | ||
| 470 | 724 | ||
| 471 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { | 725 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { |
| 472 | readonly target: LiveActivityDeliveryTarget; | 726 | readonly target: LiveActivityDeliveryTarget; |
| ... | @@ -538,9 +792,25 @@ export const make = Effect.gen(function* () { | ... | @@ -538,9 +792,25 @@ export const make = Effect.gen(function* () { |
| 538 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | 792 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); |
| 539 | } | 793 | } |
| 540 | } | 794 | } |
| 795 | if ( | ||
| 796 | input.kind === "live_activity_start" && | ||
| 797 | !(yield* userStillHasLiveWork(input.target.user_id)) | ||
| 798 | ) { | ||
| 799 | yield* liveActivities.clearStartQueued({ | ||
| 800 | userId: input.target.user_id, | ||
| 801 | deviceId: input.target.device_id, | ||
| 802 | }); | ||
| 803 | if (input.sourceJobId) { | ||
| 804 | yield* attempts.completeSourceJob({ | ||
| 805 | sourceJobId: input.sourceJobId, | ||
| 806 | apnsReason: "Stale APNs start job skipped.", | ||
| 807 | }); | ||
| 808 | } | ||
| 809 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | ||
| 810 | } | ||
| 541 | const result = yield* apns | 811 | const result = yield* apns |
| 542 | .sendLiveActivityRequest({ | 812 | .sendLiveActivityRequest({ |
| 543 | credentials: config.apns, | 813 | credentials: credentialsForTarget(config.apns, input.target), |
| 544 | request, | 814 | request, |
| 545 | issuedAtUnixSeconds: epochSeconds, | 815 | issuedAtUnixSeconds: epochSeconds, |
| 546 | }) | 816 | }) |
| ... | @@ -663,7 +933,7 @@ export const make = Effect.gen(function* () { | ... | @@ -663,7 +933,7 @@ export const make = Effect.gen(function* () { |
| 663 | } | 933 | } |
| 664 | const result = yield* apns | 934 | const result = yield* apns |
| 665 | .sendPushNotificationRequest({ | 935 | .sendPushNotificationRequest({ |
| 666 | credentials: config.apns, | 936 | credentials: credentialsForTarget(config.apns, input.target), |
| 667 | request, | 937 | request, |
| 668 | issuedAtUnixSeconds: epochSeconds, | 938 | issuedAtUnixSeconds: epochSeconds, |
| 669 | }) | 939 | }) |
| ... | @@ -752,22 +1022,28 @@ export const make = Effect.gen(function* () { | ... | @@ -752,22 +1022,28 @@ export const make = Effect.gen(function* () { |
| 752 | target: { | 1022 | target: { |
| 753 | user_id: payload.target.userId, | 1023 | user_id: payload.target.userId, |
| 754 | device_id: payload.target.deviceId, | 1024 | device_id: payload.target.deviceId, |
| 1025 | bundle_id: payload.target.bundleId ?? null, | ||
| 1026 | aps_environment: payload.target.apsEnvironment ?? null, | ||
| 755 | }, | 1027 | }, |
| 756 | token: payload.target.token, | 1028 | token: payload.target.token, |
| 757 | sourceJobId: payload.jobId, | 1029 | sourceJobId: payload.jobId, |
| 758 | kind: payload.kind, | 1030 | kind: payload.kind, |
| 759 | aggregate: payload.aggregate, | 1031 | aggregate: payload.aggregate, |
| 1032 | alert: payload.alert ?? null, | ||
| 760 | }); | 1033 | }); |
| 761 | case "live_activity_end": | 1034 | case "live_activity_end": |
| 762 | return sendLiveActivity({ | 1035 | return sendLiveActivity({ |
| 763 | target: { | 1036 | target: { |
| 764 | user_id: payload.target.userId, | 1037 | user_id: payload.target.userId, |
| 765 | device_id: payload.target.deviceId, | 1038 | device_id: payload.target.deviceId, |
| 1039 | bundle_id: payload.target.bundleId ?? null, | ||
| 1040 | aps_environment: payload.target.apsEnvironment ?? null, | ||
| 766 | }, | 1041 | }, |
| 767 | token: payload.target.token, | 1042 | token: payload.target.token, |
| 768 | sourceJobId: payload.jobId, | 1043 | sourceJobId: payload.jobId, |
| 769 | kind: payload.kind, | 1044 | kind: payload.kind, |
| 770 | aggregate: payload.aggregate, | 1045 | aggregate: payload.aggregate, |
| 1046 | alert: payload.alert ?? null, | ||
| 771 | }); | 1047 | }); |
| 772 | case "push_notification": | 1048 | case "push_notification": |
| 773 | if (payload.notification === null) { | 1049 | if (payload.notification === null) { |
| ... | @@ -783,6 +1059,8 @@ export const make = Effect.gen(function* () { | ... | @@ -783,6 +1059,8 @@ export const make = Effect.gen(function* () { |
| 783 | target: { | 1059 | target: { |
| 784 | user_id: payload.target.userId, | 1060 | user_id: payload.target.userId, |
| 785 | device_id: payload.target.deviceId, | 1061 | device_id: payload.target.deviceId, |
| 1062 | bundle_id: payload.target.bundleId ?? null, | ||
| 1063 | aps_environment: payload.target.apsEnvironment ?? null, | ||
| 786 | }, | 1064 | }, |
| 787 | token: payload.target.token, | 1065 | token: payload.target.token, |
| 788 | sourceJobId: payload.jobId, | 1066 | sourceJobId: payload.jobId, |
| ... | @@ -797,13 +1075,20 @@ export const make = Effect.gen(function* () { | ... | @@ -797,13 +1075,20 @@ export const make = Effect.gen(function* () { |
| 797 | sendPushNotification, | 1075 | sendPushNotification, |
| 798 | processSignedJob, | 1076 | processSignedJob, |
| 799 | sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { | 1077 | sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { |
| 800 | const notification = notificationForAggregate(input); | 1078 | const now = yield* DateTime.now; |
| 1079 | const notification = notificationForAggregate({ | ||
| 1080 | target: input.target, | ||
| 1081 | aggregate: input.aggregate, | ||
| 1082 | nowMs: now.epochMilliseconds, | ||
| 1083 | }); | ||
| 801 | const token = input.target.push_token; | 1084 | const token = input.target.push_token; |
| 802 | return yield* notification && token | 1085 | return yield* notification && token |
| 803 | ? deliveryQueue.enqueuePushNotification({ | 1086 | ? deliveryQueue.enqueuePushNotification({ |
| 804 | userId: input.target.user_id, | 1087 | userId: input.target.user_id, |
| 805 | deviceId: input.target.device_id, | 1088 | deviceId: input.target.device_id, |
| 806 | token, | 1089 | token, |
| 1090 | bundleId: input.target.bundle_id, | ||
| 1091 | apsEnvironment: input.target.aps_environment, | ||
| 807 | notification, | 1092 | notification, |
| 808 | }) | 1093 | }) |
| 809 | : Effect.succeed(null); | 1094 | : Effect.succeed(null); |
| ... | @@ -822,26 +1107,47 @@ export const make = Effect.gen(function* () { | ... | @@ -822,26 +1107,47 @@ export const make = Effect.gen(function* () { |
| 822 | userId: input.target.user_id, | 1107 | userId: input.target.user_id, |
| 823 | deviceId: input.target.device_id, | 1108 | deviceId: input.target.device_id, |
| 824 | token: delivery.token, | 1109 | token: delivery.token, |
| 1110 | bundleId: input.target.bundle_id, | ||
| 1111 | apsEnvironment: input.target.aps_environment, | ||
| 825 | notification: delivery.notification, | 1112 | notification: delivery.notification, |
| 826 | }); | 1113 | }); |
| 827 | return result; | 1114 | return result; |
| 828 | } | 1115 | } |
| 1116 | const notification = notificationForAggregate({ | ||
| 1117 | target: input.target, | ||
| 1118 | aggregate: input.aggregate, | ||
| 1119 | nowMs: input.nowMs, | ||
| 1120 | }); | ||
| 1121 | // The end event doubles as the "task finished" moment. When a companion | ||
| 1122 | // push notification is about to ring the device (below), the activity end | ||
| 1123 | // stays silent; otherwise the end itself carries the alert so LA-only | ||
| 1124 | // users still get the buzz. | ||
| 1125 | const alert = | ||
| 1126 | delivery.kind === "live_activity_end" | ||
| 1127 | ? notification && input.target.push_token | ||
| 1128 | ? null | ||
| 1129 | : alertForTerminalAggregate({ | ||
| 1130 | aggregate: delivery.aggregate, | ||
| 1131 | preferences: parsePreferences(input.target.preferences_json), | ||
| 1132 | }) | ||
| 1133 | : delivery.alert; | ||
| 829 | const result = yield* deliveryQueue.enqueueLiveActivity({ | 1134 | const result = yield* deliveryQueue.enqueueLiveActivity({ |
| 830 | userId: input.target.user_id, | 1135 | userId: input.target.user_id, |
| 831 | deviceId: input.target.device_id, | 1136 | deviceId: input.target.device_id, |
| 832 | kind: delivery.kind, | 1137 | kind: delivery.kind, |
| 833 | token: delivery.token, | 1138 | token: delivery.token, |
| 1139 | bundleId: input.target.bundle_id, | ||
| 1140 | apsEnvironment: input.target.aps_environment, | ||
| 834 | aggregate: delivery.aggregate, | 1141 | aggregate: delivery.aggregate, |
| 835 | }); | 1142 | alert, |
| 836 | const notification = notificationForAggregate({ | ||
| 837 | target: input.target, | ||
| 838 | aggregate: input.aggregate, | ||
| 839 | }); | 1143 | }); |
| 840 | if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { | 1144 | if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { |
| 841 | yield* deliveryQueue.enqueuePushNotification({ | 1145 | yield* deliveryQueue.enqueuePushNotification({ |
| 842 | userId: input.target.user_id, | 1146 | userId: input.target.user_id, |
| 843 | deviceId: input.target.device_id, | 1147 | deviceId: input.target.device_id, |
| 844 | token: input.target.push_token, | 1148 | token: input.target.push_token, |
| 1149 | bundleId: input.target.bundle_id, | ||
| 1150 | apsEnvironment: input.target.aps_environment, | ||
| 845 | notification, | 1151 | notification, |
| 846 | }); | 1152 | }); |
| 847 | } | 1153 | } |
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts+7| ... | @@ -58,12 +58,17 @@ export class ApnsDeliveryQueue extends Context.Service< | ... | @@ -58,12 +58,17 @@ export class ApnsDeliveryQueue extends Context.Service< |
| 58 | readonly userId: string; | 58 | readonly userId: string; |
| 59 | readonly deviceId: string; | 59 | readonly deviceId: string; |
| 60 | readonly token: string; | 60 | readonly token: string; |
| 61 | readonly bundleId?: string | null; | ||
| 62 | readonly apsEnvironment?: "sandbox" | "production" | null; | ||
| 61 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | 63 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; |
| 64 | readonly alert?: ApnsDeliveryJobPayload["alert"]; | ||
| 62 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | 65 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; |
| 63 | readonly enqueuePushNotification: (input: { | 66 | readonly enqueuePushNotification: (input: { |
| 64 | readonly userId: string; | 67 | readonly userId: string; |
| 65 | readonly deviceId: string; | 68 | readonly deviceId: string; |
| 66 | readonly token: string; | 69 | readonly token: string; |
| 70 | readonly bundleId?: string | null; | ||
| 71 | readonly apsEnvironment?: "sandbox" | "production" | null; | ||
| 67 | readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; | 72 | readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; |
| 68 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | 73 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; |
| 69 | } | 74 | } |
| ... | @@ -160,6 +165,8 @@ export const make = Effect.gen(function* () { | ... | @@ -160,6 +165,8 @@ export const make = Effect.gen(function* () { |
| 160 | userId: input.userId, | 165 | userId: input.userId, |
| 161 | deviceId: input.deviceId, | 166 | deviceId: input.deviceId, |
| 162 | token: input.token, | 167 | token: input.token, |
| 168 | bundleId: input.bundleId, | ||
| 169 | apsEnvironment: input.apsEnvironment, | ||
| 163 | aggregate: null, | 170 | aggregate: null, |
| 164 | notification: sanitizeApnsNotificationPayload(input.notification), | 171 | notification: sanitizeApnsNotificationPayload(input.notification), |
| 165 | jobId, | 172 | jobId, |
infra/relay/src/agentActivity/ApnsProviderTokens.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Redacted from "effect/Redacted"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; | ||
| 9 | |||
| 10 | const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { | ||
| 11 | namedCurve: "prime256v1", | ||
| 12 | privateKeyEncoding: { type: "pkcs8", format: "pem" }, | ||
| 13 | publicKeyEncoding: { type: "spki", format: "pem" }, | ||
| 14 | }); | ||
| 15 | |||
| 16 | const signingInput = { | ||
| 17 | teamId: "team-1", | ||
| 18 | keyId: "key-1", | ||
| 19 | privateKey: Redacted.make(privateKey), | ||
| 20 | }; | ||
| 21 | |||
| 22 | const WINDOW = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; | ||
| 23 | |||
| 24 | const decodeJwtPayload = Schema.decodeEffect( | ||
| 25 | Schema.fromJsonString(Schema.Struct({ iat: Schema.Number })), | ||
| 26 | ); | ||
| 27 | |||
| 28 | describe("ApnsProviderTokens", () => { | ||
| 29 | it.effect("derives the byte-identical token across isolates within a window", () => { | ||
| 30 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 31 | return Effect.gen(function* () { | ||
| 32 | const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; | ||
| 33 | const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); | ||
| 34 | |||
| 35 | // A fresh isolate has no cache; deterministic signing plus quantized | ||
| 36 | // iat must still reproduce the exact same JWT for the same window. | ||
| 37 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 38 | const second = yield* tokens.getJwt({ | ||
| 39 | ...signingInput, | ||
| 40 | issuedAtUnixSeconds: WINDOW * 2 - 1, | ||
| 41 | }); | ||
| 42 | expect(second).toBe(first); | ||
| 43 | |||
| 44 | const payload = yield* decodeJwtPayload( | ||
| 45 | Buffer.from(first.split(".")[1]!, "base64url").toString("utf8"), | ||
| 46 | ); | ||
| 47 | expect(payload.iat).toBe(WINDOW); | ||
| 48 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 49 | }).pipe(Effect.provide(ApnsProviderTokens.layer)); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it.effect("rolls to a new token at the window boundary", () => { | ||
| 53 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 54 | return Effect.gen(function* () { | ||
| 55 | const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; | ||
| 56 | const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); | ||
| 57 | const next = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW * 2 }); | ||
| 58 | expect(next).not.toBe(first); | ||
| 59 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 60 | }).pipe(Effect.provide(ApnsProviderTokens.layer)); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it.effect("serves repeat pushes from the isolate cache without re-signing", () => { | ||
| 64 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 65 | return Effect.gen(function* () { | ||
| 66 | const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; | ||
| 67 | const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); | ||
| 68 | const again = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 500 }); | ||
| 69 | // Deterministic signing makes equality hold either way; toBe on the | ||
| 70 | // exact string documents the cache contract. | ||
| 71 | expect(again).toBe(first); | ||
| 72 | ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); | ||
| 73 | }).pipe(Effect.provide(ApnsProviderTokens.layer)); | ||
| 74 | }); | ||
| 75 | }); | ||
infra/relay/src/agentActivity/ApnsProviderTokens.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | apnsProviderTokenCacheKey, | ||
| 7 | makeApnsJwt, | ||
| 8 | type ApnsJwtError, | ||
| 9 | type ApnsJwtSigningInput, | ||
| 10 | } from "./apnsJwt.ts"; | ||
| 11 | |||
| 12 | // APNs requires REUSING the provider token: refreshing it more than roughly | ||
| 13 | // once per 20 minutes returns 429 TooManyProviderTokenUpdates and drops the | ||
| 14 | // push (observed live: bursty Live Activity updates got 429'd, leaving stale | ||
| 15 | // lock-screen state). Reuse each signed JWT for most of its 60-minute | ||
| 16 | // validity. | ||
| 17 | export const APNS_JWT_REUSE_SECONDS = 45 * 60; | ||
| 18 | |||
| 19 | export class ApnsProviderTokens extends Context.Service< | ||
| 20 | ApnsProviderTokens, | ||
| 21 | { | ||
| 22 | readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect<string, ApnsJwtError>; | ||
| 23 | } | ||
| 24 | >()("t3code-relay/agentActivity/ApnsProviderTokens") {} | ||
| 25 | |||
| 26 | interface CachedProviderToken { | ||
| 27 | readonly jwt: string; | ||
| 28 | readonly issuedAtUnixSeconds: number; | ||
| 29 | } | ||
| 30 | |||
| 31 | // Signing is deterministic (RFC 6979) and iat is quantized below, so every | ||
| 32 | // isolate independently derives the byte-identical token for a window; this | ||
| 33 | // map only avoids re-signing on every push. No shared storage is needed, and | ||
| 34 | // no provider token is ever written anywhere. | ||
| 35 | const isolateTokenCache = new Map<string, CachedProviderToken>(); | ||
| 36 | |||
| 37 | export function __resetApnsProviderTokenCacheForTest(): void { | ||
| 38 | isolateTokenCache.clear(); | ||
| 39 | } | ||
| 40 | |||
| 41 | // Quantize iat to the reuse window so all isolates agree on it. The token's | ||
| 42 | // age stays under APNs' 60-minute limit, and the whole fleet rolls to the | ||
| 43 | // next token at the same instant — one provider-token update per window. | ||
| 44 | export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number { | ||
| 45 | return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS; | ||
| 46 | } | ||
| 47 | |||
| 48 | export const make = () => | ||
| 49 | ApnsProviderTokens.of({ | ||
| 50 | getJwt: Effect.fnUntraced(function* (input) { | ||
| 51 | const issuedAtUnixSeconds = quantizedApnsJwtIssuedAt(input.issuedAtUnixSeconds); | ||
| 52 | const cacheKey = apnsProviderTokenCacheKey(input); | ||
| 53 | const cached = isolateTokenCache.get(cacheKey); | ||
| 54 | if (cached && cached.issuedAtUnixSeconds === issuedAtUnixSeconds) { | ||
| 55 | return cached.jwt; | ||
| 56 | } | ||
| 57 | const jwt = yield* makeApnsJwt({ ...input, issuedAtUnixSeconds }); | ||
| 58 | isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds }); | ||
| 59 | return jwt; | ||
| 60 | }), | ||
| 61 | }); | ||
| 62 | |||
| 63 | export const layer = Layer.succeed(ApnsProviderTokens, make()); | ||
infra/relay/src/agentActivity/Devices.test.ts+4| ... | @@ -15,6 +15,8 @@ const registration: RelayDeviceRegistrationRequest = { | ... | @@ -15,6 +15,8 @@ const registration: RelayDeviceRegistrationRequest = { |
| 15 | platform: "ios", | 15 | platform: "ios", |
| 16 | iosMajorVersion: 18, | 16 | iosMajorVersion: 18, |
| 17 | appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], | 17 | appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], |
| 18 | bundleId: "com.t3tools.t3code.preview" as RelayDeviceRegistrationRequest["bundleId"], | ||
| 19 | apsEnvironment: "production", | ||
| 18 | pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], | 20 | pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], |
| 19 | pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], | 21 | pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], |
| 20 | preferences: { | 22 | preferences: { |
| ... | @@ -106,6 +108,8 @@ describe("Devices", () => { | ... | @@ -106,6 +108,8 @@ describe("Devices", () => { |
| 106 | expect.objectContaining({ | 108 | expect.objectContaining({ |
| 107 | userId: "user-2", | 109 | userId: "user-2", |
| 108 | deviceId: "device-1", | 110 | deviceId: "device-1", |
| 111 | bundleId: "com.t3tools.t3code.preview", | ||
| 112 | apsEnvironment: "production", | ||
| 109 | pushToken: "apns-device-token", | 113 | pushToken: "apns-device-token", |
| 110 | pushToStartToken: "push-to-start-token", | 114 | pushToStartToken: "push-to-start-token", |
| 111 | }), | 115 | }), |
infra/relay/src/agentActivity/Devices.ts+88-82| ... | @@ -81,45 +81,45 @@ export const make = Effect.gen(function* () { | ... | @@ -81,45 +81,45 @@ export const make = Effect.gen(function* () { |
| 81 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | 81 | const updatedAt = DateTime.formatIso(yield* DateTime.now); |
| 82 | const registration = input.registration; | 82 | const registration = input.registration; |
| 83 | 83 | ||
| 84 | yield* Effect.all( | 84 | // The drizzle handle is alchemy's lazy proxy chain: it only becomes a |
| 85 | [ | 85 | // real Effect when consumed via `yield*`. Handing it to Effect.all sends |
| 86 | registration.pushToken | 86 | // the raw Proxy into the fiber runtime, which spins the isolate at 100% |
| 87 | ? db | 87 | // CPU (registrations then hang until the client aborts) — keep every db |
| 88 | .update(relayMobileDevices) | 88 | // chain directly yielded. |
| 89 | .set({ pushToken: null, updatedAt }) | 89 | if (registration.pushToken) { |
| 90 | .where(eq(relayMobileDevices.pushToken, registration.pushToken)) | 90 | yield* db |
| 91 | .pipe( | 91 | .update(relayMobileDevices) |
| 92 | Effect.mapError( | 92 | .set({ pushToken: null, updatedAt }) |
| 93 | (cause) => | 93 | .where(eq(relayMobileDevices.pushToken, registration.pushToken)) |
| 94 | new DeviceRegistrationPersistenceError({ | 94 | .pipe( |
| 95 | userId: input.userId, | 95 | Effect.mapError( |
| 96 | deviceId: registration.deviceId, | 96 | (cause) => |
| 97 | stage: "claim-push-token", | 97 | new DeviceRegistrationPersistenceError({ |
| 98 | cause, | 98 | userId: input.userId, |
| 99 | }), | 99 | deviceId: registration.deviceId, |
| 100 | ), | 100 | stage: "claim-push-token", |
| 101 | ) | 101 | cause, |
| 102 | : Effect.void, | 102 | }), |
| 103 | registration.pushToStartToken | 103 | ), |
| 104 | ? db | 104 | ); |
| 105 | .update(relayMobileDevices) | 105 | } |
| 106 | .set({ pushToStartToken: null, updatedAt }) | 106 | if (registration.pushToStartToken) { |
| 107 | .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) | 107 | yield* db |
| 108 | .pipe( | 108 | .update(relayMobileDevices) |
| 109 | Effect.mapError( | 109 | .set({ pushToStartToken: null, updatedAt }) |
| 110 | (cause) => | 110 | .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) |
| 111 | new DeviceRegistrationPersistenceError({ | 111 | .pipe( |
| 112 | userId: input.userId, | 112 | Effect.mapError( |
| 113 | deviceId: registration.deviceId, | 113 | (cause) => |
| 114 | stage: "claim-push-to-start-token", | 114 | new DeviceRegistrationPersistenceError({ |
| 115 | cause, | 115 | userId: input.userId, |
| 116 | }), | 116 | deviceId: registration.deviceId, |
| 117 | ), | 117 | stage: "claim-push-to-start-token", |
| 118 | ) | 118 | cause, |
| 119 | : Effect.void, | 119 | }), |
| 120 | ], | 120 | ), |
| 121 | { discard: true }, | 121 | ); |
| 122 | ); | 122 | } |
| 123 | 123 | ||
| 124 | yield* db | 124 | yield* db |
| 125 | .insert(relayMobileDevices) | 125 | .insert(relayMobileDevices) |
| ... | @@ -130,6 +130,8 @@ export const make = Effect.gen(function* () { | ... | @@ -130,6 +130,8 @@ export const make = Effect.gen(function* () { |
| 130 | platform: registration.platform, | 130 | platform: registration.platform, |
| 131 | iosMajorVersion: registration.iosMajorVersion, | 131 | iosMajorVersion: registration.iosMajorVersion, |
| 132 | appVersion: registration.appVersion ?? null, | 132 | appVersion: registration.appVersion ?? null, |
| 133 | bundleId: registration.bundleId ?? null, | ||
| 134 | apsEnvironment: registration.apsEnvironment ?? null, | ||
| 133 | pushToken: registration.pushToken ?? null, | 135 | pushToken: registration.pushToken ?? null, |
| 134 | pushToStartToken: registration.pushToStartToken ?? null, | 136 | pushToStartToken: registration.pushToStartToken ?? null, |
| 135 | preferencesJson: registration.preferences, | 137 | preferencesJson: registration.preferences, |
| ... | @@ -143,6 +145,13 @@ export const make = Effect.gen(function* () { | ... | @@ -143,6 +145,13 @@ export const make = Effect.gen(function* () { |
| 143 | label: registration.label, | 145 | label: registration.label, |
| 144 | iosMajorVersion: registration.iosMajorVersion, | 146 | iosMajorVersion: registration.iosMajorVersion, |
| 145 | appVersion: registration.appVersion ?? null, | 147 | appVersion: registration.appVersion ?? null, |
| 148 | // Preserve routing from newer app builds when an older build | ||
| 149 | // re-registers without these fields. | ||
| 150 | bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`, | ||
| 151 | apsEnvironment: sql`coalesce( | ||
| 152 | excluded.aps_environment, | ||
| 153 | ${relayMobileDevices.apsEnvironment} | ||
| 154 | )`, | ||
| 146 | pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, | 155 | pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, |
| 147 | pushToStartToken: sql`coalesce( | 156 | pushToStartToken: sql`coalesce( |
| 148 | excluded.push_to_start_token, | 157 | excluded.push_to_start_token, |
| ... | @@ -168,49 +177,46 @@ export const make = Effect.gen(function* () { | ... | @@ -168,49 +177,46 @@ export const make = Effect.gen(function* () { |
| 168 | yield* Effect.annotateCurrentSpan({ | 177 | yield* Effect.annotateCurrentSpan({ |
| 169 | "relay.mobile.device_id": input.deviceId, | 178 | "relay.mobile.device_id": input.deviceId, |
| 170 | }); | 179 | }); |
| 171 | yield* Effect.all( | 180 | // Same proxy-chain constraint as register above: db chains must be |
| 172 | [ | 181 | // consumed via `yield*`, never passed to Effect.all. |
| 173 | db | 182 | yield* db |
| 174 | .delete(relayLiveActivities) | 183 | .delete(relayLiveActivities) |
| 175 | .where( | 184 | .where( |
| 176 | and( | 185 | and( |
| 177 | eq(relayLiveActivities.userId, input.userId), | 186 | eq(relayLiveActivities.userId, input.userId), |
| 178 | eq(relayLiveActivities.deviceId, input.deviceId), | 187 | eq(relayLiveActivities.deviceId, input.deviceId), |
| 179 | ), | 188 | ), |
| 180 | ) | 189 | ) |
| 181 | .pipe( | 190 | .pipe( |
| 182 | Effect.mapError( | 191 | Effect.mapError( |
| 183 | (cause) => | 192 | (cause) => |
| 184 | new DeviceUnregistrationPersistenceError({ | 193 | new DeviceUnregistrationPersistenceError({ |
| 185 | userId: input.userId, | 194 | userId: input.userId, |
| 186 | deviceId: input.deviceId, | 195 | deviceId: input.deviceId, |
| 187 | stage: "delete-live-activity", | 196 | stage: "delete-live-activity", |
| 188 | cause, | 197 | cause, |
| 189 | }), | 198 | }), |
| 190 | ), | 199 | ), |
| 191 | ), | 200 | ); |
| 192 | db | 201 | yield* db |
| 193 | .delete(relayMobileDevices) | 202 | .delete(relayMobileDevices) |
| 194 | .where( | 203 | .where( |
| 195 | and( | 204 | and( |
| 196 | eq(relayMobileDevices.userId, input.userId), | 205 | eq(relayMobileDevices.userId, input.userId), |
| 197 | eq(relayMobileDevices.deviceId, input.deviceId), | 206 | eq(relayMobileDevices.deviceId, input.deviceId), |
| 198 | ), | 207 | ), |
| 199 | ) | 208 | ) |
| 200 | .pipe( | 209 | .pipe( |
| 201 | Effect.mapError( | 210 | Effect.mapError( |
| 202 | (cause) => | 211 | (cause) => |
| 203 | new DeviceUnregistrationPersistenceError({ | 212 | new DeviceUnregistrationPersistenceError({ |
| 204 | userId: input.userId, | 213 | userId: input.userId, |
| 205 | deviceId: input.deviceId, | 214 | deviceId: input.deviceId, |
| 206 | stage: "delete-device", | 215 | stage: "delete-device", |
| 207 | cause, | 216 | cause, |
| 208 | }), | 217 | }), |
| 209 | ), | 218 | ), |
| 210 | ), | 219 | ); |
| 211 | ], | ||
| 212 | { discard: true }, | ||
| 213 | ); | ||
| 214 | }), | 220 | }), |
| 215 | listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { | 221 | listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { |
| 216 | const rows = yield* db | 222 | const rows = yield* db |
infra/relay/src/agentActivity/LiveActivities.test.ts+51| ... | @@ -198,6 +198,57 @@ describe("LiveActivities", () => { | ... | @@ -198,6 +198,57 @@ describe("LiveActivities", () => { |
| 198 | ); | 198 | ); |
| 199 | }); | 199 | }); |
| 200 | 200 | ||
| 201 | it.effect("retires the previous activity token when a start or end is delivered", () => { | ||
| 202 | const conflictConfigs: Array<{ readonly set?: Record<string, unknown> }> = []; | ||
| 203 | const fakeDb = { | ||
| 204 | insert: () => ({ | ||
| 205 | values: () => ({ | ||
| 206 | onConflictDoUpdate: (config: { readonly set?: Record<string, unknown> }) => { | ||
| 207 | conflictConfigs.push(config); | ||
| 208 | return Effect.void; | ||
| 209 | }, | ||
| 210 | }), | ||
| 211 | }), | ||
| 212 | } as unknown as RelayDb.RelayDb["Service"]; | ||
| 213 | |||
| 214 | return Effect.gen(function* () { | ||
| 215 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 216 | const mark = (kind: "live_activity_start" | "live_activity_update" | "live_activity_end") => | ||
| 217 | liveActivities.markDelivery({ | ||
| 218 | userId: "user-2", | ||
| 219 | deviceId: "device-1", | ||
| 220 | kind, | ||
| 221 | aggregate, | ||
| 222 | deliveredAt: "2026-05-25T00:00:10.000Z", | ||
| 223 | }); | ||
| 224 | yield* mark("live_activity_start"); | ||
| 225 | yield* mark("live_activity_update"); | ||
| 226 | yield* mark("live_activity_end"); | ||
| 227 | |||
| 228 | // A start begins a new activity generation and an end retires the | ||
| 229 | // current one; both must drop the stored update token so later | ||
| 230 | // deliveries can't route to the dead activity. Plain updates keep it. | ||
| 231 | expect(conflictConfigs[0]?.set).toEqual( | ||
| 232 | expect.objectContaining({ | ||
| 233 | activityPushToken: null, | ||
| 234 | remoteStartedAt: "2026-05-25T00:00:10.000Z", | ||
| 235 | endedAt: null, | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | expect(conflictConfigs[1]?.set?.activityPushToken).not.toBeNull(); | ||
| 239 | expect(conflictConfigs[2]?.set).toEqual( | ||
| 240 | expect.objectContaining({ | ||
| 241 | activityPushToken: null, | ||
| 242 | endedAt: "2026-05-25T00:00:10.000Z", | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | }).pipe( | ||
| 246 | Effect.provide( | ||
| 247 | LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), | ||
| 248 | ), | ||
| 249 | ); | ||
| 250 | }); | ||
| 251 | |||
| 201 | it.effect("preserves correlation context and causes for persistence failures", () => { | 252 | it.effect("preserves correlation context and causes for persistence failures", () => { |
| 202 | const cause = new Error("database unavailable"); | 253 | const cause = new Error("database unavailable"); |
| 203 | const registration: RelayLiveActivityRegistrationRequest = { | 254 | const registration: RelayLiveActivityRegistrationRequest = { |
infra/relay/src/agentActivity/LiveActivities.ts+24-4| ... | @@ -69,6 +69,8 @@ export interface DeviceRow { | ... | @@ -69,6 +69,8 @@ export interface DeviceRow { |
| 69 | readonly platform: "ios"; | 69 | readonly platform: "ios"; |
| 70 | readonly ios_major_version: number; | 70 | readonly ios_major_version: number; |
| 71 | readonly app_version: string | null; | 71 | readonly app_version: string | null; |
| 72 | readonly bundle_id: string | null; | ||
| 73 | readonly aps_environment: "sandbox" | "production" | null; | ||
| 72 | readonly push_token: string | null; | 74 | readonly push_token: string | null; |
| 73 | readonly push_to_start_token: string | null; | 75 | readonly push_to_start_token: string | null; |
| 74 | readonly preferences_json: string; | 76 | readonly preferences_json: string; |
| ... | @@ -196,6 +198,8 @@ export const make = Effect.gen(function* () { | ... | @@ -196,6 +198,8 @@ export const make = Effect.gen(function* () { |
| 196 | platform: relayMobileDevices.platform, | 198 | platform: relayMobileDevices.platform, |
| 197 | ios_major_version: relayMobileDevices.iosMajorVersion, | 199 | ios_major_version: relayMobileDevices.iosMajorVersion, |
| 198 | app_version: relayMobileDevices.appVersion, | 200 | app_version: relayMobileDevices.appVersion, |
| 201 | bundle_id: relayMobileDevices.bundleId, | ||
| 202 | aps_environment: relayMobileDevices.apsEnvironment, | ||
| 199 | push_token: relayMobileDevices.pushToken, | 203 | push_token: relayMobileDevices.pushToken, |
| 200 | push_to_start_token: relayMobileDevices.pushToStartToken, | 204 | push_to_start_token: relayMobileDevices.pushToStartToken, |
| 201 | preferences_json: relayMobileDevices.preferencesJson, | 205 | preferences_json: relayMobileDevices.preferencesJson, |
| ... | @@ -276,10 +280,26 @@ export const make = Effect.gen(function* () { | ... | @@ -276,10 +280,26 @@ export const make = Effect.gen(function* () { |
| 276 | .onConflictDoUpdate({ | 280 | .onConflictDoUpdate({ |
| 277 | target: [relayLiveActivities.userId, relayLiveActivities.deviceId], | 281 | target: [relayLiveActivities.userId, relayLiveActivities.deviceId], |
| 278 | set: { | 282 | set: { |
| 279 | remoteStartedAt: sql`coalesce( | 283 | // A delivered start begins a NEW activity generation: the stored |
| 280 | ${relayLiveActivities.remoteStartedAt}, | 284 | // update token belongs to the previous activity (dead once a new |
| 281 | excluded.remote_started_at | 285 | // one starts, and certainly dead after an end), so keep it only |
| 282 | )`, | 286 | // for plain updates. Deliveries pause until the app registers |
| 287 | // the fresh activity's token; registerLiveActivity + replay then | ||
| 288 | // reconcile content (or end the activity if work already | ||
| 289 | // finished). Without this, updates and ends route to the dead | ||
| 290 | // token and the new lock-screen card is stranded at its start | ||
| 291 | // content forever. | ||
| 292 | activityPushToken: | ||
| 293 | input.kind === "live_activity_update" | ||
| 294 | ? sql`${relayLiveActivities.activityPushToken}` | ||
| 295 | : null, | ||
| 296 | remoteStartedAt: | ||
| 297 | input.kind === "live_activity_start" | ||
| 298 | ? input.deliveredAt | ||
| 299 | : sql`coalesce( | ||
| 300 | ${relayLiveActivities.remoteStartedAt}, | ||
| 301 | excluded.remote_started_at | ||
| 302 | )`, | ||
| 283 | remoteStartQueuedAt: null, | 303 | remoteStartQueuedAt: null, |
| 284 | endedAt: | 304 | endedAt: |
| 285 | input.kind === "live_activity_start" | 305 | input.kind === "live_activity_start" |
infra/relay/src/agentActivity/MobileRegistrations.test.ts+48-41| ... | @@ -20,6 +20,7 @@ import * as RelayConfiguration from "../Config.ts"; | ... | @@ -20,6 +20,7 @@ import * as RelayConfiguration from "../Config.ts"; |
| 20 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | 20 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; |
| 21 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | 21 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; |
| 22 | import * as ApnsClient from "./ApnsClient.ts"; | 22 | import * as ApnsClient from "./ApnsClient.ts"; |
| 23 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; | ||
| 23 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | 24 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; |
| 24 | import * as MobileRegistrations from "./MobileRegistrations.ts"; | 25 | import * as MobileRegistrations from "./MobileRegistrations.ts"; |
| 25 | 26 | ||
| ... | @@ -70,6 +71,7 @@ function makeAgentActivityRows( | ... | @@ -70,6 +71,7 @@ function makeAgentActivityRows( |
| 70 | return { | 71 | return { |
| 71 | upsert: () => Effect.void, | 72 | upsert: () => Effect.void, |
| 72 | remove: () => Effect.void, | 73 | remove: () => Effect.void, |
| 74 | pruneTerminal: () => Effect.void, | ||
| 73 | listForUser: () => { | 75 | listForUser: () => { |
| 74 | const activeState: RelayAgentActivityState = { | 76 | const activeState: RelayAgentActivityState = { |
| 75 | environmentId: "env-1" as RelayAgentActivityState["environmentId"], | 77 | environmentId: "env-1" as RelayAgentActivityState["environmentId"], |
| ... | @@ -147,7 +149,11 @@ function makeRegistrationReplayLayer(input: { | ... | @@ -147,7 +149,11 @@ function makeRegistrationReplayLayer(input: { |
| 147 | }) { | 149 | }) { |
| 148 | return MobileRegistrations.layer.pipe( | 150 | return MobileRegistrations.layer.pipe( |
| 149 | Layer.provide(AgentActivityPublisher.layer), | 151 | Layer.provide(AgentActivityPublisher.layer), |
| 150 | Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), | 152 | Layer.provide( |
| 153 | ApnsDeliveries.layer.pipe( | ||
| 154 | Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))), | ||
| 155 | ), | ||
| 156 | ), | ||
| 151 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), | 157 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), |
| 152 | Layer.provide( | 158 | Layer.provide( |
| 153 | Layer.mergeAll( | 159 | Layer.mergeAll( |
| ... | @@ -207,6 +213,7 @@ describe("MobileRegistrations", () => { | ... | @@ -207,6 +213,7 @@ describe("MobileRegistrations", () => { |
| 207 | }), | 213 | }), |
| 208 | ), | 214 | ), |
| 209 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | 215 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), |
| 216 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 210 | Layer.succeed( | 217 | Layer.succeed( |
| 211 | AgentActivityPublisher.AgentActivityPublisher, | 218 | AgentActivityPublisher.AgentActivityPublisher, |
| 212 | makeAgentActivityPublisher({ | 219 | makeAgentActivityPublisher({ |
| ... | @@ -249,6 +256,7 @@ describe("MobileRegistrations", () => { | ... | @@ -249,6 +256,7 @@ describe("MobileRegistrations", () => { |
| 249 | Layer.mergeAll( | 256 | Layer.mergeAll( |
| 250 | Layer.succeed(Devices.Devices, makeDevices()), | 257 | Layer.succeed(Devices.Devices, makeDevices()), |
| 251 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | 258 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), |
| 259 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 252 | Layer.succeed( | 260 | Layer.succeed( |
| 253 | AgentActivityPublisher.AgentActivityPublisher, | 261 | AgentActivityPublisher.AgentActivityPublisher, |
| 254 | makeAgentActivityPublisher({ | 262 | makeAgentActivityPublisher({ |
| ... | @@ -300,6 +308,7 @@ describe("MobileRegistrations", () => { | ... | @@ -300,6 +308,7 @@ describe("MobileRegistrations", () => { |
| 300 | }), | 308 | }), |
| 301 | ), | 309 | ), |
| 302 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | 310 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), |
| 311 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 303 | Layer.succeed( | 312 | Layer.succeed( |
| 304 | AgentActivityPublisher.AgentActivityPublisher, | 313 | AgentActivityPublisher.AgentActivityPublisher, |
| 305 | makeAgentActivityPublisher(), | 314 | makeAgentActivityPublisher(), |
| ... | @@ -344,6 +353,7 @@ describe("MobileRegistrations", () => { | ... | @@ -344,6 +353,7 @@ describe("MobileRegistrations", () => { |
| 344 | Layer.provide( | 353 | Layer.provide( |
| 345 | Layer.mergeAll( | 354 | Layer.mergeAll( |
| 346 | Layer.succeed(Devices.Devices, makeDevices()), | 355 | Layer.succeed(Devices.Devices, makeDevices()), |
| 356 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 347 | Layer.succeed( | 357 | Layer.succeed( |
| 348 | LiveActivities.LiveActivities, | 358 | LiveActivities.LiveActivities, |
| 349 | makeLiveActivities({ | 359 | makeLiveActivities({ |
| ... | @@ -381,8 +391,36 @@ describe("MobileRegistrations", () => { | ... | @@ -381,8 +391,36 @@ describe("MobileRegistrations", () => { |
| 381 | }); | 391 | }); |
| 382 | }); | 392 | }); |
| 383 | 393 | ||
| 394 | it.effect("returns the current aggregate for the app's arming decision", () => { | ||
| 395 | return Effect.gen(function* () { | ||
| 396 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 397 | const snapshot = yield* registrations.getAgentActivitySnapshot({ userId: "dev:julius" }); | ||
| 398 | |||
| 399 | expect(snapshot.aggregate).toMatchObject({ | ||
| 400 | activeCount: 1, | ||
| 401 | activities: [{ threadId: "thread-1", phase: "running" }], | ||
| 402 | }); | ||
| 403 | }).pipe( | ||
| 404 | Effect.provide( | ||
| 405 | MobileRegistrations.layer.pipe( | ||
| 406 | Layer.provide( | ||
| 407 | Layer.mergeAll( | ||
| 408 | Layer.succeed(Devices.Devices, makeDevices()), | ||
| 409 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 410 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | ||
| 411 | Layer.succeed( | ||
| 412 | AgentActivityPublisher.AgentActivityPublisher, | ||
| 413 | makeAgentActivityPublisher(), | ||
| 414 | ), | ||
| 415 | ), | ||
| 416 | ), | ||
| 417 | ), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | }); | ||
| 421 | |||
| 384 | it.effect( | 422 | it.effect( |
| 385 | "starts a remote Live Activity through the real publisher and APNs queue when a device registers after work is already active", | 423 | "does not remotely start a Live Activity when a device registers after work is already active", |
| 386 | () => { | 424 | () => { |
| 387 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | 425 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| 388 | const queuedStarts: Array< | 426 | const queuedStarts: Array< |
| ... | @@ -404,6 +442,8 @@ describe("MobileRegistrations", () => { | ... | @@ -404,6 +442,8 @@ describe("MobileRegistrations", () => { |
| 404 | platform: "ios", | 442 | platform: "ios", |
| 405 | ios_major_version: 18, | 443 | ios_major_version: 18, |
| 406 | app_version: "1.0.0", | 444 | app_version: "1.0.0", |
| 445 | bundle_id: null, | ||
| 446 | aps_environment: null, | ||
| 407 | push_token: "apns-device-token", | 447 | push_token: "apns-device-token", |
| 408 | push_to_start_token: "push-to-start-token", | 448 | push_to_start_token: "push-to-start-token", |
| 409 | preferences_json: JSON.stringify(device.preferences), | 449 | preferences_json: JSON.stringify(device.preferences), |
| ... | @@ -432,46 +472,13 @@ describe("MobileRegistrations", () => { | ... | @@ -432,46 +472,13 @@ describe("MobileRegistrations", () => { |
| 432 | }, | 472 | }, |
| 433 | }); | 473 | }); |
| 434 | 474 | ||
| 475 | // Activities are armed by the app in the foreground; a device | ||
| 476 | // registration alone never remote-starts one, even when work is | ||
| 477 | // already active and a push-to-start token is on file. | ||
| 435 | expect(result).toEqual({ ok: true }); | 478 | expect(result).toEqual({ ok: true }); |
| 436 | expect(registeredDevices).toEqual([ | 479 | expect(registeredDevices).toHaveLength(1); |
| 437 | { | 480 | expect(queuedStarts).toEqual([]); |
| 438 | userId: "dev:julius", | 481 | expect(queuedJobs).toEqual([]); |
| 439 | registration: { | ||
| 440 | ...device, | ||
| 441 | pushToken: "apns-device-token", | ||
| 442 | pushToStartToken: "push-to-start-token", | ||
| 443 | }, | ||
| 444 | }, | ||
| 445 | ]); | ||
| 446 | expect(queuedStarts).toMatchObject([ | ||
| 447 | { | ||
| 448 | userId: "dev:julius", | ||
| 449 | deviceId: "device-1", | ||
| 450 | }, | ||
| 451 | ]); | ||
| 452 | expect(queuedJobs).toHaveLength(1); | ||
| 453 | expect(queuedJobs[0]?.payload).toMatchObject({ | ||
| 454 | kind: "live_activity_start", | ||
| 455 | target: { | ||
| 456 | userId: "dev:julius", | ||
| 457 | deviceId: "device-1", | ||
| 458 | token: "push-to-start-token", | ||
| 459 | }, | ||
| 460 | aggregate: { | ||
| 461 | title: "T3 Code", | ||
| 462 | subtitle: "Agent work in progress", | ||
| 463 | activeCount: 1, | ||
| 464 | activities: [ | ||
| 465 | { | ||
| 466 | environmentId: "env-1", | ||
| 467 | threadId: "thread-1", | ||
| 468 | threadTitle: "Implement APNs", | ||
| 469 | status: "Working", | ||
| 470 | }, | ||
| 471 | ], | ||
| 472 | }, | ||
| 473 | notification: null, | ||
| 474 | }); | ||
| 475 | }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); | 482 | }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); |
| 476 | }, | 483 | }, |
| 477 | ); | 484 | ); |
infra/relay/src/agentActivity/MobileRegistrations.ts+22-1| ... | @@ -1,11 +1,14 @@ | ... | @@ -1,11 +1,14 @@ |
| 1 | import type { | 1 | import type { |
| 2 | RelayAgentActivitySnapshotResponse, | ||
| 2 | RelayDeviceRegistrationRequest, | 3 | RelayDeviceRegistrationRequest, |
| 3 | RelayLiveActivityRegistrationRequest, | 4 | RelayLiveActivityRegistrationRequest, |
| 4 | } from "@t3tools/contracts/relay"; | 5 | } from "@t3tools/contracts/relay"; |
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 6 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 8 | 10 | ||
| 11 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 9 | import * as Devices from "./Devices.ts"; | 12 | import * as Devices from "./Devices.ts"; |
| 10 | import * as LiveActivities from "./LiveActivities.ts"; | 13 | import * as LiveActivities from "./LiveActivities.ts"; |
| 11 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | 14 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; |
| ... | @@ -13,7 +16,8 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | ... | @@ -13,7 +16,8 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; |
| 13 | export type MobileRegistrationError = | 16 | export type MobileRegistrationError = |
| 14 | | Devices.DeviceRegistrationPersistenceError | 17 | | Devices.DeviceRegistrationPersistenceError |
| 15 | | Devices.DeviceUnregistrationPersistenceError | 18 | | Devices.DeviceUnregistrationPersistenceError |
| 16 | | LiveActivities.LiveActivityRegistrationPersistenceError; | 19 | | LiveActivities.LiveActivityRegistrationPersistenceError |
| 20 | | AgentActivityRows.AgentActivityRowListPersistenceError; | ||
| 17 | 21 | ||
| 18 | export class MobileRegistrations extends Context.Service< | 22 | export class MobileRegistrations extends Context.Service< |
| 19 | MobileRegistrations, | 23 | MobileRegistrations, |
| ... | @@ -30,10 +34,14 @@ export class MobileRegistrations extends Context.Service< | ... | @@ -30,10 +34,14 @@ export class MobileRegistrations extends Context.Service< |
| 30 | readonly userId: string; | 34 | readonly userId: string; |
| 31 | readonly deviceId: string; | 35 | readonly deviceId: string; |
| 32 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | 36 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; |
| 37 | readonly getAgentActivitySnapshot: (input: { | ||
| 38 | readonly userId: string; | ||
| 39 | }) => Effect.Effect<RelayAgentActivitySnapshotResponse, MobileRegistrationError>; | ||
| 33 | } | 40 | } |
| 34 | >()("t3code-relay/agentActivity/MobileRegistrations") {} | 41 | >()("t3code-relay/agentActivity/MobileRegistrations") {} |
| 35 | 42 | ||
| 36 | export const make = Effect.gen(function* () { | 43 | export const make = Effect.gen(function* () { |
| 44 | const rows = yield* AgentActivityRows.AgentActivityRows; | ||
| 37 | const devices = yield* Devices.Devices; | 45 | const devices = yield* Devices.Devices; |
| 38 | const liveActivities = yield* LiveActivities.LiveActivities; | 46 | const liveActivities = yield* LiveActivities.LiveActivities; |
| 39 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | 47 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; |
| ... | @@ -82,6 +90,19 @@ export const make = Effect.gen(function* () { | ... | @@ -82,6 +90,19 @@ export const make = Effect.gen(function* () { |
| 82 | return { ok: true as const }; | 90 | return { ok: true as const }; |
| 83 | }, | 91 | }, |
| 84 | ), | 92 | ), |
| 93 | getAgentActivitySnapshot: Effect.fn("relay.mobile_registrations.get_agent_activity_snapshot")( | ||
| 94 | function* (input) { | ||
| 95 | const activeStates = yield* rows.listForUser({ userId: input.userId }); | ||
| 96 | const now = yield* DateTime.now; | ||
| 97 | return { | ||
| 98 | aggregate: AgentActivityPublisher.makeAggregateState({ | ||
| 99 | activeStates, | ||
| 100 | terminalState: null, | ||
| 101 | nowMs: now.epochMilliseconds, | ||
| 102 | }), | ||
| 103 | }; | ||
| 104 | }, | ||
| 105 | ), | ||
| 85 | unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { | 106 | unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { |
| 86 | yield* Effect.annotateCurrentSpan({ | 107 | yield* Effect.annotateCurrentSpan({ |
| 87 | "relay.mobile.device_id": input.deviceId, | 108 | "relay.mobile.device_id": input.deviceId, |
infra/relay/src/agentActivity/agentActivityPayloads.ts+36| ... | @@ -1,9 +1,45 @@ | ... | @@ -1,9 +1,45 @@ |
| 1 | import type { | 1 | import type { |
| 2 | RelayAgentActivityAggregateRow, | 2 | RelayAgentActivityAggregateRow, |
| 3 | RelayAgentActivityAggregateState, | 3 | RelayAgentActivityAggregateState, |
| 4 | RelayAgentActivityState, | ||
| 4 | } from "@t3tools/contracts/relay"; | 5 | } from "@t3tools/contracts/relay"; |
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | |||
| 5 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; | 9 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 6 | 10 | ||
| 11 | export function isTerminalPhase(state: RelayAgentActivityState): boolean { | ||
| 12 | return state.phase === "completed" || state.phase === "failed"; | ||
| 13 | } | ||
| 14 | |||
| 15 | // Rows are only removed when their environment publishes a terminal state. An | ||
| 16 | // environment that dies mid-run (machine off, process killed) never does, so | ||
| 17 | // without an age cutoff its threads inflate activeCount forever. Actively | ||
| 18 | // running phases expire quickly; waiting phases can legitimately sit for hours | ||
| 19 | // while a user ignores an approval prompt, so they get a longer window. The | ||
| 20 | // underlying database row is left in place: a late publish for the thread | ||
| 21 | // refreshes updatedAt and the row becomes visible again. | ||
| 22 | const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; | ||
| 23 | const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; | ||
| 24 | |||
| 25 | export function isExpiredAgentActivityState( | ||
| 26 | state: RelayAgentActivityState, | ||
| 27 | nowMs: number, | ||
| 28 | ): boolean { | ||
| 29 | const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { | ||
| 30 | onNone: () => Number.NaN, | ||
| 31 | onSome: (dt) => dt.epochMilliseconds, | ||
| 32 | }); | ||
| 33 | if (Number.isNaN(updatedAtMs)) { | ||
| 34 | return true; | ||
| 35 | } | ||
| 36 | const ttlMs = | ||
| 37 | state.phase === "running" || state.phase === "starting" | ||
| 38 | ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS | ||
| 39 | : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; | ||
| 40 | return nowMs - updatedAtMs > ttlMs; | ||
| 41 | } | ||
| 42 | |||
| 7 | const MAX_SUMMARY_TEXT_LENGTH = 120; | 43 | const MAX_SUMMARY_TEXT_LENGTH = 120; |
| 8 | const MAX_STATUS_TEXT_LENGTH = 40; | 44 | const MAX_STATUS_TEXT_LENGTH = 40; |
| 9 | const MAX_DEEP_LINK_LENGTH = 512; | 45 | const MAX_DEEP_LINK_LENGTH = 512; |
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+23| ... | @@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({ | ... | @@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({ |
| 41 | }); | 41 | }); |
| 42 | export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; | 42 | export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; |
| 43 | 43 | ||
| 44 | // Alert copy attached to a Live Activity update/end push. Its presence makes | ||
| 45 | // the update "alerting": iOS wakes the screen, plays the haptic, and briefly | ||
| 46 | // expands the Dynamic Island instead of silently redrawing. | ||
| 47 | export const ApnsLiveActivityAlert = Schema.Struct({ | ||
| 48 | title: Schema.String, | ||
| 49 | body: Schema.String, | ||
| 50 | }); | ||
| 51 | export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type; | ||
| 52 | |||
| 44 | export const ApnsDeliveryJobPayload = Schema.Struct({ | 53 | export const ApnsDeliveryJobPayload = Schema.Struct({ |
| 45 | version: Schema.Literal(1), | 54 | version: Schema.Literal(1), |
| 46 | jobId: Schema.String, | 55 | jobId: Schema.String, |
| ... | @@ -49,9 +58,15 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ | ... | @@ -49,9 +58,15 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ |
| 49 | userId: Schema.String, | 58 | userId: Schema.String, |
| 50 | deviceId: Schema.String, | 59 | deviceId: Schema.String, |
| 51 | token: Schema.String, | 60 | token: Schema.String, |
| 61 | // Per-device APNs routing; absent on jobs queued by older relay builds, | ||
| 62 | // which fall back to the configured defaults. | ||
| 63 | bundleId: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 64 | apsEnvironment: Schema.optional(Schema.NullOr(Schema.Literals(["sandbox", "production"]))), | ||
| 52 | }), | 65 | }), |
| 53 | aggregate: Schema.NullOr(RelayAgentActivityAggregateState), | 66 | aggregate: Schema.NullOr(RelayAgentActivityAggregateState), |
| 54 | notification: Schema.NullOr(ApnsNotificationPayload), | 67 | notification: Schema.NullOr(ApnsNotificationPayload), |
| 68 | // Optional so jobs queued by older relay builds still decode. | ||
| 69 | alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)), | ||
| 55 | createdAt: Schema.String, | 70 | createdAt: Schema.String, |
| 56 | expiresAt: Schema.String, | 71 | expiresAt: Schema.String, |
| 57 | }); | 72 | }); |
| ... | @@ -224,8 +239,11 @@ export function makeApnsDeliveryJobPayload(input: { | ... | @@ -224,8 +239,11 @@ export function makeApnsDeliveryJobPayload(input: { |
| 224 | readonly userId: string; | 239 | readonly userId: string; |
| 225 | readonly deviceId: string; | 240 | readonly deviceId: string; |
| 226 | readonly token: string; | 241 | readonly token: string; |
| 242 | readonly bundleId?: string | null | undefined; | ||
| 243 | readonly apsEnvironment?: "sandbox" | "production" | null | undefined; | ||
| 227 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | 244 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; |
| 228 | readonly notification?: ApnsNotificationPayload | null; | 245 | readonly notification?: ApnsNotificationPayload | null; |
| 246 | readonly alert?: ApnsLiveActivityAlert | null | undefined; | ||
| 229 | readonly createdAt: string; | 247 | readonly createdAt: string; |
| 230 | readonly expiresAt: string; | 248 | readonly expiresAt: string; |
| 231 | readonly jobId: string; | 249 | readonly jobId: string; |
| ... | @@ -238,9 +256,14 @@ export function makeApnsDeliveryJobPayload(input: { | ... | @@ -238,9 +256,14 @@ export function makeApnsDeliveryJobPayload(input: { |
| 238 | userId: input.userId, | 256 | userId: input.userId, |
| 239 | deviceId: input.deviceId, | 257 | deviceId: input.deviceId, |
| 240 | token: input.token, | 258 | token: input.token, |
| 259 | ...(input.bundleId ? { bundleId: input.bundleId } : {}), | ||
| 260 | ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), | ||
| 241 | }, | 261 | }, |
| 242 | aggregate: input.aggregate, | 262 | aggregate: input.aggregate, |
| 243 | notification: input.notification ?? null, | 263 | notification: input.notification ?? null, |
| 264 | // Omitted (not null) when absent so signatures stay identical to jobs from | ||
| 265 | // relay builds that predate the field. | ||
| 266 | ...(input.alert ? { alert: input.alert } : {}), | ||
| 244 | createdAt: input.createdAt, | 267 | createdAt: input.createdAt, |
| 245 | expiresAt: input.expiresAt, | 268 | expiresAt: input.expiresAt, |
| 246 | }; | 269 | }; |
infra/relay/src/agentActivity/apnsJwt.ts created+160| ... | @@ -0,0 +1,160 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { p256 } from "@noble/curves/nist"; | ||
| 4 | import { sha256 } from "@noble/hashes/sha2"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | import * as Result from "effect/Result"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | import type { ApnsCredentials } from "../Config.ts"; | ||
| 12 | |||
| 13 | export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()( | ||
| 14 | "ApnsJwtEncodingError", | ||
| 15 | { | ||
| 16 | component: Schema.Literals(["header", "payload"]), | ||
| 17 | teamId: Schema.String, | ||
| 18 | keyId: Schema.String, | ||
| 19 | issuedAtUnixSeconds: Schema.Number, | ||
| 20 | cause: Schema.Defect(), | ||
| 21 | }, | ||
| 22 | ) { | ||
| 23 | override get message(): string { | ||
| 24 | return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()( | ||
| 29 | "ApnsJwtSigningError", | ||
| 30 | { | ||
| 31 | teamId: Schema.String, | ||
| 32 | keyId: Schema.String, | ||
| 33 | issuedAtUnixSeconds: Schema.Number, | ||
| 34 | cause: Schema.Defect(), | ||
| 35 | }, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | return `Failed to sign APNs JWT for key ${this.keyId}.`; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | export type ApnsJwtError = ApnsJwtEncodingError | ApnsJwtSigningError; | ||
| 43 | |||
| 44 | const encodeApnsJwtHeaderJson = Schema.encodeEffect( | ||
| 45 | Schema.fromJsonString( | ||
| 46 | Schema.Struct({ | ||
| 47 | alg: Schema.Literal("ES256"), | ||
| 48 | kid: Schema.String, | ||
| 49 | }), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | const encodeApnsJwtPayloadJson = Schema.encodeEffect( | ||
| 53 | Schema.fromJsonString( | ||
| 54 | Schema.Struct({ | ||
| 55 | iss: Schema.String, | ||
| 56 | iat: Schema.Number, | ||
| 57 | }), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | |||
| 61 | export interface ApnsJwtSigningInput { | ||
| 62 | readonly teamId: ApnsCredentials["teamId"]; | ||
| 63 | readonly keyId: ApnsCredentials["keyId"]; | ||
| 64 | readonly privateKey: ApnsCredentials["privateKey"]; | ||
| 65 | readonly issuedAtUnixSeconds: number; | ||
| 66 | } | ||
| 67 | |||
| 68 | export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: ApnsJwtSigningInput) { | ||
| 69 | const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( | ||
| 70 | Effect.mapError( | ||
| 71 | (cause) => | ||
| 72 | new ApnsJwtEncodingError({ | ||
| 73 | component: "header", | ||
| 74 | teamId: input.teamId, | ||
| 75 | keyId: input.keyId, | ||
| 76 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 77 | cause, | ||
| 78 | }), | ||
| 79 | ), | ||
| 80 | ); | ||
| 81 | const payloadJson = yield* encodeApnsJwtPayloadJson({ | ||
| 82 | iss: input.teamId, | ||
| 83 | iat: input.issuedAtUnixSeconds, | ||
| 84 | }).pipe( | ||
| 85 | Effect.mapError( | ||
| 86 | (cause) => | ||
| 87 | new ApnsJwtEncodingError({ | ||
| 88 | component: "payload", | ||
| 89 | teamId: input.teamId, | ||
| 90 | keyId: input.keyId, | ||
| 91 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 92 | cause, | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | ); | ||
| 96 | |||
| 97 | const privateKey = Redacted.value(input.privateKey); | ||
| 98 | const header = Encoding.encodeBase64Url(headerJson); | ||
| 99 | const payload = Encoding.encodeBase64Url(payloadJson); | ||
| 100 | const signingInput = `${header}.${payload}`; | ||
| 101 | |||
| 102 | return yield* Effect.try({ | ||
| 103 | try: () => { | ||
| 104 | // Deterministic ES256 (RFC 6979 via noble) instead of node's randomized | ||
| 105 | // signer: identical (key, iat) yields the byte-identical JWT on every | ||
| 106 | // worker isolate, so the fleet presents one stable provider token to | ||
| 107 | // APNs without any shared storage. Node crypto only converts the PEM to | ||
| 108 | // the raw scalar noble signs with. | ||
| 109 | const scalar = apnsSigningScalar(privateKey); | ||
| 110 | const signature = p256 | ||
| 111 | .sign(sha256(new TextEncoder().encode(signingInput)), scalar, { prehash: false }) | ||
| 112 | .toCompactRawBytes(); | ||
| 113 | return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; | ||
| 114 | }, | ||
| 115 | catch: (cause) => | ||
| 116 | new ApnsJwtSigningError({ | ||
| 117 | teamId: input.teamId, | ||
| 118 | keyId: input.keyId, | ||
| 119 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 120 | cause, | ||
| 121 | }), | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | // PEM parsing is pure and the key set is static per deployment; memoize the | ||
| 126 | // extracted P-256 scalar so signing never re-parses the PKCS8 document. | ||
| 127 | const signingScalarCache = new Map<string, Uint8Array>(); | ||
| 128 | |||
| 129 | function apnsSigningScalar(privateKeyPem: string): Uint8Array { | ||
| 130 | const cached = signingScalarCache.get(privateKeyPem); | ||
| 131 | if (cached) { | ||
| 132 | return cached; | ||
| 133 | } | ||
| 134 | const jwk = NodeCrypto.createPrivateKey(privateKeyPem.replace(/\\n/g, "\n")).export({ | ||
| 135 | format: "jwk", | ||
| 136 | }); | ||
| 137 | if (jwk.crv !== "P-256" || typeof jwk.d !== "string") { | ||
| 138 | throw new Error("APNs signing key is not a P-256 private key."); | ||
| 139 | } | ||
| 140 | const scalar = Result.getOrThrowWith( | ||
| 141 | Encoding.decodeBase64Url(jwk.d), | ||
| 142 | () => new Error("APNs signing key scalar is not valid base64url."), | ||
| 143 | ); | ||
| 144 | signingScalarCache.set(privateKeyPem, scalar); | ||
| 145 | return scalar; | ||
| 146 | } | ||
| 147 | |||
| 148 | // Fingerprint the key material so rotated credentials never reuse a JWT | ||
| 149 | // signed by the previous key. | ||
| 150 | export function apnsProviderTokenCacheKey(input: { | ||
| 151 | readonly teamId: string; | ||
| 152 | readonly keyId: string; | ||
| 153 | readonly privateKey: ApnsCredentials["privateKey"]; | ||
| 154 | }): string { | ||
| 155 | const keyFingerprint = NodeCrypto.createHash("sha256") | ||
| 156 | .update(Redacted.value(input.privateKey)) | ||
| 157 | .digest("hex") | ||
| 158 | .slice(0, 16); | ||
| 159 | return `${input.teamId}:${input.keyId}:${keyFingerprint}`; | ||
| 160 | } | ||
infra/relay/src/environments/EnvironmentLinker.test.ts+75-1| ... | @@ -109,6 +109,7 @@ const makeRequest = Effect.gen(function* () { | ... | @@ -109,6 +109,7 @@ const makeRequest = Effect.gen(function* () { |
| 109 | function testLayer(input?: { | 109 | function testLayer(input?: { |
| 110 | readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"]; | 110 | readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"]; |
| 111 | readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"]; | 111 | readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"]; |
| 112 | readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"]; | ||
| 112 | }) { | 113 | }) { |
| 113 | return EnvironmentLinker.layer.pipe( | 114 | return EnvironmentLinker.layer.pipe( |
| 114 | Layer.provideMerge(RelayTokens.layer), | 115 | Layer.provideMerge(RelayTokens.layer), |
| ... | @@ -135,7 +136,7 @@ function testLayer(input?: { | ... | @@ -135,7 +136,7 @@ function testLayer(input?: { |
| 135 | revokeForEnvironmentPublicKey: () => Effect.succeed(false), | 136 | revokeForEnvironmentPublicKey: () => Effect.succeed(false), |
| 136 | }), | 137 | }), |
| 137 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { | 138 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { |
| 138 | deprovision: () => Effect.void, | 139 | deprovision: input?.deprovision ?? (() => Effect.void), |
| 139 | provision: () => | 140 | provision: () => |
| 140 | Effect.succeed({ | 141 | Effect.succeed({ |
| 141 | endpoint: { | 142 | endpoint: { |
| ... | @@ -173,6 +174,79 @@ describe("EnvironmentLinker", () => { | ... | @@ -173,6 +174,79 @@ describe("EnvironmentLinker", () => { |
| 173 | ); | 174 | ); |
| 174 | }); | 175 | }); |
| 175 | 176 | ||
| 177 | it.effect("links a publish-only environment with a non-secure nominal endpoint", () => { | ||
| 178 | let persistedEndpoint: string | null = null; | ||
| 179 | let deprovisionedEnvironmentId: string | null = null; | ||
| 180 | return Effect.gen(function* () { | ||
| 181 | const now = yield* DateTime.now; | ||
| 182 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 183 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 184 | const challenge = yield* relayTokens.issueLinkChallenge({ | ||
| 185 | userId: "user_123", | ||
| 186 | request: { | ||
| 187 | notificationsEnabled: true, | ||
| 188 | liveActivitiesEnabled: true, | ||
| 189 | managedTunnelsEnabled: false, | ||
| 190 | }, | ||
| 191 | jti: "publish-only-challenge-jti", | ||
| 192 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 193 | expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 194 | }); | ||
| 195 | const payload = { | ||
| 196 | iss: "t3-env:env-link-test", | ||
| 197 | aud: "https://relay.example.test", | ||
| 198 | sub: "env-link-test", | ||
| 199 | jti: "publish-only-proof-jti", | ||
| 200 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 201 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 202 | challenge, | ||
| 203 | environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], | ||
| 204 | descriptor: { | ||
| 205 | environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], | ||
| 206 | label: "Link Test Environment", | ||
| 207 | platform: { os: "darwin", arch: "arm64" }, | ||
| 208 | serverVersion: "0.0.0-test", | ||
| 209 | capabilities: { repositoryIdentity: true }, | ||
| 210 | }, | ||
| 211 | environmentPublicKey: environmentKeyPair.publicKey.trim(), | ||
| 212 | endpoint: { | ||
| 213 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 214 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 215 | providerKind: "manual", | ||
| 216 | }, | ||
| 217 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 218 | scopes: ["agent_activity_notifications"], | ||
| 219 | } satisfies RelayEnvironmentLinkProofPayload; | ||
| 220 | const request = { | ||
| 221 | proof: signTestJwt(payload, RELAY_LINK_PROOF_TYP, environmentKeyPair.privateKey), | ||
| 222 | notificationsEnabled: true, | ||
| 223 | liveActivitiesEnabled: true, | ||
| 224 | managedTunnelsEnabled: false, | ||
| 225 | } satisfies RelayEnvironmentLinkRequest; | ||
| 226 | const linker = yield* EnvironmentLinker.EnvironmentLinker; | ||
| 227 | const result = yield* linker.link({ userId: "user_123", request }); | ||
| 228 | expect(result.environmentCredential).toBe("t3env_credential_secret"); | ||
| 229 | expect(result.endpointRuntime).toBeNull(); | ||
| 230 | expect(persistedEndpoint).toBe("http://127.0.0.1:3773/"); | ||
| 231 | // Downgrading from a managed link must release the previously provisioned | ||
| 232 | // tunnel; nothing else cleans it up before a full unlink. | ||
| 233 | expect(deprovisionedEnvironmentId).toBe("env-link-test"); | ||
| 234 | }).pipe( | ||
| 235 | Effect.provide( | ||
| 236 | testLayer({ | ||
| 237 | upsert: (input) => | ||
| 238 | Effect.sync(() => { | ||
| 239 | persistedEndpoint = input.endpoint.httpBaseUrl; | ||
| 240 | }), | ||
| 241 | deprovision: (input) => | ||
| 242 | Effect.sync(() => { | ||
| 243 | deprovisionedEnvironmentId = input.environmentId; | ||
| 244 | }), | ||
| 245 | }), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | }); | ||
| 249 | |||
| 176 | it.effect("rejects a tampered compact proof before persistence", () => { | 250 | it.effect("rejects a tampered compact proof before persistence", () => { |
| 177 | let persisted = false; | 251 | let persisted = false; |
| 178 | return Effect.gen(function* () { | 252 | return Effect.gen(function* () { |
infra/relay/src/environments/EnvironmentLinker.ts+26-1| ... | @@ -287,6 +287,27 @@ const make = Effect.gen(function* () { | ... | @@ -287,6 +287,27 @@ const make = Effect.gen(function* () { |
| 287 | stage: "validate_origin", | 287 | stage: "validate_origin", |
| 288 | }); | 288 | }); |
| 289 | } | 289 | } |
| 290 | // Downgrading a managed link to publish-only must release the tunnel and | ||
| 291 | // DNS that were provisioned for it — nothing else cleans them up until a | ||
| 292 | // full unlink. Best effort: a cleanup failure must not block the link | ||
| 293 | // itself, and the provider treats an absent allocation as already | ||
| 294 | // deprovisioned, so retrying on every non-tunnel link is cheap. | ||
| 295 | if (!input.request.managedTunnelsEnabled) { | ||
| 296 | yield* managedEndpointProvider | ||
| 297 | .deprovision({ | ||
| 298 | userId: input.userId, | ||
| 299 | environmentId: verified.environmentId, | ||
| 300 | }) | ||
| 301 | .pipe( | ||
| 302 | Effect.tapError((error) => | ||
| 303 | Effect.logWarning("managed endpoint deprovision on publish-only link failed", { | ||
| 304 | environmentId: verified.environmentId, | ||
| 305 | errorTag: error._tag, | ||
| 306 | }), | ||
| 307 | ), | ||
| 308 | Effect.ignore, | ||
| 309 | ); | ||
| 310 | } | ||
| 290 | const provisioned = input.request.managedTunnelsEnabled | 311 | const provisioned = input.request.managedTunnelsEnabled |
| 291 | ? yield* managedEndpointProvider.provision({ | 312 | ? yield* managedEndpointProvider.provision({ |
| 292 | userId: input.userId, | 313 | userId: input.userId, |
| ... | @@ -295,7 +316,11 @@ const make = Effect.gen(function* () { | ... | @@ -295,7 +316,11 @@ const make = Effect.gen(function* () { |
| 295 | }) | 316 | }) |
| 296 | : null; | 317 | : null; |
| 297 | const endpoint = provisioned?.endpoint ?? verified.endpoint; | 318 | const endpoint = provisioned?.endpoint ?? verified.endpoint; |
| 298 | if (!isSecureManagedEndpoint(endpoint)) { | 319 | // The secure-endpoint requirement only matters when the relay advertises |
| 320 | // this endpoint for other devices to reach (managed tunnel). Publish-only | ||
| 321 | // links are reached out of band (e.g. Tailscale) and their stored endpoint | ||
| 322 | // is never used for routing, so a nominal endpoint is acceptable. | ||
| 323 | if (input.request.managedTunnelsEnabled && !isSecureManagedEndpoint(endpoint)) { | ||
| 299 | return yield* new EnvironmentLinkProofInvalid({ | 324 | return yield* new EnvironmentLinkProofInvalid({ |
| 300 | userId: input.userId, | 325 | userId: input.userId, |
| 301 | environmentId: verified.environmentId, | 326 | environmentId: verified.environmentId, |
infra/relay/src/http/Api.test.ts+31| ... | @@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; | ... | @@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { vi } from "vite-plus/test"; | 3 | import { vi } from "vite-plus/test"; |
| 4 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 5 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 8 | import * as Predicate from "effect/Predicate"; | 10 | import * as Predicate from "effect/Predicate"; |
| 9 | import * as Redacted from "effect/Redacted"; | 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | import * as Tracer from "effect/Tracer"; | 13 | import * as Tracer from "effect/Tracer"; |
| 11 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | 14 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 12 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 15 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| ... | @@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | ... | @@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 14 | import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; | 17 | import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; |
| 15 | 18 | ||
| 16 | import { | 19 | import { |
| 20 | RELAY_REQUEST_DEADLINE_MS, | ||
| 17 | relayCors, | 21 | relayCors, |
| 18 | relayDocsRedirectRoute, | 22 | relayDocsRedirectRoute, |
| 19 | relayEnvironmentAuthLayer, | 23 | relayEnvironmentAuthLayer, |
| ... | @@ -203,6 +207,33 @@ describe("relay request tracing", () => { | ... | @@ -203,6 +207,33 @@ describe("relay request tracing", () => { |
| 203 | expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); | 207 | expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); |
| 204 | }), | 208 | }), |
| 205 | ); | 209 | ); |
| 210 | |||
| 211 | it.effect("fails hung requests with a 504 before the client's 10s abort", () => | ||
| 212 | Effect.gen(function* () { | ||
| 213 | const spans: Array<Tracer.NativeSpan> = []; | ||
| 214 | const tracer = Tracer.make({ | ||
| 215 | span: (options) => { | ||
| 216 | const span = new Tracer.NativeSpan(options); | ||
| 217 | spans.push(span); | ||
| 218 | return span; | ||
| 219 | }, | ||
| 220 | }); | ||
| 221 | const request = HttpServerRequest.fromWeb( | ||
| 222 | new Request("https://relay.test/v1/mobile/devices", { method: "POST" }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | const fiber = yield* traceRelayHttpRequestWith( | ||
| 226 | Effect.never, | ||
| 227 | Layer.succeed(Tracer.Tracer, tracer), | ||
| 228 | ).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.forkChild); | ||
| 229 | yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS)); | ||
| 230 | const response = yield* Fiber.join(fiber); | ||
| 231 | |||
| 232 | expect(response.status).toBe(504); | ||
| 233 | expect(spans[0]?.attributes.get("relay.request.deadline_exceeded")).toBe(true); | ||
| 234 | expect(spans[0]?.attributes.get("http.response.status_code")).toBe(504); | ||
| 235 | }), | ||
| 236 | ); | ||
| 206 | }); | 237 | }); |
| 207 | 238 | ||
| 208 | describe("relay routing fallback", () => { | 239 | describe("relay routing fallback", () => { |
infra/relay/src/http/Api.ts+54-1| ... | @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; |
| 6 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | ||
| 9 | import * as Record from "effect/Record"; | 10 | import * as Record from "effect/Record"; |
| 10 | import * as Redacted from "effect/Redacted"; | 11 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| ... | @@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add( | ... | @@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add( |
| 155 | HttpServerResponse.redirect("/docs"), | 156 | HttpServerResponse.redirect("/docs"), |
| 156 | ); | 157 | ); |
| 157 | 158 | ||
| 159 | // Shorter than the mobile client's 10s request timeout on purpose: when a | ||
| 160 | // request hangs (e.g. a stuck upstream query), the client would otherwise | ||
| 161 | // abort first, the invocation would die with the request span still open, and | ||
| 162 | // the batched spans would never export — leaving no server-side trace at all. | ||
| 163 | // Failing server-side first turns the hang into a completed 504 whose trace | ||
| 164 | // contains the exact child span that stalled, and the response still carries | ||
| 165 | // the traceparent back to the client. | ||
| 166 | export const RELAY_REQUEST_DEADLINE_MS = 9_000; | ||
| 167 | |||
| 168 | const relayRequestDeadline = <E, R>( | ||
| 169 | httpEffect: Effect.Effect< | ||
| 170 | HttpServerResponse.HttpServerResponse, | ||
| 171 | E, | ||
| 172 | HttpServerRequest.HttpServerRequest | R | ||
| 173 | >, | ||
| 174 | ) => | ||
| 175 | httpEffect.pipe( | ||
| 176 | Effect.timeoutOption(Duration.millis(RELAY_REQUEST_DEADLINE_MS)), | ||
| 177 | Effect.flatMap( | ||
| 178 | Option.match({ | ||
| 179 | onNone: () => | ||
| 180 | Effect.gen(function* () { | ||
| 181 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 182 | yield* Effect.logError("relay request exceeded deadline", { | ||
| 183 | "http.method": request.method, | ||
| 184 | "http.url": request.url, | ||
| 185 | "relay.request.deadline_ms": RELAY_REQUEST_DEADLINE_MS, | ||
| 186 | }); | ||
| 187 | yield* Effect.annotateCurrentSpan({ | ||
| 188 | "relay.request.deadline_exceeded": true, | ||
| 189 | }); | ||
| 190 | return HttpServerResponse.jsonUnsafe( | ||
| 191 | { error: "relay_request_deadline_exceeded" }, | ||
| 192 | { status: 504 }, | ||
| 193 | ); | ||
| 194 | }), | ||
| 195 | onSome: Effect.succeed, | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 158 | export const traceRelayHttpRequest = <E, R>( | 200 | export const traceRelayHttpRequest = <E, R>( |
| 159 | httpEffect: Effect.Effect< | 201 | httpEffect: Effect.Effect< |
| 160 | HttpServerResponse.HttpServerResponse, | 202 | HttpServerResponse.HttpServerResponse, |
| ... | @@ -164,7 +206,7 @@ export const traceRelayHttpRequest = <E, R>( | ... | @@ -164,7 +206,7 @@ export const traceRelayHttpRequest = <E, R>( |
| 164 | ) => | 206 | ) => |
| 165 | // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. | 207 | // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. |
| 166 | HttpMiddleware.tracer( | 208 | HttpMiddleware.tracer( |
| 167 | appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), | 209 | appendRelayTraceContextResponseHeader.pipe(Effect.andThen(relayRequestDeadline(httpEffect))), |
| 168 | ).pipe(Effect.ensuring(Effect.yieldNow)); | 210 | ).pipe(Effect.ensuring(Effect.yieldNow)); |
| 169 | 211 | ||
| 170 | export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( | 212 | export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( |
| ... | @@ -391,6 +433,17 @@ export const mobileApi = HttpApiBuilder.group( | ... | @@ -391,6 +433,17 @@ export const mobileApi = HttpApiBuilder.group( |
| 391 | return yield* registrations.registerLiveActivity({ userId, payload }); | 433 | return yield* registrations.registerLiveActivity({ userId, payload }); |
| 392 | }, mapRelayCommonApiErrors("invalid_dpop")), | 434 | }, mapRelayCommonApiErrors("invalid_dpop")), |
| 393 | ) | 435 | ) |
| 436 | .handle( | ||
| 437 | "getAgentActivitySnapshot", | ||
| 438 | Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () { | ||
| 439 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 440 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); | ||
| 441 | yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 442 | expectedAccessToken: token, | ||
| 443 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 444 | return yield* registrations.getAgentActivitySnapshot({ userId }); | ||
| 445 | }, mapRelayCommonApiErrors("invalid_dpop")), | ||
| 446 | ) | ||
| 394 | .handle( | 447 | .handle( |
| 395 | "unregisterDevice", | 448 | "unregisterDevice", |
| 396 | Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { | 449 | Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { |
infra/relay/src/persistence/schema.ts+2| ... | @@ -24,6 +24,8 @@ export const relayMobileDevices = pgTable( | ... | @@ -24,6 +24,8 @@ export const relayMobileDevices = pgTable( |
| 24 | platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), | 24 | platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), |
| 25 | iosMajorVersion: integer("ios_major_version").notNull(), | 25 | iosMajorVersion: integer("ios_major_version").notNull(), |
| 26 | appVersion: varchar("app_version", { length: 64 }), | 26 | appVersion: varchar("app_version", { length: 64 }), |
| 27 | bundleId: varchar("bundle_id", { length: 255 }), | ||
| 28 | apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(), | ||
| 27 | pushToken: text("push_token"), | 29 | pushToken: text("push_token"), |
| 28 | pushToStartToken: text("push_to_start_token"), | 30 | pushToStartToken: text("push_to_start_token"), |
| 29 | preferencesJson: jsonb("preferences_json").notNull().$type<RelayAgentAwarenessPreferences>(), | 31 | preferencesJson: jsonb("preferences_json").notNull().$type<RelayAgentAwarenessPreferences>(), |
infra/relay/src/worker.ts+15-2| ... | @@ -2,6 +2,7 @@ import * as Alchemy from "alchemy"; | ... | @@ -2,6 +2,7 @@ import * as Alchemy from "alchemy"; |
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | 2 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 3 | import * as Drizzle from "alchemy/Drizzle"; | 3 | import * as Drizzle from "alchemy/Drizzle"; |
| 4 | import * as Config from "effect/Config"; | 4 | import * as Config from "effect/Config"; |
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | 6 | import * as Crypto from "effect/Crypto"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| ... | @@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu | ... | @@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu |
| 47 | import * as RelayConfiguration from "./Config.ts"; | 48 | import * as RelayConfiguration from "./Config.ts"; |
| 48 | import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; | 49 | import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; |
| 49 | import * as ApnsClient from "./agentActivity/ApnsClient.ts"; | 50 | import * as ApnsClient from "./agentActivity/ApnsClient.ts"; |
| 51 | import * as ApnsProviderTokens from "./agentActivity/ApnsProviderTokens.ts"; | ||
| 50 | import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; | 52 | import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; |
| 51 | import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; | 53 | import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; |
| 52 | import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; | 54 | import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; |
| ... | @@ -196,7 +198,7 @@ export default class Api extends Cloudflare.Worker<Api>()( | ... | @@ -196,7 +198,7 @@ export default class Api extends Cloudflare.Worker<Api>()( |
| 196 | ), | 198 | ), |
| 197 | Layer.provideMerge(DpopProofs.layer), | 199 | Layer.provideMerge(DpopProofs.layer), |
| 198 | Layer.provideMerge(ApnsDeliveries.layer), | 200 | Layer.provideMerge(ApnsDeliveries.layer), |
| 199 | Layer.provideMerge(ApnsClient.layer), | 201 | Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), |
| 200 | Layer.provideMerge( | 202 | Layer.provideMerge( |
| 201 | ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), | 203 | ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), |
| 202 | ), | 204 | ), |
| ... | @@ -242,7 +244,18 @@ export default class Api extends Cloudflare.Worker<Api>()( | ... | @@ -242,7 +244,18 @@ export default class Api extends Cloudflare.Worker<Api>()( |
| 242 | yield* Cloudflare.cron("*/5 * * * *").subscribe(() => | 244 | yield* Cloudflare.cron("*/5 * * * *").subscribe(() => |
| 243 | DpopProofs.DpopProofReplay.pipe( | 245 | DpopProofs.DpopProofReplay.pipe( |
| 244 | Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), | 246 | Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), |
| 245 | Effect.withSpan("relay.cron.prune_expired_dpop_proofs"), | 247 | // Terminal thread rows are kept briefly so finished agents show as |
| 248 | // Done/Failed in the Live Activity; sweep them once they age out. | ||
| 249 | Effect.andThen( | ||
| 250 | Effect.all([AgentActivityRows.AgentActivityRows, DateTime.now]).pipe( | ||
| 251 | Effect.flatMap(([activityRows, now]) => | ||
| 252 | activityRows.pruneTerminal({ | ||
| 253 | updatedBefore: DateTime.formatIso(DateTime.subtract(now, { minutes: 30 })), | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | Effect.withSpan("relay.cron.prune_expired_state"), | ||
| 246 | Effect.provide(runtimeLayer), | 259 | Effect.provide(runtimeLayer), |
| 247 | ), | 260 | ), |
| 248 | ); | 261 | ); |
packages/client-runtime/src/connection/resolver.test.ts+2| ... | @@ -84,6 +84,7 @@ function relayClient( | ... | @@ -84,6 +84,7 @@ function relayClient( |
| 84 | registerDevice: () => unsupported("registerDevice"), | 84 | registerDevice: () => unsupported("registerDevice"), |
| 85 | unregisterDevice: () => unsupported("unregisterDevice"), | 85 | unregisterDevice: () => unsupported("unregisterDevice"), |
| 86 | registerLiveActivity: () => unsupported("registerLiveActivity"), | 86 | registerLiveActivity: () => unsupported("registerLiveActivity"), |
| 87 | getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"), | ||
| 87 | resetTokenCache: Effect.void, | 88 | resetTokenCache: Effect.void, |
| 88 | }); | 89 | }); |
| 89 | } | 90 | } |
| ... | @@ -449,6 +450,7 @@ describe("ConnectionResolver", () => { | ... | @@ -449,6 +450,7 @@ describe("ConnectionResolver", () => { |
| 449 | new ManagedRelay.ManagedRelayRequestTimeoutError({ | 450 | new ManagedRelay.ManagedRelayRequestTimeoutError({ |
| 450 | activity: "Relay environment connection", | 451 | activity: "Relay environment connection", |
| 451 | timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, | 452 | timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, |
| 453 | traceId: null, | ||
| 452 | }), | 454 | }), |
| 453 | ), | 455 | ), |
| 454 | }); | 456 | }); |
packages/client-runtime/src/relay/discovery.test.ts+3| ... | @@ -112,6 +112,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { | ... | @@ -112,6 +112,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 112 | registerDevice: () => Effect.die("unused"), | 112 | registerDevice: () => Effect.die("unused"), |
| 113 | unregisterDevice: () => Effect.die("unused"), | 113 | unregisterDevice: () => Effect.die("unused"), |
| 114 | registerLiveActivity: () => Effect.die("unused"), | 114 | registerLiveActivity: () => Effect.die("unused"), |
| 115 | getAgentActivitySnapshot: () => Effect.die("unused"), | ||
| 115 | resetTokenCache: Effect.void, | 116 | resetTokenCache: Effect.void, |
| 116 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); | 117 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); |
| 117 | const connectivity = Connectivity.Connectivity.of({ | 118 | const connectivity = Connectivity.Connectivity.of({ |
| ... | @@ -261,6 +262,7 @@ describe("RelayEnvironmentDiscovery", () => { | ... | @@ -261,6 +262,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 261 | new ManagedRelay.ManagedRelayRequestTimeoutError({ | 262 | new ManagedRelay.ManagedRelayRequestTimeoutError({ |
| 262 | activity: "Relay environment listing", | 263 | activity: "Relay environment listing", |
| 263 | timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, | 264 | timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, |
| 265 | traceId: null, | ||
| 264 | }), | 266 | }), |
| 265 | ), | 267 | ), |
| 266 | getEnvironmentStatus: () => Effect.die("unused"), | 268 | getEnvironmentStatus: () => Effect.die("unused"), |
| ... | @@ -272,6 +274,7 @@ describe("RelayEnvironmentDiscovery", () => { | ... | @@ -272,6 +274,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 272 | registerDevice: () => Effect.die("unused"), | 274 | registerDevice: () => Effect.die("unused"), |
| 273 | unregisterDevice: () => Effect.die("unused"), | 275 | unregisterDevice: () => Effect.die("unused"), |
| 274 | registerLiveActivity: () => Effect.die("unused"), | 276 | registerLiveActivity: () => Effect.die("unused"), |
| 277 | getAgentActivitySnapshot: () => Effect.die("unused"), | ||
| 275 | resetTokenCache: Effect.void, | 278 | resetTokenCache: Effect.void, |
| 276 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); | 279 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); |
| 277 | const layer = RelayEnvironmentDiscovery.layer.pipe( | 280 | const layer = RelayEnvironmentDiscovery.layer.pipe( |
packages/client-runtime/src/relay/managedRelay.ts+49-5| ... | @@ -17,11 +17,13 @@ import { | ... | @@ -17,11 +17,13 @@ import { |
| 17 | RelayExchangeDpopAccessTokenEndpoint, | 17 | RelayExchangeDpopAccessTokenEndpoint, |
| 18 | RelayGetEnvironmentStatusEndpoint, | 18 | RelayGetEnvironmentStatusEndpoint, |
| 19 | RelayJwtSubjectTokenType, | 19 | RelayJwtSubjectTokenType, |
| 20 | type RelayAgentActivitySnapshotResponse, | ||
| 20 | type RelayLiveActivityRegistrationRequest, | 21 | type RelayLiveActivityRegistrationRequest, |
| 21 | RelayMobileRegistrationScope, | 22 | RelayMobileRegistrationScope, |
| 22 | type RelayOkResponse, | 23 | type RelayOkResponse, |
| 23 | type RelayPublicClientId, | 24 | type RelayPublicClientId, |
| 24 | RelayRegisterDeviceEndpoint, | 25 | RelayRegisterDeviceEndpoint, |
| 26 | RelayAgentActivitySnapshotEndpoint, | ||
| 25 | RelayRegisterLiveActivityEndpoint, | 27 | RelayRegisterLiveActivityEndpoint, |
| 26 | RelayProtectedError, | 28 | RelayProtectedError, |
| 27 | type RelayProtectedError as RelayProtectedErrorType, | 29 | type RelayProtectedError as RelayProtectedErrorType, |
| ... | @@ -92,6 +94,7 @@ export const ManagedRelayRequestAction = Schema.Literals([ | ... | @@ -92,6 +94,7 @@ export const ManagedRelayRequestAction = Schema.Literals([ |
| 92 | "register relay mobile device", | 94 | "register relay mobile device", |
| 93 | "unregister relay mobile device", | 95 | "unregister relay mobile device", |
| 94 | "register relay live activity", | 96 | "register relay live activity", |
| 97 | "read relay agent activity snapshot", | ||
| 95 | ]); | 98 | ]); |
| 96 | export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; | 99 | export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; |
| 97 | 100 | ||
| ... | @@ -107,6 +110,7 @@ export const ManagedRelayRequestActivity = Schema.Literals([ | ... | @@ -107,6 +110,7 @@ export const ManagedRelayRequestActivity = Schema.Literals([ |
| 107 | "Relay mobile device registration", | 110 | "Relay mobile device registration", |
| 108 | "Relay mobile device unregistration", | 111 | "Relay mobile device unregistration", |
| 109 | "Relay Live Activity registration", | 112 | "Relay Live Activity registration", |
| 113 | "Relay agent activity snapshot", | ||
| 110 | ]); | 114 | ]); |
| 111 | export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; | 115 | export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; |
| 112 | 116 | ||
| ... | @@ -115,6 +119,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man | ... | @@ -115,6 +119,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man |
| 115 | { | 119 | { |
| 116 | activity: ManagedRelayRequestActivity, | 120 | activity: ManagedRelayRequestActivity, |
| 117 | timeoutMs: Schema.Number, | 121 | timeoutMs: Schema.Number, |
| 122 | // The CLIENT span's trace id. A timed-out request has no server response | ||
| 123 | // to take an id from, but the client span was exported, so carrying its id | ||
| 124 | // makes the failure searchable instead of logging `traceId: null`. | ||
| 125 | traceId: Schema.NullOr(Schema.String), | ||
| 118 | }, | 126 | }, |
| 119 | ) { | 127 | ) { |
| 120 | override get message(): string { | 128 | override get message(): string { |
| ... | @@ -286,6 +294,9 @@ export class ManagedRelayClient extends Context.Service< | ... | @@ -286,6 +294,9 @@ export class ManagedRelayClient extends Context.Service< |
| 286 | readonly clerkToken: string; | 294 | readonly clerkToken: string; |
| 287 | readonly payload: RelayLiveActivityRegistrationRequest; | 295 | readonly payload: RelayLiveActivityRegistrationRequest; |
| 288 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | 296 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; |
| 297 | readonly getAgentActivitySnapshot: (input: { | ||
| 298 | readonly clerkToken: string; | ||
| 299 | }) => Effect.Effect<RelayAgentActivitySnapshotResponse, ManagedRelayClientError>; | ||
| 289 | readonly resetTokenCache: Effect.Effect<void>; | 300 | readonly resetTokenCache: Effect.Effect<void>; |
| 290 | } | 301 | } |
| 291 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} | 302 | >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} |
| ... | @@ -326,11 +337,18 @@ function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { | ... | @@ -326,11 +337,18 @@ function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { |
| 326 | Effect.flatMap( | 337 | Effect.flatMap( |
| 327 | Option.match({ | 338 | Option.match({ |
| 328 | onNone: () => | 339 | onNone: () => |
| 329 | Effect.fail( | 340 | Effect.currentParentSpan.pipe( |
| 330 | new ManagedRelayRequestTimeoutError({ | 341 | Effect.map((span) => span.traceId), |
| 331 | activity, | 342 | Effect.orElseSucceed(() => null), |
| 332 | timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, | 343 | Effect.flatMap((traceId) => |
| 333 | }), | 344 | Effect.fail( |
| 345 | new ManagedRelayRequestTimeoutError({ | ||
| 346 | activity, | ||
| 347 | timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, | ||
| 348 | traceId, | ||
| 349 | }), | ||
| 350 | ), | ||
| 351 | ), | ||
| 334 | ), | 352 | ), |
| 335 | onSome: Effect.succeed, | 353 | onSome: Effect.succeed, |
| 336 | }), | 354 | }), |
| ... | @@ -399,6 +417,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi | ... | @@ -399,6 +417,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi |
| 399 | registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), | 417 | registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), |
| 400 | unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), | 418 | unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), |
| 401 | registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), | 419 | registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), |
| 420 | getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"), | ||
| 402 | resetTokenCache: Effect.void.pipe( | 421 | resetTokenCache: Effect.void.pipe( |
| 403 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), | 422 | Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), |
| 404 | ), | 423 | ), |
| ... | @@ -446,6 +465,10 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( | ... | @@ -446,6 +465,10 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( |
| 446 | method: RelayUnregisterDeviceEndpoint.method, | 465 | method: RelayUnregisterDeviceEndpoint.method, |
| 447 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), | 466 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), |
| 448 | }), | 467 | }), |
| 468 | getAgentActivitySnapshot: (): DpopProofTarget => ({ | ||
| 469 | method: RelayAgentActivitySnapshotEndpoint.method, | ||
| 470 | url: urlBuilder.mobile.getAgentActivitySnapshot(), | ||
| 471 | }), | ||
| 449 | registerLiveActivity: (): DpopProofTarget => ({ | 472 | registerLiveActivity: (): DpopProofTarget => ({ |
| 450 | method: RelayRegisterLiveActivityEndpoint.method, | 473 | method: RelayRegisterLiveActivityEndpoint.method, |
| 451 | url: urlBuilder.mobile.registerLiveActivity(), | 474 | url: urlBuilder.mobile.registerLiveActivity(), |
| ... | @@ -845,6 +868,27 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( | ... | @@ -845,6 +868,27 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( |
| 845 | Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), | 868 | Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), |
| 846 | withRelayClientTracing, | 869 | withRelayClientTracing, |
| 847 | ), | 870 | ), |
| 871 | getAgentActivitySnapshot: Effect.fnUntraced( | ||
| 872 | function* (input) { | ||
| 873 | return yield* mobileRegistrationRequest( | ||
| 874 | { | ||
| 875 | clerkToken: input.clerkToken, | ||
| 876 | target: dpopProofTargets.getAgentActivitySnapshot(), | ||
| 877 | }, | ||
| 878 | (authorization) => | ||
| 879 | client.mobile | ||
| 880 | .getAgentActivitySnapshot({ | ||
| 881 | headers: dpopHeaders(authorization), | ||
| 882 | }) | ||
| 883 | .pipe( | ||
| 884 | Effect.mapError(relayRequestError("read relay agent activity snapshot")), | ||
| 885 | timeoutRelayRequest("Relay agent activity snapshot"), | ||
| 886 | ), | ||
| 887 | ); | ||
| 888 | }, | ||
| 889 | Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"), | ||
| 890 | withRelayClientTracing, | ||
| 891 | ), | ||
| 848 | registerLiveActivity: Effect.fnUntraced( | 892 | registerLiveActivity: Effect.fnUntraced( |
| 849 | function* (input) { | 893 | function* (input) { |
| 850 | return yield* mobileRegistrationRequest( | 894 | return yield* mobileRegistrationRequest( |
packages/client-runtime/src/relay/managedRelayState.test.ts+1| ... | @@ -83,6 +83,7 @@ function createManager( | ... | @@ -83,6 +83,7 @@ function createManager( |
| 83 | registerDevice: () => Effect.die("unused"), | 83 | registerDevice: () => Effect.die("unused"), |
| 84 | unregisterDevice: () => Effect.die("unused"), | 84 | unregisterDevice: () => Effect.die("unused"), |
| 85 | registerLiveActivity: () => Effect.die("unused"), | 85 | registerLiveActivity: () => Effect.die("unused"), |
| 86 | getAgentActivitySnapshot: () => Effect.die("unused"), | ||
| 86 | resetTokenCache: Effect.void, | 87 | resetTokenCache: Effect.void, |
| 87 | ...overrides, | 88 | ...overrides, |
| 88 | }); | 89 | }); |
packages/contracts/src/environmentHttp.ts+5| ... | @@ -342,6 +342,11 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ | ... | @@ -342,6 +342,11 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ |
| 342 | cloudUserId: Schema.NullOr(Schema.String), | 342 | cloudUserId: Schema.NullOr(Schema.String), |
| 343 | relayUrl: Schema.NullOr(Schema.String), | 343 | relayUrl: Schema.NullOr(Schema.String), |
| 344 | relayIssuer: Schema.NullOr(Schema.String), | 344 | relayIssuer: Schema.NullOr(Schema.String), |
| 345 | // A managed Cloudflare tunnel is provisioned for this link. False for a | ||
| 346 | // publish-only link (activity publishing without a relay-managed tunnel), so | ||
| 347 | // clients can present the two capabilities as independent settings. | ||
| 348 | // Optional so newer clients tolerate older environment servers. | ||
| 349 | managedTunnelActive: Schema.optional(Schema.Boolean), | ||
| 345 | publishAgentActivity: Schema.Boolean, | 350 | publishAgentActivity: Schema.Boolean, |
| 346 | }); | 351 | }); |
| 347 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; | 352 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; |
packages/contracts/src/relay.ts+28| ... | @@ -35,12 +35,21 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ | ... | @@ -35,12 +35,21 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ |
| 35 | }); | 35 | }); |
| 36 | export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; | 36 | export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; |
| 37 | 37 | ||
| 38 | export const RelayApnsEnvironment = Schema.Literals(["sandbox", "production"]); | ||
| 39 | export type RelayApnsEnvironment = typeof RelayApnsEnvironment.Type; | ||
| 40 | |||
| 38 | export const RelayDeviceRegistrationRequest = Schema.Struct({ | 41 | export const RelayDeviceRegistrationRequest = Schema.Struct({ |
| 39 | deviceId: TrimmedNonEmptyString, | 42 | deviceId: TrimmedNonEmptyString, |
| 40 | label: TrimmedNonEmptyString, | 43 | label: TrimmedNonEmptyString, |
| 41 | platform: RelayAgentAwarenessPlatform, | 44 | platform: RelayAgentAwarenessPlatform, |
| 42 | iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), | 45 | iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), |
| 43 | appVersion: Schema.optional(TrimmedNonEmptyString), | 46 | appVersion: Schema.optional(TrimmedNonEmptyString), |
| 47 | // APNs routing for this install: the topic must match the app's bundle id | ||
| 48 | // (dev/preview/prod variants differ) and development-signed builds receive | ||
| 49 | // sandbox tokens. Optional so older app builds keep registering; the relay | ||
| 50 | // falls back to its configured defaults. | ||
| 51 | bundleId: Schema.optional(TrimmedNonEmptyString), | ||
| 52 | apsEnvironment: Schema.optional(RelayApnsEnvironment), | ||
| 44 | pushToken: Schema.optional(TrimmedNonEmptyString), | 53 | pushToken: Schema.optional(TrimmedNonEmptyString), |
| 45 | pushToStartToken: Schema.optional(TrimmedNonEmptyString), | 54 | pushToStartToken: Schema.optional(TrimmedNonEmptyString), |
| 46 | preferences: RelayAgentAwarenessPreferences, | 55 | preferences: RelayAgentAwarenessPreferences, |
| ... | @@ -864,6 +873,24 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( | ... | @@ -864,6 +873,24 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( |
| 864 | }, | 873 | }, |
| 865 | ).annotate(OpenApi.Summary, "Register a Live Activity push token"); | 874 | ).annotate(OpenApi.Summary, "Register a Live Activity push token"); |
| 866 | 875 | ||
| 876 | export const RelayAgentActivitySnapshotResponse = Schema.Struct({ | ||
| 877 | aggregate: Schema.NullOr(RelayAgentActivityAggregateState), | ||
| 878 | }); | ||
| 879 | export type RelayAgentActivitySnapshotResponse = typeof RelayAgentActivitySnapshotResponse.Type; | ||
| 880 | |||
| 881 | // Lets the app decide whether arming a Live Activity is worthwhile before | ||
| 882 | // creating one (no empty lock-screen card when nothing is running) and seed | ||
| 883 | // the card with the real aggregate instead of a placeholder. | ||
| 884 | export const RelayAgentActivitySnapshotEndpoint = HttpApiEndpoint.get( | ||
| 885 | "getAgentActivitySnapshot", | ||
| 886 | "/v1/mobile/agent-activity", | ||
| 887 | { | ||
| 888 | headers: RelayDpopRequestHeaders, | ||
| 889 | success: RelayAgentActivitySnapshotResponse, | ||
| 890 | error: RelayAuthAndInternalErrors, | ||
| 891 | }, | ||
| 892 | ).annotate(OpenApi.Summary, "Read the current Live Activity aggregate"); | ||
| 893 | |||
| 867 | export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( | 894 | export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( |
| 868 | "unregisterDevice", | 895 | "unregisterDevice", |
| 869 | "/v1/mobile/devices/:deviceId", | 896 | "/v1/mobile/devices/:deviceId", |
| ... | @@ -879,6 +906,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") | ... | @@ -879,6 +906,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") |
| 879 | .add( | 906 | .add( |
| 880 | RelayRegisterDeviceEndpoint, | 907 | RelayRegisterDeviceEndpoint, |
| 881 | RelayRegisterLiveActivityEndpoint, | 908 | RelayRegisterLiveActivityEndpoint, |
| 909 | RelayAgentActivitySnapshotEndpoint, | ||
| 882 | RelayUnregisterDeviceEndpoint, | 910 | RelayUnregisterDeviceEndpoint, |
| 883 | ) | 911 | ) |
| 884 | .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") | 912 | .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") |
packages/shared/src/agentAwareness.test.ts+52| ... | @@ -102,6 +102,58 @@ describe("projectThreadAwareness", () => { | ... | @@ -102,6 +102,58 @@ describe("projectThreadAwareness", () => { |
| 102 | }); | 102 | }); |
| 103 | }); | 103 | }); |
| 104 | 104 | ||
| 105 | it("projects completed turns as completed even when teardown settled them as interrupted", () => { | ||
| 106 | const finishedTurn = { | ||
| 107 | turnId: "turn-1" as TurnId, | ||
| 108 | state: "interrupted" as const, | ||
| 109 | requestedAt: NOW, | ||
| 110 | startedAt: NOW, | ||
| 111 | completedAt: NOW, | ||
| 112 | assistantMessageId: null, | ||
| 113 | }; | ||
| 114 | const state = projectThreadAwareness({ | ||
| 115 | environmentId: "env-1" as EnvironmentId, | ||
| 116 | project, | ||
| 117 | thread: thread({ latestTurn: finishedTurn }), | ||
| 118 | }); | ||
| 119 | |||
| 120 | // Session teardown settles still-running turns by session status, and | ||
| 121 | // that write can race turn.completed; the completion timestamp is the | ||
| 122 | // durable signal. Without this the thread resolves to null persistently | ||
| 123 | // and gets tombstoned off the lock-screen card instead of showing Done. | ||
| 124 | expect(state?.phase).toBe("completed"); | ||
| 125 | |||
| 126 | const trulyInterrupted = projectThreadAwareness({ | ||
| 127 | environmentId: "env-1" as EnvironmentId, | ||
| 128 | project, | ||
| 129 | thread: thread({ latestTurn: { ...finishedTurn, completedAt: null } }), | ||
| 130 | }); | ||
| 131 | expect(trulyInterrupted).toBeNull(); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("projects ready sessions with no materialized turn as completed", () => { | ||
| 135 | // Quick threads without code changes never get a checkpoint, so the SQL | ||
| 136 | // shell has no latestTurn row and latest_turn_id is cleared when the | ||
| 137 | // session settles; the ready session is the only completion signal left. | ||
| 138 | const state = projectThreadAwareness({ | ||
| 139 | environmentId: "env-1" as EnvironmentId, | ||
| 140 | project, | ||
| 141 | thread: thread({ | ||
| 142 | session: { | ||
| 143 | threadId: "thread-1" as ThreadId, | ||
| 144 | status: "ready", | ||
| 145 | providerName: "Codex", | ||
| 146 | runtimeMode: "full-access", | ||
| 147 | activeTurnId: null, | ||
| 148 | lastError: null, | ||
| 149 | updatedAt: NOW, | ||
| 150 | }, | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | |||
| 154 | expect(state?.phase).toBe("completed"); | ||
| 155 | }); | ||
| 156 | |||
| 105 | it("projects failures with the session error detail", () => { | 157 | it("projects failures with the session error detail", () => { |
| 106 | const state = projectThreadAwareness({ | 158 | const state = projectThreadAwareness({ |
| 107 | environmentId: "env-1" as EnvironmentId, | 159 | environmentId: "env-1" as EnvironmentId, |
packages/shared/src/agentAwareness.ts+18| ... | @@ -103,6 +103,24 @@ function resolveThreadAwarenessPhase( | ... | @@ -103,6 +103,24 @@ function resolveThreadAwarenessPhase( |
| 103 | if (thread.latestTurn?.state === "completed") { | 103 | if (thread.latestTurn?.state === "completed") { |
| 104 | return "completed"; | 104 | return "completed"; |
| 105 | } | 105 | } |
| 106 | // A turn that finished can still read as "interrupted" here: session | ||
| 107 | // teardown settles still-running turns by session status, and that write | ||
| 108 | // can race the turn.completed one. completedAt survives the race — a turn | ||
| 109 | // that has a completion timestamp finished, whatever the state column says. | ||
| 110 | // Without this, quick finish-then-teardown threads resolve to null | ||
| 111 | // persistently and get tombstoned instead of published as completed. | ||
| 112 | if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) { | ||
| 113 | return "completed"; | ||
| 114 | } | ||
| 115 | // Threads whose turns never produce a checkpoint (no code changes) have no | ||
| 116 | // materialized latestTurn in the shell at all, and the session-set | ||
| 117 | // projection clears latest_turn_id the moment the session settles. The | ||
| 118 | // session status is then the only surviving completion signal: a live | ||
| 119 | // session at "ready"/"idle" with nothing pending and nothing running means | ||
| 120 | // the agent finished and is waiting for the next prompt — Done. | ||
| 121 | if (thread.session?.status === "ready" || thread.session?.status === "idle") { | ||
| 122 | return "completed"; | ||
| 123 | } | ||
| 106 | return null; | 124 | return null; |
| 107 | } | 125 | } |
| 108 | 126 |
pnpm-lock.yaml+6| ... | @@ -671,6 +671,12 @@ importers: | ... | @@ -671,6 +671,12 @@ importers: |
| 671 | '@effect/sql-pg': | 671 | '@effect/sql-pg': |
| 672 | specifier: 4.0.0-beta.78 | 672 | specifier: 4.0.0-beta.78 |
| 673 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) | 673 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| 674 | '@noble/curves': | ||
| 675 | specifier: 'catalog:' | ||
| 676 | version: 1.9.1 | ||
| 677 | '@noble/hashes': | ||
| 678 | specifier: 'catalog:' | ||
| 679 | version: 1.8.0 | ||
| 674 | '@t3tools/client-runtime': | 680 | '@t3tools/client-runtime': |
| 675 | specifier: workspace:* | 681 | specifier: workspace:* |
| 676 | version: link:../../packages/client-runtime | 682 | version: link:../../packages/client-runtime |