mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
Simplify mobile push delivery and mirror desktop notification categories
This commit is contained in:
+14
-5
@@ -35,17 +35,26 @@ the repository's root [MIT license](../LICENSE).
|
||||
Orca credential for it: the desktop host authenticates with the same X25519
|
||||
key it uses for the relay, answering an encrypted challenge to mint a 24 hour
|
||||
session, then registers each paired phone's native push token and asks the
|
||||
gateway to push. The gateway coalesces a burst per registration into one
|
||||
notification, enforces per-host and per-registration quotas, and retires a
|
||||
gateway to push. The gateway queues each event as its own notification,
|
||||
enforces per-host quotas and request limits, and retires a
|
||||
registration as soon as Apple or Google reports the token unregistered.
|
||||
Provider push is the only ordinary mobile OS-banner path. The notification
|
||||
socket is retained only for live dismissal and reconnect tray reconciliation;
|
||||
it never creates or recovers banners. Desktop notification categories remain
|
||||
authoritative.
|
||||
During a rolling gateway update, old workers can still use their former summary
|
||||
and collapse behavior; the individual-presentation guarantee starts only after
|
||||
those revisions retire.
|
||||
Legacy category and summary fields remain only for mixed-version compatibility.
|
||||
FCM notification messages are inherently collapsible while offline and have a
|
||||
small concurrent collapse-key budget, so every pending alert is not guaranteed.
|
||||
|
||||
Storage follows the relay pattern: PostgreSQL in production, SQLite for tests
|
||||
and local development. Configure it with `ORCA_PUSH_PUBLIC_URL`,
|
||||
`ORCA_PUSH_DATABASE_URL`, the three APNs variables (`ORCA_PUSH_APNS_KEY`,
|
||||
`ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`, all three or none), and
|
||||
optionally `ORCA_PUSH_APNS_TOPIC`, `ORCA_PUSH_FCM_PROJECT_ID`, and
|
||||
`ORCA_PUSH_COALESCE_MS`. The FCM credential comes from the runtime service
|
||||
account, so no key material is configured for Android. The full contract lives
|
||||
optionally `ORCA_PUSH_APNS_TOPIC` and `ORCA_PUSH_FCM_PROJECT_ID`. The FCM credential comes from
|
||||
the runtime service account, so no key material is configured for Android. The full contract lives
|
||||
in `docs/reference/mobile-push-contract.md` at the repository root.
|
||||
|
||||
Logging is aggregate counters only. Tokens, notification titles, notification
|
||||
|
||||
@@ -17,7 +17,7 @@ function credentials(): ApnsCredentials {
|
||||
return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }
|
||||
}
|
||||
|
||||
function delivery(coalescedCount = 1) {
|
||||
function delivery() {
|
||||
return buildPushDelivery({
|
||||
registrationId: 'reg-1',
|
||||
hostFingerprint: HOST,
|
||||
@@ -30,10 +30,7 @@ function delivery(coalescedCount = 1) {
|
||||
title: 'Agent needs input',
|
||||
body: 'Waiting on your answer',
|
||||
worktreeId: 'wt-1'
|
||||
},
|
||||
title: 'Agent needs input',
|
||||
body: 'Waiting on your answer',
|
||||
coalescedCount
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,22 +105,21 @@ describe('apns client', () => {
|
||||
notificationSeq: 7,
|
||||
notificationEpoch: 'epoch-1',
|
||||
source: 'agent-task-complete',
|
||||
agentState: 'needs-input',
|
||||
coalescedCount: 1
|
||||
agentState: 'needs-input'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('targets the sandbox host and the host collapse id for a summary', async () => {
|
||||
it('targets the sandbox host and keeps the individual collapse id', async () => {
|
||||
const fake = fakeTransport({ status: 200, body: '' })
|
||||
const client = new ApnsClient({
|
||||
topic: 'com.stably.orca.mobile',
|
||||
credentials: credentials(),
|
||||
transport: fake.transport
|
||||
})
|
||||
await client.send(delivery(3), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
|
||||
await client.send(delivery(), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
|
||||
expect(fake.requests[0]?.host).toBe('api.sandbox.push.apple.com')
|
||||
expect(fake.requests[0]?.headers['apns-collapse-id']).toBe(`host:${HOST}`)
|
||||
expect(fake.requests[0]?.headers['apns-collapse-id']).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
|
||||
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadPushConfig, PUSH_DATABASE_POOL_MAX } from './config.js'
|
||||
|
||||
@@ -25,7 +25,6 @@ describe('push gateway config', () => {
|
||||
apns: undefined,
|
||||
apnsTopic: PUSH_DEFAULTS.apnsTopic,
|
||||
fcmProjectId: PUSH_DEFAULTS.fcmProjectId,
|
||||
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
|
||||
trustedProxyHops: 0
|
||||
})
|
||||
})
|
||||
@@ -42,7 +41,6 @@ describe('push gateway config', () => {
|
||||
ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456',
|
||||
ORCA_PUSH_APNS_TOPIC: 'com.stably.orca.mobile.dev',
|
||||
ORCA_PUSH_FCM_PROJECT_ID: 'onorca-staging',
|
||||
ORCA_PUSH_COALESCE_MS: '1500',
|
||||
ORCA_PUSH_TRUSTED_PROXY_HOPS: '1'
|
||||
})
|
||||
expect(config).toMatchObject({
|
||||
@@ -52,8 +50,7 @@ describe('push gateway config', () => {
|
||||
apns: { keyPem, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
|
||||
apnsTopic: 'com.stably.orca.mobile.dev',
|
||||
trustedProxyHops: 1,
|
||||
fcmProjectId: 'onorca-staging',
|
||||
coalesceMs: 1500
|
||||
fcmProjectId: 'onorca-staging'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,7 +94,6 @@ it('treats blank defaulted environment settings as absent', () => {
|
||||
'ORCA_PUSH_DATA_DIR',
|
||||
'ORCA_PUSH_APNS_TOPIC',
|
||||
'ORCA_PUSH_FCM_PROJECT_ID',
|
||||
'ORCA_PUSH_COALESCE_MS',
|
||||
'ORCA_PUSH_DATABASE_POOL_MAX',
|
||||
'ORCA_PUSH_TRUSTED_PROXY_HOPS'
|
||||
].map((key) => [key, ' '])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
|
||||
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PUSH_DATABASE_POOL_MAX = 10
|
||||
@@ -35,12 +35,6 @@ const EnvSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]{4,64}$/)
|
||||
.default(PUSH_DEFAULTS.fcmProjectId),
|
||||
ORCA_PUSH_COALESCE_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(60_000)
|
||||
.default(PUSH_LIMITS.coalesceWindowMs),
|
||||
// How many proxies append to x-forwarded-for after the client. 0 is Cloud Run
|
||||
// alone; raise it to 1 when a load balancer fronts the service.
|
||||
ORCA_PUSH_TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().max(8).default(0)
|
||||
@@ -58,7 +52,6 @@ export type PushConfig = {
|
||||
apns?: ApnsCredentials
|
||||
apnsTopic: string
|
||||
fcmProjectId: string
|
||||
coalesceMs: number
|
||||
trustedProxyHops: number
|
||||
}
|
||||
|
||||
@@ -113,7 +106,6 @@ export function loadPushConfig(env: NodeJS.ProcessEnv = process.env): PushConfig
|
||||
apns: readApnsCredentials(parsed),
|
||||
apnsTopic: parsed.ORCA_PUSH_APNS_TOPIC,
|
||||
fcmProjectId: parsed.ORCA_PUSH_FCM_PROJECT_ID,
|
||||
coalesceMs: parsed.ORCA_PUSH_COALESCE_MS,
|
||||
trustedProxyHops: parsed.ORCA_PUSH_TRUSTED_PROXY_HOPS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,42 +60,180 @@ describe('durable push acceptance', () => {
|
||||
expect(await store.accept('host', 'phone1', notification(301))).toBe('queued')
|
||||
})
|
||||
|
||||
it('recovers acknowledged work and coalesces across independent service instances', async () => {
|
||||
it('queues one delivery per event and recovers work across service instances', async () => {
|
||||
const { db, store, clock, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
const restarted = new DurablePushStore(db, clock)
|
||||
await restarted.accept('host', 'phone', notification(1))
|
||||
advance(1)
|
||||
await restarted.accept('host', 'phone', notification(2))
|
||||
advance(3000)
|
||||
const batch = await restarted.claim()
|
||||
expect(batch?.notifications).toHaveLength(2)
|
||||
const rows = await db.query(
|
||||
"SELECT payload_json, due_at, created_at FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending'",
|
||||
['phone']
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(
|
||||
rows.every((row) => (JSON.parse(String(row.payload_json)) as unknown[]).length === 1)
|
||||
).toBe(true)
|
||||
expect(rows.every((row) => Number(row.due_at) === 0)).toBe(true)
|
||||
const delivery = await restarted.claim()
|
||||
expect(delivery?.notification.notificationSeq).toBe(1)
|
||||
expect(await store.claim()).toBeNull()
|
||||
advance(DELIVERY_LEASE_MS)
|
||||
const reclaimed = await store.claim()
|
||||
expect(reclaimed?.id).toBe(batch?.id)
|
||||
expect(reclaimed?.lease).not.toBe(batch?.lease)
|
||||
await restarted.finish(batch!)
|
||||
expect(reclaimed?.id).toBe(delivery?.id)
|
||||
expect(reclaimed?.lease).not.toBe(delivery?.lease)
|
||||
await restarted.finish(delivery!)
|
||||
expect(await store.claim()).toBeNull()
|
||||
await store.finish(reclaimed!)
|
||||
advance(DELIVERY_LEASE_MS)
|
||||
const second = await restarted.claim()
|
||||
expect(second?.notification.notificationSeq).toBe(2)
|
||||
await restarted.finish(second!)
|
||||
expect(await restarted.claim()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps zero-marked new rows out of an old writer coalescing lookup', async () => {
|
||||
const { db, store, clock } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
await db.query(
|
||||
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
|
||||
VALUES ('legacy-row', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`,
|
||||
[JSON.stringify([notification(2)]), clock() + 3000, clock() + 300_000, clock()]
|
||||
)
|
||||
const oldWriterRows = await db.query(
|
||||
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND kind = 'alert' AND state = 'pending' AND attempts = 0 AND due_at > ?",
|
||||
['phone', clock()]
|
||||
)
|
||||
expect(oldWriterRows.map((row) => row.batch_id)).toEqual(['legacy-row'])
|
||||
})
|
||||
|
||||
it('atomically splits a legacy summary without changing its deadline', async () => {
|
||||
const { db, store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
advance(1)
|
||||
await store.accept('host', 'phone', notification(2))
|
||||
const rows = await db.query(
|
||||
"SELECT * FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' ORDER BY created_at",
|
||||
['phone']
|
||||
)
|
||||
const deadline = Number(rows[0]!.expires_at)
|
||||
await db.query(
|
||||
'UPDATE push_delivery_batches SET payload_json = ?, attempts = 2 WHERE batch_id = ?',
|
||||
[JSON.stringify([notification(1), notification(2)]), rows[0]!.batch_id]
|
||||
)
|
||||
await db.query('DELETE FROM push_delivery_batches WHERE batch_id = ?', [rows[1]!.batch_id])
|
||||
expect(await store.accept('host', 'phone', notification(2))).toBe('queued')
|
||||
expect(await store.pendingCount('phone')).toBe(2)
|
||||
|
||||
const first = (await store.claim())!
|
||||
expect(first.notification.notificationSeq).toBe(1)
|
||||
expect(first.expiresAt).toBe(deadline)
|
||||
expect(first.attempts).toBe(3)
|
||||
await store.finish(first)
|
||||
const second = (await store.claim())!
|
||||
expect(second.notification.notificationSeq).toBe(2)
|
||||
expect(second.expiresAt).toBe(deadline)
|
||||
expect(second.attempts).toBe(3)
|
||||
await store.finish(second)
|
||||
expect(await store.claim()).toBeNull()
|
||||
})
|
||||
|
||||
it('never extends expiry and refuses conflicting duplicate content', async () => {
|
||||
const { store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
expect(await store.accept('host', 'phone', { ...notification(1), body: 'changed' })).toBe(
|
||||
'error'
|
||||
)
|
||||
advance(3000)
|
||||
const batch = (await store.claim())!
|
||||
await store.finish(batch, 10 * 60_000)
|
||||
const delivery = (await store.claim())!
|
||||
await store.finish(delivery, 10 * 60_000)
|
||||
advance(60_000)
|
||||
expect(await store.claim()).toBeNull()
|
||||
advance(5 * 60_000)
|
||||
expect(await store.accept('host', 'phone', notification(1))).toBe('error')
|
||||
})
|
||||
|
||||
it('orders a due retry before a fresh first attempt without delaying the retry', async () => {
|
||||
const { store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
const first = (await store.claim())!
|
||||
await store.finish(first, 1000)
|
||||
expect(await store.claim()).toBeNull()
|
||||
|
||||
advance(1000)
|
||||
await store.accept('host', 'phone', notification(2))
|
||||
const retry = (await store.claim())!
|
||||
expect(retry.notification.notificationSeq).toBe(1)
|
||||
await store.finish(retry)
|
||||
const fresh = (await store.claim())!
|
||||
expect(fresh?.notification.notificationSeq).toBe(2)
|
||||
await store.finish(fresh!)
|
||||
})
|
||||
|
||||
it('orders an expired first-attempt lease by creation time after a retry becomes due', async () => {
|
||||
const { db, store, clock, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
const retry = (await store.claim())!
|
||||
await store.finish(retry, 1000)
|
||||
|
||||
advance(2000)
|
||||
await db.query(
|
||||
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
|
||||
VALUES ('crashed-singleton', 'host', 'phone', 'alert', ?, 'pending', 0, ?, 0, 1, ?)`,
|
||||
[JSON.stringify([notification(2)]), clock() + 300_000, clock()]
|
||||
)
|
||||
const reclaimedRetry = (await store.claim())!
|
||||
expect(reclaimedRetry.notification.notificationSeq).toBe(1)
|
||||
await store.finish(reclaimedRetry)
|
||||
const reclaimedCrash = (await store.claim())!
|
||||
expect(reclaimedCrash.notification.notificationSeq).toBe(2)
|
||||
await store.finish(reclaimedCrash)
|
||||
})
|
||||
|
||||
it('keeps every legacy member ahead of a same-ID event accepted one millisecond later', async () => {
|
||||
const { db, store, advance } = await fixture()
|
||||
const oldA = { ...notification(1), notificationId: 'A' }
|
||||
const oldC = { ...notification(2), notificationId: 'C' }
|
||||
const oldB = { ...notification(3), notificationId: 'B' }
|
||||
const newerB = { ...notification(4), notificationId: 'B' }
|
||||
await store.accept('host', 'phone', oldA)
|
||||
const [row] = await db.query(
|
||||
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = 'phone' AND state = 'pending'"
|
||||
)
|
||||
await db.query('UPDATE push_delivery_batches SET payload_json = ?, due_at = ? WHERE batch_id = ?', [
|
||||
JSON.stringify([oldA, oldC, oldB]),
|
||||
1_003_000,
|
||||
row!.batch_id
|
||||
])
|
||||
|
||||
advance(1)
|
||||
await store.accept('host', 'phone', newerB)
|
||||
const first = (await store.claim())!
|
||||
expect(first.notification.notificationSeq).toBe(1)
|
||||
const split = await db.query(
|
||||
"SELECT batch_id, created_at FROM push_delivery_batches WHERE batch_id LIKE ? ORDER BY batch_id",
|
||||
[`${String(row!.batch_id)}:legacy:%`]
|
||||
)
|
||||
expect(
|
||||
split.map((item) => ({
|
||||
batchId: String(item.batch_id),
|
||||
createdAt: Number(item.created_at)
|
||||
}))
|
||||
).toEqual([
|
||||
{ batchId: `${String(row!.batch_id)}:legacy:01`, createdAt: 1_000_000 },
|
||||
{ batchId: `${String(row!.batch_id)}:legacy:02`, createdAt: 1_000_000 }
|
||||
])
|
||||
await store.finish(first)
|
||||
const second = (await store.claim())!
|
||||
expect(second.notification.notificationSeq).toBe(2)
|
||||
await store.finish(second)
|
||||
const third = (await store.claim())!
|
||||
expect(third.notification.notificationSeq).toBe(3)
|
||||
await store.finish(third)
|
||||
const fourth = (await store.claim())!
|
||||
expect(fourth.notification.notificationSeq).toBe(4)
|
||||
await store.finish(fourth)
|
||||
})
|
||||
|
||||
it('rolls quota and payload back together if persistence fails', async () => {
|
||||
const { db, store } = await fixture()
|
||||
await db.query('ALTER TABLE push_delivery_batches RENAME TO push_delivery_batches_unavailable')
|
||||
@@ -120,26 +258,57 @@ it('serializes concurrent instances at the quota boundary', async () => {
|
||||
})
|
||||
|
||||
it('cancels unsent alerts and prevents an older replay after dismissal', async () => {
|
||||
const { store, advance } = await fixture()
|
||||
const { store } = await fixture()
|
||||
const alert = notification(1)
|
||||
await store.accept('host', 'phone', alert)
|
||||
await store.accept('host', 'phone', {
|
||||
...notification(2, 'dismiss'),
|
||||
notificationId: alert.notificationId
|
||||
})
|
||||
advance(3000)
|
||||
const batch = (await store.claim())!
|
||||
expect(batch.notifications.map((item) => item.kind)).toEqual(['dismiss'])
|
||||
await store.finish(batch)
|
||||
const delivery = (await store.claim())!
|
||||
expect(delivery.notification.kind).toBe('dismiss')
|
||||
await store.finish(delivery)
|
||||
expect(await store.claim()).toBeNull()
|
||||
await store.accept('host', 'another-phone', alert)
|
||||
expect(await store.claim()).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels one member of a legacy queued summary without dropping the others', async () => {
|
||||
const { db, store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
advance(1)
|
||||
await store.accept('host', 'phone', notification(2))
|
||||
const rows = await db.query(
|
||||
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' ORDER BY created_at",
|
||||
['phone']
|
||||
)
|
||||
await db.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [
|
||||
JSON.stringify([notification(1), notification(2)]),
|
||||
rows[0]!.batch_id
|
||||
])
|
||||
await db.query('DELETE FROM push_delivery_batches WHERE batch_id = ?', [rows[1]!.batch_id])
|
||||
advance(1)
|
||||
await store.accept('host', 'phone', {
|
||||
...notification(3, 'dismiss'),
|
||||
notificationId: notification(1).notificationId
|
||||
})
|
||||
|
||||
const delivered: PushNotification[] = []
|
||||
for (;;) {
|
||||
const queued = await store.claim()
|
||||
if (!queued) break
|
||||
delivered.push(queued.notification)
|
||||
await store.finish(queued)
|
||||
}
|
||||
expect(delivered.map((item) => [item.kind ?? 'alert', item.notificationSeq])).toEqual([
|
||||
['alert', 2],
|
||||
['dismiss', 3]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not resurrect an in-flight alert after a dismissal and transient provider failure', async () => {
|
||||
const { store, advance } = await fixture()
|
||||
await store.accept('host', 'phone', notification(1))
|
||||
advance(3000)
|
||||
const inFlight = (await store.claim())!
|
||||
await store.accept('host', 'phone', {
|
||||
...notification(2, 'dismiss'),
|
||||
@@ -147,7 +316,7 @@ it('does not resurrect an in-flight alert after a dismissal and transient provid
|
||||
})
|
||||
await store.finish(inFlight, 1000)
|
||||
const dismissal = (await store.claim())!
|
||||
expect(dismissal.notifications[0]?.kind).toBe('dismiss')
|
||||
expect(dismissal.notification.kind).toBe('dismiss')
|
||||
await store.finish(dismissal)
|
||||
advance(1000)
|
||||
expect(await store.claim()).toBeNull()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { canCoalescePushNotifications } from './push-delivery-message.js'
|
||||
import { reconcileQueuedDismissal, removeDismissedAlerts } from './push-queued-dismissal.js'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
|
||||
import type { PushDatabase, SqlRow } from './push-database.js'
|
||||
|
||||
const RETENTION_MS = 24 * 60 * 60_000
|
||||
const LEGACY_BATCH_MAX_NOTIFICATIONS = 32
|
||||
export const DELIVERY_LEASE_MS = 30_000
|
||||
export type DeliveryBatch = {
|
||||
export type QueuedPushDelivery = {
|
||||
id: string
|
||||
registrationId: string
|
||||
hostFingerprint: string
|
||||
notifications: PushNotification[]
|
||||
notification: PushNotification
|
||||
expiresAt: number
|
||||
lease: string
|
||||
attempts: number
|
||||
@@ -19,8 +19,7 @@ export type DeliveryBatch = {
|
||||
export class DurablePushStore {
|
||||
constructor(
|
||||
private readonly database: PushDatabase,
|
||||
private readonly now = Date.now,
|
||||
private readonly coalesceMs: number = PUSH_LIMITS.coalesceWindowMs
|
||||
private readonly now = Date.now
|
||||
) {}
|
||||
|
||||
async accept(
|
||||
@@ -66,46 +65,22 @@ export class DurablePushStore {
|
||||
if (recipient) return 'queued'
|
||||
if (await reconcileQueuedDismissal(tx, host, registrationId, notification, now))
|
||||
return 'queued'
|
||||
const [batch] =
|
||||
kind === 'alert'
|
||||
? await tx.query(
|
||||
"SELECT * FROM push_delivery_batches WHERE registration_id = ? AND kind = ? AND state = 'pending' AND attempts = 0 AND due_at > ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1",
|
||||
[registrationId, kind, now, now]
|
||||
)
|
||||
: []
|
||||
const notifications = batch
|
||||
? [...(JSON.parse(String(batch.payload_json)) as PushNotification[]), notification]
|
||||
: [notification]
|
||||
if (batch && canCoalescePushNotifications(notifications, host)) {
|
||||
await tx.query(
|
||||
'UPDATE push_delivery_batches SET payload_json = ?, expires_at = ? WHERE batch_id = ?',
|
||||
[
|
||||
JSON.stringify(notifications),
|
||||
Math.min(expiresAt, Number(batch.expires_at)),
|
||||
batch.batch_id
|
||||
]
|
||||
)
|
||||
} else {
|
||||
if (batch)
|
||||
await tx.query('UPDATE push_delivery_batches SET due_at = ? WHERE batch_id = ?', [
|
||||
now,
|
||||
batch.batch_id
|
||||
])
|
||||
await tx.query(
|
||||
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, 0, ?)`,
|
||||
[
|
||||
randomUUID(),
|
||||
host,
|
||||
registrationId,
|
||||
kind,
|
||||
JSON.stringify([notification]),
|
||||
now + (kind === 'dismiss' ? 0 : this.coalesceMs),
|
||||
expiresAt,
|
||||
now
|
||||
]
|
||||
)
|
||||
}
|
||||
await tx.query(
|
||||
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, 0, ?)`,
|
||||
[
|
||||
randomUUID(),
|
||||
host,
|
||||
registrationId,
|
||||
kind,
|
||||
// Keep the persisted envelope readable by workers from the previous release.
|
||||
JSON.stringify([notification]),
|
||||
// Zero marks a singleton so an overlapping old gateway will not append to it.
|
||||
0,
|
||||
expiresAt,
|
||||
now
|
||||
]
|
||||
)
|
||||
await tx.query(
|
||||
'INSERT INTO push_event_recipients(event_id, registration_id, created_at) VALUES (?, ?, ?)',
|
||||
[eventId, registrationId, now]
|
||||
@@ -114,15 +89,15 @@ export class DurablePushStore {
|
||||
})
|
||||
}
|
||||
|
||||
async claim(): Promise<DeliveryBatch | null> {
|
||||
async claim(): Promise<QueuedPushDelivery | null> {
|
||||
return this.database.transaction(async (tx) => {
|
||||
await tx.lockQuotaScope('push-worker-claim')
|
||||
const now = this.now()
|
||||
const params = [now, now, now, now]
|
||||
const predicate =
|
||||
"state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)"
|
||||
"state = 'pending' AND lease_until <= ? AND expires_at > ? AND (attempts = 0 OR due_at <= ?) AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)"
|
||||
let [row] = await tx.query(
|
||||
`SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY due_at, created_at LIMIT 1`,
|
||||
`SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY CASE WHEN attempts = 0 OR due_at = 0 THEN created_at ELSE due_at END, created_at, batch_id LIMIT 1`,
|
||||
params
|
||||
)
|
||||
if (!row) return null
|
||||
@@ -131,64 +106,90 @@ export class DurablePushStore {
|
||||
row.batch_id
|
||||
])
|
||||
if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null
|
||||
const notifications = await removeDismissedAlerts(
|
||||
const notifications = JSON.parse(String(row.payload_json)) as PushNotification[]
|
||||
if (notifications.length > LEGACY_BATCH_MAX_NOTIFICATIONS) {
|
||||
throw new Error('legacy_push_delivery_exceeds_member_limit')
|
||||
}
|
||||
const deliverable = await removeDismissedAlerts(
|
||||
tx,
|
||||
String(row.host_fingerprint),
|
||||
JSON.parse(String(row.payload_json)) as PushNotification[]
|
||||
notifications
|
||||
)
|
||||
if (!notifications.length) {
|
||||
if (!deliverable.length) {
|
||||
await tx.query(
|
||||
"UPDATE push_delivery_batches SET state = 'dismissed', payload_json = '[]' WHERE batch_id = ?",
|
||||
[row.batch_id]
|
||||
)
|
||||
return null
|
||||
}
|
||||
row.payload_json = JSON.stringify(notifications)
|
||||
const [notification, ...legacyRemainder] = deliverable
|
||||
row.payload_json = JSON.stringify([notification])
|
||||
await tx.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [
|
||||
row.payload_json,
|
||||
row.batch_id
|
||||
])
|
||||
for (const [index, queued] of legacyRemainder.entries()) {
|
||||
await tx.query(
|
||||
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, ?, ?)`,
|
||||
[
|
||||
`${String(row.batch_id)}:legacy:${String(index + 1).padStart(2, '0')}`,
|
||||
row.host_fingerprint,
|
||||
row.registration_id,
|
||||
queued.kind ?? 'alert',
|
||||
JSON.stringify([queued]),
|
||||
row.due_at,
|
||||
row.expires_at,
|
||||
row.attempts,
|
||||
row.created_at
|
||||
]
|
||||
)
|
||||
}
|
||||
const lease = randomUUID()
|
||||
await tx.query(
|
||||
'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?',
|
||||
[lease, now + DELIVERY_LEASE_MS, row.batch_id]
|
||||
)
|
||||
return this.batch(row, lease)
|
||||
return this.delivery(row, lease)
|
||||
})
|
||||
}
|
||||
|
||||
private batch(row: SqlRow, lease: string): DeliveryBatch {
|
||||
private delivery(row: SqlRow, lease: string): QueuedPushDelivery {
|
||||
return {
|
||||
id: String(row.batch_id),
|
||||
registrationId: String(row.registration_id),
|
||||
hostFingerprint: String(row.host_fingerprint),
|
||||
notifications: JSON.parse(String(row.payload_json)) as PushNotification[],
|
||||
notification: (JSON.parse(String(row.payload_json)) as PushNotification[])[0]!,
|
||||
expiresAt: Number(row.expires_at),
|
||||
lease,
|
||||
attempts: Number(row.attempts) + 1
|
||||
}
|
||||
}
|
||||
|
||||
async renew(batch: DeliveryBatch): Promise<void> {
|
||||
async renew(delivery: QueuedPushDelivery): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE push_delivery_batches SET lease_until = ? WHERE batch_id = ? AND lease_token = ?',
|
||||
[this.now() + DELIVERY_LEASE_MS, batch.id, batch.lease]
|
||||
[this.now() + DELIVERY_LEASE_MS, delivery.id, delivery.lease]
|
||||
)
|
||||
}
|
||||
|
||||
async finish(batch: DeliveryBatch, retryAfterMs?: number, outcome = 'done'): Promise<void> {
|
||||
async finish(
|
||||
delivery: QueuedPushDelivery,
|
||||
retryAfterMs?: number,
|
||||
outcome = 'done'
|
||||
): Promise<void> {
|
||||
const now = this.now()
|
||||
const retryAt = retryAfterMs === undefined ? Infinity : now + Math.max(1000, retryAfterMs)
|
||||
const retry = retryAt < batch.expiresAt
|
||||
const retry = retryAt < delivery.expiresAt
|
||||
await this.database.query(
|
||||
`UPDATE push_delivery_batches SET state = ?, payload_json = ?, due_at = ?, lease_until = 0, lease_token = NULL
|
||||
WHERE batch_id = ? AND lease_token = ?`,
|
||||
[
|
||||
retry ? 'pending' : retryAfterMs !== undefined ? 'expired' : outcome,
|
||||
retry ? JSON.stringify(batch.notifications) : '[]',
|
||||
retry ? JSON.stringify([delivery.notification]) : '[]',
|
||||
retry ? retryAt : now,
|
||||
batch.id,
|
||||
batch.lease
|
||||
delivery.id,
|
||||
delivery.lease
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,40 +67,36 @@ async function fixture() {
|
||||
}
|
||||
}
|
||||
|
||||
it('waits for the real coalescing deadline and sends complete summaries', async () => {
|
||||
it('sends every burst event immediately with its original content and identity', async () => {
|
||||
const h = await fixture()
|
||||
await h.accept(note(1))
|
||||
h.advance(2000)
|
||||
await h.accept(note(2, { agentState: 'needs-input' }))
|
||||
await h.accept(
|
||||
note(2, { agentState: 'needs-input', title: 'Answer needed', body: 'Please respond' })
|
||||
)
|
||||
await h.worker.runDue()
|
||||
expect(h.send).not.toHaveBeenCalled()
|
||||
h.advance(1000)
|
||||
await h.worker.runDue()
|
||||
expect(h.send).toHaveBeenCalledOnce()
|
||||
expect(h.send.mock.calls[0]![0]).toMatchObject({
|
||||
title: 'Orca',
|
||||
body: '2 agents need attention',
|
||||
orca: {
|
||||
notificationSeq: 2,
|
||||
coalescedCount: 2,
|
||||
summaryMembers: [
|
||||
{ notificationId: 'note-1', notificationSeq: 1, notificationEpoch: 'epoch' },
|
||||
{ notificationId: 'note-2', notificationSeq: 2, notificationEpoch: 'epoch' }
|
||||
]
|
||||
}
|
||||
})
|
||||
await h.accept(note(3))
|
||||
h.advance(3000)
|
||||
await h.worker.runDue()
|
||||
expect(h.send.mock.calls[1]![0]).toMatchObject({
|
||||
expect(h.send).toHaveBeenCalledTimes(2)
|
||||
const first = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 1)![0]
|
||||
const second = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 2)![0]
|
||||
expect(first).toMatchObject({
|
||||
title: 'Done',
|
||||
body: 'Finished task',
|
||||
orca: { coalescedCount: 1 }
|
||||
orca: {
|
||||
notificationId: 'note-1',
|
||||
notificationSeq: 1
|
||||
}
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
title: 'Answer needed',
|
||||
body: 'Please respond',
|
||||
orca: { notificationId: 'note-2', notificationSeq: 2 }
|
||||
})
|
||||
expect(first.collapseId).not.toBe(second.collapseId)
|
||||
expect(h.send.mock.calls.every(([delivery]) => !('coalescedCount' in delivery.orca))).toBe(true)
|
||||
expect(h.send.mock.calls.every(([delivery]) => !('summaryMembers' in delivery.orca))).toBe(true)
|
||||
expect(h.onRetry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps untrackable bells individual and summaries scoped to each phone', async () => {
|
||||
it('keeps untrackable bells and per-phone deliveries individually replaceable', async () => {
|
||||
const h = await fixture()
|
||||
const other = await h.devices.upsert({
|
||||
hostFingerprint: 'host',
|
||||
@@ -114,22 +110,15 @@ it('keeps untrackable bells individual and summaries scoped to each phone', asyn
|
||||
await h.accept(note(2))
|
||||
await h.accept(note(3))
|
||||
await h.store.accept('host', other.registrationId, note(2))
|
||||
h.advance(3000)
|
||||
await h.worker.runDue()
|
||||
expect(h.send).toHaveBeenCalledTimes(3)
|
||||
expect(h.send).toHaveBeenCalledTimes(4)
|
||||
const deliveries = h.send.mock.calls.map(([delivery]) => delivery)
|
||||
expect(deliveries.find((delivery) => delivery.orca.source === 'terminal-bell')).toMatchObject({
|
||||
collapseId: 'host:host',
|
||||
orca: { coalescedCount: 1 }
|
||||
})
|
||||
expect(deliveries.find((delivery) => delivery.orca.coalescedCount === 2)).toMatchObject({
|
||||
registrationId: h.registrationId,
|
||||
body: '2 updates'
|
||||
})
|
||||
const primary = deliveries.filter((delivery) => delivery.registrationId === h.registrationId)
|
||||
expect(primary).toHaveLength(3)
|
||||
expect(new Set(primary.map((delivery) => delivery.collapseId)).size).toBe(3)
|
||||
expect(
|
||||
deliveries.find((delivery) => delivery.registrationId === other.registrationId)?.orca
|
||||
.coalescedCount
|
||||
).toBe(1)
|
||||
deliveries.find((delivery) => delivery.registrationId === other.registrationId)
|
||||
).toMatchObject({ orca: { notificationId: 'note-2', notificationSeq: 2 } })
|
||||
})
|
||||
|
||||
it('persists provider retry delay and resumes it through a new worker', async () => {
|
||||
@@ -141,7 +130,6 @@ it('persists provider retry delay and resumes it through a new worker', async ()
|
||||
retryAfterMs: 10000
|
||||
})
|
||||
await h.accept(note(1))
|
||||
h.advance(3000)
|
||||
await h.worker.runDue()
|
||||
expect(h.send).toHaveBeenCalledOnce()
|
||||
await h.worker.stop()
|
||||
@@ -166,7 +154,6 @@ it('expires instead of shortening a provider delay beyond the delivery lifetime'
|
||||
retryAfterMs: 600000
|
||||
})
|
||||
await h.accept(note(1))
|
||||
h.advance(3000)
|
||||
await h.worker.runDue()
|
||||
h.advance(600000)
|
||||
await h.worker.runDue()
|
||||
@@ -179,7 +166,6 @@ it('rechecks the device before a persisted retry and does not send after unregis
|
||||
const h = await fixture()
|
||||
h.send.mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true })
|
||||
await h.accept(note(1))
|
||||
h.advance(3000)
|
||||
await h.worker.runDue()
|
||||
await h.devices.deleteOwned('host', h.registrationId)
|
||||
h.advance(3000)
|
||||
@@ -202,11 +188,9 @@ it('joins active work on shutdown and leaves unclaimed work for the next instanc
|
||||
})
|
||||
})
|
||||
await h.accept(note(1))
|
||||
h.advance(3000)
|
||||
const pending = h.worker.runDue()
|
||||
await entered
|
||||
await h.accept(note(2))
|
||||
h.advance(3000)
|
||||
let stopped = false
|
||||
const stopping = h.worker.stop().then(() => {
|
||||
stopped = true
|
||||
@@ -230,7 +214,6 @@ it('runs due work on its timer and releases the timer on stop', async () => {
|
||||
h.worker.start()
|
||||
h.worker.start()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
h.advance(3000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await h.worker.runDue()
|
||||
expect(h.send).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildPushDelivery, summaryBody } from './push-delivery-message.js'
|
||||
import { buildPushDelivery } from './push-delivery-message.js'
|
||||
import type { PushDispatcher } from './push-dispatcher.js'
|
||||
import type { DurablePushStore } from './durable-push-store.js'
|
||||
|
||||
@@ -45,41 +45,35 @@ export class DurablePushWorker {
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
for (let count = 0; count < 25 && !this.stopped; count++) {
|
||||
const batch = await this.store.claim()
|
||||
if (!batch) return
|
||||
const latest = batch.notifications.at(-1)!
|
||||
const multiple = batch.notifications.length > 1
|
||||
const queued = await this.store.claim()
|
||||
if (!queued) return
|
||||
const delivery = buildPushDelivery({
|
||||
registrationId: batch.registrationId,
|
||||
hostFingerprint: batch.hostFingerprint,
|
||||
notification: latest,
|
||||
title: multiple ? 'Orca' : latest.title,
|
||||
body: multiple ? summaryBody(batch.notifications) : latest.body,
|
||||
coalescedCount: batch.notifications.length,
|
||||
notifications: batch.notifications
|
||||
registrationId: queued.registrationId,
|
||||
hostFingerprint: queued.hostFingerprint,
|
||||
notification: queued.notification
|
||||
})
|
||||
delivery.expiresAt = batch.expiresAt
|
||||
if ((this.options.now ?? Date.now)() >= batch.expiresAt) {
|
||||
await this.store.finish(batch)
|
||||
delivery.expiresAt = queued.expiresAt
|
||||
if ((this.options.now ?? Date.now)() >= queued.expiresAt) {
|
||||
await this.store.finish(queued)
|
||||
continue
|
||||
}
|
||||
const heartbeat = setInterval(() => {
|
||||
void this.store.renew(batch).catch(() => {})
|
||||
void this.store.renew(queued).catch(() => {})
|
||||
}, 10_000)
|
||||
heartbeat.unref()
|
||||
try {
|
||||
if (batch.attempts > 1) this.options.onRetry?.()
|
||||
if (queued.attempts > 1) this.options.onRetry?.()
|
||||
const outcome = await this.dispatcher.sendOnce(delivery)
|
||||
const retryAfterMs =
|
||||
outcome.status === 'error' && outcome.retryable
|
||||
? Math.max(
|
||||
outcome.retryAfterMs ?? 0,
|
||||
Math.min(30_000, 1000 * 2 ** Math.min(batch.attempts, 5))
|
||||
Math.min(30_000, 1000 * 2 ** Math.min(queued.attempts, 5))
|
||||
)
|
||||
: undefined
|
||||
await this.store.finish(batch, retryAfterMs, outcome.status)
|
||||
await this.store.finish(queued, retryAfterMs, outcome.status)
|
||||
} catch {
|
||||
await this.store.finish(batch, 5000)
|
||||
await this.store.finish(queued, 5000)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildPushDelivery } from './push-delivery-message.js'
|
||||
const HOST = 'abcdefghijklmnop'
|
||||
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
function delivery(coalescedCount = 1, agentState: 'needs-input' | null = 'needs-input') {
|
||||
function delivery(agentState: 'needs-input' | null = 'needs-input') {
|
||||
return buildPushDelivery({
|
||||
registrationId: 'reg-1',
|
||||
hostFingerprint: HOST,
|
||||
@@ -19,10 +19,7 @@ function delivery(coalescedCount = 1, agentState: 'needs-input' | null = 'needs-
|
||||
title: 'Agent needs input',
|
||||
body: 'Waiting on your answer',
|
||||
worktreeId: 'wt-1'
|
||||
},
|
||||
title: coalescedCount > 1 ? 'Orca' : 'Agent needs input',
|
||||
body: coalescedCount > 1 ? '3 agents need attention' : 'Waiting on your answer',
|
||||
coalescedCount
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,8 +82,7 @@ describe('fcm client', () => {
|
||||
notificationSeq: '7',
|
||||
notificationEpoch: 'epoch-1',
|
||||
source: 'agent-task-complete',
|
||||
agentState: 'needs-input',
|
||||
coalescedCount: '1'
|
||||
agentState: 'needs-input'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -94,7 +90,7 @@ describe('fcm client', () => {
|
||||
|
||||
it('carries every data value as a string and omits a null agent state', async () => {
|
||||
const { fake, client: fcm } = client({ status: 200, body: '{}' })
|
||||
await fcm.send(delivery(3, null), { token: TOKEN })
|
||||
await fcm.send(delivery(null), { token: TOKEN })
|
||||
const message = JSON.parse(fake.requests[0]!.body) as {
|
||||
message: {
|
||||
android: { collapse_key: string; notification: { tag: string } }
|
||||
@@ -105,9 +101,12 @@ describe('fcm client', () => {
|
||||
true
|
||||
)
|
||||
expect(message.message.data.agentState).toBeUndefined()
|
||||
expect(message.message.data.coalescedCount).toBe('3')
|
||||
expect(message.message.android.notification.tag).toBe(`host:${HOST}`)
|
||||
expect(message.message.android.collapse_key).toBe(fcmCollapseKey(`host:${HOST}`))
|
||||
const tag = createHash('sha256')
|
||||
.update(JSON.stringify([HOST, 'note-1']))
|
||||
.digest('hex')
|
||||
expect(message.message.data.coalescedCount).toBeUndefined()
|
||||
expect(message.message.android.notification.tag).toBe(tag)
|
||||
expect(message.message.android.collapse_key).toBe(fcmCollapseKey(tag))
|
||||
expect(message.message.android.collapse_key).toHaveLength(32)
|
||||
})
|
||||
|
||||
|
||||
@@ -38,10 +38,7 @@ async function registered() {
|
||||
const delivery = buildPushDelivery({
|
||||
registrationId: row.registrationId,
|
||||
hostFingerprint: input.hostFingerprint,
|
||||
notification: note,
|
||||
title: note.title,
|
||||
body: note.body,
|
||||
coalescedCount: 1
|
||||
notification: note
|
||||
})
|
||||
return { db, devices, input, delivery }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { type PushNotification } from '@orca-cloud/push-contract'
|
||||
import { pushSummaryMembers, type PushSummaryMember } from './push-summary-members.js'
|
||||
|
||||
export type PushOrcaData = {
|
||||
kind?: 'alert' | 'dismiss'
|
||||
@@ -11,8 +10,6 @@ export type PushOrcaData = {
|
||||
notificationEpoch: string
|
||||
source: string
|
||||
agentState: string | null
|
||||
coalescedCount: number
|
||||
summaryMembers?: PushSummaryMember[]
|
||||
}
|
||||
|
||||
export type PushDelivery = {
|
||||
@@ -26,31 +23,13 @@ export type PushDelivery = {
|
||||
orca: PushOrcaData
|
||||
}
|
||||
|
||||
export function hostCollapseId(hostFingerprint: string): string {
|
||||
return `host:${hostFingerprint}`
|
||||
}
|
||||
|
||||
// APNs rejects a collapse id over 64 bytes, and notification ids are opaque
|
||||
// desktop strings that may be longer or carry multi-byte characters.
|
||||
export function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const encoded = Buffer.from(value, 'utf8')
|
||||
if (encoded.byteLength <= maxBytes) return value
|
||||
let end = maxBytes
|
||||
// Walk back off a continuation byte so the cut never splits a code point.
|
||||
while (end > 0 && (encoded[end]! & 0b1100_0000) === 0b1000_0000) end -= 1
|
||||
return encoded.subarray(0, end).toString('utf8')
|
||||
}
|
||||
|
||||
export function collapseIdFor(
|
||||
notification: PushNotification,
|
||||
hostFingerprint: string,
|
||||
coalescedCount: number
|
||||
): string {
|
||||
if (coalescedCount > 1 || notification.notificationId === undefined) {
|
||||
return hostCollapseId(hostFingerprint)
|
||||
}
|
||||
export function collapseIdFor(notification: PushNotification, hostFingerprint: string): string {
|
||||
const identity =
|
||||
notification.notificationId === undefined
|
||||
? [notification.notificationEpoch, notification.notificationSeq]
|
||||
: notification.notificationId
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([hostFingerprint, notification.notificationId]))
|
||||
.update(JSON.stringify([hostFingerprint, identity]))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
@@ -58,24 +37,15 @@ export function buildPushDelivery(input: {
|
||||
registrationId: string
|
||||
hostFingerprint: string
|
||||
notification: PushNotification
|
||||
title: string
|
||||
body: string
|
||||
coalescedCount: number
|
||||
notifications?: readonly PushNotification[]
|
||||
}): PushDelivery {
|
||||
const { notification, hostFingerprint, coalescedCount } = input
|
||||
const summaryMembers = input.notifications ? pushSummaryMembers(input.notifications) : undefined
|
||||
const { notification, hostFingerprint } = input
|
||||
return {
|
||||
...(notification.sound === false ? { sound: false } : {}),
|
||||
registrationId: input.registrationId,
|
||||
hostFingerprint,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
collapseId: summaryMembers
|
||||
? createHash('sha256')
|
||||
.update(JSON.stringify([hostFingerprint, summaryMembers]))
|
||||
.digest('hex')
|
||||
: collapseIdFor(notification, hostFingerprint, coalescedCount),
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
collapseId: collapseIdFor(notification, hostFingerprint),
|
||||
orca: {
|
||||
...(notification.kind ? { kind: notification.kind } : {}),
|
||||
hostFingerprint,
|
||||
@@ -86,9 +56,7 @@ export function buildPushDelivery(input: {
|
||||
notificationSeq: notification.notificationSeq,
|
||||
notificationEpoch: notification.notificationEpoch,
|
||||
source: notification.source,
|
||||
agentState: notification.agentState,
|
||||
coalescedCount,
|
||||
...(summaryMembers ? { summaryMembers } : {})
|
||||
agentState: notification.agentState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,36 +71,3 @@ export function orcaDataStrings(orca: PushOrcaData): Record<string, string> {
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function canCoalescePushNotifications(
|
||||
notifications: readonly PushNotification[],
|
||||
hostFingerprint: string
|
||||
): boolean {
|
||||
if (!pushSummaryMembers(notifications)) return false
|
||||
const delivery = buildPushDelivery({
|
||||
registrationId: '',
|
||||
hostFingerprint,
|
||||
notifications,
|
||||
notification: notifications.at(-1)!,
|
||||
title: 'Orca',
|
||||
body: '32 agents need attention',
|
||||
coalescedCount: notifications.length
|
||||
})
|
||||
// Reserve provider envelope space, including FCM's JSON-string escaping.
|
||||
return (
|
||||
Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
notification: { title: delivery.title, body: delivery.body },
|
||||
data: orcaDataStrings(delivery.orca)
|
||||
}),
|
||||
'utf8'
|
||||
) <= 3500
|
||||
)
|
||||
}
|
||||
|
||||
export function summaryBody(notifications: readonly PushNotification[]): string {
|
||||
const count = notifications.length
|
||||
return notifications.some((notification) => notification.agentState === 'needs-input')
|
||||
? `${count} agents need attention`
|
||||
: `${count} updates`
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ it('dismissal provider payloads cannot display a new alert or play a sound', ()
|
||||
agentState: null,
|
||||
title: 'Orca',
|
||||
body: ''
|
||||
},
|
||||
title: 'Orca',
|
||||
body: '',
|
||||
coalescedCount: 1
|
||||
}
|
||||
})
|
||||
expect(JSON.parse(apnsBody(delivery)).aps).toEqual({ 'content-available': 1 })
|
||||
const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message
|
||||
|
||||
@@ -12,8 +12,7 @@ export type PushDispatcherOptions = {
|
||||
onOutcome?: (outcome: PushProviderOutcome['status']) => void
|
||||
}
|
||||
|
||||
// Sends one coalesced delivery through the provider the registration belongs
|
||||
// to, and retires the registration when the provider says the token is gone.
|
||||
// Retires the registration when the provider says the token is gone.
|
||||
export class PushDispatcher {
|
||||
constructor(private readonly options: PushDispatcherOptions) {}
|
||||
|
||||
|
||||
@@ -17,10 +17,7 @@ it('carries a silent preference through validation to APNs and Android payloads'
|
||||
const delivery = buildPushDelivery({
|
||||
registrationId: 'reg',
|
||||
hostFingerprint: 'host',
|
||||
notification,
|
||||
title: 'Bell',
|
||||
body: '',
|
||||
coalescedCount: 1
|
||||
notification
|
||||
})
|
||||
expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound')
|
||||
expect(
|
||||
|
||||
@@ -22,12 +22,12 @@ export async function reconcileQueuedDismissal(
|
||||
DO UPDATE SET notification_seq = CASE WHEN push_dismissed_events.notification_seq > excluded.notification_seq THEN push_dismissed_events.notification_seq ELSE excluded.notification_seq END, created_at = excluded.created_at`,
|
||||
[...key, notification.notificationSeq, now]
|
||||
)
|
||||
const batches = await tx.query(
|
||||
"SELECT batch_id, payload_json FROM push_delivery_batches WHERE registration_id = ? AND kind = 'alert' AND state = 'pending' AND lease_until <= ?",
|
||||
[registrationId, now]
|
||||
const deliveries = await tx.query(
|
||||
"SELECT batch_id, payload_json FROM push_delivery_batches WHERE host_fingerprint = ? AND registration_id = ? AND kind = 'alert' AND state = 'pending' AND lease_until <= ?",
|
||||
[host, registrationId, now]
|
||||
)
|
||||
for (const batch of batches) {
|
||||
const previous = JSON.parse(String(batch.payload_json)) as PushNotification[]
|
||||
for (const delivery of deliveries) {
|
||||
const previous = JSON.parse(String(delivery.payload_json)) as PushNotification[]
|
||||
const remaining = previous.filter(
|
||||
(item) =>
|
||||
item.notificationEpoch !== notification.notificationEpoch ||
|
||||
@@ -37,7 +37,7 @@ export async function reconcileQueuedDismissal(
|
||||
if (remaining.length === previous.length) continue
|
||||
await tx.query(
|
||||
'UPDATE push_delivery_batches SET payload_json = ?, state = ? WHERE batch_id = ?',
|
||||
[JSON.stringify(remaining), remaining.length ? 'pending' : 'dismissed', batch.batch_id]
|
||||
[JSON.stringify(remaining), remaining.length ? 'pending' : 'dismissed', delivery.batch_id]
|
||||
)
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -6,7 +6,7 @@ afterEach(async () => {
|
||||
await Promise.all(harnesses.splice(0).map((h) => h.close()))
|
||||
})
|
||||
|
||||
it('returns queued for concurrent retries without double quota or a false summary', async () => {
|
||||
it('returns queued for concurrent retries without double quota or delivery', async () => {
|
||||
const h = await createPushServerHarness()
|
||||
harnesses.push(h)
|
||||
const token = await h.signIn(createPushHostKeypair(2))
|
||||
@@ -22,7 +22,7 @@ it('returns queued for concurrent retries without double quota or a false summar
|
||||
await h.post('/v1/send', body, token)
|
||||
await h.flushDeliveries()
|
||||
expect(h.fcmRequests).toHaveLength(1)
|
||||
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBe('1')
|
||||
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBeUndefined()
|
||||
expect(
|
||||
Number((await h.database.query('SELECT COUNT(*) AS count FROM push_events'))[0]?.count)
|
||||
).toBe(1)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
|
||||
import { expect } from 'vitest'
|
||||
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
|
||||
import type { PushConfig } from './config.js'
|
||||
@@ -45,7 +44,6 @@ export function testPushConfig(): PushConfig {
|
||||
apns: { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
|
||||
apnsTopic: 'com.stably.orca.mobile',
|
||||
fcmProjectId: 'onorca-cloud',
|
||||
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
|
||||
trustedProxyHops: 0
|
||||
}
|
||||
}
|
||||
@@ -117,7 +115,6 @@ export async function createPushServerHarness() {
|
||||
answer,
|
||||
now: () => clock,
|
||||
flushDeliveries: async (): Promise<void> => {
|
||||
clock += PUSH_LIMITS.coalesceWindowMs
|
||||
await server.worker.runDue()
|
||||
},
|
||||
advanceClock: (deltaMs: number): void => {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('push gateway send route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces a burst into one apns summary with a membership-specific identity', async () => {
|
||||
it('sends a burst as individual APNs alerts grouped by the host thread', async () => {
|
||||
const sessionToken = await harness.signIn(createPushHostKeypair(18))
|
||||
const registration = await harness.post(
|
||||
'/v1/devices',
|
||||
@@ -143,17 +143,30 @@ describe('push gateway send route', () => {
|
||||
)
|
||||
}
|
||||
await harness.flushDeliveries()
|
||||
expect(harness.apnsRequests).toHaveLength(1)
|
||||
const request = harness.apnsRequests[0]!
|
||||
expect(request.host).toBe('api.sandbox.push.apple.com')
|
||||
const body = JSON.parse(request.body) as {
|
||||
aps: { alert: { title: string; body: string } }
|
||||
orca: { coalescedCount: number; notificationSeq: number }
|
||||
}
|
||||
expect(body.aps.alert).toEqual({ title: 'Orca', body: '3 agents need attention' })
|
||||
expect(body.orca.coalescedCount).toBe(3)
|
||||
expect(body.orca.notificationSeq).toBe(3)
|
||||
expect(request.headers['apns-collapse-id']).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(harness.apnsRequests).toHaveLength(3)
|
||||
const bodies = harness.apnsRequests.map(
|
||||
(request) =>
|
||||
JSON.parse(request.body) as {
|
||||
aps: { alert: { title: string; body: string }; 'thread-id': string }
|
||||
orca: Record<string, unknown> & { notificationSeq: number }
|
||||
}
|
||||
)
|
||||
expect(
|
||||
harness.apnsRequests.every((request) => request.host === 'api.sandbox.push.apple.com')
|
||||
).toBe(true)
|
||||
expect(bodies.map((body) => body.aps.alert)).toEqual(
|
||||
Array.from({ length: 3 }, () => ({
|
||||
title: 'Agent needs input',
|
||||
body: 'Waiting on your answer'
|
||||
}))
|
||||
)
|
||||
expect(new Set(bodies.map((body) => body.aps['thread-id'])).size).toBe(1)
|
||||
expect(bodies.map((body) => body.orca.notificationSeq).sort((a, b) => a - b)).toEqual([1, 2, 3])
|
||||
expect(bodies.every((body) => !('coalescedCount' in body.orca))).toBe(true)
|
||||
expect(bodies.every((body) => !('summaryMembers' in body.orca))).toBe(true)
|
||||
expect(
|
||||
new Set(harness.apnsRequests.map((request) => request.headers['apns-collapse-id'])).size
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('sends a lone event through unchanged with its own collapse id', async () => {
|
||||
@@ -169,7 +182,7 @@ describe('push gateway send route', () => {
|
||||
message: { android: { notification: { tag: string } }; data: Record<string, string> }
|
||||
}
|
||||
expect(message.message.android.notification.tag).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(message.message.data.coalescedCount).toBe('1')
|
||||
expect(message.message.data.coalescedCount).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports an error for a registration the host does not own', async () => {
|
||||
@@ -200,7 +213,9 @@ describe('push gateway send route', () => {
|
||||
await harness.server.deliveryStore.accept(
|
||||
hostFingerprint,
|
||||
registrationId,
|
||||
PushNotificationSchema.parse(notification({ notificationSeq: index + 1000 }))
|
||||
PushNotificationSchema.parse(
|
||||
notification({ notificationId: `note-${index + 1000}`, notificationSeq: index + 1000 })
|
||||
)
|
||||
)
|
||||
).toBe('queued')
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createPushServer(
|
||||
const challenges = new PushHostChallengeStore(database, config.publicUrl, now)
|
||||
const sessions = new PushHostSessionStore(database, now)
|
||||
const devices = new PushDeviceRegistryStore(database, now)
|
||||
const deliveryStore = new DurablePushStore(database, now, config.coalesceMs)
|
||||
const deliveryStore = new DurablePushStore(database, now)
|
||||
const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null)
|
||||
const dispatcher = new PushDispatcher({
|
||||
devices,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { PushNotification } from '@orca-cloud/push-contract'
|
||||
import { openInMemoryPushDatabase } from './push-database.js'
|
||||
import { DurablePushStore } from './durable-push-store.js'
|
||||
import {
|
||||
buildPushDelivery,
|
||||
canCoalescePushNotifications,
|
||||
orcaDataStrings
|
||||
} from './push-delivery-message.js'
|
||||
import { apnsBody } from './apns-client.js'
|
||||
|
||||
const notification = (seq: number, id = `agent-${seq}`): PushNotification => ({
|
||||
notificationId: id,
|
||||
notificationEpoch: 'epoch',
|
||||
notificationSeq: seq,
|
||||
source: 'agent-task-complete',
|
||||
agentState: 'finished',
|
||||
title: 'Done',
|
||||
body: ''
|
||||
})
|
||||
it('partitions a burst into durable, complete summaries within the provider payload budget', async () => {
|
||||
const database = await openInMemoryPushDatabase()
|
||||
try {
|
||||
let now = 1000
|
||||
const store = new DurablePushStore(database, () => now)
|
||||
const expected = Array.from({ length: 70 }, (_, index) => notification(index))
|
||||
expected.push(notification(70, 'a'.repeat(1800)), notification(71, 'b'.repeat(1800)))
|
||||
for (const item of expected) expect(await store.accept('host', 'phone', item)).toBe('queued')
|
||||
now += 3000
|
||||
const recovered: number[] = []
|
||||
const collapseIds = new Set<string>()
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const batch = await store.claim()
|
||||
if (!batch) break
|
||||
const delivery = buildPushDelivery({
|
||||
registrationId: 'phone',
|
||||
hostFingerprint: 'host',
|
||||
notification: batch.notifications.at(-1)!,
|
||||
notifications: batch.notifications,
|
||||
title: 'Orca',
|
||||
body: 'Updates',
|
||||
coalescedCount: batch.notifications.length
|
||||
})
|
||||
expect(Buffer.byteLength(apnsBody(delivery))).toBeLessThanOrEqual(4096)
|
||||
expect(
|
||||
Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
notification: { title: delivery.title, body: delivery.body },
|
||||
data: orcaDataStrings(delivery.orca)
|
||||
})
|
||||
)
|
||||
).toBeLessThanOrEqual(4096)
|
||||
if (batch.notifications.length > 1) {
|
||||
expect(delivery.orca.summaryMembers).toHaveLength(batch.notifications.length)
|
||||
expect(JSON.parse(orcaDataStrings(delivery.orca).summaryMembers!)).toEqual(
|
||||
delivery.orca.summaryMembers
|
||||
)
|
||||
}
|
||||
expect(collapseIds.has(delivery.collapseId)).toBe(false)
|
||||
collapseIds.add(delivery.collapseId)
|
||||
recovered.push(...batch.notifications.map((item) => item.notificationSeq))
|
||||
await store.finish(batch)
|
||||
}
|
||||
expect(recovered.sort((a, b) => a - b)).toEqual(expected.map((item) => item.notificationSeq))
|
||||
expect(await store.pendingCount('phone')).toBe(0)
|
||||
} finally {
|
||||
await database.close()
|
||||
}
|
||||
})
|
||||
it('does not make an untrackable event part of a dismissible summary', () => {
|
||||
expect(
|
||||
canCoalescePushNotifications(
|
||||
[notification(1), { ...notification(2), notificationId: undefined }],
|
||||
'host'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
canCoalescePushNotifications([notification(1), { ...notification(2), kind: 'dismiss' }], 'host')
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { PushNotification } from '@orca-cloud/push-contract'
|
||||
|
||||
export type PushSummaryMember = {
|
||||
notificationId: string
|
||||
notificationEpoch: string
|
||||
notificationSeq: number
|
||||
}
|
||||
|
||||
export function pushSummaryMembers(
|
||||
notifications: readonly PushNotification[]
|
||||
): PushSummaryMember[] | undefined {
|
||||
if (notifications.length < 2 || notifications.length > 32) return undefined
|
||||
if (notifications.some((item) => !item.notificationId || item.kind === 'dismiss')) return undefined
|
||||
return notifications.map((item) => ({
|
||||
notificationId: item.notificationId!,
|
||||
notificationEpoch: item.notificationEpoch,
|
||||
notificationSeq: item.notificationSeq
|
||||
}))
|
||||
}
|
||||
+56
-40
@@ -3,7 +3,10 @@
|
||||
`orca-cloud-push` is a public Cloud Run service in `onorca-cloud` that turns a desktop
|
||||
notification into an APNs or FCM push for a paired phone. The desktop registers each phone's
|
||||
native token with it and calls `POST /v1/send` after the socket fan-out it already does; the
|
||||
phone dedupes by `notificationId#notificationSeq`. The service is the only place the Apple
|
||||
phone treats APNs/FCM as the sole ordinary OS-banner path. The notification socket is retained only
|
||||
for live dismissal and reconnect tray reconciliation; it does not create or recover banners. Desktop
|
||||
notification categories remain authoritative; category and summary fields retained in contracts
|
||||
exist only for mixed-version compatibility. The service is the only place the Apple
|
||||
`.p8` signing key is readable, which is the reason it exists as a service at all.
|
||||
|
||||
The contract every lane builds against is `docs/reference/mobile-push-contract.md` in the
|
||||
@@ -17,22 +20,22 @@ edit plus a second set of Apple credentials.
|
||||
|
||||
## Shape
|
||||
|
||||
| Setting | Value | Where |
|
||||
| --- | --- | --- |
|
||||
| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` |
|
||||
| Region | `us-central1` | `region` |
|
||||
| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` |
|
||||
| Database pool | 2 per instance | `push_database_pool_max` |
|
||||
| Concurrency | 80 | `push_concurrency` |
|
||||
| Ingress | all | `INGRESS_TRAFFIC_ALL` |
|
||||
| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service |
|
||||
| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` |
|
||||
| Database | `orca_push` on the shared Cloud SQL instance | `google_sql_database.push` |
|
||||
| Hostname | `push.onorca.dev` | `push_base_url` |
|
||||
| Setting | Value | Where |
|
||||
| ----------------- | ------------------------------------------------------ | -------------------------------------------- |
|
||||
| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` |
|
||||
| Region | `us-central1` | `region` |
|
||||
| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` |
|
||||
| Database pool | 2 per instance | `push_database_pool_max` |
|
||||
| Concurrency | 80 | `push_concurrency` |
|
||||
| Ingress | all | `INGRESS_TRAFFIC_ALL` |
|
||||
| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service |
|
||||
| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` |
|
||||
| Database | `orca_push` on the shared Cloud SQL instance | `google_sql_database.push` |
|
||||
| Hostname | `push.onorca.dev` | `push_base_url` |
|
||||
|
||||
The minimum of one instance is deliberate and did not move when the ceiling came down to two. A
|
||||
cold start delays a notification past the point where it is worth showing, and the three-second
|
||||
coalescing window lives in instance memory, so the floor is what keeps a notification prompt. The
|
||||
cold start delays a notification past the point where it is worth showing, so the floor is what
|
||||
keeps a notification prompt. The
|
||||
ceiling is a different question, answered below.
|
||||
|
||||
The maximum and the pool are set by the connection budget, not by the gateway's own appetite. Two
|
||||
@@ -58,20 +61,20 @@ the only way to reach an open service here.
|
||||
|
||||
Set on the container by Terraform:
|
||||
|
||||
| Variable | Source |
|
||||
| --- | --- |
|
||||
| `PORT` | Cloud Run, container port 8080 |
|
||||
| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` |
|
||||
| `ORCA_PUSH_FCM_PROJECT_ID` | `push_fcm_project_id`, empty means `project_id` |
|
||||
| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-database-url`, version `latest` |
|
||||
| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance |
|
||||
| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` |
|
||||
| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` |
|
||||
| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` |
|
||||
| Variable | Source |
|
||||
| ----------------------------- | -------------------------------------------------------- |
|
||||
| `PORT` | Cloud Run, container port 8080 |
|
||||
| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` |
|
||||
| `ORCA_PUSH_FCM_PROJECT_ID` | `push_fcm_project_id`, empty means `project_id` |
|
||||
| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-database-url`, version `latest` |
|
||||
| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance |
|
||||
| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` |
|
||||
| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` |
|
||||
| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` |
|
||||
|
||||
`ORCA_PUSH_APNS_TOPIC` and `ORCA_PUSH_COALESCE_MS` are left to their application defaults
|
||||
(`com.stably.orca.mobile` and `3000`). Add them here only when one of them has to differ from
|
||||
the code default, so that a code-side change stays visible rather than silently overridden.
|
||||
`ORCA_PUSH_APNS_TOPIC` is left to its application default (`com.stably.orca.mobile`). Add it here
|
||||
only when it has to differ from the code default, so that a code-side change stays visible rather
|
||||
than silently overridden.
|
||||
|
||||
Terraform owns the three Apple secret **names, labels, and replication, and never a version.**
|
||||
The `.p8` is issued by the Apple developer portal, so a Terraform-managed version would put the
|
||||
@@ -251,6 +254,14 @@ The inert phase intentionally cannot validate a new schema by applying it to pro
|
||||
migrations and validate them against isolated PostgreSQL before dispatch. No actual Cloud Run
|
||||
rollout, provider delivery or physical-device acceptance is implied by local contract tests.
|
||||
|
||||
The individual-presentation contract begins only after every older worker revision has retired.
|
||||
During rollout overlap, an old worker may still send a pre-existing queue row as a summary or assign
|
||||
its former host-wide collapse identity to a new singleton identity-less bell. Do not compensate by
|
||||
fabricating notification IDs or extending the wire contract. After retirement and connection drain,
|
||||
acceptance must send two alerts for one host (including the identity-less shape) and confirm their
|
||||
provider replacement identities remain independent and each can be dismissed without replacing the
|
||||
other.
|
||||
|
||||
### Why the FCM probe impersonates the runtime account
|
||||
|
||||
A gateway that boots and answers `/ready` can still be unable to send: the FCM grant lives on
|
||||
@@ -283,6 +294,7 @@ the window between.
|
||||
```
|
||||
|
||||
The team ID does not change, so `orca-cloud-push-apple-team-id` is untouched.
|
||||
|
||||
3. Dispatch `Deploy Push Gateway Production`. The container reads `latest` at start, so only a
|
||||
new revision picks the key up; there is no in-place reload.
|
||||
4. Verify from a real device that an iOS notification still arrives. The workflow's FCM probe
|
||||
@@ -321,11 +333,11 @@ problem, not device churn.
|
||||
Two independent limits, both enforced in the gateway and both returning HTTP 200 with
|
||||
`status: "rate_limited"` per result rather than failing the request:
|
||||
|
||||
| Limit | Scope |
|
||||
| --- | --- |
|
||||
| 300 logical alerts per rolling 15 minutes | per `hostFingerprint` |
|
||||
| 300 logical dismissals per rolling 15 minutes | per `hostFingerprint`, separate budget |
|
||||
| 20 `registrationIds` | per request, hard cap, HTTP 400 over it |
|
||||
| Limit | Scope |
|
||||
| --------------------------------------------- | --------------------------------------- |
|
||||
| 300 logical alerts per rolling 15 minutes | per `hostFingerprint` |
|
||||
| 300 logical dismissals per rolling 15 minutes | per `hostFingerprint`, separate budget |
|
||||
| 20 `registrationIds` | per request, hard cap, HTTP 400 over it |
|
||||
|
||||
Fanout to several phones counts one logical event; there is no per-phone daily allowance.
|
||||
Unauthenticated handshakes and invalid bearer attempts have separate 30/minute IP buckets.
|
||||
@@ -333,7 +345,8 @@ Authenticated requests use a 600/minute host bucket per instance. Auth database
|
||||
and waiting work are bounded independently of HTTP concurrency.
|
||||
|
||||
`push_events` backs quota accounting. `push_event_recipients` deduplicates fanout and
|
||||
`push_delivery_batches` persists coalescing, worker leases, retries and outcomes. Identity metadata
|
||||
`push_delivery_batches` retains its historical name and persists individual deliveries, worker
|
||||
leases, retries and outcomes. Identity metadata
|
||||
is retained for 24 hours. Payloads expire within five minutes and are cleared on completion or by
|
||||
minute-level expiry cleanup. FCM project-level provider quotas remain independent of host limits.
|
||||
|
||||
@@ -355,7 +368,6 @@ push.onorca.dev. CNAME ghs.googlehosted.com. (DNS only, not proxied)
|
||||
record is ever lost, recreate it exactly like that; Cloudflare proxying blocks certificate
|
||||
issuance and breaks Cloud Run host routing.
|
||||
|
||||
|
||||
### Recovery and delivery guarantees
|
||||
|
||||
Candidate tags and deterministic revision names are recorded before deployment. Promotion intent is
|
||||
@@ -370,14 +382,18 @@ Session replacement is serialized per host and a unique host index upgrades olde
|
||||
retaining their newest session. Cloud Verify runs push concurrency tests against PostgreSQL.
|
||||
|
||||
Accepted sends commit quota and pending work together before returning `queued`. Workers resume
|
||||
unfinished batches after restarts without relying on desktop retries. Shared batching and expiring
|
||||
leases coordinate replicas. All provider attempts retain the original five-minute deadline and
|
||||
respect provider backoff; no retry extends alert life. Silent dismissal messages have their own quota
|
||||
and cancel matching unsent alerts. Mobile OS delivery/execution is not guaranteed.
|
||||
unfinished deliveries after restarts without relying on desktop retries. The durable queue and
|
||||
expiring leases coordinate replicas. All provider attempts retain the original five-minute deadline
|
||||
and respect provider backoff; no retry extends alert life. Silent dismissal messages have their own
|
||||
quota and cancel matching unsent alerts. Mobile OS delivery/execution is not guaranteed.
|
||||
|
||||
Shutdown stops admission and new claims; unfinished leases remain recoverable. Provider acceptance
|
||||
and SQL completion cannot be atomic, so repeated transport delivery remains possible after a crash.
|
||||
Stable collapse identities reduce duplicates without promising exactly-once visible delivery.
|
||||
Stable per-event replacement identities reduce duplicates without promising exactly-once visible
|
||||
delivery. FCM notification messages are inherently collapsible while offline and support only a
|
||||
small number of concurrent collapse keys per device, so excess pending messages may be discarded and
|
||||
every offline alert is not guaranteed to appear. Socket reconnect reconciles dismissals against the
|
||||
current native tray; it has no stored replay watermark and never recovers a missed OS banner.
|
||||
|
||||
### Dedicated database preparation
|
||||
|
||||
|
||||
@@ -45,12 +45,10 @@ describe('push contract limits', () => {
|
||||
maxDevicesPerListResponse: 1_024,
|
||||
hostEventsPerWindow: 300,
|
||||
eventQuotaWindowMs: 900_000,
|
||||
coalesceWindowMs: 3_000,
|
||||
challengeTtlMs: 10_000,
|
||||
clockSkewToleranceMs: 30_000,
|
||||
sessionTtlMs: 86_400_000,
|
||||
notificationTtlSeconds: 300,
|
||||
apnsCollapseIdMaxBytes: 64,
|
||||
hostRetentionMs: 3_600_000,
|
||||
unauthenticatedRequestsPerMinutePerIp: 30,
|
||||
authenticatedRequestsPerMinutePerHost: 600
|
||||
|
||||
@@ -11,13 +11,11 @@ export const PUSH_LIMITS = {
|
||||
maxHttpBodyBytes: 16 * 1024,
|
||||
hostEventsPerWindow: 300,
|
||||
eventQuotaWindowMs: 15 * 60 * 1000,
|
||||
coalesceWindowMs: 3_000,
|
||||
challengeTtlMs: 10_000,
|
||||
// Covers routine NTP drift without extending the signed challenge window.
|
||||
clockSkewToleranceMs: 30_000,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
notificationTtlSeconds: 5 * 60,
|
||||
apnsCollapseIdMaxBytes: 64,
|
||||
// Nothing reads a host row, and any keypair mints one for free, so a host
|
||||
// with no registration left is kept only long enough to survive a phone swap.
|
||||
hostRetentionMs: 60 * 60 * 1000,
|
||||
|
||||
@@ -43,8 +43,7 @@ export const PushNotificationSchema = z
|
||||
export const PushSendRequestSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
// Deduped before the gateway sees it: a repeated id would otherwise reserve
|
||||
// quota twice and inflate the coalesced count for one banner.
|
||||
// Deduped before the gateway sees it so a repeated id cannot reserve quota twice.
|
||||
registrationIds: z
|
||||
.array(OpaqueIdSchema)
|
||||
.min(1)
|
||||
|
||||
@@ -7,10 +7,13 @@ This document is the single contract every lane builds against. Do not deviate w
|
||||
|
||||
A small Orca-hosted push gateway (`cloud/apps/push`) holds the APNs key and FCM credentials and sends
|
||||
to phones. The desktop host registers each paired phone's native push token with the gateway and asks
|
||||
the gateway to push on every mobile notification it already fans out over the socket. The phone dedupes
|
||||
by host, counter epoch, and `notificationId#notificationSeq`. Foreground pushes and socket events
|
||||
share the same per-host delivery queue so a pending native schedule cannot produce a second banner. No ack gate, no generic mode, no staging gateway, one auth path for
|
||||
signed-in and accountless hosts.
|
||||
the gateway to push each eligible desktop notification. Native APNs/FCM delivery is the only ordinary
|
||||
mobile OS-banner path. The notification socket is retained only for live dismissals and reconnect tray
|
||||
reconciliation; ordinary notification frames never create banners. Reconciliation compares the current
|
||||
native tray with durable host dismissal history and does not replay alerts. Desktop notification
|
||||
categories are authoritative. Legacy category, replay-notification, and summary fields remain on the
|
||||
wire only for mixed-version compatibility. No ack gate, no generic mode, no staging gateway, one auth
|
||||
path for signed-in and accountless hosts.
|
||||
|
||||
## Identities
|
||||
|
||||
@@ -96,9 +99,10 @@ replaces the old. `deviceId` is caller-chosen, so a host is capped at 64 registr
|
||||
distinct `deviceId` → 409 `{ "error": "too_many_devices" }`. Re-registering a `deviceId` the host
|
||||
already owns is always accepted, and deleting a registration frees its slot. `GET /v1/devices` is
|
||||
bounded at 1024 rows to match its response schema, which the per-host cap keeps well out of reach.
|
||||
`filter` is stored but enforced by the host (see desktop); gateway stores it only so a
|
||||
host restart can re-read it. iOS tokens are variable-length, hex-encoded byte strings; Android
|
||||
tokens are FCM registration strings.
|
||||
The gateway `filter` contains only the legacy `sources` and `agentStates` arrays. It remains stored and
|
||||
accepted for mixed-version schema compatibility but is not an updated delivery-policy input. The host
|
||||
persists and enforces desktop eligibility plus the phone-specific away-only, sound, and expiry settings.
|
||||
iOS tokens are variable-length, hex-encoded byte strings; Android tokens are FCM registration strings.
|
||||
|
||||
`DELETE /v1/devices/:registrationId` (Bearer) → 204. Only the owning host may delete.
|
||||
|
||||
@@ -162,25 +166,37 @@ tokens are FCM registration strings.
|
||||
but the OS banner shows while the app is backgrounded. Reaching it needs the victim's native token,
|
||||
which the gateway never returns and which only the phone and its host ever see.
|
||||
|
||||
### Coalescing (gateway)
|
||||
### Individual delivery (gateway)
|
||||
|
||||
Per registration, persist a three-second window and summarize bursts using the latest event's
|
||||
routing fields and `coalescedCount`. Windows are shared across replicas. Single-alert collapse IDs
|
||||
hash host and notification identity; summaries hash the host and their complete membership.
|
||||
Each summary carries optional `summaryMembers` (ID, epoch, sequence), encoded as a JSON string
|
||||
in FCM data. Admission splits bursts into independently leased batches of at most 32 members,
|
||||
and reserves provider envelope space within the 4 KB payload limit. An event without a notification
|
||||
identity stays individual. Batches that fill the membership/payload budget become immediately due;
|
||||
quota accounting still counts logical alerts, not batches or recipients.
|
||||
Each accepted event immediately creates one queued delivery per eligible registration. Bursts retain
|
||||
their original title, body, routing fields, and replacement identity; the gateway does not generate
|
||||
summary text or summary membership. APNs groups alerts visually by the host thread identifier. Android
|
||||
uses a distinct notification tag for each event and relies on platform behavior rather than a custom
|
||||
summary notification. Quota accounting still counts logical alerts, not deliveries or recipients.
|
||||
|
||||
Four worker lanes per instance claim delivery batches with expiring, renewed SQL leases. Retry state
|
||||
Four worker lanes per instance claim deliveries with expiring, renewed SQL leases. Retry state
|
||||
is persistent, with exponential backoff and provider minimum delays. Retry-After is never shortened
|
||||
to fit event lifetime: expire instead. Device validity is checked before each attempt. Dismissals
|
||||
cancel pending matching alerts, bypass alert coalescing, and use silent provider messages. Mobile OS
|
||||
cancel the matching pending alert and use silent provider messages. Mobile OS
|
||||
background execution remains best effort, particularly after force-quit on iOS.
|
||||
|
||||
The queue table retains its historical `push_delivery_batches` name to avoid a data migration. New
|
||||
rows keep a one-element JSON array only as a rolling-deploy storage envelope so an older worker can
|
||||
read them, but new code models and sends one notification. New rows are immediately due, which keeps
|
||||
an overlapping older gateway from appending to them. A new worker atomically splits a pre-deployment
|
||||
multi-event row into individual rows, preserving its fixed expiry, attempt count, and existing event
|
||||
recipient records; legacy rows were capped at 32 events, bounding that transaction. A legacy row
|
||||
already leased by an older revision completes under that revision's behavior, while immediately due
|
||||
new rows cannot be appended to by its admission path.
|
||||
|
||||
Individual presentation is guaranteed only after every older worker revision has retired. During
|
||||
the overlap, an older worker can still send a pre-existing row as a summary and can assign its former
|
||||
host-wide collapse identity to a new singleton identity-less bell. Do not add fabricated IDs or new
|
||||
wire fields to conceal old-binary behavior. Post-retirement acceptance must verify that two alerts for
|
||||
one host retain independent provider replacement identities and can be dismissed independently.
|
||||
|
||||
Shutdown stops admission and work acquisition, waits for active work within the platform grace, and
|
||||
leaves unfinished batches recoverable after their leases expire. A provider acceptance followed by a
|
||||
leaves unfinished deliveries recoverable after their leases expire. A provider acceptance followed by a
|
||||
crash before SQL completion can still cause a repeated send; collapse identity mitigates this without
|
||||
promising exactly-once delivery.
|
||||
|
||||
@@ -190,10 +206,11 @@ APNs (HTTP/2, `api.push.apple.com` or `api.sandbox.push.apple.com` by `apnsEnvir
|
||||
from key id + team id + `.p8`, token cached and refreshed every 50 min):
|
||||
|
||||
- headers: `apns-topic: com.stably.orca.mobile`, `apns-push-type: alert`, `apns-priority: 10`,
|
||||
`apns-expiration: fixed event deadline (at most five minutes)`, `apns-collapse-id: <sha256(host + notification identity), or host:<fp>>`
|
||||
`apns-expiration: fixed event deadline (at most five minutes)`, `apns-collapse-id: <sha256(host + notification identity)>`
|
||||
(identity-less events use their epoch and sequence)
|
||||
- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":"<hostFingerprint>"},
|
||||
"orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
|
||||
agentState, coalescedCount }}`
|
||||
agentState }}`
|
||||
- Dismissals use `apns-push-type: background`, priority `5`, no collapse header, and
|
||||
`aps: {"content-available": 1}` with `orca.kind: "dismiss"`. They carry no alert or sound.
|
||||
- Dead token: 410, or 400 with `BadDeviceToken`/`Unregistered`/`DeviceTokenNotForTopic`.
|
||||
@@ -206,9 +223,12 @@ metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
|
||||
"data":{ all orca fields as strings }}}`
|
||||
- Dismissals are data-only (`kind: "dismiss"`); omit both `message.notification` and
|
||||
`android.notification`. No visible alert or sound is requested.
|
||||
- FCM notification messages are inherently collapsible while offline; `collapse_key` does not
|
||||
preserve every alert. Android `tag` controls replacement after delivery. On reconnect, the
|
||||
existing host notification replay recovers retained events; the tray is not an event log.
|
||||
- FCM notification messages are inherently collapsible while offline, and FCM supports only a small
|
||||
number of concurrent collapse keys per device. Per-event `collapse_key` and Android `tag` preserve
|
||||
individual replacement identity while a message is retained, but excess offline pending messages
|
||||
may be discarded and every alert is not guaranteed to appear. Android automatic grouping remains
|
||||
platform-owned and is unverified on physical devices. Socket reconnect never recovers missed OS
|
||||
banners; the tray is not an event log.
|
||||
- Dead token: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
|
||||
|
||||
### Gateway storage (Postgres in prod, SQLite in tests, same pattern as `cloud/apps/relay/src/database.ts`)
|
||||
@@ -225,7 +245,8 @@ expires_at, consumed_at)`
|
||||
filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
|
||||
- `push_events` holds logical event identity, content fingerprint, quota timestamp, and expiry.
|
||||
- `push_event_recipients` records accepted event/phone pairs for idempotent fanout.
|
||||
- `push_delivery_batches` holds coalesced payloads, retry deadlines, and renewable worker leases.
|
||||
- `push_delivery_batches` holds individual payload envelopes, retry deadlines, and renewable worker
|
||||
leases.
|
||||
- `push_dismissed_events` fences older alerts from replaying after dismissal.
|
||||
- Queue identities and dismissal fences are retained for 24 hours; completed payloads are cleared.
|
||||
|
||||
@@ -237,7 +258,7 @@ Logging: aggregate counters only. Never log tokens, titles, bodies, or raw finge
|
||||
`PORT`, `ORCA_PUSH_PUBLIC_URL`, `ORCA_PUSH_DATABASE_URL` (absent → SQLite under `ORCA_PUSH_DATA_DIR`),
|
||||
`ORCA_PUSH_APNS_KEY` (PEM text), `ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`,
|
||||
`ORCA_PUSH_APNS_TOPIC` (default `com.stably.orca.mobile`), `ORCA_PUSH_FCM_PROJECT_ID` (default
|
||||
`onorca-cloud`), `ORCA_PUSH_COALESCE_MS` (default 3000), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
|
||||
`onorca-cloud`), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
|
||||
proxies appending to `x-forwarded-for` after the client).
|
||||
Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-key`,
|
||||
`orca-cloud-push-apns-key-id`, `orca-cloud-push-apple-team-id`. Runtime SA:
|
||||
@@ -247,16 +268,19 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
|
||||
|
||||
- Capability `NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1'` in
|
||||
`src/shared/protocol-version.ts`, advertised statically.
|
||||
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }` (same shapes
|
||||
as the gateway `POST /v1/devices` minus deviceId, which comes from `ctx.pairedDeviceId`). Returns
|
||||
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }`. The mobile
|
||||
filter includes away-only, expiry, and sound settings plus the legacy category fields; the host
|
||||
persists that full policy, while its gateway `POST /v1/devices` forwards only `sources` and
|
||||
`agentStates` for compatibility. `deviceId` comes from `ctx.pairedDeviceId`. The RPC returns
|
||||
`{ registered: true, registrationId } | { registered: false, reason: 'gateway_unreachable' |
|
||||
'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
|
||||
register at most 10 times per minute (`throttled` beyond that, its earlier registration untouched):
|
||||
each call is a gateway write plus a synchronous registry write on the main thread, and a paired
|
||||
phone could otherwise loop it. The unregister RPC is not throttled, since with nothing registered it
|
||||
is a lookup and with something registered it can only run once per successful register. The params
|
||||
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists `pushRegistration:
|
||||
{ registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
|
||||
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists
|
||||
`pushRegistration: { registrationId, platform, filter, registeredAt }` on `DeviceEntry` in
|
||||
`device-registry.ts` (new
|
||||
optional field, tolerated by old registries). When the gateway accepted the token but the host could
|
||||
not store it — the device left mobile scope mid-call (`not_mobile`) or the registry write threw
|
||||
(`registration_storage_failed`) — the host queues the gateway delete in the unregister outbox rather
|
||||
@@ -279,8 +303,9 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
|
||||
`answerRelayHostChallenge` with the push transcript fields. Shared code with the relay proof is
|
||||
welcome if it stays a pure refactor.
|
||||
- Dispatch hook: in `RuntimeMobileNotificationController.dispatch`, after the socket fan-out, call
|
||||
`pushDispatcher.enqueue(eventWithSeq)`. The dispatcher applies each device's `filter`, skips `dismiss`
|
||||
events, maps `agentState` to `needs-input | finished` (blocked/waiting → needs-input, else finished),
|
||||
`pushDispatcher.enqueue(eventWithSeq)`. The dispatcher requires desktop category eligibility, applies
|
||||
each device's phone-specific away-only and sound preferences, skips `dismiss` events, maps `agentState`
|
||||
to `needs-input | finished` (blocked/waiting → needs-input, else finished),
|
||||
batches matching registrationIds into `POST /v1/send` requests of at most 20 registrations each (the
|
||||
gateway's per-request cap; extra devices get their own request rather than being dropped), and drops
|
||||
unchanged registrations the gateway reports `dead`. Failure categories are counted without payload
|
||||
@@ -301,10 +326,10 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
|
||||
`apnsEnvironment`: `__DEV__ ? 'sandbox' : 'production'` (dev-client builds are debug, TestFlight and
|
||||
App Store are release). Listen with `addPushTokenListener` and re-register on change.
|
||||
- Settings (`mobile/app/notifications.tsx`): one default-off **Enable notifications** switch
|
||||
controls connected and background delivery. Hint: “Get agent alerts even when the app is closed.
|
||||
Delivered through Orca’s push service and Apple or Google.” Source controls remain visible,
|
||||
indented and disabled while **Use desktop settings** is on. Phone sound and focus controls
|
||||
remain independent. **Only when away from desktop** defaults on (180 seconds of OS input idle,
|
||||
controls native push registration. Hint: “Get agent alerts even when the app is closed.
|
||||
Delivered through Orca’s push service and Apple or Google.” Desktop category controls are
|
||||
authoritative and are not duplicated as phone overrides. Phone sound and viewing controls remain
|
||||
independent. **Only when away from desktop** defaults on (180 seconds of OS input idle,
|
||||
or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU
|
||||
activity. The detailed payload disclosure remains in the notification documentation.
|
||||
- `notifications.delivery-policy.v1` advertises the away and mobile-inactivity lease policy.
|
||||
@@ -319,14 +344,17 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
|
||||
switch is on, call `notifications.registerPush` on that host if it advertises the capability. On
|
||||
switch-off call `notifications.unregisterPush` on every connected host and remember to retry on hosts
|
||||
that were offline. On host removal, best-effort unregister before deleting credentials.
|
||||
- Receive: `addNotificationReceivedListener` (foreground) checks `data.orca.notificationId` +
|
||||
`notificationSeq` against the host session seen set in `notification-reconnect-catchup.ts`; if seen,
|
||||
suppress via `setNotificationHandler` returning no banner; otherwise show and mark seen. Background and
|
||||
killed: OS shows it.
|
||||
- Receive: `addNotificationReceivedListener` (foreground) validates that the host is paired, master
|
||||
consent is enabled, the destination is not currently viewed, and the event is not fenced by a
|
||||
persisted dismissal. A bounded process-local identity claim suppresses concurrent duplicates by
|
||||
host, epoch, sequence, and optional notification ID. Background and killed delivery remains owned by
|
||||
the OS and is best effort.
|
||||
- Tap: `data.orca.hostFingerprint` → hostId by computing the same sha256/base64url/16 derivation over each
|
||||
stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`.
|
||||
- Reopen: existing replay catch-up runs unchanged. Dismiss events also
|
||||
`dismissNotificationAsync` any presented notification whose `data.orca.notificationId` matches.
|
||||
- Reopen: subscribe to socket notifications for live dismissals, but ignore ordinary
|
||||
notification frames for banner presentation. Reconnect reconciliation sends identities currently in
|
||||
the native tray and applies returned dismissal decisions; it does not replay notifications or create
|
||||
banners. Live dismiss events also remove matching presented notifications.
|
||||
- Old host without the capability: nothing changes.
|
||||
|
||||
## Infra (`cloud/infra/terraform`, `.github/workflows`)
|
||||
@@ -353,25 +381,23 @@ alert messages, Live Activities, account-based quota tiers.
|
||||
|
||||
### Device delivery preferences
|
||||
|
||||
The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains
|
||||
active when desktop notifications are off; semantic validity checks still precede delivery.
|
||||
IPC publishes `desktopAllowed: false` for terminal events disabled by the desktop master or
|
||||
source switch. Desktop focus and native authorization remain desktop-only delivery gates.
|
||||
The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains active
|
||||
when desktop notifications are off; semantic validity checks still precede delivery. IPC publishes
|
||||
`desktopAllowed: false` when the desktop master or source/category switch rejects an event. That
|
||||
desktop category decision is authoritative for both desktop and phone alerts. Desktop focus and
|
||||
native authorization remain desktop-only presentation gates and do not change mobile eligibility.
|
||||
|
||||
`notifications.subscribe` and `notifications.getMissedSince` accept optional
|
||||
`includeDesktopSuppressed: true`. Only opted-in callers receive those events, including replay;
|
||||
legacy callers keep the old filtered stream. A new phone against an older host can narrow the
|
||||
available events but cannot recover events that host never published.
|
||||
Updated mobile subscribes with `includeDesktopSuppressed: true` for mixed-version compatibility and
|
||||
to receive dismissals, but it never turns ordinary socket notification frames into OS banners. The
|
||||
optional subscribe/replay fields, replayed `notifications`, `followDesktop`, `sources`, and
|
||||
`agentStates` remain accepted and populated only for mixed-version compatibility. Updated hosts ignore
|
||||
the mobile category fields and gate provider alerts on `desktopAllowed`; old hosts and clients retain
|
||||
their previous behavior without a wire break.
|
||||
|
||||
The phone defaults to following each host. `filter.followDesktop` is optional: absent retains
|
||||
legacy desktop gating; explicit false permits independent event choices. The desktop persists
|
||||
it with the paired registration and evaluates it for every send, so desktop preference changes
|
||||
work while the phone is disconnected. This flag is host-local and is not sent to the gateway.
|
||||
The phone uses the same shared event predicate for socket/replay delivery as the push dispatcher.
|
||||
Optional `emittedAt` carries the event time for per-device five-second burst suppression after
|
||||
source filtering. Desktop eligibility, source, and agent state use separate upstream cooldown
|
||||
buckets so filtered events cannot suppress the next eligible event. Legacy RPC callers retain
|
||||
workspace-wide burst suppression on the host.
|
||||
The mobile registration always sends `followDesktop: true` and complete legacy category arrays.
|
||||
There are no active phone category overrides or category defaults. Optional `emittedAt` and replay
|
||||
notification response fields remain compatibility surface rather than a second delivery policy or
|
||||
banner path.
|
||||
|
||||
`filter.sound` is also host-local. False groups that device's requests separately and adds
|
||||
optional `notification.sound: false` to gateway sends. The gateway omits APNs `aps.sound` and
|
||||
@@ -379,20 +405,21 @@ uses Android's `orca-desktop-silent` channel. Missing sound preserves existing a
|
||||
Deploy the updated gateway before distributing hosts that send the optional sound field: older
|
||||
gateways strictly reject unknown notification fields. No token or database migration is needed.
|
||||
|
||||
The phone's master switch disables background registration as well as local scheduling. Sound
|
||||
and viewing preferences belong to the receiving phone. The phone suppresses a banner for its
|
||||
The phone's master switch disables native push registration. Sound and viewing preferences belong to
|
||||
the receiving phone. The phone suppresses a foreground banner for its
|
||||
currently viewed host/workspace only while active; it never assumes desktop focus means the
|
||||
phone is viewing that workspace. Changes to an offline host's persisted filter take effect on
|
||||
reconnection. No live APNs/FCM delivery is implied by simulator notification injection.
|
||||
phone is viewing that workspace. Once registered, the host applies its persisted phone settings while
|
||||
the phone is disconnected; preference changes synchronize when it reconnects. No live APNs/FCM
|
||||
delivery is implied by simulator notification injection.
|
||||
|
||||
For a phone registered for background push, socket notification delivery waits while the app is
|
||||
inactive. On foreground, it checks the native push tray before scheduling a local fallback, so
|
||||
a still-connected background socket cannot duplicate APNs/FCM delivery. Unsubscribing cancels
|
||||
the wait without claiming delivery. Hosts without push registration keep local delivery.
|
||||
APNs/FCM is the sole ordinary OS-banner path whether the app is foregrounded, backgrounded, or
|
||||
killed. Socket notification events do not wait, schedule a local fallback, or recover a missed native
|
||||
alert. Hosts without push registration therefore have no mobile OS-banner fallback.
|
||||
|
||||
Native notification readers accept Expo's iOS `request.trigger.payload` as well as
|
||||
`request.content.data`. APNs custom fields can exist only in the former; foreground deduplication,
|
||||
tray replay suppression, dismissal, and tap routing all use the same reader.
|
||||
`request.content.data`. APNs custom fields can exist only in the former; foreground identity claims,
|
||||
dismissal, reconciliation, and tap routing all use the same reader. Legacy summary members remain
|
||||
readable only for notifications already delivered during a mixed-version transition.
|
||||
|
||||
### Dismissal recovery and desktop presence
|
||||
|
||||
@@ -403,20 +430,19 @@ attention; explicit mark-read actions remain available, including in browser cli
|
||||
|
||||
The runtime persists notification identities and dismissal sequence fences in its own user-data
|
||||
directory before fanout. History is bounded to 4,096 records retained for seven days. It stores no
|
||||
notification text or push tokens. On reconnect, mobile optionally includes up to 256 `deliveredPushes`
|
||||
identities in `notifications.getMissedSince`; updated hosts return optional `dismissedPushes` for
|
||||
confirmed handled identities. This recovers dismissals after event replay eviction or host restart
|
||||
within retained history. Unknown IDs, newer sequences and different epochs are preserved. Older
|
||||
hosts ignore the optional request field, and older clients ignore the additional response field.
|
||||
No new RPC method or stream opcode is required.
|
||||
notification text or push tokens. On reconnect, mobile sends up to 256 `deliveredPushes` identities
|
||||
from the current native tray in each `notifications.getMissedSince` request; updated hosts return
|
||||
optional `dismissedPushes` for confirmed handled identities. Mobile processes only those dismissal
|
||||
decisions. Unknown IDs, newer sequences, and different epochs are preserved. Older hosts ignore the
|
||||
optional request field, and older clients ignore the additional response fields. Legacy replayed
|
||||
`notifications` and epoch fields remain wire-compatible but do not drive current mobile banners.
|
||||
|
||||
On iOS, a local Expo module handles silent dismissals directly through the native notification
|
||||
center, independent of JavaScript initialization. Native and JavaScript dismissal paths use the
|
||||
same host/epoch/sequence fences; native watermarks retain up to 512 entries for 24 hours. Older
|
||||
same host/epoch/sequence fences; native dismissal fences retain up to 512 entries for 24 hours. Older
|
||||
native shells and Android retain the JavaScript implementation. A native callback test proves
|
||||
processing only when invoked: iOS background push delivery remains best-effort, including while
|
||||
suspended or force-quit. Summaries with complete membership are removed only when every member is
|
||||
covered by a matching host/epoch/sequence dismissal fence. Partial, malformed and legacy summaries
|
||||
without membership remain preserved. Reconciliation inspects up to 2,048 represented identities,
|
||||
sending pages of 256 without repeating historical replay. A first connection without a saved
|
||||
watermark reconciles the tray without replaying old alerts.
|
||||
processing only when invoked: iOS background push delivery remains best effort, including while
|
||||
suspended or force-quit. Narrow legacy decoding keeps already delivered summary notifications from
|
||||
being mistaken for individual events during the transition. Reconciliation inspects up to 2,048
|
||||
represented identities in pages of 256. It has no stored replay watermark: every connection compares
|
||||
the current tray with host dismissal history and never replays an alert.
|
||||
|
||||
@@ -60,6 +60,8 @@ final class PushDismissalLedger {
|
||||
}
|
||||
|
||||
func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
// Summary expansion is retained only for legacy delivered tray entries; new
|
||||
// pushes always carry one individual identity.
|
||||
if let count = payload["coalescedCount"] as? NSNumber, count.doubleValue > 1 {
|
||||
guard count.doubleValue.rounded(.down) == count.doubleValue, count.intValue <= 32,
|
||||
let members = payload["summaryMembers"] as? [[String: Any]], members.count == count.intValue,
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('react-native', () => ({
|
||||
Switch: 'Switch'
|
||||
}))
|
||||
|
||||
it('keeps inherited controls visible and disables editing until mirroring is off', () => {
|
||||
it('shows only phone-specific controls while desktop owns category eligibility', () => {
|
||||
const onChange = vi.fn()
|
||||
let renderer: ReturnType<typeof create>
|
||||
act(() => {
|
||||
@@ -22,31 +22,16 @@ it('keeps inherited controls visible and disables editing until mirroring is off
|
||||
})
|
||||
const switches = () => renderer.root.findAllByType('Switch' as never)
|
||||
expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([
|
||||
'Use desktop settings',
|
||||
'Agent task complete',
|
||||
'Terminal bell',
|
||||
'Plugin notifications',
|
||||
'Only when away from desktop',
|
||||
'Notification sound',
|
||||
'Suppress while focused'
|
||||
])
|
||||
expect(switches()[1].props.disabled).toBe(true)
|
||||
expect(switches()[2].props.disabled).toBe(true)
|
||||
expect(JSON.stringify(renderer.toJSON())).toContain(
|
||||
'Alert types follow each paired desktop’s notification settings.'
|
||||
)
|
||||
act(() => switches()[0].props.onValueChange(false))
|
||||
const independent = onChange.mock.calls[0][0]
|
||||
expect(independent.followDesktop).toBe(false)
|
||||
act(() =>
|
||||
renderer.update(createElement(NotificationDeliverySection, { value: independent, onChange }))
|
||||
)
|
||||
expect(switches()[1].props.disabled).toBe(false)
|
||||
expect(switches().map((node) => node.props.accessibilityLabel)).toContain('Terminal bell')
|
||||
act(() =>
|
||||
switches()
|
||||
.find((node) => node.props.accessibilityLabel === 'Terminal bell')!
|
||||
.props.onValueChange(false)
|
||||
)
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ terminalBell: false, taskFinished: true, needsInput: true })
|
||||
expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true })
|
||||
)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
@@ -9,15 +9,9 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NotificationDeliverySection({ value, disabled, onChange }: Props) {
|
||||
const row = (
|
||||
key: keyof NotificationDeliveryPreferences,
|
||||
label: string,
|
||||
hint?: string,
|
||||
inherited = false
|
||||
) => {
|
||||
const locked = disabled || (inherited && value.followDesktop)
|
||||
const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => {
|
||||
return (
|
||||
<View key={key} style={[styles.row, locked && styles.disabled]}>
|
||||
<View key={key} style={[styles.row, disabled && styles.disabled]}>
|
||||
<View style={styles.labelGroup}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{hint && <Text style={styles.hint}>{hint}</Text>}
|
||||
@@ -26,14 +20,8 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
|
||||
accessibilityLabel={label}
|
||||
testID={`notification-${key}`}
|
||||
value={value[key]}
|
||||
disabled={locked}
|
||||
onValueChange={(enabled) =>
|
||||
onChange({
|
||||
...value,
|
||||
[key]: enabled,
|
||||
...(key === 'taskFinished' ? { needsInput: enabled } : {})
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
onValueChange={(enabled) => onChange({ ...value, [key]: enabled })}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
@@ -42,20 +30,6 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<View style={styles.section}>
|
||||
{row('followDesktop', 'Use desktop settings', 'Use each desktop’s alert preferences.')}
|
||||
<View style={styles.children}>
|
||||
{value.followDesktop && <Text style={styles.inherited}>Managed on each desktop</Text>}
|
||||
{row(
|
||||
'taskFinished',
|
||||
'Agent task complete',
|
||||
'An agent finishes or needs your input.',
|
||||
true
|
||||
)}
|
||||
{row('terminalBell', 'Terminal bell', 'A terminal requests your attention.', true)}
|
||||
{row('plugin', 'Plugin notifications', undefined, true)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
{row(
|
||||
'onlyWhenDesktopAway',
|
||||
@@ -70,7 +44,8 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.footer}>
|
||||
Notifications pause after 7 days without using this app. Open it and reconnect to resume.
|
||||
Alert types follow each paired desktop’s notification settings. Notifications pause after 7
|
||||
days without using this app; open it and reconnect to resume.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
@@ -87,18 +62,6 @@ const styles = StyleSheet.create({
|
||||
labelGroup: { flex: 1, gap: spacing.xs },
|
||||
label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary },
|
||||
hint: { fontSize: typography.metaSize, color: colors.textMuted },
|
||||
children: {
|
||||
marginLeft: spacing.xl,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.borderSubtle,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
inherited: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs
|
||||
},
|
||||
disabled: { opacity: 0.5 },
|
||||
footer: {
|
||||
fontSize: typography.metaSize,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
notificationEpoch?: string
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
const memory = vi.hoisted(() => new Map<string, string>())
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {
|
||||
dismissLocalNotification,
|
||||
showLocalNotification,
|
||||
type NotificationEvent
|
||||
} from './local-notification-scheduling'
|
||||
import { allowsLocalNotification } from './notification-viewing-policy'
|
||||
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { Platform } from 'react-native'
|
||||
import { shouldSuppressForegroundPush } from './push-receive'
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'active' }, Platform: { OS: 'ios' } }))
|
||||
vi.mock('expo-notifications', () => ({
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(async () => ({ status: 'granted', canAskAgain: true })),
|
||||
scheduleNotificationAsync: vi.fn(async () => 'local'),
|
||||
dismissNotificationAsync: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('../transport/host-store', () => ({
|
||||
loadHostCatalog: vi.fn(async () =>
|
||||
[
|
||||
'host',
|
||||
'host-policy',
|
||||
'host-preferences',
|
||||
'host-permission',
|
||||
'host-enabled',
|
||||
'host-replacement',
|
||||
'host-newer-epoch',
|
||||
'host-newer-new-epoch',
|
||||
'host-channel'
|
||||
].map((id) => ({ id }))
|
||||
)
|
||||
}))
|
||||
vi.mock('./push-host-fingerprint', () => ({
|
||||
resolveHostIdForFingerprint: () => 'host',
|
||||
deriveHostFingerprint: () => 'abcdefghijklmnop'
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: async () => true,
|
||||
loadPushNotificationsEnabled: vi.fn(async () => true),
|
||||
loadRemotePushHostRegistrations: async () => ({ registeredHostIds: ['host'] })
|
||||
}))
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => memory.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
memory.set(key, value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./notification-viewing-policy', () => ({
|
||||
allowsLocalNotification: vi.fn(async () => true)
|
||||
}))
|
||||
vi.mock('./notification-delivery-preferences', () => ({
|
||||
loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true }))
|
||||
}))
|
||||
const event: NotificationEvent = {
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: '',
|
||||
notificationId: 'lifetime',
|
||||
notificationSeq: 20,
|
||||
notificationEpoch: 'epoch'
|
||||
}
|
||||
const dismiss = {
|
||||
type: 'dismiss' as const,
|
||||
notificationId: 'lifetime',
|
||||
notificationSeq: 21,
|
||||
notificationEpoch: 'epoch'
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
memory.clear()
|
||||
Platform.OS = 'ios'
|
||||
})
|
||||
|
||||
it.each(['policy', 'preferences', 'enabled', 'permission'] as const)(
|
||||
'honors dismissal during %s before scheduling',
|
||||
async (stage) => {
|
||||
let finish!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const entered = vi.fn()
|
||||
if (stage === 'policy') {
|
||||
vi.mocked(allowsLocalNotification).mockImplementationOnce(async () => {
|
||||
entered()
|
||||
await gate
|
||||
return true
|
||||
})
|
||||
} else if (stage === 'preferences') {
|
||||
vi.mocked(loadNotificationDeliveryPreferences).mockImplementationOnce(async () => {
|
||||
entered()
|
||||
await gate
|
||||
return { sound: true } as never
|
||||
})
|
||||
} else if (stage === 'enabled') {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockImplementationOnce(async () => {
|
||||
entered()
|
||||
await gate
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockImplementationOnce(async () => {
|
||||
entered()
|
||||
await gate
|
||||
return { status: 'granted' } as never
|
||||
})
|
||||
}
|
||||
const timedEvent = { ...event, emittedAt: 100000 }
|
||||
const pending = showLocalNotification(timedEvent, `host-${stage}`)
|
||||
await vi.waitFor(() => expect(entered).toHaveBeenCalled())
|
||||
await dismissLocalNotification(dismiss, `host-${stage}`)
|
||||
finish()
|
||||
await pending
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
|
||||
await showLocalNotification(
|
||||
{ ...timedEvent, notificationSeq: 22, emittedAt: 100001 },
|
||||
`host-${stage}`
|
||||
)
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('cleans up a push dismissal that completes while native scheduling is pending', async () => {
|
||||
let finish!: (id: string) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const pending = showLocalNotification(event, 'host')
|
||||
await vi.waitFor(() => expect(finish).toBeDefined())
|
||||
await shouldSuppressForegroundPush({
|
||||
orca: { ...dismiss, kind: 'dismiss', hostFingerprint: 'abcdefghijklmnop' }
|
||||
})
|
||||
finish('late-native')
|
||||
await pending
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('late-native')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['epoch', 22],
|
||||
['new-epoch', 1]
|
||||
] as const)(
|
||||
'preserves newer same-ID alert %s/%s during and after scheduling',
|
||||
async (notificationEpoch, notificationSeq) => {
|
||||
let finish!: (id: string) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const pending = showLocalNotification(
|
||||
{ ...event, notificationEpoch, notificationSeq },
|
||||
`host-newer-${notificationEpoch}`
|
||||
)
|
||||
await vi.waitFor(() => expect(finish).toBeDefined())
|
||||
await dismissLocalNotification(dismiss, `host-newer-${notificationEpoch}`)
|
||||
finish('newer-native')
|
||||
await pending
|
||||
await dismissLocalNotification(dismiss, `host-newer-${notificationEpoch}`)
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('newer-native')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[true, 'channel-id'],
|
||||
[false, 'channel-id'],
|
||||
[true, undefined],
|
||||
[false, undefined]
|
||||
])('selects Android channel in the trigger (sound=%s, id=%s)', async (sound, notificationId) => {
|
||||
Platform.OS = 'android'
|
||||
vi.mocked(loadNotificationDeliveryPreferences).mockResolvedValueOnce({ sound } as never)
|
||||
await showLocalNotification(
|
||||
{ ...event, notificationId: notificationId as string | undefined },
|
||||
'host-channel'
|
||||
)
|
||||
const request = vi.mocked(Notifications.scheduleNotificationAsync).mock.calls[0][0]
|
||||
expect(request.trigger).toEqual({ channelId: sound ? 'orca-desktop' : 'orca-desktop-silent' })
|
||||
expect(request.content).not.toHaveProperty('channelId')
|
||||
expect(request.content.sound).toBe(sound ? 'default' : false)
|
||||
})
|
||||
|
||||
it('honors dismissal while awaiting removal of a previous local banner', async () => {
|
||||
await showLocalNotification(
|
||||
{ ...event, notificationSeq: 19, emittedAt: 90000 },
|
||||
'host-replacement'
|
||||
)
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls[0][0].trigger).toBeNull()
|
||||
let finish!: () => void
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const pending = showLocalNotification({ ...event, emittedAt: 100000 }, 'host-replacement')
|
||||
await vi.waitFor(() => expect(finish).toBeDefined())
|
||||
await dismissLocalNotification(dismiss, 'host-replacement')
|
||||
finish()
|
||||
await pending
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
|
||||
await showLocalNotification(
|
||||
{ ...event, notificationSeq: 22, emittedAt: 100001 },
|
||||
'host-replacement'
|
||||
)
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases cooldown for an identified notification whose native schedule fails', async () => {
|
||||
const retryEvent = { ...event, notificationId: 'retry', worktreeId: 'retry', emittedAt: 100000 }
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(
|
||||
new Error('native failed')
|
||||
)
|
||||
await expect(showLocalNotification(retryEvent, 'host')).rejects.toThrow('native failed')
|
||||
await showLocalNotification(retryEvent, 'host')
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not release a newer concurrent notification cooldown when an older schedule fails', async () => {
|
||||
const older = {
|
||||
...event,
|
||||
notificationId: 'older-failure',
|
||||
worktreeId: 'overlap',
|
||||
emittedAt: 100000
|
||||
}
|
||||
let fail!: (error: Error) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
)
|
||||
const pending = showLocalNotification(older, 'host').catch((error) => error)
|
||||
await vi.waitFor(() => expect(fail).toBeDefined())
|
||||
await showLocalNotification(
|
||||
{ ...older, notificationId: 'newer-success', emittedAt: 106000 },
|
||||
'host'
|
||||
)
|
||||
fail(new Error('native failed'))
|
||||
expect(await pending).toBeInstanceOf(Error)
|
||||
await showLocalNotification(
|
||||
{ ...older, notificationId: 'within-newer-cooldown', emittedAt: 106001 },
|
||||
'host'
|
||||
)
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
import { reserveNotificationCooldown } from '../../../src/shared/notification-burst-cooldown'
|
||||
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
|
||||
import { allowsLocalNotification } from './notification-viewing-policy'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { DESKTOP_NOTIFICATION_CHANNEL_ID } from './desktop-notification-channel'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
import { ensureNotificationPermissions } from './notification-permissions'
|
||||
import { dismissHostPushNotification, wasHostPushDismissed } from './push-socket-dismissal'
|
||||
|
||||
export type NotificationEvent = {
|
||||
type: 'notification'
|
||||
desktopAllowed?: boolean
|
||||
desktopAway?: boolean
|
||||
emittedAt?: number
|
||||
agentState?: string
|
||||
source: DesktopNotificationSource
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
|
||||
notificationSeq?: number
|
||||
// Counter lifetime the seq belongs to (#8591); absent on older runtimes.
|
||||
notificationEpoch?: string
|
||||
}
|
||||
|
||||
export type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
notificationEpoch?: string
|
||||
}
|
||||
|
||||
type ScheduledNotificationState = {
|
||||
event?: NotificationEvent
|
||||
identifier?: string
|
||||
pending?: Promise<string | null>
|
||||
dismissAfterSchedule?: boolean
|
||||
}
|
||||
|
||||
const recentNotifications = new Map<string, number>()
|
||||
|
||||
function reserveLocalNotification(event: NotificationEvent, hostId: string): boolean {
|
||||
return (
|
||||
event.emittedAt === undefined ||
|
||||
reserveNotificationCooldown(
|
||||
recentNotifications,
|
||||
JSON.stringify([hostId, event.worktreeId ?? 'global']),
|
||||
event.emittedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function releaseLocalNotification(event: NotificationEvent, hostId: string): void {
|
||||
const key = JSON.stringify([hostId, event.worktreeId ?? 'global'])
|
||||
if (event.emittedAt !== undefined && recentNotifications.get(key) === event.emittedAt) {
|
||||
recentNotifications.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
|
||||
const MAX_SCHEDULED_NOTIFICATIONS = 256
|
||||
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
|
||||
|
||||
function getStoredNotificationKey(hostId: string, notificationId: string): string {
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
|
||||
function boundScheduledNotifications(): void {
|
||||
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
|
||||
let evicted = false
|
||||
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
|
||||
if (!state.pending) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(key)
|
||||
evicted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!evicted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: override the cap (pass no arg to restore the default). */
|
||||
export function setScheduledNotificationsMaxForTests(max?: number): void {
|
||||
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
|
||||
}
|
||||
|
||||
export async function showLocalNotification(
|
||||
event: NotificationEvent,
|
||||
hostId: string
|
||||
): Promise<void> {
|
||||
if (!(await allowsLocalNotification(event, hostId))) {
|
||||
return
|
||||
}
|
||||
const preferences = await loadNotificationDeliveryPreferences()
|
||||
const channelId = preferences.sound
|
||||
? DESKTOP_NOTIFICATION_CHANNEL_ID
|
||||
: `${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`
|
||||
const storedKey = event.notificationId
|
||||
? getStoredNotificationKey(hostId, event.notificationId)
|
||||
: null
|
||||
|
||||
if (!storedKey) {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!reserveLocalNotification(event, hostId)) {
|
||||
return
|
||||
}
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
sound: preferences.sound ? 'default' : false,
|
||||
data: buildLocalNotificationData(event, hostId)
|
||||
},
|
||||
trigger: Platform.OS === 'android' ? { channelId } : null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
|
||||
if (state?.pending) {
|
||||
return
|
||||
}
|
||||
if (!state) {
|
||||
state = {}
|
||||
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
|
||||
}
|
||||
const notificationState = state
|
||||
notificationState.event = event
|
||||
let reserved = false
|
||||
let scheduled = false
|
||||
|
||||
const pending = (async () => {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ((await wasHostPushDismissed(event, hostId)) || notificationState.dismissAfterSchedule) {
|
||||
return null
|
||||
}
|
||||
reserved = reserveLocalNotification(event, hostId)
|
||||
if (!reserved) {
|
||||
return null
|
||||
}
|
||||
if (notificationState.identifier) {
|
||||
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
|
||||
notificationState.identifier = undefined
|
||||
if ((await wasHostPushDismissed(event, hostId)) || notificationState.dismissAfterSchedule) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
sound: preferences.sound ? 'default' : false,
|
||||
data: buildLocalNotificationData(event, hostId)
|
||||
},
|
||||
trigger: Platform.OS === 'android' ? { channelId } : null
|
||||
})
|
||||
})()
|
||||
notificationState.pending = pending
|
||||
|
||||
try {
|
||||
const scheduledIdentifier = await pending
|
||||
if (!scheduledIdentifier) {
|
||||
if (!notificationState.identifier) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
scheduled = true
|
||||
const dismissed = await wasHostPushDismissed(event, hostId)
|
||||
if (dismissed || notificationState.dismissAfterSchedule) {
|
||||
notificationState.dismissAfterSchedule = false
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
|
||||
return
|
||||
}
|
||||
notificationState.identifier = scheduledIdentifier
|
||||
boundScheduledNotifications()
|
||||
} finally {
|
||||
// Only roll back this attempt; another notification may own a newer reservation.
|
||||
if (reserved && !scheduled) {
|
||||
releaseLocalNotification(event, hostId)
|
||||
}
|
||||
if (notificationState.pending === pending) {
|
||||
notificationState.pending = undefined
|
||||
notificationState.dismissAfterSchedule = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissLocalNotification(
|
||||
event: DismissNotificationEvent,
|
||||
hostId: string
|
||||
): Promise<void> {
|
||||
if (!event.notificationId) {
|
||||
return
|
||||
}
|
||||
// Why first and unconditionally: a push the OS presented while Orca was closed has
|
||||
// no entry below, so the local registry alone would leave it in the tray forever.
|
||||
await dismissHostPushNotification(event, hostId)
|
||||
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
|
||||
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.notificationEpoch &&
|
||||
event.notificationSeq !== undefined &&
|
||||
state.event?.notificationEpoch &&
|
||||
state.event.notificationSeq !== undefined &&
|
||||
(state.event.notificationEpoch !== event.notificationEpoch ||
|
||||
state.event.notificationSeq > event.notificationSeq)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (state.pending) {
|
||||
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
|
||||
state.dismissAfterSchedule = true
|
||||
return
|
||||
}
|
||||
if (!state.identifier) {
|
||||
return
|
||||
}
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
|
||||
}
|
||||
@@ -1,726 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
getNotificationPermissionState,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { dismissHostPushNotification } from './push-socket-dismissal'
|
||||
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
vi.mock('./push-socket-dismissal', () => ({
|
||||
dismissHostPushNotification: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
vi.mock('./push-dismissal-reconciliation', () => ({
|
||||
requestNotificationCatchup: vi.fn(async () => ({ ok: true }))
|
||||
}))
|
||||
vi.mock('./notification-permissions', () => ({}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
type Handler = (data: unknown) => void
|
||||
|
||||
// Why: mobile-notifications now persists the catch-up watermark to
|
||||
// AsyncStorage. The package isn't resolvable in the node test env (other
|
||||
// mobile tests mock it the same way), so we provide a no-op mock.
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
// Why (#8591): the reconnect watermark/seen-set now live per host at module
|
||||
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
|
||||
// tests so each case starts from a genuine cold open.
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
describe('getNotificationPermissionState', () => {
|
||||
it.each([
|
||||
{ os: 'android', version: 32, expected: false },
|
||||
{ os: 'android', version: 33, expected: true },
|
||||
{ os: 'ios', version: 18, expected: true }
|
||||
])(
|
||||
'reports whether a granted $os $version authorization reflects user choice',
|
||||
async ({ os, version, expected }) => {
|
||||
Object.assign(Platform, { OS: os, Version: version })
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
|
||||
await expect(getNotificationPermissionState()).resolves.toMatchObject({
|
||||
granted: true,
|
||||
authorizationReflectsUserChoice: expected
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
|
||||
// client must fetch missed notifications from its watermark and push exactly
|
||||
// the ones it had not yet delivered — never re-pushing an already-delivered id.
|
||||
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
const AsyncStorageMock = vi.mocked(AsyncStorage)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
AsyncStorageMock.getItem.mockResolvedValue(null)
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const sentRequests: { method: string; params: unknown }[] = []
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(
|
||||
async (method: string, _params: unknown = {}) =>
|
||||
({
|
||||
ok: true,
|
||||
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
|
||||
}) as never
|
||||
)
|
||||
}
|
||||
// Why: onData is captured live via a getter (not destructured) because the
|
||||
// subscribe mock assigns it asynchronously as a side effect of
|
||||
// subscribeToDesktopNotifications calling client.subscribe.
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
sentRequests
|
||||
function client() {
|
||||
let handler: Handler | undefined
|
||||
return {
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async () => ({ ok: true })),
|
||||
subscribe: vi.fn((_method: string, _params: unknown, callback: Handler) => {
|
||||
handler = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
emit(data: unknown) {
|
||||
handler?.(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
|
||||
'notifications.getMissedSince',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
const sub = makeClient()
|
||||
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
|
||||
// because seq 11 (agent:dup) was already delivered on the live stream and
|
||||
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open (no fetch).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream already delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
describe('subscribeToDesktopNotifications', () => {
|
||||
it('never presents an OS banner for socket alert or replay events', async () => {
|
||||
const rpc = client()
|
||||
subscribeToDesktopNotifications(rpc as never, 'host-1')
|
||||
rpc.emit({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
rpc.emit({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
notificationId: 'agent-1',
|
||||
title: 'Needs input',
|
||||
body: 'Reply',
|
||||
source: 'agent-task-complete'
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect ready → fetchMissed sends the watermark (11).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// The watermark passed to getMissedSince is the delivered seq.
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 11 })
|
||||
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('voids a persisted watermark whose epoch predates a desktop restart', async () => {
|
||||
// #8591: the desktop's seq counter restarts at 0 each launch while this watermark
|
||||
// is persisted. Reconnecting to a restarted desktop with seq 57 would make
|
||||
// `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells
|
||||
// the client the counter changed, so the stale watermark must be dropped.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
|
||||
: null
|
||||
await Promise.resolve()
|
||||
expect(requestNotificationCatchup).toHaveBeenCalledWith(
|
||||
rpc,
|
||||
'host-1',
|
||||
undefined,
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Cold open under the OLD desktop process, so the watermark loads as 57.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// Desktop restarts: new epoch, counter back near 0.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCalls = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// The cold open catches up from its stored watermark against the SAME counter —
|
||||
// 57 is meaningful there, so it is the correct cut (#8591 second pass).
|
||||
expect(missedCalls[0]?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 57,
|
||||
epoch: 'epoch-before-restart'
|
||||
})
|
||||
// After the restart the watermark is reset to 0 and tagged with the live epoch —
|
||||
// not the stale 57, which would make `57 >= 2` true and kill catch-up silently.
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 0,
|
||||
epoch: 'epoch-after-restart'
|
||||
})
|
||||
expect(dismissHostPushNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => {
|
||||
// The seed read is deliberately not awaited (so subscribe doesn't block on
|
||||
// AsyncStorage), which means it can land AFTER 'ready' already adopted the live
|
||||
// epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is
|
||||
// about — the reset having already happened doesn't help, because the seed runs
|
||||
// last and wins. Only a stored epoch matching the live one may seed.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
// Hold the storage read open so 'ready' is guaranteed to be processed first.
|
||||
let releaseStorage: () => void = () => {}
|
||||
const storageGate = new Promise<void>((resolve) => {
|
||||
releaseStorage = resolve
|
||||
})
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => {
|
||||
await storageGate
|
||||
return key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
|
||||
: null
|
||||
})
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Live epoch adopted while the stored one is still in flight.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
|
||||
releaseStorage()
|
||||
await flushAsync()
|
||||
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 0,
|
||||
epoch: 'epoch-after-restart'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the persisted watermark when the desktop epoch is unchanged', async () => {
|
||||
// The reset must be narrow: a plain socket reap with the same desktop process
|
||||
// still has to send the real watermark, or every reconnect re-pushes the buffer.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-stable' })
|
||||
: null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 57,
|
||||
epoch: 'epoch-stable'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
|
||||
// (already delivered live) alongside a new seq 12.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'new',
|
||||
body: 'b',
|
||||
notificationId: 'agent:new',
|
||||
notificationSeq: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivers seq 5.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 5
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsWatermark:host-1',
|
||||
JSON.stringify({ seq: 5, epoch: null })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
|
||||
// and persist the watermark. This is the exact case the seq/notificationSeq
|
||||
// field mismatch broke — the desktop replay path returns `notificationSeq`
|
||||
// (matching the live fan-out), so the client watermark moves and the next
|
||||
// reconnect resumes from it instead of re-fetching from 0.
|
||||
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 8
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// First reconnect → replay delivers seq 8 (no prior live delivery).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// Watermark advanced to the replayed seq and was persisted.
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsWatermark:host-1',
|
||||
JSON.stringify({ seq: 8, epoch: null })
|
||||
)
|
||||
|
||||
// Second reconnect resumes from the advanced watermark, not 0.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
const missedCalls = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 8 })
|
||||
})
|
||||
|
||||
it('replays a terminal bell at a seq the previous desktop counter already used', async () => {
|
||||
// Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no
|
||||
// notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1;
|
||||
// after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the
|
||||
// one that consults the seen-set, so without clearing it on epoch change the
|
||||
// replayed post-restart bell is mistaken for a duplicate and silently skipped —
|
||||
// #8591's silent loss again, now one notification at a time.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Catch-up returns epoch B's first bell — same seq 1 the old counter used.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
epoch: 'epoch-B',
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'bell',
|
||||
body: 'B',
|
||||
notificationSeq: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
|
||||
await flushAsync()
|
||||
// A live bell under epoch A — no notificationId, so its seen-key is `seq:1`.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'bell',
|
||||
body: 'A',
|
||||
notificationSeq: 1
|
||||
})
|
||||
await flushAsync()
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
|
||||
|
||||
// Desktop restarts; reconnect triggers catch-up against the fresh counter.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// The post-restart bell must reach the user, not be swallowed as a stale `seq:1`.
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('does not trust a legacy epoch-less watermark against a live counter', async () => {
|
||||
// Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch.
|
||||
// Seeding it and then treating the first observed epoch as "nothing changed"
|
||||
// leaves 57 cutting a counter it was never measured against — #8591 reached
|
||||
// through the upgrade path. An unprovenanced seq may not survive epoch adoption.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
// Only the LEGACY key exists — exactly what an upgrading install has on disk.
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted...
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
// ...then the live epoch arrives for the first time.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// Must not be 57: that seq was never shown to belong to this counter.
|
||||
expect(missedCall?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 0,
|
||||
epoch: 'epoch-live'
|
||||
})
|
||||
})
|
||||
|
||||
it('catches up on the FIRST connection after an upgrade, without a second ready', async () => {
|
||||
// Round-2 review finding: catch-up hung off `connectedBefore`, which is false on
|
||||
// the first 'ready' of a process. So a cold app open — post-upgrade, or after the
|
||||
// OS evicted the app — adopted the epoch but never replayed. Everything between
|
||||
// the stored watermark and the next live seq was then lost permanently, because
|
||||
// the first live event advances the watermark past the gap.
|
||||
//
|
||||
// The earlier migration test masked this by emitting a SECOND 'ready'. This one
|
||||
// emits exactly one, which is what a real cold open does.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-live' })
|
||||
: null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) =>
|
||||
method === 'notifications.getMissedSince'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
epoch: 'epoch-live',
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
notificationId: 'missed-58',
|
||||
notificationSeq: 58,
|
||||
notificationEpoch: 'epoch-live',
|
||||
title: 'while the app was closed',
|
||||
body: 'b'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// The single 'ready' must replay from the stored watermark, not skip it.
|
||||
expect(missedCall?.[1]).toEqual({
|
||||
includeDesktopSuppressed: true,
|
||||
lastSeenSeq: 57,
|
||||
epoch: 'epoch-live'
|
||||
})
|
||||
// And the missed notification must actually reach the user.
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not replay the desktop buffer at a first-ever pairing', async () => {
|
||||
// The other side of the finding above: with nothing stored, this device has never
|
||||
// delivered for this host. Catching up would push the whole retained buffer at a
|
||||
// user who was never subscribed for any of it.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persists seq and epoch as one value so a crash cannot split the pair', async () => {
|
||||
// Round-1 review finding: written as two keys, a process death between the writes
|
||||
// leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the
|
||||
// next launch and is therefore trusted — silently cutting B's first 57 events.
|
||||
// One key means the pair is always written whole or not at all.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
|
||||
await flushAsync()
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:x',
|
||||
notificationSeq: 9
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// Every watermark write is a single key carrying both halves together.
|
||||
const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).startsWith('orca:mobileNotifications')
|
||||
)
|
||||
expect(watermarkWrites.length).toBeGreaterThan(0)
|
||||
for (const [key, value] of watermarkWrites) {
|
||||
expect(key).toBe('orca:mobileNotificationsWatermark:host-1')
|
||||
expect(JSON.parse(String(value))).toHaveProperty('epoch')
|
||||
expect(JSON.parse(String(value))).toHaveProperty('seq')
|
||||
}
|
||||
expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({
|
||||
seq: 9,
|
||||
epoch: 'epoch-A'
|
||||
})
|
||||
it('keeps socket dismissal processing active', async () => {
|
||||
const rpc = client()
|
||||
subscribeToDesktopNotifications(rpc as never, 'host-1')
|
||||
rpc.emit({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
const dismissal = { type: 'dismiss', notificationId: 'agent-1', notificationSeq: 4 }
|
||||
rpc.emit(dismissal)
|
||||
await Promise.resolve()
|
||||
expect(dismissHostPushNotification).toHaveBeenCalledWith(dismissal, 'host-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,219 +1,22 @@
|
||||
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
|
||||
import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
|
||||
import { dismissHostPushNotification } from './push-socket-dismissal'
|
||||
import type { DismissNotificationEvent } from './desktop-notification-events'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export {
|
||||
ensureNotificationPermissions,
|
||||
getNotificationPermissionState,
|
||||
type NotificationPermissionState
|
||||
} from './notification-permissions'
|
||||
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
|
||||
import {
|
||||
dismissLocalNotification,
|
||||
showLocalNotification,
|
||||
type DismissNotificationEvent,
|
||||
type NotificationEvent
|
||||
} from './local-notification-scheduling'
|
||||
import { ensureDesktopNotificationChannel } from './desktop-notification-channel'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
catchUpWatermarkSeq,
|
||||
enqueueHostDelivery,
|
||||
getHostNotificationSession,
|
||||
quarantineCatchUpWatermark,
|
||||
releaseQueuedShowNotificationId,
|
||||
resolveCatchUpQuarantine,
|
||||
saveWatermark,
|
||||
seedWatermarkFromStorage,
|
||||
seenKeyForEvent,
|
||||
shouldQueueShowForNotificationId
|
||||
} from './notification-reconnect-catchup'
|
||||
import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
|
||||
|
||||
type SubscribeResult = {
|
||||
type: 'ready'
|
||||
subscriptionId: string
|
||||
// Desktop counter lifetime (#8591); absent from runtimes that predate it.
|
||||
epoch?: string
|
||||
}
|
||||
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
void ensureDesktopNotificationChannel().catch(() => {})
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
const deliveryAbort = new AbortController()
|
||||
const session = getHostNotificationSession(hostId)
|
||||
|
||||
/**
|
||||
* Queue one delivery on the host chain, dropping a show whose notificationId
|
||||
* already has one queued.
|
||||
*
|
||||
* Why the claim is taken HERE and not inside deliverLive (#8591): the point of
|
||||
* the dedup is to notice a second event arriving while the first is still
|
||||
* outstanding. Inside the queued task the first has already finished, so the
|
||||
* overlap is no longer observable — it has to be checked before enqueueing.
|
||||
*/
|
||||
function queueDelivery(
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
if (
|
||||
type === 'notification' &&
|
||||
!shouldQueueShowForNotificationId(session, event.notificationId)
|
||||
) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
// Tray cleanup must not wait for a background show to hand off on foreground.
|
||||
const dismissal =
|
||||
type === 'dismiss'
|
||||
? dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
: undefined
|
||||
void dismissal?.catch(() => {})
|
||||
return enqueueHostDelivery(session, async () => {
|
||||
try {
|
||||
await deliverLive(type, event, dismissal)
|
||||
} finally {
|
||||
if (type === 'notification') {
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
}
|
||||
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
|
||||
// surface as an unhandled rejection (a RN redbox) instead of being retried by
|
||||
// the next catch-up — which is now possible, since `seen` is marked after the show.
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function deliverLive(
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent,
|
||||
dismissal?: Promise<void>
|
||||
): Promise<void> {
|
||||
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
|
||||
const epochAtDelivery = session.lastDeliveredEpoch
|
||||
const key = seenKeyForEvent(event)
|
||||
if (type === 'notification') {
|
||||
const show =
|
||||
!(event.notificationEpoch && key && session.seen.has(key)) &&
|
||||
(await waitForSocketPushHandoff(event as NotificationEvent, hostId, deliveryAbort.signal))
|
||||
if (disposed) {
|
||||
throw new Error('notification_subscription_disposed')
|
||||
}
|
||||
if (show) {
|
||||
await showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
} else {
|
||||
await (dismissal ?? dismissLocalNotification(event as DismissNotificationEvent, hostId))
|
||||
}
|
||||
// Claim only after local delivery or a matching presented push.
|
||||
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
|
||||
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
|
||||
session.seen.add(key)
|
||||
}
|
||||
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
|
||||
session.lastDeliveredSeq = event.notificationSeq
|
||||
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
|
||||
// the live seq would let the next catch-up ask from above the gap and the desktop
|
||||
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
|
||||
void saveWatermark(hostId, {
|
||||
seq: catchUpWatermarkSeq(session),
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverMissedEvent(
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && session.seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deliverLive('notification', event)
|
||||
} finally {
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
|
||||
async function fetchMissed(): Promise<void> {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Preserve the delivered floor if catch-up fails.
|
||||
const askFrom = catchUpWatermarkSeq(session)
|
||||
// Read concurrently; claim inside the queue after epoch adoption to avoid stale keys.
|
||||
const presentedPushKeys = readPresentedPushSeenKeys(hostId)
|
||||
const missed = await requestNotificationCatchup(
|
||||
client,
|
||||
hostId,
|
||||
{
|
||||
lastSeenSeq: askFrom,
|
||||
includeDesktopSuppressed: true,
|
||||
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
|
||||
},
|
||||
() => disposed
|
||||
)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
|
||||
adoptNotificationEpoch(session, hostId, result?.epoch)
|
||||
return Array.isArray(result?.notifications) ? result.notifications : []
|
||||
})
|
||||
.catch(() => null)
|
||||
if (missed == null) {
|
||||
// Why quarantine rather than retry: the range this catch-up abandoned stays
|
||||
// unrecovered until SOME later one succeeds, and a live seq persisting past it
|
||||
// meanwhile would make the desktop cut it forever.
|
||||
quarantineCatchUpWatermark(session, hostId, askFrom)
|
||||
return
|
||||
}
|
||||
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
|
||||
// the event loop between replays, so a live seq 11 slots into the chain between
|
||||
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
|
||||
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
|
||||
// chain for that would stall live delivery on a slow link.
|
||||
await enqueueHostDelivery(session, async () => {
|
||||
markPresentedPushesSeen(session, await presentedPushKeys)
|
||||
let contiguousSeq = askFrom
|
||||
let drained = false
|
||||
try {
|
||||
for (const raw of missed) {
|
||||
// Re-checked per event: the batch can start before a teardown and still be
|
||||
// draining after it, and a torn-down host must stop pushing.
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
await deliverMissedEvent(event)
|
||||
contiguousSeq = event.notificationSeq ?? contiguousSeq
|
||||
}
|
||||
drained = true
|
||||
} finally {
|
||||
if (drained) {
|
||||
resolveCatchUpQuarantine(session, hostId)
|
||||
} else {
|
||||
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
|
||||
}
|
||||
}
|
||||
// Why swallowed here: the `finally` above already recorded the contiguous point,
|
||||
// and the only caller is an un-awaited 'ready' continuation — letting a failed
|
||||
// show escape turns every one into an unhandled rejection (a RN redbox).
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
seedWatermarkFromStorage(session, hostId)
|
||||
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
@@ -223,81 +26,26 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
|
||||
const params = { includeDesktopSuppressed: true }
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => {
|
||||
const event = data as
|
||||
| NotificationEvent
|
||||
| DismissNotificationEvent
|
||||
| SubscribeResult
|
||||
| { type: 'end' }
|
||||
const event = data as DismissNotificationEvent | SubscribeResult | { type: string }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
const isReconnect = session.connectedBefore
|
||||
session.connectedBefore = true
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
return
|
||||
}
|
||||
const readyEpoch = (event as SubscribeResult).epoch
|
||||
// Why (#8591) the await: on a cold app open the persisted read is still in
|
||||
// flight, so deciding here would see watermarkLoaded false and skip catch-up —
|
||||
// which is precisely the post-upgrade / post-process-death case that loses
|
||||
// every notification between the stored watermark and the next live seq.
|
||||
void (async () => {
|
||||
await session.watermarkSeeded
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why before fetchMissed: adopting the epoch here is what voids a watermark
|
||||
// left over from a previous desktop lifetime, so the catch-up request carries
|
||||
// a watermark that means something against the counter now answering it.
|
||||
adoptNotificationEpoch(session, hostId, readyEpoch)
|
||||
// First pairing recovers tray dismissals without replaying historical alerts.
|
||||
if (isReconnect || session.hadStoredWatermark) {
|
||||
await fetchMissed()
|
||||
} else {
|
||||
await requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
})()
|
||||
// A max watermark asks only which delivered pushes are stale; socket history
|
||||
// never becomes a second OS-notification delivery route.
|
||||
void requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (event.type === 'end') {
|
||||
if (disposed) {
|
||||
unsubscribeStream()
|
||||
}
|
||||
return
|
||||
if (!disposed && event.type === 'dismiss') {
|
||||
void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {})
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (event.type !== 'notification' && event.type !== 'dismiss') {
|
||||
return
|
||||
}
|
||||
// Why the await (#8591): deliverLive advances the watermark. A live event landing
|
||||
// while the persisted read is still in flight would push it past the buffered seqs
|
||||
// the catch-up is about to ask for, and getMissedSince would cut them. Ordering is
|
||||
// preserved — every handler waits on the same promise, and the 'ready' continuation
|
||||
// registered on it first, so catch-up still builds its request before any live seq.
|
||||
const liveEvent = event
|
||||
void (async () => {
|
||||
await session.watermarkSeeded
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why the queue (#8591): a live event must not overtake an in-flight
|
||||
// catch-up replay, or it persists a watermark past seqs still unshown.
|
||||
await queueDelivery(
|
||||
liveEvent.type === 'notification' ? 'notification' : 'dismiss',
|
||||
liveEvent as NotificationEvent | DismissNotificationEvent
|
||||
)
|
||||
})()
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
deliveryAbort.abort()
|
||||
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
|
||||
unsubscribeStream()
|
||||
if (subscriptionId) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function persistedSeq(): number {
|
||||
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
|
||||
}
|
||||
|
||||
type MissedOutcome =
|
||||
| { kind: 'reject' }
|
||||
| { kind: 'notOk' }
|
||||
| { kind: 'ok'; notifications: unknown[] }
|
||||
// Rejects only once `settle()` is called, so a live event can land mid-request.
|
||||
| { kind: 'heldReject' }
|
||||
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const askedFrom: number[] = []
|
||||
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
|
||||
let releaseHeld: (() => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn(() => {
|
||||
onData = null
|
||||
})
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
|
||||
if (method !== 'notifications.getMissedSince') {
|
||||
return { ok: true, result: undefined } as never
|
||||
}
|
||||
askedFrom.push(
|
||||
(params as { includeDesktopSuppressed: true; lastSeenSeq: number }).lastSeenSeq
|
||||
)
|
||||
if (outcome.kind === 'heldReject') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHeld = resolve
|
||||
})
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
if (outcome.kind === 'reject') {
|
||||
throw new Error('socket closed')
|
||||
}
|
||||
if (outcome.kind === 'notOk') {
|
||||
return { ok: false, error: { message: 'timeout' } } as never
|
||||
}
|
||||
return { ok: true, result: { notifications: outcome.notifications } } as never
|
||||
})
|
||||
}
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
askedFrom,
|
||||
setOutcome(next: MissedOutcome) {
|
||||
outcome = next
|
||||
},
|
||||
settleHeld() {
|
||||
releaseHeld?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notification(seq: number) {
|
||||
return {
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: `m${seq}`,
|
||||
body: 'b',
|
||||
notificationId: `agent:${seq}`,
|
||||
notificationSeq: seq
|
||||
}
|
||||
}
|
||||
|
||||
describe('#8591 catch-up failure quarantines the watermark', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
|
||||
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
|
||||
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
|
||||
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
|
||||
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
|
||||
// window stays open until some catch-up succeeds, not for one round trip.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'reject' })
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5])
|
||||
|
||||
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
// Second catch-up also fails; the gap is still open.
|
||||
host.setOutcome({ kind: 'notOk' })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5, 5])
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
// Third succeeds and replays the abandoned range.
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.askedFrom).toEqual([5, 5, 5])
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
|
||||
// m11/m12 are the live events that kept flowing while the gap stayed open.
|
||||
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
|
||||
|
||||
// Only now may the watermark move past the recovered range.
|
||||
expect(persistedSeq()).toBe(12)
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5, 5, 5, 12])
|
||||
})
|
||||
|
||||
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
|
||||
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
|
||||
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
|
||||
// the next launch reads it back and resumes past the range this catch-up abandoned.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'heldReject' })
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(host.askedFrom).toEqual([5])
|
||||
|
||||
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(11)
|
||||
|
||||
host.settleHeld()
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
})
|
||||
|
||||
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
|
||||
// The batch can start before a teardown and still be draining after it, so the
|
||||
// events past the interruption were never shown. A live seq arriving on the next
|
||||
// connection must not persist over them.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({
|
||||
kind: 'ok',
|
||||
notifications: [notification(6), notification(7), notification(8)]
|
||||
})
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
|
||||
if ((request as { content: { title: string } }).content.title === 'm6') {
|
||||
unsubscribe?.()
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6'])
|
||||
|
||||
// A fresh subscription on the same module-scope session takes a live seq 20 before
|
||||
// its own catch-up, then resumes from 6 rather than from 20.
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
const host2 = makeHostClient()
|
||||
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
|
||||
subscribeToDesktopNotifications(host2.client, 'host-1')
|
||||
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(6)
|
||||
|
||||
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host2.askedFrom).toEqual([6])
|
||||
expect(
|
||||
vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
).toEqual(['m6', 'm20', 'm7', 'm8'])
|
||||
expect(persistedSeq()).toBe(20)
|
||||
})
|
||||
|
||||
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
|
||||
// The quarantine only holds the RANGE. If the failing event is also marked seen,
|
||||
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
|
||||
// never shown, and the first later event to drain the batch lifts the quarantine
|
||||
// past it. Silent loss with the watermark looking healthy.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
|
||||
|
||||
let failNext = true
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
|
||||
const title = (request as { content: { title: string } }).content.title
|
||||
if (title === 'm6' && failNext) {
|
||||
failNext = false
|
||||
throw new Error('scheduling rejected')
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6', 'm6', 'm7'])
|
||||
expect(host.askedFrom).toEqual([5, 5])
|
||||
expect(persistedSeq()).toBe(7)
|
||||
})
|
||||
|
||||
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
|
||||
// The same hole without any catch-up failing: the live path marks seen before the
|
||||
// show, so a rejected show leaves the key behind while the watermark stays put.
|
||||
// The next catch-up dutifully re-fetches the seq and the guard eats it.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const host = makeHostClient()
|
||||
host.setOutcome({ kind: 'ok', notifications: [] })
|
||||
|
||||
let failNext = true
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error('scheduling rejected')
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
expect(persistedSeq()).toBe(5)
|
||||
|
||||
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6', 'm6'])
|
||||
expect(persistedSeq()).toBe(6)
|
||||
})
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const storage = new Map<string, string>()
|
||||
let getItemImpl: (key: string) => Promise<string | null> = async (key) => storage.get(key) ?? null
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn((key: string) => getItemImpl(key)),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function persistedSeq(): number {
|
||||
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
|
||||
}
|
||||
|
||||
describe('#8591 per-host delivery ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
getItemImpl = async (key) => storage.get(key) ?? null
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('never persists a watermark past a notification the catch-up has not shown', async () => {
|
||||
// The watermark is a promise that everything up to that seq reached the user.
|
||||
// If a live seq 11 is processed while catch-up is still showing seq 6, it
|
||||
// persists 11 — and a process death before 7 is shown loses 7 forever, because
|
||||
// the next launch asks the desktop for seq > 11. That is the original #8591
|
||||
// loss re-entered through concurrency rather than through a restarted counter.
|
||||
let releaseFirstShow!: () => void
|
||||
const firstShowBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirstShow = resolve
|
||||
})
|
||||
let shown = 0
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
|
||||
shown += 1
|
||||
if (shown === 1) {
|
||||
await firstShowBlocked
|
||||
}
|
||||
return 'sched-1'
|
||||
})
|
||||
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'm6',
|
||||
body: 'b',
|
||||
notificationId: 'a:6',
|
||||
notificationSeq: 6
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'm7',
|
||||
body: 'b',
|
||||
notificationId: 'a:7',
|
||||
notificationSeq: 7
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
// Live seq 11 arrives while the replay is wedged on seq 6.
|
||||
onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live-11',
|
||||
body: 'b',
|
||||
notificationId: 'a:11',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(persistedSeq()).toBeLessThan(6)
|
||||
|
||||
releaseFirstShow()
|
||||
await flushAsync()
|
||||
|
||||
// Once the chain drains, everything is shown and the watermark catches up.
|
||||
expect(persistedSeq()).toBe(11)
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toEqual(['m6', 'm7', 'live-11'])
|
||||
})
|
||||
|
||||
it('shows one banner when a replay and a live event carry the same notification id', async () => {
|
||||
// Serializing deliveries removed the overlap the old dedup relied on: the
|
||||
// replay's show now COMPLETES before the live duplicate starts, so nothing is
|
||||
// pending for it to observe and the user gets the same notification twice.
|
||||
let releaseFirstShow!: () => void
|
||||
const firstShowBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirstShow = resolve
|
||||
})
|
||||
let shown = 0
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
|
||||
shown += 1
|
||||
if (shown === 1) {
|
||||
await firstShowBlocked
|
||||
}
|
||||
return `sched-${shown}`
|
||||
})
|
||||
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 6
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
await flushAsync()
|
||||
|
||||
// Same id arrives live while the replay's show is still blocked. A different
|
||||
// seq, so the seen-set does not catch it — only the queued-show claim does.
|
||||
onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 7
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
releaseFirstShow()
|
||||
await flushAsync()
|
||||
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still delivers when the persisted watermark read never resolves', async () => {
|
||||
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
|
||||
// this host's notifications for the whole app lifetime — silently.
|
||||
getItemImpl = (key) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? new Promise<string | null>(() => {})
|
||||
: Promise.resolve(null)
|
||||
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never)
|
||||
} as unknown as RpcClient
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live-1',
|
||||
body: 'b',
|
||||
notificationId: 'a:1',
|
||||
notificationSeq: 1
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(3100)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toContain('live-1')
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
saveNotificationDeliveryPreferences
|
||||
} from './notification-delivery-preferences'
|
||||
import {
|
||||
allowsLocalNotification,
|
||||
setNotificationViewingWorkspace
|
||||
setNotificationViewingWorkspace,
|
||||
shouldSuppressNotificationWhileViewing
|
||||
} from './notification-viewing-policy'
|
||||
import { allowsMobileNotification } from '../../../src/shared/mobile-notification-policy'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -28,68 +27,65 @@ beforeEach(() => {
|
||||
AppState.currentState = 'background'
|
||||
})
|
||||
|
||||
it('defaults to following desktop and persists independent event preferences', async () => {
|
||||
it('persists only phone-specific delivery preferences', async () => {
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
|
||||
const value = {
|
||||
...DEFAULT_NOTIFICATION_DELIVERY,
|
||||
followDesktop: false,
|
||||
terminalBell: false,
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false
|
||||
}
|
||||
await saveNotificationDeliveryPreferences(value)
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(value)
|
||||
expect(notificationPreferencesFilter(value)).toMatchObject({
|
||||
followDesktop: false,
|
||||
followDesktop: true,
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false,
|
||||
sources: ['agent-task-complete', 'plugin']
|
||||
expireAfterInactivity: true,
|
||||
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
|
||||
agentStates: ['needs-input', 'finished']
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicitly narrowed filters from before the new settings screen', async () => {
|
||||
storage.set('orca:remotePushAgentStates', '["needs-input"]')
|
||||
expect(await loadNotificationDeliveryPreferences()).toMatchObject({
|
||||
followDesktop: false,
|
||||
needsInput: true,
|
||||
taskFinished: false
|
||||
it('ignores obsolete category overrides from the previous settings screen', async () => {
|
||||
storage.set(
|
||||
'orca:notificationDeliveryPreferences',
|
||||
JSON.stringify({
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false,
|
||||
suppressWhileViewing: false,
|
||||
followDesktop: false,
|
||||
taskFinished: false,
|
||||
needsInput: false,
|
||||
terminalBell: false,
|
||||
plugin: false
|
||||
})
|
||||
)
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual({
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false,
|
||||
suppressWhileViewing: false
|
||||
})
|
||||
expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toMatchObject({
|
||||
followDesktop: true,
|
||||
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
|
||||
agentStates: ['needs-input', 'finished']
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['agent-task-complete', 'terminal-bell', 'plugin'])(
|
||||
'uses identical type filtering for socket/replay and background push: %s',
|
||||
async (source) => {
|
||||
for (const followDesktop of [true, false]) {
|
||||
const value = {
|
||||
...DEFAULT_NOTIFICATION_DELIVERY,
|
||||
followDesktop,
|
||||
terminalBell: false,
|
||||
taskFinished: false
|
||||
}
|
||||
await saveNotificationDeliveryPreferences(value)
|
||||
for (const desktopAllowed of [true, false]) {
|
||||
const event = { source, desktopAllowed, agentState: 'done' }
|
||||
expect(await allowsLocalNotification(event, 'host')).toBe(
|
||||
allowsMobileNotification(notificationPreferencesFilter(value), event)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
|
||||
const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
|
||||
setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
|
||||
AppState.currentState = 'active'
|
||||
expect(await allowsLocalNotification(event, 'ssh-host')).toBe(false)
|
||||
expect(await allowsLocalNotification(event, 'another-host')).toBe(true)
|
||||
expect(await allowsLocalNotification({ ...event, worktreeId: 'other' }, 'ssh-host')).toBe(true)
|
||||
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host')).toBe(true)
|
||||
expect(await shouldSuppressNotificationWhileViewing(event, 'another-host')).toBe(false)
|
||||
expect(
|
||||
await shouldSuppressNotificationWhileViewing({ ...event, worktreeId: 'other' }, 'ssh-host')
|
||||
).toBe(false)
|
||||
AppState.currentState = 'background'
|
||||
expect(await allowsLocalNotification(event, 'ssh-host')).toBe(true)
|
||||
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['orca:notificationDeliveryPreferences', 'orca:remotePushAgentStates'])(
|
||||
'recovers defaults from malformed stored %s',
|
||||
async (key) => {
|
||||
storage.set(key, '{broken')
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
|
||||
}
|
||||
)
|
||||
it('recovers defaults from malformed stored preferences', async () => {
|
||||
storage.set('orca:notificationDeliveryPreferences', '{broken')
|
||||
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
|
||||
})
|
||||
|
||||
@@ -8,22 +8,12 @@ import {
|
||||
const KEY = 'orca:notificationDeliveryPreferences'
|
||||
export type NotificationDeliveryPreferences = {
|
||||
onlyWhenDesktopAway: boolean
|
||||
followDesktop: boolean
|
||||
taskFinished: boolean
|
||||
needsInput: boolean
|
||||
terminalBell: boolean
|
||||
plugin: boolean
|
||||
sound: boolean
|
||||
suppressWhileViewing: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = {
|
||||
onlyWhenDesktopAway: true,
|
||||
followDesktop: true,
|
||||
taskFinished: true,
|
||||
needsInput: true,
|
||||
terminalBell: true,
|
||||
plugin: true,
|
||||
sound: true,
|
||||
suppressWhileViewing: true
|
||||
}
|
||||
@@ -32,19 +22,6 @@ export async function loadNotificationDeliveryPreferences(): Promise<Notificatio
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY)
|
||||
if (!raw) {
|
||||
// Preserve an existing explicit background filter when upgrading.
|
||||
const legacy = await AsyncStorage.getItem('orca:remotePushAgentStates')
|
||||
if (legacy) {
|
||||
const states: unknown = JSON.parse(legacy)
|
||||
if (Array.isArray(states)) {
|
||||
return {
|
||||
...DEFAULT_NOTIFICATION_DELIVERY,
|
||||
followDesktop: false,
|
||||
taskFinished: states.includes('finished'),
|
||||
needsInput: states.includes('needs-input')
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_NOTIFICATION_DELIVERY }
|
||||
}
|
||||
const stored = JSON.parse(raw) as Record<string, unknown>
|
||||
@@ -69,30 +46,12 @@ export async function saveNotificationDeliveryPreferences(
|
||||
export function notificationPreferencesFilter(
|
||||
value: NotificationDeliveryPreferences
|
||||
): MobilePushFilter {
|
||||
if (value.followDesktop) {
|
||||
return {
|
||||
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
|
||||
expireAfterInactivity: true,
|
||||
sound: value.sound,
|
||||
followDesktop: true,
|
||||
sources: MOBILE_PUSH_SOURCES,
|
||||
agentStates: MOBILE_PUSH_AGENT_STATES
|
||||
}
|
||||
}
|
||||
return {
|
||||
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
|
||||
expireAfterInactivity: true,
|
||||
followDesktop: false,
|
||||
followDesktop: true,
|
||||
sound: value.sound,
|
||||
sources: MOBILE_PUSH_SOURCES.filter((source) =>
|
||||
source === 'terminal-bell'
|
||||
? value.terminalBell
|
||||
: source === 'plugin'
|
||||
? value.plugin
|
||||
: value.needsInput || value.taskFinished
|
||||
),
|
||||
agentStates: MOBILE_PUSH_AGENT_STATES.filter((state) =>
|
||||
state === 'needs-input' ? value.needsInput : value.taskFinished
|
||||
)
|
||||
sources: MOBILE_PUSH_SOURCES,
|
||||
agentStates: MOBILE_PUSH_AGENT_STATES
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { showLocalNotification } from './local-notification-scheduling'
|
||||
import { Platform } from 'react-native'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
// Why: mobile-notifications now persists the catch-up watermark to
|
||||
// AsyncStorage. The package isn't resolvable in the node test env (other
|
||||
// mobile tests mock it the same way), so we provide a no-op mock.
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
// Why (#8591): the reconnect watermark/seen-set now live per host at module
|
||||
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
|
||||
// tests so each case starts from a genuine cold open.
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
describe('subscribeToDesktopNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
|
||||
// the per-host serialization queue, so a delivery is several more `await` hops deep
|
||||
// than it used to be and a fixed tick count silently under-drains. Yielding to the
|
||||
// macrotask queue drains whatever depth the chain happens to have.
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
it('drops the local stream when disposed before the desktop returns ready', () => {
|
||||
const unsubscribeStream = vi.fn()
|
||||
const client = {
|
||||
subscribe: vi.fn(() => unsubscribeStream),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
|
||||
unsubscribe()
|
||||
|
||||
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockResolvedValueOnce('scheduled-1')
|
||||
.mockResolvedValueOnce('scheduled-2')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
worktreeId: 'repo::/tmp/worktree',
|
||||
notificationId: 'agent:one'
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done again',
|
||||
body: 'Finished again.',
|
||||
notificationId: 'agent:one'
|
||||
})
|
||||
await flushAsync()
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:one' })
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
notificationId: 'agent:one',
|
||||
worktreeId: 'repo::/tmp/worktree'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1')
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
|
||||
})
|
||||
|
||||
it('dedupes concurrent notification events with the same desktop notification id', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-concurrent')
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
notificationId: 'agent:concurrent'
|
||||
})
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
notificationId: 'agent:concurrent'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('filters before cooldown and retains the existing banner when a later burst is suppressed', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
JSON.stringify({ followDesktop: false, terminalBell: false })
|
||||
)
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('cooldown-banner')
|
||||
const event = {
|
||||
type: 'notification' as const,
|
||||
title: 'Done',
|
||||
body: '',
|
||||
worktreeId: 'folder',
|
||||
notificationId: 'cooldown-event',
|
||||
emittedAt: 10000
|
||||
}
|
||||
await showLocalNotification({ ...event, source: 'terminal-bell' }, 'cooldown-host')
|
||||
await showLocalNotification(
|
||||
{ ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10250 },
|
||||
'cooldown-host'
|
||||
)
|
||||
await showLocalNotification(
|
||||
{ ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10500 },
|
||||
'cooldown-host'
|
||||
)
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
setScheduledNotificationsMaxForTests,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
// Why: mobile-notifications now persists the catch-up watermark to
|
||||
// AsyncStorage. The package isn't resolvable in the node test env (other
|
||||
// mobile tests mock it the same way), so we provide a no-op mock.
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
// Why (#8591): the reconnect watermark/seen-set now live per host at module
|
||||
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
|
||||
// tests so each case starts from a genuine cold open.
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
describe('subscribeToDesktopNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
|
||||
// the per-host serialization queue, so a delivery is several more `await` hops deep
|
||||
// than it used to be and a fixed tick count silently under-drains. Yielding to the
|
||||
// macrotask queue drains whatever depth the chain happens to have.
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
function makeDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
let resolveSchedule!: (identifier: string) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveSchedule = resolve
|
||||
})
|
||||
)
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-dismiss-race')
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
notificationId: 'agent:pending'
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
|
||||
resolveSchedule('scheduled-pending')
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
|
||||
})
|
||||
|
||||
it('does not carry a failed pending dismiss into a future schedule', async () => {
|
||||
const secondEnabled = makeDeferred<boolean>()
|
||||
vi.mocked(loadPushNotificationsEnabled)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockReturnValueOnce(secondEnabled.promise)
|
||||
.mockResolvedValueOnce(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockResolvedValueOnce('scheduled-1')
|
||||
.mockResolvedValueOnce('scheduled-2')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
notificationId: 'agent:stale-dismiss'
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done again',
|
||||
body: 'Finished again.',
|
||||
notificationId: 'agent:stale-dismiss'
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
|
||||
secondEnabled.resolve(false)
|
||||
await flushAsync()
|
||||
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done later',
|
||||
body: 'Finished later.',
|
||||
notificationId: 'agent:stale-dismiss'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
|
||||
})
|
||||
|
||||
it('treats unknown dismiss events as no-ops', async () => {
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-unknown')
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' })
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: notificationId is unique per completion, so the map grew unbounded when
|
||||
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
|
||||
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
|
||||
setScheduledNotificationsMaxForTests(1)
|
||||
try {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockResolvedValueOnce('scheduled-old')
|
||||
.mockResolvedValueOnce('scheduled-new')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:old'
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:new'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// The older entry was evicted by the cap: dismissing it is a no-op...
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
|
||||
|
||||
// ...while the most-recent entry is retained and still dismissable.
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
|
||||
} finally {
|
||||
setScheduledNotificationsMaxForTests()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,415 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
|
||||
// from mobile-notifications.ts so that file stays under its max-lines budget.
|
||||
// The highest desktop notification seq this device has delivered is persisted
|
||||
// per-host so it survives app restarts. On reconnect we send it to
|
||||
// notifications.getMissedSince as the catch-up watermark — the desktop then
|
||||
// returns only notifications dispatched after it, so we never re-push a
|
||||
// notification we already delivered. The in-memory seen-set is a second guard
|
||||
// against double-delivery for events that arrive on both the live stream and a
|
||||
// replay (e.g. a brief liveness spell before a reap).
|
||||
// Why (#8591): a seq is meaningless without the counter it indexes — after a
|
||||
// desktop restart that counter is gone. The epoch names the counter's lifetime so
|
||||
// a reconnect can tell "nothing missed" from "different counter".
|
||||
//
|
||||
// Why ONE key holding both, rather than a key each: they are only meaningful as a
|
||||
// pair. Written separately, a process death between the two writes leaves an epoch
|
||||
// from one counter beside a seq from another — a pair that looks internally valid
|
||||
// on the next launch and is therefore trusted, silently cutting real notifications.
|
||||
// A single JSON value cannot tear that way.
|
||||
const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:'
|
||||
// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written.
|
||||
const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
|
||||
|
||||
function watermarkStorageKey(hostId: string): string {
|
||||
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
}
|
||||
|
||||
// A null epoch means "the counter this seq came from is unknown" — a legacy
|
||||
// watermark, or nothing stored. It can never be assumed to be the live counter.
|
||||
export type PersistedWatermark = { seq: number; epoch: string | null }
|
||||
// `stored` is the record's existence, independent of its seq: it answers "has this
|
||||
// device ever been subscribed to this host", which is what a cold open needs to tell
|
||||
// a returning device from a first pairing. A seq of 0 is a real answer, not an absence.
|
||||
export type LoadedWatermark = PersistedWatermark & { stored: boolean }
|
||||
|
||||
function coerceSeq(value: unknown): number {
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
}
|
||||
|
||||
export async function loadWatermark(hostId: string): Promise<LoadedWatermark> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId))
|
||||
if (raw != null) {
|
||||
const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown }
|
||||
const epoch =
|
||||
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null
|
||||
return { seq: coerceSeq(parsed.seq), epoch, stored: true }
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or malformed: fall through to the legacy key rather than throw.
|
||||
}
|
||||
try {
|
||||
const legacy = await AsyncStorage.getItem(
|
||||
LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
)
|
||||
return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null }
|
||||
} catch {
|
||||
return { seq: 0, epoch: null, stored: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearWatermark(hostId: string): Promise<void> {
|
||||
// Why both keys: loadWatermark falls back to the legacy one, so removing only the
|
||||
// current key would let a re-paired host resurrect a pre-#8591 seq from a counter
|
||||
// lifetime that is long gone — the exact stale cut this fix removes.
|
||||
await Promise.all([
|
||||
AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}),
|
||||
AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch(
|
||||
() => {}
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark))
|
||||
} catch {
|
||||
// Why: persisting the watermark is best-effort. If it fails (or lags), the
|
||||
// stored value stays BELOW what we delivered, so a later cold start can
|
||||
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
|
||||
// delivered notification. That's the accepted at-least-once trade-off;
|
||||
// within a live session the in-memory watermark is authoritative, so only
|
||||
// post-restart reconnects are affected.
|
||||
}
|
||||
}
|
||||
|
||||
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
|
||||
// on the current connection. The desktop already dedupes by seq on replay, but
|
||||
// a socket that flickers background→foreground→background can deliver an event
|
||||
// on the live stream and again in a replay; the seen-set guarantees each
|
||||
// notificationId maps to at most one local push for the connection lifetime.
|
||||
// Bounded so a long-lived session can't grow without limit — a 2x superset of
|
||||
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
|
||||
const RECENTLY_SEEN_CAP = 512
|
||||
|
||||
export function createSeenNotificationGuard(): {
|
||||
has: (id: string) => boolean
|
||||
add: (id: string) => void
|
||||
clear: () => void
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
return {
|
||||
has(id: string): boolean {
|
||||
return seen.has(id)
|
||||
},
|
||||
add(id: string): void {
|
||||
seen.add(id)
|
||||
if (seen.size > RECENTLY_SEEN_CAP) {
|
||||
// Why: insertion order; the oldest entries are first. Drop one to stay
|
||||
// bounded without disturbing the more-recently-relevant keys.
|
||||
const first = seen.values().next().value
|
||||
if (first !== undefined) {
|
||||
seen.delete(first)
|
||||
}
|
||||
}
|
||||
},
|
||||
clear(): void {
|
||||
seen.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why (#8591): app/index.tsx tears the notification subscription down on every
|
||||
// non-'connected' state and builds a fresh one on reconnect, so everything held
|
||||
// in the subscription closure — the ready counter, the delivered watermark, the
|
||||
// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host
|
||||
// at module scope is what makes the catch-up recognise a reconnect (instead of
|
||||
// mistaking it for a cold open) and keeps dedup effective across the teardown.
|
||||
export type HostNotificationSession = {
|
||||
// Highest desktop seq delivered for this host in this app process. Outranks
|
||||
// the persisted value, which lags because saveLastSeenSeq is fire-and-forget.
|
||||
lastDeliveredSeq: number
|
||||
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
|
||||
// mismatch on reconnect means the desktop restarted and the watermark is void.
|
||||
lastDeliveredEpoch: string | null
|
||||
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
|
||||
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
|
||||
catchUpQuarantineSeq: number | null
|
||||
seen: ReturnType<typeof createSeenNotificationGuard>
|
||||
// False only until the host's first subscription reaches 'ready' — a true cold open.
|
||||
connectedBefore: boolean
|
||||
// Why (#8591): distinguishes "this device has delivered for this host before"
|
||||
// from a first-ever pairing. Only the former may catch up on a cold open — a
|
||||
// brand-new pairing fetching from seq 0 would push the desktop's whole buffer
|
||||
// at someone who was never subscribed for any of it.
|
||||
hadStoredWatermark: boolean
|
||||
// Resolves once the persisted read has landed, so the first 'ready' can wait for
|
||||
// it instead of deciding catch-up against an unread watermark.
|
||||
watermarkSeeded: Promise<void> | null
|
||||
// Tail of the per-host delivery chain; see enqueueHostDelivery.
|
||||
deliveryTail: Promise<void>
|
||||
// notificationIds with a show queued or in flight on that chain; see
|
||||
// shouldQueueShowForNotificationId.
|
||||
queuedShowIds: Set<string>
|
||||
}
|
||||
|
||||
const sessionsByHost = new Map<string, HostNotificationSession>()
|
||||
|
||||
export function getHostNotificationSession(hostId: string): HostNotificationSession {
|
||||
let session = sessionsByHost.get(hostId)
|
||||
if (!session) {
|
||||
session = {
|
||||
lastDeliveredSeq: 0,
|
||||
lastDeliveredEpoch: null,
|
||||
catchUpQuarantineSeq: null,
|
||||
seen: createSeenNotificationGuard(),
|
||||
connectedBefore: false,
|
||||
hadStoredWatermark: false,
|
||||
watermarkSeeded: null,
|
||||
deliveryTail: Promise.resolve(),
|
||||
queuedShowIds: new Set<string>()
|
||||
}
|
||||
sessionsByHost.set(hostId, session)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` after every delivery already queued for this host, and return a
|
||||
* promise for its completion.
|
||||
*
|
||||
* Why (#8591): the watermark is persisted by whichever delivery advances it, so
|
||||
* replay and live delivery running concurrently can persist out of order. A live
|
||||
* seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a
|
||||
* process death before 7..10 are shown loses them permanently — the next launch
|
||||
* asks the desktop for seq > 11. Serializing per host makes the watermark's
|
||||
* monotonic advance mean "everything up to here was actually delivered".
|
||||
*
|
||||
* A rejected task does not break the chain: the tail swallows the failure so a
|
||||
* single bad notification cannot wedge the host's queue forever.
|
||||
*/
|
||||
export function enqueueHostDelivery<T>(
|
||||
session: HostNotificationSession,
|
||||
task: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const run = session.deliveryTail.then(task)
|
||||
session.deliveryTail = run.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a notificationId for a queued show, returning false if one is already
|
||||
* queued or in flight for it.
|
||||
*
|
||||
* Why this exists (#8591): showLocalNotification deduped two same-id events by
|
||||
* observing that the first was still pending when the second arrived. Serializing
|
||||
* deliveries removed that overlap — the first now COMPLETES before the second
|
||||
* starts, so the second reads no pending state and schedules a second banner for
|
||||
* the same notification. The dedup has to happen where concurrency is still
|
||||
* visible, which after serialization is enqueue time rather than delivery time.
|
||||
*
|
||||
* Only shows are tracked. A dismiss for the same id must still run: it is the
|
||||
* mechanism that retires the notification the show created.
|
||||
*/
|
||||
export function shouldQueueShowForNotificationId(
|
||||
session: HostNotificationSession,
|
||||
notificationId: string | undefined
|
||||
): boolean {
|
||||
if (notificationId == null) {
|
||||
return true
|
||||
}
|
||||
if (session.queuedShowIds.has(notificationId)) {
|
||||
return false
|
||||
}
|
||||
session.queuedShowIds.add(notificationId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */
|
||||
export function releaseQueuedShowNotificationId(
|
||||
session: HostNotificationSession,
|
||||
notificationId: string | undefined
|
||||
): void {
|
||||
if (notificationId != null) {
|
||||
session.queuedShowIds.delete(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: drop per-host session state so each test starts from a cold open. */
|
||||
export function resetHostNotificationSessionsForTests(): void {
|
||||
sessionsByHost.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the catch-up watermark at the last seq known delivered contiguously,
|
||||
* after a catch-up that did not complete.
|
||||
*
|
||||
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
|
||||
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
|
||||
* the desktop cuts by seq, so those notifications are never replayed and are
|
||||
* gone. Lowest wins: an earlier failure's gap is still open.
|
||||
*/
|
||||
export function quarantineCatchUpWatermark(
|
||||
session: HostNotificationSession,
|
||||
hostId: string,
|
||||
contiguousSeq: number
|
||||
): void {
|
||||
session.catchUpQuarantineSeq =
|
||||
session.catchUpQuarantineSeq == null
|
||||
? contiguousSeq
|
||||
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
|
||||
// Why re-persist: a live event delivered while the catch-up was still in flight
|
||||
// already stored a seq above the gap. Clamping only later writes would leave that
|
||||
// value on disk, so a restart still resumes past the abandoned range.
|
||||
void saveWatermark(hostId, {
|
||||
seq: catchUpWatermarkSeq(session),
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
|
||||
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
|
||||
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
|
||||
if (session.catchUpQuarantineSeq == null) {
|
||||
return
|
||||
}
|
||||
session.catchUpQuarantineSeq = null
|
||||
void saveWatermark(hostId, {
|
||||
seq: session.lastDeliveredSeq,
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The seq a catch-up may ask from and the highest seq safe to persist — the live
|
||||
* watermark, clamped to any open gap.
|
||||
*/
|
||||
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
|
||||
return session.catchUpQuarantineSeq == null
|
||||
? session.lastDeliveredSeq
|
||||
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
|
||||
}
|
||||
|
||||
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
|
||||
// from a previous lifetime indexes a counter that no longer exists. Comparing it
|
||||
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
|
||||
// catch-up dies silently until the new process out-dispatches the old watermark.
|
||||
// Adopting the new epoch means dropping the watermark with it.
|
||||
export function adoptNotificationEpoch(
|
||||
session: HostNotificationSession,
|
||||
hostId: string,
|
||||
epoch: string | undefined
|
||||
): void {
|
||||
if (!epoch || epoch === session.lastDeliveredEpoch) {
|
||||
return
|
||||
}
|
||||
// Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded
|
||||
// from a legacy store carries no epoch, so it cannot be shown to belong to this
|
||||
// counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 —
|
||||
// the exact #8591 failure, reached through the upgrade path instead of a restart.
|
||||
session.lastDeliveredSeq = 0
|
||||
// Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have
|
||||
// no notificationId at all (they key on `seq:N` alone). Across a restart the new
|
||||
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
|
||||
// the new counter's first bell. The dedup window belongs to one counter lifetime.
|
||||
session.seen.clear()
|
||||
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
|
||||
session.catchUpQuarantineSeq = null
|
||||
session.lastDeliveredEpoch = epoch
|
||||
void saveWatermark(hostId, { seq: 0, epoch })
|
||||
}
|
||||
|
||||
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
|
||||
// Only the first subscription for a host needs it; later ones inherit the live value.
|
||||
/**
|
||||
* Ms the persisted read may block catch-up and live delivery before they proceed
|
||||
* without it. AsyncStorage normally answers in single-digit ms; a read that has
|
||||
* not landed by now is assumed wedged.
|
||||
*
|
||||
* Why a bound at all (#8591): every delivery awaits this promise, so a read that
|
||||
* never settles silently disables notifications for the host for the whole app
|
||||
* lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly
|
||||
* better: the watermark stays 0, so catch-up over-fetches and the seen-set
|
||||
* de-duplicates, which costs a redundant request instead of every notification.
|
||||
*/
|
||||
const WATERMARK_SEED_TIMEOUT_MS = 3000
|
||||
|
||||
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
void promise.then(
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void {
|
||||
if (session.watermarkSeeded) {
|
||||
return
|
||||
}
|
||||
const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => {
|
||||
// Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists
|
||||
// `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for
|
||||
// this host reloads as seq 0. Keying on the seq would read that as a first pairing
|
||||
// and skip catch-up for the whole window the epoch change was meant to recover.
|
||||
if (stored) {
|
||||
session.hadStoredWatermark = true
|
||||
}
|
||||
// Why the epoch comparison: this read can land AFTER 'ready' already adopted a
|
||||
// live epoch. If the stored watermark belongs to a different (older) counter,
|
||||
// applying it here would silently reinstate exactly the stale cut this fixes.
|
||||
// A null stored epoch is a legacy watermark of unknown provenance — it may only
|
||||
// seed while no live epoch is known, and adopting one later resets it.
|
||||
if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) {
|
||||
session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq)
|
||||
if (session.lastDeliveredEpoch === null && epoch !== null) {
|
||||
session.lastDeliveredEpoch = epoch
|
||||
}
|
||||
}
|
||||
})
|
||||
// The late seed still applies when it eventually lands; the timeout only stops it
|
||||
// from holding delivery hostage. `seeded` never rejects into the awaiters.
|
||||
session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Why (#8591): sessions live at module scope so they survive the subscription
|
||||
// teardown a reconnect performs. Nothing else drops them, so a host that is removed
|
||||
// and re-paired would retain its session and up to 512 seen keys until app restart.
|
||||
export function forgetHostNotificationSession(hostId: string): void {
|
||||
sessionsByHost.delete(hostId)
|
||||
}
|
||||
|
||||
// Why: key for the replay dedup guard. Uses notificationId when present, but
|
||||
// disambiguates by seq so a legitimate live re-delivery of the same id at a
|
||||
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
|
||||
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
|
||||
// live is suppressed. Replay events always carry a seq (the desktop assigns
|
||||
// one), so the guard is effective on the reconnect path.
|
||||
export function seenKeyForEvent(event: {
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
}): string | null {
|
||||
const id = event.notificationId
|
||||
if (id != null && event.notificationSeq != null) {
|
||||
return `id:${id}#${event.notificationSeq}`
|
||||
}
|
||||
if (id != null) {
|
||||
return `id:${id}`
|
||||
}
|
||||
if (event.notificationSeq != null) {
|
||||
return `seq:${event.notificationSeq}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
// In-memory AsyncStorage so the persisted watermark survives across the
|
||||
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
|
||||
const storage = new Map<string, string>()
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (k: string) => storage.get(k) ?? null),
|
||||
setItem: vi.fn(async (k: string, v: string) => {
|
||||
storage.set(k, v)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
// Models mobile/app/index.tsx:497-537: a per-host client whose notification
|
||||
// subscription is torn down on any non-'connected' state and re-created from
|
||||
// scratch on the next 'connected'.
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number }[] = []
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn(() => {
|
||||
onData = null
|
||||
})
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
getMissedCalls.push(params as { includeDesktopSuppressed: true; lastSeenSeq: number })
|
||||
return { ok: true, result: { notifications: missedQueue } } as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
}
|
||||
let missedQueue: unknown[] = []
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
getMissedCalls,
|
||||
setMissed(events: unknown[]) {
|
||||
missedQueue = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
vi.mocked(AsyncStorage.getItem).mockClear()
|
||||
})
|
||||
|
||||
it('fetches missed notifications after a disconnect tears the subscription down', async () => {
|
||||
const host = makeHostClient()
|
||||
|
||||
// ── Connected: cold open, one live notification delivered (desktop seq 7).
|
||||
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 7
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the
|
||||
// non-'connected' state, destroying the subscribeToDesktopNotifications
|
||||
// closure (and with it reconnectReadyCount / lastDeliveredSeq).
|
||||
unsub()
|
||||
await flushAsync()
|
||||
|
||||
// ── While disconnected the desktop dispatched seq 8 and 9.
|
||||
host.setMissed([
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'missed-8',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m8',
|
||||
notificationSeq: 8
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'missed-9',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m9',
|
||||
notificationSeq: 9
|
||||
}
|
||||
])
|
||||
|
||||
// ── Reconnected: app re-subscribes with a FRESH closure.
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
|
||||
await flushAsync()
|
||||
|
||||
// The user must be told about seq 8 and 9. Nothing else can deliver them:
|
||||
// the desktop only fans out live, so this catch-up is the only path.
|
||||
expect(host.getMissedCalls).toHaveLength(1)
|
||||
expect(host.getMissedCalls[0]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 7 })
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toContain('missed-8')
|
||||
expect(titles).toContain('missed-9')
|
||||
})
|
||||
|
||||
it('does not re-push a live notification the catch-up replays after a teardown', async () => {
|
||||
// Why: the seen-set lives on the host session precisely so it survives the teardown.
|
||||
// getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the
|
||||
// brief window before the drop is still inside the desktop's retained buffer, so the
|
||||
// reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate
|
||||
// banner for something the user was already shown.
|
||||
const host = makeHostClient()
|
||||
|
||||
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live-7',
|
||||
body: 'b',
|
||||
notificationId: 'agent:seven',
|
||||
notificationSeq: 7
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
unsub()
|
||||
await flushAsync()
|
||||
|
||||
// The desktop replays seq 7 alongside the genuinely-missed seq 8.
|
||||
host.setMissed([
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live-7',
|
||||
body: 'b',
|
||||
notificationId: 'agent:seven',
|
||||
notificationSeq: 7
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'missed-8',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m8',
|
||||
notificationSeq: 8
|
||||
}
|
||||
])
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
|
||||
expect(titles.filter((title) => title === 'live-7')).toHaveLength(1)
|
||||
expect(titles).toContain('missed-8')
|
||||
})
|
||||
})
|
||||
@@ -1,237 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
|
||||
// Why this file exists: a push the OS drew while Orca was closed never runs through
|
||||
// the foreground handler, so nothing marks it seen. The reconnect catch-up then
|
||||
// replays the same event and the user gets a second banner for it.
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
|
||||
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
|
||||
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function presentTray(entries: readonly Record<string, unknown>[]): void {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue(
|
||||
entries.map((orca, index) => ({
|
||||
request: {
|
||||
identifier: `tray-${index}`,
|
||||
content: { data: null },
|
||||
trigger: { type: 'push', payload: { orca } }
|
||||
}
|
||||
})) as never
|
||||
)
|
||||
}
|
||||
|
||||
function shownTitles(): string[] {
|
||||
return vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
|
||||
}
|
||||
|
||||
function persistedSeq(): number {
|
||||
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
|
||||
}
|
||||
|
||||
/** A catch-up that replays seq 6 and 7 for host-1. */
|
||||
function catchUpClient(): { client: RpcClient; ready: () => void } {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, callback: (data: unknown) => void) => {
|
||||
onData = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'm6',
|
||||
body: 'b',
|
||||
notificationId: 'a:6',
|
||||
notificationSeq: 6
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'm7',
|
||||
body: 'b',
|
||||
notificationId: 'a:7',
|
||||
notificationSeq: 7
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
return {
|
||||
client,
|
||||
ready: () => onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenWithTray(): Promise<void> {
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
|
||||
const { client, ready } = catchUpClient()
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
ready()
|
||||
await flushAsync()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadHostCatalog).mockResolvedValue([
|
||||
{ id: 'host-1', publicKeyB64 }
|
||||
] as unknown as HostCatalogEntry[])
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('reopen after a push the OS showed while Orca was closed', () => {
|
||||
it('recovers a dismissed push without a stored watermark or replaying historical alerts', async () => {
|
||||
const identity = { notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' }
|
||||
presentTray([{ hostFingerprint, ...identity }])
|
||||
const { client, ready } = catchUpClient()
|
||||
vi.mocked(client.sendRequest).mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
dismissedPushes: [identity],
|
||||
notifications: [{ type: 'notification', title: 'Historical alert', notificationSeq: 5 }]
|
||||
}
|
||||
} as never)
|
||||
const dispose = subscribeToDesktopNotifications(client, 'host-1')
|
||||
ready()
|
||||
await flushAsync()
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', {
|
||||
lastSeenSeq: Number.MAX_SAFE_INTEGER,
|
||||
deliveredPushes: [identity]
|
||||
})
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('tray-0')
|
||||
expect(shownTitles()).toEqual([])
|
||||
expect(persistedSeq()).toBe(0)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not request history on a first pairing with an empty tray', async () => {
|
||||
const { client, ready } = catchUpClient()
|
||||
const dispose = subscribeToDesktopNotifications(client, 'host-1')
|
||||
ready()
|
||||
await flushAsync()
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('replays only the events still missing from the tray', async () => {
|
||||
presentTray([
|
||||
{ hostFingerprint, notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' }
|
||||
])
|
||||
|
||||
await reopenWithTray()
|
||||
|
||||
expect(shownTitles()).toEqual(['m7'])
|
||||
})
|
||||
|
||||
it('leaves the watermark to the replay rather than jumping it to the push seq', async () => {
|
||||
presentTray([
|
||||
{ hostFingerprint, notificationId: 'a:9', notificationSeq: 9, notificationEpoch: 'epoch-1' }
|
||||
])
|
||||
|
||||
await reopenWithTray()
|
||||
|
||||
// Seq 9 in the tray says one event was shown, not that 6..8 were; advancing past
|
||||
// them would make the desktop cut them out of every later catch-up.
|
||||
expect(shownTitles()).toEqual(['m6', 'm7'])
|
||||
expect(persistedSeq()).toBe(7)
|
||||
})
|
||||
|
||||
it('still replays an event a coalesced summary only counted', async () => {
|
||||
presentTray([
|
||||
{
|
||||
hostFingerprint,
|
||||
notificationId: 'a:6',
|
||||
notificationSeq: 6,
|
||||
notificationEpoch: 'epoch-1',
|
||||
coalescedCount: 3
|
||||
}
|
||||
])
|
||||
|
||||
await reopenWithTray()
|
||||
|
||||
expect(shownTitles()).toEqual(['m6', 'm7'])
|
||||
})
|
||||
|
||||
it('ignores a tray entry pushed for a different paired host', async () => {
|
||||
presentTray([
|
||||
{
|
||||
hostFingerprint: '0123456789abcdef',
|
||||
notificationId: 'a:6',
|
||||
notificationSeq: 6,
|
||||
notificationEpoch: 'epoch-1'
|
||||
}
|
||||
])
|
||||
|
||||
await reopenWithTray()
|
||||
|
||||
expect(shownTitles()).toEqual(['m6', 'm7'])
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,17 @@
|
||||
import { AppState } from 'react-native'
|
||||
import {
|
||||
allowsMobileNotification,
|
||||
type MobileNotificationPolicyEvent
|
||||
} from '../../../src/shared/mobile-notification-policy'
|
||||
import {
|
||||
loadNotificationDeliveryPreferences,
|
||||
notificationPreferencesFilter
|
||||
} from './notification-delivery-preferences'
|
||||
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
|
||||
|
||||
let viewing: { hostId: string; worktreeId: string } | null = null
|
||||
export function setNotificationViewingWorkspace(value: typeof viewing): void {
|
||||
viewing = value
|
||||
}
|
||||
|
||||
export async function allowsLocalNotification(
|
||||
event: MobileNotificationPolicyEvent & { worktreeId?: string },
|
||||
export async function shouldSuppressNotificationWhileViewing(
|
||||
event: { worktreeId?: string },
|
||||
hostId: string
|
||||
): Promise<boolean> {
|
||||
const preferences = await loadNotificationDeliveryPreferences()
|
||||
if (!allowsMobileNotification(notificationPreferencesFilter(preferences), event)) {
|
||||
return false
|
||||
}
|
||||
return !(
|
||||
return (
|
||||
preferences.suppressWhileViewing &&
|
||||
AppState.currentState === 'active' &&
|
||||
viewing?.hostId === hostId &&
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
clearWatermark,
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests,
|
||||
seedWatermarkFromStorage
|
||||
} from './notification-reconnect-catchup'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
dismissNotificationAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'background' },
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
|
||||
// and mapping those to this host needs the catalog, whose real module pulls the
|
||||
// native keychain. No push is presented in these tests, so an empty catalog is enough.
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
|
||||
|
||||
// A storage whose reads can be held open, so a live event can be injected into the
|
||||
// exact window a real cold open has: subscription up, persisted watermark not yet read.
|
||||
const storage = new Map<string, string>()
|
||||
let heldReads: (() => void)[] = []
|
||||
let holdReads = false
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn((key: string) => {
|
||||
const read = (): string | null => storage.get(key) ?? null
|
||||
if (!holdReads) {
|
||||
return Promise.resolve(read())
|
||||
}
|
||||
return new Promise<string | null>((resolve) => {
|
||||
heldReads.push(() => resolve(read()))
|
||||
})
|
||||
}),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => false),
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseReads(): void {
|
||||
const pending = heldReads
|
||||
heldReads = []
|
||||
for (const resolve of pending) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }[] =
|
||||
[]
|
||||
const client = {
|
||||
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn(() => {
|
||||
onData = null
|
||||
})
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
getMissedCalls.push(
|
||||
params as { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }
|
||||
)
|
||||
return { ok: true, result: { notifications: [] } } as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
}
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
getMissedCalls
|
||||
}
|
||||
}
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1'
|
||||
|
||||
describe('#8591 watermark seeding races a cold open', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
heldReads = []
|
||||
holdReads = false
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('asks for catch-up from the persisted seq even if a live event lands first', async () => {
|
||||
// The window is real: app/index.tsx subscribes immediately, and the desktop's
|
||||
// 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the
|
||||
// live seq is allowed to advance the watermark first, getMissedSince is asked to
|
||||
// start from it and the desktop cuts everything the device actually missed.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' }))
|
||||
holdReads = true
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'live-12',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 12,
|
||||
notificationEpoch: 'epoch-a'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// Nothing may be decided while the read is outstanding.
|
||||
expect(host.getMissedCalls).toHaveLength(0)
|
||||
|
||||
releaseReads()
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([
|
||||
{ includeDesktopSuppressed: true, lastSeenSeq: 5, epoch: 'epoch-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
|
||||
// adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a
|
||||
// dead counter. That record still proves this device has been subscribed here, so a
|
||||
// cold open after it must catch up — reading it as "never paired" drops the window.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' }))
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([
|
||||
{ includeDesktopSuppressed: true, lastSeenSeq: 0, epoch: 'epoch-a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not catch up on a first-ever pairing', async () => {
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => {
|
||||
// Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications
|
||||
// can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed
|
||||
// always resolves first today. Pinned anyway because the guard is load-bearing the
|
||||
// moment any caller adopts an epoch before seeding: applying a seq 40 from a counter
|
||||
// that no longer exists would let getMissedSince cut the new counter's 1..40, which
|
||||
// is the original #8591 loss re-entered through the seeding path.
|
||||
const session = getHostNotificationSession('host-1')
|
||||
adoptNotificationEpoch(session, 'host-1', 'epoch-new')
|
||||
await flushAsync()
|
||||
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' }))
|
||||
seedWatermarkFromStorage(session, 'host-1')
|
||||
await session.watermarkSeeded
|
||||
await flushAsync()
|
||||
|
||||
expect(session.lastDeliveredEpoch).toBe('epoch-new')
|
||||
expect(session.lastDeliveredSeq).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => {
|
||||
// loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired
|
||||
// host read a pre-#8591 seq belonging to a counter lifetime that no longer exists.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' }))
|
||||
storage.set(LEGACY_KEY, '57')
|
||||
|
||||
await clearWatermark('host-1')
|
||||
|
||||
expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual(
|
||||
expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY])
|
||||
)
|
||||
expect(storage.has(WATERMARK_KEY)).toBe(false)
|
||||
expect(storage.has(LEGACY_KEY)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import { nativePushDismissal } from './native-push-dismissal'
|
||||
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { foregroundNotificationBehavior, shouldSuppressForegroundPush } from './push-receive'
|
||||
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
|
||||
const memory = vi.hoisted(() => new Map<string, string>())
|
||||
const nativeLedger = vi.hoisted(() => new Map<string, number>())
|
||||
@@ -45,7 +44,9 @@ vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: async () => true,
|
||||
loadRemotePushEnabled: async () => true
|
||||
}))
|
||||
vi.mock('./notification-viewing-policy', () => ({ allowsLocalNotification: async () => true }))
|
||||
vi.mock('./notification-viewing-policy', () => ({
|
||||
shouldSuppressNotificationWhileViewing: async () => false
|
||||
}))
|
||||
vi.mock('./notification-delivery-preferences', () => ({
|
||||
loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true }))
|
||||
}))
|
||||
@@ -63,14 +64,13 @@ beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => memory.get(key) ?? null)
|
||||
memory.clear()
|
||||
nativeLedger.clear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
it('suppresses a foreground push dismissed natively during its queued fallback read', async () => {
|
||||
let finish!: () => void
|
||||
let reads = 0
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => {
|
||||
if (key === 'orca:pushDismissalWatermarks:v1' && ++reads === 2) {
|
||||
if (key === 'orca:pushDismissalWatermarks:v1' && ++reads === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ async function readDelivered(hostId: string): Promise<Map<string, OrcaPushPayloa
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Legacy shells can still use ordinary event replay without tray inspection.
|
||||
// Tray inspection is best-effort; failure leaves OS banners for later reconciliation.
|
||||
}
|
||||
return selected
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function requestNotificationCatchup(
|
||||
}
|
||||
const entries = [...delivered.entries()]
|
||||
const response = await client.sendRequest('notifications.getMissedSince', {
|
||||
// First pairing reconciles the tray without requesting historical alerts.
|
||||
// First pairing reconciles tray identities without requesting historical events.
|
||||
...(params ?? { lastSeenSeq: Number.MAX_SAFE_INTEGER }),
|
||||
...(delivered.size
|
||||
? { deliveredPushes: entries.slice(0, 256).map(([, payload]) => identity(payload)!) }
|
||||
@@ -89,7 +89,7 @@ export async function requestNotificationCatchup(
|
||||
}
|
||||
}
|
||||
await applyDismissals(response, new Map(entries.slice(0, 256)))
|
||||
// Summaries may represent more identities than one RPC permits; replay only once.
|
||||
// Page remaining tray identities without requesting historical events again.
|
||||
for (let offset = 256; offset < entries.length && !isDisposed(); offset += 256) {
|
||||
const requested = new Map(entries.slice(offset, offset + 256))
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
}
|
||||
}
|
||||
}))
|
||||
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import {
|
||||
areLegacySummaryPushesDismissed,
|
||||
rememberPushDismissal,
|
||||
wasPushDismissed
|
||||
} from './push-dismissal-watermarks'
|
||||
const payload = {
|
||||
hostFingerprint: 'host-a',
|
||||
notificationEpoch: 'epoch-a',
|
||||
@@ -55,7 +59,7 @@ it('expires retained metadata and ignores unversioned dismissals', async () => {
|
||||
|
||||
it('does not discard a summary representing other undismissed alerts', async () => {
|
||||
await rememberPushDismissal(payload)
|
||||
expect(await wasPushDismissed({ ...payload, coalescedCount: 3 })).toBe(false)
|
||||
expect(await areLegacySummaryPushesDismissed({ ...payload, coalescedCount: 3 })).toBe(false)
|
||||
})
|
||||
|
||||
it('joins an overtaking fallback write before retrying a delayed negative snapshot', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { representedPushes } from './push-summary-members'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { OrcaPushPayload } from './push-payload'
|
||||
import { nativePushDismissal } from './native-push-dismissal'
|
||||
import { representedPushes } from './push-summary-members'
|
||||
|
||||
const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1'
|
||||
const RETENTION_MS = 24 * 60 * 60 * 1000
|
||||
@@ -97,10 +97,6 @@ async function readDismissal(payload: OrcaPushPayload, key: string): Promise<boo
|
||||
}
|
||||
|
||||
export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolean> {
|
||||
if ((payload.coalescedCount ?? 0) > 1) {
|
||||
const members = representedPushes(payload)
|
||||
return members.length > 0 && (await Promise.all(members.map(wasPushDismissed))).every(Boolean)
|
||||
}
|
||||
const key = eventKey(payload)
|
||||
if (!key) {
|
||||
return false
|
||||
@@ -114,3 +110,12 @@ export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolea
|
||||
// An overtaking write invalidates a negative snapshot; one queued read cannot be overtaken again.
|
||||
return queueDismissalOperation(() => readDismissal(payload, key))
|
||||
}
|
||||
|
||||
/** Legacy summaries are expanded only while deciding whether to show or remove old deliveries. */
|
||||
export async function areLegacySummaryPushesDismissed(payload: OrcaPushPayload): Promise<boolean> {
|
||||
if ((payload.coalescedCount ?? 0) <= 1) {
|
||||
return wasPushDismissed(payload)
|
||||
}
|
||||
const members = representedPushes(payload)
|
||||
return members.length > 0 && (await Promise.all(members.map(wasPushDismissed))).every(Boolean)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export type OrcaPushPayload = {
|
||||
readonly worktreeId?: string
|
||||
readonly source?: string
|
||||
readonly agentState?: string
|
||||
// Present only on a gateway summary standing in for N events; see the coalescing
|
||||
// window in docs/reference/mobile-push-contract.md.
|
||||
// Legacy-only fields retained during rolling overlap to decode summaries already in OS trays.
|
||||
readonly summaryMembers?: readonly Identity[]
|
||||
readonly coalescedCount?: number
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ beforeEach(() => {
|
||||
storage.set('orca:remotePushEnabled', 'true')
|
||||
})
|
||||
|
||||
it('replaces an in-flight old registration with the latest event and sound preferences', async () => {
|
||||
it('replaces an in-flight registration with the latest away and sound preferences', async () => {
|
||||
const calls: { method: string; params: unknown }[] = []
|
||||
let finishFirst: ((value: unknown) => void) | undefined
|
||||
const client = {
|
||||
@@ -64,8 +64,7 @@ it('replaces an in-flight old registration with the latest event and sound prefe
|
||||
await vi.waitFor(() => expect(finishFirst).toBeDefined())
|
||||
const update = setNotificationDeliveryPreferences({
|
||||
...DEFAULT_NOTIFICATION_DELIVERY,
|
||||
followDesktop: false,
|
||||
terminalBell: false,
|
||||
onlyWhenDesktopAway: false,
|
||||
sound: false
|
||||
})
|
||||
finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } })
|
||||
@@ -77,7 +76,14 @@ it('replaces an in-flight old registration with the latest event and sound prefe
|
||||
)
|
||||
const latest = calls.findLast((call) => call.method === 'notifications.registerPush')
|
||||
expect(latest?.params).toMatchObject({
|
||||
filter: { followDesktop: false, sound: false, sources: ['agent-task-complete', 'plugin'] }
|
||||
filter: {
|
||||
onlyWhenDesktopAway: false,
|
||||
expireAfterInactivity: true,
|
||||
followDesktop: true,
|
||||
sound: false,
|
||||
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
|
||||
agentStates: ['needs-input', 'finished']
|
||||
}
|
||||
})
|
||||
expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true)
|
||||
detach()
|
||||
|
||||
@@ -1,47 +1,40 @@
|
||||
vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() }))
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import { getNotificationNavigationTarget } from './notification-routing'
|
||||
import {
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests
|
||||
} from './notification-reconnect-catchup'
|
||||
import {
|
||||
foregroundNotificationBehavior,
|
||||
isRemotePushTrigger,
|
||||
pushNotificationRouteData,
|
||||
resetForegroundPushClaimsForTests,
|
||||
shouldSuppressForegroundPush
|
||||
} from './push-receive'
|
||||
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
|
||||
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
const storage = vi.hoisted(() => new Map<string, string>())
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn(async () => undefined)
|
||||
setItem: vi.fn(async (key: string, value: string) => storage.set(key, value))
|
||||
}
|
||||
}))
|
||||
|
||||
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
|
||||
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
|
||||
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
|
||||
|
||||
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
|
||||
const hostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 1)))
|
||||
.toString('base64url')
|
||||
.slice(0, 16)
|
||||
const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
|
||||
const otherPublicKeyB64 = Buffer.alloc(32, 2).toString('base64')
|
||||
const otherHostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 2)))
|
||||
.toString('base64url')
|
||||
.slice(0, 16)
|
||||
|
||||
// APNs nests Orca's fields beside `aps`; FCM sends them flat and stringified.
|
||||
function apnsData(orca: Record<string, unknown>): unknown {
|
||||
return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca }
|
||||
}
|
||||
|
||||
function fcmData(orca: Record<string, unknown>): unknown {
|
||||
return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)]))
|
||||
}
|
||||
@@ -51,232 +44,238 @@ beforeEach(() => {
|
||||
storage.clear()
|
||||
storage.set('orca:pushNotificationsEnabled', 'true')
|
||||
storage.set('orca:remotePushEnabled', 'true')
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
|
||||
resetForegroundPushClaimsForTests()
|
||||
vi.mocked(loadHostCatalog).mockResolvedValue([
|
||||
...hosts,
|
||||
{ id: 'host-2', publicKeyB64: otherPublicKeyB64 }
|
||||
] as unknown as HostCatalogEntry[])
|
||||
})
|
||||
|
||||
describe('shouldSuppressForegroundPush', () => {
|
||||
it('suppresses a push whose id and seq the socket already delivered', async () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
session.seen.add('id:agent:one#7')
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
notificationSeq: 7,
|
||||
notificationEpoch: 'epoch-1'
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('shows an unseen push and marks it so the socket replay is dropped', async () => {
|
||||
const data = apnsData({
|
||||
const push = () =>
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
notificationSeq: 7,
|
||||
notificationEpoch: 'epoch-1'
|
||||
})
|
||||
|
||||
await expect(shouldSuppressForegroundPush(data)).resolves.toBe(false)
|
||||
|
||||
expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(true)
|
||||
await expect(shouldSuppressForegroundPush(data)).resolves.toBe(true)
|
||||
it('allows one eligible native push and suppresses an in-process duplicate', async () => {
|
||||
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(false)
|
||||
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reads the flat stringified fields an FCM data message carries', async () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
session.seen.add('id:agent:one#7')
|
||||
|
||||
it('reads flat FCM fields and allows the first native push', async () => {
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
fcmData({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
notificationSeq: 7,
|
||||
notificationSeq: 8,
|
||||
notificationEpoch: 'epoch-1'
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keys a terminal bell on its seq alone, since it carries no notification id', async () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
session.seen.add('seq:4')
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
source: 'terminal-bell',
|
||||
notificationSeq: 4,
|
||||
notificationEpoch: 'epoch-1'
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('shows a push that names no counter lifetime without letting it claim a key', async () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
session.seen.add('seq:4')
|
||||
|
||||
// Without an epoch the seq cannot be tied to this counter, so a forged seq:4
|
||||
// must neither be swallowed against it nor stop the real bell at seq 4.
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 4 }))
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 5 }))
|
||||
).resolves.toBe(false)
|
||||
expect(session.seen.has('seq:5')).toBe(false)
|
||||
})
|
||||
|
||||
it('voids seen keys from a previous desktop lifetime before testing its own', async () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-old'
|
||||
session.seen.add('seq:4')
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({ hostFingerprint, notificationSeq: 4, notificationEpoch: 'epoch-new' })
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a locally scheduled notification to the existing path', async () => {
|
||||
it('deduplicates ID-less bells by host, epoch, and valid sequence', async () => {
|
||||
const bell = (overrides: Record<string, unknown> = {}) =>
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
source: 'terminal-bell',
|
||||
notificationSeq: 4,
|
||||
notificationEpoch: 'epoch-1',
|
||||
...overrides
|
||||
})
|
||||
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(false)
|
||||
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(true)
|
||||
await expect(shouldSuppressForegroundPush(bell({ notificationSeq: 5 }))).resolves.toBe(false)
|
||||
await expect(
|
||||
shouldSuppressForegroundPush({ hostId: 'host-1', source: 'agent-task-complete' })
|
||||
shouldSuppressForegroundPush(bell({ notificationEpoch: 'epoch-2' }))
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(bell({ hostFingerprint: otherHostFingerprint }))
|
||||
).resolves.toBe(false)
|
||||
expect(loadHostCatalog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses a push for a host this phone no longer has, since its tap routes nowhere', async () => {
|
||||
it('does not claim invalid sequence values as duplicate identities', async () => {
|
||||
const invalid = apnsData({
|
||||
hostFingerprint,
|
||||
source: 'plugin',
|
||||
notificationSeq: 1.5,
|
||||
notificationEpoch: 'epoch-1'
|
||||
})
|
||||
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
|
||||
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses pushes for an unpaired host', async () => {
|
||||
vi.mocked(loadHostCatalog).mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 }))
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('seeds the persisted watermark before adopting, so a push cannot void it', async () => {
|
||||
storage.set(
|
||||
'orca:mobileNotificationsWatermark:host-1',
|
||||
JSON.stringify({ seq: 42, epoch: 'epoch-1' })
|
||||
)
|
||||
|
||||
await shouldSuppressForegroundPush(
|
||||
apnsData({ hostFingerprint, notificationSeq: 43, notificationEpoch: 'epoch-1' })
|
||||
)
|
||||
|
||||
// Unseeded, the null epoch reads as a new counter lifetime: the seq resets to 0
|
||||
// and {seq: 0} is persisted over a watermark the next reconnect still needs.
|
||||
expect(getHostNotificationSession('host-1').lastDeliveredSeq).toBe(42)
|
||||
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
|
||||
it('suppresses a push after a matching persisted dismissal', async () => {
|
||||
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
|
||||
const payload = {
|
||||
hostFingerprint,
|
||||
notificationId: 'dismissed',
|
||||
notificationSeq: 2,
|
||||
notificationEpoch: 'epoch-1'
|
||||
}
|
||||
await rememberPushDismissal(payload)
|
||||
await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('shows a coalesced summary without claiming the key of the one event it names', async () => {
|
||||
it('keeps a legacy summary visible while an earlier member remains unread', async () => {
|
||||
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
|
||||
const members = [
|
||||
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
|
||||
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
|
||||
]
|
||||
await rememberPushDismissal({ hostFingerprint, ...members[1]! })
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
notificationSeq: 7,
|
||||
coalescedCount: 3
|
||||
...members[1],
|
||||
coalescedCount: members.length,
|
||||
summaryMembers: members
|
||||
})
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
// Claiming it would make the socket swallow the banner for agent:one itself,
|
||||
// which the summary only ever counted.
|
||||
expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(false)
|
||||
it('suppresses a legacy summary only after every member is dismissed', async () => {
|
||||
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
|
||||
const members = [
|
||||
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
|
||||
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
|
||||
]
|
||||
await Promise.all(
|
||||
members.map((member) => rememberPushDismissal({ hostFingerprint, ...member }))
|
||||
)
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
...members[1],
|
||||
coalescedCount: members.length,
|
||||
summaryMembers: members
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps malformed legacy summary membership visible', async () => {
|
||||
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
|
||||
const latest = {
|
||||
notificationId: 'agent:latest',
|
||||
notificationSeq: 7,
|
||||
notificationEpoch: 'epoch-1'
|
||||
}
|
||||
await rememberPushDismissal({ hostFingerprint, ...latest })
|
||||
|
||||
await expect(
|
||||
shouldSuppressForegroundPush(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
...latest,
|
||||
coalescedCount: 2,
|
||||
summaryMembers: [latest]
|
||||
})
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps legacy summary and individual duplicate claims independent', async () => {
|
||||
const members = [
|
||||
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
|
||||
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
|
||||
]
|
||||
const summaryPush = apnsData({
|
||||
hostFingerprint,
|
||||
...members[1],
|
||||
coalescedCount: members.length,
|
||||
summaryMembers: members
|
||||
})
|
||||
const individualPush = apnsData({ hostFingerprint, ...members[1] })
|
||||
|
||||
await expect(shouldSuppressForegroundPush(summaryPush)).resolves.toBe(false)
|
||||
await expect(shouldSuppressForegroundPush(individualPush)).resolves.toBe(false)
|
||||
await expect(shouldSuppressForegroundPush(individualPush)).resolves.toBe(true)
|
||||
await expect(shouldSuppressForegroundPush(summaryPush)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed for recognized pushes when suppression checks throw', async () => {
|
||||
const preferences = await import('./notification-delivery-preferences')
|
||||
const dismissals = await import('./push-dismissal-watermarks')
|
||||
const preferenceSpy = vi
|
||||
.spyOn(preferences, 'loadNotificationDeliveryPreferences')
|
||||
.mockResolvedValueOnce({ sound: true } as never)
|
||||
.mockRejectedValueOnce(new Error('preference read failed'))
|
||||
const dismissalSpy = vi.spyOn(dismissals, 'wasPushDismissed')
|
||||
await expect(
|
||||
foregroundNotificationBehavior({ request: { content: { data: push() } } })
|
||||
).resolves.toMatchObject({ shouldShowBanner: false, shouldShowList: false })
|
||||
expect(dismissalSpy).not.toHaveBeenCalled()
|
||||
preferenceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps unrelated notifications visible when suppression checks throw', async () => {
|
||||
const dismissals = await import('./push-dismissal-watermarks')
|
||||
const dismissalSpy = vi
|
||||
.spyOn(dismissals, 'wasPushDismissed')
|
||||
.mockRejectedValue(new Error('dismissal read failed'))
|
||||
await expect(
|
||||
foregroundNotificationBehavior({
|
||||
request: { content: { data: { title: 'Other app notification' } } }
|
||||
})
|
||||
).resolves.toMatchObject({ shouldShowBanner: true, shouldShowList: true })
|
||||
dismissalSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushNotificationRouteData', () => {
|
||||
it('routes a tap by mapping the fingerprint to the paired host id', () => {
|
||||
const data = pushNotificationRouteData(
|
||||
apnsData({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
|
||||
source: 'agent-task-complete'
|
||||
}),
|
||||
apnsData({ hostFingerprint, worktreeId: 'repo::/feature', source: 'agent-task-complete' }),
|
||||
hosts
|
||||
)
|
||||
|
||||
expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({
|
||||
hostId: 'host-1',
|
||||
sessionTarget: {
|
||||
name: '[hostId]/session/[worktreeId]',
|
||||
params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }
|
||||
params: { hostId: 'host-1', worktreeId: 'repo::/feature' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the host screen for a push with no worktree', () => {
|
||||
it('maps a push without a worktree to the host screen', () => {
|
||||
const data = pushNotificationRouteData(
|
||||
fcmData({ hostFingerprint, source: 'terminal-bell' }),
|
||||
hosts
|
||||
)
|
||||
|
||||
expect(getNotificationNavigationTarget(data)).toEqual({
|
||||
hostId: 'host-1',
|
||||
sessionTarget: null
|
||||
})
|
||||
expect(getNotificationNavigationTarget(data)).toEqual({ hostId: 'host-1', sessionTarget: null })
|
||||
})
|
||||
|
||||
it('passes locally scheduled data through untouched', () => {
|
||||
const data = { hostId: 'host-9', source: 'agent-task-complete' }
|
||||
|
||||
expect(pushNotificationRouteData(data, hosts)).toBe(data)
|
||||
})
|
||||
|
||||
it('leaves an unresolvable fingerprint unrouted rather than guessing a host', () => {
|
||||
const data = pushNotificationRouteData(apnsData({ hostFingerprint: '0123456789abcdef' }), hosts)
|
||||
|
||||
expect(getNotificationNavigationTarget(data)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a remote push unrouted when no host catalog could be read', () => {
|
||||
const data = { hostId: 'host-1', orca: { hostFingerprint, notificationId: 'agent:one' } }
|
||||
|
||||
expect(pushNotificationRouteData(data, [], true)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a remote push with no fingerprint unrouted instead of treating it as local', () => {
|
||||
const data = { hostId: 'host-1', worktreeId: 'wt-1', source: 'agent-task-complete' }
|
||||
|
||||
expect(pushNotificationRouteData(data, hosts, true)).toBeNull()
|
||||
// The same shape from this app's own scheduler still routes.
|
||||
expect(pushNotificationRouteData(data, hosts, false)).toBe(data)
|
||||
})
|
||||
|
||||
it('recognises only a provider-delivered trigger as remote', () => {
|
||||
expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
|
||||
expect(isRemotePushTrigger({ type: 'timeInterval', seconds: 1 })).toBe(false)
|
||||
expect(isRemotePushTrigger({ channelId: 'orca-desktop' })).toBe(false)
|
||||
expect(isRemotePushTrigger(null)).toBe(false)
|
||||
expect(isRemotePushTrigger(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a gateway payload that pairs an unresolvable fingerprint with a stray hostId', () => {
|
||||
const data = {
|
||||
hostId: 'host-1',
|
||||
orca: { hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' }
|
||||
}
|
||||
|
||||
// Returning the raw data would let the stray hostId route a tap the push never named.
|
||||
expect(pushNotificationRouteData(data, hosts)).toBeNull()
|
||||
it('keeps local data untouched and rejects an unresolvable remote fingerprint', () => {
|
||||
const local = { hostId: 'host-9', source: 'agent-task-complete' }
|
||||
expect(pushNotificationRouteData(local, hosts)).toBe(local)
|
||||
expect(
|
||||
getNotificationNavigationTarget(pushNotificationRouteData(data, hosts), {
|
||||
knownHostIds: new Set(['host-1'])
|
||||
})
|
||||
pushNotificationRouteData(
|
||||
{ hostId: 'host-1', orca: { hostFingerprint: 'unknown' } },
|
||||
hosts,
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('recognises only provider-delivered triggers', () => {
|
||||
expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
|
||||
expect(isRemotePushTrigger({ type: 'timeInterval' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
import { wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { areLegacySummaryPushesDismissed, wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { dismissPresentedPushNotification } from './push-tray-dismissal'
|
||||
import { allowsLocalNotification } from './notification-viewing-policy'
|
||||
import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy'
|
||||
import { loadPushNotificationsEnabled, loadRemotePushEnabled } from '../storage/preferences'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
enqueueHostDelivery,
|
||||
getHostNotificationSession,
|
||||
seedWatermarkFromStorage,
|
||||
seenKeyForEvent
|
||||
} from './notification-reconnect-catchup'
|
||||
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
|
||||
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
|
||||
import type { Notification, NotificationBehavior } from 'expo-notifications'
|
||||
import { readNativeNotificationData } from './native-notification-data'
|
||||
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
|
||||
|
||||
const RECENT_FOREGROUND_PUSH_CAP = 512
|
||||
const recentForegroundPushes = new Set<string>()
|
||||
|
||||
function claimForegroundPush(payload: OrcaPushPayload): boolean {
|
||||
const seq = payload.notificationSeq
|
||||
if (
|
||||
!payload.notificationEpoch ||
|
||||
typeof seq !== 'number' ||
|
||||
!Number.isSafeInteger(seq) ||
|
||||
seq < 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
payload.hostFingerprint,
|
||||
payload.notificationEpoch,
|
||||
payload.notificationId ?? null,
|
||||
seq
|
||||
])
|
||||
if (recentForegroundPushes.has(key)) {
|
||||
return false
|
||||
}
|
||||
recentForegroundPushes.add(key)
|
||||
if (recentForegroundPushes.size > RECENT_FOREGROUND_PUSH_CAP) {
|
||||
const oldest = recentForegroundPushes.values().next().value
|
||||
if (oldest !== undefined) {
|
||||
recentForegroundPushes.delete(oldest)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetForegroundPushClaimsForTests(): void {
|
||||
recentForegroundPushes.clear()
|
||||
}
|
||||
|
||||
export async function foregroundNotificationBehavior(
|
||||
notification: Pick<Notification, 'request'>
|
||||
): Promise<NotificationBehavior> {
|
||||
const preferences = await loadNotificationDeliveryPreferences()
|
||||
// No storage awaits after suppression: a dismissal may arrive during any read.
|
||||
const suppressed = await shouldSuppressForegroundPush(
|
||||
readNativeNotificationData(notification.request)
|
||||
).catch(() => false)
|
||||
const data = readNativeNotificationData(notification.request)
|
||||
const recognizedPush = readOrcaPushPayload(data) !== null
|
||||
const preferences = await loadNotificationDeliveryPreferences().catch(() => ({ sound: true }))
|
||||
// Unrecognized notifications retain normal behavior; recognized pushes fail closed
|
||||
// when consent, host, viewing, or dismissal checks cannot complete.
|
||||
const suppressed = await shouldSuppressForegroundPush(data).catch(() => recognizedPush)
|
||||
return {
|
||||
shouldShowBanner: !suppressed,
|
||||
shouldShowList: !suppressed,
|
||||
@@ -37,14 +67,6 @@ async function resolvePushHostId(payload: OrcaPushPayload): Promise<string | nul
|
||||
return resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a foreground notification is a push for an event the socket already
|
||||
* delivered, and must therefore be swallowed instead of banner'd a second time.
|
||||
*
|
||||
* Marking happens here rather than in a received listener because the handler is
|
||||
* the only hook that can actually suppress, and the key must be claimed exactly
|
||||
* once — a listener running afterwards would mark an event the handler dropped.
|
||||
*/
|
||||
export async function shouldSuppressForegroundPush(data: unknown): Promise<boolean> {
|
||||
const payload = readOrcaPushPayload(data)
|
||||
if (!payload) {
|
||||
@@ -60,9 +82,6 @@ export async function shouldSuppressForegroundPush(data: unknown): Promise<boole
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (await wasPushDismissed(payload)) {
|
||||
return true
|
||||
}
|
||||
const hostId = await resolvePushHostId(payload)
|
||||
// Why suppressed rather than shown: the only pushes that outlive their host are
|
||||
// ones a gateway registration still holds after a removal whose unregister never
|
||||
@@ -74,50 +93,14 @@ export async function shouldSuppressForegroundPush(data: unknown): Promise<boole
|
||||
if (!(await loadPushNotificationsEnabled()) || !(await loadRemotePushEnabled())) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
!(await allowsLocalNotification(
|
||||
{ ...payload, source: payload.source ?? 'agent-task-complete' },
|
||||
hostId
|
||||
))
|
||||
) {
|
||||
if (await shouldSuppressNotificationWhileViewing(payload, hostId)) {
|
||||
return true
|
||||
}
|
||||
const session = getHostNotificationSession(hostId)
|
||||
// Why seeded first: the socket may never have connected this launch (phone on
|
||||
// cellular), leaving lastDeliveredEpoch null. Adopting against an unseeded session
|
||||
// resets the seq to 0 and persists that over a valid watermark, so the next
|
||||
// reconnect replays the desktop's whole retained buffer.
|
||||
seedWatermarkFromStorage(session, hostId)
|
||||
await session.watermarkSeeded
|
||||
// A push that names no counter lifetime cannot claim a seq-derived key: the
|
||||
// desktop always sends the epoch, so this is shown as-is and never marked.
|
||||
if (payload.notificationEpoch == null) {
|
||||
return false
|
||||
// Keep this last: a socket/native dismissal may land during any preference or host read.
|
||||
if ((payload.coalescedCount ?? 0) > 1) {
|
||||
return areLegacySummaryPushesDismissed(payload)
|
||||
}
|
||||
// Push and socket delivery share one claim, including an in-flight native schedule.
|
||||
return enqueueHostDelivery(session, async () => {
|
||||
if (await wasPushDismissed(payload)) {
|
||||
return true
|
||||
}
|
||||
// The seen keys are seq-derived, so a push from a new desktop lifetime must void
|
||||
// them before its own key is tested against a counter that no longer exists.
|
||||
adoptNotificationEpoch(session, hostId, payload.notificationEpoch)
|
||||
// Why a coalesced summary is neither suppressed nor marked: it carries only the
|
||||
// latest event's fields, so claiming that key would make the socket swallow the
|
||||
// specific banner for an event the summary only ever counted.
|
||||
if ((payload.coalescedCount ?? 0) > 1) {
|
||||
return false
|
||||
}
|
||||
const key = seenKeyForEvent(payload)
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
if (session.seen.has(key)) {
|
||||
return true
|
||||
}
|
||||
session.seen.add(key)
|
||||
return false
|
||||
})
|
||||
return (await wasPushDismissed(payload)) || !claimForegroundPush(payload)
|
||||
}
|
||||
|
||||
/** Whether the OS says a notification came from a provider rather than this app. */
|
||||
|
||||
@@ -6,14 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
loadRemotePushAgentStates,
|
||||
loadRemotePushEnabled,
|
||||
loadRemotePushFilter,
|
||||
loadRemotePushHostRegistrations,
|
||||
saveRemotePushAgentStates,
|
||||
saveRemotePushEnabled,
|
||||
saveRemotePushHostRegistrations,
|
||||
type RemotePushAgentState,
|
||||
type RemotePushHostRegistrations
|
||||
} from '../storage/preferences'
|
||||
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
|
||||
@@ -21,7 +18,6 @@ import {
|
||||
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
|
||||
attachPushRegistration,
|
||||
resetPushRegistrationForTests,
|
||||
setRemotePushAgentStates,
|
||||
setRemotePushEnabled,
|
||||
startPushTokenSync,
|
||||
unregisterPushForRemovedHost
|
||||
@@ -30,8 +26,6 @@ import {
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(),
|
||||
saveRemotePushEnabled: vi.fn(),
|
||||
loadRemotePushAgentStates: vi.fn(),
|
||||
saveRemotePushAgentStates: vi.fn(),
|
||||
loadRemotePushFilter: vi.fn(),
|
||||
loadRemotePushHostRegistrations: vi.fn(),
|
||||
saveRemotePushHostRegistrations: vi.fn()
|
||||
@@ -92,7 +86,6 @@ function methodsIn(sent: SentRequest[]): string[] {
|
||||
}
|
||||
|
||||
let enabled = false
|
||||
let agentStates: readonly RemotePushAgentState[] = ['needs-input', 'finished']
|
||||
let stored: RemotePushHostRegistrations
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -100,20 +93,15 @@ beforeEach(() => {
|
||||
AppState.currentState = 'active'
|
||||
resetPushRegistrationForTests()
|
||||
enabled = false
|
||||
agentStates = ['needs-input', 'finished']
|
||||
stored = { registeredHostIds: [], pendingUnregisterHostIds: [] }
|
||||
|
||||
vi.mocked(loadRemotePushEnabled).mockImplementation(async () => enabled)
|
||||
vi.mocked(saveRemotePushEnabled).mockImplementation(async (value) => {
|
||||
enabled = value
|
||||
})
|
||||
vi.mocked(loadRemotePushAgentStates).mockImplementation(async () => agentStates)
|
||||
vi.mocked(saveRemotePushAgentStates).mockImplementation(async (value) => {
|
||||
agentStates = value
|
||||
})
|
||||
vi.mocked(loadRemotePushFilter).mockImplementation(async () => ({
|
||||
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
|
||||
agentStates
|
||||
agentStates: ['needs-input', 'finished']
|
||||
}))
|
||||
vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored)
|
||||
vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => {
|
||||
@@ -193,7 +181,7 @@ describe('push registration capability gating', () => {
|
||||
attachPushRegistration('host-legacy', client)
|
||||
await flush()
|
||||
|
||||
await setRemotePushAgentStates(['needs-input'])
|
||||
await setRemotePushEnabled(true)
|
||||
await flush()
|
||||
|
||||
expect(methodsIn(sent)).toEqual(['status.get'])
|
||||
@@ -221,7 +209,7 @@ describe('push registration capability gating', () => {
|
||||
|
||||
// A latched `false` would keep this host unregistered for the connection's life.
|
||||
probeFails = false
|
||||
await setRemotePushAgentStates(['needs-input'])
|
||||
await setRemotePushEnabled(true)
|
||||
await flush()
|
||||
|
||||
expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush'])
|
||||
@@ -236,14 +224,14 @@ describe('push registration capability gating', () => {
|
||||
expect(methodsIn(sent)).toEqual(['status.get'])
|
||||
|
||||
// A token can be missing only for now — APNs registration still in flight.
|
||||
await setRemotePushAgentStates(['needs-input'])
|
||||
await setRemotePushEnabled(true)
|
||||
await flush()
|
||||
|
||||
expect(methodsIn(sent)).toContain('notifications.registerPush')
|
||||
})
|
||||
})
|
||||
|
||||
describe('push registration token and filter changes', () => {
|
||||
describe('push registration token changes', () => {
|
||||
it('re-registers every connected host when the provider rolls the token', async () => {
|
||||
let onTokenChange: ((token: MobilePushToken) => void) | null = null
|
||||
vi.mocked(addPushTokenListener).mockImplementation((listener) => {
|
||||
@@ -267,25 +255,6 @@ describe('push registration token and filter changes', () => {
|
||||
})
|
||||
stop()
|
||||
})
|
||||
|
||||
it('re-registers with the narrowed filter when a sub-switch is turned off', async () => {
|
||||
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
|
||||
await setRemotePushEnabled(true)
|
||||
attachPushRegistration('host-1', client)
|
||||
await flush()
|
||||
|
||||
await setRemotePushAgentStates(['needs-input'])
|
||||
await flush()
|
||||
|
||||
const registers = sent.filter((request) => request.method === 'notifications.registerPush')
|
||||
expect(registers).toHaveLength(2)
|
||||
expect(registers[1]?.params).toMatchObject({
|
||||
filter: {
|
||||
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
|
||||
agentStates: ['needs-input']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('push unregistration', () => {
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
loadRemotePushEnabled,
|
||||
loadRemotePushFilter,
|
||||
loadRemotePushHostRegistrations,
|
||||
saveRemotePushAgentStates,
|
||||
saveRemotePushEnabled,
|
||||
saveRemotePushHostRegistrations,
|
||||
type RemotePushAgentState,
|
||||
type RemotePushFilter
|
||||
} from '../storage/preferences'
|
||||
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
|
||||
@@ -258,15 +256,6 @@ export async function setNotificationDeliveryPreferences(
|
||||
await reconcileAllHosts()
|
||||
}
|
||||
|
||||
/** Re-registers every connected host so the gateway stores the narrowed filter. */
|
||||
export async function setRemotePushAgentStates(
|
||||
states: readonly RemotePushAgentState[]
|
||||
): Promise<void> {
|
||||
consentGeneration++
|
||||
await saveRemotePushAgentStates(states)
|
||||
await reconcileAllHosts()
|
||||
}
|
||||
|
||||
// Offline hosts retain the registration until unpaired or its mobile-use lease expires.
|
||||
export async function unregisterPushForRemovedHost(hostId: string): Promise<void> {
|
||||
const state = hostsById.get(hostId)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import { deriveHostFingerprint } from './push-host-fingerprint'
|
||||
import { dismissPresentedPushNotification } from './push-tray-dismissal'
|
||||
import type { DismissNotificationEvent } from './local-notification-scheduling'
|
||||
import type { DismissNotificationEvent } from './desktop-notification-events'
|
||||
|
||||
async function hostFingerprint(hostId: string): Promise<string | null> {
|
||||
const hosts = await loadHostCatalog().catch(() => [])
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
const memory = vi.hoisted(() => new Map<string, string>())
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import { shouldSuppressForegroundPush } from './push-receive'
|
||||
import {
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests
|
||||
} from './notification-reconnect-catchup'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'active' }, Platform: { OS: 'ios' } }))
|
||||
vi.mock('expo-notifications', () => ({
|
||||
getPresentedNotificationsAsync: vi.fn(async () => []),
|
||||
getPermissionsAsync: vi.fn(async () => ({ status: 'granted', canAskAgain: true })),
|
||||
scheduleNotificationAsync: vi.fn(async () => 'local'),
|
||||
dismissNotificationAsync: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => [{ id: 'host' }]) }))
|
||||
vi.mock('./push-host-fingerprint', () => ({
|
||||
resolveHostIdForFingerprint: () => 'host',
|
||||
deriveHostFingerprint: () => 'abcdefghijklmnop'
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: async () => true,
|
||||
loadPushNotificationsEnabled: async () => true,
|
||||
loadRemotePushHostRegistrations: async () => ({ registeredHostIds: ['host'] })
|
||||
}))
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => memory.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
memory.set(key, value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
const event = {
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: '',
|
||||
notificationId: 'done',
|
||||
notificationSeq: 1,
|
||||
notificationEpoch: 'epoch'
|
||||
}
|
||||
const push = { orca: { ...event, hostFingerprint: 'abcdefghijklmnop' } }
|
||||
const disposals: (() => void)[] = []
|
||||
beforeEach(() => {
|
||||
for (const dispose of disposals.splice(0)) {
|
||||
dispose()
|
||||
}
|
||||
memory.clear()
|
||||
vi.clearAllMocks()
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
async function socket() {
|
||||
let receive!: (data: unknown) => void
|
||||
const client = {
|
||||
subscribe: (_method: string, _params: unknown, callback: typeof receive) => {
|
||||
receive = callback
|
||||
return () => {}
|
||||
},
|
||||
getState: () => 'connected',
|
||||
sendRequest: async () => ({ ok: true, result: { notifications: [] } })
|
||||
}
|
||||
disposals.push(subscribeToDesktopNotifications(client as unknown as RpcClient, 'host'))
|
||||
receive({ type: 'ready', subscriptionId: 'sub', epoch: 'epoch' })
|
||||
await getHostNotificationSession('host').watermarkSeeded
|
||||
return receive
|
||||
}
|
||||
|
||||
it('does not show a second banner when a foreground push precedes its live socket event', async () => {
|
||||
const receive = await socket()
|
||||
expect(await shouldSuppressForegroundPush(push)).toBe(false)
|
||||
receive(event)
|
||||
await vi.waitFor(() => expect(getHostNotificationSession('host').lastDeliveredSeq).toBe(1))
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for in-flight socket scheduling before deciding whether to show its push', async () => {
|
||||
let finish!: (id: string) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const receive = await socket()
|
||||
receive(event)
|
||||
await vi.waitFor(() => expect(finish).toBeDefined())
|
||||
let decision: boolean | undefined
|
||||
const pending = shouldSuppressForegroundPush(push).then((value) => {
|
||||
decision = value
|
||||
return value
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const beforeScheduleFinished = decision
|
||||
finish('local')
|
||||
expect(await pending).toBe(true)
|
||||
expect(beforeScheduleFinished).toBeUndefined()
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets the push deliver if the in-flight local schedule fails', async () => {
|
||||
let fail!: (error: Error) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
)
|
||||
const receive = await socket()
|
||||
receive(event)
|
||||
await vi.waitFor(() => expect(fail).toBeDefined())
|
||||
const pending = shouldSuppressForegroundPush(push)
|
||||
fail(new Error('native scheduling failed'))
|
||||
expect(await pending).toBe(false)
|
||||
})
|
||||
|
||||
it('dismisses the tray while an earlier socket show is still waiting for foreground', async () => {
|
||||
const { AppState } = await import('react-native')
|
||||
let activate!: (state: string) => void
|
||||
Object.assign(AppState, {
|
||||
currentState: 'background',
|
||||
addEventListener: (_name: string, callback: typeof activate) => {
|
||||
activate = callback
|
||||
return { remove: () => {} }
|
||||
}
|
||||
})
|
||||
const receive = await socket()
|
||||
receive(event)
|
||||
await vi.waitFor(() => expect(activate).toBeDefined())
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValueOnce([
|
||||
{ request: { identifier: 'remote-alert', content: { data: push } } }
|
||||
] as never)
|
||||
receive({ ...event, type: 'dismiss', notificationSeq: 2 })
|
||||
await vi.waitFor(() =>
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('remote-alert')
|
||||
)
|
||||
AppState.currentState = 'active'
|
||||
activate('active')
|
||||
})
|
||||
it('rechecks dismissal while push waits behind another socket show', async () => {
|
||||
const { AppState } = await import('react-native')
|
||||
AppState.currentState = 'active'
|
||||
let finish!: (id: string) => void
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const receive = await socket()
|
||||
receive({ ...event, notificationId: 'other', notificationSeq: 10 })
|
||||
await vi.waitFor(() => expect(finish).toBeDefined())
|
||||
const tail = getHostNotificationSession('host').deliveryTail
|
||||
const pending = shouldSuppressForegroundPush({
|
||||
orca: { ...event, notificationSeq: 11, hostFingerprint: 'abcdefghijklmnop' }
|
||||
})
|
||||
await vi.waitFor(() => expect(getHostNotificationSession('host').deliveryTail).not.toBe(tail))
|
||||
await shouldSuppressForegroundPush({
|
||||
orca: { ...event, kind: 'dismiss', notificationSeq: 12, hostFingerprint: 'abcdefghijklmnop' }
|
||||
})
|
||||
finish('other-local')
|
||||
expect(await pending).toBe(true)
|
||||
})
|
||||
|
||||
it('dismiss arriving during socket handoff cannot resurrect local banner', async () => {
|
||||
const { AppState } = await import('react-native')
|
||||
AppState.currentState = 'active'
|
||||
const receive = await socket()
|
||||
await getHostNotificationSession('host').deliveryTail
|
||||
let finishTray!: (v: never[]) => void
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishTray = resolve
|
||||
})
|
||||
)
|
||||
receive({ ...event, notificationId: 'handoff', notificationSeq: 20 })
|
||||
await vi.waitFor(() => expect(finishTray).toBeDefined())
|
||||
const trayReads = vi.mocked(Notifications.getPresentedNotificationsAsync).mock.calls.length
|
||||
receive({ ...event, type: 'dismiss', notificationId: 'handoff', notificationSeq: 21 })
|
||||
await vi.waitFor(() =>
|
||||
expect(Notifications.getPresentedNotificationsAsync).toHaveBeenCalledTimes(trayReads + 1)
|
||||
)
|
||||
finishTray([])
|
||||
await vi.waitFor(() => expect(getHostNotificationSession('host').lastDeliveredSeq).toBe(21))
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { readOrcaPushPayload } from './push-payload'
|
||||
import { deriveHostFingerprint } from './push-host-fingerprint'
|
||||
import { dismissPresentedPushNotification } from './push-tray-dismissal'
|
||||
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
|
||||
import { wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { areLegacySummaryPushesDismissed } from './push-dismissal-watermarks'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
@@ -51,10 +51,10 @@ beforeEach(() => {
|
||||
it('preserves partially handled summaries and clears only fully handled membership', async () => {
|
||||
await dismissPresentedPushNotification(members[0]!.notificationId, hostFingerprint, members[0])
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
|
||||
expect(await wasPushDismissed(summary)).toBe(false)
|
||||
expect(await areLegacySummaryPushesDismissed(summary)).toBe(false)
|
||||
await dismissPresentedPushNotification(members[1]!.notificationId, hostFingerprint, members[1])
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('summary')
|
||||
expect(await wasPushDismissed(summary)).toBe(true)
|
||||
expect(await areLegacySummaryPushesDismissed(summary)).toBe(true)
|
||||
})
|
||||
it('reconciles every summary member, preserving a summary containing a newer unread event', async () => {
|
||||
const sendRequest = vi.fn(async () => ({
|
||||
@@ -87,7 +87,9 @@ it('accepts APNs arrays and FCM JSON strings but never treats incomplete or malf
|
||||
]) {
|
||||
expect(readOrcaPushPayload({ ...summary, summaryMembers: bad })?.summaryMembers).toBeUndefined()
|
||||
}
|
||||
expect(await wasPushDismissed({ ...summary, summaryMembers: members.slice(0, 1) })).toBe(false)
|
||||
expect(
|
||||
await areLegacySummaryPushesDismissed({ ...summary, summaryMembers: members.slice(0, 1) })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('pages summary identities without replaying history twice or trusting identities from another page', async () => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export function readSummaryMembers(
|
||||
}
|
||||
|
||||
export function representedPushes(payload: OrcaPushPayload): OrcaPushPayload[] {
|
||||
// Gateway summaries are no longer generated; this bounded reader exists only
|
||||
// for notifications already sitting in a user's tray during the transition.
|
||||
if ((payload.coalescedCount ?? 0) <= 1) {
|
||||
return [payload]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { representedPushes } from './push-summary-members'
|
||||
import { readNativeNotificationData } from './native-notification-data'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
|
||||
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import {
|
||||
areLegacySummaryPushesDismissed,
|
||||
rememberPushDismissal,
|
||||
wasPushDismissed
|
||||
} from './push-dismissal-watermarks'
|
||||
|
||||
async function dismissMatchingPresentedPushes(
|
||||
matches: (payload: OrcaPushPayload) => boolean | Promise<boolean>
|
||||
@@ -68,7 +72,7 @@ export async function dismissPresentedPushNotification(
|
||||
return false
|
||||
}
|
||||
if ((payload.coalescedCount ?? 0) > 1) {
|
||||
return wasPushDismissed(payload)
|
||||
return areLegacySummaryPushesDismissed(payload)
|
||||
}
|
||||
return (
|
||||
payload.notificationId === notificationId &&
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import {
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests
|
||||
} from './notification-reconnect-catchup'
|
||||
import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
|
||||
|
||||
vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: vi.fn() }))
|
||||
|
||||
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
|
||||
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
|
||||
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
|
||||
|
||||
const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
|
||||
|
||||
function presented(orca: Record<string, unknown>): unknown {
|
||||
const identifier = `tray-${String(orca.notificationId ?? 'bell')}`
|
||||
return { request: { identifier, content: { data: { orca } } } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetHostNotificationSessionsForTests()
|
||||
vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
|
||||
})
|
||||
|
||||
describe('readPresentedPushSeenKeys', () => {
|
||||
it('keys the tray entries the gateway pushed for this host', async () => {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
|
||||
presented({ hostFingerprint, notificationId: 'agent:one', notificationSeq: 6 }),
|
||||
presented({ hostFingerprint, notificationSeq: 7 })
|
||||
] as never)
|
||||
|
||||
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([
|
||||
{ key: 'id:agent:one#6', epoch: undefined },
|
||||
{ key: 'seq:7', epoch: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a tray entry belonging to another paired host', async () => {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
|
||||
presented({ hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' })
|
||||
] as never)
|
||||
|
||||
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a coalesced summary, whose key names a banner nobody has seen', async () => {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
|
||||
presented({
|
||||
hostFingerprint,
|
||||
notificationId: 'agent:one',
|
||||
notificationSeq: 6,
|
||||
coalescedCount: 3
|
||||
})
|
||||
] as never)
|
||||
|
||||
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a locally scheduled notification, which the socket path already owns', async () => {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
|
||||
{ request: { identifier: 'tray-1', content: { data: { hostId: 'host-1' } } } }
|
||||
] as never)
|
||||
|
||||
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
|
||||
expect(loadHostCatalog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent on a native shell that cannot query the tray', async () => {
|
||||
vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
|
||||
new Error('unavailable')
|
||||
)
|
||||
|
||||
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('markPresentedPushesSeen', () => {
|
||||
it('claims the keys without touching the watermark', () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
|
||||
markPresentedPushesSeen(session, [{ key: 'id:agent:one#9', epoch: 'epoch-1' }])
|
||||
|
||||
expect(session.seen.has('id:agent:one#9')).toBe(true)
|
||||
// A push seq proves one event was shown, not that everything below it was.
|
||||
expect(session.lastDeliveredSeq).toBe(0)
|
||||
})
|
||||
|
||||
it('drops a key that names no counter lifetime at all', () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-1'
|
||||
|
||||
markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: undefined }])
|
||||
|
||||
// The desktop always sends an epoch; a key without one cannot be shown to belong
|
||||
// to this counter, and claiming it would drop the real bell at seq 4.
|
||||
expect(session.seen.has('seq:4')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a key from a desktop lifetime that has already been retired', () => {
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredEpoch = 'epoch-2'
|
||||
|
||||
markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: 'epoch-1' }])
|
||||
|
||||
// The new counter re-issues seq 4, so the stale key would drop a real bell.
|
||||
expect(session.seen.has('seq:4')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { wasPushDismissed } from './push-dismissal-watermarks'
|
||||
import { readNativeNotificationData } from './native-notification-data'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import { seenKeyForEvent, type HostNotificationSession } from './notification-reconnect-catchup'
|
||||
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
|
||||
import { readOrcaPushPayload } from './push-payload'
|
||||
|
||||
/**
|
||||
* Dedup keys for the pushes the OS has already drawn for one host.
|
||||
*
|
||||
* Why this exists: a push shown while Orca was closed never ran through the
|
||||
* foreground handler, so nothing in this process claimed its key. The reconnect
|
||||
* catch-up then replays that same event and shows a second banner for it.
|
||||
*
|
||||
*/
|
||||
export type PresentedPushSeenKey = { readonly key: string; readonly epoch: string | undefined }
|
||||
|
||||
export async function readPresentedPushSeenKeys(
|
||||
hostId: string
|
||||
): Promise<readonly PresentedPushSeenKey[]> {
|
||||
try {
|
||||
const presented = await Notifications.getPresentedNotificationsAsync()
|
||||
if (presented.length === 0) {
|
||||
return []
|
||||
}
|
||||
const hosts = await loadHostCatalog().catch(() => [])
|
||||
const keys: PresentedPushSeenKey[] = []
|
||||
for (const notification of presented) {
|
||||
const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
|
||||
// A coalesced summary stands in for N events while carrying only the latest
|
||||
// one's fields, so its key belongs to a banner the user has NOT seen.
|
||||
if (!payload || (payload.coalescedCount ?? 0) > 1) {
|
||||
continue
|
||||
}
|
||||
if (resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) {
|
||||
continue
|
||||
}
|
||||
if (await wasPushDismissed(payload)) {
|
||||
await Notifications.dismissNotificationAsync(notification.request.identifier).catch(
|
||||
() => {}
|
||||
)
|
||||
continue
|
||||
}
|
||||
const key = seenKeyForEvent(payload)
|
||||
if (key) {
|
||||
keys.push({ key, epoch: payload.notificationEpoch })
|
||||
}
|
||||
}
|
||||
return keys
|
||||
} catch {
|
||||
// Older native shells lack the tray query; the catch-up replays as it did before.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the tray's keys on the session, skipping any that do not name the live
|
||||
* counter lifetime. A push without an epoch cannot be tied to this counter, and
|
||||
* the desktop always sends one, so it is left unclaimed rather than allowed to
|
||||
* swallow a real event at the same seq.
|
||||
*
|
||||
* The watermark is deliberately untouched: a push seq proves one event was shown,
|
||||
* not that everything below it was, and advancing past a gap would make the desktop
|
||||
* cut the notifications in it forever.
|
||||
*/
|
||||
export function markPresentedPushesSeen(
|
||||
session: HostNotificationSession,
|
||||
keys: readonly PresentedPushSeenKey[]
|
||||
): void {
|
||||
for (const { key, epoch } of keys) {
|
||||
if (epoch == null || epoch !== session.lastDeliveredEpoch) {
|
||||
continue
|
||||
}
|
||||
session.seen.add(key)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { wasHostPushDismissed } from './push-socket-dismissal'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { AppState } from 'react-native'
|
||||
import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
|
||||
import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
|
||||
import { loadRemotePushEnabled } from '../storage/preferences'
|
||||
import { seenKeyForEvent } from './notification-reconnect-catchup'
|
||||
|
||||
vi.mock('./push-socket-dismissal', () => ({ wasHostPushDismissed: vi.fn(async () => false) }))
|
||||
|
||||
let active: ((state: string) => void) | undefined
|
||||
const remove = vi.fn()
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
currentState: 'background',
|
||||
addEventListener: vi.fn((_event, callback) => {
|
||||
active = callback
|
||||
return { remove }
|
||||
})
|
||||
}
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadRemotePushEnabled: vi.fn(async () => true),
|
||||
loadRemotePushHostRegistrations: vi.fn(async () => ({ registeredHostIds: ['host'] }))
|
||||
}))
|
||||
vi.mock('./push-tray-seen-seed', () => ({ readPresentedPushSeenKeys: vi.fn(async () => []) }))
|
||||
const event = {
|
||||
type: 'notification' as const,
|
||||
source: 'agent-task-complete' as const,
|
||||
title: 'Done',
|
||||
body: '',
|
||||
notificationId: 'done',
|
||||
notificationSeq: 1,
|
||||
notificationEpoch: 'epoch'
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
active = undefined
|
||||
AppState.currentState = 'background'
|
||||
})
|
||||
|
||||
it('waits for foreground and suppresses a live socket event already delivered by APNs', async () => {
|
||||
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([
|
||||
{ key: seenKeyForEvent(event)!, epoch: 'epoch' }
|
||||
])
|
||||
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
|
||||
await vi.waitFor(() => expect(active).toBeDefined())
|
||||
expect(readPresentedPushSeenKeys).not.toHaveBeenCalled()
|
||||
AppState.currentState = 'active'
|
||||
active?.('active')
|
||||
expect(await delivery).toBe(false)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to local delivery on foreground when no provider notification arrived', async () => {
|
||||
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([])
|
||||
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
|
||||
await vi.waitFor(() => expect(active).toBeDefined())
|
||||
AppState.currentState = 'active'
|
||||
active?.('active')
|
||||
expect(await delivery).toBe(true)
|
||||
})
|
||||
|
||||
it('releases the background wait when the subscription is disposed', async () => {
|
||||
const controller = new AbortController()
|
||||
const delivery = waitForSocketPushHandoff(event, 'host', controller.signal)
|
||||
await vi.waitFor(() => expect(active).toBeDefined())
|
||||
controller.abort()
|
||||
expect(await delivery).toBe(false)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps local background delivery when remote push is disabled', async () => {
|
||||
vi.mocked(loadRemotePushEnabled).mockResolvedValueOnce(false)
|
||||
expect(await waitForSocketPushHandoff(event, 'host', new AbortController().signal)).toBe(true)
|
||||
expect(active).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves hosts without a registered push token on local delivery', async () => {
|
||||
expect(
|
||||
await waitForSocketPushHandoff(event, 'unregistered-host', new AbortController().signal)
|
||||
).toBe(true)
|
||||
expect(active).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not resurrect an alert dismissed while its handoff waited for foreground', async () => {
|
||||
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([])
|
||||
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
|
||||
await vi.waitFor(() => expect(active).toBeDefined())
|
||||
vi.mocked(wasHostPushDismissed).mockResolvedValueOnce(true)
|
||||
AppState.currentState = 'active'
|
||||
active?.('active')
|
||||
expect(await delivery).toBe(false)
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { wasHostPushDismissed } from './push-socket-dismissal'
|
||||
import { AppState } from 'react-native'
|
||||
import { loadRemotePushEnabled, loadRemotePushHostRegistrations } from '../storage/preferences'
|
||||
import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
|
||||
import { seenKeyForEvent } from './notification-reconnect-catchup'
|
||||
import type { NotificationEvent } from './local-notification-scheduling'
|
||||
|
||||
function waitUntilActive(signal: AbortSignal): Promise<void> {
|
||||
if (AppState.currentState === 'active' || signal.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
subscription.remove()
|
||||
signal.removeEventListener('abort', finish)
|
||||
resolve()
|
||||
}
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
signal.addEventListener('abort', finish, { once: true })
|
||||
if (signal.aborted || AppState.currentState === 'active') {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForSocketPushHandoff(
|
||||
event: NotificationEvent,
|
||||
hostId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
if (!(await loadRemotePushEnabled())) {
|
||||
return true
|
||||
}
|
||||
const registrations = await loadRemotePushHostRegistrations()
|
||||
if (!registrations.registeredHostIds.includes(hostId)) {
|
||||
return true
|
||||
}
|
||||
// iOS can keep the socket alive while backgrounded; let APNs own that interval.
|
||||
await waitUntilActive(signal)
|
||||
if (signal.aborted || (await wasHostPushDismissed(event, hostId))) {
|
||||
return false
|
||||
}
|
||||
const key = seenKeyForEvent(event)
|
||||
const presented = await readPresentedPushSeenKeys(hostId)
|
||||
return !presented.some((push) => push.key === key && push.epoch === event.notificationEpoch)
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import { notifyNotificationConsentChanged } from '../notifications/notification-consent-events'
|
||||
import {
|
||||
loadNotificationDeliveryPreferences,
|
||||
notificationPreferencesFilter,
|
||||
saveNotificationDeliveryPreferences
|
||||
notificationPreferencesFilter
|
||||
} from '../notifications/notification-delivery-preferences'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
MOBILE_PUSH_AGENT_STATES,
|
||||
type MobilePushAgentState,
|
||||
type MobilePushFilter
|
||||
} from '../../../src/shared/mobile-push-contract'
|
||||
import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract'
|
||||
|
||||
const PINS_PREFIX = 'orca:pins:'
|
||||
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
|
||||
@@ -45,11 +40,8 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise<vo
|
||||
|
||||
// Retained for older mobile builds; the master preference owns both delivery paths.
|
||||
const REMOTE_PUSH_KEY = 'orca:remotePushEnabled'
|
||||
const REMOTE_PUSH_AGENT_STATES_KEY = 'orca:remotePushAgentStates'
|
||||
const REMOTE_PUSH_HOST_REGISTRATIONS_KEY = 'orca:remotePushHostRegistrations'
|
||||
|
||||
// The host and phone share the same source and agent-state vocabulary.
|
||||
export type RemotePushAgentState = MobilePushAgentState
|
||||
export type RemotePushFilter = MobilePushFilter
|
||||
|
||||
export async function loadRemotePushEnabled(): Promise<boolean> {
|
||||
@@ -68,35 +60,6 @@ export async function saveRemotePushEnabled(enabled: boolean): Promise<void> {
|
||||
await savePushNotificationsEnabled(enabled)
|
||||
}
|
||||
|
||||
function remotePushAgentStates(value: unknown): RemotePushAgentState[] {
|
||||
return stringArray(value).filter((state): state is RemotePushAgentState =>
|
||||
(MOBILE_PUSH_AGENT_STATES as readonly string[]).includes(state)
|
||||
)
|
||||
}
|
||||
|
||||
// Both states default on; an absent key is a device that never opened the section.
|
||||
export async function loadRemotePushAgentStates(): Promise<readonly RemotePushAgentState[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(REMOTE_PUSH_AGENT_STATES_KEY)
|
||||
return raw === null ? MOBILE_PUSH_AGENT_STATES : remotePushAgentStates(JSON.parse(raw))
|
||||
} catch {
|
||||
return MOBILE_PUSH_AGENT_STATES
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRemotePushAgentStates(
|
||||
states: readonly RemotePushAgentState[]
|
||||
): Promise<void> {
|
||||
const current = await loadNotificationDeliveryPreferences()
|
||||
await saveNotificationDeliveryPreferences({
|
||||
...current,
|
||||
followDesktop: false,
|
||||
taskFinished: states.includes('finished'),
|
||||
needsInput: states.includes('needs-input')
|
||||
})
|
||||
await AsyncStorage.setItem(REMOTE_PUSH_AGENT_STATES_KEY, JSON.stringify([...states]))
|
||||
}
|
||||
|
||||
export async function loadRemotePushFilter(): Promise<RemotePushFilter> {
|
||||
return notificationPreferencesFilter(await loadNotificationDeliveryPreferences())
|
||||
}
|
||||
|
||||
@@ -2,92 +2,46 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const removeHostMock = vi.hoisted(() => vi.fn())
|
||||
const unregisterPushMock = vi.hoisted(() => vi.fn(async () => {}))
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
// Why removeItem is here: clearWatermark() swallows its own failures, so a mock
|
||||
// missing this method turns the persisted-watermark cleanup into a caught
|
||||
// TypeError — the assertion below would pass even if the call were deleted.
|
||||
removeItem: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
|
||||
vi.mock('./host-store', () => ({
|
||||
removeHost: (hostId: string) => removeHostMock(hostId)
|
||||
}))
|
||||
|
||||
// Why mocked: the real module reaches expo-notifications for the device token, which
|
||||
// no node test environment can load.
|
||||
vi.mock('../notifications/push-registration', () => ({
|
||||
unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(hostId)
|
||||
}))
|
||||
|
||||
import { removeHostAndCloseClient } from './host-removal-lifecycle'
|
||||
import {
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests
|
||||
} from '../notifications/notification-reconnect-catchup'
|
||||
|
||||
describe('host removal lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
removeHostMock.mockReset()
|
||||
unregisterPushMock.mockClear()
|
||||
asyncStorage.removeItem.mockClear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
it('closes the client only after metadata removal commits', async () => {
|
||||
let commitRemoval: (() => void) | null = null
|
||||
removeHostMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
commitRemoval = resolve
|
||||
})
|
||||
)
|
||||
removeHostMock.mockReturnValue(new Promise<void>((resolve) => (commitRemoval = resolve)))
|
||||
const closeHostClient = vi.fn()
|
||||
|
||||
const removal = removeHostAndCloseClient('host-1', closeHostClient)
|
||||
expect(closeHostClient).not.toHaveBeenCalled()
|
||||
commitRemoval?.()
|
||||
await removal
|
||||
|
||||
expect(closeHostClient).toHaveBeenCalledWith('host-1')
|
||||
})
|
||||
|
||||
it('keeps the client open when metadata removal fails', async () => {
|
||||
removeHostMock.mockRejectedValue(new Error('storage unavailable'))
|
||||
const closeHostClient = vi.fn()
|
||||
|
||||
await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
|
||||
'storage unavailable'
|
||||
)
|
||||
expect(closeHostClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retires the notification session so a removed host leaves nothing behind', async () => {
|
||||
// Round-1 review finding: the session lives at module scope (it must survive the
|
||||
// subscription teardown a reconnect performs), so removal is the only thing that
|
||||
// can retire it. Left behind, each remove/re-pair cycle strands a session plus up
|
||||
// to 512 seen keys, and a re-paired host inherits a watermark it never earned.
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredSeq = 42
|
||||
session.lastDeliveredEpoch = 'epoch-A'
|
||||
|
||||
await removeHostAndCloseClient('host-1', vi.fn())
|
||||
|
||||
// A fresh session for the same id — not the retained one.
|
||||
const afterRemoval = getHostNotificationSession('host-1')
|
||||
expect(afterRemoval).not.toBe(session)
|
||||
expect(afterRemoval.lastDeliveredSeq).toBe(0)
|
||||
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the gateway push registration before the credentials it needs are gone', async () => {
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeHostAndCloseClient('host-1', vi.fn())
|
||||
|
||||
expect(unregisterPushMock).toHaveBeenCalledWith('host-1')
|
||||
expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
removeHostMock.mock.invocationCallOrder[0]
|
||||
@@ -98,23 +52,7 @@ describe('host removal lifecycle', () => {
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
unregisterPushMock.mockRejectedValueOnce(new Error('socket closed'))
|
||||
const closeHostClient = vi.fn()
|
||||
|
||||
await removeHostAndCloseClient('host-1', closeHostClient)
|
||||
|
||||
expect(closeHostClient).toHaveBeenCalledWith('host-1')
|
||||
})
|
||||
|
||||
it('erases the persisted watermark, not just the in-memory session', async () => {
|
||||
// Why separately from the test above: the session is process-local, the
|
||||
// watermark is not. Retiring only the session lets a re-pair of the same host
|
||||
// read the old seq off disk and resume against a counter it never saw — the
|
||||
// catch-up would then start above the real cut and drop everything below it.
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeHostAndCloseClient('host-1', vi.fn())
|
||||
// clearWatermark is fire-and-forget; let its microtask land.
|
||||
await Promise.resolve()
|
||||
|
||||
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
clearWatermark,
|
||||
forgetHostNotificationSession
|
||||
} from '../notifications/notification-reconnect-catchup'
|
||||
import { unregisterPushForRemovedHost } from '../notifications/push-registration'
|
||||
import { removeHost } from './host-store'
|
||||
|
||||
@@ -16,9 +12,4 @@ export async function removeHostAndCloseClient(
|
||||
// storage failure; closing immediately after success prevents socket leaks.
|
||||
await removeHost(hostId)
|
||||
forgetHostClient(hostId)
|
||||
// Why: the notification session outlives the socket by design (it must survive
|
||||
// reconnects), so removal is the only thing that can retire it. Left behind, a
|
||||
// re-pair of the same host would inherit a watermark for a counter it never saw.
|
||||
forgetHostNotificationSession(hostId)
|
||||
void clearWatermark(hostId)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('PushDispatcher', () => {
|
||||
expect(harness.sends).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('applies each device filter independently', async () => {
|
||||
it('ignores obsolete category filters on existing registrations', async () => {
|
||||
const harness = createHarness({
|
||||
devices: [
|
||||
{
|
||||
@@ -127,10 +127,10 @@ describe('PushDispatcher', () => {
|
||||
harness.dispatcher.enqueue(notification({ agentState: 'blocked' }))
|
||||
await flush()
|
||||
|
||||
expect(harness.sends[0]?.registrationIds).toEqual(['reg-needs', 'reg-all'])
|
||||
expect(harness.sends[0]?.registrationIds).toEqual(['reg-needs', 'reg-bell', 'reg-all'])
|
||||
})
|
||||
|
||||
it('pushes a bell to a device that filtered agent states out', async () => {
|
||||
it('pushes a desktop-eligible bell despite an obsolete empty agent-state filter', async () => {
|
||||
const harness = createHarness({
|
||||
devices: [
|
||||
{
|
||||
|
||||
@@ -3,11 +3,14 @@ import { reserveNotificationCooldown } from '../../../shared/notification-burst-
|
||||
// already went to connected sockets is offered to the push gateway so a phone
|
||||
// with Orca closed still hears about it. Fire-and-forget by construction: the
|
||||
// socket fan-out must never wait on, or fail because of, a push.
|
||||
import type { MobilePushRegistration } from '../../../shared/mobile-push-contract'
|
||||
import { PushOutcomeCounters } from './push-outcome-counters'
|
||||
import { MOBILE_PUSH_SOURCES } from '../../../shared/mobile-push-contract'
|
||||
import {
|
||||
MOBILE_PUSH_SOURCES,
|
||||
type MobilePushAgentState,
|
||||
type MobilePushRegistration
|
||||
} from '../../../shared/mobile-push-contract'
|
||||
import type { MobileNotificationEvent } from '../runtime-mobile-notification-controller'
|
||||
import type { PushGatewayClient, PushSendNotification } from './push-gateway-client'
|
||||
import { PushOutcomeCounters } from './push-outcome-counters'
|
||||
|
||||
const PUSH_RETRY_DELAY_MS = 2_000
|
||||
// The gateway rejects a whole request above this, so a host with more paired
|
||||
@@ -35,11 +38,29 @@ function clip(value: string, maxLength: number): string {
|
||||
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`
|
||||
}
|
||||
|
||||
export { mapPushAgentState } from '../../../shared/mobile-notification-policy'
|
||||
import {
|
||||
allowsMobileNotification,
|
||||
mapPushAgentState
|
||||
} from '../../../shared/mobile-notification-policy'
|
||||
export function mapPushAgentState(
|
||||
source: string,
|
||||
state: string | undefined
|
||||
): MobilePushAgentState | null | undefined {
|
||||
if (source !== 'agent-task-complete') {
|
||||
return null
|
||||
}
|
||||
if (state === 'blocked' || state === 'waiting' || state === 'needs-input') {
|
||||
return 'needs-input'
|
||||
}
|
||||
return state === undefined || state === 'done' || state === 'finished' ? 'finished' : undefined
|
||||
}
|
||||
|
||||
function allowsPushDelivery(
|
||||
registration: MobilePushRegistration,
|
||||
event: MobileNotificationEvent
|
||||
): boolean {
|
||||
return (
|
||||
event.type === 'notification' &&
|
||||
event.desktopAllowed !== false &&
|
||||
(!registration.filter.onlyWhenDesktopAway || event.desktopAway !== false)
|
||||
)
|
||||
}
|
||||
|
||||
export class PushDispatcher {
|
||||
private readonly recentNotifications = new Map<string, number>()
|
||||
@@ -139,7 +160,7 @@ export class PushDispatcher {
|
||||
if (
|
||||
!registration ||
|
||||
(registration.expiresAt !== undefined && registration.expiresAt <= Date.now()) ||
|
||||
!allowsMobileNotification(registration.filter, event)
|
||||
!allowsPushDelivery(registration, event)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { readDesktopAwayState } from '../../notifications/desktop-away-state'
|
||||
import { allowsMobileNotification } from '../../../shared/mobile-notification-policy'
|
||||
import { DeviceRegistry } from '../device-registry'
|
||||
import { RuntimeMobileNotificationController } from '../runtime-mobile-notification-controller'
|
||||
import { setRuntimeDesktopSurface } from '../runtime-desktop-surface'
|
||||
@@ -76,7 +75,7 @@ async function pipeline() {
|
||||
return { path, registry, device, controller, client, register, dispatch }
|
||||
}
|
||||
|
||||
it('carries the native idle boundary through replay, socket policy and push dispatch', async () => {
|
||||
it('carries the native idle boundary through replay and push dispatch', async () => {
|
||||
let idle = 179
|
||||
setRuntimeDesktopSurface({
|
||||
isAwayForMobileNotifications: () =>
|
||||
@@ -90,12 +89,6 @@ it('carries the native idle boundary through replay, socket policy and push disp
|
||||
removeIpcListener: () => {}
|
||||
})
|
||||
const h = await pipeline()
|
||||
const socketVerdicts: boolean[] = []
|
||||
h.controller.onDispatched((event) => {
|
||||
if (event.type === 'notification') {
|
||||
socketVerdicts.push(allowsMobileNotification(filter, JSON.parse(JSON.stringify(event))))
|
||||
}
|
||||
})
|
||||
h.dispatch()
|
||||
await flush()
|
||||
expect(h.client.send).not.toHaveBeenCalled()
|
||||
@@ -107,12 +100,11 @@ it('carries the native idle boundary through replay, socket policy and push disp
|
||||
h.dispatch()
|
||||
await flush()
|
||||
expect(h.client.send).toHaveBeenCalledTimes(1)
|
||||
expect(socketVerdicts).toEqual([false, true, false])
|
||||
const replay = h.controller.getMissedSince(0)
|
||||
expect(replay).toHaveLength(3)
|
||||
expect(
|
||||
replay.map((event) => allowsMobileNotification(filter, JSON.parse(JSON.stringify(event))))
|
||||
).toEqual([false, true, false])
|
||||
expect(replay.map((event) => (event.type === 'notification' ? event.desktopAway : null))).toEqual(
|
||||
[false, true, false]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps headless presence unknown and legacy socket events readable', async () => {
|
||||
@@ -124,12 +116,6 @@ it('keeps headless presence unknown and legacy socket events readable', async ()
|
||||
await flush()
|
||||
expect(events[0]).not.toHaveProperty('desktopAway')
|
||||
expect(h.client.send).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
allowsMobileNotification(filter, {
|
||||
source: 'agent-task-complete',
|
||||
agentState: 'done'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('expires persisted registration at seven days despite host activity and renews explicitly', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseMobilePushRegistration } from '../../../shared/mobile-push-contract'
|
||||
import { createHarness, notification, registration, flush } from './push-dispatcher.test-fixture'
|
||||
|
||||
it('routes a desktop-disabled bell only to a phone that independently permits bells', async () => {
|
||||
it('applies current desktop category eligibility over every persisted phone filter', async () => {
|
||||
const filter = registration().filter
|
||||
const harness = createHarness({
|
||||
devices: [
|
||||
@@ -21,17 +22,24 @@ it('routes a desktop-disabled bell only to a phone that independently permits be
|
||||
},
|
||||
{
|
||||
deviceId: 'no-bells',
|
||||
pushRegistration: registration({
|
||||
registrationId: 'no-bells',
|
||||
filter: { ...filter, followDesktop: false, sources: ['agent-task-complete'] }
|
||||
})
|
||||
pushRegistration: parseMobilePushRegistration(
|
||||
registration({
|
||||
registrationId: 'no-bells',
|
||||
filter: { ...filter, followDesktop: false, sources: ['agent-task-complete'] }
|
||||
})
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: false }))
|
||||
await flush()
|
||||
expect(harness.sends).toHaveLength(1)
|
||||
expect(harness.sends[0]).toMatchObject({
|
||||
expect(harness.sends).toHaveLength(0)
|
||||
|
||||
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: true }))
|
||||
await flush()
|
||||
expect(harness.sends).toHaveLength(2)
|
||||
expect(harness.sends[0]).toMatchObject({ registrationIds: ['mirror', 'no-bells'] })
|
||||
expect(harness.sends[1]).toMatchObject({
|
||||
registrationIds: ['override'],
|
||||
notification: { sound: false }
|
||||
})
|
||||
@@ -61,7 +69,7 @@ it('keeps sound preferences separate when several phones receive the same event'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies burst suppression after each phone filters event types', async () => {
|
||||
it('applies burst suppression independently to each eligible phone', async () => {
|
||||
const harness = createHarness({
|
||||
devices: [
|
||||
{
|
||||
@@ -83,5 +91,5 @@ it('applies burst suppression after each phone filters event types', async () =>
|
||||
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', emittedAt: 10000 }))
|
||||
harness.dispatcher.enqueue(notification({ emittedAt: 10250 }))
|
||||
await flush()
|
||||
expect(harness.sends.map((send) => send.registrationIds)).toEqual([['all'], ['no-bells']])
|
||||
expect(harness.sends.map((send) => send.registrationIds)).toEqual([['all', 'no-bells']])
|
||||
})
|
||||
|
||||
@@ -47,9 +47,8 @@ const NotificationGetMissedSinceParams = z.object({
|
||||
.optional()
|
||||
})
|
||||
|
||||
// Why: the phone owns which alerts are worth waking it for; the host stores the
|
||||
// filter per device and applies it before it ever calls the gateway. Native push
|
||||
// tokens are long (FCM registration strings), so the bound is generous.
|
||||
// Category fields remain required for older peers, but updated hosts use only
|
||||
// phone-specific away, expiry, and sound preferences from this filter.
|
||||
const NotificationPushFilterParams = z.object({
|
||||
onlyWhenDesktopAway: z.boolean().optional(),
|
||||
expireAfterInactivity: z.boolean().optional(),
|
||||
@@ -75,10 +74,8 @@ const NotificationRegisterPushParams = z
|
||||
message: 'apnsEnvironment is required for ios'
|
||||
})
|
||||
|
||||
// Why: notifications.subscribe streams desktop notification events to mobile
|
||||
// clients over WebSocket. The mobile client shows a local push notification
|
||||
// for each event. This avoids requiring Firebase/APNs — the existing
|
||||
// persistent WebSocket connection doubles as the push channel.
|
||||
// The socket stream keeps mobile app state and dismissal reconciliation live;
|
||||
// native push is the only route that presents ordinary mobile OS alerts.
|
||||
export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineStreamingMethod({
|
||||
name: 'notifications.subscribe',
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { allowsMobileNotification } from './mobile-notification-policy'
|
||||
import {
|
||||
MOBILE_PUSH_SOURCES,
|
||||
MOBILE_PUSH_AGENT_STATES,
|
||||
parseMobilePushRegistration
|
||||
} from './mobile-push-contract'
|
||||
|
||||
describe('notification delivery preferences', () => {
|
||||
const filter = { sources: MOBILE_PUSH_SOURCES, agentStates: MOBILE_PUSH_AGENT_STATES }
|
||||
it.each(['agent-task-complete', 'terminal-bell', 'plugin'])(
|
||||
'mirrors desktop settings for %s, but permits an explicit override',
|
||||
(source) => {
|
||||
const event = { source, desktopAllowed: false }
|
||||
expect(allowsMobileNotification(filter, event)).toBe(false)
|
||||
expect(allowsMobileNotification({ ...filter, followDesktop: true }, event)).toBe(false)
|
||||
expect(allowsMobileNotification({ ...filter, followDesktop: false }, event)).toBe(true)
|
||||
expect(allowsMobileNotification(filter, { source })).toBe(true)
|
||||
}
|
||||
)
|
||||
it('keeps bells independent of agent states and supports disabling them', () => {
|
||||
expect(
|
||||
allowsMobileNotification({ ...filter, agentStates: [] }, { source: 'terminal-bell' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
allowsMobileNotification(
|
||||
{ ...filter, sources: ['agent-task-complete'] },
|
||||
{ source: 'terminal-bell' }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
it.each(['working', 'unknown'])('never presents %s agent activity', (agentState) => {
|
||||
expect(allowsMobileNotification(filter, { source: 'agent-task-complete', agentState })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
it('preserves independent mode and silence through a desktop restart', () => {
|
||||
expect(
|
||||
parseMobilePushRegistration({
|
||||
registrationId: 'r',
|
||||
platform: 'ios',
|
||||
registeredAt: 1,
|
||||
filter: { ...filter, followDesktop: false, sound: false }
|
||||
})?.filter
|
||||
).toEqual({ ...filter, followDesktop: false, sound: false })
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { MobilePushAgentState, MobilePushFilter } from './mobile-push-contract'
|
||||
|
||||
export type MobileNotificationPolicyEvent = {
|
||||
source: string
|
||||
agentState?: string
|
||||
desktopAllowed?: boolean
|
||||
desktopAway?: boolean
|
||||
}
|
||||
|
||||
export function mapPushAgentState(
|
||||
source: string,
|
||||
state: string | undefined
|
||||
): MobilePushAgentState | null | undefined {
|
||||
if (source !== 'agent-task-complete') {
|
||||
return null
|
||||
}
|
||||
if (state === 'blocked' || state === 'waiting' || state === 'needs-input') {
|
||||
return 'needs-input'
|
||||
}
|
||||
return state === undefined || state === 'done' || state === 'finished' ? 'finished' : undefined
|
||||
}
|
||||
|
||||
export function allowsMobileNotification(
|
||||
filter: MobilePushFilter,
|
||||
event: MobileNotificationPolicyEvent
|
||||
): boolean {
|
||||
if (filter.onlyWhenDesktopAway && event.desktopAway === false) {
|
||||
return false
|
||||
}
|
||||
if (filter.followDesktop !== false && event.desktopAllowed === false) {
|
||||
return false
|
||||
}
|
||||
if (!filter.sources.some((source) => source === event.source)) {
|
||||
return false
|
||||
}
|
||||
const state = mapPushAgentState(event.source, event.agentState)
|
||||
return state !== undefined && (state === null || filter.agentStates.includes(state))
|
||||
}
|
||||
@@ -18,8 +18,9 @@ export type MobilePushApnsEnvironment = (typeof MOBILE_PUSH_APNS_ENVIRONMENTS)[n
|
||||
export type MobilePushFilter = {
|
||||
onlyWhenDesktopAway?: boolean
|
||||
expireAfterInactivity?: boolean
|
||||
followDesktop?: boolean
|
||||
sound?: boolean
|
||||
/** Legacy category fields retained so independently updated clients and hosts still register. */
|
||||
followDesktop?: boolean
|
||||
sources: readonly MobilePushSource[]
|
||||
agentStates: readonly MobilePushAgentState[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user