/** * push/service.ts * * PushService, the seam the gateway verbs and the daemon event sources both * talk to. It owns the subscription store, the VAPID key custody, and the * delivery path, and it turns a real daemon event (an approval that needs a * decision) into a fan-out of encrypted pushes. * * Honest-degrade posture, end to end: * - No subscriptions -> `deliver()` returns an empty receipt list; nothing is * faked as sent. * - A subscription whose endpoint is gone (404/410) is pruned by the delivery * path and reported as `pruned`. * - VAPID keys are minted lazily on first real need; a daemon that never uses * push never generates or stores a key. */ import { type PushTransport } from './delivery.js'; import { PushSubscriptionStore } from './subscription-store.js'; import { VapidManager } from './vapid.js'; import type { PublicPushSubscription, PushDeliveryReceipt, PushMessage, PushNotificationCategory, PushReconcileDrift, SubscriptionKeyMaterial } from './types.js'; /** The slice of the approval broker the push delivery source subscribes to. */ export interface ApprovalSource { subscribe(listener: (approval: ApprovalNotice) => void): () => void; } /** The minimum an approval record needs to expose to become a push. */ export interface ApprovalNotice { readonly id: string; readonly status: string; readonly request?: { readonly tool?: string; readonly analysis?: { readonly summary?: string; }; } | undefined; } /** * A fleet lifecycle notice, the structural slice of a FleetEvent payload the * needs-input push source reacts to. Kept structural (not an import of the fleet * event union) so the push module stays decoupled from the runtime bus; the * composition root adapts `bus.onDomain('fleet', …)` into this source. */ export interface FleetNotice { readonly type: string; readonly nodeId: string; readonly label?: string | undefined; readonly reason?: 'approval' | 'input' | 'pick' | 'conflict' | undefined; readonly sessionId?: string | undefined; /** Node kind (agent, chain, workstream, …), the completion source's scope filter. */ readonly kind?: string | undefined; /** Terminal state on a FLEET_NODE_FINISHED notice (done/failed/killed). */ readonly state?: string | undefined; } /** The slice of a fleet event stream the needs-input push source subscribes to. */ export interface FleetNoticeSource { subscribe(listener: (notice: FleetNotice) => void): () => void; } /** Presence check: is an operator surface currently attached to this session? */ export interface NeedsInputPresence { isAttached(sessionId: string): boolean; } /** * How long a block may wait on a HUMAN before a device push is sent regardless * of attachment, and the bounded reminder cadence after that first escalation. * A process merely being attached (heartbeat / an open TUI) is presence, not a * response, only a real interaction with the ask (which clears the block: * FLEET_NODE_UNBLOCKED / FLEET_NODE_FINISHED) counts as a response and cancels * the escalation. */ export interface PushEscalationConfig { /** Grace before an unanswered block escalates to a device push (honest default ~5m). */ readonly blockedGraceMs: number; /** Interval between bounded follow-up reminders after the first escalation. */ readonly followUpIntervalMs: number; /** How many follow-up reminders may fire after the first escalation (0 = escalate once). */ readonly maxFollowUps: number; } /** Honest defaults: escalate after 5 minutes, then at most two 5-minute reminders. */ export declare const DEFAULT_PUSH_ESCALATION: PushEscalationConfig; /** * A one-shot timer seam so escalation is deterministic under test. `schedule` * returns a cancel handle; the default uses `setTimeout` with `unref()` so an * armed escalation never keeps the daemon process alive on its own. */ export interface EscalationScheduler { schedule(fn: () => void, delayMs: number): () => void; } export interface PushServiceDeps { readonly vapid: VapidManager; readonly store: PushSubscriptionStore; /** Overridable delivery transport; production uses the built-in fetch. */ readonly transport?: PushTransport | undefined; /** * Per-class silencing toggle, read LIVE at each event (the composition root * wires it to the notifications.push* config keys). Absent ⇒ every class is * on, the toggles exist to turn classes OFF, never as a prerequisite for * the fan-out to work. */ readonly isCategoryEnabled?: ((category: PushNotificationCategory) => boolean) | undefined; /** * Escalation policy for blocked-too-long asks, read LIVE at each block (the * composition root wires it to the notifications.blockedEscalation* keys). * Absent ⇒ {@link DEFAULT_PUSH_ESCALATION}. */ readonly escalation?: (() => PushEscalationConfig) | undefined; /** * Consecutive refused deliveries after which an endpoint is treated as dead, * read LIVE per delivery (the composition root wires it to * `push.subscriptions.failureThreshold`, the same key the subscription sweep * reads). Absent ⇒ the delivery module's own bound. */ readonly failureThreshold?: (() => number) | undefined; /** Timer seam for escalation; absent ⇒ real `setTimeout`-based scheduler. */ readonly scheduler?: EscalationScheduler | undefined; /** Clock seam so "how long has it waited" is deterministic under test. */ readonly now?: (() => number) | undefined; } export interface SubscribeInput { readonly principalId: string; /** Stable device identity; when present the record reconciles on it, not the endpoint. */ readonly deviceId?: string | undefined; readonly endpoint: string; readonly keys: SubscriptionKeyMaterial; } /** The result a reconcile-on-open hands back: the redacted record plus what drifted. */ export interface ReconcileOutput { readonly subscription: PublicPushSubscription; readonly drift: PushReconcileDrift; } export declare class PushService { private readonly vapid; private readonly store; private readonly transport?; private readonly failureThreshold?; /** Approval ids already pushed, so re-publishes (claim/approve) don't re-notify. */ private readonly notifiedApprovals; /** Fleet node ids already pushed as needs-input, cleared when they unblock/finish. */ private readonly notifiedNeedsInput; /** Fleet node ids already pushed as completed, a terminal state fires once. */ private readonly notifiedCompletions; /** Blocks under escalation tracking, keyed by node id; cleared on response. */ private readonly trackedBlocks; private readonly isCategoryEnabled; private readonly escalationConfig; private readonly scheduler; private readonly now; constructor(deps: PushServiceDeps); /** The public VAPID key clients subscribe with. */ getPublicKey(): Promise; subscribe(input: SubscribeInput): Promise; /** * Reconcile-on-open: store the client's CURRENT endpoint/keys for its device * identity, healing a stale record in place, and report what drifted so the * client learns whether the daemon had held an out-of-date endpoint. */ reconcile(input: SubscribeInput): Promise; listSubscriptions(principalId: string): Promise; /** Delete a subscription for a principal. False when the id was already absent. */ unsubscribe(id: string, principalId: string): Promise; /** * Send a test push to one of the principal's subscriptions and return the * honest receipt (delivered / pruned / failed). Returns null when the id is * not a subscription owned by this principal, so the verb can 404. */ verify(id: string, principalId: string): Promise; /** Fan a message out to every stored subscription. */ deliver(message: PushMessage): Promise; /** * Release every escalation timer this service has armed. * * A blocked ask arms a grace timer (honest default ~5 minutes) and each * escalation arms the next reminder, so at any moment the service holds one * live timer per outstanding block. The only way to cancel one was to answer * the ask or to inject a scheduler seam, which meant a daemon shutting down * with blocked asks outstanding had no way at all to put them down, the * production scheduler's `unref()` keeps them off the event loop but does * nothing about a graph that is meant to be finished with. * * The tracked blocks go too: after this the service is not tracking anything, * so a stale escalation cannot fire against a torn-down graph. Idempotent. */ dispose(): void; /** Delivery dependencies with the bounded-failure threshold read live. */ private deliveryDeps; /** * Wire a real event source: when an approval is created (status `pending`), * push it to every registered device. Later re-publishes of the same approval * (claimed/approved/denied) do not re-notify. Returns an unsubscribe handle. */ attachApprovalSource(source: ApprovalSource): () => void; /** * Wire the fleet needs-input source: when a fleet node becomes blocked on the * operator (a FLEET_NODE_BLOCKED_ON_USER notice), push a 'needs-input' * notification carrying the session/node deep-link reference. * * The FIRST push is SUPPRESSED when an operator surface is already attached to * that node's session (presence), someone is looking, so an immediate device * push would be noise. But presence is process-liveness, not a human answer: * an attached-but-idle desk (an open TUI, a heartbeat) never counts as a * response. So every block is also TRACKED, and if it is still outstanding * after the configured grace (honest default ~5m) with no HUMAN response, an * escalation push fires REGARDLESS of attachment, followed by a bounded, * configurable set of reminders. A real interaction with the ask clears the * block (FLEET_NODE_UNBLOCKED / FLEET_NODE_FINISHED), which cancels the * escalation, an answered ask never escalates. * * A node's notice is de-duped by node id until it unblocks or finishes, so a * later re-block re-notifies. Returns an unsubscribe handle. */ attachFleetNeedsInputSource(source: FleetNoticeSource, presence?: NeedsInputPresence): () => void; /** Arm the next escalation/reminder timer for a tracked block. */ private armEscalation; /** Compose and fire one needs-input push (immediate or escalated). */ private sendNeedsInput; /** * Wire the completion source: a tracked run reaching a terminal state (a * FLEET_NODE_FINISHED notice) pushes a 'completion' notification to every * paired target, by DEFAULT, with zero configuration; the * notifications.pushCompletion toggle exists only to silence the class. * Scoped to run-level kinds (agent/chain/workstream/workflow/automation-job) * so a chain finishing does not also fan out one push per subtask/work-item * child; the FINISHED event itself fires only on an OBSERVED terminal * transition (emit-bridge honesty), so nothing is inferred. One push per * node id. Returns an unsubscribe handle. */ attachCompletionSource(source: FleetNoticeSource): () => void; } //# sourceMappingURL=service.d.ts.map