/** * `mosaic.presence` heartbeat construction and loop (RFC-001 §4.2/§4.5). * * The emitter is deterministic and side-effect free (easy to unit test): it * owns the monotonic `seq` and stamps each beat. The loop wires the emitter to * a sender on an interval; timers are injectable so the loop is testable with * fake clocks. */ import { MACP_VERSION, type PresenceHeartbeatContent, type PresenceStatus } from './types.js'; export interface HeartbeatAgentIdentity { mxid: string; slug: string; harness: string; } export interface HeartbeatEmitterOptions { agent: HeartbeatAgentIdentity; /** Nominal interval advertised in each beat (interval_ms). */ intervalMs: number; /** Optional mission correlation (RFC-001 §4.2 envelope). */ missionId?: string; /** Injectable clock for deterministic tests. Default Date.now. */ now?: () => number; } /** * Produces successive heartbeat contents with a monotonically increasing seq. * The first `next()` returns seq=1. */ export class HeartbeatEmitter { private seq = 0; private readonly now: () => number; constructor(private readonly opts: HeartbeatEmitterOptions) { this.now = opts.now ?? Date.now; } /** Current sequence number (0 before the first beat). */ get currentSeq(): number { return this.seq; } /** Build the next heartbeat content, advancing the sequence. */ next(status: PresenceStatus = 'online'): PresenceHeartbeatContent { this.seq += 1; const ts = this.now(); const content: PresenceHeartbeatContent = { macp_version: MACP_VERSION, macp_type: 'presence', msgtype: 'mosaic.presence', agent: { mxid: this.opts.agent.mxid, slug: this.opts.agent.slug, harness: this.opts.agent.harness, }, ts, body: `${this.opts.agent.slug} ${status} (seq ${this.seq})`, status, seq: this.seq, interval_ms: this.opts.intervalMs, }; if (this.opts.missionId !== undefined) { content.mission_id = this.opts.missionId; } return content; } } export type HeartbeatSender = (content: PresenceHeartbeatContent) => void | Promise; export interface HeartbeatLoopOptions { emitter: HeartbeatEmitter; send: HeartbeatSender; intervalMs: number; /** Status supplier evaluated each beat. Default: always 'online'. */ status?: () => PresenceStatus; /** Called if a beat's send rejects (so a transient failure doesn't kill the loop). */ onError?: (err: unknown) => void; /** Injectable timer (tests). Defaults to global setInterval/clearInterval. */ setIntervalFn?: (cb: () => void, ms: number) => unknown; clearIntervalFn?: (handle: unknown) => void; } /** A running heartbeat loop; call stop() to end it. */ export interface HeartbeatLoopHandle { stop: () => void; } /** * Start a heartbeat loop: emits one beat immediately, then every intervalMs. * Returns a handle whose `stop()` is idempotent. */ export function startHeartbeatLoop(opts: HeartbeatLoopOptions): HeartbeatLoopHandle { const status = opts.status ?? (() => 'online' as PresenceStatus); const onError = opts.onError ?? (() => {}); const setIntervalFn = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms)); const clearIntervalFn = opts.clearIntervalFn ?? ((h) => clearInterval(h as ReturnType)); const beat = (): void => { try { const result = opts.send(opts.emitter.next(status())); if (result instanceof Promise) { result.catch(onError); } } catch (err) { onError(err); } }; beat(); // immediate first beat so liveness is fresh at once const handle = setIntervalFn(beat, opts.intervalMs); let stopped = false; return { stop: () => { if (stopped) return; stopped = true; clearIntervalFn(handle); }, }; }