/** * EVO-13 / feed-contract P1 step 4a — the mirror push as an OPT-IN live daemon. * * `h2a remote mirror` is a ONE-SHOT push (build → sign → POST once). The * ratified feed contract * (`docs/superpowers/specs/2026-07-24-h2a-feed-contract-for-sentropic.md`, * Part C) needs that same push to run continuously on a 15–30s beat, so the * hosted read-only store stays warm for a UI that only ever pulls. A laptop * behind NAT cannot accept inbound, so the data path must be push — the daemon * is the pipeline, not a new trust boundary. * * This module changes NOTHING about what is pushed. It wraps the existing * one-shot cycle (`buildInstanceMirror` → `sendRemoteEnvelope`) in the same * supervisor idioms already shipped for the L1 objective loop * (`runtime/loop/supervisor.ts`): opt-in only, a global kill-switch, an * injectable clock, a drainable abort signal, and a per-beat summary that is * observability — never control. The signed-envelope trust boundary, the * payload shape, the sequence fencing and the accept-side verification are * untouched. * * Safety properties, in the order they matter: * * - OPT-IN, NEVER DEFAULT-ON. Nothing here runs unless a caller explicitly * asks for an interval. `h2a remote mirror` with no `--interval-ms` still * takes the byte-identical one-shot path it took before this module existed. * The global kill-switch `H2A_MIRROR_PUSH_OFF` hard-disables the loop even * when a unit file or an operator asked for it — checked before the FIRST * cycle, so a frozen daemon never emits a single request. * * - AUTH FAILURE STOPS, IT DOES NOT RETRY FOREVER. A 401/403 means the * receiving side does not trust this instance's signing key (never enrolled, * revoked, or — the live case today — the agent re-anchored and mints a * different keypair than the one enrolled). No amount of retrying fixes * that: it needs a human re-enrollment ceremony. So consecutive auth * rejections are backed off and then the daemon STOPS with an actionable * message, instead of hammering a rejecting endpoint indefinitely. Only a * genuinely accepted push (`ok`) clears the counter — a transient error in * between does NOT, because "network flap between two 401s" must not be a * way to loop forever against a server that is refusing us. * * - NO OVERLAP. The daemon cannot overlap itself: it awaits each cycle before * scheduling the next, so a slow push delays the next beat rather than * stacking on top of it (and the slot arithmetic below then skips the beats * that were missed). The exported runner ALSO carries an in-flight guard, for * the different case of two concurrent callers sharing one runner: the second * gets `skipped-overlap` without building or sending anything. That guard is * defence for external callers, not what protects the daemon's own loop. * * - NO DRIFT. Cycles are scheduled against a monotonic slot anchor * (`anchor + n × interval`), not by sleeping a fixed interval after each * cycle, so per-cycle duration does not accumulate into lateness. Slots * already in the past are skipped rather than fired back-to-back. A small * bounded jitter de-synchronises several agents pushing to one endpoint. * * - TRANSIENT ERRORS KEEP LOOPING. A network throw, a 5xx or a 429 is the * endpoint's problem, not ours: exponential backoff (capped), then carry on. * * - A STATUS MUST NOT BE WIDER THAN ITS EVIDENCE. The dividing line for every * stop rule above is "can this self-heal": a network outage can, a refused * key / a refused request / a root that cannot build this instance's mirror * cannot. The non-self-healing cases therefore TERMINATE rather than idle, * because idling would make this process the dishonest layer. Consider a * wrong `H2A_ROOT`: nothing is ever sent, so the feed downstream correctly * starts reporting those rows `stale` once `mirroredAt` stops advancing — * while `systemctl status` would still read `active (running)`. The two * layers would disagree and the only honest signal would be the one FURTHEST * from the operator, in a UI nobody is watching. The signal nearest the fault * has to be at least as honest as the far one, so we exit 1 and say why. * Correspondingly, the per-cycle line reports what the push CONTAINED (seq + * counts), so a successful push of an empty mirror is never mistaken for a * healthy feed. * * - DRAINABLE. `signal` is honored before each cycle and inside every sleep, * so SIGTERM stops promptly instead of finishing the schedule. * * - LOGS ARE SAFE BY CONSTRUCTION. The per-cycle line carries an outcome, an * HTTP status, a closed-vocabulary rejection reason and a redacted endpoint. * It NEVER carries key material, a token, a request body or a response body. * Everything free-form (an error message) goes through `sanitizeForLog`. */ import type { H2AEnvelope } from "@sentropic/h2a"; /** * Default beat. The ratified contract asks for 15–30s; 20s sits in the middle * and keeps a UI's worst-case staleness under the 90s presence keepalive * window, so a `stale` row means the pipeline really is down. */ export declare const DEFAULT_MIRROR_PUSH_INTERVAL_MS = 20000; /** * Floor the CLI enforces on `--interval-ms`. The library itself accepts any * positive interval (tests drive it at millisecond speed against a fake * transport); the operator-facing surface refuses anything that would hammer a * hosted endpoint faster than the contract's own range. */ export declare const MIN_MIRROR_PUSH_INTERVAL_MS = 5000; /** Bounded de-synchronisation jitter, as a fraction of the interval (±10%). */ export declare const MIRROR_PUSH_JITTER_FRACTION = 0.1; /** First transient-failure backoff; doubles per consecutive failure. */ export declare const MIRROR_PUSH_BACKOFF_BASE_MS = 5000; /** Backoff ceiling — a long outage retries every 5 min, not every 5 hours. */ export declare const MIRROR_PUSH_BACKOFF_MAX_MS = 300000; /** * How many CONSECUTIVE auth rejections are tolerated (each backed off) before * the daemon stops. 3 absorbs a key-rotation race on the accept side while * still stopping in well under a minute of real rejection. */ export declare const DEFAULT_MIRROR_AUTH_FAILURE_LIMIT = 3; /** * How many CONSECUTIVE non-auth rejections (a 4xx that is not 401/403) are * tolerated before the daemon stops. Some of these genuinely self-heal — a * stale-sequence or replay rejection clears as `seq` advances — which is why the * budget is looser than the auth one. But a permanently malformed request or a * wrong path returning 404 will never heal, and retrying it forever is the same * failure mode as retrying a refused key: a unit that reads "active (running)" * while pushing nothing. 5 tolerates a real fencing race, then stops. */ export declare const DEFAULT_MIRROR_REJECT_LIMIT = 5; /** * How many CONSECUTIVE local build failures are tolerated before the daemon * stops. `buildInstanceMirror` throwing means this root does not know this * instance — overwhelmingly because `H2A_ROOT` points somewhere the agent never * registered, the misconfiguration the shipped unit file explicitly warns about. * A wrong root NEVER self-heals, and looping on it forever is the worst possible * failure shape: no request is ever sent, yet `systemctl status` reads * `active (running)` indefinitely, so monitoring keyed on systemd state reads * green while the feed is dead. It must terminate loudly instead. 5 leaves room * for a genuinely transient race (an agent re-registering, a root being created). * * The contract-level argument (from the feed contract's own architect): in this * failure mode the feed WOULD report `stale` correctly, because `mirroredAt` stops * advancing — so the honest signal exists, but only at the layer furthest from the * operator. Idling here would ship a daemon that claims health next to a contract * written specifically to refuse claiming it. */ export declare const DEFAULT_MIRROR_BUILD_FAILURE_LIMIT = 5; /** Name of the global kill-switch env var. */ export declare const MIRROR_PUSH_OFF_ENV = "H2A_MIRROR_PUSH_OFF"; /** * Global kill-switch for the live mirror push. When `H2A_MIRROR_PUSH_OFF` is * set to any non-empty, non-"0"/"false" value, NO mirror push daemon runs * anywhere, regardless of flags or unit files. Same semantics as * `H2A_LOOP_AUTOTICK_OFF` for the objective-loop supervisor — one lever to * freeze the pipeline without editing any invocation. */ export declare function mirrorPushGloballyDisabled(env?: NodeJS.ProcessEnv): boolean; /** * Emitted when the daemon stops because the endpoint keeps rejecting the request * itself (not the key). Like the auth stop, retrying cannot fix a permanently * malformed request or a wrong path. */ export declare const MIRROR_PUSH_REJECTED_MESSAGE: string; /** * Emitted when the daemon is asked to push to something that is not a usable * http(s) endpoint. Returned BEFORE any cycle runs, because a URL `fetch` cannot * even parse would otherwise be retried forever as a transient network failure. */ /** * Emitted when the daemon stops because it cannot even BUILD a mirror locally. * Names the likely cause, because this failure never reaches the network and so * leaves no server-side trace for the operator to correlate against. */ export declare const MIRROR_PUSH_BUILD_FAILED_MESSAGE: string; export declare const MIRROR_PUSH_INVALID_URL_MESSAGE: string; /** * The actionable message emitted when the daemon stops on repeated auth * rejection. Deliberately explicit: the failure is NOT retryable and the * operator must know exactly which human act unblocks it. */ export declare const MIRROR_PUSH_REENROLLMENT_MESSAGE: string; /** What one cycle did. `skipped-overlap` performed no build and no request. */ export type MirrorPushOutcome = "ok" | "auth-rejected" | "rejected" | "transient" | "build-failed" | "skipped-overlap"; /** * Why the daemon returned. Every reason is a clean, intentional stop — the * daemon never falls out of its loop by accident. * * `auth-stop`, `reject-stop`, `build-stop` and `config-invalid` are the four * that need a human act; they are exactly the reasons that carry a `message`, * and the CLI turns any `message` into exit 1 so systemd keeps the unit stopped. * `log-unavailable` is a CLEAN stop (no message, exit 0): the status sink went * away — a piped stdout closed — so the daemon stops rather than keep pushing * with no way to report what it is doing. */ export type MirrorPushStopReason = "max-cycles" | "aborted" | "kill-switch" | "auth-stop" | "reject-stop" | "build-stop" | "config-invalid" | "log-unavailable"; export interface MirrorPushCycleResult { readonly outcome: MirrorPushOutcome; /** HTTP status, when a request actually completed. */ readonly status?: number; /** Closed-vocabulary accept-side rejection reason, when the body carried one. */ readonly reason?: string; /** Sanitized error text for `transient` / `build-failed`. Never secrets. */ readonly error?: string; readonly durationMs: number; /** * What the pushed envelope actually CONTAINED. All non-secret: a monotonic * sequence number and three cardinalities, never a body. * * Without these, a `200` says only "the endpoint accepted something" — a valid * root whose instance has no live sessions pushes `presence: []` every cycle * and logs a perfectly healthy `ok`, while the hosted UI shows nothing. These * make "successfully pushed nothing" distinguishable from "pushed the mirror". */ readonly payload?: MirrorPushPayloadShape; } /** Non-secret shape of one pushed mirror: what it carried, never its content. */ export interface MirrorPushPayloadShape { /** Per-instance monotonic sequence the receiver fences on. */ readonly seq?: number; readonly registrations: number; readonly presence: number; readonly subagents: number; } /** The one-line-per-cycle status record. Safe to journal verbatim. */ export interface MirrorPushCycleLog { readonly cycle: number; readonly at: string; readonly instance: string; /** Redacted endpoint: scheme + host + path only. No query, no userinfo. */ readonly endpoint: string; readonly outcome: MirrorPushOutcome; readonly status?: number; readonly reason?: string; readonly error?: string; readonly durationMs: number; /** Sleep before the next cycle. Absent when the daemon is stopping. */ readonly nextInMs?: number; /** Consecutive auth rejections so far — 0 unless the key is being refused. */ readonly authFailures?: number; /** Consecutive non-auth rejections so far — 0 unless the request is refused. */ readonly rejections?: number; /** Consecutive local build failures so far — 0 unless the root is wrong. */ readonly buildFailures?: number; /** Sequence number of the pushed envelope (fencing anchor). */ readonly seq?: number; /** How many registrations the pushed mirror carried. */ readonly registrations?: number; /** How many presence sessions it carried — 0 means "pushed an empty mirror". */ readonly presence?: number; /** How many subagent bindings it carried. */ readonly subagents?: number; } export interface MirrorPushDaemonSummary { readonly cycles: number; /** Cycles the endpoint accepted (2xx). */ readonly ok: number; /** Cycles that did not push successfully (any non-ok, non-skipped outcome). */ readonly failures: number; /** * Cycles skipped because a previous push was still in flight. Always 0 for the * daemon itself, which awaits each cycle and therefore cannot overlap itself; * non-zero only when a `runner` is shared with another concurrent caller. */ readonly skippedOverlap: number; /** Consecutive auth rejections at stop time. */ readonly authFailures: number; /** Consecutive non-auth rejections at stop time. */ readonly rejections: number; /** Consecutive local build failures at stop time. */ readonly buildFailures: number; /** * Times the status sink (`onCycle`) threw. Never affects the push itself — * observability failures are counted, not propagated — but a non-zero value * means the journal is an incomplete record of what this daemon did. */ readonly logFailures: number; readonly stopReason: MirrorPushStopReason; /** * The actionable instruction for a stop that needs a human act. Present on * exactly the four such stops — `auth-stop` * ({@link MIRROR_PUSH_REENROLLMENT_MESSAGE}), `reject-stop` * ({@link MIRROR_PUSH_REJECTED_MESSAGE}), `build-stop` * ({@link MIRROR_PUSH_BUILD_FAILED_MESSAGE}) and `config-invalid` * ({@link MIRROR_PUSH_INVALID_URL_MESSAGE}) — and absent on the clean stops * (`max-cycles`, `aborted`, `kill-switch`, `log-unavailable`). Its presence is * what the CLI keys exit 1 on, so systemd's `RestartPreventExitStatus=1` keeps * the unit stopped instead of restarting into the same wall. */ readonly message?: string; } /** Builds the (unsigned) mirror envelope for a given clock reading. */ export type MirrorEnvelopeBuilder = (nowMs: number) => H2AEnvelope; /** Signs + POSTs the envelope. Injected in tests; never a real socket there. */ export type MirrorEnvelopeSender = (url: string, envelope: H2AEnvelope, options: { by: string; privateKeyPem: string; }) => Promise<{ status: number; body: unknown; }>; /** * Strip anything that could be secret out of free-form text before it is * logged. Defence in depth: nothing here is SUPPOSED to see key material, but a * thrown error can carry whatever the thrower put in it, and this daemon holds a * private key in memory. PEM blocks, bearer tokens and token-ish query params * are replaced; the result is length-capped so a giant blob cannot be smuggled * out one line at a time. */ export declare function sanitizeForLog(text: string): string; /** * True when `url` is something a push could actually reach: parseable, and * http(s). Anything else — an unfilled placeholder, a typo, a `file:` or `ws:` * scheme — makes `fetch` throw a parse error that would otherwise be classified * as a retryable network failure and retried forever. */ export declare function isPushableHttpUrl(url: string | undefined): boolean; /** * Reduce a URL to scheme + host + path. Drops query and userinfo, which are the * two places a credential can hide in a URL, and keeps enough for an operator to * recognise which endpoint is being pushed to. */ export declare function redactEndpoint(url: string): string; export interface MirrorPushRunnerOptions { /** h2a root the mirror is built from. */ readonly root: string; /** Endpoint to POST the signed envelope to. */ readonly url: string; /** Instance whose registration + presence is mirrored (also the signer). */ readonly instance: string; /** Signer's ed25519 private-key PEM. Never logged, never sent. */ readonly privateKeyPem: string; /** Injectable clock (ms). Default `Date.now`. */ readonly now?: () => number; /** Injectable envelope builder. Default the real `buildInstanceMirror`. */ readonly buildImpl?: MirrorEnvelopeBuilder; /** Injectable transport. Default the real `sendRemoteEnvelope`. */ readonly sendImpl?: MirrorEnvelopeSender; } /** * Create the guarded one-cycle runner: build → sign → POST once, classified. * * The returned function is the ONLY thing that touches the network. It carries an * in-flight guard for CONCURRENT callers of the same runner: while one cycle is * running, a second call returns `skipped-overlap` immediately without building * an envelope or issuing a request. (The daemon itself awaits each cycle, so it * never trips this guard on its own — see the module header.) Exposed so a caller * or a test can drive single cycles without the timer loop, exactly as * `runSupervisorBeat` is exposed next to `runLoopSupervisor`. * * Never throws: every failure is classified into a {@link MirrorPushOutcome}. */ export declare function createMirrorPushRunner(options: MirrorPushRunnerOptions): () => Promise; export interface MirrorPushDaemonOptions extends MirrorPushRunnerOptions { /** Beat interval. Default {@link DEFAULT_MIRROR_PUSH_INTERVAL_MS}. */ readonly intervalMs?: number; /** Stop after this many cycles (testing / bounded ops runs). */ readonly max?: number; /** Abort to stop the daemon between cycles and inside every sleep. */ readonly signal?: AbortSignal; /** Environment for the kill-switch. Default `process.env`. */ readonly env?: NodeJS.ProcessEnv; /** Injectable randomness for the jitter. Default `Math.random`. */ readonly random?: () => number; /** Injectable sleep, so tests advance a fake clock instead of waiting. */ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise; /** Consecutive auth rejections tolerated. Default {@link DEFAULT_MIRROR_AUTH_FAILURE_LIMIT}. */ readonly authFailureLimit?: number; /** Consecutive non-auth rejections tolerated. Default {@link DEFAULT_MIRROR_REJECT_LIMIT}. */ readonly rejectLimit?: number; /** Consecutive local build failures tolerated. Default {@link DEFAULT_MIRROR_BUILD_FAILURE_LIMIT}. */ readonly buildFailureLimit?: number; readonly backoffBaseMs?: number; readonly backoffMaxMs?: number; /** Pre-built runner (shares one overlap guard across callers). */ readonly runner?: () => Promise; /** Called once per cycle with the safe status line. */ readonly onCycle?: (log: MirrorPushCycleLog) => void | Promise; } /** * Run the live mirror push until `signal` aborts, `max` cycles elapse, or the * endpoint has refused this key often enough to stop (see * {@link MIRROR_PUSH_REENROLLMENT_MESSAGE}). * * This is what the systemd `--user` unit runs. It is NEVER on any default code * path: no caller reaches it without an explicit interval, and the kill-switch * short-circuits it before the first request. */ export declare function runMirrorPushDaemon(options: MirrorPushDaemonOptions): Promise; //# sourceMappingURL=push-daemon.d.ts.map