Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
125 lines
3.7 KiB
TypeScript
125 lines
3.7 KiB
TypeScript
/**
|
|
* `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<void>;
|
|
|
|
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<typeof setInterval>));
|
|
|
|
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);
|
|
},
|
|
};
|
|
}
|