import type { NodeLifeStatus } from './web-client/shared/protocol.js'; import type { PushLastSeenStore, PushRegistryStore, PushSubscriptionRecord } from './push-registry.js'; /** The subset of `NodeSummary` the diff engine needs — kept minimal and * decoupled from the full wire type so the engine can be fed by any canvas * reader (production: `canvas snapshot`; tests: a fixture array). */ export interface CanvasNodeRow { node_id: string; parent: string | null; status: NodeLifeStatus; attention_count: number; } /** A pending human ask with just the `DeckSummary` provenance the payload/dedupe * logic needs (design: "ask provenance comes from DeckSummary"). */ export interface PendingAsk { /** Opaque, stable inbox-entry id — the dedupe key (never re-notify the same * unresolved ask). */ id: string; /** The interaction job id — carried in the payload as `askId` for the deep-link. */ jobId: string; title: string; conversationId: string; conversationTitle: string; blockedSince: string; } export type PushSendResult = 'sent' | 'gone' | 'error'; export interface PushPayload { conversationId: string; kind: 'needs-you' | 'done'; askId?: string; title: string; snippet: string; } export interface PushEngineDeps { registry: PushRegistryStore; lastSeen: PushLastSeenStore; /** Re-read the live canvas roster. */ readCanvasNodes: () => Promise | CanvasNodeRow[]; /** Re-read every currently pending ask (deck provenance). */ readPendingAsks: () => Promise | PendingAsk[]; /** Deliver one push to one subscription. Implementations classify a * service-side 404/410 as `'gone'` so the engine can prune the record. */ sendPush: (record: PushSubscriptionRecord, payload: PushPayload) => Promise; /** True iff a subscription's persisted authScope matches the canvas/user this * engine is watching (design line 252: push is sent ONLY to subscriptions * whose authScope is on the identical boundary that gates the /v1 proxy). * Required — no lenient default — so a foreign scope can never be targeted. */ isTargetScope: (authScope: string) => boolean; now?: () => number; rateWindowMs?: number; } export declare class PushDiffEngine { private readonly registry; private readonly lastSeen; private readonly readCanvasNodes; private readonly readPendingAsks; private readonly sendPush; private readonly isTargetScope; private readonly now; private readonly rateWindowMs; /** Drain-queue state: only one diff pass mutates last-seen at a time. An * invalidation arriving mid-pass sets its pending flag and is reprocessed * when the current pass finishes — no two passes race the same snapshot. */ private draining; private pendingNodes; private pendingInbox; /** Per-conversation rate-cap clock. Deliberately in-memory/process-lifetime * only (not persisted) — a restart re-seeding silently is already the * no-storm guarantee; the rate cap only smooths a live process's bursts. */ private readonly lastPushAt; /** Per-(conversationId+kind) set of subscription ids already delivered for * the CURRENT still-pending notification. A transient failure on one * subscription must never cause a later retry to re-send to subscriptions * that already got `'sent'` — this is exactly what remembers them across * passes. Cleared the moment the event fully succeeds (or has no eligible * subscriber left), since a later, distinct event reusing the same key * must start fresh. */ private readonly deliveredForKey; constructor(deps: PushEngineDeps); /** Seed last-seen from a fresh canvas+deck read and send nothing. Call once * on server start — a restart must never be an attention event. */ seed(): Promise; /** 'nodes' invalidation: re-read the canvas roster, emit a 'done' push for * every conversation root that just crossed into a done|dead|canceled * status from a non-done status. */ onNodesInvalidation(): Promise; onInboxInvalidation(): Promise; /** Serialize passes: if a pass is already running, just set the pending flag * and return — the running drain loop will pick it up. Otherwise run pending * passes until none remain. */ private drain; private runNodesPass; /** 'inbox' invalidation: re-read pending asks, emit exactly one 'needs-you' * push per conversation for its NEW (not-yet-notified) asks, coalesced. An * ask no longer present (resolved/missing) is simply dropped, never * notified. */ private runInboxPass; /** Send one push per ELIGIBLE subscription — one whose authScope is on the * watched boundary (`isTargetScope`) AND that opted in for this payload kind. * Respects the per-conversation rate cap; prunes any subscription the push * service reports gone (404/410) or whose endpoint fails the push-service * allowlist re-check. Tracks per-subscription delivery for this notification * (`deliveredForKey`) so a retry after a transient failure on one * subscription never re-sends to a subscription that already got `'sent'`. * Returns true iff last-seen may advance: false when suppressed by the rate * cap OR when a transient send error means the item must be retried next * pass. A no-eligible pass returns true (nothing to retry). */ private emit; }