diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index cc9da520ca0..d03e52e66bd 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -161,6 +161,9 @@ const config: ExpoConfig = {
bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${variant.iosBundleIdentifier}`,
enablePushNotifications: true,
+ // Agent activity can update many times an hour; without the
+ // frequent-updates entitlement iOS throttles the update budget sooner.
+ frequentUpdates: true,
widgets: [
{
name: "AgentActivity",
@@ -171,6 +174,9 @@ const config: ExpoConfig = {
],
},
],
+ // Must run after expo-widgets so the widget target exists when it wires in
+ // the branded logo asset catalog.
+ "./plugins/withWidgetLogoAsset.cjs",
"./plugins/withIosSceneLifecycle.cjs",
"./plugins/withAndroidCleartextTraffic.cjs",
],
diff --git a/apps/mobile/assets/widget/T3Mark.svg b/apps/mobile/assets/widget/T3Mark.svg
new file mode 100644
index 00000000000..48d1b09446f
--- /dev/null
+++ b/apps/mobile/assets/widget/T3Mark.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs b/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs
new file mode 100644
index 00000000000..e4907be509d
--- /dev/null
+++ b/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs
@@ -0,0 +1,73 @@
+"use strict";
+
+// Bundle the widget asset catalog into an app-extension (widget) target.
+//
+// expo-widgets generates the widget target without a Resources build phase, and
+// hand-adding a PBXResourcesBuildPhase does NOT get picked up — xcodebuild's
+// planner never schedules `actool` for it (verified: even a full re-plan skips
+// it). So instead we add a shell-script phase that runs `actool` directly and
+// drops the compiled Assets.car into the extension bundle. Marked
+// alwaysOutOfDate so the build system always runs it.
+//
+// Idempotent across re-runs. Returns true when it added the phase, false when it
+// was already present or the target was not found.
+
+const PHASE_NAME = "Compile Widget Assets";
+
+// Compiles ExpoWidgetsTarget/Assets.xcassets into the extension's resources dir.
+// Uses only Xcode-provided build settings so it works for device + simulator.
+const ACTOOL_SCRIPT = [
+ "set -e",
+ 'CATALOG="${SRCROOT}/ExpoWidgetsTarget/Assets.xcassets"',
+ 'if [ ! -d "$CATALOG" ]; then',
+ ' echo "warning: widget asset catalog not found at $CATALOG"',
+ " exit 0",
+ "fi",
+ 'DEST="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"',
+ 'mkdir -p "$DEST"',
+ 'xcrun actool "$CATALOG" --compile "$DEST" --platform "${PLATFORM_NAME}" --minimum-deployment-target "${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" --output-format human-readable-text',
+].join("\n");
+
+function stripComments(map) {
+ const out = {};
+ for (const key of Object.keys(map || {})) {
+ if (key.endsWith("_comment")) continue;
+ out[key] = map[key];
+ }
+ return out;
+}
+
+function findByName(map, name) {
+ for (const [uuid, value] of Object.entries(stripComments(map))) {
+ if (value && value.name === name) return { uuid, value };
+ }
+ return null;
+}
+
+/**
+ * @param {import('xcode').XcodeProject} proj
+ * @param {{ targetName: string }} opts
+ */
+function addWidgetAssetCatalog(proj, opts) {
+ const objects = proj.hash.project.objects;
+ const target = findByName(objects.PBXNativeTarget, opts.targetName);
+ if (!target) return false;
+
+ const phases = target.value.buildPhases || [];
+ const existing = objects.PBXShellScriptBuildPhase || {};
+ const already = Object.entries(stripComments(existing)).some(
+ ([uuid, value]) =>
+ value && value.name === `"${PHASE_NAME}"` && phases.some((p) => p.value === uuid),
+ );
+ if (already) return false;
+
+ const { uuid } = proj.addBuildPhase([], "PBXShellScriptBuildPhase", PHASE_NAME, target.uuid, {
+ shellPath: "/bin/sh",
+ shellScript: ACTOOL_SCRIPT,
+ });
+ // Always run: input-analysis is exactly what skipped the Resources phase.
+ objects.PBXShellScriptBuildPhase[uuid].alwaysOutOfDate = 1;
+ return true;
+}
+
+module.exports = { addWidgetAssetCatalog };
diff --git a/apps/mobile/plugins/withWidgetLogoAsset.cjs b/apps/mobile/plugins/withWidgetLogoAsset.cjs
new file mode 100644
index 00000000000..939a92af4c4
--- /dev/null
+++ b/apps/mobile/plugins/withWidgetLogoAsset.cjs
@@ -0,0 +1,62 @@
+"use strict";
+
+// Ships the branded T3 mark to the Live Activity / widget extension.
+//
+// expo-widgets generates ExpoWidgetsTarget without a Resources build phase and
+// has no asset support, so this plugin (a) writes an SVG template image set into
+// the generated widget asset catalog and (b) wires that catalog into the widget
+// target with a dedicated Resources build phase. Both steps are idempotent and
+// survive `expo prebuild --clean`. Must be listed AFTER "expo-widgets" in the
+// plugins array so the widget target exists when this runs.
+
+const path = require("path");
+const fs = require("fs");
+const { withDangerousMod, withXcodeProject } = require("expo/config-plugins");
+const { addWidgetAssetCatalog } = require("./lib/addWidgetAssetCatalog.cjs");
+
+const TARGET_NAME = "ExpoWidgetsTarget";
+const CATALOG_NAME = "Assets.xcassets";
+const IMAGE_SET = "T3Mark.imageset";
+const SVG_NAME = "T3Mark.svg";
+
+const CATALOG_CONTENTS = JSON.stringify({ info: { author: "expo", version: 1 } }, null, 2) + "\n";
+const IMAGE_SET_CONTENTS =
+ JSON.stringify(
+ {
+ images: [{ idiom: "universal", filename: SVG_NAME }],
+ info: { author: "expo", version: 1 },
+ properties: {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template",
+ },
+ },
+ null,
+ 2,
+ ) + "\n";
+
+function withAssetFiles(config) {
+ return withDangerousMod(config, [
+ "ios",
+ (cfg) => {
+ const source = path.join(cfg.modRequest.projectRoot, "assets", "widget", SVG_NAME);
+ const catalogDir = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, CATALOG_NAME);
+ const imageSetDir = path.join(catalogDir, IMAGE_SET);
+ fs.mkdirSync(imageSetDir, { recursive: true });
+ fs.writeFileSync(path.join(catalogDir, "Contents.json"), CATALOG_CONTENTS);
+ fs.writeFileSync(path.join(imageSetDir, "Contents.json"), IMAGE_SET_CONTENTS);
+ fs.copyFileSync(source, path.join(imageSetDir, SVG_NAME));
+ return cfg;
+ },
+ ]);
+}
+
+function withAssetWiring(config) {
+ return withXcodeProject(config, (cfg) => {
+ addWidgetAssetCatalog(cfg.modResults, { targetName: TARGET_NAME });
+ return cfg;
+ });
+}
+
+module.exports = function withWidgetLogoAsset(config) {
+ return withAssetWiring(withAssetFiles(config));
+};
diff --git a/apps/mobile/scripts/wire-widget-asset-catalog.cjs b/apps/mobile/scripts/wire-widget-asset-catalog.cjs
new file mode 100644
index 00000000000..ad96a4dabf7
--- /dev/null
+++ b/apps/mobile/scripts/wire-widget-asset-catalog.cjs
@@ -0,0 +1,30 @@
+"use strict";
+
+// One-off: apply the widget asset-catalog wiring to the already-generated
+// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets
+// without a full `expo prebuild`. The durable equivalent lives in
+// plugins/withWidgetLogoAsset.cjs and runs on prebuild.
+
+const path = require("path");
+const fs = require("fs");
+
+const xcodePath = require.resolve("xcode", {
+ paths: [
+ require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }),
+ ],
+});
+const xcode = require(xcodePath);
+const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs");
+
+const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj");
+const proj = xcode.project(pbxprojPath);
+proj.parseSync();
+
+const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" });
+
+if (added) {
+ fs.writeFileSync(pbxprojPath, proj.writeSync());
+ console.log("Added widget asset-compile phase to ExpoWidgetsTarget.");
+} else {
+ console.log("No change: phase already present or target not found.");
+}
diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
index a4e6fc3d6db..cd2e36a403c 100644
--- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts
+++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
@@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
import type { Preferences } from "../../lib/storage";
+// Development builds are Xcode-signed and receive sandbox APNs tokens;
+// preview and production builds are distribution-signed and use production
+// APNs. The relay routes each device's pushes accordingly.
+export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {
+ return appVariant === "development" ? "sandbox" : "production";
+}
+
export function makeRelayDeviceRegistrationRequest(input: {
readonly deviceId: string;
readonly label: string;
readonly iosMajorVersion: number;
readonly appVersion?: string;
+ readonly bundleId?: string;
+ readonly apsEnvironment?: "sandbox" | "production";
readonly pushToken?: string;
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
@@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: {
platform: "ios",
iosMajorVersion: input.iosMajorVersion,
appVersion: input.appVersion,
+ ...(input.bundleId ? { bundleId: input.bundleId } : {}),
+ ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
...(input.pushToken ? { pushToken: input.pushToken } : {}),
...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
preferences: {
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
index 7f97d7c718c..99686898eb7 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -16,20 +16,29 @@ import { verifyDpopProof } from "@t3tools/shared/dpop";
import type { SavedRemoteConnection } from "../../lib/connection";
import { cryptoLayer } from "../cloud/dpop";
import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
-import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
+import {
+ clearAgentAwarenessRegistrationRecord,
+ loadAgentAwarenessRegistrationRecord,
+ loadOrCreateAgentAwarenessDeviceId,
+ saveAgentAwarenessRegistrationRecord,
+} from "../../lib/storage";
+import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
import {
AgentAwarenessOperationError,
__resetAgentAwarenessRemoteRegistrationForTest,
+ getAgentAwarenessRegistrationStatus,
refreshActiveLiveActivityRemoteRegistration,
refreshAgentAwarenessRegistration,
normalizeAgentAwarenessRelayBaseUrl,
registerAgentAwarenessConnection,
registerLiveActivityPushToken,
+ releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
shouldRegisterAgentAwarenessDeviceForProvider,
unregisterAgentAwarenessConnection,
} from "./remoteRegistration";
import * as Notifications from "expo-notifications";
+import { addPushToStartTokenListener } from "expo-widgets";
const secureStore = vi.hoisted(() => new Map());
const widgetMocks = vi.hoisted(() => ({
@@ -41,6 +50,16 @@ const backgroundRuntime = vi.hoisted(() => ({
readonly resolve: (exit: Exit.Exit) => void;
}>,
}));
+const appStateMock = vi.hoisted(() => ({
+ listeners: [] as Array<(state: string) => void>,
+}));
+const registrationRecordStore = vi.hoisted(() => ({
+ current: null as {
+ readonly identity: string;
+ readonly signature: string;
+ readonly pushToStartToken?: string;
+ } | null,
+}));
vi.mock("expo-constants", () => ({
default: {
@@ -100,6 +119,19 @@ vi.mock("react-native", () => ({
OS: "ios",
Version: "18.0",
},
+ AppState: {
+ addEventListener: (_event: string, listener: (state: string) => void) => {
+ appStateMock.listeners.push(listener);
+ return {
+ remove: () => {
+ const index = appStateMock.listeners.indexOf(listener);
+ if (index >= 0) {
+ appStateMock.listeners.splice(index, 1);
+ }
+ },
+ };
+ },
+ },
}));
vi.mock("../../lib/runtime", () => ({
@@ -115,6 +147,17 @@ vi.mock("../../lib/storage", () => ({
loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
loadPreferences: vi.fn(() => Promise.resolve({})),
+ loadAgentAwarenessRegistrationRecord: vi.fn(() =>
+ Promise.resolve(registrationRecordStore.current),
+ ),
+ saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => {
+ registrationRecordStore.current = record;
+ return Promise.resolve();
+ }),
+ clearAgentAwarenessRegistrationRecord: vi.fn(() => {
+ registrationRecordStore.current = null;
+ return Promise.resolve();
+ }),
}));
function proofIat(proof: string): number {
@@ -176,6 +219,12 @@ describe("makeRelayDeviceRegistrationRequest", () => {
backgroundRuntime.pending.length = 0;
Constants.expoConfig!.extra = {};
__resetAgentAwarenessRemoteRegistrationForTest();
+ appStateMock.listeners.length = 0;
+ registrationRecordStore.current = null;
+ vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
+ vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear();
+ vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear();
+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
});
@@ -213,6 +262,31 @@ describe("makeRelayDeviceRegistrationRequest", () => {
});
});
+ it("registers the app's APNs routing so the relay targets the right bundle", () => {
+ expect(
+ makeRelayDeviceRegistrationRequest({
+ deviceId: "device-1",
+ label: "Julius's iPhone",
+ iosMajorVersion: 18,
+ appVersion: "1.0.0",
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: resolveApsEnvironment("preview"),
+ notificationsEnabled: true,
+ preferences: {},
+ }),
+ ).toMatchObject({
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: "production",
+ });
+ });
+
+ it("routes development builds to the APNs sandbox", () => {
+ expect(resolveApsEnvironment("development")).toBe("sandbox");
+ expect(resolveApsEnvironment("preview")).toBe("production");
+ expect(resolveApsEnvironment("production")).toBe("production");
+ expect(resolveApsEnvironment(undefined)).toBe("production");
+ });
+
it("marks notification delivery disabled when APNs permission is unavailable", () => {
expect(
makeRelayDeviceRegistrationRequest({
@@ -329,6 +403,53 @@ describe("makeRelayDeviceRegistrationRequest", () => {
},
);
+ it.effect(
+ "re-registers active Live Activity tokens when the app returns to the foreground",
+ () => {
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ };
+ widgetMocks.getInstances.mockReturnValue([activity] as never);
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ activity.getPushToken.mockClear();
+
+ expect(appStateMock.listeners).toHaveLength(1);
+ for (const listener of appStateMock.listeners) {
+ listener("background");
+ }
+ yield* runBackgroundOperations();
+ expect(activity.getPushToken).not.toHaveBeenCalled();
+
+ for (const listener of appStateMock.listeners) {
+ listener("active");
+ }
+ yield* runBackgroundOperations();
+ expect(activity.getPushToken).toHaveBeenCalled();
+ }).pipe(Effect.provide(relayTestLayer));
+ },
+ );
+
+ it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
+ const end = vi.fn(() => Promise.resolve());
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ end,
+ };
+ widgetMocks.getInstances.mockReturnValue([activity] as never);
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ expect(appStateMock.listeners).toHaveLength(1);
+
+ setAgentAwarenessRelayTokenProvider(null);
+
+ expect(end).toHaveBeenCalledWith("immediate");
+ expect(appStateMock.listeners).toHaveLength(0);
+ });
+
it.effect("refreshes APNs registration for connected environments after settings changes", () => {
registerAgentAwarenessConnection(savedConnection());
return Effect.gen(function* () {
@@ -395,6 +516,219 @@ describe("makeRelayDeviceRegistrationRequest", () => {
nowEpochSeconds: proofIat(dpop),
}),
).toMatchObject({ ok: true });
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("marks registration failed when device registration cannot complete", () => {
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
+ new Error("registration failed"),
+ );
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ // Drive the registration directly so the assertion does not depend on the
+ // background queue draining; refreshAgentAwarenessRegistration swallows the
+ // error but must record the failed status so the settings toggles cannot
+ // read as enabled.
+ yield* refreshAgentAwarenessRegistration();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it("clears registration status on cloud sign-out", () => {
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ setAgentAwarenessRelayTokenProvider(null);
+ expect(getAgentAwarenessRegistrationStatus()).toBe("unknown");
+ expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled();
+ });
+
+ it("releases the provider without ending activities or clearing the registration", () => {
+ const end = vi.fn(() => Promise.resolve());
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ end,
+ };
+ widgetMocks.getInstances.mockReturnValue([activity] as never);
+ registrationRecordStore.current = { identity: "", signature: "sig" };
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ expect(appStateMock.listeners).toHaveLength(1);
+
+ releaseAgentAwarenessRelayTokenProvider();
+
+ expect(appStateMock.listeners).toHaveLength(0);
+ expect(end).not.toHaveBeenCalled();
+ expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
+ expect(registrationRecordStore.current).not.toBeNull();
+ });
+
+ it.effect("resets a pending status to unknown when relay config is missing", () => {
+ // No relay url configured: registration can neither run nor ever succeed,
+ // so the status must not stick at "pending".
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("unknown");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("keeps a registered status when a later refresh fails", () => {
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+
+ // The relay still holds the accepted registration; a transient refresh
+ // failure must not flip the settings toggles off.
+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
+ new Error("transient failure"),
+ );
+ yield* refreshAgentAwarenessRegistration();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("does not re-register the same account when nothing has changed", () => {
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* refreshAgentAwarenessRegistration();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
+ expect(registrationRecordStore.current).not.toBeNull();
+
+ // Second attempt with an identical payload must skip the relay entirely,
+ // so no new registration record is written.
+ vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
+ yield* refreshAgentAwarenessRegistration();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("carries the accepted push-to-start token forward so tokenless refreshes skip", () => {
+ const fetchMock = vi.fn((request: RequestInfo | URL) => {
+ const url = request instanceof Request ? request.url : String(request);
+ return Promise.resolve(
+ Response.json(
+ url.endsWith("/v1/client/dpop-token")
+ ? {
+ access_token: "relay-dpop-token",
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "DPoP",
+ expires_in: 300,
+ scope: "mobile:registration",
+ }
+ : { ok: true },
+ ),
+ );
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+ const pushToStartListener = vi.mocked(addPushToStartTokenListener).mock.calls.at(-1)?.[0];
+ expect(pushToStartListener).toBeDefined();
+ pushToStartListener?.({ activityPushToStartToken: "push-to-start-token" });
+
+ return Effect.gen(function* () {
+ yield* runBackgroundOperations();
+ expect(registrationRecordStore.current?.pushToStartToken).toBe("push-to-start-token");
+
+ // A later refresh has no token event to carry — it must reuse the
+ // persisted token and skip instead of re-posting. (Fetch counts are
+ // unreliable here: the module-level relay layer captures the first
+ // test's fetch, so assert via the record save that only follows a real
+ // relay POST.)
+ vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
+ yield* refreshAgentAwarenessRegistration();
+ expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("dedupes rapid activity-token re-registrations within the replay window", () => {
+ // Fetch counts are unreliable here (the module-level relay layer captures
+ // the first test's fetch), so assert on the flow's own seams: a real
+ // registration attempt loads the device id, a deduped one short-circuits
+ // before it.
+ const fetchMock = vi.fn((request: RequestInfo | URL) => {
+ const url = request instanceof Request ? request.url : String(request);
+ return Promise.resolve(
+ Response.json(
+ url.endsWith("/v1/client/dpop-token")
+ ? {
+ access_token: "relay-dpop-token",
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "DPoP",
+ expires_in: 300,
+ scope: "mobile:registration",
+ }
+ : { ok: true },
+ ),
+ );
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+ const activity = {
+ getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+ addPushTokenListener: vi.fn(),
+ };
+ widgetMocks.getInstances.mockReturnValue([activity] as never);
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ // Drains the sign-in refresh, which registers the activity token.
+ yield* runBackgroundOperations();
+ expect(activity.getPushToken).toHaveBeenCalled();
+
+ // A burst refresh (foreground / connection update seconds later) must
+ // dedupe: it reads the token but never proceeds to a registration
+ // attempt (which would load the device id first).
+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear();
+ yield* refreshActiveLiveActivityRemoteRegistration();
+ expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled();
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
+ it.effect("re-registers when the stored account identity differs", () => {
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+ registrationRecordStore.current = { identity: "someone-else", signature: "stale" };
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* refreshAgentAwarenessRegistration();
+ expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(relayTestLayer));
});
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
index 3281381e0e1..b99e349a363 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -3,7 +3,7 @@ import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
-import { Platform } from "react-native";
+import { AppState, Platform } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import {
type RelayDeviceRegistrationRequest,
@@ -20,13 +20,16 @@ import {
import type { SavedRemoteConnection } from "../../lib/connection";
import { runtime } from "../../lib/runtime";
import {
+ clearAgentAwarenessRegistrationRecord,
loadAgentAwarenessDeviceId,
+ loadAgentAwarenessRegistrationRecord,
loadOrCreateAgentAwarenessDeviceId,
loadPreferences,
+ saveAgentAwarenessRegistrationRecord,
} from "../../lib/storage";
import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
import { resolveCloudPublicConfig } from "../cloud/publicConfig";
-import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
+import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
@@ -58,8 +61,50 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass();
const activityPushTokenListeners = new WeakSet>();
+// Activity tokens the relay recently accepted, by acceptance time. The refresh
+// runs on sign-in, every app foreground, and every environment-connection
+// update, which arrive in bursts and spammed identical registrations. But the
+// registration is not a pure no-op: the relay replays the current aggregate to
+// this device on every accepted registration, and that replay is the
+// foreground reconciliation that repairs drifted or orphaned activities. So
+// dedupe only within a short window — bursts collapse to one request, while a
+// foreground after real time away still triggers a replay. Cleared on
+// sign-out/identity change alongside the device registration state.
+const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
+const registeredActivityPushTokens = new Map();
let pushToStartSubscription: { remove: () => void } | null = null;
let pushTokenSubscription: { remove: () => void } | null = null;
+let appStateSubscription: { remove: () => void } | null = null;
+
+// Whether the relay has actually accepted this device's registration. The
+// notification/Live Activity settings toggles must reflect this rather than
+// only local iOS permission or saved preferences: if the registration request
+// never succeeded, the device cannot receive anything, so the switches must
+// not read as enabled.
+export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed";
+let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
+const registrationStatusListeners = new Set<() => void>();
+
+function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
+ if (registrationStatus === next) {
+ return;
+ }
+ registrationStatus = next;
+ for (const listener of registrationStatusListeners) {
+ listener();
+ }
+}
+
+export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
+ return registrationStatus;
+}
+
+export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void {
+ registrationStatusListeners.add(listener);
+ return () => {
+ registrationStatusListeners.delete(listener);
+ };
+}
let activeLiveActivityRegistrationRetry: ReturnType | null = null;
let relayTokenProvider: (() => Promise) | null = null;
let relayTokenProviderIdentity: string | null = null;
@@ -120,6 +165,7 @@ export function setAgentAwarenessRelayTokenProvider(
deviceRegistrationGeneration++;
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
+ registeredActivityPushTokens.clear();
}
relayTokenProvider = provider;
relayTokenProviderIdentity = provider ? (identity ?? null) : null;
@@ -128,24 +174,62 @@ export function setAgentAwarenessRelayTokenProvider(
pushToStartSubscription = null;
pushTokenSubscription?.remove();
pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
if (activeLiveActivityRegistrationRetry) {
clearTimeout(activeLiveActivityRegistrationRetry);
activeLiveActivityRegistrationRetry = null;
}
+ // Without a signed-in user the relay can no longer update or end these
+ // activities, so they would sit orphaned on the lock screen.
+ endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
+ setRegistrationStatus("unknown");
+ // Sign-out is the only thing that invalidates a stored registration, so the
+ // next sign-in re-registers.
+ void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => {
+ logRegistrationError("clear registration record on sign-out failed", error);
+ });
return;
}
ensurePushToStartListener();
ensurePushTokenListener();
+ ensureAppStateListener();
runRegistrationInBackground(
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
if (isExistingIdentity) {
+ // Same account re-activating (e.g. Clerk token refresh) normally needs no
+ // re-registration — but if the previous attempt never succeeded, this is
+ // the only trigger that will retry it before the next cold start.
+ if (registrationStatus !== "registered") {
+ enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed");
+ }
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
}
+// Detach the provider and native listeners without the destructive sign-out
+// cleanup. For provider teardown while the user is still signed in (e.g. the
+// auth bridge unmounting/remounting), ending lock-screen activities and wiping
+// the persisted registration would be wrong — the relay still holds a valid
+// registration and the next mount reuses it.
+export function releaseAgentAwarenessRelayTokenProvider(): void {
+ relayTokenProvider = null;
+ relayTokenProviderIdentity = null;
+ pushToStartSubscription?.remove();
+ pushToStartSubscription = null;
+ pushTokenSubscription?.remove();
+ pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
+ if (activeLiveActivityRegistrationRetry) {
+ clearTimeout(activeLiveActivityRegistrationRetry);
+ activeLiveActivityRegistrationRetry = null;
+ }
+}
+
function iosMajorVersion(): number {
const version = Platform.Version;
if (typeof version === "number") {
@@ -211,6 +295,28 @@ const relayToken = (
});
});
+// Stable fingerprint of everything the relay stores for this device. When it
+// matches the last accepted registration for the same account, re-registering
+// is a no-op, so a launch that changed nothing skips the request entirely.
+function registrationSignature(body: RelayDeviceRegistrationRequest): string {
+ return [
+ body.deviceId,
+ body.pushToken ?? "",
+ body.pushToStartToken ?? "",
+ body.bundleId ?? "",
+ body.apsEnvironment ?? "",
+ body.appVersion ?? "",
+ body.label,
+ body.iosMajorVersion,
+ body.preferences.notificationsEnabled,
+ body.preferences.liveActivitiesEnabled,
+ body.preferences.notifyOnApproval,
+ body.preferences.notifyOnInput,
+ body.preferences.notifyOnCompletion,
+ body.preferences.notifyOnFailure,
+ ].join("|");
+}
+
function registerDeviceWithRelay(
body: RelayDeviceRegistrationRequest,
expectedGeneration: number,
@@ -223,7 +329,13 @@ function registerDeviceWithRelay(
});
return;
}
- if (!readRelayConfig()) return;
+ const relayConfig = readRelayConfig();
+ if (!relayConfig) {
+ // Nothing is in flight and nothing can succeed until configuration
+ // appears; "pending" would otherwise stick forever.
+ setRegistrationStatus("unknown");
+ return;
+ }
const token = yield* relayToken("read-device-registration-relay-token");
if (expectedGeneration !== deviceRegistrationGeneration) {
logRegistrationDebug("device registration cancelled after auth lookup", {
@@ -234,6 +346,45 @@ function registerDeviceWithRelay(
}
if (!token) {
logRegistrationDebug("relay device registration skipped; user is not signed in");
+ setRegistrationStatus("unknown");
+ return;
+ }
+
+ // Skip the request when this account already registered an identical
+ // payload; the relay upsert would be a no-op. The record is only cleared on
+ // sign-out, so a device stays registered across launches without re-hitting
+ // the relay every time the app opens.
+ const identity = relayTokenProviderIdentity ?? "";
+ const persisted = yield* Effect.tryPromise({
+ try: () => loadAgentAwarenessRegistrationRecord(),
+ catch: (cause) => cause,
+ }).pipe(Effect.orElseSucceed(() => null));
+ if (expectedGeneration !== deviceRegistrationGeneration) {
+ // Signed out while the record loaded — do not resurrect the cleared
+ // record or report the previous account's registration as current.
+ logRegistrationDebug("device registration cancelled after record lookup", {
+ expectedGeneration,
+ currentGeneration: deviceRegistrationGeneration,
+ });
+ return;
+ }
+ // The push-to-start token only rides along on registrations triggered by a
+ // native token event; ones triggered by sign-in or app foreground omit it.
+ // Carry the last accepted token forward so its absence means "unchanged",
+ // not "cleared" — otherwise the signature alternates between the two
+ // trigger shapes and the skip below never fires.
+ const payload =
+ !body.pushToStartToken && persisted?.identity === identity && persisted.pushToStartToken
+ ? { ...body, pushToStartToken: persisted.pushToStartToken }
+ : body;
+ // The relay URL participates so pointing the app at a different relay
+ // invalidates the record and re-registers there.
+ const signature = `${relayConfig.url}|${registrationSignature(payload)}`;
+ if (persisted && persisted.identity === identity && persisted.signature === signature) {
+ setRegistrationStatus("registered");
+ logRegistrationDebug("relay device registration skipped; already registered for account", {
+ expectedGeneration,
+ });
return;
}
@@ -243,8 +394,28 @@ function registerDeviceWithRelay(
});
yield* client.registerDevice({
clerkToken: token,
- payload: body,
+ payload,
});
+ if (expectedGeneration !== deviceRegistrationGeneration) {
+ // Signed out while the request was in flight: the sign-out path already
+ // reset the status and cleared the record for the next account, so a
+ // stale success must not overwrite either.
+ logRegistrationDebug("device registration completed after sign-out; result discarded", {
+ expectedGeneration,
+ currentGeneration: deviceRegistrationGeneration,
+ });
+ return;
+ }
+ setRegistrationStatus("registered");
+ yield* Effect.promise(() =>
+ saveAgentAwarenessRegistrationRecord({
+ identity,
+ signature,
+ ...(payload.pushToStartToken ? { pushToStartToken: payload.pushToStartToken } : {}),
+ }).catch((error: unknown) => {
+ logRegistrationError("persist registration record failed", error);
+ }),
+ );
logRegistrationDebug("relay device registration request completed", {
expectedGeneration,
});
@@ -365,6 +536,9 @@ function startPendingDeviceRegistration(): void {
hasObservedPushToken: next.input.observedPushToken !== undefined,
hasPushToStartToken: next.input.pushToStartToken !== undefined,
});
+ if (registrationStatus !== "registered") {
+ setRegistrationStatus("pending");
+ }
const registration = {
input: next.input,
operation: Promise.resolve(),
@@ -375,6 +549,13 @@ function startPendingDeviceRegistration(): void {
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ // A transient failure on a later refresh (e.g. token rotation) leaves
+ // the prior accepted registration intact on the relay, so an already
+ // registered device stays "registered" rather than flipping the
+ // settings toggles off.
+ if (registrationStatus !== "registered") {
+ setRegistrationStatus("failed");
+ }
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -444,12 +625,15 @@ function registerDevice(
expectedGeneration,
notificationsEnabled: pushTokenRegistration.notificationsEnabled,
});
+ const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim();
yield* registerDeviceWithRelay(
makeRelayDeviceRegistrationRequest({
deviceId,
label: Constants.deviceName?.trim() || "iOS device",
iosMajorVersion: iosMajorVersion(),
appVersion: Constants.expoConfig?.version,
+ ...(bundleId ? { bundleId } : {}),
+ apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant),
...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
notificationsEnabled: pushTokenRegistration.notificationsEnabled,
@@ -498,6 +682,43 @@ function ensurePushTokenListener(): void {
});
}
+// Re-registering activity tokens on foreground makes the relay replay the
+// current aggregate to this device, which updates content that drifted while
+// pushes could not be delivered and ends orphaned activities whose end push
+// never arrived. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid
+// foreground/sign-in bursts collapse to one registration, but returning after
+// real time away still replays.)
+function ensureAppStateListener(): void {
+ if (appStateSubscription || !canRegisterRemoteLiveActivities()) {
+ return;
+ }
+
+ appStateSubscription = AppState.addEventListener("change", (state) => {
+ if (state !== "active") {
+ return;
+ }
+ runRegistrationInBackground(
+ refreshActiveLiveActivityRemoteRegistration(),
+ "active live activity reconciliation after app foreground failed",
+ );
+ });
+}
+
+function endLocalLiveActivities(context: string): void {
+ if (!canRegisterRemoteLiveActivities()) {
+ return;
+ }
+ try {
+ for (const activity of AgentActivity.getInstances()) {
+ activity.end("immediate").catch((error: unknown) => {
+ logRegistrationError(context, error);
+ });
+ }
+ } catch (error) {
+ logRegistrationError(context, error);
+ }
+}
+
export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
if (!canRegisterRemoteLiveActivities()) {
return;
@@ -506,6 +727,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti
environmentConnections.set(connection.environmentId, connection);
ensurePushToStartListener();
ensurePushTokenListener();
+ ensureAppStateListener();
enqueueDeviceRegistration({}, "device registration failed");
runRegistrationInBackground(
refreshActiveLiveActivityRemoteRegistration(),
@@ -527,6 +749,8 @@ export function unregisterAllAgentAwarenessConnections(): void {
pushToStartSubscription = null;
pushTokenSubscription?.remove();
pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
if (activeLiveActivityRegistrationRetry) {
clearTimeout(activeLiveActivityRegistrationRetry);
activeLiveActivityRegistrationRetry = null;
@@ -541,6 +765,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<
return registerDeviceForCurrentUser().pipe(
Effect.catch((error) =>
Effect.sync(() => {
+ // Same rationale as the queued path: a failed refresh does not undo an
+ // already accepted registration.
+ if (registrationStatus !== "registered") {
+ setRegistrationStatus("failed");
+ }
logRegistrationError("device registration refresh failed", error);
}),
),
@@ -553,6 +782,8 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
pushToStartSubscription = null;
pushTokenSubscription?.remove();
pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
if (activeLiveActivityRegistrationRetry) {
clearTimeout(activeLiveActivityRegistrationRetry);
activeLiveActivityRegistrationRetry = null;
@@ -562,6 +793,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
deviceRegistrationGeneration++;
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
+ registrationStatus = "unknown";
+ registrationStatusListeners.clear();
+ registeredActivityPushTokens.clear();
}
export function unregisterAgentAwarenessDeviceForCurrentUser(
@@ -649,6 +883,13 @@ function registerLiveActivityPushTokenValue(input: {
readonly activityPushToken: string;
}): Effect.Effect {
return Effect.gen(function* () {
+ const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken);
+ if (
+ acceptedAt !== undefined &&
+ Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS
+ ) {
+ return true;
+ }
const deviceId = yield* Effect.tryPromise({
try: () => loadOrCreateAgentAwarenessDeviceId(),
catch: (cause) =>
@@ -662,6 +903,7 @@ function registerLiveActivityPushTokenValue(input: {
activityPushToken: input.activityPushToken,
});
if (registered) {
+ registeredActivityPushTokens.set(input.activityPushToken, Date.now());
logRegistrationDebug("live activity push token registered", {
tokenSuffix: input.activityPushToken.slice(-8),
});
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
index c89aeb9249a..4180ae15c5e 100644
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomCommand } from "../../state/use-atom-command";
import {
+ releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
@@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
useEffect(
() => () => {
previousTokenProviderRef.current = null;
- deactivateCloudRelayAccount();
+ // Unmounting is not a sign-out: the user is usually still signed in, so
+ // detach the provider without ending lock-screen activities or wiping the
+ // persisted registration (a remount reuses both).
+ releaseAgentAwarenessRelayTokenProvider();
+ setManagedRelaySession(appAtomRegistry, null);
},
[],
);
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index d4d0a1ab992..2b07b96b3c0 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { SymbolView } from "expo-symbols";
import * as Effect from "effect/Effect";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Alert, Linking, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -20,7 +20,11 @@ import {
import { AppText as Text } from "../../components/AppText";
import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
-import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
+import {
+ getAgentAwarenessRegistrationStatus,
+ refreshAgentAwarenessRegistration,
+ subscribeAgentAwarenessRegistrationStatus,
+} from "../agent-awareness/remoteRegistration";
import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
@@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
+// Reflects whether the relay actually accepted this device's registration.
+// The notification and Live Activity switches are gated on this so they can
+// never read as enabled when the device cannot receive anything (e.g. the
+// registration request timed out).
+function useDeviceRegistered(): boolean {
+ const status = useSyncExternalStore(
+ subscribeAgentAwarenessRegistrationStatus,
+ getAgentAwarenessRegistrationStatus,
+ () => "unknown" as const,
+ );
+ return status === "registered";
+}
+
export function SettingsRouteScreen() {
const navigation = useNavigation();
@@ -113,6 +130,7 @@ function ConfiguredSettingsRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const [notificationStatus, setNotificationStatus] = useState("checking");
const [liveActivityStatus, setLiveActivityStatus] = useState("checking");
+ const deviceRegistered = useDeviceRegistered();
const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
const environmentCount = connections.length;
@@ -182,10 +200,19 @@ function ConfiguredSettingsRouteScreen() {
}
if (result.value.type === "granted") {
setNotificationStatus("enabled");
- Alert.alert(
- "Notifications enabled",
- "Live Activity notifications are enabled for this device.",
- );
+ // Permission alone is not enough: the switch stays off until the relay
+ // registration succeeds, so tell the user the truth about which happened.
+ if (getAgentAwarenessRegistrationStatus() === "registered") {
+ Alert.alert(
+ "Notifications enabled",
+ "Live Activity notifications are enabled for this device.",
+ );
+ } else {
+ Alert.alert(
+ "Couldn't finish enabling notifications",
+ "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.",
+ );
+ }
return;
}
if (result.value.type === "unsupported") {
@@ -271,12 +298,22 @@ function ConfiguredSettingsRouteScreen() {
refreshManagedRelayEnvironments();
setLiveActivityStatus("enabled");
- Alert.alert(
- "Live Activities enabled",
- environmentCount > 0
- ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
- : "Live Activity updates are enabled. Add an environment to start receiving updates.",
- );
+ // The environment link can succeed while this device's own registration
+ // (the push-to-start token the relay needs) has not — don't claim Live
+ // Activities are live until the device is actually registered.
+ if (getAgentAwarenessRegistrationStatus() === "registered") {
+ Alert.alert(
+ "Live Activities enabled",
+ environmentCount > 0
+ ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
+ : "Live Activity updates are enabled. Add an environment to start receiving updates.",
+ );
+ } else {
+ Alert.alert(
+ "Couldn't finish enabling Live Activities",
+ "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.",
+ );
+ }
}, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
const handleDeviceNotificationsChange = useCallback(
@@ -395,7 +432,10 @@ function ConfiguredSettingsRouteScreen() {
icon="bell.badge"
label="Device Notifications"
disabled={notificationStatus === "checking" || notificationStatus === "unsupported"}
- value={notificationStatus === "enabled"}
+ // Only reads as on when this device is actually registered with the
+ // relay; otherwise notifications cannot be delivered regardless of
+ // the local iOS permission.
+ value={notificationStatus === "enabled" && deviceRegistered}
onValueChange={handleDeviceNotificationsChange}
/>
diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts
index 8ed999225e1..57f7359ec43 100644
--- a/apps/mobile/src/lib/storage.ts
+++ b/apps/mobile/src/lib/storage.ts
@@ -13,10 +13,12 @@ import {
const CONNECTIONS_KEY = "t3code.connections";
const PREFERENCES_KEY = "t3code.preferences";
const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
+const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
const MobileStorageKey = Schema.Literals([
CONNECTIONS_KEY,
PREFERENCES_KEY,
AGENT_AWARENESS_DEVICE_ID_KEY,
+ AGENT_AWARENESS_REGISTRATION_KEY,
]);
type MobileStorageKeyValue = typeof MobileStorageKey.Type;
@@ -222,3 +224,46 @@ export async function loadAgentAwarenessDeviceId(): Promise {
const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
return existing?.trim() ? existing : null;
}
+
+export interface AgentAwarenessRegistrationRecord {
+ readonly identity: string;
+ readonly signature: string;
+ // Last push-to-start token the relay accepted. Registrations triggered
+ // without a token event merge it back in so token absence never reads as a
+ // change (which would defeat the register-once skip every launch).
+ readonly pushToStartToken?: string;
+}
+
+// Remembers the account identity and payload signature the relay last accepted
+// so the app does not re-register on every launch while nothing has changed.
+// Cleared only on sign-out.
+export async function loadAgentAwarenessRegistrationRecord(): Promise {
+ const parsed = await readJsonStorageItem(
+ AGENT_AWARENESS_REGISTRATION_KEY,
+ );
+ if (
+ !parsed ||
+ typeof parsed !== "object" ||
+ typeof parsed.identity !== "string" ||
+ typeof parsed.signature !== "string"
+ ) {
+ return null;
+ }
+ return {
+ identity: parsed.identity,
+ signature: parsed.signature,
+ ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken
+ ? { pushToStartToken: parsed.pushToStartToken }
+ : {}),
+ };
+}
+
+export async function saveAgentAwarenessRegistrationRecord(
+ record: AgentAwarenessRegistrationRecord,
+): Promise {
+ await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record);
+}
+
+export async function clearAgentAwarenessRegistrationRecord(): Promise {
+ await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, "");
+}
diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts
index 719e39554fb..fc25b5bf539 100644
--- a/apps/mobile/src/widgets/AgentActivity.test.ts
+++ b/apps/mobile/src/widgets/AgentActivity.test.ts
@@ -2,23 +2,48 @@ import { describe, expect, it, vi } from "vite-plus/test";
vi.mock("@expo/ui/swift-ui", () => ({
HStack: "HStack",
+ Image: "Image",
Spacer: "Spacer",
Text: "Text",
VStack: "VStack",
+ ZStack: "ZStack",
}));
vi.mock("@expo/ui/swift-ui/modifiers", () => ({
font: (value: unknown) => value,
foregroundStyle: (value: unknown) => value,
+ frame: (value: unknown) => value,
+ layoutPriority: (value: unknown) => value,
lineLimit: (value: unknown) => value,
padding: (value: unknown) => value,
+ resizable: (value: unknown) => value,
+ widgetURL: (value: unknown) => ({ widgetURL: value }),
}));
vi.mock("expo-widgets", () => ({
createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })),
}));
-import { AgentActivity, type AgentActivityProps } from "./AgentActivity";
+import {
+ AgentActivity,
+ type AgentActivityProps,
+ type AgentActivityRowProps,
+} from "./AgentActivity";
+
+function makeRow(overrides: Partial): AgentActivityRowProps {
+ return {
+ environmentId: "env-1",
+ threadId: "thread-1",
+ projectTitle: "Project",
+ threadTitle: "Thread",
+ modelTitle: "gpt-5.4",
+ phase: "running",
+ status: "Working",
+ updatedAt: "2026-05-25T13:07:00.000Z",
+ deepLink: "/threads/env-1/thread-1",
+ ...overrides,
+ };
+}
const props = {
title: "T3 Code",
@@ -33,17 +58,162 @@ const environment = {
isLuminanceReduced: false,
} as const;
+const lightEnvironment = {
+ colorScheme: "light",
+ isLuminanceReduced: false,
+} as const;
+
describe("AgentActivity widget layout", () => {
- it("formats its updated-at label without app-runtime helper references", () => {
- expect(JSON.stringify(AgentActivity(props, environment as never))).toContain(
- '"children":["Updated ","1:07"]',
+ it("tints each row by its own phase using the web sidebar's dark palette", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 2,
+ activities: [
+ makeRow({}),
+ makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }),
+ ],
+ },
+ environment as never,
+ );
+ const banner = JSON.stringify(layout.banner);
+ expect(banner).toContain("#7dd3fc"); // sky-300: running
+ expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval
+ });
+
+ it("switches to the web sidebar's light palette when the scheme is light", () => {
+ // macOS (iPhone Mirroring / Mac notification center) renders the activity
+ // on a light background; the dark-material palette is illegible there.
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 2,
+ activities: [
+ makeRow({}),
+ makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }),
+ ],
+ },
+ lightEnvironment as never,
+ );
+ const banner = JSON.stringify(layout.banner);
+ expect(banner).toContain("#0284c7"); // sky-600: running
+ expect(banner).toContain("#d97706"); // amber-600: waiting_for_approval
+ expect(banner).not.toContain("#7dd3fc");
+ expect(banner).not.toContain("#fcd34d");
+ });
+
+ it("orders rows attention-first in the banner", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 2,
+ activities: [
+ makeRow({ threadTitle: "Working thread" }),
+ makeRow({
+ threadId: "thread-2",
+ threadTitle: "Blocked thread",
+ phase: "waiting_for_approval",
+ status: "Approval",
+ }),
+ ],
+ },
+ environment as never,
+ );
+ const banner = JSON.stringify(layout.banner);
+ expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1);
+ expect(banner.indexOf("Blocked thread")).toBeLessThan(banner.indexOf("Working thread"));
+ });
+
+ it("summarizes the attention count in the banner header", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 3,
+ activities: [
+ makeRow({}),
+ makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }),
+ ],
+ },
+ environment as never,
);
- expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel");
+ const banner = JSON.stringify(layout.banner);
+ expect(banner).toContain("3 active agents");
+ expect(banner).toContain("1 needs attention");
});
- it("uses now when the updated-at timestamp is malformed", () => {
+ it("uses the attention tint for the compact presentations when a row needs input", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 2,
+ activities: [
+ makeRow({}),
+ makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }),
+ ],
+ },
+ environment as never,
+ );
+ expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300
+ expect(JSON.stringify(layout.compactTrailing)).toContain("Input");
+ expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc");
+ });
+
+ it("deep links the banner to the row that needs attention", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 2,
+ activities: [
+ makeRow({}),
+ makeRow({
+ threadId: "thread-2",
+ phase: "waiting_for_approval",
+ status: "Approval",
+ deepLink: "/threads/env-1/thread-2",
+ }),
+ ],
+ },
+ environment as never,
+ );
+ expect(JSON.stringify(layout.banner)).toContain(
+ '"widgetURL":"t3code://threads/env-1/thread-2"',
+ );
+ });
+
+ it("deep links the banner to the first row when nothing needs attention", () => {
+ const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never);
+ expect(JSON.stringify(layout.banner)).toContain(
+ '"widgetURL":"t3code://threads/env-1/thread-1"',
+ );
+ });
+
+ it("omits the deep link for unsafe paths and empty aggregates", () => {
+ expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL");
expect(
- JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)),
- ).toContain('"children":["Updated ","now"]');
+ JSON.stringify(
+ AgentActivity(
+ { ...props, activities: [makeRow({ deepLink: "//evil.example" })] },
+ environment as never,
+ ),
+ ),
+ ).not.toContain("widgetURL");
+ });
+
+ it("renders up to five rows in the banner", () => {
+ const layout = AgentActivity(
+ {
+ ...props,
+ activeCount: 6,
+ activities: [1, 2, 3, 4, 5, 6].map((n) =>
+ makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }),
+ ),
+ },
+ environment as never,
+ );
+ const banner = JSON.stringify(layout.banner);
+ for (const visible of [1, 2, 3, 4, 5]) {
+ expect(banner).toContain(`Thread ${visible}`);
+ }
+ expect(banner).not.toContain("Thread 6");
});
});
diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx
index 56ada5f2a02..92e61caaea1 100644
--- a/apps/mobile/src/widgets/AgentActivity.tsx
+++ b/apps/mobile/src/widgets/AgentActivity.tsx
@@ -1,5 +1,15 @@
-import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";
-import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";
+import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
+import type { ComponentProps } from "react";
+import {
+ font,
+ foregroundStyle,
+ frame,
+ layoutPriority,
+ lineLimit,
+ padding,
+ resizable,
+ widgetURL,
+} from "@expo/ui/swift-ui/modifiers";
import {
createLiveActivity,
type LiveActivityComponent,
@@ -37,282 +47,319 @@ export interface AgentActivityProps {
readonly activities: ReadonlyArray;
}
+// This function is serialized into the widget extension's JS bundle, so it
+// must stay self-contained: no references to module-scope helpers, only the
+// imported view/modifier factories.
export function AgentActivity(
props: AgentActivityProps,
environment: LiveActivityEnvironment,
): LiveActivityLayout {
"widget";
- const row0 = props.activities[0];
- const row1 = props.activities[1];
- const row2 = props.activities[2];
- const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt);
- const updatedAtHours24 = Number(updatedAtMatch?.[1]);
- const updatedAtMinutes = updatedAtMatch?.[2];
- const updatedAt =
- Number.isInteger(updatedAtHours24) &&
- updatedAtHours24 >= 0 &&
- updatedAtHours24 <= 23 &&
- updatedAtMinutes
- ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}`
- : "now";
+ // Use SwiftUI's semantic label colors rather than fixed hex keyed off the
+ // device color scheme. A Live Activity banner always renders over a dark
+ // system material regardless of the device's light/dark setting, so
+ // scheme-derived dark text read as unreadable dark-on-dark on the lock
+ // screen. Semantic colors adapt to whatever material the OS places them on:
+ // the dark LA banner and the (light or dark) home-screen widget alike.
+ const primaryForeground = "primary";
+ const secondaryForeground = "secondary";
+
+ // Status tints mirror the web sidebar's pills
+ // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber
+ // for approval, indigo for input, sky for working, emerald for completed.
+ // On iPhone the LA sits on a dark material, but macOS (iPhone Mirroring /
+ // Mac notification center) renders it on a light one — so pick the web
+ // palette's light (-600) or dark (-300) variant off the color scheme.
+ const isLightScheme = environment.colorScheme === "light";
+ const phaseTint = (phase: AgentActivityPhase | undefined): string => {
+ if (environment.isLuminanceReduced) {
+ return secondaryForeground;
+ }
+ switch (phase) {
+ case "waiting_for_approval":
+ return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300
+ case "waiting_for_input":
+ return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300
+ case "failed":
+ return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
+ case "completed":
+ return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300
+ case "starting":
+ case "running":
+ default:
+ return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300
+ }
+ };
+
+ // Order attention-first so whatever needs the user floats to the top of every
+ // presentation, then failures, then in-flight work, then finished/stale.
+ const phasePriority = (phase: AgentActivityPhase): number => {
+ if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
+ if (phase === "failed") return 1;
+ if (phase === "running" || phase === "starting") return 2;
+ return 3;
+ };
+ const ordered = [...props.activities].sort(
+ (a, b) => phasePriority(a.phase) - phasePriority(b.phase),
+ );
+ const row0 = ordered[0];
+ const row1 = ordered[1];
+ const row2 = ordered[2];
+ const row3 = ordered[3];
+ const row4 = ordered[4];
+
+ const attentionRows = props.activities.filter(
+ (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
+ );
+ const attentionRow = attentionRows[0];
+ const failedRow = props.activities.find((row) => row.phase === "failed");
+ const heroRow = attentionRow ?? failedRow ?? row0;
+ const tint = phaseTint(heroRow?.phase);
+ // Headline count leans on the accent when a human is actually blocked.
+ const headerTint = attentionRow
+ ? phaseTint(attentionRow.phase)
+ : failedRow
+ ? phaseTint(failedRow.phase)
+ : tint;
+
+ // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders
+ // the two parts in-line so the attention half can carry the accent color;
+ // `summary` is the short form for tight spots (expanded center, watch card).
+ const agentWord = props.activeCount === 1 ? "agent" : "agents";
+ const agentsLabel = `${props.activeCount} active ${agentWord}`;
+ const attentionSuffix =
+ attentionRows.length > 0
+ ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
+ : "";
+ const summary = attentionSuffix || `${props.activeCount} active`;
+
+ // Any registered scheme variant routes back to this app; taps are delivered
+ // to the widget's containing app, so the prod scheme is safe for all builds.
+ const deepLinkRow = attentionRow ?? row0;
+ const deepLink =
+ deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")
+ ? `t3code://${deepLinkRow.deepLink.slice(1)}`
+ : null;
+
const activeLabel = `${props.activeCount} active`;
- const isLight = environment.colorScheme === "light";
- const primaryForeground = isLight ? "#262626" : "#f5f5f5";
- const secondaryForeground = isLight ? "#525252" : "#a3a3a3";
- const mutedForeground = isLight ? "#737373" : "#8e8e93";
- const tint = environment.isLuminanceReduced
- ? secondaryForeground
- : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input"
- ? "#f97316"
- : row0?.phase === "failed"
- ? "#ef4444"
- : "#14b8a6";
+
+ // A scannable status glyph per phase — reads faster than colored words and
+ // ties the compact / expanded / banner / watch presentations together.
+ type SFName = NonNullable["systemName"]>;
+ const phaseSymbol = (phase: AgentActivityPhase): SFName => {
+ switch (phase) {
+ case "waiting_for_approval":
+ return "exclamationmark.circle.fill";
+ case "waiting_for_input":
+ return "questionmark.circle.fill";
+ case "failed":
+ return "xmark.octagon.fill";
+ case "completed":
+ return "checkmark.circle.fill";
+ case "starting":
+ return "circle.dotted";
+ case "stale":
+ return "clock.arrow.circlepath";
+ case "running":
+ default:
+ return "arrow.triangle.2.circlepath";
+ }
+ };
+
+ // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to
+ // the image; size + tint them through a container the resizable symbol fills.
+ const renderGlyph = (systemName: SFName, size: number, color: string) => (
+
+
+
+ );
+
+ // Single-line row used by every presentation: glyph, title, inline project,
+ // status. The project and status carry layoutPriority(1) so when space runs
+ // out it's the title that truncates, never the (short) project name or the
+ // status label. Single-line keeps rows inside the expanded island's hard
+ // height budget (~160pt) and lets the banner fit more agents.
+ const renderCompactRow = (row: AgentActivityRowProps) => (
+
+
+ {row.threadTitle}
+
+ {/* No layoutPriority and no frame on the project: two bare texts take
+ their ideal width when it fits and shrink proportionally only when it
+ doesn't — so short rows never truncate, and long title + long project
+ truncate together. (A maxWidth frame is greedy and reserved its full
+ width even for short names; layoutPriority let the project starve the
+ title.) */}
+
+ {row.projectTitle}
+
+
+
+ {row.status}
+
+
+ );
+
+ // The branded T3 mark. `assetName` resolves the template image set bundled in
+ // the widget extension's asset catalog. Image views only honor `resizable`
+ // directly (frame/foregroundStyle are dropped), so we size it via a container
+ // frame the resizable image fills and tint it through the container's
+ // foreground style, which the template image inherits. The 3:2 frame matches
+ // the glyph's aspect ratio so it never distorts.
+ const renderLogo = (height: number, color: string) => (
+
+
+
+ );
return {
banner: (
-
-
-
-
- {props.title}
-
+
+ {/* Logo pinned to the leading edge; the status texts centered across the
+ full width (ZStack so the logo doesn't skew the centering). No footer —
+ overflow beyond the visible rows is inferable from the count. */}
+
+
+ {renderLogo(13, primaryForeground)}
+
+
+
+
- {props.subtitle}
+ {agentsLabel}
-
-
-
- {activeLabel}
-
-
- {row0 ? (
-
-
+ {attentionSuffix ? (
+ ·
+ ) : null}
+ {attentionSuffix ? (
- {row0.threadTitle}
-
-
- {row0.projectTitle} - {row0.modelTitle}
-
-
-
-
- {row0.status}
-
-
- ) : null}
- {row1 ? (
-
-
-
- {row1.threadTitle}
-
-
- {row1.projectTitle} - {row1.modelTitle}
-
-
-
-
- {row1.status}
-
-
- ) : null}
- {row2 ? (
-
-
-
- {row2.threadTitle}
-
-
- {row2.projectTitle} - {row2.modelTitle}
+ {attentionSuffix}
-
-
-
- {row2.status}
-
+ ) : null}
+
- ) : null}
-
- Updated {updatedAt}
-
+
+ {row0 ? renderCompactRow(row0) : null}
+ {row1 ? renderCompactRow(row1) : null}
+ {row2 ? renderCompactRow(row2) : null}
+ {row3 ? renderCompactRow(row3) : null}
+ {row4 ? renderCompactRow(row4) : null}
),
+ // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family):
+ // brand + count, then the single most important agent with its status glyph.
bannerSmall: (
-
-
+
+
+ {renderLogo(14, primaryForeground)}
- {props.title}
+ {attentionRows.length > 0 ? summary : activeLabel}
-
- {activeLabel}
-
{row0 ? (
-
+
{row0.threadTitle}
-
- {row0.projectTitle} - {row0.status}
+
+
+ {row0.status}
-
+
) : null}
),
- compactLeading: (
- T3
- ),
+ compactLeading: renderLogo(14, tint),
compactTrailing: (
- {activeLabel}
+ {attentionRow
+ ? attentionRow.phase === "waiting_for_approval"
+ ? "Approval"
+ : "Input"
+ : activeLabel}
),
- minimal: (
- T3
- ),
+ // The shared/minimal form is a ~22pt circle — a single signal reads there,
+ // the wordmark does not. Show the blocking phase glyph, else the mark.
+ minimal:
+ attentionRow || failedRow
+ ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase))
+ : renderLogo(11, tint),
expandedLeading: (
-
-
- {activeLabel}
-
-
- ),
- expandedCenter: row0 ? (
-
-
- {row0.threadTitle}
+
+ {renderLogo(15, tint)}
+
+ {`${props.activeCount}`}
-
- {row0.projectTitle} - {row0.status}
-
-
- ) : null,
- expandedTrailing: (
-
- Updated {updatedAt}
-
+
),
+ // No center content: the phase glyphs + statuses in expandedBottom already
+ // carry the attention signal, and the expanded island's height budget is
+ // tight enough that a summary line there pushed the third row off.
+ expandedCenter: null,
+ // No trailing content: a timestamp is glanceable-lock-screen info, not
+ // useful in a view the user is actively holding open — and the trailing
+ // region hugs the island's corner radius, which clipped it anyway.
+ expandedTrailing: null,
expandedBottom: (
-
- {row0 ? (
-
-
-
- {row0.threadTitle}
-
-
- {row0.projectTitle} - {row0.modelTitle}
-
-
-
-
- {row0.status}
-
-
- ) : null}
- {row1 ? (
-
-
-
- {row1.threadTitle}
-
-
- {row1.projectTitle} - {row1.modelTitle}
-
-
-
-
- {row1.status}
-
-
- ) : null}
- {row2 ? (
-
-
-
- {row2.threadTitle}
-
-
- {row2.projectTitle} - {row2.modelTitle}
-
-
-
-
- {row2.status}
-
-
- ) : null}
+ // Vertical padding only: the expanded region provides its own horizontal
+ // content margins, so `all` padding double-indented the rows.
+ // Horizontal padding keeps both edges clear of the island's corner
+ // curvature (right edge clipped status labels; titles hugged the left).
+
+ {row0 ? renderCompactRow(row0) : null}
+ {row1 ? renderCompactRow(row1) : null}
+ {row2 ? renderCompactRow(row2) : null}
),
};
diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts
index 70b0329ac90..f01c93f0538 100644
--- a/apps/server/src/cli/connect.test.ts
+++ b/apps/server/src/cli/connect.test.ts
@@ -8,7 +8,11 @@ import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as References from "effect/References";
-import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts";
+import {
+ acquireRelayClientForLink,
+ isPublishAgentActivityEnabledValue,
+ reportCloudDisconnectResults,
+} from "./connect.ts";
const managedExecutable = {
status: "available",
@@ -153,3 +157,10 @@ it.effect("keeps disconnect causes in structured logs and out of console warning
),
);
});
+
+it("treats only the literal 'true' as publish-enabled", () => {
+ assert.equal(isPublishAgentActivityEnabledValue("true"), true);
+ assert.equal(isPublishAgentActivityEnabledValue("false"), false);
+ assert.equal(isPublishAgentActivityEnabledValue(null), false);
+ assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false);
+});
diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts
index 3ce53391fa6..d09d0814222 100644
--- a/apps/server/src/cli/connect.ts
+++ b/apps/server/src/cli/connect.ts
@@ -29,7 +29,11 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as CliState from "../cloud/CliState.ts";
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
-import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
+import {
+ CLOUD_LINKED_USER_ID,
+ PUBLISH_AGENT_ACTIVITY_SECRET,
+ RELAY_URL_SECRET,
+} from "../cloud/config.ts";
import { relayUrlConfig } from "../cloud/publicConfig.ts";
import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
import * as ServerConfig from "../config.ts";
@@ -46,12 +50,21 @@ function bytesToString(value: Uint8Array): string {
return new TextDecoder().decode(value);
}
+function stringToBytes(value: string): Uint8Array {
+ return new TextEncoder().encode(value);
+}
+
+export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
+ return value === "true";
+}
+
interface CloudCliStatus {
readonly desired: boolean;
readonly authenticated: boolean;
readonly linked: boolean;
readonly cloudUserId: string | null;
readonly relayUrl: string | null;
+ readonly publishAgentActivity: boolean;
readonly relayClient: RelayClient.RelayClientStatus;
}
@@ -104,6 +117,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
` Authorization: ${status.authenticated ? "stored credential" : "missing"}`,
` Environment link: ${provisioned}`,
` Relay: ${status.relayUrl ?? "not provisioned"}`,
+ ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
...formatRelayClientStatus(status.relayClient),
...(nextStep ? ["", `Next: ${nextStep}`] : []),
].join("\n");
@@ -368,31 +382,52 @@ const connectLoginCommand = Command.make("login", {
const connectLinkCommand = Command.make("link", {
...projectLocationFlags,
+ publishOnly: Flag.boolean("publish-only").pipe(
+ Flag.withDescription(
+ "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
+ ),
+ Flag.withDefault(false),
+ ),
}).pipe(
Command.withDescription("Authorize this environment for T3 Connect on next start."),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Effect.gen(function* () {
- const relayClient = yield* RelayClient.RelayClient;
- const installed = yield* acquireRelayClientForLink(
- relayClient,
- confirmRelayClientInstall,
- reportRelayClientInstallProgress,
- );
- if (Option.isNone(installed)) {
- yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
- return;
+ // A publish-only link needs no Cloudflare tunnel, so skip installing the
+ // relay client entirely.
+ if (!flags.publishOnly) {
+ const relayClient = yield* RelayClient.RelayClient;
+ const installed = yield* acquireRelayClientForLink(
+ relayClient,
+ confirmRelayClientInstall,
+ reportRelayClientInstallProgress,
+ );
+ if (Option.isNone(installed)) {
+ yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
+ return;
+ }
+ yield* Console.log(
+ `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
+ );
}
- yield* Console.log(
- `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
- );
const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.get;
- yield* CliState.setCliDesiredCloudLink(true);
+ yield* CliState.setCliDesiredCloudLink(
+ true,
+ flags.publishOnly ? "publish_only" : "managed",
+ );
+ if (flags.publishOnly) {
+ // A publish-only link exists solely to publish; without the publish
+ // flag the link would be inert and the success message a lie.
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
+ }
yield* Console.log(
- "This T3 environment will be available through T3 Connect the next time T3 starts.",
+ flags.publishOnly
+ ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
+ : "This T3 environment will be available through T3 Connect the next time T3 starts.",
);
}),
),
@@ -411,22 +446,27 @@ const connectStatusCommand = Command.make("status", {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const relayClient = yield* RelayClient.RelayClient;
const tokens = yield* CliTokenManager.CloudCliTokenManager;
- const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all(
- [
- CliState.readCliDesiredCloudLink,
- tokens.hasCredential,
- secrets.get(CLOUD_LINKED_USER_ID),
- secrets.get(RELAY_URL_SECRET),
- relayClient.resolve,
- ],
- { concurrency: "unbounded" },
- );
+ const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] =
+ yield* Effect.all(
+ [
+ CliState.readCliDesiredCloudLink,
+ tokens.hasCredential,
+ secrets.get(CLOUD_LINKED_USER_ID),
+ secrets.get(RELAY_URL_SECRET),
+ secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
+ relayClient.resolve,
+ ],
+ { concurrency: "unbounded" },
+ );
const status: CloudCliStatus = {
desired,
authenticated,
linked: Option.isSome(cloudUserId),
cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
+ publishAgentActivity: isPublishAgentActivityEnabledValue(
+ Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null,
+ ),
relayClient: executable,
};
yield* Console.log(formatCloudStatus(status, { json: flags.json }));
@@ -438,6 +478,76 @@ const connectStatusCommand = Command.make("status", {
),
);
+const connectPublishCommand = Command.make("publish", {
+ ...projectLocationFlags,
+ disable: Flag.boolean("disable").pipe(
+ Flag.withDescription("Stop publishing agent activity to your mobile clients."),
+ Flag.withDefault(false),
+ ),
+}).pipe(
+ Command.withDescription(
+ "Toggle publishing agent activity (push notifications and Live Activities) to your mobile clients.",
+ ),
+ Command.withHandler((flags) =>
+ runCloudCommand(
+ flags,
+ Effect.gen(function* () {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const tokens = yield* CliTokenManager.CloudCliTokenManager;
+ const enabled = !flags.disable;
+ yield* secrets.set(
+ PUBLISH_AGENT_ACTIVITY_SECRET,
+ stringToBytes(enabled ? "true" : "false"),
+ );
+ if (!enabled) {
+ // If enabling scheduled a publish-only link that hasn't been
+ // provisioned yet, disabling must cancel it too — otherwise the next
+ // start still links an environment whose only purpose was publishing.
+ // A pending managed link is left alone; it exists for the tunnel.
+ const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
+ if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") {
+ yield* CliState.setCliDesiredCloudLink(false);
+ yield* Console.log("Cancelled the pending publish-only T3 Connect link.");
+ }
+ yield* Console.log("Publishing agent activity disabled.");
+ return;
+ }
+
+ yield* Console.log("Publishing agent activity enabled.");
+ const linked = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID));
+ if (linked) {
+ return;
+ }
+
+ // Publishing needs the relay to know this environment belongs to you.
+ // Establish a tunnel-free publish-only link automatically so signing in
+ // is all it takes — the mobile client can still reach the environment
+ // out of band without T3 Connect.
+ if (!(yield* tokens.hasCredential)) {
+ yield* Console.log(
+ "Run `t3 connect login` first so this environment can be authorized to publish.",
+ );
+ return;
+ }
+ // A link may already be desired (e.g. `t3 connect link` before the
+ // server's first start). Never downgrade it: a desired managed link
+ // also covers publishing, so only request a publish-only link when no
+ // link is pending at all.
+ if (yield* CliState.readCliDesiredCloudLink) {
+ yield* Console.log(
+ "A T3 Connect link is already pending. Start T3 to finish provisioning it; publishing starts once it links.",
+ );
+ return;
+ }
+ yield* CliState.setCliDesiredCloudLink(true, "publish_only");
+ yield* Console.log(
+ "Restart T3 to finish authorizing this environment to publish (no managed tunnel is created).",
+ );
+ }),
+ ),
+ ),
+);
+
const connectUnlinkCommand = Command.make("unlink", {
...projectLocationFlags,
}).pipe(
@@ -461,6 +571,7 @@ export const connectCommand = Command.make("connect").pipe(
Command.withSubcommands([
connectLoginCommand,
connectLinkCommand,
+ connectPublishCommand,
connectStatusCommand,
connectUnlinkCommand,
connectLogoutCommand,
diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts
index 3fbf4f12db2..39f904b47b8 100644
--- a/apps/server/src/cloud/CliState.test.ts
+++ b/apps/server/src/cloud/CliState.test.ts
@@ -56,4 +56,27 @@ it.layer(NodeServices.layer)("CliState", (it) => {
}
}).pipe(Effect.provide(makeTestLayer())),
);
+
+ it.effect("round-trips the desired link mode and defaults legacy links to managed", () =>
+ Effect.gen(function* () {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+
+ assert.equal(yield* CliState.readCliDesiredLinkMode, "managed");
+
+ yield* CliState.setCliDesiredCloudLink(true, "publish_only");
+ assert.isTrue(yield* CliState.readCliDesiredCloudLink);
+ assert.equal(yield* CliState.readCliDesiredLinkMode, "publish_only");
+
+ yield* CliState.setCliDesiredCloudLink(true, "managed");
+ assert.equal(yield* CliState.readCliDesiredLinkMode, "managed");
+
+ // A pre-existing link persisted the literal "true"; treat it as managed.
+ yield* secrets.set(CliState.CLOUD_CLI_DESIRED_LINK_SECRET, new TextEncoder().encode("true"));
+ assert.isTrue(yield* CliState.readCliDesiredCloudLink);
+ assert.equal(yield* CliState.readCliDesiredLinkMode, "managed");
+
+ yield* CliState.setCliDesiredCloudLink(false);
+ assert.equal(yield* CliState.readCliDesiredLinkMode, "managed");
+ }).pipe(Effect.provide(makeTestLayer())),
+ );
});
diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts
index 2e18fff4250..9af9a032f85 100644
--- a/apps/server/src/cloud/CliState.ts
+++ b/apps/server/src/cloud/CliState.ts
@@ -14,19 +14,43 @@ import {
export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link";
-const TRUE_BYTES = new TextEncoder().encode("true");
+// "managed" provisions a Cloudflare tunnel (default, legacy value "true").
+// "publish_only" links the environment to the relay purely to publish agent
+// activity — no tunnel, no relay-advertised endpoint — so activity can flow to
+// mobile clients even when they reach the environment out of band (Tailscale,
+// direct pairing) without T3 Connect.
+export type CliDesiredLinkMode = "managed" | "publish_only";
+
+const MANAGED_BYTES = new TextEncoder().encode("managed");
+const PUBLISH_ONLY_BYTES = new TextEncoder().encode("publish_only");
export const readCliDesiredCloudLink = Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET));
});
+export const readCliDesiredLinkMode = Effect.gen(function* () {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const value = yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET);
+ if (Option.isNone(value)) {
+ return "managed" as CliDesiredLinkMode;
+ }
+ // Legacy links stored the literal "true" and are always managed.
+ return new TextDecoder().decode(value.value) === "publish_only"
+ ? ("publish_only" as CliDesiredLinkMode)
+ : ("managed" as CliDesiredLinkMode);
+});
+
export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* (
desired: boolean,
+ mode: CliDesiredLinkMode = "managed",
) {
const secrets = yield* ServerSecretStore.ServerSecretStore;
if (desired) {
- yield* secrets.set(CLOUD_CLI_DESIRED_LINK_SECRET, TRUE_BYTES);
+ yield* secrets.set(
+ CLOUD_CLI_DESIRED_LINK_SECRET,
+ mode === "publish_only" ? PUBLISH_ONLY_BYTES : MANAGED_BYTES,
+ );
} else {
yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET);
}
diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts
index ed2e5a4cf75..bab9cdd27f3 100644
--- a/apps/server/src/cloud/http.test.ts
+++ b/apps/server/src/cloud/http.test.ts
@@ -11,7 +11,13 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
-import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
+import type { RelayLinkProofRequest } from "@t3tools/contracts/relay";
+import {
+ consumeCloudReplayGuards,
+ isSupportedLinkProviderKind,
+ linkProofScopes,
+ reconcileDesiredCloudLink,
+} from "./http.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
@@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => {
),
);
});
+
+describe("link proof provider kinds", () => {
+ const proofRequest = (
+ providerKind: RelayLinkProofRequest["endpoint"]["providerKind"],
+ ): RelayLinkProofRequest => ({
+ challenge: "challenge",
+ relayIssuer: "https://relay.example.test",
+ endpoint: {
+ httpBaseUrl: "http://127.0.0.1:7331",
+ wsBaseUrl: "ws://127.0.0.1:7331",
+ providerKind,
+ },
+ origin: { localHttpHost: "127.0.0.1", localHttpPort: 7331 },
+ });
+
+ it("accepts managed and manual endpoints but not t3_relay", () => {
+ expect(isSupportedLinkProviderKind(proofRequest("cloudflare_tunnel"))).toBe(true);
+ expect(isSupportedLinkProviderKind(proofRequest("manual"))).toBe(true);
+ expect(isSupportedLinkProviderKind(proofRequest("t3_relay"))).toBe(false);
+ });
+
+ it("only claims the managed-tunnel scope for tunnel links", () => {
+ expect(linkProofScopes(proofRequest("cloudflare_tunnel"))).toEqual([
+ "agent_activity_notifications",
+ "managed_tunnels",
+ ]);
+ expect(linkProofScopes(proofRequest("manual"))).toEqual(["agent_activity_notifications"]);
+ });
+});
diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts
index fc2adca9fbc..92423bb215e 100644
--- a/apps/server/src/cloud/http.ts
+++ b/apps/server/src/cloud/http.ts
@@ -68,7 +68,7 @@ import {
RELAY_URL_SECRET,
} from "./config.ts";
import { relayUrlConfig } from "./publicConfig.ts";
-import { setCliDesiredCloudLink } from "./CliState.ts";
+import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
import { traceRelayRequest } from "./traceRelayRequest.ts";
@@ -299,8 +299,23 @@ function isAllowedEndpointOrigin(input: {
return input.origin.localHttpPort === endpointRequestPort(url);
}
-function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean {
- return request.endpoint.providerKind === "cloudflare_tunnel";
+// A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must
+// point at a loopback origin. A manual endpoint is reached out of band (e.g.
+// Tailscale) or not advertised at all for publish-only links, so it is not
+// tied to the managed-tunnel scope.
+export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean {
+ return (
+ request.endpoint.providerKind === "cloudflare_tunnel" ||
+ request.endpoint.providerKind === "manual"
+ );
+}
+
+export function linkProofScopes(
+ request: RelayLinkProofRequest,
+): RelayEnvironmentLinkProofPayload["scopes"] {
+ return request.endpoint.providerKind === "cloudflare_tunnel"
+ ? ["agent_activity_notifications", "managed_tunnels"]
+ : ["agent_activity_notifications"];
}
function hasExactScope(input: {
@@ -352,7 +367,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function
) {
const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets);
if (
- !providerKindMatchesRequestedLinkScopes(request) ||
+ !isSupportedLinkProviderKind(request) ||
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
@@ -379,7 +394,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function
environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey),
endpoint: request.endpoint,
origin: request.origin,
- scopes: ["agent_activity_notifications", "managed_tunnels"],
+ scopes: linkProofScopes(request),
} satisfies RelayEnvironmentLinkProofPayload;
return yield* signRelayJwt({
privateKey: keyPair.privateKey,
@@ -537,6 +552,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
}),
),
);
+ const mode = yield* readCliDesiredLinkMode;
+ const managedTunnelsEnabled = mode !== "publish_only";
const relayUrl = yield* requireRelayUrl;
const challenge = yield* relayClientRequest(dependencies, {
url: `${relayUrl}/v1/client/environment-link-challenges`,
@@ -544,7 +561,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
payload: {
notificationsEnabled: true,
liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
+ managedTunnelsEnabled,
},
schema: RelayEnvironmentLinkChallengeResponse,
});
@@ -556,7 +573,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
endpoint: {
httpBaseUrl: localOrigin,
wsBaseUrl: localWsOrigin,
- providerKind: "cloudflare_tunnel",
+ providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual",
},
origin: {
localHttpHost: localUrl.hostname,
@@ -572,11 +589,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
proof,
notificationsEnabled: true,
liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
+ managedTunnelsEnabled,
},
schema: RelayEnvironmentLinkResponse,
});
- yield* setCliDesiredCloudLink(true);
+ yield* setCliDesiredCloudLink(true, mode);
return yield* applyCloudRelayConfig(dependencies, {
relayUrl,
relayIssuer: link.relayIssuer,
@@ -608,20 +625,25 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
dependencies: CloudHttpDependencies,
) {
- const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all(
- [
- dependencies.secrets.get(CLOUD_LINKED_USER_ID),
- dependencies.secrets.get(RELAY_URL_SECRET),
- dependencies.secrets.get(RELAY_ISSUER_SECRET),
- dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
- ],
- { concurrency: 4 },
- );
+ const [cloudUserId, relayUrl, relayIssuer, endpointRuntimeConfig, publishAgentActivity] =
+ yield* Effect.all(
+ [
+ dependencies.secrets.get(CLOUD_LINKED_USER_ID),
+ dependencies.secrets.get(RELAY_URL_SECRET),
+ dependencies.secrets.get(RELAY_ISSUER_SECRET),
+ dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
+ dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
+ ],
+ { concurrency: 5 },
+ );
return {
linked: Option.isSome(cloudUserId),
cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null,
relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null,
+ // The managed tunnel runtime config is only stored for managed links; a
+ // publish-only link leaves it absent.
+ managedTunnelActive: Option.isSome(endpointRuntimeConfig),
publishAgentActivity: Option.isSome(publishAgentActivity)
? bytesToString(publishAgentActivity.value) === "true"
: false,
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 6a776afe48d..347c2920792 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -1700,7 +1700,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
- it.effect("rejects managed cloud link proofs for manual endpoint providers", () =>
+ it.effect("rejects cloud link proofs for unsupported endpoint providers", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
@@ -1721,7 +1721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
wsBaseUrl: linkProofUrl
.replace("http://", "ws://")
.replace("/api/connect/link-proof", "/ws"),
- providerKind: "manual",
+ // "manual" and "cloudflare_tunnel" are supported; "t3_relay" is not.
+ providerKind: "t3_relay",
},
origin: {
localHttpHost: "127.0.0.1",
diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts
index 7e6f2365e50..38e205beabb 100644
--- a/apps/web/src/cloud/linkEnvironment.test.ts
+++ b/apps/web/src/cloud/linkEnvironment.test.ts
@@ -204,6 +204,7 @@ describe("web cloud link environment client", () => {
cloudUserId: "user-1",
relayUrl: "https://relay.example.test",
relayIssuer: "https://relay.example.test",
+ managedTunnelActive: true,
publishAgentActivity: false,
}),
);
@@ -217,6 +218,7 @@ describe("web cloud link environment client", () => {
cloudUserId: "user-1",
relayUrl: "https://relay.example.test",
relayIssuer: "https://relay.example.test",
+ managedTunnelActive: true,
publishAgentActivity: false,
}),
);
@@ -234,6 +236,7 @@ describe("web cloud link environment client", () => {
cloudUserId: "user-1",
relayUrl: "https://relay.example.test",
relayIssuer: "https://relay.example.test",
+ managedTunnelActive: true,
publishAgentActivity: false,
}),
);
@@ -261,6 +264,7 @@ describe("web cloud link environment client", () => {
cloudUserId: "user-1",
relayUrl: "https://relay.example.test",
relayIssuer: "https://relay.example.test",
+ managedTunnelActive: true,
publishAgentActivity: true,
}),
);
@@ -339,6 +343,57 @@ describe("web cloud link environment client", () => {
}),
);
+ it.effect("links publish-only without a managed tunnel", () =>
+ Effect.gen(function* () {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ Response.json({
+ challenge: "challenge",
+ expiresAt: "2026-06-06T00:05:00.000Z",
+ }),
+ )
+ .mockResolvedValueOnce(Response.json("signed-proof"))
+ .mockResolvedValueOnce(
+ Response.json({
+ ok: true,
+ environmentId: TARGET.environmentId,
+ endpoint: {
+ httpBaseUrl: TARGET.httpBaseUrl,
+ wsBaseUrl: TARGET.wsBaseUrl,
+ providerKind: "manual",
+ },
+ endpointRuntime: null,
+ relayIssuer: "https://relay.example.test",
+ cloudUserId: "user-1",
+ environmentCredential: "environment-credential",
+ cloudMintPublicKey: "public-key",
+ }),
+ )
+ .mockResolvedValueOnce(
+ Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ yield* withServices(
+ linkPrimaryEnvironmentToCloud({
+ target: TARGET,
+ clerkToken: "clerk-token",
+ mode: "publish_only",
+ }),
+ );
+
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
+ managedTunnelsEnabled: false,
+ });
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
+ endpoint: { providerKind: "manual" },
+ });
+ }),
+ );
+
it.effect("installs a missing relay client before linking", () =>
Effect.gen(function* () {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true })));
diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts
index 20bf75c7d6d..ec25fd104d1 100644
--- a/apps/web/src/cloud/linkEnvironment.ts
+++ b/apps/web/src/cloud/linkEnvironment.ts
@@ -383,9 +383,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
}).pipe(Effect.provide(primaryEnvironmentHttpLayer));
}
+// "publish_only" links the environment to the relay for agent-activity
+// publishing alone: no managed tunnel is provisioned, so it can be toggled
+// independently of T3 Connect while clients reach the environment out of band.
+export type CloudLinkMode = "managed" | "publish_only";
+
+const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind;
+
export function linkPrimaryEnvironmentToCloud(input: {
readonly target: CloudLinkTarget;
readonly clerkToken: string;
+ readonly mode?: CloudLinkMode;
}): Effect.Effect<
void,
CloudEnvironmentLinkError,
@@ -398,9 +406,15 @@ export function linkPrimaryEnvironmentToCloud(input: {
message: "T3CODE_RELAY_URL is not configured.",
});
}
+ const managedTunnelsEnabled = (input.mode ?? "managed") === "managed";
+ const providerKind = managedTunnelsEnabled
+ ? MANAGED_ENDPOINT_PROVIDER_KIND
+ : PUBLISH_ONLY_PROVIDER_KIND;
const relayClient = yield* ManagedRelay.ManagedRelayClient;
const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
- yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
+ if (managedTunnelsEnabled) {
+ yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
+ }
const challenge = yield* relayClient
.createEnvironmentLinkChallenge({
@@ -408,7 +422,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
payload: {
notificationsEnabled: true,
liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
+ managedTunnelsEnabled,
},
})
.pipe(
@@ -427,7 +441,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
endpoint: {
httpBaseUrl: input.target.httpBaseUrl,
wsBaseUrl: input.target.wsBaseUrl,
- providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
+ providerKind,
},
origin: endpointOrigin(input.target.httpBaseUrl),
},
@@ -440,7 +454,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
proof,
notificationsEnabled: true,
liveActivitiesEnabled: true,
- managedTunnelsEnabled: true,
+ managedTunnelsEnabled,
},
})
.pipe(
@@ -450,7 +464,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
);
yield* ensureLinkedEnvironmentMatches({
expectedEnvironmentId: input.target.environmentId,
- expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
+ expectedProviderKind: providerKind,
link,
});
diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts
index 4cb62271a48..1094e860e46 100644
--- a/apps/web/src/cloud/linkEnvironmentAtoms.ts
+++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts
@@ -6,6 +6,7 @@ import {
import { connectionAtomRuntime } from "../connection/runtime";
import {
linkPrimaryEnvironmentToCloud,
+ type CloudLinkMode,
type CloudLinkTarget,
unlinkPrimaryEnvironmentFromCloud,
updatePrimaryCloudPreferences,
@@ -21,8 +22,11 @@ export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime
label: "web:cloud:link-primary-environment",
scheduler: cloudLinkScheduler,
concurrency: cloudLinkConcurrency,
- execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) =>
- linkPrimaryEnvironmentToCloud(input),
+ execute: (input: {
+ readonly target: CloudLinkTarget;
+ readonly clerkToken: string;
+ readonly mode?: CloudLinkMode;
+ }) => linkPrimaryEnvironmentToCloud(input),
});
export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, {
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index d0fb12f6153..f058a1cc61c 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -1602,15 +1602,17 @@ function CloudLinkSwitch({
disabled,
disabledReason,
onCheckedChange,
+ ariaLabel = "Enable T3 Connect",
}: {
readonly checked: boolean;
readonly disabled: boolean;
readonly disabledReason: string | null;
readonly onCheckedChange?: (enabled: boolean) => void;
+ readonly ariaLabel?: string;
}) {
const control = (
{
- setIsUpdating(true);
+ // Older environment servers predate the managedTunnelActive field; for them a
+ // link always implies a managed tunnel, so fall back to `linked`.
+ const managedTunnelActive =
+ primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
+ const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
+ const linked = primaryCloudLinkState.data?.linked ?? false;
+ const disabledReason = !isSignedIn
+ ? "Sign in to T3 Connect to manage this environment."
+ : !canManageRelay
+ ? "Your session does not have permission to manage T3 Connect access."
+ : null;
+ const isBusy = isUpdating || isUpdatingPreference;
+
+ // T3 Connect (managed tunnel) and publishing are independent capabilities
+ // backed by a single relay link. Reconcile the whole desired state: unlink
+ // when neither is wanted, otherwise (re)link with the mode the managed-tunnel
+ // bit implies and set the publish preference. Re-linking only happens when the
+ // managed-tunnel mode actually changes, so flipping publish alone is cheap.
+ const reconcileCloudState = async (desired: {
+ readonly managedTunnel: boolean;
+ readonly publish: boolean;
+ }): Promise => {
setOperationError(null);
- const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
- if (tokenResult._tag === "Failure") {
- reportUpdateFailure(squashAtomCommandFailure(tokenResult));
- setIsUpdating(false);
- return;
- }
-
const target = primaryCloudLinkState.target;
if (!target) {
reportUpdateFailure(new Error("Local environment is not ready yet."));
- setIsUpdating(false);
- return;
+ return false;
}
- if (enabled && !tokenResult.value) {
- reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment."));
- setIsUpdating(false);
- return;
+ const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
+ if (tokenResult._tag === "Failure") {
+ reportUpdateFailure(squashAtomCommandFailure(tokenResult));
+ return false;
}
-
- const linkResult =
- enabled && tokenResult.value
- ? await linkPrimaryEnvironment({
- target,
- clerkToken: tokenResult.value,
- })
- : await unlinkPrimaryEnvironment({
- target,
- clerkToken: tokenResult.value ?? null,
- });
- if (linkResult._tag === "Failure") {
- if (!isAtomCommandInterrupted(linkResult)) {
- reportUpdateFailure(squashAtomCommandFailure(linkResult));
+ const clerkToken = tokenResult.value;
+ const wantsLink = desired.managedTunnel || desired.publish;
+
+ // A failure after this point may follow a partially applied mutation (e.g.
+ // the link succeeded but the preference update did not), so every exit —
+ // success or failure — refreshes the rendered state to whatever the server
+ // actually holds now.
+ if (!wantsLink) {
+ const unlinkResult = await unlinkPrimaryEnvironment({
+ target,
+ clerkToken: clerkToken ?? null,
+ });
+ if (unlinkResult._tag === "Failure") {
+ if (!isAtomCommandInterrupted(unlinkResult)) {
+ reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
+ }
+ primaryCloudLinkState.refresh();
+ return false;
+ }
+ } else {
+ if (!clerkToken) {
+ reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
+ return false;
+ }
+ if (!linked || managedTunnelActive !== desired.managedTunnel) {
+ const linkResult = await linkPrimaryEnvironment({
+ target,
+ clerkToken,
+ mode: desired.managedTunnel ? "managed" : "publish_only",
+ });
+ if (linkResult._tag === "Failure") {
+ if (!isAtomCommandInterrupted(linkResult)) {
+ reportUpdateFailure(squashAtomCommandFailure(linkResult));
+ }
+ primaryCloudLinkState.refresh();
+ return false;
+ }
+ }
+ const prefResult = await updatePrimaryEnvironmentPreferences({
+ target,
+ publishAgentActivity: desired.publish,
+ });
+ if (prefResult._tag === "Failure") {
+ if (!isAtomCommandInterrupted(prefResult)) {
+ reportUpdateFailure(squashAtomCommandFailure(prefResult));
+ }
+ primaryCloudLinkState.refresh();
+ return false;
}
- setIsUpdating(false);
- return;
}
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
- if (refreshResult._tag === "Failure") {
- if (!isAtomCommandInterrupted(refreshResult)) {
- reportUpdateFailure(squashAtomCommandFailure(refreshResult));
- }
- setIsUpdating(false);
- return;
+ if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
+ reportUpdateFailure(squashAtomCommandFailure(refreshResult));
+ return false;
}
+ return true;
+ };
- toastManager.add({
- type: "success",
- title: enabled ? "T3 Connect linked" : "T3 Connect unlinked",
- description: enabled
- ? "This environment is available through T3 Connect."
- : "This environment is no longer available through T3 Connect.",
- });
+ const updateManagedTunnel = async (enabled: boolean) => {
+ setIsUpdating(true);
+ const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
+ if (ok) {
+ // Turning the tunnel off while publishing stays on downgrades the link
+ // rather than removing it — say so instead of claiming an unlink.
+ toastManager.add({
+ type: "success",
+ title: enabled
+ ? "T3 Connect linked"
+ : publishAgentActivity
+ ? "T3 Connect tunnel disabled"
+ : "T3 Connect unlinked",
+ description: enabled
+ ? "This environment is available through T3 Connect."
+ : publishAgentActivity
+ ? "The managed tunnel was removed. Agent activity publishing stays on."
+ : "This environment is no longer available through T3 Connect.",
+ });
+ }
setIsUpdating(false);
};
const updatePublishAgentActivity = async (enabled: boolean) => {
- const target = primaryCloudLinkState.target;
- if (!target) {
- reportUpdateFailure(new Error("Local environment is not ready yet."));
- return;
- }
-
setIsUpdatingPreference(true);
- setOperationError(null);
- const updateResult = await updatePrimaryEnvironmentPreferences({
- target,
- publishAgentActivity: enabled,
- });
- if (updateResult._tag === "Failure") {
- if (!isAtomCommandInterrupted(updateResult)) {
- reportUpdateFailure(squashAtomCommandFailure(updateResult));
- }
- setIsUpdatingPreference(false);
- return;
+ const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
+ if (ok) {
+ toastManager.add({
+ type: "success",
+ title: enabled ? "Agent activity enabled" : "Agent activity disabled",
+ description: enabled
+ ? "This environment publishes agent activity to your mobile clients."
+ : "This environment will stop publishing agent activity.",
+ });
}
-
- primaryCloudLinkState.refresh();
- toastManager.add({
- type: "success",
- title: enabled ? "Agent activity enabled" : "Agent activity disabled",
- description: enabled
- ? "This environment can publish agent activity to your mobile clients."
- : "This environment will stop publishing agent activity.",
- });
setIsUpdatingPreference(false);
};
- const disabledReason = !isSignedIn
- ? "Sign in to T3 Connect to manage this environment."
- : !canManageRelay
- ? "Your session does not have permission to manage T3 Connect access."
- : null;
- const linked = primaryCloudLinkState.data?.linked ?? false;
return (
<>
void updateLink(enabled)}
+ onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
+ />
+ }
+ />
+ void updatePublishAgentActivity(enabled)}
/>
}
/>
- {linked ? (
- void updatePublishAgentActivity(enabled)}
- />
- }
- />
- ) : null}
>
);
}
diff --git a/infra/relay/migrations/postgres/20260706061126_migration/migration.sql b/infra/relay/migrations/postgres/20260706061126_migration/migration.sql
new file mode 100644
index 00000000000..cba591f3757
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260706061126_migration/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);
+--> statement-breakpoint
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);
diff --git a/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json b/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json
new file mode 100644
index 00000000000..0d75da1e486
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json
@@ -0,0 +1,1479 @@
+{
+ "dialect": "postgres",
+ "id": "f4151a65-091a-4601-bde8-029b155255b0",
+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],
+ "version": "8",
+ "ddl": [
+ {
+ "isRlsEnabled": false,
+ "name": "relay_agent_activity_rows",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_delivery_attempts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_dpop_proofs",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_environment_credentials",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_environment_links",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_live_activities",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_managed_endpoint_allocations",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_mobile_devices",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thread_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "state_json",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(36)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thread_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "kind",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "source_job_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(16)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token_suffix",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_reason",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(128)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "transport_error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(128)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thumbprint",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "jti",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "iat",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "credential_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "credential_hash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "revoked_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'T3 Environment'",
+ "generated": null,
+ "identity": null,
+ "name": "environment_label",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_http_base_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_ws_base_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(32)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_provider_kind",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "notifications_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "live_activities_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "managed_tunnels_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_by_device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "revoked_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "activity_push_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_start_queued_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_started_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ended_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "last_aggregate_json",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "last_live_activity_delivery_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "hostname",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tunnel_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "tunnel_name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "dns_record_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ready_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'iOS device'",
+ "generated": null,
+ "identity": null,
+ "name": "label",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(16)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "platform",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ios_major_version",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "app_version",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "bundle_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(16)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "aps_environment",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "push_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "push_to_start_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "preferences_json",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_agent_activity_rows_updated",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_delivery_attempts_environment",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "source_job_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_delivery_attempts_source_job",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_dpop_proofs_expires_at",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "credential_hash",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_environment_credentials_hash",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "revoked_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_environment_credentials_environment",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "environment_public_key",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "revoked_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_environment_credentials_environment_key",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "revoked_at",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_environment_links_environment",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_live_activities_user",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "activity_push_token",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_live_activities_activity_push_token",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "hostname",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_managed_endpoint_allocations_hostname",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "tunnel_name",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_managed_endpoint_allocations_tunnel_name",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_mobile_devices_user",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "push_token",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_mobile_devices_push_token",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "push_to_start_token",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "idx_relay_mobile_devices_push_to_start_token",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "columns": ["environment_id", "environment_public_key", "thread_id"],
+ "nameExplicit": false,
+ "name": "relay_agent_activity_rows_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "columns": ["thumbprint", "jti"],
+ "nameExplicit": false,
+ "name": "relay_dpop_proofs_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "columns": ["user_id", "environment_id"],
+ "nameExplicit": false,
+ "name": "relay_environment_links_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "columns": ["user_id", "device_id"],
+ "nameExplicit": false,
+ "name": "relay_live_activities_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "columns": ["user_id", "environment_id"],
+ "nameExplicit": false,
+ "name": "relay_managed_endpoint_allocations_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_managed_endpoint_allocations"
+ },
+ {
+ "columns": ["user_id", "device_id"],
+ "nameExplicit": false,
+ "name": "relay_mobile_devices_pkey",
+ "entityType": "pks",
+ "schema": "public",
+ "table": "relay_mobile_devices"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "relay_delivery_attempts_pkey",
+ "schema": "public",
+ "table": "relay_delivery_attempts",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["credential_id"],
+ "nameExplicit": false,
+ "name": "relay_environment_credentials_pkey",
+ "schema": "public",
+ "table": "relay_environment_credentials",
+ "entityType": "pks"
+ }
+ ],
+ "renames": []
+}
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
index 9671f4984b2..e267d51275f 100644
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
@@ -28,6 +28,8 @@ function target(deviceId: string): LiveActivities.TargetRow {
platform: "ios",
ios_major_version: 18,
app_version: "1.0.0",
+ bundle_id: null,
+ aps_environment: null,
push_token: null,
push_to_start_token: "start-token",
preferences_json: "{}",
@@ -628,3 +630,70 @@ describe("AgentActivityPublisher", () => {
},
);
});
+
+describe("isExpiredAgentActivityState", () => {
+ const hourMs = 60 * 60 * 1_000;
+
+ it("expires running rows after two hours without an update", () => {
+ expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs - 1)).toBe(false);
+ expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs + 1)).toBe(true);
+ });
+
+ it("keeps waiting rows for a day", () => {
+ const waiting: RelayAgentActivityState = { ...state, phase: "waiting_for_approval" };
+ expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 23 * hourMs)).toBe(false);
+ expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 25 * hourMs)).toBe(true);
+ });
+
+ it("treats rows with unparseable timestamps as expired", () => {
+ expect(
+ AgentActivityPublisher.isExpiredAgentActivityState({ ...state, updatedAt: "not-a-date" }, 0),
+ ).toBe(true);
+ });
+});
+
+describe("makeAggregateState", () => {
+ const hourMs = 60 * 60 * 1_000;
+
+ it("drops expired rows from the aggregate", () => {
+ const fresh: RelayAgentActivityState = {
+ ...state,
+ threadId: "thread-fresh" as RelayAgentActivityState["threadId"],
+ updatedAt: "1970-01-01T03:00:00.000Z",
+ };
+ const aggregate = AgentActivityPublisher.makeAggregateState({
+ activeStates: [state, fresh],
+ terminalState: null,
+ nowMs: 3 * hourMs,
+ });
+
+ expect(aggregate?.activeCount).toBe(1);
+ expect(aggregate?.activities).toMatchObject([{ threadId: "thread-fresh" }]);
+ });
+
+ it("returns null when every row has expired and nothing terminal remains", () => {
+ expect(
+ AgentActivityPublisher.makeAggregateState({
+ activeStates: [state],
+ terminalState: null,
+ nowMs: 3 * hourMs,
+ }),
+ ).toBeNull();
+ });
+
+ it("still reports the terminal state when active rows have expired", () => {
+ const terminalState: RelayAgentActivityState = {
+ ...state,
+ phase: "completed",
+ updatedAt: "1970-01-01T03:00:00.000Z",
+ };
+ const aggregate = AgentActivityPublisher.makeAggregateState({
+ activeStates: [state],
+ terminalState,
+ nowMs: 3 * hourMs,
+ });
+
+ expect(aggregate?.activeCount).toBe(0);
+ expect(aggregate?.activities).toMatchObject([{ phase: "completed" }]);
+ });
+});
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
index abe05f07da2..2bd9c053e90 100644
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -8,6 +8,7 @@ import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts";
import * as AgentActivityRows from "./AgentActivityRows.ts";
@@ -55,6 +56,7 @@ export const make = Effect.gen(function* () {
? makeAggregateState({
activeStates,
terminalState: input.state && isTerminalPhase(input.state) ? input.state : null,
+ nowMs: input.nowMs,
})
: null;
const notificationOnlyAggregate =
@@ -64,6 +66,7 @@ export const make = Effect.gen(function* () {
? makeAggregateState({
activeStates: isTerminalPhase(input.state) ? [] : [input.state],
terminalState: isTerminalPhase(input.state) ? input.state : null,
+ nowMs: input.nowMs,
})
: null;
const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId });
@@ -110,8 +113,12 @@ export const make = Effect.gen(function* () {
if (target === null) {
return null;
}
- const aggregate = makeAggregateState({ activeStates, terminalState: null });
const now = yield* DateTime.now;
+ const aggregate = makeAggregateState({
+ activeStates,
+ terminalState: null,
+ nowMs: now.epochMilliseconds,
+ });
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,
@@ -174,7 +181,9 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string {
case "failed":
return "Failed";
case "starting":
- return "Starting";
+ // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same
+ // thread reads the same across surfaces.
+ return "Connecting";
case "running":
return "Working";
case "stale":
@@ -186,6 +195,34 @@ function isTerminalPhase(state: RelayAgentActivityState): boolean {
return state.phase === "completed" || state.phase === "failed";
}
+// Rows are only removed when their environment publishes a terminal state. An
+// environment that dies mid-run (machine off, process killed) never does, so
+// without an age cutoff its threads inflate activeCount forever. Actively
+// running phases expire quickly; waiting phases can legitimately sit for hours
+// while a user ignores an approval prompt, so they get a longer window. The
+// underlying database row is left in place: a late publish for the thread
+// refreshes updatedAt and the row becomes visible again.
+const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;
+const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000;
+
+export function isExpiredAgentActivityState(
+ state: RelayAgentActivityState,
+ nowMs: number,
+): boolean {
+ const updatedAtMs = Option.match(DateTime.make(state.updatedAt), {
+ onNone: () => Number.NaN,
+ onSome: (dt) => dt.epochMilliseconds,
+ });
+ if (Number.isNaN(updatedAtMs)) {
+ return true;
+ }
+ const ttlMs =
+ state.phase === "running" || state.phase === "starting"
+ ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS
+ : WAITING_AGENT_ACTIVITY_ROW_TTL_MS;
+ return nowMs - updatedAtMs > ttlMs;
+}
+
function aggregateRowForState(state: RelayAgentActivityState) {
return {
environmentId: state.environmentId,
@@ -210,11 +247,14 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv
});
}
-function makeAggregateState(input: {
+export function makeAggregateState(input: {
readonly activeStates: ReadonlyArray;
readonly terminalState: RelayAgentActivityState | null;
+ readonly nowMs: number;
}): RelayAgentActivityAggregateState | null {
- const activeStates = input.activeStates.filter((state) => !isTerminalPhase(state));
+ const activeStates = input.activeStates.filter(
+ (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs),
+ );
if (activeStates.length === 0) {
return input.terminalState === null ? null : terminalAggregateState(input.terminalState);
}
diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts
index bb557376862..ff34a73e3df 100644
--- a/infra/relay/src/agentActivity/ApnsClient.test.ts
+++ b/infra/relay/src/agentActivity/ApnsClient.test.ts
@@ -96,6 +96,32 @@ describe("ApnsClient", () => {
}).pipe(Effect.provide(TestLayer)),
);
+ it.effect("builds a high-priority alerting update payload when an alert is attached", () =>
+ Effect.gen(function* () {
+ const apns = yield* ApnsClient.ApnsClient;
+ const request = apns.makeLiveActivityRequest({
+ event: "update",
+ token: "token",
+ state,
+ alert: { title: "Thread", body: "Approval: Project" },
+ nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
+ nowIso: DateTime.formatIso(now),
+ });
+
+ expect(request.priority).toBe("10");
+ expect(request.payload).toMatchObject({
+ aps: {
+ event: "update",
+ alert: {
+ title: "Thread",
+ body: "Approval: Project",
+ sound: "default",
+ },
+ },
+ });
+ }).pipe(Effect.provide(TestLayer)),
+ );
+
it.effect("builds an end payload with a dismissal date", () =>
Effect.gen(function* () {
const apns = yield* ApnsClient.ApnsClient;
diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts
index 1ac218cdd3c..166dc6fc4b9 100644
--- a/infra/relay/src/agentActivity/ApnsClient.ts
+++ b/infra/relay/src/agentActivity/ApnsClient.ts
@@ -12,10 +12,14 @@ import * as Headers from "effect/unstable/http/Headers";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
-import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
+import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
const LIVE_ACTIVITY_NAME = "AgentActivity";
-const STALE_AFTER_SECONDS = 2 * 60;
+// Updates only flow on domain events, so a healthy agent can be silent for
+// minutes (long tool calls, pending approvals). Two minutes made iOS dim
+// perfectly healthy activities; ten minutes still bounds how long a dead
+// environment can look alive.
+const STALE_AFTER_SECONDS = 10 * 60;
const DISMISS_AFTER_SECONDS = 5 * 60;
const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);
@@ -197,12 +201,27 @@ type MakeLiveActivityRequestInput =
| (LiveActivityRequestBase & {
readonly event: "end";
readonly state: RelayAgentActivityAggregateState | null;
+ readonly alert?: ApnsLiveActivityAlert | null;
})
| (LiveActivityRequestBase & {
readonly event: "start" | "update";
readonly state: RelayAgentActivityAggregateState;
+ readonly alert?: ApnsLiveActivityAlert | null;
});
+// An alert dict on an update/end makes it an "alerting" update: iOS wakes the
+// screen and plays the haptic (the Apple Sports score-change behavior) instead
+// of silently redrawing the activity.
+function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) {
+ return {
+ alert: {
+ title: alert.title,
+ body: alert.body,
+ sound: "default",
+ },
+ };
+}
+
function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {
const timestamp = input.nowEpochSeconds;
if (input.event === "end") {
@@ -215,6 +234,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
timestamp,
event: "end",
...(input.state ? { "content-state": contentState(input.state) } : {}),
+ ...(input.alert ? liveActivityAlertPayload(input.alert) : {}),
"dismissal-date": timestamp + DISMISS_AFTER_SECONDS,
},
},
@@ -225,7 +245,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
return {
token: input.token,
event: input.event,
- priority: input.event === "update" ? "5" : "10",
+ // Alerting updates must land immediately; routine redraws stay at the
+ // budget-friendly low priority.
+ priority: input.event === "update" && !input.alert ? "5" : "10",
payload: {
aps: {
timestamp,
@@ -241,6 +263,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA
},
}
: {}),
+ ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}),
"content-state": contentState(state),
"stale-date": timestamp + STALE_AFTER_SECONDS,
},
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
index da3c39cfa71..2e2301870aa 100644
--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
@@ -127,6 +127,8 @@ const target: LiveActivities.TargetRow = {
platform: "ios",
ios_major_version: 18,
app_version: "1.0.0",
+ bundle_id: null,
+ aps_environment: null,
push_token: null,
push_to_start_token: "start-token",
preferences_json: enabledPreferences,
@@ -389,6 +391,196 @@ describe("ApnsDeliveries", () => {
}).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
});
+ it.effect("queues Live Activity jobs with the device's APNs routing", () => {
+ const attempts: Array = [];
+ const queuedJobs: Array = [];
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ yield* deliveries.sendForTarget({
+ target: {
+ ...target,
+ bundle_id: "com.t3tools.t3code.preview",
+ aps_environment: "production",
+ ended_at: "1970-01-01T00:00:05.000Z",
+ },
+ aggregate,
+ nowMs: 10_000,
+ });
+
+ expect(queuedJobs).toMatchObject([
+ {
+ payload: {
+ kind: "live_activity_start",
+ target: {
+ token: "start-token",
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: "production",
+ },
+ },
+ },
+ ]);
+ }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
+ });
+
+ it.effect("sends signed jobs to the device's APNs environment and bundle topic", () => {
+ const attempts: Array = [];
+ const requests: Array = [];
+ const payload = makeApnsDeliveryJobPayload({
+ kind: "live_activity_update",
+ userId: target.user_id,
+ deviceId: target.device_id,
+ token: "activity-token",
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: "sandbox",
+ aggregate,
+ createdAt: "1970-01-01T00:00:00.000Z",
+ expiresAt: "1970-01-01T00:10:00.000Z",
+ jobId: "job-routing-1",
+ });
+ const signed = signApnsDeliveryJob({
+ secret: config.apnsDeliveryJobSigningSecret,
+ payload,
+ });
+ const execute = (request: HttpClientRequest.HttpClientRequest) =>
+ Effect.sync(() => {
+ requests.push(request);
+ return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
+ });
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ const result = yield* deliveries.processSignedJob(signed);
+
+ expect(result.ok).toBe(true);
+ expect(requests).toHaveLength(1);
+ expect(requests[0]?.url).toBe("https://api.sandbox.push.apple.com/3/device/activity-token");
+ expect(requests[0]?.headers["apns-topic"]).toBe(
+ "com.t3tools.t3code.preview.push-type.liveactivity",
+ );
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ attempts,
+ config: signingConfig,
+ execute,
+ }),
+ ),
+ );
+ });
+
+ it.effect(
+ "suppresses all deliveries when the aggregate is unchanged while a row awaits input",
+ () => {
+ const attempts: Array = [];
+ const queuedJobs: Array = [];
+ const waitingAggregate: RelayAgentActivityAggregateState = {
+ ...aggregate,
+ activities: [
+ {
+ ...aggregate.activities[0]!,
+ phase: "waiting_for_input",
+ status: "Input",
+ },
+ ],
+ };
+ const waitingAggregateJson = JSON.stringify(waitingAggregate);
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ const result = yield* deliveries.sendForTarget({
+ target: {
+ ...target,
+ // A registered alert token must not turn the suppressed Live
+ // Activity update into an alert push on every republish.
+ push_token: "apns-device-token",
+ last_aggregate_json: waitingAggregateJson,
+ last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
+ },
+ aggregate: waitingAggregate,
+ nowMs: 5_000,
+ });
+
+ expect(result).toBeNull();
+ expect(queuedJobs).toEqual([]);
+ expect(attempts).toEqual([]);
+ }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
+ },
+ );
+
+ it.effect(
+ "queues an update inside the throttle window when a changed aggregate awaits input",
+ () => {
+ const attempts: Array = [];
+ const queuedJobs: Array = [];
+ const waitingAggregate: RelayAgentActivityAggregateState = {
+ ...aggregate,
+ activities: [
+ {
+ ...aggregate.activities[0]!,
+ phase: "waiting_for_input",
+ status: "Input",
+ },
+ ],
+ };
+ const previousAggregateJson = JSON.stringify(aggregate);
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ const result = yield* deliveries.sendForTarget({
+ target: {
+ ...target,
+ last_aggregate_json: previousAggregateJson,
+ last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
+ },
+ aggregate: waitingAggregate,
+ nowMs: 5_000,
+ });
+
+ expect(result?.kind).toBe("live_activity_update");
+ expect(queuedJobs).toMatchObject([
+ {
+ payload: {
+ kind: "live_activity_update",
+ target: {
+ token: "activity-token",
+ },
+ },
+ },
+ ]);
+ }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
+ },
+ );
+
+ it.effect(
+ "throttles updates for changed aggregates with stable counts and no pending attention",
+ () => {
+ const attempts: Array = [];
+ const queuedJobs: Array = [];
+ const changedAggregate: RelayAgentActivityAggregateState = {
+ ...aggregate,
+ updatedAt: "1970-01-01T00:00:04.000Z",
+ };
+ const previousAggregateJson = JSON.stringify(aggregate);
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ const result = yield* deliveries.sendForTarget({
+ target: {
+ ...target,
+ last_aggregate_json: previousAggregateJson,
+ last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z",
+ },
+ aggregate: changedAggregate,
+ nowMs: 5_000,
+ });
+
+ expect(result).toBeNull();
+ expect(queuedJobs).toEqual([]);
+ }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
+ },
+ );
+
it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => {
const attempts: Array = [];
const queuedJobs: Array = [];
@@ -1177,3 +1369,115 @@ describe("ApnsDeliveries", () => {
);
});
});
+
+describe("live activity alert decisions", () => {
+ const preferences = {
+ liveActivitiesEnabled: true,
+ notificationsEnabled: true,
+ notifyOnApproval: true,
+ notifyOnInput: true,
+ notifyOnCompletion: true,
+ notifyOnFailure: true,
+ };
+
+ const attentionRow = {
+ ...aggregate.activities[0]!,
+ threadId: "thread-2" as RelayAgentActivityState["threadId"],
+ threadTitle: "Blocked thread",
+ phase: "waiting_for_approval" as const,
+ status: "Approval",
+ };
+
+ it("alerts when a thread newly enters an attention phase", () => {
+ const alert = ApnsDeliveries.alertForAttentionTransition({
+ previousAggregate: aggregate,
+ nextAggregate: {
+ ...aggregate,
+ activeCount: 2,
+ activities: [...aggregate.activities, attentionRow],
+ },
+ preferences,
+ });
+ expect(alert).toEqual({ title: "Blocked thread", body: "Approval: Project" });
+ });
+
+ it("stays silent when the attention phase was already delivered", () => {
+ const withAttention = {
+ ...aggregate,
+ activeCount: 2,
+ activities: [...aggregate.activities, attentionRow],
+ };
+ expect(
+ ApnsDeliveries.alertForAttentionTransition({
+ previousAggregate: withAttention,
+ nextAggregate: withAttention,
+ preferences,
+ }),
+ ).toBeNull();
+ });
+
+ it("stays silent without a delivered baseline so replays cannot buzz", () => {
+ expect(
+ ApnsDeliveries.alertForAttentionTransition({
+ previousAggregate: null,
+ nextAggregate: { ...aggregate, activities: [attentionRow] },
+ preferences,
+ }),
+ ).toBeNull();
+ });
+
+ it("honors the per-event notification switch for attention alerts", () => {
+ expect(
+ ApnsDeliveries.alertForAttentionTransition({
+ previousAggregate: aggregate,
+ nextAggregate: {
+ ...aggregate,
+ activeCount: 2,
+ activities: [...aggregate.activities, attentionRow],
+ },
+ preferences: { ...preferences, notifyOnApproval: false },
+ }),
+ ).toBeNull();
+ });
+
+ it("summarizes multiple newly blocked threads in one alert", () => {
+ const secondAttentionRow = {
+ ...attentionRow,
+ threadId: "thread-3" as RelayAgentActivityState["threadId"],
+ threadTitle: "Other blocked thread",
+ phase: "waiting_for_input" as const,
+ status: "Input",
+ };
+ const alert = ApnsDeliveries.alertForAttentionTransition({
+ previousAggregate: aggregate,
+ nextAggregate: {
+ ...aggregate,
+ activeCount: 3,
+ activities: [...aggregate.activities, attentionRow, secondAttentionRow],
+ },
+ preferences,
+ });
+ expect(alert).toEqual({
+ title: "2 agents need attention",
+ body: "Blocked thread, Other blocked thread",
+ });
+ });
+
+ it("alerts for a terminal aggregate and honors the completion switch", () => {
+ const terminalAggregate = {
+ ...aggregate,
+ activeCount: 0,
+ activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
+ };
+ expect(
+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),
+ ).toEqual({ title: "Thread", body: "Done: Project" });
+ expect(
+ ApnsDeliveries.alertForTerminalAggregate({
+ aggregate: terminalAggregate,
+ preferences: { ...preferences, notifyOnCompletion: false },
+ }),
+ ).toBeNull();
+ expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();
+ });
+});
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
index c83eaf34f2e..95461561898 100644
--- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
@@ -26,6 +26,7 @@ import {
ApnsDeliveryJobLiveActivityAggregateMissing,
ApnsDeliveryJobPushNotificationMissing,
ApnsDeliveryJobQueuePayloadInvalid,
+ type ApnsLiveActivityAlert,
type ApnsNotificationPayload,
SignedApnsDeliveryJob,
isApnsDeliveryJobVerificationError,
@@ -55,11 +56,13 @@ type ChosenLiveActivityDelivery =
readonly kind: "live_activity_start" | "live_activity_update";
readonly token: string;
readonly aggregate: RelayAgentActivityAggregateState;
+ readonly alert: ApnsLiveActivityAlert | null;
}
| {
readonly kind: "live_activity_end";
readonly token: string;
readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly alert: ApnsLiveActivityAlert | null;
};
type ChosenPushNotificationDelivery = {
@@ -130,6 +133,91 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null
return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value));
}
+function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): boolean {
+ return aggregate.activities.some(
+ (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
+ );
+}
+
+function isAttentionPhase(phase: string): boolean {
+ return phase === "waiting_for_approval" || phase === "waiting_for_input";
+}
+
+// Honors the same per-event notification switches the push channel uses; a
+// missing/corrupt preferences blob only disables nothing (matching how the
+// liveActivitiesEnabled check treats it), since every registration writes one.
+function alertAllowedForPhase(
+ preferences: RelayAgentAwarenessPreferences | null,
+ phase: string,
+): boolean {
+ if (preferences === null) {
+ return true;
+ }
+ switch (phase) {
+ case "waiting_for_approval":
+ return preferences.notifyOnApproval;
+ case "waiting_for_input":
+ return preferences.notifyOnInput;
+ case "completed":
+ return preferences.notifyOnCompletion;
+ case "failed":
+ return preferences.notifyOnFailure;
+ default:
+ return false;
+ }
+}
+
+// Alert copy for an update whose aggregate contains threads that were NOT in an
+// attention phase in the previously delivered aggregate. A null previous
+// aggregate means there is no known baseline (fresh registration, replay after
+// data loss) — alerting there would buzz on reconnect, not on a transition.
+export function alertForAttentionTransition(input: {
+ readonly previousAggregate: RelayAgentActivityAggregateState | null;
+ readonly nextAggregate: RelayAgentActivityAggregateState;
+ readonly preferences: RelayAgentAwarenessPreferences | null;
+}): ApnsLiveActivityAlert | null {
+ if (input.previousAggregate === null) {
+ return null;
+ }
+ const previouslyAttention = new Set(
+ input.previousAggregate.activities
+ .filter((row) => isAttentionPhase(row.phase))
+ .map((row) => row.threadId),
+ );
+ const newlyAttention = input.nextAggregate.activities.filter(
+ (row) =>
+ isAttentionPhase(row.phase) &&
+ !previouslyAttention.has(row.threadId) &&
+ alertAllowedForPhase(input.preferences, row.phase),
+ );
+ const first = newlyAttention[0];
+ if (!first) {
+ return null;
+ }
+ if (newlyAttention.length === 1) {
+ return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` };
+ }
+ return {
+ title: `${newlyAttention.length} agents need attention`,
+ body: newlyAttention.map((row) => row.threadTitle).join(", "),
+ };
+}
+
+// Alert copy for an end event carrying a terminal (Done/Failed) aggregate.
+export function alertForTerminalAggregate(input: {
+ readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly preferences: RelayAgentAwarenessPreferences | null;
+}): ApnsLiveActivityAlert | null {
+ const row = input.aggregate?.activities[0];
+ if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
+ return null;
+ }
+ if (!alertAllowedForPhase(input.preferences, row.phase)) {
+ return null;
+ }
+ return { title: row.threadTitle, body: `${row.status}: ${row.projectTitle}` };
+}
+
function shouldUpdateLiveActivity(input: {
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
@@ -139,11 +227,14 @@ function shouldUpdateLiveActivity(input: {
if (!input.previousAggregate) {
return true;
}
+ if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) {
+ return false;
+ }
if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) {
return true;
}
- if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) {
- return false;
+ if (aggregateNeedsAttention(input.nextAggregate)) {
+ return true;
}
const lastDeliveryAtMs =
input.lastDeliveryAt === null
@@ -191,11 +282,14 @@ function notificationForAggregate(input: {
};
}
+// "suppressed" means a Live Activity owns this state but no update is due
+// (unchanged or throttled); callers must not fall back to an alert push, or
+// every republish of a waiting aggregate would ring the device.
function chooseLiveActivityDelivery(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly nowMs: number;
-}): ChosenLiveActivityDelivery | null {
+}): ChosenLiveActivityDelivery | "suppressed" | null {
const hasActiveActivity =
input.target.ended_at === null &&
(input.target.remote_start_queued_at !== null ||
@@ -208,6 +302,7 @@ function chooseLiveActivityDelivery(input: {
kind: "live_activity_end",
token: input.target.activity_push_token,
aggregate: null,
+ alert: null,
}
: null;
}
@@ -217,6 +312,7 @@ function chooseLiveActivityDelivery(input: {
kind: "live_activity_end",
token: input.target.activity_push_token,
aggregate: input.aggregate,
+ alert: null,
}
: null;
}
@@ -226,27 +322,31 @@ function chooseLiveActivityDelivery(input: {
kind: "live_activity_start",
token: input.target.push_to_start_token,
aggregate: input.aggregate,
+ alert: null,
}
: null;
}
if (!input.target.activity_push_token) {
return null;
}
+ const previousAggregate = parseAggregate(input.target.last_aggregate_json);
return shouldUpdateLiveActivity({
- previousAggregate: parseAggregate(input.target.last_aggregate_json),
+ previousAggregate,
nextAggregate: input.aggregate,
lastDeliveryAt: input.target.last_live_activity_delivery_at,
nowMs: input.nowMs,
- }) ||
- input.aggregate.activities.some(
- (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
- )
+ })
? {
kind: "live_activity_update",
token: input.target.activity_push_token,
aggregate: input.aggregate,
+ alert: alertForAttentionTransition({
+ previousAggregate,
+ nextAggregate: input.aggregate,
+ preferences,
+ }),
}
- : null;
+ : "suppressed";
}
function chooseDelivery(input: {
@@ -255,6 +355,9 @@ function chooseDelivery(input: {
readonly nowMs: number;
}): ChosenDelivery | null {
const liveActivityDelivery = chooseLiveActivityDelivery(input);
+ if (liveActivityDelivery === "suppressed") {
+ return null;
+ }
if (liveActivityDelivery) {
return liveActivityDelivery;
}
@@ -365,6 +468,24 @@ const recoverApnsDeliveryTransportError = (
interface LiveActivityDeliveryTarget {
readonly user_id: string;
readonly device_id: string;
+ readonly bundle_id?: string | null;
+ readonly aps_environment?: "sandbox" | "production" | null;
+}
+
+// Devices register the bundle id and APS environment of the build they run
+// (dev/preview/prod variants have distinct bundle ids; development-signed
+// builds get sandbox tokens). Sending with mismatched routing yields
+// DeviceTokenNotForTopic/BadDeviceToken, so per-device values override the
+// relay-wide defaults when present.
+function credentialsForTarget(
+ credentials: RelayConfiguration.RelayConfiguration["Service"]["apns"],
+ target: LiveActivityDeliveryTarget,
+): RelayConfiguration.RelayConfiguration["Service"]["apns"] {
+ return {
+ ...credentials,
+ ...(target.bundle_id ? { bundleId: target.bundle_id } : {}),
+ ...(target.aps_environment ? { environment: target.aps_environment } : {}),
+ };
}
function expectedCurrentToken(input: {
@@ -392,10 +513,12 @@ export type SendLiveActivityDeliveryInput =
| (SendLiveActivityDeliveryInputBase & {
readonly kind: "live_activity_start" | "live_activity_update";
readonly aggregate: RelayAgentActivityAggregateState;
+ readonly alert?: ApnsLiveActivityAlert | null;
})
| (SendLiveActivityDeliveryInputBase & {
readonly kind: "live_activity_end";
readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly alert?: ApnsLiveActivityAlert | null;
});
function makeLiveActivityDeliveryRequest(
@@ -419,6 +542,7 @@ function makeLiveActivityDeliveryRequest(
...base,
event: deliveryEvent(input.kind),
state: input.aggregate,
+ alert: input.alert ?? null,
}),
};
case "live_activity_end":
@@ -429,6 +553,7 @@ function makeLiveActivityDeliveryRequest(
...base,
event: "end",
state: input.aggregate,
+ alert: input.alert ?? null,
}),
};
}
@@ -540,7 +665,7 @@ export const make = Effect.gen(function* () {
}
const result = yield* apns
.sendLiveActivityRequest({
- credentials: config.apns,
+ credentials: credentialsForTarget(config.apns, input.target),
request,
issuedAtUnixSeconds: epochSeconds,
})
@@ -663,7 +788,7 @@ export const make = Effect.gen(function* () {
}
const result = yield* apns
.sendPushNotificationRequest({
- credentials: config.apns,
+ credentials: credentialsForTarget(config.apns, input.target),
request,
issuedAtUnixSeconds: epochSeconds,
})
@@ -752,22 +877,28 @@ export const make = Effect.gen(function* () {
target: {
user_id: payload.target.userId,
device_id: payload.target.deviceId,
+ bundle_id: payload.target.bundleId ?? null,
+ aps_environment: payload.target.apsEnvironment ?? null,
},
token: payload.target.token,
sourceJobId: payload.jobId,
kind: payload.kind,
aggregate: payload.aggregate,
+ alert: payload.alert ?? null,
});
case "live_activity_end":
return sendLiveActivity({
target: {
user_id: payload.target.userId,
device_id: payload.target.deviceId,
+ bundle_id: payload.target.bundleId ?? null,
+ aps_environment: payload.target.apsEnvironment ?? null,
},
token: payload.target.token,
sourceJobId: payload.jobId,
kind: payload.kind,
aggregate: payload.aggregate,
+ alert: payload.alert ?? null,
});
case "push_notification":
if (payload.notification === null) {
@@ -783,6 +914,8 @@ export const make = Effect.gen(function* () {
target: {
user_id: payload.target.userId,
device_id: payload.target.deviceId,
+ bundle_id: payload.target.bundleId ?? null,
+ aps_environment: payload.target.apsEnvironment ?? null,
},
token: payload.target.token,
sourceJobId: payload.jobId,
@@ -804,6 +937,8 @@ export const make = Effect.gen(function* () {
userId: input.target.user_id,
deviceId: input.target.device_id,
token,
+ bundleId: input.target.bundle_id,
+ apsEnvironment: input.target.aps_environment,
notification,
})
: Effect.succeed(null);
@@ -822,26 +957,46 @@ export const make = Effect.gen(function* () {
userId: input.target.user_id,
deviceId: input.target.device_id,
token: delivery.token,
+ bundleId: input.target.bundle_id,
+ apsEnvironment: input.target.aps_environment,
notification: delivery.notification,
});
return result;
}
+ const notification = notificationForAggregate({
+ target: input.target,
+ aggregate: input.aggregate,
+ });
+ // The end event doubles as the "task finished" moment. When a companion
+ // push notification is about to ring the device (below), the activity end
+ // stays silent; otherwise the end itself carries the alert so LA-only
+ // users still get the buzz.
+ const alert =
+ delivery.kind === "live_activity_end"
+ ? notification && input.target.push_token
+ ? null
+ : alertForTerminalAggregate({
+ aggregate: delivery.aggregate,
+ preferences: parsePreferences(input.target.preferences_json),
+ })
+ : delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({
userId: input.target.user_id,
deviceId: input.target.device_id,
kind: delivery.kind,
token: delivery.token,
+ bundleId: input.target.bundle_id,
+ apsEnvironment: input.target.aps_environment,
aggregate: delivery.aggregate,
- });
- const notification = notificationForAggregate({
- target: input.target,
- aggregate: input.aggregate,
+ alert,
});
if (delivery.kind === "live_activity_end" && notification && input.target.push_token) {
yield* deliveryQueue.enqueuePushNotification({
userId: input.target.user_id,
deviceId: input.target.device_id,
token: input.target.push_token,
+ bundleId: input.target.bundle_id,
+ apsEnvironment: input.target.aps_environment,
notification,
});
}
diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts
index 6c1fd79dc1c..3b095d6fd56 100644
--- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts
@@ -58,12 +58,17 @@ export class ApnsDeliveryQueue extends Context.Service<
readonly userId: string;
readonly deviceId: string;
readonly token: string;
+ readonly bundleId?: string | null;
+ readonly apsEnvironment?: "sandbox" | "production" | null;
readonly aggregate: ApnsDeliveryJobPayload["aggregate"];
+ readonly alert?: ApnsDeliveryJobPayload["alert"];
}) => Effect.Effect;
readonly enqueuePushNotification: (input: {
readonly userId: string;
readonly deviceId: string;
readonly token: string;
+ readonly bundleId?: string | null;
+ readonly apsEnvironment?: "sandbox" | "production" | null;
readonly notification: NonNullable;
}) => Effect.Effect;
}
@@ -160,6 +165,8 @@ export const make = Effect.gen(function* () {
userId: input.userId,
deviceId: input.deviceId,
token: input.token,
+ bundleId: input.bundleId,
+ apsEnvironment: input.apsEnvironment,
aggregate: null,
notification: sanitizeApnsNotificationPayload(input.notification),
jobId,
diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts
index 553899da178..5a37b1f20fd 100644
--- a/infra/relay/src/agentActivity/Devices.test.ts
+++ b/infra/relay/src/agentActivity/Devices.test.ts
@@ -15,6 +15,8 @@ const registration: RelayDeviceRegistrationRequest = {
platform: "ios",
iosMajorVersion: 18,
appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"],
+ bundleId: "com.t3tools.t3code.preview" as RelayDeviceRegistrationRequest["bundleId"],
+ apsEnvironment: "production",
pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"],
pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"],
preferences: {
@@ -106,6 +108,8 @@ describe("Devices", () => {
expect.objectContaining({
userId: "user-2",
deviceId: "device-1",
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: "production",
pushToken: "apns-device-token",
pushToStartToken: "push-to-start-token",
}),
diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts
index 86e3564d5be..f1c09fb3b28 100644
--- a/infra/relay/src/agentActivity/Devices.ts
+++ b/infra/relay/src/agentActivity/Devices.ts
@@ -130,6 +130,8 @@ export const make = Effect.gen(function* () {
platform: registration.platform,
iosMajorVersion: registration.iosMajorVersion,
appVersion: registration.appVersion ?? null,
+ bundleId: registration.bundleId ?? null,
+ apsEnvironment: registration.apsEnvironment ?? null,
pushToken: registration.pushToken ?? null,
pushToStartToken: registration.pushToStartToken ?? null,
preferencesJson: registration.preferences,
@@ -143,6 +145,13 @@ export const make = Effect.gen(function* () {
label: registration.label,
iosMajorVersion: registration.iosMajorVersion,
appVersion: registration.appVersion ?? null,
+ // Preserve routing from newer app builds when an older build
+ // re-registers without these fields.
+ bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`,
+ apsEnvironment: sql`coalesce(
+ excluded.aps_environment,
+ ${relayMobileDevices.apsEnvironment}
+ )`,
pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`,
pushToStartToken: sql`coalesce(
excluded.push_to_start_token,
diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts
index 608ee0704ab..f6109b07a19 100644
--- a/infra/relay/src/agentActivity/LiveActivities.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.ts
@@ -69,6 +69,8 @@ export interface DeviceRow {
readonly platform: "ios";
readonly ios_major_version: number;
readonly app_version: string | null;
+ readonly bundle_id: string | null;
+ readonly aps_environment: "sandbox" | "production" | null;
readonly push_token: string | null;
readonly push_to_start_token: string | null;
readonly preferences_json: string;
@@ -196,6 +198,8 @@ export const make = Effect.gen(function* () {
platform: relayMobileDevices.platform,
ios_major_version: relayMobileDevices.iosMajorVersion,
app_version: relayMobileDevices.appVersion,
+ bundle_id: relayMobileDevices.bundleId,
+ aps_environment: relayMobileDevices.apsEnvironment,
push_token: relayMobileDevices.pushToken,
push_to_start_token: relayMobileDevices.pushToStartToken,
preferences_json: relayMobileDevices.preferencesJson,
diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
index a223e9707c4..2e2a4f3d245 100644
--- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts
+++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
@@ -404,6 +404,8 @@ describe("MobileRegistrations", () => {
platform: "ios",
ios_major_version: 18,
app_version: "1.0.0",
+ bundle_id: null,
+ aps_environment: null,
push_token: "apns-device-token",
push_to_start_token: "push-to-start-token",
preferences_json: JSON.stringify(device.preferences),
diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
index 2af61085eab..027b17bd5e0 100644
--- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
+++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts
@@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({
});
export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
+// Alert copy attached to a Live Activity update/end push. Its presence makes
+// the update "alerting": iOS wakes the screen, plays the haptic, and briefly
+// expands the Dynamic Island instead of silently redrawing.
+export const ApnsLiveActivityAlert = Schema.Struct({
+ title: Schema.String,
+ body: Schema.String,
+});
+export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type;
+
export const ApnsDeliveryJobPayload = Schema.Struct({
version: Schema.Literal(1),
jobId: Schema.String,
@@ -49,9 +58,15 @@ export const ApnsDeliveryJobPayload = Schema.Struct({
userId: Schema.String,
deviceId: Schema.String,
token: Schema.String,
+ // Per-device APNs routing; absent on jobs queued by older relay builds,
+ // which fall back to the configured defaults.
+ bundleId: Schema.optional(Schema.NullOr(Schema.String)),
+ apsEnvironment: Schema.optional(Schema.NullOr(Schema.Literals(["sandbox", "production"]))),
}),
aggregate: Schema.NullOr(RelayAgentActivityAggregateState),
notification: Schema.NullOr(ApnsNotificationPayload),
+ // Optional so jobs queued by older relay builds still decode.
+ alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)),
createdAt: Schema.String,
expiresAt: Schema.String,
});
@@ -224,8 +239,11 @@ export function makeApnsDeliveryJobPayload(input: {
readonly userId: string;
readonly deviceId: string;
readonly token: string;
+ readonly bundleId?: string | null | undefined;
+ readonly apsEnvironment?: "sandbox" | "production" | null | undefined;
readonly aggregate: ApnsDeliveryJobPayload["aggregate"];
readonly notification?: ApnsNotificationPayload | null;
+ readonly alert?: ApnsLiveActivityAlert | null | undefined;
readonly createdAt: string;
readonly expiresAt: string;
readonly jobId: string;
@@ -238,9 +256,14 @@ export function makeApnsDeliveryJobPayload(input: {
userId: input.userId,
deviceId: input.deviceId,
token: input.token,
+ ...(input.bundleId ? { bundleId: input.bundleId } : {}),
+ ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
},
aggregate: input.aggregate,
notification: input.notification ?? null,
+ // Omitted (not null) when absent so signatures stay identical to jobs from
+ // relay builds that predate the field.
+ ...(input.alert ? { alert: input.alert } : {}),
createdAt: input.createdAt,
expiresAt: input.expiresAt,
};
diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts
index f6bd1c6d977..18a362f5247 100644
--- a/infra/relay/src/environments/EnvironmentLinker.test.ts
+++ b/infra/relay/src/environments/EnvironmentLinker.test.ts
@@ -109,6 +109,7 @@ const makeRequest = Effect.gen(function* () {
function testLayer(input?: {
readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"];
readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"];
+ readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"];
}) {
return EnvironmentLinker.layer.pipe(
Layer.provideMerge(RelayTokens.layer),
@@ -135,7 +136,7 @@ function testLayer(input?: {
revokeForEnvironmentPublicKey: () => Effect.succeed(false),
}),
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
- deprovision: () => Effect.void,
+ deprovision: input?.deprovision ?? (() => Effect.void),
provision: () =>
Effect.succeed({
endpoint: {
@@ -173,6 +174,79 @@ describe("EnvironmentLinker", () => {
);
});
+ it.effect("links a publish-only environment with a non-secure nominal endpoint", () => {
+ let persistedEndpoint: string | null = null;
+ let deprovisionedEnvironmentId: string | null = null;
+ return Effect.gen(function* () {
+ const now = yield* DateTime.now;
+ const expiresAt = DateTime.add(now, { minutes: 5 });
+ const relayTokens = yield* RelayTokens.RelayTokens;
+ const challenge = yield* relayTokens.issueLinkChallenge({
+ userId: "user_123",
+ request: {
+ notificationsEnabled: true,
+ liveActivitiesEnabled: true,
+ managedTunnelsEnabled: false,
+ },
+ jti: "publish-only-challenge-jti",
+ issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
+ expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000),
+ });
+ const payload = {
+ iss: "t3-env:env-link-test",
+ aud: "https://relay.example.test",
+ sub: "env-link-test",
+ jti: "publish-only-proof-jti",
+ iat: Math.floor(now.epochMilliseconds / 1_000),
+ exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
+ challenge,
+ environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"],
+ descriptor: {
+ environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"],
+ label: "Link Test Environment",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true },
+ },
+ environmentPublicKey: environmentKeyPair.publicKey.trim(),
+ endpoint: {
+ httpBaseUrl: "http://127.0.0.1:3773/",
+ wsBaseUrl: "ws://127.0.0.1:3773/",
+ providerKind: "manual",
+ },
+ origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
+ scopes: ["agent_activity_notifications"],
+ } satisfies RelayEnvironmentLinkProofPayload;
+ const request = {
+ proof: signTestJwt(payload, RELAY_LINK_PROOF_TYP, environmentKeyPair.privateKey),
+ notificationsEnabled: true,
+ liveActivitiesEnabled: true,
+ managedTunnelsEnabled: false,
+ } satisfies RelayEnvironmentLinkRequest;
+ const linker = yield* EnvironmentLinker.EnvironmentLinker;
+ const result = yield* linker.link({ userId: "user_123", request });
+ expect(result.environmentCredential).toBe("t3env_credential_secret");
+ expect(result.endpointRuntime).toBeNull();
+ expect(persistedEndpoint).toBe("http://127.0.0.1:3773/");
+ // Downgrading from a managed link must release the previously provisioned
+ // tunnel; nothing else cleans it up before a full unlink.
+ expect(deprovisionedEnvironmentId).toBe("env-link-test");
+ }).pipe(
+ Effect.provide(
+ testLayer({
+ upsert: (input) =>
+ Effect.sync(() => {
+ persistedEndpoint = input.endpoint.httpBaseUrl;
+ }),
+ deprovision: (input) =>
+ Effect.sync(() => {
+ deprovisionedEnvironmentId = input.environmentId;
+ }),
+ }),
+ ),
+ );
+ });
+
it.effect("rejects a tampered compact proof before persistence", () => {
let persisted = false;
return Effect.gen(function* () {
diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts
index 6a97eefffa0..fbfdc6428a4 100644
--- a/infra/relay/src/environments/EnvironmentLinker.ts
+++ b/infra/relay/src/environments/EnvironmentLinker.ts
@@ -287,6 +287,27 @@ const make = Effect.gen(function* () {
stage: "validate_origin",
});
}
+ // Downgrading a managed link to publish-only must release the tunnel and
+ // DNS that were provisioned for it — nothing else cleans them up until a
+ // full unlink. Best effort: a cleanup failure must not block the link
+ // itself, and the provider treats an absent allocation as already
+ // deprovisioned, so retrying on every non-tunnel link is cheap.
+ if (!input.request.managedTunnelsEnabled) {
+ yield* managedEndpointProvider
+ .deprovision({
+ userId: input.userId,
+ environmentId: verified.environmentId,
+ })
+ .pipe(
+ Effect.tapError((error) =>
+ Effect.logWarning("managed endpoint deprovision on publish-only link failed", {
+ environmentId: verified.environmentId,
+ errorTag: error._tag,
+ }),
+ ),
+ Effect.ignore,
+ );
+ }
const provisioned = input.request.managedTunnelsEnabled
? yield* managedEndpointProvider.provision({
userId: input.userId,
@@ -295,7 +316,11 @@ const make = Effect.gen(function* () {
})
: null;
const endpoint = provisioned?.endpoint ?? verified.endpoint;
- if (!isSecureManagedEndpoint(endpoint)) {
+ // The secure-endpoint requirement only matters when the relay advertises
+ // this endpoint for other devices to reach (managed tunnel). Publish-only
+ // links are reached out of band (e.g. Tailscale) and their stored endpoint
+ // is never used for routing, so a nominal endpoint is acceptable.
+ if (input.request.managedTunnelsEnabled && !isSecureManagedEndpoint(endpoint)) {
return yield* new EnvironmentLinkProofInvalid({
userId: input.userId,
environmentId: verified.environmentId,
diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts
index 158bcec9803..b43a2837590 100644
--- a/infra/relay/src/http/Api.test.ts
+++ b/infra/relay/src/http/Api.test.ts
@@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend";
import { describe, expect, it } from "@effect/vitest";
import { vi } from "vite-plus/test";
import * as Context from "effect/Context";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as Redacted from "effect/Redacted";
+import * as TestClock from "effect/testing/TestClock";
import * as Tracer from "effect/Tracer";
import * as HttpRouter from "effect/unstable/http/HttpRouter";
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
@@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
import {
+ RELAY_REQUEST_DEADLINE_MS,
relayCors,
relayDocsRedirectRoute,
relayEnvironmentAuthLayer,
@@ -203,6 +207,33 @@ describe("relay request tracing", () => {
expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId);
}),
);
+
+ it.effect("fails hung requests with a 504 before the client's 10s abort", () =>
+ Effect.gen(function* () {
+ const spans: Array = [];
+ const tracer = Tracer.make({
+ span: (options) => {
+ const span = new Tracer.NativeSpan(options);
+ spans.push(span);
+ return span;
+ },
+ });
+ const request = HttpServerRequest.fromWeb(
+ new Request("https://relay.test/v1/mobile/devices", { method: "POST" }),
+ );
+
+ const fiber = yield* traceRelayHttpRequestWith(
+ Effect.never,
+ Layer.succeed(Tracer.Tracer, tracer),
+ ).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.forkChild);
+ yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS));
+ const response = yield* Fiber.join(fiber);
+
+ expect(response.status).toBe(504);
+ expect(spans[0]?.attributes.get("relay.request.deadline_exceeded")).toBe(true);
+ expect(spans[0]?.attributes.get("http.response.status_code")).toBe(504);
+ }),
+ );
});
describe("relay routing fallback", () => {
diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts
index 29e2026de3c..87044efa9da 100644
--- a/infra/relay/src/http/Api.ts
+++ b/infra/relay/src/http/Api.ts
@@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
import * as Record from "effect/Record";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
@@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add(
HttpServerResponse.redirect("/docs"),
);
+// Shorter than the mobile client's 10s request timeout on purpose: when a
+// request hangs (e.g. a stuck upstream query), the client would otherwise
+// abort first, the invocation would die with the request span still open, and
+// the batched spans would never export — leaving no server-side trace at all.
+// Failing server-side first turns the hang into a completed 504 whose trace
+// contains the exact child span that stalled, and the response still carries
+// the traceparent back to the client.
+export const RELAY_REQUEST_DEADLINE_MS = 9_000;
+
+const relayRequestDeadline = (
+ httpEffect: Effect.Effect<
+ HttpServerResponse.HttpServerResponse,
+ E,
+ HttpServerRequest.HttpServerRequest | R
+ >,
+) =>
+ httpEffect.pipe(
+ Effect.timeoutOption(Duration.millis(RELAY_REQUEST_DEADLINE_MS)),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ yield* Effect.logError("relay request exceeded deadline", {
+ "http.method": request.method,
+ "http.url": request.url,
+ "relay.request.deadline_ms": RELAY_REQUEST_DEADLINE_MS,
+ });
+ yield* Effect.annotateCurrentSpan({
+ "relay.request.deadline_exceeded": true,
+ });
+ return HttpServerResponse.jsonUnsafe(
+ { error: "relay_request_deadline_exceeded" },
+ { status: 504 },
+ );
+ }),
+ onSome: Effect.succeed,
+ }),
+ ),
+ );
+
export const traceRelayHttpRequest = (
httpEffect: Effect.Effect<
HttpServerResponse.HttpServerResponse,
@@ -164,7 +206,7 @@ export const traceRelayHttpRequest = (
) =>
// HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first.
HttpMiddleware.tracer(
- appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)),
+ appendRelayTraceContextResponseHeader.pipe(Effect.andThen(relayRequestDeadline(httpEffect))),
).pipe(Effect.ensuring(Effect.yieldNow));
export const traceRelayHttpRequestWith = (
diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts
index ab3d2dfd97a..0952ade1731 100644
--- a/infra/relay/src/persistence/schema.ts
+++ b/infra/relay/src/persistence/schema.ts
@@ -24,6 +24,8 @@ export const relayMobileDevices = pgTable(
platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(),
iosMajorVersion: integer("ios_major_version").notNull(),
appVersion: varchar("app_version", { length: 64 }),
+ bundleId: varchar("bundle_id", { length: 255 }),
+ apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(),
pushToken: text("push_token"),
pushToStartToken: text("push_to_start_token"),
preferencesJson: jsonb("preferences_json").notNull().$type(),
diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts
index 0469e459d16..b4d990fc844 100644
--- a/packages/client-runtime/src/connection/resolver.test.ts
+++ b/packages/client-runtime/src/connection/resolver.test.ts
@@ -449,6 +449,7 @@ describe("ConnectionResolver", () => {
new ManagedRelay.ManagedRelayRequestTimeoutError({
activity: "Relay environment connection",
timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
+ traceId: null,
}),
),
});
diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts
index 6bdc7798fb2..01886beabf6 100644
--- a/packages/client-runtime/src/relay/discovery.test.ts
+++ b/packages/client-runtime/src/relay/discovery.test.ts
@@ -261,6 +261,7 @@ describe("RelayEnvironmentDiscovery", () => {
new ManagedRelay.ManagedRelayRequestTimeoutError({
activity: "Relay environment listing",
timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
+ traceId: null,
}),
),
getEnvironmentStatus: () => Effect.die("unused"),
diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts
index 08b720b46a3..383fcf582c8 100644
--- a/packages/client-runtime/src/relay/managedRelay.ts
+++ b/packages/client-runtime/src/relay/managedRelay.ts
@@ -115,6 +115,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass
- Effect.fail(
- new ManagedRelayRequestTimeoutError({
- activity,
- timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
- }),
+ Effect.currentParentSpan.pipe(
+ Effect.map((span) => span.traceId),
+ Effect.orElseSucceed(() => null),
+ Effect.flatMap((traceId) =>
+ Effect.fail(
+ new ManagedRelayRequestTimeoutError({
+ activity,
+ timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
+ traceId,
+ }),
+ ),
+ ),
),
onSome: Effect.succeed,
}),
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index 86b9f151f98..2d40dad60cc 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -342,6 +342,11 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({
cloudUserId: Schema.NullOr(Schema.String),
relayUrl: Schema.NullOr(Schema.String),
relayIssuer: Schema.NullOr(Schema.String),
+ // A managed Cloudflare tunnel is provisioned for this link. False for a
+ // publish-only link (activity publishing without a relay-managed tunnel), so
+ // clients can present the two capabilities as independent settings.
+ // Optional so newer clients tolerate older environment servers.
+ managedTunnelActive: Schema.optional(Schema.Boolean),
publishAgentActivity: Schema.Boolean,
});
export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts
index dea3709f488..f1c4cd12cf3 100644
--- a/packages/contracts/src/relay.ts
+++ b/packages/contracts/src/relay.ts
@@ -35,12 +35,21 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({
});
export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type;
+export const RelayApnsEnvironment = Schema.Literals(["sandbox", "production"]);
+export type RelayApnsEnvironment = typeof RelayApnsEnvironment.Type;
+
export const RelayDeviceRegistrationRequest = Schema.Struct({
deviceId: TrimmedNonEmptyString,
label: TrimmedNonEmptyString,
platform: RelayAgentAwarenessPlatform,
iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),
appVersion: Schema.optional(TrimmedNonEmptyString),
+ // APNs routing for this install: the topic must match the app's bundle id
+ // (dev/preview/prod variants differ) and development-signed builds receive
+ // sandbox tokens. Optional so older app builds keep registering; the relay
+ // falls back to its configured defaults.
+ bundleId: Schema.optional(TrimmedNonEmptyString),
+ apsEnvironment: Schema.optional(RelayApnsEnvironment),
pushToken: Schema.optional(TrimmedNonEmptyString),
pushToStartToken: Schema.optional(TrimmedNonEmptyString),
preferences: RelayAgentAwarenessPreferences,