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 = {
161161 bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
162162 groupIdentifier: `group.${variant.iosBundleIdentifier}`,
163163 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,
164167 widgets: [
165168 {
166169 name: "AgentActivity",
......@@ -171,6 +174,9 @@ const config: ExpoConfig = {
171174 ],
172175 },
173176 ],
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",
174180 "./plugins/withIosSceneLifecycle.cjs",
175181 "./plugins/withAndroidCleartextTraffic.cjs",
176182 ],
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";
22
33import 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
512export function makeRelayDeviceRegistrationRequest(input: {
613 readonly deviceId: string;
714 readonly label: string;
815 readonly iosMajorVersion: number;
916 readonly appVersion?: string;
17 readonly bundleId?: string;
18 readonly apsEnvironment?: "sandbox" | "production";
1019 readonly pushToken?: string;
1120 readonly pushToStartToken?: string;
1221 readonly notificationsEnabled: boolean;
......@@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: {
1928 platform: "ios",
2029 iosMajorVersion: input.iosMajorVersion,
2130 appVersion: input.appVersion,
31 ...(input.bundleId ? { bundleId: input.bundleId } : {}),
32 ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
2233 ...(input.pushToken ? { pushToken: input.pushToken } : {}),
2334 ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
2435 preferences: {
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+290-2
......@@ -16,15 +16,23 @@ import { verifyDpopProof } from "@t3tools/shared/dpop";
1616import type { SavedRemoteConnection } from "../../lib/connection";
1717import { cryptoLayer } from "../cloud/dpop";
1818import { 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";
2026import {
2127 AgentAwarenessOperationError,
2228 __resetAgentAwarenessRemoteRegistrationForTest,
29 getAgentAwarenessRegistrationStatus,
2330 refreshActiveLiveActivityRemoteRegistration,
2431 refreshAgentAwarenessRegistration,
2532 normalizeAgentAwarenessRelayBaseUrl,
2633 registerAgentAwarenessConnection,
2734 registerLiveActivityPushToken,
35 releaseAgentAwarenessRelayTokenProvider,
2836 setAgentAwarenessRelayTokenProvider,
2937 shouldRegisterAgentAwarenessDeviceForProvider,
3038 unregisterAgentAwarenessConnection,
......@@ -41,6 +49,16 @@ const backgroundRuntime = vi.hoisted(() => ({
4149 readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
4250 }>,
4351}));
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
4563vi.mock("expo-constants", () => ({
4664 default: {
......@@ -100,6 +118,19 @@ vi.mock("react-native", () => ({
100118 OS: "ios",
101119 Version: "18.0",
102120 },
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 },
103134}));
104135
105136vi.mock("../../lib/runtime", () => ({
......@@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({
114145vi.mock("../../lib/storage", () => ({
115146 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
116147 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 }),
118160}));
119161
120162function proofIat(proof: string): number {
......@@ -176,6 +218,12 @@ describe("makeRelayDeviceRegistrationRequest", () => {
176218 backgroundRuntime.pending.length = 0;
177219 Constants.expoConfig!.extra = {};
178220 __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");
179227 widgetMocks.getInstances.mockReset();
180228 widgetMocks.getInstances.mockReturnValue([]);
181229 });
......@@ -213,6 +261,31 @@ describe("makeRelayDeviceRegistrationRequest", () => {
213261 });
214262 });
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
216289 it("marks notification delivery disabled when APNs permission is unavailable", () => {
217290 expect(
218291 makeRelayDeviceRegistrationRequest({
......@@ -329,6 +402,53 @@ describe("makeRelayDeviceRegistrationRequest", () => {
329402 },
330403 );
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
332452 it.effect("refreshes APNs registration for connected environments after settings changes", () => {
333453 registerAgentAwarenessConnection(savedConnection());
334454 return Effect.gen(function* () {
......@@ -395,6 +515,174 @@ describe("makeRelayDeviceRegistrationRequest", () => {
395515 nowEpochSeconds: proofIat(dpop),
396516 }),
397517 ).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);
398686 }).pipe(Effect.provide(relayTestLayer));
399687 });
400688
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+406-49
......@@ -1,12 +1,13 @@
1import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";
1import { type LiveActivity } from "expo-widgets";
22import Constants from "expo-constants";
33import * as Notifications from "expo-notifications";
44import * as Effect from "effect/Effect";
55import * as Schema from "effect/Schema";
6import { Platform } from "react-native";
6import { AppState, Platform } from "react-native";
77import type { EnvironmentId } from "@t3tools/contracts";
88import {
99 type RelayDeviceRegistrationRequest,
10 type RelayAgentActivitySnapshotResponse,
1011 type RelayLiveActivityRegistrationRequest,
1112} from "@t3tools/contracts/relay";
1213import { findErrorTraceId } from "@t3tools/client-runtime/errors";
......@@ -20,13 +21,16 @@ import {
2021import type { SavedRemoteConnection } from "../../lib/connection";
2122import { runtime } from "../../lib/runtime";
2223import {
24 clearAgentAwarenessRegistrationRecord,
2325 loadAgentAwarenessDeviceId,
26 loadAgentAwarenessRegistrationRecord,
2427 loadOrCreateAgentAwarenessDeviceId,
2528 loadPreferences,
29 saveAgentAwarenessRegistrationRecord,
2630} from "../../lib/storage";
2731import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
2832import { resolveCloudPublicConfig } from "../cloud/publicConfig";
29import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
33import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
3034
3135const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
3236
......@@ -42,6 +46,8 @@ const AgentAwarenessOperation = Schema.Literals([
4246 "read-live-activity-push-token",
4347 "load-live-activity-registration-identifier",
4448 "list-active-live-activities",
49 "load-live-activity-prime-preferences",
50 "prime-live-activity",
4551]);
4652
4753export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentAwarenessOperationError>()(
......@@ -58,8 +64,49 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass<AgentA
5864
5965const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
6066const 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>();
6278let 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}
63110let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
64111let relayTokenProvider: (() => Promise<string | null>) | null = null;
65112let relayTokenProviderIdentity: string | null = null;
......@@ -74,7 +121,6 @@ let pendingDeviceRegistration: {
74121} | null = null;
75122
76123interface DeviceRegistrationInput {
77 readonly pushToStartToken?: string;
78124 readonly observedPushToken?: string;
79125}
80126
......@@ -120,32 +166,66 @@ export function setAgentAwarenessRelayTokenProvider(
120166 deviceRegistrationGeneration++;
121167 activeDeviceRegistration = null;
122168 pendingDeviceRegistration = null;
169 registeredActivityPushTokens.clear();
123170 }
124171 relayTokenProvider = provider;
125172 relayTokenProviderIdentity = provider ? (identity ?? null) : null;
126173 if (!provider) {
127 pushToStartSubscription?.remove();
128 pushToStartSubscription = null;
129174 pushTokenSubscription?.remove();
130175 pushTokenSubscription = null;
176 appStateSubscription?.remove();
177 appStateSubscription = null;
131178 if (activeLiveActivityRegistrationRetry) {
132179 clearTimeout(activeLiveActivityRegistrationRetry);
133180 activeLiveActivityRegistrationRetry = null;
134181 }
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 });
135191 return;
136192 }
137 ensurePushToStartListener();
138193 ensurePushTokenListener();
194 ensureAppStateListener();
139195 runRegistrationInBackground(
140196 refreshActiveLiveActivityRemoteRegistration(),
141197 "active live activity registration after cloud sign-in failed",
142198 );
143199 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 }
144206 return;
145207 }
146208 enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
147209}
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
149229function iosMajorVersion(): number {
150230 const version = Platform.Version;
151231 if (typeof version === "number") {
......@@ -211,6 +291,27 @@ const relayToken = (
211291 });
212292 });
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
214315function registerDeviceWithRelay(
215316 body: RelayDeviceRegistrationRequest,
216317 expectedGeneration: number,
......@@ -223,7 +324,13 @@ function registerDeviceWithRelay(
223324 });
224325 return;
225326 }
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 }
227334 const token = yield* relayToken("read-device-registration-relay-token");
228335 if (expectedGeneration !== deviceRegistrationGeneration) {
229336 logRegistrationDebug("device registration cancelled after auth lookup", {
......@@ -234,6 +341,37 @@ function registerDeviceWithRelay(
234341 }
235342 if (!token) {
236343 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 });
237375 return;
238376 }
239377
......@@ -243,8 +381,27 @@ function registerDeviceWithRelay(
243381 });
244382 yield* client.registerDevice({
245383 clerkToken: token,
246 payload: body,
384 payload,
247385 });
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 );
248405 logRegistrationDebug("relay device registration request completed", {
249406 expectedGeneration,
250407 });
......@@ -278,6 +435,91 @@ function unregisterDeviceWithRelay(input: {
278435 });
279436}
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
281523function registerLiveActivityWithRelay(
282524 body: RelayLiveActivityRegistrationRequest,
283525): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
......@@ -332,14 +574,8 @@ function mergeDeviceRegistrationInput(
332574 current: DeviceRegistrationInput,
333575 next: DeviceRegistrationInput,
334576): DeviceRegistrationInput {
335 return {
336 ...((next.pushToStartToken ?? current.pushToStartToken)
337 ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken }
338 : {}),
339 ...((next.observedPushToken ?? current.observedPushToken)
340 ? { observedPushToken: next.observedPushToken ?? current.observedPushToken }
341 : {}),
342 };
577 const observedPushToken = next.observedPushToken ?? current.observedPushToken;
578 return observedPushToken ? { observedPushToken } : {};
343579}
344580
345581function registrationAddsInformation(
......@@ -347,8 +583,7 @@ function registrationAddsInformation(
347583 next: DeviceRegistrationInput,
348584): boolean {
349585 return (
350 (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) ||
351 (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken)
586 next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken
352587 );
353588}
354589
......@@ -363,8 +598,10 @@ function startPendingDeviceRegistration(): void {
363598 logRegistrationDebug("device registration started", {
364599 generation,
365600 hasObservedPushToken: next.input.observedPushToken !== undefined,
366 hasPushToStartToken: next.input.pushToStartToken !== undefined,
367601 });
602 if (registrationStatus !== "registered") {
603 setRegistrationStatus("pending");
604 }
368605 const registration = {
369606 input: next.input,
370607 operation: Promise.resolve(),
......@@ -375,6 +612,13 @@ function startPendingDeviceRegistration(): void {
375612 runtime.runPromiseExit(registerDevice(next.input, generation)),
376613 );
377614 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 }
378622 logRegistrationError(next.context, squashAtomCommandFailure(result));
379623 }
380624 logRegistrationDebug("device registration finished", { generation });
......@@ -444,14 +688,16 @@ function registerDevice(
444688 expectedGeneration,
445689 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
446690 });
691 const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim();
447692 yield* registerDeviceWithRelay(
448693 makeRelayDeviceRegistrationRequest({
449694 deviceId,
450695 label: Constants.deviceName?.trim() || "iOS device",
451696 iosMajorVersion: iosMajorVersion(),
452697 appVersion: Constants.expoConfig?.version,
698 ...(bundleId ? { bundleId } : {}),
699 apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant),
453700 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
454 ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
455701 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
456702 preferences,
457703 }),
......@@ -460,27 +706,12 @@ function registerDevice(
460706 });
461707}
462708
463function registerDeviceForCurrentUser(
464 pushToStartToken?: string,
465): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
466 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
467}
468
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 });
709function registerDeviceForCurrentUser(): Effect.Effect<
710 void,
711 unknown,
712 ManagedRelay.ManagedRelayClient
713> {
714 return registerDevice(undefined);
484715}
485716
486717function ensurePushTokenListener(): void {
......@@ -498,14 +729,51 @@ function ensurePushTokenListener(): void {
498729 });
499730}
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
501769export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
502770 if (!canRegisterRemoteLiveActivities()) {
503771 return;
504772 }
505773
506774 environmentConnections.set(connection.environmentId, connection);
507 ensurePushToStartListener();
508775 ensurePushTokenListener();
776 ensureAppStateListener();
509777 enqueueDeviceRegistration({}, "device registration failed");
510778 runRegistrationInBackground(
511779 refreshActiveLiveActivityRemoteRegistration(),
......@@ -523,10 +791,10 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId)
523791
524792export function unregisterAllAgentAwarenessConnections(): void {
525793 environmentConnections.clear();
526 pushToStartSubscription?.remove();
527 pushToStartSubscription = null;
528794 pushTokenSubscription?.remove();
529795 pushTokenSubscription = null;
796 appStateSubscription?.remove();
797 appStateSubscription = null;
530798 if (activeLiveActivityRegistrationRetry) {
531799 clearTimeout(activeLiveActivityRegistrationRetry);
532800 activeLiveActivityRegistrationRetry = null;
......@@ -541,6 +809,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<
541809 return registerDeviceForCurrentUser().pipe(
542810 Effect.catch((error) =>
543811 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 }
544817 logRegistrationError("device registration refresh failed", error);
545818 }),
546819 ),
......@@ -549,10 +822,10 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<
549822
550823export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
551824 environmentConnections.clear();
552 pushToStartSubscription?.remove();
553 pushToStartSubscription = null;
554825 pushTokenSubscription?.remove();
555826 pushTokenSubscription = null;
827 appStateSubscription?.remove();
828 appStateSubscription = null;
556829 if (activeLiveActivityRegistrationRetry) {
557830 clearTimeout(activeLiveActivityRegistrationRetry);
558831 activeLiveActivityRegistrationRetry = null;
......@@ -562,6 +835,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
562835 deviceRegistrationGeneration++;
563836 activeDeviceRegistration = null;
564837 pendingDeviceRegistration = null;
838 registrationStatus = "unknown";
839 registrationStatusListeners.clear();
840 registeredActivityPushTokens.clear();
565841}
566842
567843export function unregisterAgentAwarenessDeviceForCurrentUser(
......@@ -649,6 +925,13 @@ function registerLiveActivityPushTokenValue(input: {
649925 readonly activityPushToken: string;
650926}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
651927 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 }
652935 const deviceId = yield* Effect.tryPromise({
653936 try: () => loadOrCreateAgentAwarenessDeviceId(),
654937 catch: (cause) =>
......@@ -662,6 +945,7 @@ function registerLiveActivityPushTokenValue(input: {
662945 activityPushToken: input.activityPushToken,
663946 });
664947 if (registered) {
948 registeredActivityPushTokens.set(input.activityPushToken, Date.now());
665949 logRegistrationDebug("live activity push token registered", {
666950 tokenSuffix: input.activityPushToken.slice(-8),
667951 });
......@@ -694,7 +978,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
694978 return;
695979 }
696980
697 const activities = yield* Effect.try({
981 let activities = yield* Effect.try({
698982 try: () => AgentActivity.getInstances(),
699983 catch: (cause) =>
700984 new AgentAwarenessOperationError({
......@@ -710,6 +994,79 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
710994 ),
711995 );
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
7131070 const registrationResults = yield* Effect.forEach(activities, (activity) =>
7141071 registerLiveActivityPushToken({ activity }).pipe(
7151072 Effect.map((registered) => !registered),
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+6-1
......@@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime";
1414import { appAtomRegistry } from "../../state/atom-registry";
1515import { useAtomCommand } from "../../state/use-atom-command";
1616import {
17 releaseAgentAwarenessRelayTokenProvider,
1718 setAgentAwarenessRelayTokenProvider,
1819 unregisterAgentAwarenessDeviceForCurrentUser,
1920} from "../agent-awareness/remoteRegistration";
......@@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
143144 useEffect(
144145 () => () => {
145146 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);
147152 },
148153 [],
149154 );
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+59-14
......@@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native";
66import { NativeStackScreenOptions } from "../../native/StackHeader";
77import { SymbolView } from "expo-symbols";
88import * as Effect from "effect/Effect";
9import { useCallback, useEffect, useMemo, useState } from "react";
9import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
1010import { Alert, Linking, Platform, ScrollView, View } from "react-native";
1111import { useSafeAreaInsets } from "react-native-safe-area-context";
1212
......@@ -20,7 +20,11 @@ import {
2020import { AppText as Text } from "../../components/AppText";
2121import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
2222import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
23import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
23import {
24 getAgentAwarenessRegistrationStatus,
25 refreshAgentAwarenessRegistration,
26 subscribeAgentAwarenessRegistrationStatus,
27} from "../agent-awareness/remoteRegistration";
2428import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
2529import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
2630import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
......@@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
3741type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
3842type 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
4057export function SettingsRouteScreen() {
4158 const navigation = useNavigation();
4259
......@@ -113,6 +130,7 @@ function ConfiguredSettingsRouteScreen() {
113130 const { savedConnectionsById } = useSavedRemoteConnections();
114131 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
115132 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
133 const deviceRegistered = useDeviceRegistered();
116134
117135 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
118136 const environmentCount = connections.length;
......@@ -182,10 +200,19 @@ function ConfiguredSettingsRouteScreen() {
182200 }
183201 if (result.value.type === "granted") {
184202 setNotificationStatus("enabled");
185 Alert.alert(
186 "Notifications enabled",
187 "Live Activity notifications are enabled for this device.",
188 );
203 // Permission alone is not enough: the switch stays off until the relay
204 // registration succeeds, so tell the user the truth about which happened.
205 if (getAgentAwarenessRegistrationStatus() === "registered") {
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 }
189216 return;
190217 }
191218 if (result.value.type === "unsupported") {
......@@ -271,12 +298,22 @@ function ConfiguredSettingsRouteScreen() {
271298
272299 refreshManagedRelayEnvironments();
273300 setLiveActivityStatus("enabled");
274 Alert.alert(
275 "Live Activities enabled",
276 environmentCount > 0
277 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
278 : "Live Activity updates are enabled. Add an environment to start receiving updates.",
279 );
301 // The environment link can succeed while this device's own registration
302 // (the push-to-start token the relay needs) has not — don't claim Live
303 // Activities are live until the device is actually registered.
304 if (getAgentAwarenessRegistrationStatus() === "registered") {
305 Alert.alert(
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 }
280317 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
281318
282319 const handleDeviceNotificationsChange = useCallback(
......@@ -395,7 +432,10 @@ function ConfiguredSettingsRouteScreen() {
395432 icon="bell.badge"
396433 label="Device Notifications"
397434 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}
399439 onValueChange={handleDeviceNotificationsChange}
400440 />
401441 <SettingsSwitchRow
......@@ -404,7 +444,12 @@ function ConfiguredSettingsRouteScreen() {
404444 }
405445 icon="bolt.circle"
406446 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 }
408453 onValueChange={handleLiveActivitiesChange}
409454 />
410455 </SettingsSection>
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+10
......@@ -34,6 +34,8 @@ import {
3434import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3535import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
3636import { useProjects } from "../../state/entities";
37import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
38import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
3739import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
3840import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
3941import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
......@@ -524,6 +526,14 @@ export function NewTaskDraftScreen(props: {
524526 }
525527
526528 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 });
527537 const result = await createProjectThread({
528538 project: selectedProject,
529539 modelSelection,
apps/mobile/src/features/threads/ThreadComposer.tsx+14-1
......@@ -27,6 +27,7 @@ import {
2727import ImageViewing from "react-native-image-viewing";
2828import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
2929import { useThemeColor } from "../../lib/useThemeColor";
30import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
3031import { scopedThreadKey } from "../../lib/scopedEntities";
3132
3233import { AppText as Text } from "../../components/AppText";
......@@ -499,12 +500,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
499500 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
500501 if (inFlightThreadIdsRef.current.has(threadKey)) return;
501502 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 });
502509 try {
503510 await onSendMessage();
504511 } finally {
505512 inFlightThreadIdsRef.current.delete(threadKey);
506513 }
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 ]);
508521 const handleCommandSelect = useCallback(
509522 (item: ComposerCommandItem) => {
510523 if (!composerTrigger) return;
apps/mobile/src/lib/storage.ts+45
......@@ -13,10 +13,12 @@ import {
1313const CONNECTIONS_KEY = "t3code.connections";
1414const PREFERENCES_KEY = "t3code.preferences";
1515const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
16const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
1617const MobileStorageKey = Schema.Literals([
1718 CONNECTIONS_KEY,
1819 PREFERENCES_KEY,
1920 AGENT_AWARENESS_DEVICE_ID_KEY,
21 AGENT_AWARENESS_REGISTRATION_KEY,
2022]);
2123type MobileStorageKeyValue = typeof MobileStorageKey.Type;
2224
......@@ -222,3 +224,46 @@ export async function loadAgentAwarenessDeviceId(): Promise<string | null> {
222224 const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
223225 return existing?.trim() ? existing : null;
224226}
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";
22
33vi.mock("@expo/ui/swift-ui", () => ({
44 HStack: "HStack",
5 Image: "Image",
56 Spacer: "Spacer",
67 Text: "Text",
78 VStack: "VStack",
9 ZStack: "ZStack",
810}));
911
1012vi.mock("@expo/ui/swift-ui/modifiers", () => ({
1113 font: (value: unknown) => value,
1214 foregroundStyle: (value: unknown) => value,
15 frame: (value: unknown) => value,
16 layoutPriority: (value: unknown) => value,
1317 lineLimit: (value: unknown) => value,
1418 padding: (value: unknown) => value,
19 resizable: (value: unknown) => value,
20 widgetURL: (value: unknown) => ({ widgetURL: value }),
1521}));
1622
1723vi.mock("expo-widgets", () => ({
1824 createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })),
1925}));
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
2348const props = {
2449 title: "T3 Code",
......@@ -33,17 +58,162 @@ const environment = {
3358 isLuminanceReduced: false,
3459} as const;
3560
61const lightEnvironment = {
62 colorScheme: "light",
63 isLuminanceReduced: false,
64} as const;
65
3666describe("AgentActivity widget layout", () => {
37 it("formats its updated-at label without app-runtime helper references", () => {
38 expect(JSON.stringify(AgentActivity(props, environment as never))).toContain(
39 '"children":["Updated ","1:07"]',
67 it("tints each row by its own phase using the web sidebar's dark palette", () => {
68 const layout = AgentActivity(
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,
40138 );
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");
42142 });
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");
45192 expect(
46 JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)),
47 ).toContain('"children":["Updated ","now"]');
193 JSON.stringify(
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");
48218 });
49219});
apps/mobile/src/widgets/AgentActivity.tsx+276-229
......@@ -1,5 +1,15 @@
1import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";
1import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
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";
313import {
414 createLiveActivity,
515 type LiveActivityComponent,
......@@ -37,282 +47,319 @@ export interface AgentActivityProps {
3747 readonly activities: ReadonlyArray<AgentActivityRowProps>;
3848}
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.
4053export function AgentActivity(
4154 props: AgentActivityProps,
4255 environment: LiveActivityEnvironment,
4356): LiveActivityLayout {
4457 "widget";
4558
46 const row0 = props.activities[0];
47 const row1 = props.activities[1];
48 const row2 = props.activities[2];
49 const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt);
50 const updatedAtHours24 = Number(updatedAtMatch?.[1]);
51 const updatedAtMinutes = updatedAtMatch?.[2];
52 const updatedAt =
53 Number.isInteger(updatedAtHours24) &&
54 updatedAtHours24 >= 0 &&
55 updatedAtHours24 <= 23 &&
56 updatedAtMinutes
57 ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}`
58 : "now";
59 // Use SwiftUI's semantic label colors rather than fixed hex keyed off the
60 // device color scheme. A Live Activity banner always renders over a dark
61 // system material regardless of the device's light/dark setting, so
62 // scheme-derived dark text read as unreadable dark-on-dark on the lock
63 // screen. Semantic colors adapt to whatever material the OS places them on:
64 // the dark LA banner and the (light or dark) home-screen widget alike.
65 const primaryForeground = "primary";
66 const secondaryForeground = "secondary";
67
68 // Status tints mirror the web sidebar's pills
69 // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber
70 // for approval, indigo for input, sky for working, emerald for completed.
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
59145 const activeLabel = `${props.activeCount} active`;
60 const isLight = environment.colorScheme === "light";
61 const primaryForeground = isLight ? "#262626" : "#f5f5f5";
62 const secondaryForeground = isLight ? "#525252" : "#a3a3a3";
63 const mutedForeground = isLight ? "#737373" : "#8e8e93";
64 const tint = environment.isLuminanceReduced
65 ? secondaryForeground
66 : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input"
67 ? "#f97316"
68 : row0?.phase === "failed"
69 ? "#ef4444"
70 : "#14b8a6";
146
147 // A scannable status glyph per phase — reads faster than colored words and
148 // ties the compact / expanded / banner / watch presentations together.
149 type SFName = NonNullable<ComponentProps<typeof Image>["systemName"]>;
150 const phaseSymbol = (phase: AgentActivityPhase): SFName => {
151 switch (phase) {
152 case "waiting_for_approval":
153 return "exclamationmark.circle.fill";
154 case "waiting_for_input":
155 return "questionmark.circle.fill";
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
72228 return {
73229 banner: (
74 <VStack modifiers={[padding({ all: 14 })]}>
75 <HStack>
76 <VStack>
77 <Text
78 modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle(primaryForeground)]}
79 >
80 {props.title}
81 </Text>
230 <VStack
231 alignment="leading"
232 spacing={6}
233 modifiers={deepLink ? [padding({ all: 14 }), widgetURL(deepLink)] : [padding({ all: 14 })]}
234 >
235 {/* Logo pinned to the leading edge; the status texts centered across the
236 full width (ZStack so the logo doesn't skew the centering). No footer —
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} />
82245 <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 ]}
84251 >
85 {props.subtitle}
252 {agentsLabel}
86253 </Text>
87 </VStack>
88 <Spacer minLength={8} />
89 <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}>
90 {activeLabel}
91 </Text>
92 </HStack>
93 {row0 ? (
94 <HStack modifiers={[padding({ vertical: 4 })]}>
95 <VStack>
254 {attentionSuffix ? (
255 <Text modifiers={[font({ size: 13 }), foregroundStyle(secondaryForeground)]}>·</Text>
256 ) : null}
257 {attentionSuffix ? (
96258 <Text
97259 modifiers={[
98 font({ weight: "bold", size: 13 }),
99 foregroundStyle(primaryForeground),
260 font({ weight: "semibold", size: 13 }),
261 foregroundStyle(headerTint),
100262 lineLimit(1),
101263 ]}
102264 >
103 {row0.threadTitle}
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}
265 {attentionSuffix}
157266 </Text>
158 </VStack>
159 <Spacer minLength={8} />
160 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
161 {row2.status}
162 </Text>
267 ) : null}
268 <Spacer minLength={0} />
163269 </HStack>
164 ) : null}
165 <Text modifiers={[font({ size: 11 }), foregroundStyle(mutedForeground)]}>
166 Updated {updatedAt}
167 </Text>
270 </ZStack>
271 {row0 ? renderCompactRow(row0) : null}
272 {row1 ? renderCompactRow(row1) : null}
273 {row2 ? renderCompactRow(row2) : null}
274 {row3 ? renderCompactRow(row3) : null}
275 {row4 ? renderCompactRow(row4) : null}
168276 </VStack>
169277 ),
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.
170280 bannerSmall: (
171 <VStack modifiers={[padding({ all: 12 })]}>
172 <HStack>
281 <VStack alignment="leading" spacing={5} modifiers={[padding({ all: 10 })]}>
282 <HStack spacing={7} alignment="center">
283 {renderLogo(14, primaryForeground)}
173284 <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 ]}
175290 >
176 {props.title}
291 {attentionRows.length > 0 ? summary : activeLabel}
177292 </Text>
178293 <Spacer minLength={6} />
179 <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}>
180 {activeLabel}
181 </Text>
182294 </HStack>
183295 {row0 ? (
184 <VStack>
296 <HStack spacing={7} alignment="center">
185297 <Text
186298 modifiers={[
187 font({ weight: "bold", size: 12 }),
299 font({ weight: "semibold", size: 12 }),
188300 foregroundStyle(primaryForeground),
189301 lineLimit(1),
190302 ]}
191303 >
192304 {row0.threadTitle}
193305 </Text>
194 <Text
195 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
196 >
197 {row0.projectTitle} - {row0.status}
306 <Spacer minLength={6} />
307 <Text modifiers={[font({ size: 11 }), foregroundStyle(phaseTint(row0.phase))]}>
308 {row0.status}
198309 </Text>
199 </VStack>
310 </HStack>
200311 ) : null}
201312 </VStack>
202313 ),
203 compactLeading: (
204 <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text>
205 ),
314 compactLeading: renderLogo(14, tint),
206315 compactTrailing: (
207316 <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}
209322 </Text>
210323 ),
211 minimal: (
212 <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text>
213 ),
324 // The shared/minimal form is a ~22pt circle — a single signal reads there,
325 // the wordmark does not. Show the blocking phase glyph, else the mark.
326 minimal:
327 attentionRow || failedRow
328 ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase))
329 : renderLogo(11, tint),
214330 expandedLeading: (
215 <VStack modifiers={[padding({ all: 8 })]}>
216 <Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle(tint)]}>
217 {activeLabel}
218 </Text>
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}
331 <HStack spacing={5} alignment="center" modifiers={[padding({ leading: 4, vertical: 4 })]}>
332 {renderLogo(15, tint)}
333 <Text modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(tint)]}>
334 {`${props.activeCount}`}
231335 </Text>
232 <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}>
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>
336 </HStack>
241337 ),
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,
242346 expandedBottom: (
243 <VStack modifiers={[padding({ all: 8 })]}>
244 {row0 ? (
245 <HStack modifiers={[padding({ vertical: 4 })]}>
246 <VStack>
247 <Text
248 modifiers={[
249 font({ weight: "bold", size: 13 }),
250 foregroundStyle(primaryForeground),
251 lineLimit(1),
252 ]}
253 >
254 {row0.threadTitle}
255 </Text>
256 <Text
257 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
258 >
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}
347 // Vertical padding only: the expanded region provides its own horizontal
348 // content margins, so `all` padding double-indented the rows.
349 // Horizontal padding keeps both edges clear of the island's corner
350 // curvature (right edge clipped status labels; titles hugged the left).
351 <VStack
352 alignment="leading"
353 spacing={5}
354 modifiers={
355 deepLink
356 ? [padding({ vertical: 2, horizontal: 8 }), widgetURL(deepLink)]
357 : [padding({ vertical: 2, horizontal: 8 })]
358 }
359 >
360 {row0 ? renderCompactRow(row0) : null}
361 {row1 ? renderCompactRow(row1) : null}
362 {row2 ? renderCompactRow(row2) : null}
316363 </VStack>
317364 ),
318365 };
apps/server/src/cli/connect.test.ts+12-1
......@@ -8,7 +8,11 @@ import * as Logger from "effect/Logger";
88import * as Option from "effect/Option";
99import * as References from "effect/References";
1010
11import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts";
11import {
12 acquireRelayClientForLink,
13 isPublishAgentActivityEnabledValue,
14 reportCloudDisconnectResults,
15} from "./connect.ts";
1216
1317const managedExecutable = {
1418 status: "available",
......@@ -153,3 +157,10 @@ it.effect("keeps disconnect causes in structured logs and out of console warning
153157 ),
154158 );
155159});
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";
2929import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3030import * as CliState from "../cloud/CliState.ts";
3131import * 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";
3337import { relayUrlConfig } from "../cloud/publicConfig.ts";
3438import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3539import * as ServerConfig from "../config.ts";
......@@ -46,12 +50,21 @@ function bytesToString(value: Uint8Array): string {
4650 return new TextDecoder().decode(value);
4751}
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
4961interface CloudCliStatus {
5062 readonly desired: boolean;
5163 readonly authenticated: boolean;
5264 readonly linked: boolean;
5365 readonly cloudUserId: string | null;
5466 readonly relayUrl: string | null;
67 readonly publishAgentActivity: boolean;
5568 readonly relayClient: RelayClient.RelayClientStatus;
5669}
5770
......@@ -104,6 +117,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
104117 ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
105118 ` Environment link: ${provisioned}`,
106119 ` Relay: ${status.relayUrl ?? "not provisioned"}`,
120 ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
107121 ...formatRelayClientStatus(status.relayClient),
108122 ...(nextStep ? ["", `Next: ${nextStep}`] : []),
109123 ].join("\n");
......@@ -368,31 +382,52 @@ const connectLoginCommand = Command.make("login", {
368382
369383const connectLinkCommand = Command.make("link", {
370384 ...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 ),
371391}).pipe(
372392 Command.withDescription("Authorize this environment for T3 Connect on next start."),
373393 Command.withHandler((flags) =>
374394 runCloudCommand(
375395 flags,
376396 Effect.gen(function* () {
377 const relayClient = yield* RelayClient.RelayClient;
378 const installed = yield* acquireRelayClientForLink(
379 relayClient,
380 confirmRelayClientInstall,
381 reportRelayClientInstallProgress,
382 );
383 if (Option.isNone(installed)) {
384 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
385 return;
397 // A publish-only link needs no Cloudflare tunnel, so skip installing the
398 // relay client entirely.
399 if (!flags.publishOnly) {
400 const relayClient = yield* RelayClient.RelayClient;
401 const installed = yield* acquireRelayClientForLink(
402 relayClient,
403 confirmRelayClientInstall,
404 reportRelayClientInstallProgress,
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 );
386413 }
387 yield* Console.log(
388 `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
389 );
390414
391415 const tokens = yield* CliTokenManager.CloudCliTokenManager;
392416 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 }
394427 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.",
396431 );
397432 }),
398433 ),
......@@ -411,22 +446,27 @@ const connectStatusCommand = Command.make("status", {
411446 const secrets = yield* ServerSecretStore.ServerSecretStore;
412447 const relayClient = yield* RelayClient.RelayClient;
413448 const tokens = yield* CliTokenManager.CloudCliTokenManager;
414 const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all(
415 [
416 CliState.readCliDesiredCloudLink,
417 tokens.hasCredential,
418 secrets.get(CLOUD_LINKED_USER_ID),
419 secrets.get(RELAY_URL_SECRET),
420 relayClient.resolve,
421 ],
422 { concurrency: "unbounded" },
423 );
449 const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] =
450 yield* Effect.all(
451 [
452 CliState.readCliDesiredCloudLink,
453 tokens.hasCredential,
454 secrets.get(CLOUD_LINKED_USER_ID),
455 secrets.get(RELAY_URL_SECRET),
456 secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
457 relayClient.resolve,
458 ],
459 { concurrency: "unbounded" },
460 );
424461 const status: CloudCliStatus = {
425462 desired,
426463 authenticated,
427464 linked: Option.isSome(cloudUserId),
428465 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
429466 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
467 publishAgentActivity: isPublishAgentActivityEnabledValue(
468 Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null,
469 ),
430470 relayClient: executable,
431471 };
432472 yield* Console.log(formatCloudStatus(status, { json: flags.json }));
......@@ -438,6 +478,76 @@ const connectStatusCommand = Command.make("status", {
438478 ),
439479);
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
441551const connectUnlinkCommand = Command.make("unlink", {
442552 ...projectLocationFlags,
443553}).pipe(
......@@ -461,6 +571,7 @@ export const connectCommand = Command.make("connect").pipe(
461571 Command.withSubcommands([
462572 connectLoginCommand,
463573 connectLinkCommand,
574 connectPublishCommand,
464575 connectStatusCommand,
465576 connectUnlinkCommand,
466577 connectLogoutCommand,
apps/server/src/cloud/CliState.test.ts+23
......@@ -56,4 +56,27 @@ it.layer(NodeServices.layer)("CliState", (it) => {
5656 }
5757 }).pipe(Effect.provide(makeTestLayer())),
5858 );
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 );
5982});
apps/server/src/cloud/CliState.ts+26-2
......@@ -14,19 +14,43 @@ import {
1414
1515export 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
1927export const readCliDesiredCloudLink = Effect.gen(function* () {
2028 const secrets = yield* ServerSecretStore.ServerSecretStore;
2129 return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET));
2230});
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
2444export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* (
2545 desired: boolean,
46 mode: CliDesiredLinkMode = "managed",
2647) {
2748 const secrets = yield* ServerSecretStore.ServerSecretStore;
2849 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 );
3054 } else {
3155 yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET);
3256 }
apps/server/src/cloud/http.test.ts+36-1
......@@ -11,7 +11,13 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1111import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1212import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1313import * 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";
1521import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
1622import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
1723
......@@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => {
205211 ),
206212 );
207213});
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 {
6868 RELAY_URL_SECRET,
6969} from "./config.ts";
7070import { relayUrlConfig } from "./publicConfig.ts";
71import { setCliDesiredCloudLink } from "./CliState.ts";
71import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts";
7272import * as CliTokenManager from "./CliTokenManager.ts";
7373import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
7474import { traceRelayRequest } from "./traceRelayRequest.ts";
......@@ -299,8 +299,23 @@ function isAllowedEndpointOrigin(input: {
299299 return input.origin.localHttpPort === endpointRequestPort(url);
300300}
301301
302function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean {
303 return request.endpoint.providerKind === "cloudflare_tunnel";
302// A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must
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"];
304319}
305320
306321function hasExactScope(input: {
......@@ -352,7 +367,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function
352367) {
353368 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets);
354369 if (
355 !providerKindMatchesRequestedLinkScopes(request) ||
370 !isSupportedLinkProviderKind(request) ||
356371 !isAllowedEndpointOrigin({
357372 origin: request.origin,
358373 requestUrl,
......@@ -379,7 +394,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function
379394 environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey),
380395 endpoint: request.endpoint,
381396 origin: request.origin,
382 scopes: ["agent_activity_notifications", "managed_tunnels"],
397 scopes: linkProofScopes(request),
383398 } satisfies RelayEnvironmentLinkProofPayload;
384399 return yield* signRelayJwt({
385400 privateKey: keyPair.privateKey,
......@@ -537,6 +552,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
537552 }),
538553 ),
539554 );
555 const mode = yield* readCliDesiredLinkMode;
556 const managedTunnelsEnabled = mode !== "publish_only";
540557 const relayUrl = yield* requireRelayUrl;
541558 const challenge = yield* relayClientRequest(dependencies, {
542559 url: `${relayUrl}/v1/client/environment-link-challenges`,
......@@ -544,7 +561,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
544561 payload: {
545562 notificationsEnabled: true,
546563 liveActivitiesEnabled: true,
547 managedTunnelsEnabled: true,
564 managedTunnelsEnabled,
548565 },
549566 schema: RelayEnvironmentLinkChallengeResponse,
550567 });
......@@ -556,7 +573,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
556573 endpoint: {
557574 httpBaseUrl: localOrigin,
558575 wsBaseUrl: localWsOrigin,
559 providerKind: "cloudflare_tunnel",
576 providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual",
560577 },
561578 origin: {
562579 localHttpHost: localUrl.hostname,
......@@ -572,11 +589,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
572589 proof,
573590 notificationsEnabled: true,
574591 liveActivitiesEnabled: true,
575 managedTunnelsEnabled: true,
592 managedTunnelsEnabled,
576593 },
577594 schema: RelayEnvironmentLinkResponse,
578595 });
579 yield* setCliDesiredCloudLink(true);
596 yield* setCliDesiredCloudLink(true, mode);
580597 return yield* applyCloudRelayConfig(dependencies, {
581598 relayUrl,
582599 relayIssuer: link.relayIssuer,
......@@ -608,20 +625,25 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
608625const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
609626 dependencies: CloudHttpDependencies,
610627) {
611 const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all(
612 [
613 dependencies.secrets.get(CLOUD_LINKED_USER_ID),
614 dependencies.secrets.get(RELAY_URL_SECRET),
615 dependencies.secrets.get(RELAY_ISSUER_SECRET),
616 dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
617 ],
618 { concurrency: 4 },
619 );
628 const [cloudUserId, relayUrl, relayIssuer, endpointRuntimeConfig, publishAgentActivity] =
629 yield* Effect.all(
630 [
631 dependencies.secrets.get(CLOUD_LINKED_USER_ID),
632 dependencies.secrets.get(RELAY_URL_SECRET),
633 dependencies.secrets.get(RELAY_ISSUER_SECRET),
634 dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
635 dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
636 ],
637 { concurrency: 5 },
638 );
620639 return {
621640 linked: Option.isSome(cloudUserId),
622641 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
623642 relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
624643 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),
625647 publishAgentActivity: Option.isSome(publishAgentActivity)
626648 ? bytesToString(publishAgentActivity.value) === "true"
627649 : false,
apps/server/src/relay/AgentAwarenessRelay.ts+90
......@@ -203,6 +203,26 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: {
203203 return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload });
204204});
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
206226export function resolveAgentAwarenessRelayPublishSnapshot(input: {
207227 readonly environmentId: EnvironmentId;
208228 readonly threadId: ThreadId;
......@@ -306,6 +326,14 @@ export const make = Effect.gen(function* () {
306326 transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
307327 }).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
309337 const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) {
310338 const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
311339 Effect.orElseSucceed(() => false),
......@@ -382,6 +410,11 @@ export const make = Effect.gen(function* () {
382410 const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
383411 const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
384412 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);
385418 yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
386419 environmentId,
387420 threadId,
......@@ -390,6 +423,50 @@ export const make = Effect.gen(function* () {
390423 return;
391424 }
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
393470 if (snapshot.reason === "thread-not-found") {
394471 yield* Effect.logDebug("publishing agent activity tombstone; thread not found", {
395472 environmentId,
......@@ -478,6 +555,19 @@ export const make = Effect.gen(function* () {
478555
479556 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
481571 const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(
482572 function* () {
483573 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) => {
17001700 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
17011701 );
17021702
1703 it.effect("rejects managed cloud link proofs for manual endpoint providers", () =>
1703 it.effect("rejects cloud link proofs for unsupported endpoint providers", () =>
17041704 Effect.gen(function* () {
17051705 yield* buildAppUnderTest();
17061706
......@@ -1721,7 +1721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17211721 wsBaseUrl: linkProofUrl
17221722 .replace("http://", "ws://")
17231723 .replace("/api/connect/link-proof", "/ws"),
1724 providerKind: "manual",
1724 // "manual" and "cloudflare_tunnel" are supported; "t3_relay" is not.
1725 providerKind: "t3_relay",
17251726 },
17261727 origin: {
17271728 localHttpHost: "127.0.0.1",
apps/web/src/cloud/linkEnvironment.test.ts+55
......@@ -204,6 +204,7 @@ describe("web cloud link environment client", () => {
204204 cloudUserId: "user-1",
205205 relayUrl: "https://relay.example.test",
206206 relayIssuer: "https://relay.example.test",
207 managedTunnelActive: true,
207208 publishAgentActivity: false,
208209 }),
209210 );
......@@ -217,6 +218,7 @@ describe("web cloud link environment client", () => {
217218 cloudUserId: "user-1",
218219 relayUrl: "https://relay.example.test",
219220 relayIssuer: "https://relay.example.test",
221 managedTunnelActive: true,
220222 publishAgentActivity: false,
221223 }),
222224 );
......@@ -234,6 +236,7 @@ describe("web cloud link environment client", () => {
234236 cloudUserId: "user-1",
235237 relayUrl: "https://relay.example.test",
236238 relayIssuer: "https://relay.example.test",
239 managedTunnelActive: true,
237240 publishAgentActivity: false,
238241 }),
239242 );
......@@ -261,6 +264,7 @@ describe("web cloud link environment client", () => {
261264 cloudUserId: "user-1",
262265 relayUrl: "https://relay.example.test",
263266 relayIssuer: "https://relay.example.test",
267 managedTunnelActive: true,
264268 publishAgentActivity: true,
265269 }),
266270 );
......@@ -339,6 +343,57 @@ describe("web cloud link environment client", () => {
339343 }),
340344 );
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
342397 it.effect("installs a missing relay client before linking", () =>
343398 Effect.gen(function* () {
344399 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: {
383383 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
384384}
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
386393export function linkPrimaryEnvironmentToCloud(input: {
387394 readonly target: CloudLinkTarget;
388395 readonly clerkToken: string;
396 readonly mode?: CloudLinkMode;
389397}): Effect.Effect<
390398 void,
391399 CloudEnvironmentLinkError,
......@@ -398,9 +406,15 @@ export function linkPrimaryEnvironmentToCloud(input: {
398406 message: "T3CODE_RELAY_URL is not configured.",
399407 });
400408 }
409 const managedTunnelsEnabled = (input.mode ?? "managed") === "managed";
410 const providerKind = managedTunnelsEnabled
411 ? MANAGED_ENDPOINT_PROVIDER_KIND
412 : PUBLISH_ONLY_PROVIDER_KIND;
401413 const relayClient = yield* ManagedRelay.ManagedRelayClient;
402414 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
405419 const challenge = yield* relayClient
406420 .createEnvironmentLinkChallenge({
......@@ -408,7 +422,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
408422 payload: {
409423 notificationsEnabled: true,
410424 liveActivitiesEnabled: true,
411 managedTunnelsEnabled: true,
425 managedTunnelsEnabled,
412426 },
413427 })
414428 .pipe(
......@@ -427,7 +441,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
427441 endpoint: {
428442 httpBaseUrl: input.target.httpBaseUrl,
429443 wsBaseUrl: input.target.wsBaseUrl,
430 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
444 providerKind,
431445 },
432446 origin: endpointOrigin(input.target.httpBaseUrl),
433447 },
......@@ -440,7 +454,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
440454 proof,
441455 notificationsEnabled: true,
442456 liveActivitiesEnabled: true,
443 managedTunnelsEnabled: true,
457 managedTunnelsEnabled,
444458 },
445459 })
446460 .pipe(
......@@ -450,7 +464,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
450464 );
451465 yield* ensureLinkedEnvironmentMatches({
452466 expectedEnvironmentId: input.target.environmentId,
453 expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
467 expectedProviderKind: providerKind,
454468 link,
455469 });
456470
apps/web/src/cloud/linkEnvironmentAtoms.ts+6-2
......@@ -6,6 +6,7 @@ import {
66import { connectionAtomRuntime } from "../connection/runtime";
77import {
88 linkPrimaryEnvironmentToCloud,
9 type CloudLinkMode,
910 type CloudLinkTarget,
1011 unlinkPrimaryEnvironmentFromCloud,
1112 updatePrimaryCloudPreferences,
......@@ -21,8 +22,11 @@ export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime
2122 label: "web:cloud:link-primary-environment",
2223 scheduler: cloudLinkScheduler,
2324 concurrency: cloudLinkConcurrency,
24 execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) =>
25 linkPrimaryEnvironmentToCloud(input),
25 execute: (input: {
26 readonly target: CloudLinkTarget;
27 readonly clerkToken: string;
28 readonly mode?: CloudLinkMode;
29 }) => linkPrimaryEnvironmentToCloud(input),
2630});
2731
2832export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, {
apps/web/src/components/settings/ConnectionsSettings.tsx+129-104
......@@ -1602,15 +1602,17 @@ function CloudLinkSwitch({
16021602 disabled,
16031603 disabledReason,
16041604 onCheckedChange,
1605 ariaLabel = "Enable T3 Connect",
16051606}: {
16061607 readonly checked: boolean;
16071608 readonly disabled: boolean;
16081609 readonly disabledReason: string | null;
16091610 readonly onCheckedChange?: (enabled: boolean) => void;
1611 readonly ariaLabel?: string;
16101612}) {
16111613 const control = (
16121614 <Switch
1613 aria-label="Enable T3 Connect"
1615 aria-label={ariaLabel}
16141616 checked={checked}
16151617 disabled={disabled}
16161618 {...(onCheckedChange ? { onCheckedChange } : {})}
......@@ -1666,146 +1668,169 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16661668 });
16671669 };
16681670
1669 const updateLink = async (enabled: boolean) => {
1670 setIsUpdating(true);
1671 // Older environment servers predate the managedTunnelActive field; for them a
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> => {
16711693 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
16791694 const target = primaryCloudLinkState.target;
16801695 if (!target) {
16811696 reportUpdateFailure(new Error("Local environment is not ready yet."));
1682 setIsUpdating(false);
1683 return;
1697 return false;
16841698 }
1685 if (enabled && !tokenResult.value) {
1686 reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment."));
1687 setIsUpdating(false);
1688 return;
1699 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
1700 if (tokenResult._tag === "Failure") {
1701 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
1702 return false;
16891703 }
1690
1691 const linkResult =
1692 enabled && tokenResult.value
1693 ? await linkPrimaryEnvironment({
1694 target,
1695 clerkToken: tokenResult.value,
1696 })
1697 : await unlinkPrimaryEnvironment({
1698 target,
1699 clerkToken: tokenResult.value ?? null,
1700 });
1701 if (linkResult._tag === "Failure") {
1702 if (!isAtomCommandInterrupted(linkResult)) {
1703 reportUpdateFailure(squashAtomCommandFailure(linkResult));
1704 const clerkToken = tokenResult.value;
1705 const wantsLink = desired.managedTunnel || desired.publish;
1706
1707 // A failure after this point may follow a partially applied mutation (e.g.
1708 // the link succeeded but the preference update did not), so every exit —
1709 // success or failure — refreshes the rendered state to whatever the server
1710 // actually holds now.
1711 if (!wantsLink) {
1712 const unlinkResult = await unlinkPrimaryEnvironment({
1713 target,
1714 clerkToken: clerkToken ?? null,
1715 });
1716 if (unlinkResult._tag === "Failure") {
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;
17041752 }
1705 setIsUpdating(false);
1706 return;
17071753 }
17081754
17091755 primaryCloudLinkState.refresh();
17101756 const refreshResult = await refreshRelayEnvironments();
1711 if (refreshResult._tag === "Failure") {
1712 if (!isAtomCommandInterrupted(refreshResult)) {
1713 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
1714 }
1715 setIsUpdating(false);
1716 return;
1757 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
1758 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
1759 return false;
17171760 }
1761 return true;
1762 };
17181763
1719 toastManager.add({
1720 type: "success",
1721 title: enabled ? "T3 Connect linked" : "T3 Connect unlinked",
1722 description: enabled
1723 ? "This environment is available through T3 Connect."
1724 : "This environment is no longer available through T3 Connect.",
1725 });
1764 const updateManagedTunnel = async (enabled: boolean) => {
1765 setIsUpdating(true);
1766 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
1767 if (ok) {
1768 // Turning the tunnel off while publishing stays on downgrades the link
1769 // rather than removing it — say so instead of claiming an unlink.
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 }
17261784 setIsUpdating(false);
17271785 };
17281786
17291787 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
17361788 setIsUpdatingPreference(true);
1737 setOperationError(null);
1738 const updateResult = await updatePrimaryEnvironmentPreferences({
1739 target,
1740 publishAgentActivity: enabled,
1741 });
1742 if (updateResult._tag === "Failure") {
1743 if (!isAtomCommandInterrupted(updateResult)) {
1744 reportUpdateFailure(squashAtomCommandFailure(updateResult));
1745 }
1746 setIsUpdatingPreference(false);
1747 return;
1789 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
1790 if (ok) {
1791 toastManager.add({
1792 type: "success",
1793 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
1794 description: enabled
1795 ? "This environment publishes agent activity to your mobile clients."
1796 : "This environment will stop publishing agent activity.",
1797 });
17481798 }
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 });
17581799 setIsUpdatingPreference(false);
17591800 };
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
17671802 return (
17681803 <>
17691804 <SettingsRow
17701805 title="T3 Connect"
17711806 description={
1772 linked
1807 managedTunnelActive
17731808 ? "This environment is available to your other devices through T3 Connect."
17741809 : "Make this environment available to your other devices through T3 Connect."
17751810 }
17761811 status={operationError ?? primaryCloudLinkState.error}
17771812 control={
17781813 <CloudLinkSwitch
1779 checked={linked}
1780 disabled={
1781 !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating
1782 }
1814 checked={managedTunnelActive}
1815 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}
17831816 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)}
17851831 />
17861832 }
17871833 />
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}
18091834 </>
18101835 );
18111836}
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 @@
1111 "dependencies": {
1212 "@clerk/backend": "catalog:",
1313 "@effect/sql-pg": "catalog:",
14 "@noble/curves": "catalog:",
15 "@noble/hashes": "catalog:",
1416 "@t3tools/client-runtime": "workspace:*",
1517 "@t3tools/contracts": "workspace:*",
1618 "@t3tools/shared": "workspace:*",
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+180-6
......@@ -28,6 +28,8 @@ function target(deviceId: string): LiveActivities.TargetRow {
2828 platform: "ios",
2929 ios_major_version: 18,
3030 app_version: "1.0.0",
31 bundle_id: null,
32 aps_environment: null,
3133 push_token: null,
3234 push_to_start_token: "start-token",
3335 preferences_json: "{}",
......@@ -60,6 +62,7 @@ function makeAgentActivityRows(
6062 return {
6163 upsert: () => Effect.void,
6264 remove: () => Effect.void,
65 pruneTerminal: () => Effect.void,
6366 listForUser: () => Effect.succeed([state]),
6467 ...overrides,
6568 };
......@@ -306,7 +309,7 @@ describe("AgentActivityPublisher", () => {
306309 const sentAggregates: Array<
307310 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]
308311 > = [];
309 const removals: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["remove"]>[0]> =
312 const upserts: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["upsert"]>[0]> =
310313 [];
311314
312315 return Effect.gen(function* () {
......@@ -326,9 +329,9 @@ describe("AgentActivityPublisher", () => {
326329 Layer.succeed(
327330 AgentActivityRows.AgentActivityRows,
328331 makeAgentActivityRows({
329 remove: (input) =>
332 upsert: (input) =>
330333 Effect.sync(() => {
331 removals.push(input);
334 upserts.push(input);
332335 }),
333336 listForUser: () => Effect.succeed([]),
334337 }),
......@@ -378,11 +381,12 @@ describe("AgentActivityPublisher", () => {
378381 ok: true,
379382 },
380383 ]);
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([
382387 {
383 environmentId: "env",
384388 environmentPublicKey: "environment-public-key",
385 threadId: "thread",
389 state: completedState,
386390 },
387391 ]);
388392 expect(sentAggregates).toHaveLength(1);
......@@ -628,3 +632,173 @@ describe("AgentActivityPublisher", () => {
628632 },
629633 );
630634});
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";
88import * as DateTime from "effect/DateTime";
99import * as Effect from "effect/Effect";
1010import * 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";
1320import * as AgentActivityRows from "./AgentActivityRows.ts";
1421import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
1522import * as LiveActivities from "./LiveActivities.ts";
......@@ -55,6 +62,7 @@ export const make = Effect.gen(function* () {
5562 ? makeAggregateState({
5663 activeStates,
5764 terminalState: input.state && isTerminalPhase(input.state) ? input.state : null,
65 nowMs: input.nowMs,
5866 })
5967 : null;
6068 const notificationOnlyAggregate =
......@@ -64,6 +72,7 @@ export const make = Effect.gen(function* () {
6472 ? makeAggregateState({
6573 activeStates: isTerminalPhase(input.state) ? [] : [input.state],
6674 terminalState: isTerminalPhase(input.state) ? input.state : null,
75 nowMs: input.nowMs,
6776 })
6877 : null;
6978 const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId });
......@@ -110,8 +119,12 @@ export const make = Effect.gen(function* () {
110119 if (target === null) {
111120 return null;
112121 }
113 const aggregate = makeAggregateState({ activeStates, terminalState: null });
114122 const now = yield* DateTime.now;
123 const aggregate = makeAggregateState({
124 activeStates,
125 terminalState: null,
126 nowMs: now.epochMilliseconds,
127 });
115128 return yield* apnsDeliveries.sendForTarget({
116129 target,
117130 aggregate,
......@@ -124,7 +137,11 @@ export const make = Effect.gen(function* () {
124137 "relay.thread_id": input.threadId,
125138 "relay.agent_activity.phase": input.state?.phase ?? "deleted",
126139 });
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.
128145 yield* rows.upsert({
129146 environmentPublicKey: input.environmentPublicKey,
130147 state: input.state,
......@@ -174,7 +191,9 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string {
174191 case "failed":
175192 return "Failed";
176193 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";
178197 case "running":
179198 return "Working";
180199 case "stale":
......@@ -182,10 +201,6 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string {
182201 }
183202}
184203
185function isTerminalPhase(state: RelayAgentActivityState): boolean {
186 return state.phase === "completed" || state.phase === "failed";
187}
188
189204function aggregateRowForState(state: RelayAgentActivityState) {
190205 return {
191206 environmentId: state.environmentId,
......@@ -210,15 +225,65 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv
210225 });
211226}
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: {
214248 readonly activeStates: ReadonlyArray<RelayAgentActivityState>;
215249 readonly terminalState: RelayAgentActivityState | null;
250 readonly nowMs: number;
216251}): 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 );
218255 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 });
220278 }
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) =>
222287 state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest,
223288 ).updatedAt;
224289 return sanitizeAgentActivityAggregateState({
......@@ -226,7 +291,7 @@ function makeAggregateState(input: {
226291 subtitle: "Agent work in progress",
227292 activeCount: activeStates.length,
228293 updatedAt,
229 activities: activeStates.slice(0, 3).map(aggregateRowForState),
294 activities: displayedStates.map(aggregateRowForState),
230295 });
231296}
232297
infra/relay/src/agentActivity/AgentActivityRows.ts+39-1
......@@ -7,7 +7,7 @@ import * as Function from "effect/Function";
77import * as Layer from "effect/Layer";
88import * as Option from "effect/Option";
99import * as Schema from "effect/Schema";
10import { and, desc, eq, isNull } from "drizzle-orm";
10import { and, desc, eq, isNull, lt, sql } from "drizzle-orm";
1111
1212import * as RelayDb from "../db.ts";
1313import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts";
......@@ -38,6 +38,18 @@ export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorCl
3838 }
3939}
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
4153export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass<AgentActivityRowListPersistenceError>()(
4254 "AgentActivityRowListPersistenceError",
4355 {
......@@ -57,6 +69,9 @@ export class AgentActivityRows extends Context.Service<
5769 readonly environmentPublicKey: string;
5870 readonly state: RelayAgentActivityState;
5971 }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>;
72 readonly pruneTerminal: (input: {
73 readonly updatedBefore: string;
74 }) => Effect.Effect<void, AgentActivityRowPruneTerminalPersistenceError>;
6075 readonly remove: (input: {
6176 readonly environmentId: string;
6277 readonly environmentPublicKey: string;
......@@ -163,6 +178,29 @@ export const make = Effect.gen(function* () {
163178 );
164179 }),
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
166204 listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) {
167205 return yield* db
168206 .select({ stateJson: relayAgentActivityRows.stateJson })
infra/relay/src/agentActivity/ApnsClient.test.ts+108-1
......@@ -10,14 +10,17 @@ import * as Redacted from "effect/Redacted";
1010import * as Schema from "effect/Schema";
1111import * as HttpClient from "effect/unstable/http/HttpClient";
1212import * as HttpClientError from "effect/unstable/http/HttpClientError";
13import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1314
1415import type { ApnsCredentials } from "../Config.ts";
1516import * as ApnsClient from "./ApnsClient.ts";
17import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
1618
1719const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError);
1820const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError);
1921
2022const TestLayer = ApnsClient.layer.pipe(
23 Layer.provide(ApnsProviderTokens.layer),
2124 Layer.provide(
2225 Layer.succeed(
2326 HttpClient.HttpClient,
......@@ -96,6 +99,32 @@ describe("ApnsClient", () => {
9699 }).pipe(Effect.provide(TestLayer)),
97100 );
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
99128 it.effect("builds an end payload with a dismissal date", () =>
100129 Effect.gen(function* () {
101130 const apns = yield* ApnsClient.ApnsClient;
......@@ -114,6 +143,22 @@ describe("ApnsClient", () => {
114143 "dismissal-date": 300,
115144 },
116145 });
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 });
117162 }).pipe(Effect.provide(TestLayer)),
118163 );
119164
......@@ -181,7 +226,10 @@ describe("ApnsClient", () => {
181226 expect(error).toMatchObject({
182227 teamId: "team-1",
183228 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,
185233 cause: expect.any(Error),
186234 message: "Failed to sign APNs JWT for key key-1.",
187235 });
......@@ -210,6 +258,7 @@ describe("ApnsClient", () => {
210258 ),
211259 );
212260 const layer = ApnsClient.layer.pipe(
261 Layer.provide(ApnsProviderTokens.layer),
213262 Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)),
214263 );
215264
......@@ -254,4 +303,62 @@ describe("ApnsClient", () => {
254303 });
255304 }).pipe(Effect.provide(layer));
256305 });
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 });
257364});
infra/relay/src/agentActivity/ApnsClient.ts+39-115
......@@ -1,22 +1,30 @@
1import * as NodeCrypto from "node:crypto";
2
31import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
42import * as Context from "effect/Context";
53import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
74import * as Layer from "effect/Layer";
85import * as Option from "effect/Option";
9import * as Redacted from "effect/Redacted";
106import * as Schema from "effect/Schema";
117import * as Headers from "effect/unstable/http/Headers";
128import * as HttpClient from "effect/unstable/http/HttpClient";
139import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
1410import { 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
1717const 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;
1923const 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
2129const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);
2230export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type;
......@@ -43,35 +51,6 @@ export interface ApnsDeliveryResult {
4351 readonly apnsId: string | null;
4452}
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
7554export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()(
7655 "ApnsHttpRequestError",
7756 {
......@@ -104,82 +83,6 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
10483 }),
10584 ),
10685);
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
18386function contentState(state: RelayAgentActivityAggregateState) {
18487 return {
18588 name: LIVE_ACTIVITY_NAME,
......@@ -197,12 +100,27 @@ type MakeLiveActivityRequestInput =
197100 | (LiveActivityRequestBase & {
198101 readonly event: "end";
199102 readonly state: RelayAgentActivityAggregateState | null;
103 readonly alert?: ApnsLiveActivityAlert | null;
200104 })
201105 | (LiveActivityRequestBase & {
202106 readonly event: "start" | "update";
203107 readonly state: RelayAgentActivityAggregateState;
108 readonly alert?: ApnsLiveActivityAlert | null;
204109 });
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
206124function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {
207125 const timestamp = input.nowEpochSeconds;
208126 if (input.event === "end") {
......@@ -215,7 +133,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
215133 timestamp,
216134 event: "end",
217135 ...(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),
219139 },
220140 },
221141 };
......@@ -225,7 +145,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
225145 return {
226146 token: input.token,
227147 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",
229151 payload: {
230152 aps: {
231153 timestamp,
......@@ -241,6 +163,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
241163 },
242164 }
243165 : {}),
166 ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}),
244167 "content-state": contentState(state),
245168 "stale-date": timestamp + STALE_AFTER_SECONDS,
246169 },
......@@ -300,12 +223,13 @@ export class ApnsClient extends Context.Service<
300223
301224export const make = Effect.gen(function* () {
302225 const httpClient = yield* HttpClient.HttpClient;
226 const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens;
303227
304228 const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn(
305229 "relay.apns.send_live_activity_request",
306230 )(function* (input) {
307231 yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event });
308 const jwt = yield* makeApnsJwt({
232 const jwt = yield* providerTokens.getJwt({
309233 ...input.credentials,
310234 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
311235 });
......@@ -363,7 +287,7 @@ export const make = Effect.gen(function* () {
363287 const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] =
364288 Effect.fn("relay.apns.send_push_notification_request")(function* (input) {
365289 yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" });
366 const jwt = yield* makeApnsJwt({
290 const jwt = yield* providerTokens.getJwt({
367291 ...input.credentials,
368292 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
369293 });
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+447-32
......@@ -5,6 +5,7 @@ import type {
55import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
66import { describe, expect, it } from "@effect/vitest";
77import * as NodeCrypto from "node:crypto";
8import * as DateTime from "effect/DateTime";
89import * as Effect from "effect/Effect";
910import * as Layer from "effect/Layer";
1011import * as Logger from "effect/Logger";
......@@ -26,8 +27,10 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts";
2627import * as LiveActivities from "./LiveActivities.ts";
2728import * as RelayConfiguration from "../Config.ts";
2829import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
30import * as AgentActivityRows from "./AgentActivityRows.ts";
2931import * as ApnsDeliveries from "./ApnsDeliveries.ts";
3032import * as ApnsClient from "./ApnsClient.ts";
33import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
3134
3235const config = RelayConfiguration.RelayConfiguration.of({
3336 relayIssuer: "https://relay.example.test",
......@@ -127,6 +130,8 @@ const target: LiveActivities.TargetRow = {
127130 platform: "ios",
128131 ios_major_version: 18,
129132 app_version: "1.0.0",
133 bundle_id: null,
134 aps_environment: null,
130135 push_token: null,
131136 push_to_start_token: "start-token",
132137 preferences_json: enabledPreferences,
......@@ -156,15 +161,31 @@ function makeLayer(input: {
156161 >;
157162 readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>;
158163 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>;
159168 readonly execute?: (
160169 request: HttpClientRequest.HttpClientRequest,
161170 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>;
162171}) {
163172 return ApnsDeliveries.layer.pipe(
164173 Layer.provide(ApnsClient.layer),
174 Layer.provide(ApnsProviderTokens.layer),
165175 Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))),
166176 Layer.provide(
167177 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"]),
168189 Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
169190 send: (body) =>
170191 Effect.sync(() => {
......@@ -225,62 +246,61 @@ function makeLayer(input: {
225246}
226247
227248describe("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", () => {
229250 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
230251 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
231252 const queuedStarts: Array<
232253 Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0]
233254 > = [];
234 const markedDeliveries: Array<
235 Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0]
236 > = [];
237255
238256 return Effect.gen(function* () {
239257 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
240258 const result = yield* deliveries.sendForTarget({
241259 target: {
242260 ...target,
261 activity_push_token: null,
262 remote_started_at: null,
243263 ended_at: "1970-01-01T00:00:05.000Z",
244264 },
245265 aggregate,
246266 nowMs: 10_000,
247267 });
248268
249 expect(result?.kind).toBe("live_activity_start");
250 expect(result?.ok).toBe(true);
251 expect(queuedJobs).toMatchObject([
252 {
253 payload: {
254 kind: "live_activity_start",
255 target: {
256 token: "start-token",
257 },
258 },
259 },
260 ]);
269 // Activities are armed by the app in the foreground; the relay never
270 // uses the push-to-start token, so the only fallback is the push
271 // notification channel (none here: the aggregate is not an attention
272 // phase).
273 expect(result).toBeNull();
274 expect(queuedJobs).toEqual([]);
275 expect(queuedStarts).toEqual([]);
261276 expect(attempts).toEqual([]);
262 expect(queuedStarts).toMatchObject([
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 })));
277 }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts })));
270278 });
271279
272 it.effect("queues an end using the activity token", () => {
280 it.effect("ends the armed card when nothing remains to show", () => {
273281 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
274282 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
275283
276284 return Effect.gen(function* () {
277285 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({
279289 target,
280290 aggregate: null,
281291 nowMs: 5_000,
282292 });
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.
284304 expect(result?.kind).toBe("live_activity_end");
285305 expect(result?.ok).toBe(true);
286306 expect(queuedJobs).toMatchObject([
......@@ -368,12 +388,7 @@ describe("ApnsDeliveries", () => {
368388 return Effect.gen(function* () {
369389 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
370390 yield* deliveries.sendForTarget({
371 target: {
372 ...target,
373 activity_push_token: null,
374 remote_started_at: null,
375 ended_at: "1970-01-01T00:00:05.000Z",
376 },
391 target,
377392 aggregate: inputAggregate,
378393 nowMs: 10_000,
379394 });
......@@ -389,6 +404,196 @@ describe("ApnsDeliveries", () => {
389404 }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
390405 });
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
392597 it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => {
393598 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
394599 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
......@@ -629,6 +834,48 @@ describe("ApnsDeliveries", () => {
629834 }).pipe(Effect.provide(makeLayer({ attempts })));
630835 });
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
632879 it.effect("processes signed jobs through APNs and records attempts", () => {
633880 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
634881 const transportErrors: Array<ApnsDeliveries.ApnsDeliveryTransportError> = [];
......@@ -1177,3 +1424,171 @@ describe("ApnsDeliveries", () => {
11771424 );
11781425 });
11791426});
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";
1818import * as Schema from "effect/Schema";
1919
2020import {
21 isExpiredAgentActivityState,
22 isTerminalPhase,
2123 sanitizeAgentActivityAggregateState,
2224 sanitizeApnsNotificationPayload,
2325} from "./agentActivityPayloads.ts";
......@@ -26,12 +28,14 @@ import {
2628 ApnsDeliveryJobLiveActivityAggregateMissing,
2729 ApnsDeliveryJobPushNotificationMissing,
2830 ApnsDeliveryJobQueuePayloadInvalid,
31 type ApnsLiveActivityAlert,
2932 type ApnsNotificationPayload,
3033 SignedApnsDeliveryJob,
3134 isApnsDeliveryJobVerificationError,
3235 verifySignedApnsDeliveryJob,
3336 type ApnsDeliveryJobVerificationError,
3437} from "./apnsDeliveryJobs.ts";
38import * as AgentActivityRows from "./AgentActivityRows.ts";
3539import * as DeliveryAttempts from "./DeliveryAttempts.ts";
3640import * as LiveActivities from "./LiveActivities.ts";
3741import * as RelayConfiguration from "../Config.ts";
......@@ -39,6 +43,10 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
3943import { withSpanAttributes } from "../observability.ts";
4044
4145const 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;
4250const PERMANENT_APNS_TOKEN_REASONS = new Set([
4351 "BadDeviceToken",
4452 "DeviceTokenNotForTopic",
......@@ -55,11 +63,13 @@ type ChosenLiveActivityDelivery =
5563 readonly kind: "live_activity_start" | "live_activity_update";
5664 readonly token: string;
5765 readonly aggregate: RelayAgentActivityAggregateState;
66 readonly alert: ApnsLiveActivityAlert | null;
5867 }
5968 | {
6069 readonly kind: "live_activity_end";
6170 readonly token: string;
6271 readonly aggregate: RelayAgentActivityAggregateState | null;
72 readonly alert: ApnsLiveActivityAlert | null;
6373 };
6474
6575type ChosenPushNotificationDelivery = {
......@@ -130,6 +140,154 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null
130140 return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value));
131141}
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
133291function shouldUpdateLiveActivity(input: {
134292 readonly previousAggregate: RelayAgentActivityAggregateState | null;
135293 readonly nextAggregate: RelayAgentActivityAggregateState;
......@@ -139,11 +297,20 @@ function shouldUpdateLiveActivity(input: {
139297 if (!input.previousAggregate) {
140298 return true;
141299 }
300 if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) {
301 return false;
302 }
142303 if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) {
143304 return true;
144305 }
145 if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) {
146 return false;
306 if (aggregateNeedsAttention(input.nextAggregate)) {
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;
147314 }
148315 const lastDeliveryAtMs =
149316 input.lastDeliveryAt === null
......@@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: {
159326 );
160327}
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
162333function notificationForAggregate(input: {
163334 readonly target: LiveActivities.TargetRow;
164335 readonly aggregate: RelayAgentActivityAggregateState | null;
336 readonly nowMs: number;
165337}): ApnsNotificationPayload | null {
166338 if (!input.target.push_token || input.aggregate === null) {
167339 return null;
......@@ -174,6 +346,15 @@ function notificationForAggregate(input: {
174346 if (!activity) {
175347 return null;
176348 }
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 }
177358 const enabled =
178359 (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
179360 (activity.phase === "waiting_for_input" && preferences.notifyOnInput) ||
......@@ -191,62 +372,85 @@ function notificationForAggregate(input: {
191372 };
192373}
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.
194378function chooseLiveActivityDelivery(input: {
195379 readonly target: LiveActivities.TargetRow;
196380 readonly aggregate: RelayAgentActivityAggregateState | null;
197381 readonly nowMs: number;
198}): ChosenLiveActivityDelivery | 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);
382}): ChosenLiveActivityDelivery | "suppressed" | null {
204383 const preferences = parsePreferences(input.target.preferences_json);
205384 if (preferences?.liveActivitiesEnabled === false) {
206 return hasActiveActivity && input.target.activity_push_token
385 return input.target.activity_push_token
207386 ? {
208387 kind: "live_activity_end",
209388 token: input.target.activity_push_token,
210389 aggregate: null,
390 alert: null,
211391 }
212392 : null;
213393 }
214 if (input.aggregate === null || input.aggregate.activeCount === 0) {
215 return hasActiveActivity && input.target.activity_push_token
216 ? {
217 kind: "live_activity_end",
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 }
394 // Activities are started by the app in the foreground, never remotely.
395 // Without a registered token there is nothing addressable; attention
396 // transitions fall back to the push notification channel until the user
397 // next arms the card from the app.
232398 if (!input.target.activity_push_token) {
233399 return null;
234400 }
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);
235430 return shouldUpdateLiveActivity({
236 previousAggregate: parseAggregate(input.target.last_aggregate_json),
237 nextAggregate: input.aggregate,
431 previousAggregate,
432 nextAggregate,
238433 lastDeliveryAt: input.target.last_live_activity_delivery_at,
239434 nowMs: input.nowMs,
240 }) ||
241 input.aggregate.activities.some(
242 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
243 )
435 })
244436 ? {
245437 kind: "live_activity_update",
246438 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 }),
248452 }
249 : null;
453 : "suppressed";
250454}
251455
252456function chooseDelivery(input: {
......@@ -255,6 +459,9 @@ function chooseDelivery(input: {
255459 readonly nowMs: number;
256460}): ChosenDelivery | null {
257461 const liveActivityDelivery = chooseLiveActivityDelivery(input);
462 if (liveActivityDelivery === "suppressed") {
463 return null;
464 }
258465 if (liveActivityDelivery) {
259466 return liveActivityDelivery;
260467 }
......@@ -365,6 +572,24 @@ const recoverApnsDeliveryTransportError = (
365572interface LiveActivityDeliveryTarget {
366573 readonly user_id: string;
367574 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 };
368593}
369594
370595function expectedCurrentToken(input: {
......@@ -392,10 +617,12 @@ export type SendLiveActivityDeliveryInput =
392617 | (SendLiveActivityDeliveryInputBase & {
393618 readonly kind: "live_activity_start" | "live_activity_update";
394619 readonly aggregate: RelayAgentActivityAggregateState;
620 readonly alert?: ApnsLiveActivityAlert | null;
395621 })
396622 | (SendLiveActivityDeliveryInputBase & {
397623 readonly kind: "live_activity_end";
398624 readonly aggregate: RelayAgentActivityAggregateState | null;
625 readonly alert?: ApnsLiveActivityAlert | null;
399626 });
400627
401628function makeLiveActivityDeliveryRequest(
......@@ -419,6 +646,7 @@ function makeLiveActivityDeliveryRequest(
419646 ...base,
420647 event: deliveryEvent(input.kind),
421648 state: input.aggregate,
649 alert: input.alert ?? null,
422650 }),
423651 };
424652 case "live_activity_end":
......@@ -429,6 +657,7 @@ function makeLiveActivityDeliveryRequest(
429657 ...base,
430658 event: "end",
431659 state: input.aggregate,
660 alert: input.alert ?? null,
432661 }),
433662 };
434663 }
......@@ -467,6 +696,31 @@ export const make = Effect.gen(function* () {
467696 const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue;
468697 const config = yield* RelayConfiguration.RelayConfiguration;
469698 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
471725 const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: {
472726 readonly target: LiveActivityDeliveryTarget;
......@@ -538,9 +792,25 @@ export const make = Effect.gen(function* () {
538792 return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });
539793 }
540794 }
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 }
541811 const result = yield* apns
542812 .sendLiveActivityRequest({
543 credentials: config.apns,
813 credentials: credentialsForTarget(config.apns, input.target),
544814 request,
545815 issuedAtUnixSeconds: epochSeconds,
546816 })
......@@ -663,7 +933,7 @@ export const make = Effect.gen(function* () {
663933 }
664934 const result = yield* apns
665935 .sendPushNotificationRequest({
666 credentials: config.apns,
936 credentials: credentialsForTarget(config.apns, input.target),
667937 request,
668938 issuedAtUnixSeconds: epochSeconds,
669939 })
......@@ -752,22 +1022,28 @@ export const make = Effect.gen(function* () {
7521022 target: {
7531023 user_id: payload.target.userId,
7541024 device_id: payload.target.deviceId,
1025 bundle_id: payload.target.bundleId ?? null,
1026 aps_environment: payload.target.apsEnvironment ?? null,
7551027 },
7561028 token: payload.target.token,
7571029 sourceJobId: payload.jobId,
7581030 kind: payload.kind,
7591031 aggregate: payload.aggregate,
1032 alert: payload.alert ?? null,
7601033 });
7611034 case "live_activity_end":
7621035 return sendLiveActivity({
7631036 target: {
7641037 user_id: payload.target.userId,
7651038 device_id: payload.target.deviceId,
1039 bundle_id: payload.target.bundleId ?? null,
1040 aps_environment: payload.target.apsEnvironment ?? null,
7661041 },
7671042 token: payload.target.token,
7681043 sourceJobId: payload.jobId,
7691044 kind: payload.kind,
7701045 aggregate: payload.aggregate,
1046 alert: payload.alert ?? null,
7711047 });
7721048 case "push_notification":
7731049 if (payload.notification === null) {
......@@ -783,6 +1059,8 @@ export const make = Effect.gen(function* () {
7831059 target: {
7841060 user_id: payload.target.userId,
7851061 device_id: payload.target.deviceId,
1062 bundle_id: payload.target.bundleId ?? null,
1063 aps_environment: payload.target.apsEnvironment ?? null,
7861064 },
7871065 token: payload.target.token,
7881066 sourceJobId: payload.jobId,
......@@ -797,13 +1075,20 @@ export const make = Effect.gen(function* () {
7971075 sendPushNotification,
7981076 processSignedJob,
7991077 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 });
8011084 const token = input.target.push_token;
8021085 return yield* notification && token
8031086 ? deliveryQueue.enqueuePushNotification({
8041087 userId: input.target.user_id,
8051088 deviceId: input.target.device_id,
8061089 token,
1090 bundleId: input.target.bundle_id,
1091 apsEnvironment: input.target.aps_environment,
8071092 notification,
8081093 })
8091094 : Effect.succeed(null);
......@@ -822,26 +1107,47 @@ export const make = Effect.gen(function* () {
8221107 userId: input.target.user_id,
8231108 deviceId: input.target.device_id,
8241109 token: delivery.token,
1110 bundleId: input.target.bundle_id,
1111 apsEnvironment: input.target.aps_environment,
8251112 notification: delivery.notification,
8261113 });
8271114 return result;
8281115 }
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;
8291134 const result = yield* deliveryQueue.enqueueLiveActivity({
8301135 userId: input.target.user_id,
8311136 deviceId: input.target.device_id,
8321137 kind: delivery.kind,
8331138 token: delivery.token,
1139 bundleId: input.target.bundle_id,
1140 apsEnvironment: input.target.aps_environment,
8341141 aggregate: delivery.aggregate,
835 });
836 const notification = notificationForAggregate({
837 target: input.target,
838 aggregate: input.aggregate,
1142 alert,
8391143 });
8401144 if (delivery.kind === "live_activity_end" && notification && input.target.push_token) {
8411145 yield* deliveryQueue.enqueuePushNotification({
8421146 userId: input.target.user_id,
8431147 deviceId: input.target.device_id,
8441148 token: input.target.push_token,
1149 bundleId: input.target.bundle_id,
1150 apsEnvironment: input.target.aps_environment,
8451151 notification,
8461152 });
8471153 }
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts+7
......@@ -58,12 +58,17 @@ export class ApnsDeliveryQueue extends Context.Service<
5858 readonly userId: string;
5959 readonly deviceId: string;
6060 readonly token: string;
61 readonly bundleId?: string | null;
62 readonly apsEnvironment?: "sandbox" | "production" | null;
6163 readonly aggregate: ApnsDeliveryJobPayload["aggregate"];
64 readonly alert?: ApnsDeliveryJobPayload["alert"];
6265 }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>;
6366 readonly enqueuePushNotification: (input: {
6467 readonly userId: string;
6568 readonly deviceId: string;
6669 readonly token: string;
70 readonly bundleId?: string | null;
71 readonly apsEnvironment?: "sandbox" | "production" | null;
6772 readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>;
6873 }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>;
6974 }
......@@ -160,6 +165,8 @@ export const make = Effect.gen(function* () {
160165 userId: input.userId,
161166 deviceId: input.deviceId,
162167 token: input.token,
168 bundleId: input.bundleId,
169 apsEnvironment: input.apsEnvironment,
163170 aggregate: null,
164171 notification: sanitizeApnsNotificationPayload(input.notification),
165172 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 = {
1515 platform: "ios",
1616 iosMajorVersion: 18,
1717 appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"],
18 bundleId: "com.t3tools.t3code.preview" as RelayDeviceRegistrationRequest["bundleId"],
19 apsEnvironment: "production",
1820 pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"],
1921 pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"],
2022 preferences: {
......@@ -106,6 +108,8 @@ describe("Devices", () => {
106108 expect.objectContaining({
107109 userId: "user-2",
108110 deviceId: "device-1",
111 bundleId: "com.t3tools.t3code.preview",
112 apsEnvironment: "production",
109113 pushToken: "apns-device-token",
110114 pushToStartToken: "push-to-start-token",
111115 }),
infra/relay/src/agentActivity/Devices.ts+88-82
......@@ -81,45 +81,45 @@ export const make = Effect.gen(function* () {
8181 const updatedAt = DateTime.formatIso(yield* DateTime.now);
8282 const registration = input.registration;
8383
84 yield* Effect.all(
85 [
86 registration.pushToken
87 ? db
88 .update(relayMobileDevices)
89 .set({ pushToken: null, updatedAt })
90 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
91 .pipe(
92 Effect.mapError(
93 (cause) =>
94 new DeviceRegistrationPersistenceError({
95 userId: input.userId,
96 deviceId: registration.deviceId,
97 stage: "claim-push-token",
98 cause,
99 }),
100 ),
101 )
102 : Effect.void,
103 registration.pushToStartToken
104 ? db
105 .update(relayMobileDevices)
106 .set({ pushToStartToken: null, updatedAt })
107 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
108 .pipe(
109 Effect.mapError(
110 (cause) =>
111 new DeviceRegistrationPersistenceError({
112 userId: input.userId,
113 deviceId: registration.deviceId,
114 stage: "claim-push-to-start-token",
115 cause,
116 }),
117 ),
118 )
119 : Effect.void,
120 ],
121 { discard: true },
122 );
84 // The drizzle handle is alchemy's lazy proxy chain: it only becomes a
85 // real Effect when consumed via `yield*`. Handing it to Effect.all sends
86 // the raw Proxy into the fiber runtime, which spins the isolate at 100%
87 // CPU (registrations then hang until the client aborts) — keep every db
88 // chain directly yielded.
89 if (registration.pushToken) {
90 yield* db
91 .update(relayMobileDevices)
92 .set({ pushToken: null, updatedAt })
93 .where(eq(relayMobileDevices.pushToken, registration.pushToken))
94 .pipe(
95 Effect.mapError(
96 (cause) =>
97 new DeviceRegistrationPersistenceError({
98 userId: input.userId,
99 deviceId: registration.deviceId,
100 stage: "claim-push-token",
101 cause,
102 }),
103 ),
104 );
105 }
106 if (registration.pushToStartToken) {
107 yield* db
108 .update(relayMobileDevices)
109 .set({ pushToStartToken: null, updatedAt })
110 .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken))
111 .pipe(
112 Effect.mapError(
113 (cause) =>
114 new DeviceRegistrationPersistenceError({
115 userId: input.userId,
116 deviceId: registration.deviceId,
117 stage: "claim-push-to-start-token",
118 cause,
119 }),
120 ),
121 );
122 }
123123
124124 yield* db
125125 .insert(relayMobileDevices)
......@@ -130,6 +130,8 @@ export const make = Effect.gen(function* () {
130130 platform: registration.platform,
131131 iosMajorVersion: registration.iosMajorVersion,
132132 appVersion: registration.appVersion ?? null,
133 bundleId: registration.bundleId ?? null,
134 apsEnvironment: registration.apsEnvironment ?? null,
133135 pushToken: registration.pushToken ?? null,
134136 pushToStartToken: registration.pushToStartToken ?? null,
135137 preferencesJson: registration.preferences,
......@@ -143,6 +145,13 @@ export const make = Effect.gen(function* () {
143145 label: registration.label,
144146 iosMajorVersion: registration.iosMajorVersion,
145147 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 )`,
146155 pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
147156 pushToStartToken: sql`coalesce(
148157 excluded.push_to_start_token,
......@@ -168,49 +177,46 @@ export const make = Effect.gen(function* () {
168177 yield* Effect.annotateCurrentSpan({
169178 "relay.mobile.device_id": input.deviceId,
170179 });
171 yield* Effect.all(
172 [
173 db
174 .delete(relayLiveActivities)
175 .where(
176 and(
177 eq(relayLiveActivities.userId, input.userId),
178 eq(relayLiveActivities.deviceId, input.deviceId),
179 ),
180 )
181 .pipe(
182 Effect.mapError(
183 (cause) =>
184 new DeviceUnregistrationPersistenceError({
185 userId: input.userId,
186 deviceId: input.deviceId,
187 stage: "delete-live-activity",
188 cause,
189 }),
190 ),
191 ),
192 db
193 .delete(relayMobileDevices)
194 .where(
195 and(
196 eq(relayMobileDevices.userId, input.userId),
197 eq(relayMobileDevices.deviceId, input.deviceId),
198 ),
199 )
200 .pipe(
201 Effect.mapError(
202 (cause) =>
203 new DeviceUnregistrationPersistenceError({
204 userId: input.userId,
205 deviceId: input.deviceId,
206 stage: "delete-device",
207 cause,
208 }),
209 ),
210 ),
211 ],
212 { discard: true },
213 );
180 // Same proxy-chain constraint as register above: db chains must be
181 // consumed via `yield*`, never passed to Effect.all.
182 yield* db
183 .delete(relayLiveActivities)
184 .where(
185 and(
186 eq(relayLiveActivities.userId, input.userId),
187 eq(relayLiveActivities.deviceId, input.deviceId),
188 ),
189 )
190 .pipe(
191 Effect.mapError(
192 (cause) =>
193 new DeviceUnregistrationPersistenceError({
194 userId: input.userId,
195 deviceId: input.deviceId,
196 stage: "delete-live-activity",
197 cause,
198 }),
199 ),
200 );
201 yield* db
202 .delete(relayMobileDevices)
203 .where(
204 and(
205 eq(relayMobileDevices.userId, input.userId),
206 eq(relayMobileDevices.deviceId, input.deviceId),
207 ),
208 )
209 .pipe(
210 Effect.mapError(
211 (cause) =>
212 new DeviceUnregistrationPersistenceError({
213 userId: input.userId,
214 deviceId: input.deviceId,
215 stage: "delete-device",
216 cause,
217 }),
218 ),
219 );
214220 }),
215221 listForUser: Effect.fn("relay.devices.listForUser")(function* (input) {
216222 const rows = yield* db
infra/relay/src/agentActivity/LiveActivities.test.ts+51
......@@ -198,6 +198,57 @@ describe("LiveActivities", () => {
198198 );
199199 });
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
201252 it.effect("preserves correlation context and causes for persistence failures", () => {
202253 const cause = new Error("database unavailable");
203254 const registration: RelayLiveActivityRegistrationRequest = {
infra/relay/src/agentActivity/LiveActivities.ts+24-4
......@@ -69,6 +69,8 @@ export interface DeviceRow {
6969 readonly platform: "ios";
7070 readonly ios_major_version: number;
7171 readonly app_version: string | null;
72 readonly bundle_id: string | null;
73 readonly aps_environment: "sandbox" | "production" | null;
7274 readonly push_token: string | null;
7375 readonly push_to_start_token: string | null;
7476 readonly preferences_json: string;
......@@ -196,6 +198,8 @@ export const make = Effect.gen(function* () {
196198 platform: relayMobileDevices.platform,
197199 ios_major_version: relayMobileDevices.iosMajorVersion,
198200 app_version: relayMobileDevices.appVersion,
201 bundle_id: relayMobileDevices.bundleId,
202 aps_environment: relayMobileDevices.apsEnvironment,
199203 push_token: relayMobileDevices.pushToken,
200204 push_to_start_token: relayMobileDevices.pushToStartToken,
201205 preferences_json: relayMobileDevices.preferencesJson,
......@@ -276,10 +280,26 @@ export const make = Effect.gen(function* () {
276280 .onConflictDoUpdate({
277281 target: [relayLiveActivities.userId, relayLiveActivities.deviceId],
278282 set: {
279 remoteStartedAt: sql`coalesce(
280 ${relayLiveActivities.remoteStartedAt},
281 excluded.remote_started_at
282 )`,
283 // A delivered start begins a NEW activity generation: the stored
284 // update token belongs to the previous activity (dead once a new
285 // one starts, and certainly dead after an end), so keep it only
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 )`,
283303 remoteStartQueuedAt: null,
284304 endedAt:
285305 input.kind === "live_activity_start"
infra/relay/src/agentActivity/MobileRegistrations.test.ts+48-41
......@@ -20,6 +20,7 @@ import * as RelayConfiguration from "../Config.ts";
2020import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
2121import * as ApnsDeliveries from "./ApnsDeliveries.ts";
2222import * as ApnsClient from "./ApnsClient.ts";
23import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
2324import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
2425import * as MobileRegistrations from "./MobileRegistrations.ts";
2526
......@@ -70,6 +71,7 @@ function makeAgentActivityRows(
7071 return {
7172 upsert: () => Effect.void,
7273 remove: () => Effect.void,
74 pruneTerminal: () => Effect.void,
7375 listForUser: () => {
7476 const activeState: RelayAgentActivityState = {
7577 environmentId: "env-1" as RelayAgentActivityState["environmentId"],
......@@ -147,7 +149,11 @@ function makeRegistrationReplayLayer(input: {
147149}) {
148150 return MobileRegistrations.layer.pipe(
149151 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 ),
151157 Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))),
152158 Layer.provide(
153159 Layer.mergeAll(
......@@ -207,6 +213,7 @@ describe("MobileRegistrations", () => {
207213 }),
208214 ),
209215 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
216 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
210217 Layer.succeed(
211218 AgentActivityPublisher.AgentActivityPublisher,
212219 makeAgentActivityPublisher({
......@@ -249,6 +256,7 @@ describe("MobileRegistrations", () => {
249256 Layer.mergeAll(
250257 Layer.succeed(Devices.Devices, makeDevices()),
251258 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
259 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
252260 Layer.succeed(
253261 AgentActivityPublisher.AgentActivityPublisher,
254262 makeAgentActivityPublisher({
......@@ -300,6 +308,7 @@ describe("MobileRegistrations", () => {
300308 }),
301309 ),
302310 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
311 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
303312 Layer.succeed(
304313 AgentActivityPublisher.AgentActivityPublisher,
305314 makeAgentActivityPublisher(),
......@@ -344,6 +353,7 @@ describe("MobileRegistrations", () => {
344353 Layer.provide(
345354 Layer.mergeAll(
346355 Layer.succeed(Devices.Devices, makeDevices()),
356 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
347357 Layer.succeed(
348358 LiveActivities.LiveActivities,
349359 makeLiveActivities({
......@@ -381,8 +391,36 @@ describe("MobileRegistrations", () => {
381391 });
382392 });
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
384422 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",
386424 () => {
387425 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
388426 const queuedStarts: Array<
......@@ -404,6 +442,8 @@ describe("MobileRegistrations", () => {
404442 platform: "ios",
405443 ios_major_version: 18,
406444 app_version: "1.0.0",
445 bundle_id: null,
446 aps_environment: null,
407447 push_token: "apns-device-token",
408448 push_to_start_token: "push-to-start-token",
409449 preferences_json: JSON.stringify(device.preferences),
......@@ -432,46 +472,13 @@ describe("MobileRegistrations", () => {
432472 },
433473 });
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.
435478 expect(result).toEqual({ ok: true });
436 expect(registeredDevices).toEqual([
437 {
438 userId: "dev:julius",
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 });
479 expect(registeredDevices).toHaveLength(1);
480 expect(queuedStarts).toEqual([]);
481 expect(queuedJobs).toEqual([]);
475482 }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs })));
476483 },
477484 );
infra/relay/src/agentActivity/MobileRegistrations.ts+22-1
......@@ -1,11 +1,14 @@
11import type {
2 RelayAgentActivitySnapshotResponse,
23 RelayDeviceRegistrationRequest,
34 RelayLiveActivityRegistrationRequest,
45} from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
57import * as Context from "effect/Context";
68import * as Effect from "effect/Effect";
79import * as Layer from "effect/Layer";
810
11import * as AgentActivityRows from "./AgentActivityRows.ts";
912import * as Devices from "./Devices.ts";
1013import * as LiveActivities from "./LiveActivities.ts";
1114import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
......@@ -13,7 +16,8 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
1316export type MobileRegistrationError =
1417 | Devices.DeviceRegistrationPersistenceError
1518 | Devices.DeviceUnregistrationPersistenceError
16 | LiveActivities.LiveActivityRegistrationPersistenceError;
19 | LiveActivities.LiveActivityRegistrationPersistenceError
20 | AgentActivityRows.AgentActivityRowListPersistenceError;
1721
1822export class MobileRegistrations extends Context.Service<
1923 MobileRegistrations,
......@@ -30,10 +34,14 @@ export class MobileRegistrations extends Context.Service<
3034 readonly userId: string;
3135 readonly deviceId: string;
3236 }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>;
37 readonly getAgentActivitySnapshot: (input: {
38 readonly userId: string;
39 }) => Effect.Effect<RelayAgentActivitySnapshotResponse, MobileRegistrationError>;
3340 }
3441>()("t3code-relay/agentActivity/MobileRegistrations") {}
3542
3643export const make = Effect.gen(function* () {
44 const rows = yield* AgentActivityRows.AgentActivityRows;
3745 const devices = yield* Devices.Devices;
3846 const liveActivities = yield* LiveActivities.LiveActivities;
3947 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
......@@ -82,6 +90,19 @@ export const make = Effect.gen(function* () {
8290 return { ok: true as const };
8391 },
8492 ),
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 ),
85106 unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) {
86107 yield* Effect.annotateCurrentSpan({
87108 "relay.mobile.device_id": input.deviceId,
infra/relay/src/agentActivity/agentActivityPayloads.ts+36
......@@ -1,9 +1,45 @@
11import type {
22 RelayAgentActivityAggregateRow,
33 RelayAgentActivityAggregateState,
4 RelayAgentActivityState,
45} from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
7import * as Option from "effect/Option";
8
59import 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
743const MAX_SUMMARY_TEXT_LENGTH = 120;
844const MAX_STATUS_TEXT_LENGTH = 40;
945const MAX_DEEP_LINK_LENGTH = 512;
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+23
......@@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({
4141});
4242export 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
4453export const ApnsDeliveryJobPayload = Schema.Struct({
4554 version: Schema.Literal(1),
4655 jobId: Schema.String,
......@@ -49,9 +58,15 @@ export const ApnsDeliveryJobPayload = Schema.Struct({
4958 userId: Schema.String,
5059 deviceId: Schema.String,
5160 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"]))),
5265 }),
5366 aggregate: Schema.NullOr(RelayAgentActivityAggregateState),
5467 notification: Schema.NullOr(ApnsNotificationPayload),
68 // Optional so jobs queued by older relay builds still decode.
69 alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)),
5570 createdAt: Schema.String,
5671 expiresAt: Schema.String,
5772});
......@@ -224,8 +239,11 @@ export function makeApnsDeliveryJobPayload(input: {
224239 readonly userId: string;
225240 readonly deviceId: string;
226241 readonly token: string;
242 readonly bundleId?: string | null | undefined;
243 readonly apsEnvironment?: "sandbox" | "production" | null | undefined;
227244 readonly aggregate: ApnsDeliveryJobPayload["aggregate"];
228245 readonly notification?: ApnsNotificationPayload | null;
246 readonly alert?: ApnsLiveActivityAlert | null | undefined;
229247 readonly createdAt: string;
230248 readonly expiresAt: string;
231249 readonly jobId: string;
......@@ -238,9 +256,14 @@ export function makeApnsDeliveryJobPayload(input: {
238256 userId: input.userId,
239257 deviceId: input.deviceId,
240258 token: input.token,
259 ...(input.bundleId ? { bundleId: input.bundleId } : {}),
260 ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
241261 },
242262 aggregate: input.aggregate,
243263 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 } : {}),
244267 createdAt: input.createdAt,
245268 expiresAt: input.expiresAt,
246269 };
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* () {
109109function testLayer(input?: {
110110 readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"];
111111 readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"];
112 readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"];
112113}) {
113114 return EnvironmentLinker.layer.pipe(
114115 Layer.provideMerge(RelayTokens.layer),
......@@ -135,7 +136,7 @@ function testLayer(input?: {
135136 revokeForEnvironmentPublicKey: () => Effect.succeed(false),
136137 }),
137138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
138 deprovision: () => Effect.void,
139 deprovision: input?.deprovision ?? (() => Effect.void),
139140 provision: () =>
140141 Effect.succeed({
141142 endpoint: {
......@@ -173,6 +174,79 @@ describe("EnvironmentLinker", () => {
173174 );
174175 });
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
176250 it.effect("rejects a tampered compact proof before persistence", () => {
177251 let persisted = false;
178252 return Effect.gen(function* () {
infra/relay/src/environments/EnvironmentLinker.ts+26-1
......@@ -287,6 +287,27 @@ const make = Effect.gen(function* () {
287287 stage: "validate_origin",
288288 });
289289 }
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 }
290311 const provisioned = input.request.managedTunnelsEnabled
291312 ? yield* managedEndpointProvider.provision({
292313 userId: input.userId,
......@@ -295,7 +316,11 @@ const make = Effect.gen(function* () {
295316 })
296317 : null;
297318 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)) {
299324 return yield* new EnvironmentLinkProofInvalid({
300325 userId: input.userId,
301326 environmentId: verified.environmentId,
infra/relay/src/http/Api.test.ts+31
......@@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend";
22import { describe, expect, it } from "@effect/vitest";
33import { vi } from "vite-plus/test";
44import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
56import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
68import * as Layer from "effect/Layer";
79import * as Option from "effect/Option";
810import * as Predicate from "effect/Predicate";
911import * as Redacted from "effect/Redacted";
12import * as TestClock from "effect/testing/TestClock";
1013import * as Tracer from "effect/Tracer";
1114import * as HttpRouter from "effect/unstable/http/HttpRouter";
1215import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
......@@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
1417import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
1518
1619import {
20 RELAY_REQUEST_DEADLINE_MS,
1721 relayCors,
1822 relayDocsRedirectRoute,
1923 relayEnvironmentAuthLayer,
......@@ -203,6 +207,33 @@ describe("relay request tracing", () => {
203207 expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId);
204208 }),
205209 );
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 );
206237});
207238
208239describe("relay routing fallback", () => {
infra/relay/src/http/Api.ts+54-1
......@@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime";
66import * as Duration from "effect/Duration";
77import * as Effect from "effect/Effect";
88import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
910import * as Record from "effect/Record";
1011import * as Redacted from "effect/Redacted";
1112import * as Schema from "effect/Schema";
......@@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add(
155156 HttpServerResponse.redirect("/docs"),
156157);
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
158200export const traceRelayHttpRequest = <E, R>(
159201 httpEffect: Effect.Effect<
160202 HttpServerResponse.HttpServerResponse,
......@@ -164,7 +206,7 @@ export const traceRelayHttpRequest = <E, R>(
164206) =>
165207 // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first.
166208 HttpMiddleware.tracer(
167 appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)),
209 appendRelayTraceContextResponseHeader.pipe(Effect.andThen(relayRequestDeadline(httpEffect))),
168210 ).pipe(Effect.ensuring(Effect.yieldNow));
169211
170212export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>(
......@@ -391,6 +433,17 @@ export const mobileApi = HttpApiBuilder.group(
391433 return yield* registrations.registerLiveActivity({ userId, payload });
392434 }, mapRelayCommonApiErrors("invalid_dpop")),
393435 )
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 )
394447 .handle(
395448 "unregisterDevice",
396449 Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) {
infra/relay/src/persistence/schema.ts+2
......@@ -24,6 +24,8 @@ export const relayMobileDevices = pgTable(
2424 platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(),
2525 iosMajorVersion: integer("ios_major_version").notNull(),
2626 appVersion: varchar("app_version", { length: 64 }),
27 bundleId: varchar("bundle_id", { length: 255 }),
28 apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(),
2729 pushToken: text("push_token"),
2830 pushToStartToken: text("push_to_start_token"),
2931 preferencesJson: jsonb("preferences_json").notNull().$type<RelayAgentAwarenessPreferences>(),
infra/relay/src/worker.ts+15-2
......@@ -2,6 +2,7 @@ import * as Alchemy from "alchemy";
22import * as Cloudflare from "alchemy/Cloudflare";
33import * as Drizzle from "alchemy/Drizzle";
44import * as Config from "effect/Config";
5import * as DateTime from "effect/DateTime";
56import * as Crypto from "effect/Crypto";
67import * as Effect from "effect/Effect";
78import * as Layer from "effect/Layer";
......@@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu
4748import * as RelayConfiguration from "./Config.ts";
4849import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";
4950import * as ApnsClient from "./agentActivity/ApnsClient.ts";
51import * as ApnsProviderTokens from "./agentActivity/ApnsProviderTokens.ts";
5052import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts";
5153import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts";
5254import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";
......@@ -196,7 +198,7 @@ export default class Api extends Cloudflare.Worker<Api>()(
196198 ),
197199 Layer.provideMerge(DpopProofs.layer),
198200 Layer.provideMerge(ApnsDeliveries.layer),
199 Layer.provideMerge(ApnsClient.layer),
201 Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))),
200202 Layer.provideMerge(
201203 ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext),
202204 ),
......@@ -242,7 +244,18 @@ export default class Api extends Cloudflare.Worker<Api>()(
242244 yield* Cloudflare.cron("*/5 * * * *").subscribe(() =>
243245 DpopProofs.DpopProofReplay.pipe(
244246 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"),
246259 Effect.provide(runtimeLayer),
247260 ),
248261 );
packages/client-runtime/src/connection/resolver.test.ts+2
......@@ -84,6 +84,7 @@ function relayClient(
8484 registerDevice: () => unsupported("registerDevice"),
8585 unregisterDevice: () => unsupported("unregisterDevice"),
8686 registerLiveActivity: () => unsupported("registerLiveActivity"),
87 getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"),
8788 resetTokenCache: Effect.void,
8889 });
8990}
......@@ -449,6 +450,7 @@ describe("ConnectionResolver", () => {
449450 new ManagedRelay.ManagedRelayRequestTimeoutError({
450451 activity: "Relay environment connection",
451452 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
453 traceId: null,
452454 }),
453455 ),
454456 });
packages/client-runtime/src/relay/discovery.test.ts+3
......@@ -112,6 +112,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
112112 registerDevice: () => Effect.die("unused"),
113113 unregisterDevice: () => Effect.die("unused"),
114114 registerLiveActivity: () => Effect.die("unused"),
115 getAgentActivitySnapshot: () => Effect.die("unused"),
115116 resetTokenCache: Effect.void,
116117 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
117118 const connectivity = Connectivity.Connectivity.of({
......@@ -261,6 +262,7 @@ describe("RelayEnvironmentDiscovery", () => {
261262 new ManagedRelay.ManagedRelayRequestTimeoutError({
262263 activity: "Relay environment listing",
263264 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
265 traceId: null,
264266 }),
265267 ),
266268 getEnvironmentStatus: () => Effect.die("unused"),
......@@ -272,6 +274,7 @@ describe("RelayEnvironmentDiscovery", () => {
272274 registerDevice: () => Effect.die("unused"),
273275 unregisterDevice: () => Effect.die("unused"),
274276 registerLiveActivity: () => Effect.die("unused"),
277 getAgentActivitySnapshot: () => Effect.die("unused"),
275278 resetTokenCache: Effect.void,
276279 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
277280 const layer = RelayEnvironmentDiscovery.layer.pipe(
packages/client-runtime/src/relay/managedRelay.ts+49-5
......@@ -17,11 +17,13 @@ import {
1717 RelayExchangeDpopAccessTokenEndpoint,
1818 RelayGetEnvironmentStatusEndpoint,
1919 RelayJwtSubjectTokenType,
20 type RelayAgentActivitySnapshotResponse,
2021 type RelayLiveActivityRegistrationRequest,
2122 RelayMobileRegistrationScope,
2223 type RelayOkResponse,
2324 type RelayPublicClientId,
2425 RelayRegisterDeviceEndpoint,
26 RelayAgentActivitySnapshotEndpoint,
2527 RelayRegisterLiveActivityEndpoint,
2628 RelayProtectedError,
2729 type RelayProtectedError as RelayProtectedErrorType,
......@@ -92,6 +94,7 @@ export const ManagedRelayRequestAction = Schema.Literals([
9294 "register relay mobile device",
9395 "unregister relay mobile device",
9496 "register relay live activity",
97 "read relay agent activity snapshot",
9598]);
9699export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;
97100
......@@ -107,6 +110,7 @@ export const ManagedRelayRequestActivity = Schema.Literals([
107110 "Relay mobile device registration",
108111 "Relay mobile device unregistration",
109112 "Relay Live Activity registration",
113 "Relay agent activity snapshot",
110114]);
111115export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;
112116
......@@ -115,6 +119,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<Man
115119 {
116120 activity: ManagedRelayRequestActivity,
117121 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),
118126 },
119127) {
120128 override get message(): string {
......@@ -286,6 +294,9 @@ export class ManagedRelayClient extends Context.Service<
286294 readonly clerkToken: string;
287295 readonly payload: RelayLiveActivityRegistrationRequest;
288296 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
297 readonly getAgentActivitySnapshot: (input: {
298 readonly clerkToken: string;
299 }) => Effect.Effect<RelayAgentActivitySnapshotResponse, ManagedRelayClientError>;
289300 readonly resetTokenCache: Effect.Effect<void>;
290301 }
291302>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
......@@ -326,11 +337,18 @@ function timeoutRelayRequest(activity: ManagedRelayRequestActivity) {
326337 Effect.flatMap(
327338 Option.match({
328339 onNone: () =>
329 Effect.fail(
330 new ManagedRelayRequestTimeoutError({
331 activity,
332 timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
333 }),
340 Effect.currentParentSpan.pipe(
341 Effect.map((span) => span.traceId),
342 Effect.orElseSucceed(() => null),
343 Effect.flatMap((traceId) =>
344 Effect.fail(
345 new ManagedRelayRequestTimeoutError({
346 activity,
347 timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
348 traceId,
349 }),
350 ),
351 ),
334352 ),
335353 onSome: Effect.succeed,
336354 }),
......@@ -399,6 +417,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi
399417 registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"),
400418 unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"),
401419 registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"),
420 getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"),
402421 resetTokenCache: Effect.void.pipe(
403422 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
404423 ),
......@@ -446,6 +465,10 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
446465 method: RelayUnregisterDeviceEndpoint.method,
447466 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),
448467 }),
468 getAgentActivitySnapshot: (): DpopProofTarget => ({
469 method: RelayAgentActivitySnapshotEndpoint.method,
470 url: urlBuilder.mobile.getAgentActivitySnapshot(),
471 }),
449472 registerLiveActivity: (): DpopProofTarget => ({
450473 method: RelayRegisterLiveActivityEndpoint.method,
451474 url: urlBuilder.mobile.registerLiveActivity(),
......@@ -845,6 +868,27 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
845868 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
846869 withRelayClientTracing,
847870 ),
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 ),
848892 registerLiveActivity: Effect.fnUntraced(
849893 function* (input) {
850894 return yield* mobileRegistrationRequest(
packages/client-runtime/src/relay/managedRelayState.test.ts+1
......@@ -83,6 +83,7 @@ function createManager(
8383 registerDevice: () => Effect.die("unused"),
8484 unregisterDevice: () => Effect.die("unused"),
8585 registerLiveActivity: () => Effect.die("unused"),
86 getAgentActivitySnapshot: () => Effect.die("unused"),
8687 resetTokenCache: Effect.void,
8788 ...overrides,
8889 });
packages/contracts/src/environmentHttp.ts+5
......@@ -342,6 +342,11 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({
342342 cloudUserId: Schema.NullOr(Schema.String),
343343 relayUrl: Schema.NullOr(Schema.String),
344344 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),
345350 publishAgentActivity: Schema.Boolean,
346351});
347352export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
packages/contracts/src/relay.ts+28
......@@ -35,12 +35,21 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({
3535});
3636export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type;
3737
38export const RelayApnsEnvironment = Schema.Literals(["sandbox", "production"]);
39export type RelayApnsEnvironment = typeof RelayApnsEnvironment.Type;
40
3841export const RelayDeviceRegistrationRequest = Schema.Struct({
3942 deviceId: TrimmedNonEmptyString,
4043 label: TrimmedNonEmptyString,
4144 platform: RelayAgentAwarenessPlatform,
4245 iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),
4346 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),
4453 pushToken: Schema.optional(TrimmedNonEmptyString),
4554 pushToStartToken: Schema.optional(TrimmedNonEmptyString),
4655 preferences: RelayAgentAwarenessPreferences,
......@@ -864,6 +873,24 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post(
864873 },
865874).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
867894export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete(
868895 "unregisterDevice",
869896 "/v1/mobile/devices/:deviceId",
......@@ -879,6 +906,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile")
879906 .add(
880907 RelayRegisterDeviceEndpoint,
881908 RelayRegisterLiveActivityEndpoint,
909 RelayAgentActivitySnapshotEndpoint,
882910 RelayUnregisterDeviceEndpoint,
883911 )
884912 .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.")
packages/shared/src/agentAwareness.test.ts+52
......@@ -102,6 +102,58 @@ describe("projectThreadAwareness", () => {
102102 });
103103 });
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
105157 it("projects failures with the session error detail", () => {
106158 const state = projectThreadAwareness({
107159 environmentId: "env-1" as EnvironmentId,
packages/shared/src/agentAwareness.ts+18
......@@ -103,6 +103,24 @@ function resolveThreadAwarenessPhase(
103103 if (thread.latestTurn?.state === "completed") {
104104 return "completed";
105105 }
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 }
106124 return null;
107125}
108126
pnpm-lock.yaml+6
......@@ -671,6 +671,12 @@ importers:
671671 '@effect/sql-pg':
672672 specifier: 4.0.0-beta.78
673673 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
674680 '@t3tools/client-runtime':
675681 specifier: workspace:*
676682 version: link:../../packages/client-runtime