From 5649b5edb8dbeee35fba797b8508d428c7f9efff Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:15:32 +0000 Subject: [PATCH 01/16] fix(mobile): disabled environments stop notifying that phone Disabling an environment in the mobile app only closed its connection. The relay kept sending that environment's alerts and activity cards, and the Android receiver shows them without JS. The device registration now carries the environments disabled on that phone. The mobile app re-registers whenever the catalog's disabled set changes, even during an in-flight registration. The relay hides those rows from that device's card and skips their alerts, rechecking current preferences when a queued delivery runs. Re-enabling replays the card silently as the new baseline. Older app builds omit the field and keep today's behavior. Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/registrationPayload.ts | 5 + .../remoteRegistration.test.ts | 91 ++++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 54 ++++++++++- docs/user/mobile-notifications.md | 2 +- .../AgentActivityPublisher.test.ts | 28 +++++- .../agentActivity/AgentActivityPublisher.ts | 35 ++++--- .../relay/src/agentActivity/ApnsDeliveries.ts | 11 ++- .../src/agentActivity/FcmDeliveries.test.ts | 31 +++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 1 + .../agentActivity/agentActivityAggregate.ts | 22 ++++- .../src/agentActivity/agentActivityAlerts.ts | 23 +++-- .../agentActivity/agentActivityPolicy.test.ts | 6 ++ packages/contracts/src/relay.ts | 2 + 13 files changed, 277 insertions(+), 34 deletions(-) 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..4e7aa46e89a91 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,76 @@ 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.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..edfc9661bfcb9 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"; @@ -97,6 +99,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 +140,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 +208,8 @@ export function setAgentAwarenessRelayTokenProvider( pushTokenSubscription = null; appStateSubscription?.remove(); appStateSubscription = null; + environmentCatalogSubscription?.(); + environmentCatalogSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -218,6 +225,7 @@ export function setAgentAwarenessRelayTokenProvider( } ensurePushTokenListener(); ensureAppStateListener(); + ensureEnvironmentCatalogListener(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after cloud sign-in failed", @@ -243,6 +251,8 @@ export function releaseAgentAwarenessRelayTokenProvider(): void { pushTokenSubscription = null; appStateSubscription?.remove(); appStateSubscription = null; + environmentCatalogSubscription?.(); + environmentCatalogSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -354,6 +364,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { body.preferences.notifyOnInput, body.preferences.notifyOnCompletion, body.preferences.notifyOnFailure, + body.preferences.disabledEnvironmentIds?.join(",") ?? "", ].join("|"); } @@ -606,7 +617,8 @@ function registrationAddsInformation( next: DeviceRegistrationInput, ): boolean { return ( - next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken + next.environmentsChanged === true || + (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) ); } @@ -668,6 +680,11 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* 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({ @@ -725,6 +742,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, + disabledEnvironmentIds, }), expectedGeneration, ); @@ -783,6 +801,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 { @@ -845,6 +895,8 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { 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/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 33f1518ec30ae..324bd0cdd7389 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -143,8 +143,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]> = []; return Effect.gen(function* () { const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; yield* publisher.publish({ @@ -161,7 +173,10 @@ describe("AgentActivityPublisher", () => { { target: android, state }, { target: android, state: null, replay: true }, ]); - expect(appleDevices).toEqual(["ios"]); + expect(appleDevices).toEqual([ + ["ios", 1], + ["ios-disabled", null], + ]); }).pipe( Effect.provide( AgentActivityPublisher.layer.pipe( @@ -171,14 +186,19 @@ 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, + ]); return null; }), }), diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 6ef258acdb6d3..cfe5d25750232 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -2,16 +2,19 @@ import { makeAggregateState, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, } from "./agentActivityAggregate.ts"; -import type { - RelayAgentActivityState, - RelayDeliveryResult, - RelayPublishResponse, +import { + RelayAgentAwarenessPreferences, + type RelayAgentActivityState, + type RelayDeliveryResult, + type RelayPublishResponse, } from "@t3tools/contracts/relay"; import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { isTerminalPhase } from "./agentActivityPayloads.ts"; @@ -22,6 +25,11 @@ import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as FcmDeliveries from "./FcmDeliveries.ts"; +const decodePreferencesJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferences), +); +const decodePreferences = (value: string) => Option.getOrNull(decodePreferencesJson(value)); + export type AgentActivityPublishError = | FcmDeliveries.FcmDeliveryError | AgentActivityRows.AgentActivityRowUpsertPersistenceError @@ -66,13 +74,15 @@ 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, - }) - : null; + const liveActivityAggregateFor = (target: LiveActivities.TargetRow) => + input.deliveryUser.liveActivitiesEnabled + ? makeAggregateState({ + activeStates, + terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, + nowMs: input.nowMs, + preferences: decodePreferences(target.preferences_json), + }) + : null; const notificationOnlyAggregate = input.deliveryUser.notificationsEnabled && !input.deliveryUser.liveActivitiesEnabled && @@ -94,7 +104,7 @@ const make = Effect.gen(function* () { [ apnsDeliveries.sendForTarget({ target, - aggregate: liveActivityAggregate, + aggregate: liveActivityAggregateFor(target), nowMs: input.nowMs, }), notificationOnlyAggregate === null @@ -139,6 +149,7 @@ const make = Effect.gen(function* () { activeStates, terminalState: null, nowMs: now.epochMilliseconds, + preferences: decodePreferences(target.preferences_json), }); return yield* apnsDeliveries.sendForTarget({ target, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 8913bdd1f8c5b..da4866e592ca8 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -515,7 +515,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 +527,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. @@ -725,7 +729,10 @@ const make = Effect.gen(function* () { input.kind === "live_activity_end" && aggregate === null && parsePreferences(currentTarget.preferences_json)?.liveActivitiesEnabled !== false && - (yield* userHasContentToShow(input.target.user_id)) + (yield* userHasContentToShow( + input.target.user_id, + parsePreferences(currentTarget.preferences_json), + )) ) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 71cde83149284..56befc27425d6 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -353,6 +353,37 @@ describe("Android delivery routing", () => { }, ); + it.effect("keeps a device-disabled environment off the phone until a silent re-enable", () => { + const h = harness(); + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); + 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]! }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + disabledEnvironmentIds: [other.environmentId], + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process(otherJob); + expect(h.sent.filter((message) => message.alert).map((message) => message.data)).toEqual([ + expect.objectContaining({ alert_title: "Fix notifications" }), + ]); + expect(h.marked.at(-1)?.aggregate?.activities).toMatchObject([ + { environmentId: state.environmentId }, + ]); + + h.current.target.preferences_json = encodeJson(preferences); + yield* delivery.enqueue({ target: h.current.target, state: null, replay: true }); + yield* delivery.process(h.queued[0]); + yield* delivery.process(otherJob); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }); + 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..5f0bdcd136203 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -207,6 +207,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)) diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 99fdcf71ef90d..96a80351e20d8 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -1,9 +1,11 @@ import type { RelayAgentActivityAggregateState, RelayAgentActivityState, + RelayAgentAwarenessPreferences, } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; +import { environmentEnabledOnDevice } from "./agentActivityAlerts.ts"; import { isExpiredAgentActivityState, isTerminalPhase, @@ -75,16 +77,26 @@ 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 +111,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..58cc8ccef1fd5 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -45,12 +45,20 @@ function isAttentionPhase(phase: string): boolean { return phase === "waiting_for_approval" || phase === "waiting_for_input"; } -function alertAllowedForPhase( +export function environmentEnabledOnDevice( preferences: RelayAgentAwarenessPreferences | null, - phase: string, + environmentId: string, +): boolean { + return !preferences?.disabledEnvironmentIds?.some((disabled) => disabled === environmentId); +} + +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": @@ -74,7 +82,7 @@ export function attentionTransitionRows(input: TransitionInput) { (row) => isAttentionPhase(row.phase) && !previouslyAttention.has(rowKey(row)) && - alertAllowedForPhase(input.preferences, row.phase), + alertAllowedForRow(input.preferences, row), ); } @@ -107,7 +115,7 @@ export function terminalTransitionRows( input.includeUnobserved, ).filter((row) => { return ( - alertAllowedForPhase(input.preferences, row.phase) && + alertAllowedForRow(input.preferences, row) && isFreshTerminalNotification(row.updatedAt, input.nowMs) ); }); @@ -143,10 +151,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 +163,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/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/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8ebc9df23a0bb..179bb3e0e93f4 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; From 5f4025976543c879e293f08e5c895347d53d48a6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:42:36 +0000 Subject: [PATCH 02/16] fix(relay): keep disabled environments hidden through queued and re-enabled deliveries Review found three gaps in the per-device environment filter. Queued iOS Live Activity jobs kept the aggregate they were built with, so an older job could put a just-disabled environment back on the card. After re-enabling, an event that reached the phone before the silent replay compared against a baseline that never had that environment's rows, so old approvals alerted. Foreground priming read an unfiltered snapshot, and the fresh-card grace kept a card that only had hidden work. Each delivery baseline now records the environments it hid, and rows from those environments are not treated as new transitions. Queued iOS jobs carry the filter they were built under and are skipped when it no longer matches; the registration replay supersedes them. The snapshot endpoint takes an optional deviceId and applies that device's filter, and the grace period only covers cards with no work at all, not work the device hides. Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/remoteRegistration.ts | 12 ++- .../migration.sql | 1 + .../snapshot.json | 42 ++++++++ .../AgentActivityPublisher.test.ts | 8 +- .../agentActivity/AgentActivityPublisher.ts | 50 +++++----- .../src/agentActivity/ApnsDeliveries.test.ts | 99 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 58 +++++++---- .../src/agentActivity/ApnsDeliveryQueue.ts | 1 + .../src/agentActivity/FcmDeliveries.test.ts | 4 + .../relay/src/agentActivity/FcmDeliveries.ts | 6 ++ .../src/agentActivity/LiveActivities.test.ts | 2 + .../relay/src/agentActivity/LiveActivities.ts | 7 ++ .../agentActivity/MobileRegistrations.test.ts | 64 ++++++++++++ .../src/agentActivity/MobileRegistrations.ts | 11 ++- .../agentActivity/agentActivityAggregate.ts | 15 ++- .../src/agentActivity/agentActivityAlerts.ts | 40 +++++--- .../src/agentActivity/apnsDeliveryJobs.ts | 6 ++ infra/relay/src/http/Api.ts | 7 +- infra/relay/src/persistence/schema.ts | 2 + .../client-runtime/src/relay/managedRelay.ts | 5 +- packages/contracts/src/relay.ts | 2 + 21 files changed, 376 insertions(+), 66 deletions(-) create mode 100644 infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/migration.sql create mode 100644 infra/relay/migrations/postgres/20261107123500_live_activity_hidden_environments/snapshot.json diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index edfc9661bfcb9..2d2cc946efeb7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -65,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", ]); @@ -551,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(() => { 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/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 324bd0cdd7389..dc6490921f23f 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -49,6 +49,7 @@ function target(deviceId: string): LiveActivities.TargetRow { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, last_live_activity_delivery_at: null, }; } @@ -156,7 +157,7 @@ describe("AgentActivityPublisher", () => { }), }; const fcmCalls: Array[0]> = []; - const appleDevices: Array<[string, number | null]> = []; + const appleDevices: Array<[string, number | null, boolean]> = []; return Effect.gen(function* () { const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; yield* publisher.publish({ @@ -174,8 +175,8 @@ describe("AgentActivityPublisher", () => { { target: android, state: null, replay: true }, ]); expect(appleDevices).toEqual([ - ["ios", 1], - ["ios-disabled", null], + ["ios", 1, false], + ["ios-disabled", null, true], ]); }).pipe( Effect.provide( @@ -198,6 +199,7 @@ describe("AgentActivityPublisher", () => { 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 cfe5d25750232..ea1fdd79f2764 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -1,20 +1,18 @@ import { makeAggregateState, + parseDevicePreferences, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, } from "./agentActivityAggregate.ts"; -import { - RelayAgentAwarenessPreferences, - type RelayAgentActivityState, - type RelayDeliveryResult, - type RelayPublishResponse, +import type { + RelayAgentActivityState, + RelayDeliveryResult, + RelayPublishResponse, } from "@t3tools/contracts/relay"; import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import { isTerminalPhase } from "./agentActivityPayloads.ts"; @@ -25,11 +23,6 @@ import * as LiveActivities from "./LiveActivities.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as FcmDeliveries from "./FcmDeliveries.ts"; -const decodePreferencesJson = Schema.decodeUnknownOption( - Schema.fromJsonString(RelayAgentAwarenessPreferences), -); -const decodePreferences = (value: string) => Option.getOrNull(decodePreferencesJson(value)); - export type AgentActivityPublishError = | FcmDeliveries.FcmDeliveryError | AgentActivityRows.AgentActivityRowUpsertPersistenceError @@ -74,15 +67,10 @@ const make = Effect.gen(function* () { const activeStates = input.deliveryUser.liveActivitiesEnabled ? yield* rows.listForUser({ userId: input.deliveryUser.userId }) : []; - const liveActivityAggregateFor = (target: LiveActivities.TargetRow) => - input.deliveryUser.liveActivitiesEnabled - ? makeAggregateState({ - activeStates, - terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, - nowMs: input.nowMs, - preferences: decodePreferences(target.preferences_json), - }) - : null; + 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 && !input.deliveryUser.liveActivitiesEnabled && @@ -100,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: liveActivityAggregateFor(target), + aggregate, + contentHidden: aggregate === null && accountAggregate !== null, nowMs: input.nowMs, }), notificationOnlyAggregate === null @@ -149,11 +146,18 @@ const make = Effect.gen(function* () { activeStates, terminalState: null, nowMs: now.epochMilliseconds, - preferences: decodePreferences(target.preferences_json), + 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..a7d40e0df254c 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -135,6 +135,7 @@ 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, last_live_activity_delivery_at: null, }; @@ -848,6 +849,104 @@ 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( + "keeps attention an environment reached while disabled silent after re-enabling", + () => { + const queuedJobs: Array = []; + const other = { ...aggregate.activities[0]!, environmentId: EnvironmentId.make("other-env") }; + const waiting = { ...other, phase: "waiting_for_approval" as const, status: "Approval" }; + + 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], + 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).toBeNull(); + }).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 da4866e592ca8..66d21d2a2843e 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, + sameEnvironmentIds, 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,13 @@ function chooseLiveActivityDelivery(input: { ? null : (alertForAttentionTransition({ previousAggregate, + previousHiddenEnvironmentIds: input.target.last_hidden_environment_ids, nextAggregate, preferences, }) ?? alertForNewlyTerminal({ previousAggregate, + previousHiddenEnvironmentIds: input.target.last_hidden_environment_ids, nextAggregate, preferences, nowMs: input.nowMs, @@ -279,6 +288,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 +437,7 @@ interface SendLiveActivityDeliveryInputBase { readonly target: LiveActivityDeliveryTarget; readonly token: string; readonly sourceJobId: string; + readonly hiddenEnvironmentIds?: ReadonlyArray; } export type SendLiveActivityDeliveryInput = @@ -484,6 +495,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; @@ -691,31 +704,32 @@ 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, + 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, })); } + // The registration replay that changed the filter supersedes cards built under the old one. if ( aggregate !== null && - !(yield* aggregateRowsAreCurrent({ - userId: input.target.user_id, - aggregate, - })) + (!sameEnvironmentIds(input.hiddenEnvironmentIds, hiddenEnvironmentIds(currentPreferences)) || + !(yield* aggregateRowsAreCurrent({ + userId: input.target.user_id, + aggregate, + }))) ) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, @@ -728,11 +742,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, - parsePreferences(currentTarget.preferences_json), - )) + currentPreferences?.liveActivitiesEnabled !== false && + (yield* userHasContentToShow(input.target.user_id, currentPreferences)) ) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, @@ -764,6 +775,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)) { @@ -977,6 +989,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({ @@ -991,6 +1004,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) { @@ -1046,6 +1060,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, }); @@ -1090,6 +1105,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/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 56befc27425d6..ef765d77abe01 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -61,6 +61,7 @@ const target: LiveActivities.TargetRow = { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, last_live_activity_delivery_at: null, }; const config = { @@ -125,6 +126,7 @@ function harness() { 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, @@ -377,6 +379,8 @@ describe("Android delivery routing", () => { h.current.target.preferences_json = encodeJson(preferences); yield* delivery.enqueue({ target: h.current.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(h.sent.filter((message) => message.alert)).toHaveLength(1); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 5f0bdcd136203..ba371fc9a2606 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -28,6 +28,7 @@ import { isExpiredAgentActivityState, notificationForActivity } from "./agentAct import { alertForActivityRows, attentionTransitionRows, + hiddenEnvironmentIds, terminalTransitionRows, shouldAlertForActivity, } from "./agentActivityAlerts.ts"; @@ -76,6 +77,7 @@ export function androidAlertForState( export function androidAlertForAggregate(input: { readonly previousAggregate: RelayAgentActivityAggregateState; + readonly previousHiddenEnvironmentIds?: ReadonlyArray | null; readonly nextAggregate: RelayAgentActivityAggregateState; readonly preferences: RelayAgentAwarenessPreferences; readonly nowMs: number; @@ -264,6 +266,7 @@ const make = Effect.gen(function* () { } alert = androidAlertForAggregate({ previousAggregate, + previousHiddenEnvironmentIds: target.last_hidden_environment_ids, nextAggregate: { ...aggregate, activities: aggregate.activities.filter((row) => @@ -330,6 +333,9 @@ 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: preferences.value.liveActivitiesEnabled + ? hiddenEnvironmentIds(preferences.value) + : null, 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..c49524748a410 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -89,6 +89,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 +116,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 +185,7 @@ const make = Effect.gen(function* () { remoteStartedAt: armedAt, endedAt: null, lastAggregateJson: null, + lastHiddenEnvironmentIds: null, lastLiveActivityDeliveryAt: null, createdAt: updatedAt, updatedAt, @@ -195,6 +198,7 @@ const make = Effect.gen(function* () { remoteStartedAt: armedAt, endedAt: null, lastAggregateJson: null, + lastHiddenEnvironmentIds: null, lastLiveActivityDeliveryAt: null, updatedAt, }, @@ -229,6 +233,7 @@ const make = Effect.gen(function* () { 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 +325,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 +347,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..237b3291943f5 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -273,6 +273,7 @@ describe("MobileRegistrations", () => { remote_started_at: null, ended_at: null, last_aggregate_json: null, + last_hidden_environment_ids: null, last_live_activity_delivery_at: null, }, ]), @@ -295,4 +296,67 @@ 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, + 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 96a80351e20d8..1e9e0c3c54d59 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -1,10 +1,11 @@ -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, @@ -73,6 +74,14 @@ 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; diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index 58cc8ccef1fd5..dbce37aacd54f 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -22,6 +22,8 @@ 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. + readonly previousHiddenEnvironmentIds?: ReadonlyArray | null; readonly nextAggregate: RelayAgentActivityAggregateState; readonly preferences: RelayAgentAwarenessPreferences | null; }; @@ -52,6 +54,27 @@ export function environmentEnabledOnDevice( 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) => !hidden.includes(row.environmentId)); +} + function alertAllowedForRow( preferences: RelayAgentAwarenessPreferences | null, row: { readonly phase: string; readonly environmentId: string }, @@ -78,7 +101,7 @@ 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)) && @@ -87,15 +110,14 @@ export function attentionTransitionRows(input: TransitionInput) { } 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 ( @@ -109,11 +131,7 @@ 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 ( alertAllowedForRow(input.preferences, row) && isFreshTerminalNotification(row.updatedAt, input.nowMs) 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..fb48dc56a8edc 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -52,6 +52,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/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 179bb3e0e93f4..a27eb23b449df 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -1057,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, }, From 2346eca3c92217a9d7358e5f1f649c6510890c88 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:52:49 +0000 Subject: [PATCH 03/16] fix(android): keep re-enabled alerts silent with an empty activity baseline --- infra/relay/src/agentActivity/FcmDeliveries.test.ts | 10 ++++++++++ infra/relay/src/agentActivity/FcmDeliveries.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index ef765d77abe01..6ca43e437501f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -385,6 +385,16 @@ describe("Android delivery routing", () => { yield* delivery.process(otherJob); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + + h.current.target.preferences_json = encodeJson({ + ...preferences, + disabledEnvironmentIds: [state.environmentId, other.environmentId], + }); + yield* delivery.process({ ...h.job, state: null, replay: true }); + expect(h.marked.at(-1)?.aggregate).toBeNull(); + h.current.target.preferences_json = encodeJson(preferences); + yield* delivery.process(otherJob); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); }).pipe(Effect.provide(h.layer)); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index ba371fc9a2606..f9fac7d158a69 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -280,6 +280,7 @@ const make = Effect.gen(function* () { deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && state.updatedAt === job.state.updatedAt && + !target.last_hidden_environment_ids?.includes(state.environmentId) && !isExpiredAgentActivityState(state, now.epochMilliseconds) ) { alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); From 978fbcb5bf8a8a31fa6c273333e0dc939d05b9ff Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:00:19 +0000 Subject: [PATCH 04/16] fix(android): preserve disabled environment history when activity cards are off --- .../src/agentActivity/FcmDeliveries.test.ts | 16 ++++++++++++++++ infra/relay/src/agentActivity/FcmDeliveries.ts | 15 +++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 6ca43e437501f..2617064f133c3 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -395,6 +395,22 @@ describe("Android delivery routing", () => { h.current.target.preferences_json = encodeJson(preferences); yield* delivery.process(otherJob); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: false, + disabledEnvironmentIds: [state.environmentId, other.environmentId], + }); + yield* delivery.process({ ...h.job, state: null, replay: true }); + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: false, + }); + yield* delivery.process(otherJob); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + h.current.otherStates = [{ ...other, phase: "completed" }]; + yield* delivery.process({ ...otherJob, state: h.current.otherStates[0]! }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(2); }).pipe(Effect.provide(h.layer)); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index f9fac7d158a69..0aa86c673f0da 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -29,6 +29,7 @@ import { alertForActivityRows, attentionTransitionRows, hiddenEnvironmentIds, + sameEnvironmentIds, terminalTransitionRows, shouldAlertForActivity, } from "./agentActivityAlerts.ts"; @@ -200,6 +201,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 @@ -288,7 +290,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, @@ -334,9 +343,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: preferences.value.liveActivitiesEnabled - ? hiddenEnvironmentIds(preferences.value) - : null, + hiddenEnvironmentIds: currentHiddenEnvironmentIds, deliveredAt: DateTime.formatIso(now), }); } From b8e2fd55298e75bdf11dedf93eec4a904090473b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:37:41 +0000 Subject: [PATCH 05/16] fix(android): approvals queued while an environment was hidden stay silent after re-enable A notification-only Android job decides its alert when consumed. If the re-enable replay was consumed first, it cleared the device's hidden list and left no baseline, so an approval queued while the environment was hidden then alerted. Jobs now record whether their environment was hidden on that device when queued, and such jobs never alert, matching iOS, which decides at enqueue. Co-Authored-By: Claude Opus 5.5 --- .../src/agentActivity/FcmDeliveries.test.ts | 34 +++++++++++++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 11 ++++++ 2 files changed, 45 insertions(+) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 2617064f133c3..c7640006e7feb 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -414,6 +414,40 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); + it.effect.each([ + { name: "with activity cards off", liveActivitiesEnabled: false, notificationOnly: false }, + { + name: "for a notification-only environment", + liveActivitiesEnabled: true, + notificationOnly: true, + }, + ])("re-enabling stays silent $name when the replay is consumed first", (config) => { + const h = harness(); + const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; + if (config.notificationOnly) h.current.notificationOnlyEnvironments = [other.environmentId]; + const visible = { ...preferences, liveActivitiesEnabled: config.liveActivitiesEnabled }; + h.current.target.preferences_json = encodeJson({ + ...visible, + disabledEnvironmentIds: [other.environmentId], + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: null, replay: true }); + h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }]; + yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); + h.current.target.preferences_json = encodeJson(visible); + yield* delivery.enqueue({ target: h.current.target, state: null, replay: true }); + yield* delivery.process(h.queued[1]); + yield* delivery.process(h.queued[0]); + expect(h.sent.filter((message) => message.alert)).toHaveLength(0); + + h.current.otherStates = [{ ...other, phase: "completed" }]; + yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); + yield* delivery.process(h.queued[2]); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + 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 0aa86c673f0da..e3f56d0226229 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -28,6 +28,7 @@ import { isExpiredAgentActivityState, notificationForActivity } from "./agentAct import { alertForActivityRows, attentionTransitionRows, + environmentEnabledOnDevice, hiddenEnvironmentIds, sameEnvironmentIds, terminalTransitionRows, @@ -41,6 +42,8 @@ export const FcmDeliveryJob = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState), queuedAt: Schema.Number, replay: Schema.optional(Schema.Boolean), + // Re-enabling clears the device's hidden list, so a queued job keeps its own. + environmentHidden: Schema.optional(Schema.Boolean), }); export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); @@ -165,6 +168,12 @@ const make = Effect.gen(function* () { }; } const now = yield* DateTime.now; + const environmentHidden = + input.state !== null && + !environmentEnabledOnDevice( + Option.getOrNull(decodePreferences(input.target.preferences_json)), + input.state.environmentId, + ); yield* sender .send({ userId: input.target.user_id, @@ -172,6 +181,7 @@ const make = Effect.gen(function* () { token: input.target.push_token, state: input.state, ...(input.replay ? { replay: true } : {}), + ...(environmentHidden ? { environmentHidden: true } : {}), queuedAt: now.epochMilliseconds, }) .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause }))); @@ -280,6 +290,7 @@ const make = Effect.gen(function* () { }); } else if ( deliveryUser?.notificationsEnabled && + !job.environmentHidden && state?.phase === job.state.phase && state.updatedAt === job.state.updatedAt && !target.last_hidden_environment_ids?.includes(state.environmentId) && From 541598f13b367fde17c585657510c7db8707dab7 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:38:53 +0000 Subject: [PATCH 06/16] fix(mobile): device registrations run one at a time so the newest toggle lands last Preference updates and the notification-permission refresh registered outside the queue, so a registration holding an older environment snapshot could reach the relay after a newer toggle and restore its filter. Every device registration now takes a per-account lock and reads the catalog and preferences only once it holds it. A preference override stays applied to later registrations for that account, because Settings saves it only after its registration finishes. Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 82 ++++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 30 ++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 4e7aa46e89a91..c337b218ee80b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -106,13 +106,17 @@ const catalogMock = vi.hoisted(() => ({ atom: Symbol("catalogValueAtom"), value: { isReady: true, entries: new Map() }, listeners: new Set<(value: unknown) => void>(), + reads: 0, })); // Avoid importing the native connection stack; registration only reads these atoms. vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { - get: (atom: unknown) => - atom === catalogMock.atom ? catalogMock.value : environmentConfigsMock.configs, + get: (atom: unknown) => { + if (atom !== catalogMock.atom) return environmentConfigsMock.configs; + catalogMock.reads++; + return catalogMock.value; + }, subscribe: (_atom: unknown, listener: (value: unknown) => void) => { catalogMock.listeners.add(listener); return () => catalogMock.listeners.delete(listener); @@ -297,6 +301,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.clear(); catalogMock.value = { isReady: true, entries: new Map() }; catalogMock.listeners.clear(); + catalogMock.reads = 0; }); it("disables push features in Personal Team relay registrations", () => { @@ -732,6 +737,79 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + 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 readsBefore = catalogMock.reads; + const toggleFiber = yield* Effect.forkChild( + toggle.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, + { startImmediately: true }, + ); + // A snapshot taken now would be older than the update still in flight. + expect(catalogMock.reads).toBe(readsBefore); + 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.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 2d2cc946efeb7..c5d369c650aab 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -683,9 +683,36 @@ 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; @@ -716,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( From 50fc3d6e8467ed527241570f4517c241c3b96f85 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:56:32 +0000 Subject: [PATCH 07/16] fix(android): alerts queued before a disable stay silent after re-enable An approval queued while an environment was enabled could still alert after the user disabled and re-enabled it, if the re-enable replay was consumed first. The job-level hidden flag only covered jobs queued while hidden. The device row now records when each environment was re-enabled, and a notification-only job queued before that time stays silent, like the replay baseline that activity cards already use. Other environments' pending alerts and later events are unaffected. Entries expire with the five-minute job lifetime. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 1 + .../snapshot.json | 37 +++++++++++ .../AgentActivityPublisher.test.ts | 1 + .../src/agentActivity/ApnsDeliveries.test.ts | 1 + .../src/agentActivity/FcmDeliveries.test.ts | 63 +++++++++++++++---- .../relay/src/agentActivity/FcmDeliveries.ts | 27 ++++---- .../src/agentActivity/LiveActivities.test.ts | 2 + .../relay/src/agentActivity/LiveActivities.ts | 7 +++ .../agentActivity/MobileRegistrations.test.ts | 2 + infra/relay/src/persistence/schema.ts | 3 + 10 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql create mode 100644 infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/snapshot.json diff --git a/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql b/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql new file mode 100644 index 0000000000000..08c6fd9a7b0ff --- /dev/null +++ b/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "relay_live_activities" ADD COLUMN "environment_revealed_at" jsonb; \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/snapshot.json b/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/snapshot.json new file mode 100644 index 0000000000000..07447315bc414 --- /dev/null +++ b/infra/relay/migrations/postgres/20261110100000_live_activity_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_live_activities", "environment_revealed_at"], + "writes": [ + "s|col|public.relay_live_activities.environment_revealed_at", + "n|col|public.relay_live_activities.environment_revealed_at", + "e|col|public.relay_live_activities.environment_revealed_at" + ], + "reads": ["n|tbl|public.relay_live_activities", "n|sch|public"] + } + ], + "deltas": [ + { + "op": "create", + "row": { + "schema": "public", + "table": "relay_live_activities", + "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 dc6490921f23f..c353b68770cf6 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -50,6 +50,7 @@ function target(deviceId: string): LiveActivities.TargetRow { ended_at: null, last_aggregate_json: null, last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; } diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index a7d40e0df254c..d2b1cadd2f269 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -136,6 +136,7 @@ const target: LiveActivities.TargetRow = { ended_at: null, last_aggregate_json: null, last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index c7640006e7feb..1efaeae854baf 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -8,6 +8,7 @@ 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"; @@ -62,6 +63,7 @@ const target: LiveActivities.TargetRow = { ended_at: null, last_aggregate_json: null, last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }; const config = { @@ -127,6 +129,8 @@ function harness() { marked.push(input); current.target.last_aggregate_json = input.aggregate ? encodeJson(input.aggregate) : null; current.target.last_hidden_environment_ids = input.hiddenEnvironmentIds; + current.target.environment_revealed_at = + input.environmentRevealedAt ?? current.target.environment_revealed_at; }), invalidateDeliveryToken: () => Effect.void, @@ -408,43 +412,80 @@ describe("Android delivery routing", () => { }); yield* delivery.process(otherJob); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + yield* TestClock.adjust("1 second"); h.current.otherStates = [{ ...other, phase: "completed" }]; - yield* delivery.process({ ...otherJob, state: h.current.otherStates[0]! }); + yield* delivery.process({ ...otherJob, state: h.current.otherStates[0]!, queuedAt: 1_000 }); expect(h.sent.filter((message) => message.alert)).toHaveLength(2); }).pipe(Effect.provide(h.layer)); }); it.effect.each([ - { name: "with activity cards off", liveActivitiesEnabled: false, notificationOnly: false }, { - name: "for a notification-only environment", + name: "with activity cards off, queued while hidden", + liveActivitiesEnabled: false, + notificationOnly: false, + queuedBeforeDisable: false, + }, + { + name: "for a notification-only environment, queued while hidden", liveActivitiesEnabled: true, notificationOnly: true, + queuedBeforeDisable: false, + }, + { + name: "with activity cards off, queued before disable", + liveActivitiesEnabled: false, + notificationOnly: false, + queuedBeforeDisable: true, + }, + { + name: "for a notification-only environment, queued before disable", + liveActivitiesEnabled: true, + notificationOnly: true, + queuedBeforeDisable: true, }, ])("re-enabling stays silent $name when the replay is consumed first", (config) => { const h = harness(); const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; - if (config.notificationOnly) h.current.notificationOnlyEnvironments = [other.environmentId]; + const pending = { + ...secondState, + environmentId: EnvironmentId.make("pending-env"), + phase: "waiting_for_approval", + } as const; + h.current.notificationOnlyEnvironments = [ + pending.environmentId, + ...(config.notificationOnly ? [other.environmentId] : []), + ]; const visible = { ...preferences, liveActivitiesEnabled: config.liveActivitiesEnabled }; - h.current.target.preferences_json = encodeJson({ - ...visible, - disabledEnvironmentIds: [other.environmentId], - }); return Effect.gen(function* () { const delivery = yield* FcmDeliveries.FcmDeliveries; + h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }, pending]; + h.current.target.preferences_json = encodeJson(visible); + if (config.queuedBeforeDisable) { + yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); + } + h.current.target.preferences_json = encodeJson({ + ...visible, + disabledEnvironmentIds: [other.environmentId], + }); yield* delivery.process({ ...h.job, state: null, replay: true }); - h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }]; - yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); + if (!config.queuedBeforeDisable) { + yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); + } h.current.target.preferences_json = encodeJson(visible); yield* delivery.enqueue({ target: h.current.target, state: null, replay: true }); yield* delivery.process(h.queued[1]); yield* delivery.process(h.queued[0]); expect(h.sent.filter((message) => message.alert)).toHaveLength(0); + // An environment that stayed enabled keeps its equally old pending alert. + yield* delivery.process({ ...h.job, state: pending }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + yield* TestClock.adjust("1 second"); h.current.otherStates = [{ ...other, phase: "completed" }]; yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); yield* delivery.process(h.queued[2]); - expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + expect(h.sent.filter((message) => message.alert)).toHaveLength(2); }).pipe(Effect.provide(h.layer)); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index e3f56d0226229..b04c0bb808ce9 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -28,7 +28,6 @@ import { isExpiredAgentActivityState, notificationForActivity } from "./agentAct import { alertForActivityRows, attentionTransitionRows, - environmentEnabledOnDevice, hiddenEnvironmentIds, sameEnvironmentIds, terminalTransitionRows, @@ -42,11 +41,10 @@ export const FcmDeliveryJob = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState), queuedAt: Schema.Number, replay: Schema.optional(Schema.Boolean), - // Re-enabling clears the device's hidden list, so a queued job keeps its own. - environmentHidden: Schema.optional(Schema.Boolean), }); export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); +const JOB_TTL_MS = 5 * 60_000; const decodePreferences = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentAwarenessPreferences), ); @@ -168,12 +166,6 @@ const make = Effect.gen(function* () { }; } const now = yield* DateTime.now; - const environmentHidden = - input.state !== null && - !environmentEnabledOnDevice( - Option.getOrNull(decodePreferences(input.target.preferences_json)), - input.state.environmentId, - ); yield* sender .send({ userId: input.target.user_id, @@ -181,7 +173,6 @@ const make = Effect.gen(function* () { token: input.target.push_token, state: input.state, ...(input.replay ? { replay: true } : {}), - ...(environmentHidden ? { environmentHidden: true } : {}), queuedAt: now.epochMilliseconds, }) .pipe(Effect.mapError((cause) => new FcmDeliveryError({ operation: "enqueue", cause }))); @@ -200,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 > JOB_TTL_MS) return; const targets = yield* devices.listTargets({ userId: job.userId }); const target = targets.find( (device) => @@ -212,6 +203,16 @@ const make = Effect.gen(function* () { const preferences = decodePreferences(target.preferences_json); if (Option.isNone(preferences)) return; const currentHiddenEnvironmentIds = hiddenEnvironmentIds(preferences.value); + // Jobs queued before their environment's re-enable reached the device are replay history. + const environmentRevealedAt: Record = {}; + for (const [environmentId, at] of Object.entries(target.environment_revealed_at ?? {})) { + if (now.epochMilliseconds - at <= JOB_TTL_MS) environmentRevealedAt[environmentId] = at; + } + for (const environmentId of target.last_hidden_environment_ids ?? []) { + if (!currentHiddenEnvironmentIds.includes(environmentId)) { + environmentRevealedAt[environmentId] = now.epochMilliseconds; + } + } // Re-read at consumption to honor current links, tokens, preferences, and thread states. const states = preferences.value.liveActivitiesEnabled @@ -290,10 +291,9 @@ const make = Effect.gen(function* () { }); } else if ( deliveryUser?.notificationsEnabled && - !job.environmentHidden && state?.phase === job.state.phase && state.updatedAt === job.state.updatedAt && - !target.last_hidden_environment_ids?.includes(state.environmentId) && + job.queuedAt > (environmentRevealedAt[state.environmentId] ?? -Infinity) && !isExpiredAgentActivityState(state, now.epochMilliseconds) ) { alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); @@ -355,6 +355,7 @@ const make = Effect.gen(function* () { // Retain terminal rows as the baseline so finished-card replays cannot alert again. aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, hiddenEnvironmentIds: currentHiddenEnvironmentIds, + environmentRevealedAt, deliveredAt: DateTime.formatIso(now), }); } diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 74f7a8741e369..76203ffb2962e 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -114,6 +114,7 @@ describe("LiveActivities", () => { kind: "live_activity_update", aggregate, hiddenEnvironmentIds: ["hidden-env"], + environmentRevealedAt: { "shown-env": 10_000 }, deliveredAt: "2026-05-25T00:00:10.000Z", }); @@ -128,6 +129,7 @@ describe("LiveActivities", () => { expect.objectContaining({ endedAt: relayLiveActivities.endedAt, lastHiddenEnvironmentIds: ["hidden-env"], + environmentRevealedAt: { "shown-env": 10_000 }, lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", }), ); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index c49524748a410..ca44f6bee6c26 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -90,6 +90,7 @@ export interface LiveActivityRow { readonly ended_at: string | null; readonly last_aggregate_json: string | null; readonly last_hidden_environment_ids: ReadonlyArray | null; + readonly environment_revealed_at: Readonly> | null; readonly last_live_activity_delivery_at: string | null; } @@ -117,6 +118,7 @@ export class LiveActivities extends Context.Service< readonly kind: Exclude; readonly aggregate: RelayAgentActivityAggregateState | null; readonly hiddenEnvironmentIds: ReadonlyArray | null; + readonly environmentRevealedAt?: Readonly>; readonly deliveredAt: string; }) => Effect.Effect; readonly invalidateDeliveryToken: (input: { @@ -234,6 +236,7 @@ const make = Effect.gen(function* () { ended_at: relayLiveActivities.endedAt, last_aggregate_json: relayLiveActivities.lastAggregateJson, last_hidden_environment_ids: relayLiveActivities.lastHiddenEnvironmentIds, + environment_revealed_at: relayLiveActivities.environmentRevealedAt, last_live_activity_delivery_at: relayLiveActivities.lastLiveActivityDeliveryAt, }) .from(relayMobileDevices) @@ -326,6 +329,7 @@ const make = Effect.gen(function* () { endedAt: input.kind === "live_activity_end" ? input.deliveredAt : null, lastAggregateJson: aggregateJson, lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, + environmentRevealedAt: input.environmentRevealedAt ?? null, lastLiveActivityDeliveryAt: input.deliveredAt, createdAt: input.deliveredAt, updatedAt: input.deliveredAt, @@ -348,6 +352,9 @@ const make = Effect.gen(function* () { : relayLiveActivities.endedAt, lastAggregateJson: aggregateJson, lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, + ...(input.environmentRevealedAt + ? { environmentRevealedAt: input.environmentRevealedAt } + : {}), 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 237b3291943f5..2d47c4c8abbcb 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -274,6 +274,7 @@ describe("MobileRegistrations", () => { ended_at: null, last_aggregate_json: null, last_hidden_environment_ids: null, + environment_revealed_at: null, last_live_activity_delivery_at: null, }, ]), @@ -318,6 +319,7 @@ describe("MobileRegistrations", () => { 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) => diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index fb48dc56a8edc..421c867a8a903 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -54,6 +54,9 @@ export const relayLiveActivities = pgTable( 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>(), + // Android: when each environment was re-enabled; older queued jobs for it stay silent. + environmentRevealedAt: + jsonb("environment_revealed_at").$type>>(), lastLiveActivityDeliveryAt: varchar("last_live_activity_delivery_at", { length: 64 }), createdAt: varchar("created_at", { length: 64 }).notNull(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), From 9e4652e745e6f8e75127cf4ef7e3d8b26c6fcaa6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:57:41 +0000 Subject: [PATCH 08/16] test(mobile): assert the queued toggle's landed preferences, not catalog reads Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/remoteRegistration.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c337b218ee80b..b8f5b80b62add 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -106,17 +106,13 @@ const catalogMock = vi.hoisted(() => ({ atom: Symbol("catalogValueAtom"), value: { isReady: true, entries: new Map() }, listeners: new Set<(value: unknown) => void>(), - reads: 0, })); // Avoid importing the native connection stack; registration only reads these atoms. vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { - get: (atom: unknown) => { - if (atom !== catalogMock.atom) return environmentConfigsMock.configs; - catalogMock.reads++; - return catalogMock.value; - }, + 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); @@ -301,7 +297,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.clear(); catalogMock.value = { isReady: true, entries: new Map() }; catalogMock.listeners.clear(); - catalogMock.reads = 0; }); it("disables push features in Personal Team relay registrations", () => { @@ -780,7 +775,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { 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 readsBefore = catalogMock.reads; const toggleFiber = yield* Effect.forkChild( toggle.operation as Effect.Effect< unknown, @@ -789,8 +783,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { >, { startImmediately: true }, ); - // A snapshot taken now would be older than the update still in flight. - expect(catalogMock.reads).toBe(readsBefore); openUpdate?.(); yield* Fiber.join(update); toggle.resolve(yield* Fiber.await(toggleFiber)); From e8edaa5030b28e6277e6c682e9b737d853d7ab23 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:13:25 +0000 Subject: [PATCH 09/16] fix(android): re-enabling an environment records the time at registration Android alerts queued before an environment was re-enabled could still fire when no delivery reached the phone while it was hidden, and a real event queued right after the re-enable stayed silent until a later delivery recorded the reveal. The device registration now records when each environment leaves the disabled list, in the same upsert that saves the new preferences, so delivery workers only read it. Entries expire with the five-minute job lifetime. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 1 - .../migration.sql | 1 + .../snapshot.json | 12 +- infra/relay/src/agentActivity/Devices.test.ts | 76 ++++ infra/relay/src/agentActivity/Devices.ts | 26 +- .../src/agentActivity/FcmDeliveries.test.ts | 386 ++++++++++++------ .../relay/src/agentActivity/FcmDeliveries.ts | 18 +- .../src/agentActivity/LiveActivities.test.ts | 2 - .../relay/src/agentActivity/LiveActivities.ts | 9 +- .../src/agentActivity/agentActivityAlerts.ts | 3 + infra/relay/src/persistence/schema.ts | 6 +- infra/relay/src/persistence/testDatabase.ts | 49 +++ 12 files changed, 422 insertions(+), 167 deletions(-) delete mode 100644 infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql create mode 100644 infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/migration.sql rename infra/relay/migrations/postgres/{20261110100000_live_activity_environment_revealed_at => 20261110100000_mobile_device_environment_revealed_at}/snapshot.json (59%) create mode 100644 infra/relay/src/agentActivity/Devices.test.ts create mode 100644 infra/relay/src/persistence/testDatabase.ts diff --git a/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql b/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql deleted file mode 100644 index 08c6fd9a7b0ff..0000000000000 --- a/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "relay_live_activities" 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/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_live_activity_environment_revealed_at/snapshot.json b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json similarity index 59% rename from infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/snapshot.json rename to infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json index 07447315bc414..2c7d84053ad29 100644 --- a/infra/relay/migrations/postgres/20261110100000_live_activity_environment_revealed_at/snapshot.json +++ b/infra/relay/migrations/postgres/20261110100000_mobile_device_environment_revealed_at/snapshot.json @@ -7,13 +7,13 @@ "footprints": [ { "statementType": "add_column", - "statementTarget": ["column", "public", "relay_live_activities", "environment_revealed_at"], + "statementTarget": ["column", "public", "relay_mobile_devices", "environment_revealed_at"], "writes": [ - "s|col|public.relay_live_activities.environment_revealed_at", - "n|col|public.relay_live_activities.environment_revealed_at", - "e|col|public.relay_live_activities.environment_revealed_at" + "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_live_activities", "n|sch|public"] + "reads": ["n|tbl|public.relay_mobile_devices", "n|sch|public"] } ], "deltas": [ @@ -21,7 +21,7 @@ "op": "create", "row": { "schema": "public", - "table": "relay_live_activities", + "table": "relay_mobile_devices", "name": "environment_revealed_at", "type": "jsonb", "typeSchema": null, diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts new file mode 100644 index 0000000000000..770dfb3173052 --- /dev/null +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -0,0 +1,76 @@ +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 delivery job 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 }); + // Entries outlive every job queued before them, then expire. + yield* at(1_000 + 5 * 60_000 + 1); + yield* register([a]); + expect(yield* revealedAt).toEqual({ [b]: 3_000 }); + yield* at(400_000); + yield* register([]); + expect(yield* revealedAt).toEqual({ [a]: 400_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..e54fcc7c94b65 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 { ANDROID_DELIVERY_JOB_TTL_MS } from "./agentActivityAlerts.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 - ANDROID_DELIVERY_JOB_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 1efaeae854baf..a170d02b37bff 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -1,7 +1,11 @@ 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"; @@ -15,7 +19,9 @@ 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"; @@ -86,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]> = []; @@ -120,21 +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; - current.target.last_hidden_environment_ids = input.hiddenEnvironmentIds; - current.target.environment_revealed_at = - input.environmentRevealedAt ?? current.target.environment_revealed_at; - }), + 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, @@ -214,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, @@ -359,135 +428,186 @@ describe("Android delivery routing", () => { }, ); - it.effect("keeps a device-disabled environment off the phone until a silent re-enable", () => { - const h = harness(); - const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; - h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); - 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]! }; - h.current.target.preferences_json = encodeJson({ - ...preferences, - disabledEnvironmentIds: [other.environmentId], - }); - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process(otherJob); - expect(h.sent.filter((message) => message.alert).map((message) => message.data)).toEqual([ - expect.objectContaining({ alert_title: "Fix notifications" }), - ]); - expect(h.marked.at(-1)?.aggregate?.activities).toMatchObject([ - { environmentId: state.environmentId }, - ]); - - h.current.target.preferences_json = encodeJson(preferences); - yield* delivery.enqueue({ target: h.current.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(h.sent.filter((message) => message.alert)).toHaveLength(1); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + 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 }, + ]); - h.current.target.preferences_json = encodeJson({ - ...preferences, - disabledEnvironmentIds: [state.environmentId, other.environmentId], - }); - yield* delivery.process({ ...h.job, state: null, replay: true }); - expect(h.marked.at(-1)?.aggregate).toBeNull(); - h.current.target.preferences_json = encodeJson(preferences); - yield* delivery.process(otherJob); - expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + 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); - h.current.target.preferences_json = encodeJson({ - ...preferences, - liveActivitiesEnabled: false, - disabledEnvironmentIds: [state.environmentId, other.environmentId], - }); - yield* delivery.process({ ...h.job, state: null, replay: true }); - h.current.target.preferences_json = encodeJson({ - ...preferences, - liveActivitiesEnabled: false, - }); - yield* delivery.process(otherJob); - expect(h.sent.filter((message) => message.alert)).toHaveLength(1); - yield* TestClock.adjust("1 second"); - h.current.otherStates = [{ ...other, phase: "completed" }]; - yield* delivery.process({ ...otherJob, state: h.current.otherStates[0]!, queuedAt: 1_000 }); - expect(h.sent.filter((message) => message.alert)).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + 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); + }), + ), + ); + const cardsOff = { name: "with activity cards off", cards: false, notificationOnly: false }; + const notificationOnly = { + name: "for a notification-only environment", + cards: true, + notificationOnly: true, + }; it.effect.each([ { - name: "with activity cards off, queued while hidden", - liveActivitiesEnabled: false, - notificationOnly: false, - queuedBeforeDisable: false, + ...cardsOff, + queued: "while hidden", + hiddenDelivery: true, + order: "replay stale pending new", }, { - name: "for a notification-only environment, queued while hidden", - liveActivitiesEnabled: true, - notificationOnly: true, - queuedBeforeDisable: false, + ...notificationOnly, + queued: "while hidden", + hiddenDelivery: true, + order: "replay stale pending new", }, { - name: "with activity cards off, queued before disable", - liveActivitiesEnabled: false, - notificationOnly: false, - queuedBeforeDisable: true, + ...cardsOff, + queued: "before disable", + hiddenDelivery: true, + order: "replay stale pending new", }, { - name: "for a notification-only environment, queued before disable", - liveActivitiesEnabled: true, - notificationOnly: true, - queuedBeforeDisable: true, + ...notificationOnly, + queued: "before disable", + hiddenDelivery: true, + order: "replay stale new pending", }, - ])("re-enabling stays silent $name when the replay is consumed first", (config) => { - const h = harness(); - const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; - const pending = { - ...secondState, - environmentId: EnvironmentId.make("pending-env"), - phase: "waiting_for_approval", - } as const; - h.current.notificationOnlyEnvironments = [ - pending.environmentId, - ...(config.notificationOnly ? [other.environmentId] : []), - ]; - const visible = { ...preferences, liveActivitiesEnabled: config.liveActivitiesEnabled }; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - h.current.otherStates = [{ ...other, phase: "waiting_for_approval" }, pending]; - h.current.target.preferences_json = encodeJson(visible); - if (config.queuedBeforeDisable) { - yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); - } - h.current.target.preferences_json = encodeJson({ - ...visible, - disabledEnvironmentIds: [other.environmentId], - }); - yield* delivery.process({ ...h.job, state: null, replay: true }); - if (!config.queuedBeforeDisable) { - yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); - } - h.current.target.preferences_json = encodeJson(visible); - yield* delivery.enqueue({ target: h.current.target, state: null, replay: true }); - yield* delivery.process(h.queued[1]); - yield* delivery.process(h.queued[0]); - expect(h.sent.filter((message) => message.alert)).toHaveLength(0); - // An environment that stayed enabled keeps its equally old pending alert. - yield* delivery.process({ ...h.job, state: pending }); - expect(h.sent.filter((message) => message.alert)).toHaveLength(1); - - yield* TestClock.adjust("1 second"); - h.current.otherStates = [{ ...other, phase: "completed" }]; - yield* delivery.enqueue({ target: h.current.target, state: h.current.otherStates[0]! }); - yield* delivery.process(h.queued[2]); - expect(h.sent.filter((message) => message.alert)).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + { + ...cardsOff, + queued: "before disable", + hiddenDelivery: false, + order: "stale replay new pending", + }, + { + ...notificationOnly, + queued: "before disable", + hiddenDelivery: false, + order: "stale new replay pending", + }, + { + ...cardsOff, + queued: "while hidden", + hiddenDelivery: false, + order: "new stale pending replay", + }, + { + ...notificationOnly, + queued: "while hidden", + hiddenDelivery: false, + order: "new replay stale pending", + }, + ] 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 fresh = { ...stale, threadId: ThreadId.make("new-thread"), threadTitle: "New" }; + 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); + 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"); + 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 = { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index b04c0bb808ce9..92b9a87a6bfec 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -26,6 +26,7 @@ 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, @@ -44,7 +45,6 @@ export const FcmDeliveryJob = Schema.Struct({ }); export type FcmDeliveryJob = typeof FcmDeliveryJob.Type; const decodeJob = Schema.decodeUnknownEffect(FcmDeliveryJob); -const JOB_TTL_MS = 5 * 60_000; const decodePreferences = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentAwarenessPreferences), ); @@ -191,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 > JOB_TTL_MS) 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) => @@ -203,16 +203,6 @@ const make = Effect.gen(function* () { const preferences = decodePreferences(target.preferences_json); if (Option.isNone(preferences)) return; const currentHiddenEnvironmentIds = hiddenEnvironmentIds(preferences.value); - // Jobs queued before their environment's re-enable reached the device are replay history. - const environmentRevealedAt: Record = {}; - for (const [environmentId, at] of Object.entries(target.environment_revealed_at ?? {})) { - if (now.epochMilliseconds - at <= JOB_TTL_MS) environmentRevealedAt[environmentId] = at; - } - for (const environmentId of target.last_hidden_environment_ids ?? []) { - if (!currentHiddenEnvironmentIds.includes(environmentId)) { - environmentRevealedAt[environmentId] = now.epochMilliseconds; - } - } // Re-read at consumption to honor current links, tokens, preferences, and thread states. const states = preferences.value.liveActivitiesEnabled @@ -293,7 +283,8 @@ const make = Effect.gen(function* () { deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && state.updatedAt === job.state.updatedAt && - job.queuedAt > (environmentRevealedAt[state.environmentId] ?? -Infinity) && + // 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); @@ -355,7 +346,6 @@ const make = Effect.gen(function* () { // Retain terminal rows as the baseline so finished-card replays cannot alert again. aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, hiddenEnvironmentIds: currentHiddenEnvironmentIds, - environmentRevealedAt, deliveredAt: DateTime.formatIso(now), }); } diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 76203ffb2962e..74f7a8741e369 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -114,7 +114,6 @@ describe("LiveActivities", () => { kind: "live_activity_update", aggregate, hiddenEnvironmentIds: ["hidden-env"], - environmentRevealedAt: { "shown-env": 10_000 }, deliveredAt: "2026-05-25T00:00:10.000Z", }); @@ -129,7 +128,6 @@ describe("LiveActivities", () => { expect.objectContaining({ endedAt: relayLiveActivities.endedAt, lastHiddenEnvironmentIds: ["hidden-env"], - environmentRevealedAt: { "shown-env": 10_000 }, lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", }), ); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index ca44f6bee6c26..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 { @@ -90,7 +91,6 @@ export interface LiveActivityRow { readonly ended_at: string | null; readonly last_aggregate_json: string | null; readonly last_hidden_environment_ids: ReadonlyArray | null; - readonly environment_revealed_at: Readonly> | null; readonly last_live_activity_delivery_at: string | null; } @@ -118,7 +118,6 @@ export class LiveActivities extends Context.Service< readonly kind: Exclude; readonly aggregate: RelayAgentActivityAggregateState | null; readonly hiddenEnvironmentIds: ReadonlyArray | null; - readonly environmentRevealedAt?: Readonly>; readonly deliveredAt: string; }) => Effect.Effect; readonly invalidateDeliveryToken: (input: { @@ -230,13 +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, - environment_revealed_at: relayLiveActivities.environmentRevealedAt, last_live_activity_delivery_at: relayLiveActivities.lastLiveActivityDeliveryAt, }) .from(relayMobileDevices) @@ -329,7 +328,6 @@ const make = Effect.gen(function* () { endedAt: input.kind === "live_activity_end" ? input.deliveredAt : null, lastAggregateJson: aggregateJson, lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, - environmentRevealedAt: input.environmentRevealedAt ?? null, lastLiveActivityDeliveryAt: input.deliveredAt, createdAt: input.deliveredAt, updatedAt: input.deliveredAt, @@ -352,9 +350,6 @@ const make = Effect.gen(function* () { : relayLiveActivities.endedAt, lastAggregateJson: aggregateJson, lastHiddenEnvironmentIds: input.hiddenEnvironmentIds, - ...(input.environmentRevealedAt - ? { environmentRevealedAt: input.environmentRevealedAt } - : {}), lastLiveActivityDeliveryAt: input.deliveredAt, updatedAt: input.deliveredAt, }, diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index dbce37aacd54f..72576c0e00cd5 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -47,6 +47,9 @@ function isAttentionPhase(phase: string): boolean { return phase === "waiting_for_approval" || phase === "waiting_for_input"; } +// 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, environmentId: string, diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 421c867a8a903..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(), }, @@ -54,9 +57,6 @@ export const relayLiveActivities = pgTable( 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>(), - // Android: when each environment was re-enabled; older queued jobs for it stay silent. - environmentRevealedAt: - jsonb("environment_revealed_at").$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"]; +}); From f6bf4ae914b74e66e861cf5125618ef77d724c10 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:21:50 +0000 Subject: [PATCH 10/16] fix(android): suppress re-enabled history in aggregate alerts --- .../src/agentActivity/FcmDeliveries.test.ts | 37 ++++++++++++++++++- .../relay/src/agentActivity/FcmDeliveries.ts | 19 +++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index a170d02b37bff..d8aa8bba47b4c 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -494,6 +494,7 @@ describe("Android delivery routing", () => { cards: true, notificationOnly: true, }; + const cardsOn = { name: "with activity cards on", cards: true, notificationOnly: false }; it.effect.each([ { ...cardsOff, @@ -543,6 +544,30 @@ describe("Android delivery routing", () => { hiddenDelivery: false, order: "new replay stale pending", }, + { + ...cardsOn, + queued: "before disable", + hiddenDelivery: false, + order: "stale new pending replay", + }, + { + ...cardsOn, + queued: "while hidden", + hiddenDelivery: false, + order: "stale new pending replay", + }, + { + ...cardsOn, + queued: "before disable", + hiddenDelivery: true, + order: "stale new pending replay", + }, + { + ...cardsOn, + 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) => @@ -556,7 +581,6 @@ describe("Android delivery routing", () => { threadTitle: "Stale approval", phase: "waiting_for_approval", } as const; - const fresh = { ...stale, threadId: ThreadId.make("new-thread"), threadTitle: "New" }; const pending = { ...stale, environmentId: EnvironmentId.make("pending-env"), @@ -581,6 +605,11 @@ describe("Android delivery routing", () => { 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"); @@ -593,6 +622,12 @@ describe("Android delivery routing", () => { 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); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 92b9a87a6bfec..7f4fd180811e7 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -248,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))]; @@ -268,13 +268,20 @@ const make = Effect.gen(function* () { } } alert = androidAlertForAggregate({ - previousAggregate, - previousHiddenEnvironmentIds: target.last_hidden_environment_ids, + 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, From e6b24609542b041d82ab5093a1016f7bac70fa5a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:26:20 +0000 Subject: [PATCH 11/16] fix(relay): retain re-enable history for active attention --- infra/relay/src/agentActivity/Devices.test.ts | 11 ++++--- infra/relay/src/agentActivity/Devices.ts | 4 +-- .../src/agentActivity/FcmDeliveries.test.ts | 31 +++++++++++++++++++ .../agentActivity/agentActivityPayloads.ts | 4 +-- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 770dfb3173052..e123db705260d 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -22,7 +22,7 @@ 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 delivery job lifetime", () => + 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( @@ -63,13 +63,16 @@ describe("Devices", () => { yield* at(3_000); yield* register(undefined); expect(yield* revealedAt).toEqual({ [a]: 1_000, [b]: 3_000 }); - // Entries outlive every job queued before them, then expire. + // 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(400_000); + yield* at(2_000 + 24 * 60 * 60_000); yield* register([]); - expect(yield* revealedAt).toEqual({ [a]: 400_000 }); + 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 e54fcc7c94b65..b8cd35ccfea72 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -12,7 +12,7 @@ import { sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; -import { ANDROID_DELIVERY_JOB_TTL_MS } from "./agentActivityAlerts.ts"; +import { MAX_AGENT_ACTIVITY_ROW_TTL_MS } from "./agentActivityPayloads.ts"; export class DeviceRegistrationPersistenceError extends Schema.TaggedError()( "DeviceRegistrationPersistenceError", @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { from jsonb_each( coalesce(${relayMobileDevices.environmentRevealedAt}, '{}'::jsonb) ) as kept - where (kept.value)::bigint >= ${now.epochMilliseconds - ANDROID_DELIVERY_JOB_TTL_MS} + where (kept.value)::bigint >= ${now.epochMilliseconds - MAX_AGENT_ACTIVITY_ROW_TTL_MS} ), '{}'::jsonb) || coalesce(( select jsonb_object_agg(revealed.id, ${now.epochMilliseconds}::bigint) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index d8aa8bba47b4c..0e8e7d9ef3362 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -488,6 +488,37 @@ describe("Android delivery routing", () => { ), ); + 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); + }), + ), + ); + const cardsOff = { name: "with activity cards off", cards: false, notificationOnly: false }; const notificationOnly = { name: "for a notification-only environment", 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; } From b423193eb9c3bc2ddf77dd25f5eef4d84d63252c Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:54:43 +0000 Subject: [PATCH 12/16] fix(mobile): cancel preference saves after an account switch --- .../liveActivityPreferences.test.ts | 29 +++++++++- .../liveActivityPreferences.ts | 10 +++- .../remoteRegistration.test.ts | 56 +++++++++++++++++++ .../agent-awareness/remoteRegistration.ts | 35 +++++++----- 4 files changed, 113 insertions(+), 17 deletions(-) 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/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b8f5b80b62add..3f1dacc310f1d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -802,6 +802,62 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); + 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 c5d369c650aab..552b734eaa62b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -913,21 +913,30 @@ 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) 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; From 9321f1402d2500a3dce5f0b6c292644973dbaebb Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:58:52 +0000 Subject: [PATCH 13/16] fix(mobile): return from interrupted preference updates --- apps/mobile/src/features/agent-awareness/remoteRegistration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 552b734eaa62b..daa59226fb81a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -925,7 +925,7 @@ export const updateAgentAwarenessRegistrationPreferences = Effect.fn( ): Effect.fn.Return { const generation = deviceRegistrationGeneration; yield* registerDevice({ preferencesOverride }); - if (generation !== deviceRegistrationGeneration) yield* Effect.interrupt; + if (generation !== deviceRegistrationGeneration) return yield* Effect.interrupt; }, Effect.tapError((error) => Effect.sync(() => { From 9ef8e577ab40e821b10123979b7b809f82379151 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:47:14 +0000 Subject: [PATCH 14/16] test(relay): cut the Android re-enable matrix to the rows that catch regressions Each removed row failed only on mutations a kept row also catches. AI-assisted; verified with the tests and tools listed above. --- .../src/agentActivity/FcmDeliveries.test.ts | 75 +++---------------- 1 file changed, 10 insertions(+), 65 deletions(-) diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 0e8e7d9ef3362..dcd7df3779c95 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -519,82 +519,27 @@ describe("Android delivery routing", () => { ), ); - const cardsOff = { name: "with activity cards off", cards: false, notificationOnly: false }; - const notificationOnly = { - name: "for a notification-only environment", - cards: true, - notificationOnly: true, - }; - const cardsOn = { name: "with activity cards on", cards: true, notificationOnly: false }; it.effect.each([ { - ...cardsOff, - queued: "while hidden", - hiddenDelivery: true, - order: "replay stale pending new", - }, - { - ...notificationOnly, - queued: "while hidden", - hiddenDelivery: true, - order: "replay stale pending new", - }, - { - ...cardsOff, - queued: "before disable", - hiddenDelivery: true, - order: "replay stale pending new", - }, - { - ...notificationOnly, - queued: "before disable", - hiddenDelivery: true, - order: "replay stale new pending", - }, - { - ...cardsOff, + name: "with activity cards off", + cards: false, + notificationOnly: false, queued: "before disable", hiddenDelivery: false, order: "stale replay new pending", }, { - ...notificationOnly, - queued: "before disable", - hiddenDelivery: false, - order: "stale new replay pending", - }, - { - ...cardsOff, - queued: "while hidden", - hiddenDelivery: false, - order: "new stale pending replay", - }, - { - ...notificationOnly, - queued: "while hidden", - hiddenDelivery: false, - order: "new replay stale pending", - }, - { - ...cardsOn, - queued: "before disable", - hiddenDelivery: false, - order: "stale new pending replay", - }, - { - ...cardsOn, + name: "for a notification-only environment", + cards: true, + notificationOnly: true, queued: "while hidden", - hiddenDelivery: false, - order: "stale new pending replay", - }, - { - ...cardsOn, - queued: "before disable", hiddenDelivery: true, - order: "stale new pending replay", + order: "replay stale pending new", }, { - ...cardsOn, + name: "with activity cards on", + cards: true, + notificationOnly: false, queued: "while hidden", hiddenDelivery: true, order: "stale new pending replay", From a9a481f9e7a0b1b82c75014ce2ec46dac47da380 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:06:42 +0000 Subject: [PATCH 15/16] fix(relay): alert for attention reached after re-enabling an environment The iOS hidden baseline skipped every row from an environment the last card hid, so an approval requested after the environment was re-enabled (but before the registration replay landed) was delivered without its alert. Rows from a previously hidden environment now count as new when they changed after the device's persisted re-enable time, matching the Android filter. AI-assisted; verified with the tests and tools listed above. --- .../src/agentActivity/ApnsDeliveries.test.ts | 60 ++++++++++--------- .../relay/src/agentActivity/ApnsDeliveries.ts | 3 + .../src/agentActivity/agentActivityAlerts.ts | 10 +++- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index d2b1cadd2f269..06313200ea440 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -918,35 +918,41 @@ describe("ApnsDeliveries", () => { ); }); - it.effect( - "keeps attention an environment reached while disabled silent after re-enabling", - () => { - const queuedJobs: Array = []; - const other = { ...aggregate.activities[0]!, environmentId: EnvironmentId.make("other-env") }; - const waiting = { ...other, phase: "waiting_for_approval" as const, status: "Approval" }; + 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], - last_live_activity_delivery_at: state.updatedAt, - }, - aggregate: { - ...aggregate, - activeCount: 2, - activities: [waiting, aggregate.activities[0]!], - }, - nowMs: 5_000, - }); + 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).toBeNull(); - }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs }))); - }, - ); + 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 = []; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 66d21d2a2843e..1ac0b2426366d 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -270,12 +270,14 @@ function chooseLiveActivityDelivery(input: { : (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, @@ -709,6 +711,7 @@ const make = Effect.gen(function* () { const transition = aggregate && { previousAggregate: parseAggregate(currentTarget.last_aggregate_json), previousHiddenEnvironmentIds: currentTarget.last_hidden_environment_ids, + environmentRevealedAt: currentTarget.environment_revealed_at, nextAggregate: aggregate, preferences: currentPreferences, }; diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index 72576c0e00cd5..862b6498003d5 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -22,8 +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. + // 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; }; @@ -75,7 +77,11 @@ export function sameEnvironmentIds( function rowsVisibleInBaseline(input: TransitionInput) { const hidden = input.previousHiddenEnvironmentIds; if (!hidden?.length) return input.nextAggregate.activities; - return input.nextAggregate.activities.filter((row) => !hidden.includes(row.environmentId)); + 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( From d5f63e3ca523bb41e940249a3e7d08e456dfb2c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:21:28 +0000 Subject: [PATCH 16/16] fix(relay): queued iOS alerts survive filter changes for other environments A queued Live Activity update was skipped whenever the device's disabled environment set changed at all, so disabling or re-enabling an unrelated environment dropped a pending approval alert for one that stayed enabled; the silent registration replay never re-sent it. A queued card is now skipped only when it shows an environment the device now hides, or when it was filtered for a re-enabled environment and carries no alert, in which case the replay repaints it. AI-assisted; verified with the tests and tools listed above. --- .../src/agentActivity/ApnsDeliveries.test.ts | 75 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 13 +++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 06313200ea440..f509f6a2e15d1 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -918,6 +918,81 @@ describe("ApnsDeliveries", () => { ); }); + 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 }, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 1ac0b2426366d..b82c5514ef01b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -48,7 +48,7 @@ import { alertForTerminalAggregate, hiddenEnvironmentIds, rowPhasesChanged, - sameEnvironmentIds, + environmentEnabledOnDevice, shouldAlertForActivity, } from "./agentActivityAlerts.ts"; const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; @@ -725,10 +725,17 @@ const make = Effect.gen(function* () { includeUnobserved: true, })); } - // The registration replay that changed the filter supersedes cards built under the old one. + // 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 && - (!sameEnvironmentIds(input.hiddenEnvironmentIds, hiddenEnvironmentIds(currentPreferences)) || + (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,