authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-07 00:56:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-07 00:56:20-07:00
logd8e0ba3ea02dc70874897655b2551ea72c1c738c
tree20969537c4d5d949baaa4b9c96227b6717d63fdf
parent32e7844837b35ca0a0ff038997a45ea75cb7103f
signature Signed by PGP key B5690EEEBB952194

Improve live activity routing and diagnostics (#3685)

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
15const 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.
19const 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
31function 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
40function 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 */
51function 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
73module.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
12const path = require("path");
13const fs = require("fs");
14const { withDangerousMod, withXcodeProject } = require("expo/config-plugins");
15const { addWidgetAssetCatalog } = require("./lib/addWidgetAssetCatalog.cjs");
16
17const TARGET_NAME = "ExpoWidgetsTarget";
18const CATALOG_NAME = "Assets.xcassets";
19const IMAGE_SET = "T3Mark.imageset";
20const SVG_NAME = "T3Mark.svg";
21
22const CATALOG_CONTENTS = JSON.stringify({ info: { author: "expo", version: 1 } }, null, 2) + "\n";
23const 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
37function 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
53function withAssetWiring(config) {
54 return withXcodeProject(config, (cfg) => {
55 addWidgetAssetCatalog(cfg.modResults, { targetName: TARGET_NAME });
56 return cfg;
57 });
58}
59
60module.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
8const path = require("path");
9const fs = require("fs");
10
11const xcodePath = require.resolve("xcode", {
12 paths: [
13 require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }),
14 ],
15});
16const xcode = require(xcodePath);
17const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs");
18
19const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj");
20const proj = xcode.project(pbxprojPath);
21proj.parseSync();
22
23const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" });
24
25if (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";
22
3import type { Preferences } from "../../lib/storage";3import type { Preferences } from "../../lib/storage";
44
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.
8export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {
9 return appVariant === "development" ? "sandbox" : "production";
10}
11
5export function makeRelayDeviceRegistrationRequest(input: {12export 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";
16import type { SavedRemoteConnection } from "../../lib/connection";16import type { SavedRemoteConnection } from "../../lib/connection";
17import { cryptoLayer } from "../cloud/dpop";17import { cryptoLayer } from "../cloud/dpop";
18import { managedRelayClientLayer } from "../cloud/managedRelayLayer";18import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
19import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";19import {
20 clearAgentAwarenessRegistrationRecord,
21 loadAgentAwarenessRegistrationRecord,
22 loadOrCreateAgentAwarenessDeviceId,
23 saveAgentAwarenessRegistrationRecord,
24} from "../../lib/storage";
25import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
20import {26import {
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}));
52const appStateMock = vi.hoisted(() => ({
53 listeners: [] as Array<(state: string) => void>,
54}));
55const registrationRecordStore = vi.hoisted(() => ({
56 current: null as {
57 readonly identity: string;
58 readonly signature: string;
59 readonly pushToStartToken?: string;
60 } | null,
61}));
4462
45vi.mock("expo-constants", () => ({63vi.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}));
104135
105vi.mock("../../lib/runtime", () => ({136vi.mock("../../lib/runtime", () => ({
...@@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({...@@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({
114vi.mock("../../lib/storage", () => ({145vi.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}));
119161
120function proofIat(proof: string): number {162function 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 });
215263
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 );
331404
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 });
400688
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+406-49
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";1import { type LiveActivity } from "expo-widgets";
2import Constants from "expo-constants";2import Constants from "expo-constants";
3import * as Notifications from "expo-notifications";3import * as Notifications from "expo-notifications";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import { Platform } from "react-native";6import { AppState, Platform } from "react-native";
7import type { EnvironmentId } from "@t3tools/contracts";7import type { EnvironmentId } from "@t3tools/contracts";
8import {8import {
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";
12import { findErrorTraceId } from "@t3tools/client-runtime/errors";13import { findErrorTraceId } from "@t3tools/client-runtime/errors";
...@@ -20,13 +21,16 @@ import {...@@ -20,13 +21,16 @@ import {
20import type { SavedRemoteConnection } from "../../lib/connection";21import type { SavedRemoteConnection } from "../../lib/connection";
21import { runtime } from "../../lib/runtime";22import { runtime } from "../../lib/runtime";
22import {23import {
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";
27import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";31import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
28import { resolveCloudPublicConfig } from "../cloud/publicConfig";32import { resolveCloudPublicConfig } from "../cloud/publicConfig";
29import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";33import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
3034
31const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;35const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
3236
...@@ -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]);
4652
47export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentAwarenessOperationError>()(53export 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
5864
59const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();65const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
60const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();66const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
61let 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.
76const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
77const registeredActivityPushTokens = new Map<string, number>();
62let pushTokenSubscription: { remove: () => void } | null = null;78let pushTokenSubscription: { remove: () => void } | null = null;
79let 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.
86export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed";
87let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
88const registrationStatusListeners = new Set<() => void>();
89
90function 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
100export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
101 return registrationStatus;
102}
103
104export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void {
105 registrationStatusListeners.add(listener);
106 return () => {
107 registrationStatusListeners.delete(listener);
108 };
109}
63let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;110let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
64let relayTokenProvider: (() => Promise<string | null>) | null = null;111let relayTokenProvider: (() => Promise<string | null>) | null = null;
65let relayTokenProviderIdentity: string | null = null;112let relayTokenProviderIdentity: string | null = null;
...@@ -74,7 +121,6 @@ let pendingDeviceRegistration: {...@@ -74,7 +121,6 @@ let pendingDeviceRegistration: {
74} | null = null;121} | null = null;
75122
76interface DeviceRegistrationInput {123interface DeviceRegistrationInput {
77 readonly pushToStartToken?: string;
78 readonly observedPushToken?: string;124 readonly observedPushToken?: string;
79}125}
80126
...@@ -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}
148210
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.
216export 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
149function iosMajorVersion(): number {229function 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 });
213293
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.
297function 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
214function registerDeviceWithRelay(315function 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 }
239377
...@@ -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}
280437
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.
443export 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
460function 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
500function 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
281function registerLiveActivityWithRelay(523function 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}
344580
345function registrationAddsInformation(581function 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}
354589
...@@ -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}
462708
463function registerDeviceForCurrentUser(709function 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> {
468714 return registerDevice(undefined);
469function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {
470 enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed");
471}
472
473function 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}
485716
486function ensurePushTokenListener(): void {717function ensurePushTokenListener(): void {
...@@ -498,14 +729,51 @@ function ensurePushTokenListener(): void {...@@ -498,14 +729,51 @@ function ensurePushTokenListener(): void {
498 });729 });
499}730}
500731
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.)
738function 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
754function 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
501export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {769export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
502 if (!canRegisterRemoteLiveActivities()) {770 if (!canRegisterRemoteLiveActivities()) {
503 return;771 return;
504 }772 }
505773
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)
523791
524export function unregisterAllAgentAwarenessConnections(): void {792export 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<
549822
550export function __resetAgentAwarenessRemoteRegistrationForTest(): void {823export 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}
566842
567export function unregisterAgentAwarenessDeviceForCurrentUser(843export 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 }
696980
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 );
712996
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";
14import { appAtomRegistry } from "../../state/atom-registry";14import { appAtomRegistry } from "../../state/atom-registry";
15import { useAtomCommand } from "../../state/use-atom-command";15import { useAtomCommand } from "../../state/use-atom-command";
16import {16import {
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";
6import { NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { SymbolView } from "expo-symbols";7import { SymbolView } from "expo-symbols";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import { useCallback, useEffect, useMemo, useState } from "react";9import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
10import { Alert, Linking, Platform, ScrollView, View } from "react-native";10import { Alert, Linking, Platform, ScrollView, View } from "react-native";
11import { useSafeAreaInsets } from "react-native-safe-area-context";11import { useSafeAreaInsets } from "react-native-safe-area-context";
1212
...@@ -20,7 +20,11 @@ import {...@@ -20,7 +20,11 @@ import {
20import { AppText as Text } from "../../components/AppText";20import { AppText as Text } from "../../components/AppText";
21import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";21import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
22import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";22import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
23import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";23import {
24 getAgentAwarenessRegistrationStatus,
25 refreshAgentAwarenessRegistration,
26 subscribeAgentAwarenessRegistrationStatus,
27} from "../agent-awareness/remoteRegistration";
24import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";28import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
25import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";29import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
26import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";30import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
...@@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";...@@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
37type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";41type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
38type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";42type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
3943
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).
48function useDeviceRegistered(): boolean {
49 const status = useSyncExternalStore(
50 subscribeAgentAwarenessRegistrationStatus,
51 getAgentAwarenessRegistrationStatus,
52 () => "unknown" as const,
53 );
54 return status === "registered";
55}
56
40export function SettingsRouteScreen() {57export function SettingsRouteScreen() {
41 const navigation = useNavigation();58 const navigation = useNavigation();
4259
...@@ -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();
116134
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() {
271298
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 > 0303 // 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]);
281318
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 <SettingsSwitchRow441 <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 {
34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
36import { useProjects } from "../../state/entities";36import { useProjects } from "../../state/entities";
37import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
38import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
37import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";39import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
38import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";40import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
39import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";41import { 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 }
525527
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 {
27import ImageViewing from "react-native-image-viewing";27import ImageViewing from "react-native-image-viewing";
28import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";28import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
29import { useThemeColor } from "../../lib/useThemeColor";29import { useThemeColor } from "../../lib/useThemeColor";
30import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
30import { scopedThreadKey } from "../../lib/scopedEntities";31import { scopedThreadKey } from "../../lib/scopedEntities";
3132
32import { AppText as Text } from "../../components/AppText";33import { 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 {
13const CONNECTIONS_KEY = "t3code.connections";13const CONNECTIONS_KEY = "t3code.connections";
14const PREFERENCES_KEY = "t3code.preferences";14const PREFERENCES_KEY = "t3code.preferences";
15const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";15const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
16const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
16const MobileStorageKey = Schema.Literals([17const 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]);
21type MobileStorageKeyValue = typeof MobileStorageKey.Type;23type MobileStorageKeyValue = typeof MobileStorageKey.Type;
2224
...@@ -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
228export 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.
240export 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
261export async function saveAgentAwarenessRegistrationRecord(
262 record: AgentAwarenessRegistrationRecord,
263): Promise<void> {
264 await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record);
265}
266
267export 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";
22
3vi.mock("@expo/ui/swift-ui", () => ({3vi.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}));
911
10vi.mock("@expo/ui/swift-ui/modifiers", () => ({12vi.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}));
1622
17vi.mock("expo-widgets", () => ({23vi.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}));
2026
21import { AgentActivity, type AgentActivityProps } from "./AgentActivity";27import {
28 AgentActivity,
29 type AgentActivityProps,
30 type AgentActivityRowProps,
31} from "./AgentActivity";
32
33function 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}
2247
23const props = {48const 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;
3560
61const lightEnvironment = {
62 colorScheme: "light",
63 isLuminanceReduced: false,
64} as const;
65
36describe("AgentActivity widget layout", () => {66describe("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 });
43143
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 @@
1import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";1import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
2import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";2import type { ComponentProps } from "react";
3import {
4 font,
5 foregroundStyle,
6 frame,
7 layoutPriority,
8 lineLimit,
9 padding,
10 resizable,
11 widgetURL,
12} from "@expo/ui/swift-ui/modifiers";
3import {13import {
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}
3949
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.
40export function AgentActivity(53export function AgentActivity(
41 props: AgentActivityProps,54 props: AgentActivityProps,
42 environment: LiveActivityEnvironment,55 environment: LiveActivityEnvironment,
43): LiveActivityLayout {56): LiveActivityLayout {
44 "widget";57 "widget";
4558
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 updatedAtMinutes69 // (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.isLuminanceReduced150 const phaseSymbol = (phase: AgentActivityPhase): SFName => {
65 ? secondaryForeground151 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 );
71227
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 <Text233 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 <Text245 <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 <Text258 <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 <Text284 <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 <Text297 <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 <Text306 <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 <Text351 <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 <Text360 {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";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as References from "effect/References";9import * as References from "effect/References";
1010
11import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts";11import {
12 acquireRelayClientForLink,
13 isPublishAgentActivityEnabledValue,
14 reportCloudDisconnectResults,
15} from "./connect.ts";
1216
13const managedExecutable = {17const 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
161it("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";
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30import * as CliState from "../cloud/CliState.ts";30import * as CliState from "../cloud/CliState.ts";
31import * as CliTokenManager from "../cloud/CliTokenManager.ts";31import * as CliTokenManager from "../cloud/CliTokenManager.ts";
32import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";32import {
33 CLOUD_LINKED_USER_ID,
34 PUBLISH_AGENT_ACTIVITY_SECRET,
35 RELAY_URL_SECRET,
36} from "../cloud/config.ts";
33import { relayUrlConfig } from "../cloud/publicConfig.ts";37import { relayUrlConfig } from "../cloud/publicConfig.ts";
34import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";38import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
35import * as ServerConfig from "../config.ts";39import * 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}
4852
53function stringToBytes(value: string): Uint8Array {
54 return new TextEncoder().encode(value);
55}
56
57export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
58 return value === "true";
59}
60
49interface CloudCliStatus {61interface 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}
5770
...@@ -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", {
368382
369const connectLinkCommand = Command.make("link", {383const 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 );
390414
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);
440480
481const 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
441const connectUnlinkCommand = Command.make("unlink", {551const 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 {
1414
15export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link";15export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link";
1616
17const 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.
22export type CliDesiredLinkMode = "managed" | "publish_only";
23
24const MANAGED_BYTES = new TextEncoder().encode("managed");
25const PUBLISH_ONLY_BYTES = new TextEncoder().encode("publish_only");
1826
19export const readCliDesiredCloudLink = Effect.gen(function* () {27export 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});
2331
32export 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
24export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* (44export 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";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerEnvironment from "../environment/ServerEnvironment.ts";12import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
13import * as CliTokenManager from "./CliTokenManager.ts";13import * as CliTokenManager from "./CliTokenManager.ts";
14import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";14import type { RelayLinkProofRequest } from "@t3tools/contracts/relay";
15import {
16 consumeCloudReplayGuards,
17 isSupportedLinkProviderKind,
18 linkProofScopes,
19 reconcileDesiredCloudLink,
20} from "./http.ts";
15import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";21import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
16import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";22import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
1723
...@@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => {...@@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => {
205 ),211 ),
206 );212 );
207});213});
214
215describe("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";
70import { relayUrlConfig } from "./publicConfig.ts";70import { relayUrlConfig } from "./publicConfig.ts";
71import { setCliDesiredCloudLink } from "./CliState.ts";71import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts";
72import * as CliTokenManager from "./CliTokenManager.ts";72import * as CliTokenManager from "./CliTokenManager.ts";
73import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";73import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
74import { traceRelayRequest } from "./traceRelayRequest.ts";74import { 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}
301301
302function 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.
306export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean {
307 return (
308 request.endpoint.providerKind === "cloudflare_tunnel" ||
309 request.endpoint.providerKind === "manual"
310 );
311}
312
313export 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}
305320
306function hasExactScope(input: {321function 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
608const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (625const 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});
205205
206// Compact, log-safe view of the fields the awareness phase ladder reads.
207export 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
206export function resolveAgentAwarenessRelayPublishSnapshot(input: {226export 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));
308328
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 }
392425
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* () {
478555
479 const worker = yield* makeDrainableWorker(publishThread);556 const worker = yield* makeDrainableWorker(publishThread);
480557
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 );
17021702
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();
17061706
...@@ -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: linkProofUrl1721 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 );
341345
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}
385385
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.
389export type CloudLinkMode = "managed" | "publish_only";
390
391const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind;
392
386export function linkPrimaryEnvironmentToCloud(input: {393export 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 }
404418
405 const challenge = yield* relayClient419 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 });
456470
apps/web/src/cloud/linkEnvironmentAtoms.ts+6-2
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6import { connectionAtomRuntime } from "../connection/runtime";6import { connectionAtomRuntime } from "../connection/runtime";
7import {7import {
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});
2731
28export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, {32export 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 <Switch1614 <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 };
16681670
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 }
16901704 const clerkToken = tokenResult.value;
1691 const linkResult =1705 const wantsLink = desired.managedTunnel || desired.publish;
1692 enabled && tokenResult.value1706
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 }
17081754
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 };
17181763
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: enabled1767 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 };
17281786
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;
17661801
1767 return (1802 return (
1768 <>1803 <>
1769 <SettingsRow1804 <SettingsRow
1770 title="T3 Connect"1805 title="T3 Connect"
1771 description={1806 description={
1772 linked1807 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 <CloudLinkSwitch1813 <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 @@
1ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);
2--> statement-breakpoint
3ALTER 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 [];
311314
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
636describe("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
657describe("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";
8import * as DateTime from "effect/DateTime";8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
1112
12import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts";13import {
14 isExpiredAgentActivityState,
15 isTerminalPhase,
16 sanitizeAgentActivityAggregateState,
17} from "./agentActivityPayloads.ts";
18
19export { isExpiredAgentActivityState } from "./agentActivityPayloads.ts";
13import * as AgentActivityRows from "./AgentActivityRows.ts";20import * as AgentActivityRows from "./AgentActivityRows.ts";
14import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";21import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
15import * as LiveActivities from "./LiveActivities.ts";22import * 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}
184203
185function isTerminalPhase(state: RelayAgentActivityState): boolean {
186 return state.phase === "completed" || state.phase === "failed";
187}
188
189function aggregateRowForState(state: RelayAgentActivityState) {204function 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}
212227
213function 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.
231export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 5 * 60 * 1_000;
232
233function 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
247export 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}
232297
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";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import { and, desc, eq, isNull } from "drizzle-orm";10import { and, desc, eq, isNull, lt, sql } from "drizzle-orm";
1111
12import * as RelayDb from "../db.ts";12import * as RelayDb from "../db.ts";
13import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts";13import { 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}
4040
41export 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
41export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass<AgentActivityRowListPersistenceError>()(53export 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 }),
165180
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* db205 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";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import * as HttpClient from "effect/unstable/http/HttpClient";11import * as HttpClient from "effect/unstable/http/HttpClient";
12import * as HttpClientError from "effect/unstable/http/HttpClientError";12import * as HttpClientError from "effect/unstable/http/HttpClientError";
13import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1314
14import type { ApnsCredentials } from "../Config.ts";15import type { ApnsCredentials } from "../Config.ts";
15import * as ApnsClient from "./ApnsClient.ts";16import * as ApnsClient from "./ApnsClient.ts";
17import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
1618
17const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);19const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
18const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);20const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
1921
20const TestLayer = ApnsClient.layer.pipe(22const 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 );
98101
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 );
119164
...@@ -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 );
215264
...@@ -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 @@
1import * as NodeCrypto from "node:crypto";
2
3import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";1import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
4import * as Context from "effect/Context";2import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";5import * as Option from "effect/Option";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
11import * as Headers from "effect/unstable/http/Headers";7import * as Headers from "effect/unstable/http/Headers";
12import * as HttpClient from "effect/unstable/http/HttpClient";8import * as HttpClient from "effect/unstable/http/HttpClient";
13import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";9import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
14import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";10import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
15import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";11import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
12import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
13import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
14
15export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
1616
17const LIVE_ACTIVITY_NAME = "AgentActivity";17const LIVE_ACTIVITY_NAME = "AgentActivity";
18const 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.
22const STALE_AFTER_SECONDS = 10 * 60;
19const DISMISS_AFTER_SECONDS = 5 * 60;23const 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.
27const CONTENTLESS_DISMISS_AFTER_SECONDS = 15;
2028
21const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);29const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);
22export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type;30export 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}
4553
46export 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
61export 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
75export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()(54export 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);
107const encodeApnsJwtHeaderJson = Schema.encodeEffect(
108 Schema.fromJsonString(
109 Schema.Struct({
110 alg: Schema.Literal("ES256"),
111 kid: Schema.String,
112 }),
113 ),
114);
115const encodeApnsJwtPayloadJson = Schema.encodeEffect(
116 Schema.fromJsonString(
117 Schema.Struct({
118 iss: Schema.String,
119 iat: Schema.Number,
120 }),
121 ),
122);
123
124const 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
183function contentState(state: RelayAgentActivityAggregateState) {86function 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 });
205110
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.
114function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) {
115 return {
116 alert: {
117 title: alert.title,
118 body: alert.body,
119 sound: "default",
120 },
121 };
122}
123
206function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {124function 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<
300223
301export const make = Effect.gen(function* () {224export const make = Effect.gen(function* () {
302 const httpClient = yield* HttpClient.HttpClient;225 const httpClient = yield* HttpClient.HttpClient;
226 const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens;
303227
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 {
5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import * as NodeCrypto from "node:crypto";7import * as NodeCrypto from "node:crypto";
8import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";11import * 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";
26import * as LiveActivities from "./LiveActivities.ts";27import * as LiveActivities from "./LiveActivities.ts";
27import * as RelayConfiguration from "../Config.ts";28import * as RelayConfiguration from "../Config.ts";
28import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";29import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
30import * as AgentActivityRows from "./AgentActivityRows.ts";
29import * as ApnsDeliveries from "./ApnsDeliveries.ts";31import * as ApnsDeliveries from "./ApnsDeliveries.ts";
30import * as ApnsClient from "./ApnsClient.ts";32import * as ApnsClient from "./ApnsClient.ts";
33import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
3134
32const config = RelayConfiguration.RelayConfiguration.of({35const 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}
226247
227describe("ApnsDeliveries", () => {248describe("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 > = [];
237255
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 });
248268
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 });
271279
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> = [];
275283
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();
283294
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 });
391406
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 });
631836
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
1428describe("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";
18import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
1919
20import {20import {
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";
38import * as AgentActivityRows from "./AgentActivityRows.ts";
35import * as DeliveryAttempts from "./DeliveryAttempts.ts";39import * as DeliveryAttempts from "./DeliveryAttempts.ts";
36import * as LiveActivities from "./LiveActivities.ts";40import * as LiveActivities from "./LiveActivities.ts";
37import * as RelayConfiguration from "../Config.ts";41import * 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";
39import { withSpanAttributes } from "../observability.ts";43import { withSpanAttributes } from "../observability.ts";
4044
41const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000;45const 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.
49const FRESHLY_ARMED_GRACE_MS = 2 * 60 * 1_000;
42const PERMANENT_APNS_TOKEN_REASONS = new Set([50const 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 };
6474
65type ChosenPushNotificationDelivery = {75type 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}
132142
143function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): boolean {
144 return aggregate.activities.some(
145 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
146 );
147}
148
149function 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.
156function 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.
181export 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.
218function 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
239function 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
250export 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.
277export 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
133function shouldUpdateLiveActivity(input: {291function 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 === null316 input.lastDeliveryAt === null
...@@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: {...@@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: {
159 );326 );
160}327}
161328
329// Completions replayed long after the fact (server restarts republish every
330// recently-finished thread) must not ring the device again.
331const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
332
162function notificationForAggregate(input: {333function 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}
193374
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.
194function chooseLiveActivityDelivery(input: {378function 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_token385 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_token395 // 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}
251455
252function chooseDelivery(input: {456function 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 = (
365interface LiveActivityDeliveryTarget {572interface 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.
584function 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}
369594
370function expectedCurrentToken(input: {595function 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 });
400627
401function makeLiveActivityDeliveryRequest(628function 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 });
470724
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* apns811 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* apns934 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 && token1085 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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schema from "effect/Schema";
7
8import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
9
10const { privateKey } = NodeCrypto.generateKeyPairSync("ec", {
11 namedCurve: "prime256v1",
12 privateKeyEncoding: { type: "pkcs8", format: "pem" },
13 publicKeyEncoding: { type: "spki", format: "pem" },
14});
15
16const signingInput = {
17 teamId: "team-1",
18 keyId: "key-1",
19 privateKey: Redacted.make(privateKey),
20};
21
22const WINDOW = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS;
23
24const decodeJwtPayload = Schema.decodeEffect(
25 Schema.fromJsonString(Schema.Struct({ iat: Schema.Number })),
26);
27
28describe("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 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import {
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.
17export const APNS_JWT_REUSE_SECONDS = 45 * 60;
18
19export class ApnsProviderTokens extends Context.Service<
20 ApnsProviderTokens,
21 {
22 readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect<string, ApnsJwtError>;
23 }
24>()("t3code-relay/agentActivity/ApnsProviderTokens") {}
25
26interface 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.
35const isolateTokenCache = new Map<string, CachedProviderToken>();
36
37export 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.
44export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number {
45 return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS;
46}
47
48export 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
63export 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;
8383
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.pushToken86 // the raw Proxy into the fiber runtime, which spins the isolate at 100%
87 ? db87 // 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.pushToStartToken103 ),
104 ? db104 );
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 }
123123
124 yield* db124 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 db182 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 db201 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* db222 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 });
200200
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_at285 // 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";
20import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";20import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
21import * as ApnsDeliveries from "./ApnsDeliveries.ts";21import * as ApnsDeliveries from "./ApnsDeliveries.ts";
22import * as ApnsClient from "./ApnsClient.ts";22import * as ApnsClient from "./ApnsClient.ts";
23import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
23import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";24import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
24import * as MobileRegistrations from "./MobileRegistrations.ts";25import * as MobileRegistrations from "./MobileRegistrations.ts";
2526
...@@ -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 });
383393
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 });
434474
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 @@
1import type {1import type {
2 RelayAgentActivitySnapshotResponse,
2 RelayDeviceRegistrationRequest,3 RelayDeviceRegistrationRequest,
3 RelayLiveActivityRegistrationRequest,4 RelayLiveActivityRegistrationRequest,
4} from "@t3tools/contracts/relay";5} from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
5import * as Context from "effect/Context";7import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
810
11import * as AgentActivityRows from "./AgentActivityRows.ts";
9import * as Devices from "./Devices.ts";12import * as Devices from "./Devices.ts";
10import * as LiveActivities from "./LiveActivities.ts";13import * as LiveActivities from "./LiveActivities.ts";
11import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";14import * 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";
13export type MobileRegistrationError =16export type MobileRegistrationError =
14 | Devices.DeviceRegistrationPersistenceError17 | Devices.DeviceRegistrationPersistenceError
15 | Devices.DeviceUnregistrationPersistenceError18 | Devices.DeviceUnregistrationPersistenceError
16 | LiveActivities.LiveActivityRegistrationPersistenceError;19 | LiveActivities.LiveActivityRegistrationPersistenceError
20 | AgentActivityRows.AgentActivityRowListPersistenceError;
1721
18export class MobileRegistrations extends Context.Service<22export 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") {}
3542
36export const make = Effect.gen(function* () {43export 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 @@
1import type {1import type {
2 RelayAgentActivityAggregateRow,2 RelayAgentActivityAggregateRow,
3 RelayAgentActivityAggregateState,3 RelayAgentActivityAggregateState,
4 RelayAgentActivityState,
4} from "@t3tools/contracts/relay";5} from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
7import * as Option from "effect/Option";
8
5import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";9import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
610
11export 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.
22const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;
23const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000;
24
25export 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
7const MAX_SUMMARY_TEXT_LENGTH = 120;43const MAX_SUMMARY_TEXT_LENGTH = 120;
8const MAX_STATUS_TEXT_LENGTH = 40;44const MAX_STATUS_TEXT_LENGTH = 40;
9const MAX_DEEP_LINK_LENGTH = 512;45const 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});
42export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;42export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
4343
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.
47export const ApnsLiveActivityAlert = Schema.Struct({
48 title: Schema.String,
49 body: Schema.String,
50});
51export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type;
52
44export const ApnsDeliveryJobPayload = Schema.Struct({53export 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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { p256 } from "@noble/curves/nist";
4import { sha256 } from "@noble/hashes/sha2";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10
11import type { ApnsCredentials } from "../Config.ts";
12
13export 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
28export 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
42export type ApnsJwtError = ApnsJwtEncodingError | ApnsJwtSigningError;
43
44const encodeApnsJwtHeaderJson = Schema.encodeEffect(
45 Schema.fromJsonString(
46 Schema.Struct({
47 alg: Schema.Literal("ES256"),
48 kid: Schema.String,
49 }),
50 ),
51);
52const encodeApnsJwtPayloadJson = Schema.encodeEffect(
53 Schema.fromJsonString(
54 Schema.Struct({
55 iss: Schema.String,
56 iat: Schema.Number,
57 }),
58 ),
59);
60
61export interface ApnsJwtSigningInput {
62 readonly teamId: ApnsCredentials["teamId"];
63 readonly keyId: ApnsCredentials["keyId"];
64 readonly privateKey: ApnsCredentials["privateKey"];
65 readonly issuedAtUnixSeconds: number;
66}
67
68export 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.
127const signingScalarCache = new Map<string, Uint8Array>();
128
129function 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.
150export 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* () {
109function testLayer(input?: {109function 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 });
175176
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.managedTunnelsEnabled311 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";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vite-plus/test";3import { vi } from "vite-plus/test";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";9import * as Option from "effect/Option";
8import * as Predicate from "effect/Predicate";10import * as Predicate from "effect/Predicate";
9import * as Redacted from "effect/Redacted";11import * as Redacted from "effect/Redacted";
12import * as TestClock from "effect/testing/TestClock";
10import * as Tracer from "effect/Tracer";13import * as Tracer from "effect/Tracer";
11import * as HttpRouter from "effect/unstable/http/HttpRouter";14import * as HttpRouter from "effect/unstable/http/HttpRouter";
12import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";15import * 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";
14import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";17import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
1518
16import {19import {
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});
207238
208describe("relay routing fallback", () => {239describe("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";
6import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
9import * as Record from "effect/Record";10import * as Record from "effect/Record";
10import * as Redacted from "effect/Redacted";11import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";12import * 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);
157158
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.
166export const RELAY_REQUEST_DEADLINE_MS = 9_000;
167
168const 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
158export const traceRelayHttpRequest = <E, R>(200export 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));
169211
170export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>(212export 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";
2import * as Cloudflare from "alchemy/Cloudflare";2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";3import * as Drizzle from "alchemy/Drizzle";
4import * as Config from "effect/Config";4import * as Config from "effect/Config";
5import * as DateTime from "effect/DateTime";
5import * as Crypto from "effect/Crypto";6import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu...@@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu
47import * as RelayConfiguration from "./Config.ts";48import * as RelayConfiguration from "./Config.ts";
48import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";49import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";
49import * as ApnsClient from "./agentActivity/ApnsClient.ts";50import * as ApnsClient from "./agentActivity/ApnsClient.ts";
51import * as ApnsProviderTokens from "./agentActivity/ApnsProviderTokens.ts";
50import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts";52import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts";
51import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts";53import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts";
52import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";54import * 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]);
96export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;99export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;
97100
...@@ -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]);
111export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;115export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;
112116
...@@ -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});
347export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;352export 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});
36export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type;36export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type;
3737
38export const RelayApnsEnvironment = Schema.Literals(["sandbox", "production"]);
39export type RelayApnsEnvironment = typeof RelayApnsEnvironment.Type;
40
38export const RelayDeviceRegistrationRequest = Schema.Struct({41export 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");
866875
876export const RelayAgentActivitySnapshotResponse = Schema.Struct({
877 aggregate: Schema.NullOr(RelayAgentActivityAggregateState),
878});
879export 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.
884export 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
867export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete(894export 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 });
104104
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}
108126
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.78672 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-runtime682 version: link:../../packages/client-runtime