diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 3f1fe66908e93..c40f866df4e3f 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -13,7 +13,10 @@ import { linkEnvironmentToCloudWithPreference, } from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; +import { + getAgentAwarenessRegistrationGeneration, + updateAgentAwarenessRegistrationPreferences, +} from "./remoteRegistration"; vi.mock("expo-secure-store", () => ({ deleteItemAsync: vi.fn(), @@ -38,6 +41,7 @@ vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({ vi.mock("./remoteRegistration", () => ({ updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), + getAgentAwarenessRegistrationGeneration: vi.fn(() => 1), })); const connection: SavedRemoteConnection = { @@ -73,11 +77,25 @@ const layerTest = Layer.mergeAll( describe("liveActivityPreferences", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getAgentAwarenessRegistrationGeneration).mockReturnValue(1); }); - it.effect("restores relay preferences when an environment update fails", () => { + it.effect.each([ + "same-session failure", + "account-change failure", + "account-change success", + ] as const)("restores preferences only in the original session: %s", (scenario) => { + const accountChanged = scenario !== "same-session failure"; vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => - Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), + Effect.sync(() => { + if (accountChanged) vi.mocked(getAgentAwarenessRegistrationGeneration).mockReturnValue(2); + }).pipe( + Effect.andThen( + scenario === "account-change success" + ? Effect.void + : Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), + ), + ), ); return Effect.gen(function* () { @@ -95,6 +113,11 @@ describe("liveActivityPreferences", () => { expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { liveActivitiesEnabled: false, }); + if (accountChanged) { + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); + return; + } expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { liveActivitiesEnabled: true, }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 826edaf839b91..a395928c74272 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -3,7 +3,10 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { SavedRemoteConnection } from "../../lib/connection"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; -import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; +import { + getAgentAwarenessRegistrationGeneration, + updateAgentAwarenessRegistrationPreferences, +} from "./remoteRegistration"; export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( function* (input: { @@ -13,6 +16,8 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn readonly connections: ReadonlyArray; readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; }) { + const generation = getAgentAwarenessRegistrationGeneration(); + const isCurrentSession = () => generation === getAgentAwarenessRegistrationGeneration(); const linkedConnections = input.connections.filter( (connection) => connection.bearerToken !== null, ); @@ -22,6 +27,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn enabled: boolean, clerkToken: string, ) { + if (!isCurrentSession()) return yield* Effect.interrupt; if (!input.canConfigureEnvironment(connection.environmentId)) return; yield* linkEnvironmentToCloudWithPreference({ @@ -47,6 +53,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn }); const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { + if (!isCurrentSession()) return; yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: input.previousEnabled, }).pipe( @@ -74,6 +81,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn }); yield* updateRelayPreference(input.enabled).pipe( + Effect.andThen(Effect.suspend(() => (isCurrentSession() ? Effect.void : Effect.interrupt))), Effect.onError(() => restoreRelayPreference()), ); }, diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index c66ed4c030a93..01ca8d2742b22 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; import type { Preferences } from "../../persistence/mobile-preferences"; @@ -19,6 +20,7 @@ export function makeRelayDeviceRegistrationRequest( readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; readonly preferences: Preferences; + readonly disabledEnvironmentIds?: ReadonlyArray; } & ( | { readonly platform?: "ios"; readonly iosMajorVersion: number } | { @@ -53,6 +55,9 @@ export function makeRelayDeviceRegistrationRequest( notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + ...(input.disabledEnvironmentIds + ? { disabledEnvironmentIds: input.disabledEnvironmentIds } + : {}), }, }; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9085f98058066..3f1dacc310f1d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -102,13 +102,28 @@ vi.mock("./agentLiveActivity", () => ({ startAgentLiveActivity: widgetMocks.start, })); -// Avoid importing the native connection stack; this gate only needs configs. +const catalogMock = vi.hoisted(() => ({ + atom: Symbol("catalogValueAtom"), + value: { isReady: true, entries: new Map() }, + listeners: new Set<(value: unknown) => void>(), +})); + +// Avoid importing the native connection stack; registration only reads these atoms. vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { - get: () => environmentConfigsMock.configs, + get: (atom: unknown) => + atom === catalogMock.atom ? catalogMock.value : environmentConfigsMock.configs, + subscribe: (_atom: unknown, listener: (value: unknown) => void) => { + catalogMock.listeners.add(listener); + return () => catalogMock.listeners.delete(listener); + }, }, })); +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { catalogValueAtom: catalogMock.atom }, +})); + vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), })); @@ -280,6 +295,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { publishWidgetMock.mockClear(); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); + catalogMock.value = { isReady: true, entries: new Map() }; + catalogMock.listeners.clear(); }); it("disables push features in Personal Team relay registrations", () => { @@ -645,6 +662,202 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); + it.effect( + "re-registers the latest disabled environments after toggles during registration", + () => { + const registrations: Array<{ readonly preferences: unknown }> = []; + let openFirstRegistration: (() => void) | undefined; + const firstRegistrationGate = new Promise((resolve) => { + openFirstRegistration = resolve; + }); + let markFirstRegistrationArrived: (() => void) | undefined; + const firstRegistrationArrived = new Promise((resolve) => { + markFirstRegistrationArrived = resolve; + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + 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", + }); + } + registrations.push(await request.json()); + if (registrations.length === 1) { + markFirstRegistrationArrived?.(); + await firstRegistrationGate; + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + const setCatalog = (entries: ReadonlyArray) => { + catalogMock.value = { + isReady: true, + entries: new Map(entries.map(([id, enabled]) => [id, { enabled }])), + }; + for (const listener of catalogMock.listeners) listener(catalogMock.value); + }; + setCatalog([ + ["env-a", true], + ["env-b", true], + ]); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + const drain = yield* Effect.forkChild(runBackgroundOperations()); + yield* Effect.promise(() => firstRegistrationArrived); + setCatalog([ + ["env-a", false], + ["env-b", true], + ]); + setCatalog([ + ["env-a", false], + ["env-b", false], + ]); + openFirstRegistration?.(); + yield* Fiber.join(drain); + yield* runBackgroundOperations(); + + expect(registrations.map((body) => body.preferences)).toEqual([ + expect.objectContaining({ disabledEnvironmentIds: [] }), + expect.objectContaining({ disabledEnvironmentIds: ["env-a", "env-b"] }), + ]); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }, + ); + + it.effect("a toggle during a preference update registers after it with both changes", () => { + const landed: Array<{ readonly preferences: unknown }> = []; + let openUpdate: (() => void) | undefined; + const updateGate = new Promise((resolve) => { + openUpdate = resolve; + }); + let markUpdateArrived: (() => void) | undefined; + const updateArrived = new Promise((resolve) => { + markUpdateArrived = resolve; + }); + let requests = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + 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", + }); + } + const body = await request.json(); + if (++requests === 2) { + markUpdateArrived?.(); + await updateGate; + } + landed.push(body); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + catalogMock.value = { isReady: true, entries: new Map([["env-a", { enabled: true }]]) }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + const update = yield* Effect.forkChild( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + yield* Effect.promise(() => updateArrived); + catalogMock.value = { isReady: true, entries: new Map([["env-a", { enabled: false }]]) }; + for (const listener of catalogMock.listeners) listener(catalogMock.value); + const toggle = backgroundRuntime.pending.shift()!; + const toggleFiber = yield* Effect.forkChild( + toggle.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, + { startImmediately: true }, + ); + openUpdate?.(); + yield* Fiber.join(update); + toggle.resolve(yield* Fiber.await(toggleFiber)); + yield* runBackgroundOperations(); + + expect(landed.map((body) => body.preferences)).toEqual([ + expect.objectContaining({ liveActivitiesEnabled: false, disabledEnvironmentIds: [] }), + expect.objectContaining({ liveActivitiesEnabled: true, disabledEnvironmentIds: [] }), + expect.objectContaining({ + liveActivitiesEnabled: true, + disabledEnvironmentIds: ["env-a"], + }), + ]); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + + it.effect("rejects old-account preference updates without changing the new account", () => { + const landed: Array<{ readonly preferences: unknown }> = []; + const arrived = Promise.withResolvers(); + const response = Promise.withResolvers(); + let requests = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + 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", + }); + } + const body = await request.json(); + if (++requests === 2) { + arrived.resolve(); + await response.promise; + } + landed.push(body); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-a"), "user-a"); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + const inFlight = yield* Effect.forkChild( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + ); + yield* Effect.promise(() => arrived.promise); + const waiting = yield* Effect.forkChild( + updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }), + { startImmediately: true }, + ); + setAgentAwarenessRelayTokenProvider(null); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-b"), "user-b"); + yield* runBackgroundOperations(); + expect(landed.at(-1)?.preferences).toMatchObject({ liveActivitiesEnabled: false }); + response.resolve(); + for (const fiber of [inFlight, waiting]) { + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + } + vi.mocked(loadAgentAwarenessRegistrationRecord).mockResolvedValueOnce(null); + yield* refreshAgentAwarenessRegistration(); + expect(registrationRecordStore.current?.identity).toBe("user-b"); + expect(landed.at(-1)?.preferences).toMatchObject({ liveActivitiesEnabled: false }); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it.effect.each(["ios", "android"] as const)( "does not enable %s notifications when a token rotates after permission is revoked", (os) => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ec712608fad94..daa59226fb81a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { AppState, Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; import { type RelayDeviceRegistrationRequest, type RelayAgentActivitySnapshotResponse, @@ -25,6 +26,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { runtime } from "../../lib/runtime"; +import { environmentCatalog } from "../../connection/catalog"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import type { Preferences } from "../../persistence/mobile-preferences"; @@ -63,6 +65,7 @@ const AgentAwarenessOperation = Schema.Literals([ "load-live-activity-registration-identifier", "list-active-live-activities", "load-live-activity-prime-preferences", + "load-live-activity-prime-identifier", "prime-live-activity", "load-registration-record", ]); @@ -97,6 +100,7 @@ let widgetSnapshotReads = 0; let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; +let environmentCatalogSubscription: (() => void) | null = null; export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed"; let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; @@ -137,6 +141,8 @@ let pendingDeviceRegistration: { interface DeviceRegistrationInput { readonly observedPushToken?: string; + // A registration already in flight may have read the environment list before it changed. + readonly environmentsChanged?: boolean; } interface RegisterDeviceInput extends DeviceRegistrationInput { @@ -203,6 +209,8 @@ export function setAgentAwarenessRelayTokenProvider( pushTokenSubscription = null; appStateSubscription?.remove(); appStateSubscription = null; + environmentCatalogSubscription?.(); + environmentCatalogSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -218,6 +226,7 @@ export function setAgentAwarenessRelayTokenProvider( } ensurePushTokenListener(); ensureAppStateListener(); + ensureEnvironmentCatalogListener(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after cloud sign-in failed", @@ -243,6 +252,8 @@ export function releaseAgentAwarenessRelayTokenProvider(): void { pushTokenSubscription = null; appStateSubscription?.remove(); appStateSubscription = null; + environmentCatalogSubscription?.(); + environmentCatalogSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -354,6 +365,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { body.preferences.notifyOnInput, body.preferences.notifyOnCompletion, body.preferences.notifyOnFailure, + body.preferences.disabledEnvironmentIds?.join(",") ?? "", ].join("|"); } @@ -540,8 +552,17 @@ const readAgentActivitySnapshot: () => Effect.Effect< if (!token) { return null; } + const deviceId = yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-prime-identifier", + cause, + }), + }); const client = yield* ManagedRelay.ManagedRelayClient; - return yield* client.getAgentActivitySnapshot({ clerkToken: token }); + // The relay hides this device's disabled environments so priming cannot show them. + return yield* client.getAgentActivitySnapshot({ clerkToken: token, deviceId }); }, Effect.catch((error) => Effect.sync(() => { @@ -606,7 +627,8 @@ function registrationAddsInformation( next: DeviceRegistrationInput, ): boolean { return ( - next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken + next.environmentsChanged === true || + (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) ); } @@ -661,13 +683,45 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri startPendingDeviceRegistration(); } +let deviceRegistrationSession = { + generation: deviceRegistrationGeneration, + lock: Semaphore.makeUnsafe(1), + // Settings persists a preference only after its registration, so later ones must keep it. + preferencesOverride: {} as Partial, +}; + +// Each registration snapshots the catalog and preferences, so one at a time keeps the newest last. const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ( input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, +): Effect.fn.Return { + if (expectedGeneration !== deviceRegistrationGeneration) return; + if (deviceRegistrationSession.generation !== expectedGeneration) { + deviceRegistrationSession = { + generation: expectedGeneration, + lock: Semaphore.makeUnsafe(1), + preferencesOverride: {}, + }; + } + const session = deviceRegistrationSession; + if (input.preferencesOverride) { + session.preferencesOverride = { ...session.preferencesOverride, ...input.preferencesOverride }; + } + yield* session.lock.withPermits(1)(registerDeviceNow(input, session)); +}); + +const registerDeviceNow = Effect.fn("remoteRegistration.registerDeviceNow")(function* ( + input: DeviceRegistrationInput, + session: typeof deviceRegistrationSession, ): Effect.fn.Return { if (!canRegisterPushNotifications()) { return; } + const disabledEnvironmentIds = disabledEnvironmentIdsIn( + appAtomRegistry.get(environmentCatalog.catalogValueAtom), + ); + // Registering before the catalog loads would clear the relay's filter; the catalog listener retries. + if (disabledEnvironmentIds === null) return; const [deviceId, storedPreferences] = yield* Effect.all([ Effect.tryPromise({ @@ -689,8 +743,9 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ]); const preferences = mergeAgentAwarenessRegistrationPreferences( storedPreferences, - input.preferencesOverride, + session.preferencesOverride, ); + const expectedGeneration = session.generation; if (expectedGeneration !== deviceRegistrationGeneration) return; if (relayTokenProvider && relayTokenProviderIdentity) { configureAndroidAgentNotifications( @@ -725,6 +780,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, + disabledEnvironmentIds, }), expectedGeneration, ); @@ -783,6 +839,38 @@ function publishWidget(source: AgentActivityWidgetSource): void { }); } +function disabledEnvironmentIdsIn( + catalog: EnvironmentCatalogState, +): ReadonlyArray | null { + if (!catalog.isReady) return null; + return [...catalog.entries] + .filter(([, entry]) => !entry.enabled) + .map(([environmentId]) => environmentId) + .sort(); +} + +// The relay filters pushes for this device, so it must learn every environment toggle. +function ensureEnvironmentCatalogListener(): void { + if (environmentCatalogSubscription || !canRegisterPushNotifications()) { + return; + } + let observed = disabledEnvironmentIdsIn( + appAtomRegistry.get(environmentCatalog.catalogValueAtom), + )?.join(","); + environmentCatalogSubscription = appAtomRegistry.subscribe( + environmentCatalog.catalogValueAtom, + (catalog) => { + const next = disabledEnvironmentIdsIn(catalog)?.join(","); + if (next === undefined || next === observed) return; + observed = next; + enqueueDeviceRegistration( + { environmentsChanged: true }, + "device registration after environment toggle failed", + ); + }, + ); +} + // Binaries built before the native dismissEnded method throw here. function dismissEndedLocalLiveActivities(): void { try { @@ -825,26 +913,37 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< ); } -export function updateAgentAwarenessRegistrationPreferences( - preferencesOverride: Partial, -): Effect.Effect { - return registerDevice({ preferencesOverride }).pipe( - Effect.tapError((error) => - Effect.sync(() => { - if (registrationStatus !== "registered") { - setRegistrationStatus("failed"); - } - logRegistrationError("device preference registration refresh failed", error); - }), - ), - ); +export function getAgentAwarenessRegistrationGeneration(): number { + return deviceRegistrationGeneration; } +export const updateAgentAwarenessRegistrationPreferences = Effect.fn( + "remoteRegistration.updateAgentAwarenessRegistrationPreferences", +)( + function* ( + preferencesOverride: Partial, + ): Effect.fn.Return { + const generation = deviceRegistrationGeneration; + yield* registerDevice({ preferencesOverride }); + if (generation !== deviceRegistrationGeneration) return yield* Effect.interrupt; + }, + Effect.tapError((error) => + Effect.sync(() => { + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } + logRegistrationError("device preference registration refresh failed", error); + }), + ), +); + export function __resetAgentAwarenessRemoteRegistrationForTest(): void { pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); appStateSubscription = null; + environmentCatalogSubscription?.(); + environmentCatalogSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 992efd5b7b29a..5ef97fc1c3ebe 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -8,7 +8,7 @@ For activity without ordinary alerts on Android 8 or newer, leave **Ongoing agen On iOS you can also add the **Agent Activity** widget to your Home Screen or Lock Screen. It shows the same agents as the Live Activity, using your T3 Connect sign-in. The widget refreshes when you open T3 and shows unfinished work as out of date after 10 minutes. -Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. +Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. Disabling an environment in the mobile app stops its alerts and activity on that phone only; the app needs a network connection to apply the change. Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. If Live Updates don't appear on Android 16 or newer, check **Live Update Settings** in T3 Code's **Settings → Notifications**. Android 7's battery-saving modes can delay removal of expired cards. diff --git a/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/migration.sql b/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/migration.sql new file mode 100644 index 0000000000000..78b8a11637225 --- /dev/null +++ b/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "relay_live_activities" ADD COLUMN "last_hidden_environment_ids" jsonb; \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/snapshot.json b/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/snapshot.json new file mode 100644 index 0000000000000..51b66f9d0e726 --- /dev/null +++ b/infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/snapshot.json @@ -0,0 +1,42 @@ +{ + "type": "diff", + "version": "9", + "dialect": "postgres", + "id": "00f3e7a6-0f16-4178-bb0e-fbda26828ae0", + "prevIds": ["4413755a-63f1-4f93-bdc9-d1357283c23e"], + "footprints": [ + { + "statementType": "add_column", + "statementTarget": [ + "column", + "public", + "relay_live_activities", + "last_hidden_environment_ids" + ], + "writes": [ + "s|col|public.relay_live_activities.last_hidden_environment_ids", + "n|col|public.relay_live_activities.last_hidden_environment_ids", + "e|col|public.relay_live_activities.last_hidden_environment_ids" + ], + "reads": ["n|tbl|public.relay_live_activities", "n|sch|public"] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_live_activities", + "name": "last_hidden_environment_ids", + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "entityType": "columns" + } + } + ] +} diff --git a/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/migration.sql b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/migration.sql new file mode 100644 index 0000000000000..5ddaac0cff041 --- /dev/null +++ b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "relay_mobile_devices" ADD COLUMN "environment_revealed_at" jsonb; \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json new file mode 100644 index 0000000000000..2c7d84053ad29 --- /dev/null +++ b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json @@ -0,0 +1,37 @@ +{ + "type": "diff", + "version": "9", + "dialect": "postgres", + "id": "ce1bceab-71bd-463a-8d49-36d71f94a31a", + "prevIds": ["00f3e7a6-0f16-4178-bb0e-fbda26828ae0"], + "footprints": [ + { + "statementType": "add_column", + "statementTarget": ["column", "public", "relay_mobile_devices", "environment_revealed_at"], + "writes": [ + "s|col|public.relay_mobile_devices.environment_revealed_at", + "n|col|public.relay_mobile_devices.environment_revealed_at", + "e|col|public.relay_mobile_devices.environment_revealed_at" + ], + "reads": ["n|tbl|public.relay_mobile_devices", "n|sch|public"] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_mobile_devices", + "name": "environment_revealed_at", + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "entityType": "columns" + } + } + ] +} diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 33f1518ec30ae..c353b68770cf6 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -49,6 +49,8 @@ function target(deviceId: string): LiveActivities.TargetRow { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; } @@ -143,8 +145,20 @@ describe("AgentActivityPublisher", () => { it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; const ios = target("ios"); + const iosWithEnvironmentDisabled = { + ...target("ios-disabled"), + preferences_json: JSON.stringify({ + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + disabledEnvironmentIds: [state.environmentId], + }), + }; const fcmCalls: Array[0]> = []; - const appleDevices: string[] = []; + const appleDevices: Array<[string, number | null, boolean]> = []; return Effect.gen(function* () { const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; yield* publisher.publish({ @@ -161,7 +175,10 @@ describe("AgentActivityPublisher", () => { { target: android, state }, { target: android, state: null, replay: true }, ]); - expect(appleDevices).toEqual(["ios"]); + expect(appleDevices).toEqual([ + ["ios", 1, false], + ["ios-disabled", null, true], + ]); }).pipe( Effect.provide( AgentActivityPublisher.layer.pipe( @@ -171,14 +188,20 @@ describe("AgentActivityPublisher", () => { Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), Layer.succeed( LiveActivities.LiveActivities, - makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }), + makeLiveActivities({ + listTargets: () => Effect.succeed([android, ios, iosWithEnvironmentDisabled]), + }), ), Layer.succeed( ApnsDeliveries.ApnsDeliveries, makeApnsDeliveries({ sendForTarget: (input) => Effect.sync(() => { - appleDevices.push(input.target.device_id); + appleDevices.push([ + input.target.device_id, + input.aggregate?.activeCount ?? null, + input.contentHidden ?? false, + ]); return null; }), }), diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 6ef258acdb6d3..ea1fdd79f2764 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -1,5 +1,6 @@ import { makeAggregateState, + parseDevicePreferences, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, } from "./agentActivityAggregate.ts"; import type { @@ -66,12 +67,9 @@ const make = Effect.gen(function* () { const activeStates = input.deliveryUser.liveActivitiesEnabled ? yield* rows.listForUser({ userId: input.deliveryUser.userId }) : []; - const liveActivityAggregate = input.deliveryUser.liveActivitiesEnabled - ? makeAggregateState({ - activeStates, - terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, - nowMs: input.nowMs, - }) + const terminalState = input.state && isTerminalPhase(input.state) ? input.state : null; + const accountAggregate = input.deliveryUser.liveActivitiesEnabled + ? makeAggregateState({ activeStates, terminalState, nowMs: input.nowMs }) : null; const notificationOnlyAggregate = input.deliveryUser.notificationsEnabled && @@ -90,11 +88,20 @@ const make = Effect.gen(function* () { if (target.platform === "android") { return [yield* fcmDeliveries.enqueue({ target, state: input.state })]; } + const aggregate = + accountAggregate && + makeAggregateState({ + activeStates, + terminalState, + nowMs: input.nowMs, + preferences: parseDevicePreferences(target.preferences_json), + }); return yield* Effect.all( [ apnsDeliveries.sendForTarget({ target, - aggregate: liveActivityAggregate, + aggregate, + contentHidden: aggregate === null && accountAggregate !== null, nowMs: input.nowMs, }), notificationOnlyAggregate === null @@ -139,10 +146,18 @@ const make = Effect.gen(function* () { activeStates, terminalState: null, nowMs: now.epochMilliseconds, + preferences: parseDevicePreferences(target.preferences_json), }); return yield* apnsDeliveries.sendForTarget({ target, aggregate, + contentHidden: + aggregate === null && + makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }) !== null, nowMs: now.epochMilliseconds, replay: true, }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 64823f4973593..f509f6a2e15d1 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -135,6 +135,8 @@ const target: LiveActivities.TargetRow = { remote_started_at: "1970-01-01T00:00:00.000Z", ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; @@ -848,6 +850,185 @@ describe("ApnsDeliveries", () => { ); }); + it.effect.each([ + { + label: "shows an environment the device now disables", + queuedHidden: [], + currentHidden: ["env"], + }, + { + label: "was filtered for an environment the device re-enabled", + queuedHidden: ["other-env"], + currentHidden: [], + }, + ])("skips a queued Live Activity update that $label", ({ queuedHidden, currentHidden }) => { + const attempts: Array = []; + const markedDeliveries: Array< + Parameters[0] + > = []; + let executeCount = 0; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: target.activity_push_token ?? "activity-token", + aggregate, + hiddenEnvironmentIds: queuedHidden.map((id) => EnvironmentId.make(id)), + createdAt: state.updatedAt, + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-filtered-under-old-preferences", + }); + const signed = signApnsDeliveryJob({ secret: config.apnsDeliveryJobSigningSecret, payload }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + executeCount += 1; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.processSignedJob(yield* signed); + + expect(executeCount).toBe(0); + expect(markedDeliveries).toEqual([]); + expect(attempts).toMatchObject([ + { + sourceJobId: "job-filtered-under-old-preferences", + apnsReason: "Stale agent activity state skipped.", + }, + ]); + }).pipe( + Effect.provide( + layerFor({ + attempts, + markedDeliveries, + currentTargets: [ + { + ...target, + preferences_json: JSON.stringify({ + ...JSON.parse(enabledPreferences), + disabledEnvironmentIds: currentHidden, + }), + }, + ], + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect.each([ + { label: "disables", queuedHidden: [], currentHidden: ["other-env"] }, + { label: "re-enables", queuedHidden: ["other-env"], currentHidden: [] }, + ])( + "delivers a queued alert for an enabled environment when the device $label another", + ({ queuedHidden, currentHidden }) => { + const requests: HttpClientRequest.HttpClientRequest[] = []; + const waitingAt = "1970-01-01T00:00:01.000Z"; + const waitingAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + updatedAt: waitingAt, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_approval", + status: "Approval", + updatedAt: waitingAt, + }, + ], + }; + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload: makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: target.activity_push_token ?? "activity-token", + aggregate: waitingAggregate, + alert: { title: "Thread", body: "Approval: Project" }, + hiddenEnvironmentIds: queuedHidden.map((id) => EnvironmentId.make(id)), + createdAt: waitingAt, + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-approval-before-filter-change", + }), + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.processSignedJob(yield* signed); + + expect(requests).toHaveLength(1); + const body = requests[0]!.body; + expect(body._tag).toBe("Uint8Array"); + if (body._tag === "Uint8Array") { + const { aps } = JSON.parse(new TextDecoder().decode(body.body)); + expect(aps.alert).toMatchObject({ title: "Thread" }); + } + }).pipe( + Effect.provide( + layerFor({ + attempts: [], + config: signingConfig, + currentTargets: [ + { + ...target, + preferences_json: JSON.stringify({ + ...JSON.parse(enabledPreferences), + disabledEnvironmentIds: currentHidden, + }), + last_aggregate_json: JSON.stringify(aggregate), + last_hidden_environment_ids: queuedHidden, + }, + ], + activityStates: [{ ...state, phase: "waiting_for_approval", updatedAt: waitingAt }], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + }), + ), + ); + }, + ); + + it.effect.each([ + { name: "attention reached while disabled stays silent", revealedAtMs: 4_000, alerts: false }, + { name: "attention reached after re-enabling alerts", revealedAtMs: 1_000, alerts: true }, + ])("re-enabled environment: $name", ({ revealedAtMs, alerts }) => { + const queuedJobs: Array = []; + const other = { ...aggregate.activities[0]!, environmentId: EnvironmentId.make("other-env") }; + const waiting = { + ...other, + phase: "waiting_for_approval" as const, + status: "Approval", + updatedAt: "1970-01-01T00:00:03.000Z", + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ + target: { + ...target, + last_aggregate_json: JSON.stringify(aggregate), + last_hidden_environment_ids: [other.environmentId], + environment_revealed_at: { [other.environmentId]: revealedAtMs }, + last_live_activity_delivery_at: state.updatedAt, + }, + aggregate: { + ...aggregate, + activeCount: 2, + activities: [waiting, aggregate.activities[0]!], + }, + nowMs: 5_000, + }); + + expect(queuedJobs).toMatchObject([{ payload: { kind: "live_activity_update" } }]); + expect((queuedJobs[0]?.payload.alert ?? null) !== null).toBe(alerts); + }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs }))); + }); + it.effect("retries signed queue jobs that are already claimed but not completed", () => { const attempts: Array = []; let executeCount = 0; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 8913bdd1f8c5b..b82c5514ef01b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -46,7 +46,9 @@ import { alertForAttentionTransition, alertForNewlyTerminal, alertForTerminalAggregate, + hiddenEnvironmentIds, rowPhasesChanged, + environmentEnabledOnDevice, shouldAlertForActivity, } from "./agentActivityAlerts.ts"; const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; @@ -211,6 +213,7 @@ function notificationForAggregate(input: { function chooseLiveActivityDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly contentHidden?: boolean; readonly nowMs: number; readonly replay?: boolean; }): ChosenLiveActivityDelivery | "suppressed" | null { @@ -236,7 +239,11 @@ function chooseLiveActivityDelivery(input: { : DateTime.make(input.target.remote_started_at), { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, ); - if (armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS) { + if ( + !input.contentHidden && + armedAtMs !== null && + input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS + ) { return null; } return { @@ -262,11 +269,15 @@ function chooseLiveActivityDelivery(input: { ? null : (alertForAttentionTransition({ previousAggregate, + previousHiddenEnvironmentIds: input.target.last_hidden_environment_ids, + environmentRevealedAt: input.target.environment_revealed_at, nextAggregate, preferences, }) ?? alertForNewlyTerminal({ previousAggregate, + previousHiddenEnvironmentIds: input.target.last_hidden_environment_ids, + environmentRevealedAt: input.target.environment_revealed_at, nextAggregate, preferences, nowMs: input.nowMs, @@ -279,6 +290,7 @@ function chooseLiveActivityDelivery(input: { function chooseDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly contentHidden?: boolean; readonly nowMs: number; readonly replay?: boolean; }): ChosenDelivery | null { @@ -427,6 +439,7 @@ interface SendLiveActivityDeliveryInputBase { readonly target: LiveActivityDeliveryTarget; readonly token: string; readonly sourceJobId: string; + readonly hiddenEnvironmentIds?: ReadonlyArray; } export type SendLiveActivityDeliveryInput = @@ -484,6 +497,8 @@ export class ApnsDeliveries extends Context.Service< readonly sendForTarget: (input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; + // The aggregate is empty only because the device hides every environment with work. + readonly contentHidden?: boolean; readonly nowMs: number; readonly replay?: boolean; }) => Effect.Effect; @@ -515,7 +530,10 @@ const make = Effect.gen(function* () { const apns = yield* Apns.ApnsClient; const activityRows = yield* AgentActivityRows.AgentActivityRows; - const userHasContentToShow = Effect.fnUntraced(function* (userId: string) { + const userHasContentToShow = Effect.fnUntraced(function* ( + userId: string, + preferences: RelayAgentAwarenessPreferences | null, + ) { const now = yield* DateTime.now; return yield* activityRows.listForUser({ userId }).pipe( Effect.map( @@ -524,6 +542,7 @@ const make = Effect.gen(function* () { activeStates, terminalState: null, nowMs: now.epochMilliseconds, + preferences, }) !== null, ), // Keep the card on persistence failures; a later sweep can retry. @@ -687,31 +706,40 @@ const make = Effect.gen(function* () { return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } deliveryTarget = currentTarget; + const currentPreferences = parsePreferences(currentTarget.preferences_json); if (alert) { - const preferences = parsePreferences(currentTarget.preferences_json); - const previousAggregate = parseAggregate(currentTarget.last_aggregate_json); + const transition = aggregate && { + previousAggregate: parseAggregate(currentTarget.last_aggregate_json), + previousHiddenEnvironmentIds: currentTarget.last_hidden_environment_ids, + environmentRevealedAt: currentTarget.environment_revealed_at, + nextAggregate: aggregate, + preferences: currentPreferences, + }; alert = - !preferences?.notificationsEnabled || !aggregate + !currentPreferences?.notificationsEnabled || !transition ? null - : (alertForAttentionTransition({ - previousAggregate, - nextAggregate: aggregate, - preferences, - }) ?? + : (alertForAttentionTransition(transition) ?? alertForNewlyTerminal({ - previousAggregate, - nextAggregate: aggregate, - preferences, + ...transition, nowMs: now.epochMilliseconds, includeUnobserved: true, })); } + // A card must not show an environment the device now hides. One that lacks a re-enabled + // environment's rows yields to the registration replay unless it still carries an alert. if ( aggregate !== null && - !(yield* aggregateRowsAreCurrent({ - userId: input.target.user_id, - aggregate, - })) + (aggregate.activities.some( + (row) => !environmentEnabledOnDevice(currentPreferences, row.environmentId), + ) || + (alert === null && + (input.hiddenEnvironmentIds ?? []).some((id) => + environmentEnabledOnDevice(currentPreferences, id), + )) || + !(yield* aggregateRowsAreCurrent({ + userId: input.target.user_id, + aggregate, + }))) ) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, @@ -724,8 +752,8 @@ const make = Effect.gen(function* () { if ( input.kind === "live_activity_end" && aggregate === null && - parsePreferences(currentTarget.preferences_json)?.liveActivitiesEnabled !== false && - (yield* userHasContentToShow(input.target.user_id)) + currentPreferences?.liveActivitiesEnabled !== false && + (yield* userHasContentToShow(input.target.user_id, currentPreferences)) ) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, @@ -757,6 +785,7 @@ const make = Effect.gen(function* () { deviceId: input.target.device_id, kind: input.kind, aggregate, + hiddenEnvironmentIds: aggregate === null ? null : (input.hiddenEnvironmentIds ?? []), deliveredAt: iso, }); } else if (isPermanentApnsTokenFailure(result)) { @@ -970,6 +999,7 @@ const make = Effect.gen(function* () { kind: payload.kind, aggregate: payload.aggregate, alert: payload.alert ?? null, + hiddenEnvironmentIds: payload.hiddenEnvironmentIds ?? [], }); case "live_activity_end": return sendLiveActivity({ @@ -984,6 +1014,7 @@ const make = Effect.gen(function* () { kind: payload.kind, aggregate: payload.aggregate, alert: payload.alert ?? null, + hiddenEnvironmentIds: payload.hiddenEnvironmentIds ?? [], }); case "push_notification": if (payload.notification === null) { @@ -1039,6 +1070,7 @@ const make = Effect.gen(function* () { const delivery = chooseDelivery({ target: input.target, aggregate: input.aggregate, + contentHidden: input.contentHidden ?? false, nowMs: input.nowMs, replay: input.replay ?? false, }); @@ -1083,6 +1115,7 @@ const make = Effect.gen(function* () { apsEnvironment: input.target.aps_environment, aggregate: delivery.aggregate, alert, + hiddenEnvironmentIds: hiddenEnvironmentIds(parsePreferences(input.target.preferences_json)), }); if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { yield* deliveryQueue.enqueuePushNotification({ diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 09cff5184b058..f1c4b316699d2 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -65,6 +65,7 @@ export class ApnsDeliveryQueue extends Context.Service< readonly apsEnvironment?: "sandbox" | "production" | null; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly alert?: ApnsDeliveryJobPayload["alert"]; + readonly hiddenEnvironmentIds?: ReadonlyArray; }) => Effect.Effect; readonly enqueuePushNotification: (input: { readonly userId: string; diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts new file mode 100644 index 0000000000000..e123db705260d --- /dev/null +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -0,0 +1,79 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as RelayDb from "../db.ts"; +import { makeTestRelayDb } from "../persistence/testDatabase.ts"; +import * as Devices from "./Devices.ts"; +import * as LiveActivities from "./LiveActivities.ts"; + +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; +const a = EnvironmentId.make("env-a"); +const b = EnvironmentId.make("env-b"); + +describe("Devices", () => { + it.effect("records when each environment is re-enabled for the activity lifetime", () => + Effect.gen(function* () { + const db = yield* makeTestRelayDb; + const services = Layer.mergeAll(Devices.layer, LiveActivities.layer).pipe( + Layer.provide(Layer.succeed(RelayDb.RelayDb, db)), + ); + yield* Effect.gen(function* () { + const devices = yield* Devices.Devices; + const liveActivities = yield* LiveActivities.LiveActivities; + const register = (disabledEnvironmentIds: ReadonlyArray | undefined) => + devices.register({ + userId: "user", + registration: { + deviceId: "phone", + label: "Pixel", + platform: "android", + androidApiLevel: 35, + pushToken: "fcm-token", + preferences: { + ...preferences, + ...(disabledEnvironmentIds ? { disabledEnvironmentIds } : {}), + }, + }, + }); + const revealedAt = liveActivities + .listTargets({ userId: "user" }) + .pipe(Effect.map(([target]) => target?.environment_revealed_at)); + const at = (ms: number) => TestClock.setTime(ms); + + yield* register([a, b]); + expect(yield* revealedAt).toBeNull(); + yield* at(1_000); + yield* register([b]); + expect(yield* revealedAt).toEqual({ [a]: 1_000 }); + // A refresh with unchanged preferences keeps the earlier re-enable time. + yield* at(2_000); + yield* register([b]); + expect(yield* revealedAt).toEqual({ [a]: 1_000 }); + yield* at(3_000); + yield* register(undefined); + expect(yield* revealedAt).toEqual({ [a]: 1_000, [b]: 3_000 }); + // Waiting attention outlives queued jobs, so an unchanged refresh must preserve this history. + yield* at(1_000 + 5 * 60_000 + 1); + yield* register([a]); + expect(yield* revealedAt).toEqual({ [a]: 1_000, [b]: 3_000 }); + yield* at(1_000 + 24 * 60 * 60_000 + 1); + yield* register([a]); + expect(yield* revealedAt).toEqual({ [b]: 3_000 }); + yield* at(2_000 + 24 * 60 * 60_000); + yield* register([]); + expect(yield* revealedAt).toEqual({ [a]: 2_000 + 24 * 60 * 60_000, [b]: 3_000 }); + }).pipe(Effect.provide(services)); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index a35fb4aaf8e4b..b8cd35ccfea72 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -12,6 +12,7 @@ import { sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; +import { MAX_AGENT_ACTIVITY_ROW_TTL_MS } from "./agentActivityPayloads.ts"; export class DeviceRegistrationPersistenceError extends Schema.TaggedError()( "DeviceRegistrationPersistenceError", @@ -78,7 +79,8 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.registration.deviceId, }); - const updatedAt = DateTime.formatIso(yield* DateTime.now); + const now = yield* DateTime.now; + const updatedAt = DateTime.formatIso(now); const registration = input.registration; // Alchemy's lazy drizzle proxies become Effects only at yield*; Effect.all sends raw proxies into a spinning fiber. @@ -155,6 +157,28 @@ const make = Effect.gen(function* () { ${relayMobileDevices.pushToStartToken} )`, preferencesJson: registration.preferences, + // The conflict row still holds the previous preferences, so this is atomic with the write. + environmentRevealedAt: sql`nullif( + coalesce(( + select jsonb_object_agg(kept.key, kept.value) + from jsonb_each( + coalesce(${relayMobileDevices.environmentRevealedAt}, '{}'::jsonb) + ) as kept + where (kept.value)::bigint >= ${now.epochMilliseconds - MAX_AGENT_ACTIVITY_ROW_TTL_MS} + ), '{}'::jsonb) + || coalesce(( + select jsonb_object_agg(revealed.id, ${now.epochMilliseconds}::bigint) + from jsonb_array_elements_text(coalesce( + ${relayMobileDevices.preferencesJson} -> 'disabledEnvironmentIds', + '[]'::jsonb + )) as revealed(id) + where not coalesce( + excluded.preferences_json -> 'disabledEnvironmentIds', + '[]'::jsonb + ) @> jsonb_build_array(revealed.id) + ), '{}'::jsonb), + '{}'::jsonb + )`, updatedAt, }, }) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index f1ce50d5832b6..ddb80a4c0b765 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -1,20 +1,27 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import type { + RelayAgentActivityState, + RelayAgentAwarenessPreferences, +} from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import type * as Cloudflare from "@alchemy.run/cloudflare"; import * as FcmDeliveryQueueConsumer from "./FcmDeliveryQueueConsumer.ts"; import * as RelayConfiguration from "../Config.ts"; import * as RelayDb from "../db.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import { makeTestRelayDb } from "../persistence/testDatabase.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as Devices from "./Devices.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; import * as FcmClient from "./FcmClient.ts"; @@ -61,6 +68,8 @@ const target: LiveActivities.TargetRow = { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; const config = { @@ -83,7 +92,7 @@ const config = { managedEndpointNamespace: undefined, } satisfies RelayConfiguration.RelayConfiguration["Service"]; -function harness() { +function harness(persisted?: LiveActivities.LiveActivities["Service"]) { const sent: Array[0]> = []; const queued: FcmDeliveries.FcmDeliveryJob[] = []; const marked: Array[0]> = []; @@ -117,18 +126,36 @@ function harness() { }), ), }), - Layer.succeed(LiveActivities.LiveActivities, { - register: () => Effect.void, - listTargets: () => Effect.sync(() => [current.target]), - listIdleArmedTargets: () => Effect.succeed([]), - markDelivery: (input) => - Effect.sync(() => { - marked.push(input); - current.target.last_aggregate_json = input.aggregate ? encodeJson(input.aggregate) : null; - }), + Layer.succeed( + LiveActivities.LiveActivities, + persisted + ? { + ...persisted, + markDelivery: (input) => + persisted.markDelivery(input).pipe( + Effect.tap(() => + Effect.sync(() => { + marked.push(input); + }), + ), + ), + } + : { + register: () => Effect.void, + listTargets: () => Effect.sync(() => [current.target]), + listIdleArmedTargets: () => Effect.succeed([]), + markDelivery: (input) => + Effect.sync(() => { + marked.push(input); + current.target.last_aggregate_json = input.aggregate + ? encodeJson(input.aggregate) + : null; + current.target.last_hidden_environment_ids = input.hiddenEnvironmentIds; + }), - invalidateDeliveryToken: () => Effect.void, - }), + invalidateDeliveryToken: () => Effect.void, + }, + ), Layer.succeed(AgentActivityRows.AgentActivityRows, { upsert: () => Effect.void, remove: () => Effect.void, @@ -208,6 +235,54 @@ function harness() { }; } +// The phone's preferences and delivery baseline live in the production device store. +function withRegisteredDevice( + body: (input: { + readonly h: ReturnType; + readonly register: ( + preferences: RelayAgentAwarenessPreferences, + ) => ReturnType; + readonly target: Effect.Effect< + LiveActivities.TargetRow, + Effect.Error> + >; + }) => Effect.Effect, +) { + return Effect.gen(function* () { + const db = yield* makeTestRelayDb; + return yield* Effect.gen(function* () { + const devices = yield* Devices.Devices; + const liveActivities = yield* LiveActivities.LiveActivities; + const h = harness(liveActivities); + return yield* body({ + h, + register: (preferences) => + devices.register({ + userId: target.user_id, + registration: { + deviceId: target.device_id, + label: "Pixel", + platform: "android", + androidApiLevel: 35, + bundleId: target.bundle_id!, + pushToken: target.push_token!, + preferences, + }, + }), + target: liveActivities + .listTargets({ userId: target.user_id }) + .pipe(Effect.map(([row]) => row!)), + }).pipe(Effect.provide(h.layer)); + }).pipe( + Effect.provide( + Layer.mergeAll(Devices.layer, LiveActivities.layer).pipe( + Layer.provide(Layer.succeed(RelayDb.RelayDb, db)), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)); +} + describe("Android delivery routing", () => { const secondState: RelayAgentActivityState = { ...state, @@ -353,6 +428,198 @@ describe("Android delivery routing", () => { }, ); + it.effect("keeps a device-disabled environment off the phone until a silent re-enable", () => + withRegisteredDevice(({ h, register, target }) => + Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + const alerts = () => h.sent.filter((message) => message.alert); + h.current.otherStates = [other]; + yield* register(preferences); + yield* delivery.process({ ...h.job, state: null, replay: true }); + h.current.state = { ...state, phase: "waiting_for_approval" }; + h.current.otherStates = [{ ...other, phase: "waiting_for_input" }]; + const otherJob = { ...h.job, state: h.current.otherStates[0]! }; + yield* TestClock.adjust("1 second"); + yield* register({ ...preferences, disabledEnvironmentIds: [other.environmentId] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process(otherJob); + expect(alerts().map((message) => message.data)).toEqual([ + expect.objectContaining({ alert_title: "Fix notifications" }), + ]); + expect(h.marked.at(-1)?.aggregate?.activities).toMatchObject([ + { environmentId: state.environmentId }, + ]); + + yield* TestClock.adjust("1 second"); + yield* register(preferences); + yield* delivery.enqueue({ target: yield* target, state: null, replay: true }); + // Another event can reach the phone before the re-enable replay does. + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process(h.queued[0]); + yield* delivery.process(otherJob); + expect(alerts()).toHaveLength(1); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + + const hideBoth = [state.environmentId, other.environmentId]; + yield* TestClock.adjust("1 second"); + yield* register({ ...preferences, disabledEnvironmentIds: hideBoth }); + yield* delivery.process({ ...h.job, state: null, replay: true }); + expect(h.marked.at(-1)?.aggregate).toBeNull(); + yield* TestClock.adjust("1 second"); + yield* register(preferences); + yield* delivery.process(otherJob); + expect(alerts()).toHaveLength(1); + + const cardsOff = { ...preferences, liveActivitiesEnabled: false }; + yield* TestClock.adjust("1 second"); + yield* register({ ...cardsOff, disabledEnvironmentIds: hideBoth }); + yield* delivery.process({ ...h.job, state: null, replay: true }); + yield* TestClock.adjust("1 second"); + yield* register(cardsOff); + yield* delivery.process(otherJob); + expect(alerts()).toHaveLength(1); + yield* TestClock.adjust("1 second"); + h.current.otherStates = [{ ...other, phase: "completed" }]; + yield* delivery.enqueue({ target: yield* target, state: h.current.otherStates[0]! }); + yield* delivery.process(h.queued[1]); + expect(alerts()).toHaveLength(2); + }), + ), + ); + + it.effect("an unchanged refresh preserves old attention exclusions after the job lifetime", () => + withRegisteredDevice(({ h, register, target }) => + Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + h.current.otherStates = [other]; + yield* register(preferences); + yield* delivery.process({ ...h.job, state: null, replay: true }); + yield* TestClock.adjust("1 second"); + yield* register({ ...preferences, disabledEnvironmentIds: [other.environmentId] }); + h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }]; + yield* delivery.process({ ...h.job, state: null, replay: true }); + yield* TestClock.adjust("1 second"); + yield* register(preferences); + yield* TestClock.adjust("6 minutes"); + yield* register(preferences); + h.current.state = { + ...state, + phase: "waiting_for_input", + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + yield* delivery.enqueue({ target: yield* target, state: h.current.state }); + yield* delivery.process(h.queued[0]); + expect(h.sent.filter((message) => message.alert).map((message) => message.data)).toEqual([ + expect.objectContaining({ alert_title: state.threadTitle }), + ]); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }), + ), + ); + + it.effect.each([ + { + name: "with activity cards off", + cards: false, + notificationOnly: false, + queued: "before disable", + hiddenDelivery: false, + order: "stale replay new pending", + }, + { + name: "for a notification-only environment", + cards: true, + notificationOnly: true, + queued: "while hidden", + hiddenDelivery: true, + order: "replay stale pending new", + }, + { + name: "with activity cards on", + cards: true, + notificationOnly: false, + queued: "while hidden", + hiddenDelivery: true, + order: "stale new pending replay", + }, + ] as const)( + "re-enabling $name silences an alert queued $queued (hidden delivery: $hiddenDelivery, order: $order)", + (config) => + withRegisteredDevice(({ h, register, target }) => + Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + const environmentId = EnvironmentId.make("other-env"); + const stale = { + ...secondState, + environmentId, + threadTitle: "Stale approval", + phase: "waiting_for_approval", + } as const; + const pending = { + ...stale, + environmentId: EnvironmentId.make("pending-env"), + threadTitle: "Pending approval", + }; + h.current.state = null; + h.current.otherStates = [stale, pending]; + h.current.notificationOnlyEnvironments = [ + pending.environmentId, + ...(config.notificationOnly ? [environmentId] : []), + ]; + const jobs = new Map(); + const enqueue = (name: string, state: RelayAgentActivityState | null) => + Effect.gen(function* () { + yield* delivery.enqueue({ + target: yield* target, + state, + ...(state ? {} : { replay: true }), + }); + jobs.set(name, h.queued.at(-1)!); + }); + const visible = { ...preferences, liveActivitiesEnabled: config.cards }; + + yield* register(visible); + if (config.cards && !config.notificationOnly) { + h.current.otherStates = [{ ...stale, phase: "running" }, pending]; + yield* delivery.process({ ...h.job, state: null, replay: true }); + h.current.otherStates = [stale, pending]; + } + yield* enqueue("pending", pending); + if (config.queued === "before disable") yield* enqueue("stale", stale); + yield* TestClock.adjust("1 second"); + yield* register({ ...visible, disabledEnvironmentIds: [environmentId] }); + if (config.hiddenDelivery) + yield* delivery.process({ ...h.job, state: null, replay: true }); + if (config.queued === "while hidden") yield* enqueue("stale", stale); + yield* TestClock.adjust("1 second"); + yield* register(visible); + yield* enqueue("replay", null); + // A new event after the re-enable alerts even if the re-enable replay is still queued. + yield* TestClock.adjust("1 second"); + const fresh = { + ...stale, + threadId: ThreadId.make("new-thread"), + threadTitle: "New", + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + h.current.otherStates = [stale, fresh, pending]; + yield* enqueue("new", fresh); + + const order = config.order.split(" "); + for (const name of order) yield* delivery.process(jobs.get(name)!); + expect( + h.sent.filter((message) => message.alert).map((message) => message.data.alert_title), + ).toEqual( + order + .filter((name) => name === "pending" || name === "new") + .map((name) => (name === "new" ? fresh : pending).threadTitle), + ); + }), + ), + ); + it("gives a group a stable retry identity independent of row order", () => { const other = { ...secondState, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index d2795a90a88e5..7f4fd180811e7 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -26,8 +26,11 @@ import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; import { + ANDROID_DELIVERY_JOB_TTL_MS, alertForActivityRows, attentionTransitionRows, + hiddenEnvironmentIds, + sameEnvironmentIds, terminalTransitionRows, shouldAlertForActivity, } from "./agentActivityAlerts.ts"; @@ -76,6 +79,7 @@ export function androidAlertForState( export function androidAlertForAggregate(input: { readonly previousAggregate: RelayAgentActivityAggregateState; + readonly previousHiddenEnvironmentIds?: ReadonlyArray | null; readonly nextAggregate: RelayAgentActivityAggregateState; readonly preferences: RelayAgentAwarenessPreferences; readonly nowMs: number; @@ -187,7 +191,7 @@ const make = Effect.gen(function* () { Effect.mapError((cause) => new FcmDeliveryError({ operation: "decode-job", cause })), ); const now = yield* DateTime.now; - if (now.epochMilliseconds - job.queuedAt > 5 * 60_000) return; + if (now.epochMilliseconds - job.queuedAt > ANDROID_DELIVERY_JOB_TTL_MS) return; const targets = yield* devices.listTargets({ userId: job.userId }); const target = targets.find( (device) => @@ -198,6 +202,7 @@ const make = Effect.gen(function* () { if (!target) return; const preferences = decodePreferences(target.preferences_json); if (Option.isNone(preferences)) return; + const currentHiddenEnvironmentIds = hiddenEnvironmentIds(preferences.value); // Re-read at consumption to honor current links, tokens, preferences, and thread states. const states = preferences.value.liveActivitiesEnabled @@ -207,6 +212,7 @@ const make = Effect.gen(function* () { activeStates: states, terminalState: null, nowMs: now.epochMilliseconds, + preferences: preferences.value, }); const previousAggregate = target.last_aggregate_json ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) @@ -242,7 +248,7 @@ const make = Effect.gen(function* () { if ( deliveryUser?.liveActivitiesEnabled && preferences.value.liveActivitiesEnabled && - previousAggregate && + (previousAggregate || target.environment_revealed_at) && aggregate ) { const environmentIds = [...new Set(aggregate.activities.map((row) => row.environmentId))]; @@ -262,12 +268,20 @@ const make = Effect.gen(function* () { } } alert = androidAlertForAggregate({ - previousAggregate, + previousAggregate: previousAggregate ?? { + ...aggregate, + activities: [], + activeCount: 0, + }, nextAggregate: { ...aggregate, - activities: aggregate.activities.filter((row) => - allowedEnvironments.has(row.environmentId), - ), + activities: aggregate.activities.filter((row) => { + const revealedAt = target.environment_revealed_at?.[row.environmentId]; + return ( + allowedEnvironments.has(row.environmentId) && + (revealedAt === undefined || Date.parse(row.updatedAt) > revealedAt) + ); + }), }, preferences: preferences.value, nowMs: now.epochMilliseconds, @@ -276,6 +290,8 @@ const make = Effect.gen(function* () { deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && state.updatedAt === job.state.updatedAt && + // Jobs queued before the environment was re-enabled are history, like the replay baseline. + job.queuedAt > (target.environment_revealed_at?.[state.environmentId] ?? -Infinity) && !isExpiredAgentActivityState(state, now.epochMilliseconds) ) { alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); @@ -283,7 +299,14 @@ const make = Effect.gen(function* () { } const displayedAggregate = preferences.value.liveActivitiesEnabled ? aggregate : null; const active = (displayedAggregate?.activeCount ?? 0) > 0; - if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; + if ( + !displayedAggregate && + !alert && + !previousAggregate && + job.state !== null && + sameEnvironmentIds(target.last_hidden_environment_ids, currentHiddenEnvironmentIds) + ) + return; const data: Record = { t3_kind: "agent_activity", device_id: job.deviceId, @@ -329,6 +352,7 @@ const make = Effect.gen(function* () { kind: active ? "live_activity_update" : "live_activity_end", // Retain terminal rows as the baseline so finished-card replays cannot alert again. aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, + hiddenEnvironmentIds: currentHiddenEnvironmentIds, deliveredAt: DateTime.formatIso(now), }); } diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 5da94df01f241..74f7a8741e369 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -113,6 +113,7 @@ describe("LiveActivities", () => { deviceId: "device-1", kind: "live_activity_update", aggregate, + hiddenEnvironmentIds: ["hidden-env"], deliveredAt: "2026-05-25T00:00:10.000Z", }); @@ -126,6 +127,7 @@ describe("LiveActivities", () => { expect(conflictConfigs[0]?.set).toEqual( expect.objectContaining({ endedAt: relayLiveActivities.endedAt, + lastHiddenEnvironmentIds: ["hidden-env"], lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", }), ); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 8fe571a254e35..30056d95e7025 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -81,6 +81,7 @@ export interface DeviceRow { readonly push_token: string | null; readonly push_to_start_token: string | null; readonly preferences_json: string; + readonly environment_revealed_at: Readonly> | null; } export interface LiveActivityRow { @@ -89,6 +90,7 @@ export interface LiveActivityRow { readonly remote_started_at: string | null; readonly ended_at: string | null; readonly last_aggregate_json: string | null; + readonly last_hidden_environment_ids: ReadonlyArray | null; readonly last_live_activity_delivery_at: string | null; } @@ -115,6 +117,7 @@ export class LiveActivities extends Context.Service< readonly deviceId: string; readonly kind: Exclude; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly hiddenEnvironmentIds: ReadonlyArray | null; readonly deliveredAt: string; }) => Effect.Effect; readonly invalidateDeliveryToken: (input: { @@ -183,6 +186,7 @@ const make = Effect.gen(function* () { remoteStartedAt: armedAt, endedAt: null, lastAggregateJson: null, + lastHiddenEnvironmentIds: null, lastLiveActivityDeliveryAt: null, createdAt: updatedAt, updatedAt, @@ -195,6 +199,7 @@ const make = Effect.gen(function* () { remoteStartedAt: armedAt, endedAt: null, lastAggregateJson: null, + lastHiddenEnvironmentIds: null, lastLiveActivityDeliveryAt: null, updatedAt, }, @@ -224,11 +229,13 @@ const make = Effect.gen(function* () { push_token: relayMobileDevices.pushToken, push_to_start_token: relayMobileDevices.pushToStartToken, preferences_json: relayMobileDevices.preferencesJson, + environment_revealed_at: relayMobileDevices.environmentRevealedAt, activity_push_token: relayLiveActivities.activityPushToken, remote_start_queued_at: relayLiveActivities.remoteStartQueuedAt, remote_started_at: relayLiveActivities.remoteStartedAt, ended_at: relayLiveActivities.endedAt, last_aggregate_json: relayLiveActivities.lastAggregateJson, + last_hidden_environment_ids: relayLiveActivities.lastHiddenEnvironmentIds, last_live_activity_delivery_at: relayLiveActivities.lastLiveActivityDeliveryAt, }) .from(relayMobileDevices) @@ -320,6 +327,7 @@ const make = Effect.gen(function* () { remoteStartQueuedAt: null, endedAt: input.kind === "live_activity_end" ? input.deliveredAt : null, lastAggregateJson: aggregateJson, + lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, lastLiveActivityDeliveryAt: input.deliveredAt, createdAt: input.deliveredAt, updatedAt: input.deliveredAt, @@ -341,6 +349,7 @@ const make = Effect.gen(function* () { ? input.deliveredAt : relayLiveActivities.endedAt, lastAggregateJson: aggregateJson, + lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, lastLiveActivityDeliveryAt: input.deliveredAt, updatedAt: input.deliveredAt, }, diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 1865a633d5715..2d47c4c8abbcb 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -273,6 +273,8 @@ describe("MobileRegistrations", () => { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }, ]), @@ -295,4 +297,68 @@ describe("MobileRegistrations", () => { }).pipe(Effect.provide(layerRegistrationReplay({ devices, liveActivities, queuedJobs }))); }, ); + + describe("with every environment that has work disabled on the device", () => { + const armedTarget: LiveActivities.TargetRow = { + user_id: "dev:julius", + device_id: "device-1", + platform: "ios", + ios_major_version: 18, + app_version: "1.0.0", + bundle_id: null, + aps_environment: null, + push_token: null, + push_to_start_token: null, + preferences_json: JSON.stringify({ + ...device.preferences, + disabledEnvironmentIds: ["env-1"], + }), + activity_push_token: "activity-token", + remote_start_queued_at: null, + remote_started_at: "1970-01-01T00:00:00.000Z", + ended_at: null, + last_aggregate_json: null, + last_hidden_environment_ids: null, + environment_revealed_at: null, + last_live_activity_delivery_at: null, + }; + const layer = (queuedJobs: Array) => + layerRegistrationReplay({ + devices: makeDevices(), + liveActivities: makeLiveActivities({ listTargets: () => Effect.succeed([armedTarget]) }), + queuedJobs, + }); + + it.effect("gives that device no snapshot to start a card from", () => + Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + const filtered = yield* registrations.getAgentActivitySnapshot({ + userId: "dev:julius", + deviceId: "device-1", + }); + const unfiltered = yield* registrations.getAgentActivitySnapshot({ userId: "dev:julius" }); + + expect(filtered.aggregate).toBeNull(); + expect(unfiltered.aggregate?.activeCount).toBe(1); + }).pipe(Effect.provide(layer([]))), + ); + + it.effect("ends a freshly armed card instead of waiting for work", () => { + const queuedJobs: Array = []; + return Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + yield* registrations.registerLiveActivity({ + userId: "dev:julius", + payload: { + deviceId: device.deviceId, + activityPushToken: "activity-token", + }, + }); + + expect(queuedJobs).toMatchObject([ + { payload: { kind: "live_activity_end", aggregate: null } }, + ]); + }).pipe(Effect.provide(layer(queuedJobs))); + }); + }); }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 0818a90e22bb2..0f7b6b507627b 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -1,4 +1,4 @@ -import { makeAggregateState } from "./agentActivityAggregate.ts"; +import { makeAggregateState, parseDevicePreferences } from "./agentActivityAggregate.ts"; import type { RelayAgentActivitySnapshotResponse, RelayDeviceRegistrationRequest, @@ -18,6 +18,7 @@ export type MobileRegistrationError = | Devices.DeviceRegistrationPersistenceError | Devices.DeviceUnregistrationPersistenceError | LiveActivities.LiveActivityRegistrationPersistenceError + | LiveActivities.LiveActivityTargetListPersistenceError | AgentActivityRows.AgentActivityRowListPersistenceError; export class MobileRegistrations extends Context.Service< @@ -37,6 +38,7 @@ export class MobileRegistrations extends Context.Service< }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; readonly getAgentActivitySnapshot: (input: { readonly userId: string; + readonly deviceId?: string; }) => Effect.Effect; } >()("t3code-relay/agentActivity/MobileRegistrations") {} @@ -94,12 +96,19 @@ const make = Effect.gen(function* () { getAgentActivitySnapshot: Effect.fn("relay.mobile_registrations.get_agent_activity_snapshot")( function* (input) { const activeStates = yield* rows.listForUser({ userId: input.userId }); + const target = + input.deviceId === undefined + ? undefined + : (yield* liveActivities.listTargets({ userId: input.userId })).find( + (row) => row.device_id === input.deviceId, + ); const now = yield* DateTime.now; return { aggregate: makeAggregateState({ activeStates, terminalState: null, nowMs: now.epochMilliseconds, + preferences: target ? parseDevicePreferences(target.preferences_json) : null, }), }; }, diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 99fdcf71ef90d..1e9e0c3c54d59 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -1,9 +1,12 @@ -import type { - RelayAgentActivityAggregateState, - RelayAgentActivityState, +import { + RelayAgentAwarenessPreferences, + type RelayAgentActivityAggregateState, + type RelayAgentActivityState, } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { environmentEnabledOnDevice } from "./agentActivityAlerts.ts"; import { isExpiredAgentActivityState, isTerminalPhase, @@ -71,20 +74,38 @@ function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): b return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; } +const decodePreferencesJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferences), +); + +export function parseDevicePreferences(json: string): RelayAgentAwarenessPreferences | null { + return Option.getOrNull(decodePreferencesJson(json)); +} + export function makeAggregateState(input: { readonly activeStates: ReadonlyArray; readonly terminalState: RelayAgentActivityState | null; readonly nowMs: number; + readonly preferences?: RelayAgentAwarenessPreferences | null; }): RelayAgentActivityAggregateState | null { - const activeStates = input.activeStates.filter( + const preferences = input.preferences ?? null; + const states = input.activeStates.filter((state) => + environmentEnabledOnDevice(preferences, state.environmentId), + ); + const terminalState = + input.terminalState !== null && + environmentEnabledOnDevice(preferences, input.terminalState.environmentId) + ? input.terminalState + : null; + const activeStates = states.filter( (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), ); if (activeStates.length === 0) { - if (input.terminalState !== null) { - return terminalAggregateState(input.terminalState); + if (terminalState !== null) { + return terminalAggregateState(terminalState); } // Alerts compare with the delivered aggregate; replays must repaint without buzzing. - const recentTerminal = input.activeStates + const recentTerminal = states .filter((state) => isRecentTerminalState(state, input.nowMs)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const newest = recentTerminal[0]; @@ -99,7 +120,7 @@ export function makeAggregateState(input: { activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState), }); } - const recentTerminalStates = input.activeStates + const recentTerminalStates = states .filter((state) => isRecentTerminalState(state, input.nowMs)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const displayedStates = [ diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index c6008950e95e8..862b6498003d5 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -22,6 +22,10 @@ function isFreshTerminalNotification(updatedAt: string, nowMs: number): boolean type TransitionInput = { readonly previousAggregate: RelayAgentActivityAggregateState | null; + // Rows from these environments were filtered out of the baseline, so they are not new + // unless they changed after the environment was re-enabled (epoch ms per environment). + readonly previousHiddenEnvironmentIds?: ReadonlyArray | null; + readonly environmentRevealedAt?: Readonly> | null; readonly nextAggregate: RelayAgentActivityAggregateState; readonly preferences: RelayAgentAwarenessPreferences | null; }; @@ -45,12 +49,48 @@ function isAttentionPhase(phase: string): boolean { return phase === "waiting_for_approval" || phase === "waiting_for_input"; } -function alertAllowedForPhase( +// Android delivery jobs older than this are dropped unsent. +export const ANDROID_DELIVERY_JOB_TTL_MS = 5 * 60_000; + +export function environmentEnabledOnDevice( preferences: RelayAgentAwarenessPreferences | null, - phase: string, + environmentId: string, +): boolean { + return !preferences?.disabledEnvironmentIds?.some((disabled) => disabled === environmentId); +} + +export function hiddenEnvironmentIds( + preferences: RelayAgentAwarenessPreferences | null, +): ReadonlyArray { + return [...new Set(preferences?.disabledEnvironmentIds ?? [])].sort(); +} + +export function sameEnvironmentIds( + left: ReadonlyArray | null | undefined, + right: ReadonlyArray | null | undefined, +): boolean { + const a = [...new Set(left ?? [])].sort(); + const b = [...new Set(right ?? [])].sort(); + return a.length === b.length && a.every((id, index) => id === b[index]); +} + +function rowsVisibleInBaseline(input: TransitionInput) { + const hidden = input.previousHiddenEnvironmentIds; + if (!hidden?.length) return input.nextAggregate.activities; + return input.nextAggregate.activities.filter((row) => { + if (!hidden.includes(row.environmentId)) return true; + const revealedAt = input.environmentRevealedAt?.[row.environmentId]; + return revealedAt !== undefined && Date.parse(row.updatedAt) > revealedAt; + }); +} + +function alertAllowedForRow( + preferences: RelayAgentAwarenessPreferences | null, + row: { readonly phase: string; readonly environmentId: string }, ): boolean { if (preferences === null) return true; - switch (phase) { + if (!environmentEnabledOnDevice(preferences, row.environmentId)) return false; + switch (row.phase) { case "waiting_for_approval": return preferences.notifyOnApproval; case "waiting_for_input": @@ -70,24 +110,23 @@ export function attentionTransitionRows(input: TransitionInput) { const previouslyAttention = new Set( input.previousAggregate.activities.filter((row) => isAttentionPhase(row.phase)).map(rowKey), ); - return input.nextAggregate.activities.filter( + return rowsVisibleInBaseline(input).filter( (row) => isAttentionPhase(row.phase) && !previouslyAttention.has(rowKey(row)) && - alertAllowedForPhase(input.preferences, row.phase), + alertAllowedForRow(input.preferences, row), ); } function newlyTerminalRows( - previousAggregate: RelayAgentActivityAggregateState | null, - nextAggregate: RelayAgentActivityAggregateState, + input: TransitionInput, includeUnobserved = false, ): ReadonlyArray { - if (previousAggregate === null) return []; + if (input.previousAggregate === null) return []; const previousPhases = new Map( - previousAggregate.activities.map((row) => [rowKey(row), row.phase]), + input.previousAggregate.activities.map((row) => [rowKey(row), row.phase]), ); - return nextAggregate.activities.filter((row) => { + return rowsVisibleInBaseline(input).filter((row) => { if (row.phase !== "completed" && row.phase !== "failed") return false; const previousPhase = previousPhases.get(rowKey(row)); return ( @@ -101,13 +140,9 @@ function newlyTerminalRows( export function terminalTransitionRows( input: TransitionInput & { readonly nowMs: number; readonly includeUnobserved?: boolean }, ) { - return newlyTerminalRows( - input.previousAggregate, - input.nextAggregate, - input.includeUnobserved, - ).filter((row) => { + return newlyTerminalRows(input, input.includeUnobserved).filter((row) => { return ( - alertAllowedForPhase(input.preferences, row.phase) && + alertAllowedForRow(input.preferences, row) && isFreshTerminalNotification(row.updatedAt, input.nowMs) ); }); @@ -143,10 +178,11 @@ export function alertForTerminalAggregate(input: { }): AgentActivityAlert | null { const row = input.aggregate?.activities[0]; if (!row || (row.phase !== "completed" && row.phase !== "failed")) return null; - return alertAllowedForPhase(input.preferences, row.phase) ? alertForActivityRows([row]) : null; + return alertAllowedForRow(input.preferences, row) ? alertForActivityRows([row]) : null; } export function shouldAlertForActivity(input: { + readonly environmentId: string; readonly phase: RelayAgentActivityAggregateRow["phase"]; readonly updatedAt: string; readonly preferences: RelayAgentAwarenessPreferences | null; @@ -154,7 +190,7 @@ export function shouldAlertForActivity(input: { }): boolean { return ( input.preferences?.notificationsEnabled === true && - alertAllowedForPhase(input.preferences, input.phase) && + alertAllowedForRow(input.preferences, input) && ((input.phase !== "completed" && input.phase !== "failed") || isFreshTerminalNotification(input.updatedAt, input.nowMs)) ); diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index 4195c81171803..73581f8fd0302 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -14,7 +14,7 @@ export function isTerminalPhase(state: RelayAgentActivityState): boolean { // Dead environments never publish terminal states; expire their rows so activeCount cannot remain inflated. 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 const MAX_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; export function agentActivityExpiresAt( state: Pick, @@ -29,7 +29,7 @@ export function agentActivityExpiresAt( const ttlMs = state.phase === "running" || state.phase === "starting" ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS - : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; + : MAX_AGENT_ACTIVITY_ROW_TTL_MS; return updatedAtMs + ttlMs; } diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index e73cfd85145a5..359b4e34f9982 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -101,5 +101,11 @@ describe("shared agent activity policy", () => { }), ).toBe(false); expect(shouldAlertForActivity({ ...input, nowMs: 180_000 })).toBe(false); + expect( + shouldAlertForActivity({ + ...input, + preferences: { ...preferences, disabledEnvironmentIds: [state.environmentId] }, + }), + ).toBe(false); }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index b732507a731a5..050f3feeda3b9 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -60,6 +60,8 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ aggregate: Schema.NullOr(RelayAgentActivityAggregateState), notification: Schema.NullOr(ApnsNotificationPayload), alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)), + // Environments the device hid when the aggregate was built. + hiddenEnvironmentIds: Schema.optional(Schema.Array(Schema.String)), createdAt: Schema.String, expiresAt: Schema.String, }); @@ -235,6 +237,7 @@ export function makeApnsDeliveryJobPayload(input: { readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly notification?: ApnsNotificationPayload | null; readonly alert?: ApnsLiveActivityAlert | null | undefined; + readonly hiddenEnvironmentIds?: ReadonlyArray | undefined; readonly createdAt: string; readonly expiresAt: string; readonly jobId: string; @@ -254,6 +257,9 @@ export function makeApnsDeliveryJobPayload(input: { notification: input.notification ?? null, // Omit absent fields to preserve signatures from older relay builds. ...(input.alert ? { alert: input.alert } : {}), + ...(input.hiddenEnvironmentIds?.length + ? { hiddenEnvironmentIds: input.hiddenEnvironmentIds } + : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, }; diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index f06d441c12c48..35b1db98eadb8 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -777,13 +777,16 @@ export const layerMobileApi = HttpApiBuilder.group( ) .handle( "getAgentActivitySnapshot", - Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () { + Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* ({ query }) { const { userId, token } = yield* RelayClientPrincipal; const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); yield* requireDpopThumbprint(proofKeyThumbprint, { expectedAccessToken: token, }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); - return yield* registrations.getAgentActivitySnapshot({ userId }); + return yield* registrations.getAgentActivitySnapshot({ + userId, + ...(query.deviceId ? { deviceId: query.deviceId } : {}), + }); }, mapRelayCommonApiErrors("invalid_dpop")), ) .handle( diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 0a15473776df9..fa1528b24e5b0 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -32,6 +32,9 @@ export const relayMobileDevices = pgTable( pushToken: text("push_token"), pushToStartToken: text("push_to_start_token"), preferencesJson: jsonb("preferences_json").notNull().$type(), + // Epoch ms each environment was last re-enabled; Android jobs queued before then stay silent. + environmentRevealedAt: + jsonb("environment_revealed_at").$type>>(), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), }, @@ -52,6 +55,8 @@ export const relayLiveActivities = pgTable( remoteStartedAt: varchar("remote_started_at", { length: 64 }), endedAt: varchar("ended_at", { length: 64 }), lastAggregateJson: jsonb("last_aggregate_json").$type(), + // Environments the device hid when that baseline was computed; their rows are not new there. + lastHiddenEnvironmentIds: jsonb("last_hidden_environment_ids").$type>(), lastLiveActivityDeliveryAt: varchar("last_live_activity_delivery_at", { length: 64 }), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), diff --git a/infra/relay/src/persistence/testDatabase.ts b/infra/relay/src/persistence/testDatabase.ts new file mode 100644 index 0000000000000..31fc9203fa55d --- /dev/null +++ b/infra/relay/src/persistence/testDatabase.ts @@ -0,0 +1,49 @@ +import { PgClient } from "@effect/sql-pg/PgClient"; +import { PGlite } from "@electric-sql/pglite"; +import * as PgDrizzle from "drizzle-orm/effect-postgres"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import type * as RelayDb from "../db.ts"; + +class TestQueryError extends Schema.TaggedError()("TestQueryError", { + sql: Schema.String, + cause: Schema.Defect(), +}) {} + +// Runs the production drizzle queries against an in-memory Postgres with every relay migration applied. +export const makeTestRelayDb = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pg = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create()), + (client) => Effect.promise(() => client.close()), + ); + const migrationsDir = yield* path.fromFileUrl( + new URL("../../migrations/postgres/", import.meta.url), + ); + for (const name of (yield* fs.readDirectory(migrationsDir)).toSorted()) { + const migration = yield* fs.readFileString(path.join(migrationsDir, name, "migration.sql")); + yield* Effect.promise(() => pg.exec(migration)); + } + const query = (sql: string, params: ReadonlyArray, rowMode?: "array") => + Effect.tryPromise({ + try: () => pg.query(sql, [...params], rowMode ? { rowMode } : undefined), + catch: (cause) => new TestQueryError({ sql, cause }), + }); + // The subset of @effect/sql-pg's PgClient that drizzle's effect-postgres session calls. + const client = { + unsafe: (sql: string, params: ReadonlyArray = []) => ({ + raw: query(sql, params), + values: query(sql, params, "array").pipe(Effect.map((result) => result.rows)), + withoutTransform: query(sql, params).pipe(Effect.map((result) => result.rows)), + }), + withTransaction: (effect: Effect.Effect) => effect, + }; + const db = yield* PgDrizzle.makeWithDefaults().pipe( + Effect.provideService(PgClient, client as unknown as PgClient), + ); + return db as unknown as RelayDb.RelayDb["Service"]; +}); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index c10e2d5a5eca5..5c6c86464e3c0 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -316,6 +316,7 @@ export class ManagedRelayClient extends Context.Service< }) => Effect.Effect; readonly getAgentActivitySnapshot: (input: { readonly clerkToken: string; + readonly deviceId?: string; }) => Effect.Effect; readonly resetTokenCache: Effect.Effect; } @@ -502,7 +503,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( }), getAgentActivitySnapshot: (): DpopProofTarget => ({ method: RelayAgentActivitySnapshotEndpoint.method, - url: urlBuilder.mobile.getAgentActivitySnapshot(), + // DPoP proofs bind the path without its query. + url: urlBuilder.mobile.getAgentActivitySnapshot({ query: {} }), }), registerLiveActivity: (): DpopProofTarget => ({ method: RelayRegisterLiveActivityEndpoint.method, @@ -960,6 +962,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( client.mobile .getAgentActivitySnapshot({ headers: dpopHeaders(authorization), + query: input.deviceId ? { deviceId: input.deviceId } : {}, }) .pipe( Effect.mapError(relayRequestError("read relay agent activity snapshot")), diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8ebc9df23a0bb..a27eb23b449df 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -37,6 +37,8 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ notifyOnInput: Schema.Boolean, notifyOnCompletion: Schema.Boolean, notifyOnFailure: Schema.Boolean, + // Environments this device turned off; the relay neither shows nor alerts them there. + disabledEnvironmentIds: Schema.optional(Schema.Array(EnvironmentId)), }); export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; @@ -1055,6 +1057,8 @@ export const RelayAgentActivitySnapshotEndpoint = HttpApiEndpoint.get( "/v1/mobile/agent-activity", { headers: RelayDpopRequestHeaders, + // Applies that device's environment filter; older clients omit it. + query: Schema.Struct({ deviceId: Schema.optionalKey(TrimmedNonEmptyString) }), success: RelayAgentActivitySnapshotResponse, error: RelayAuthAndInternalErrors, },