Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
/**
|
||
* High-level presence agent (RFC-001 §4.1 steps 10–11, §4.5).
|
||
*
|
||
* Ties the pieces together for one agent: join the fleet presence room, set
|
||
* native Matrix presence online (for Element's dot), and run the authoritative
|
||
* `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does
|
||
* on spin — no enrollment/token-minting/introductions (those are P2).
|
||
*/
|
||
|
||
import {
|
||
HeartbeatEmitter,
|
||
startHeartbeatLoop,
|
||
type HeartbeatAgentIdentity,
|
||
type HeartbeatLoopHandle,
|
||
} from './heartbeat.js';
|
||
import type { MinimalMatrixClient } from './matrix-client.js';
|
||
import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js';
|
||
|
||
export interface PresenceAgentOptions {
|
||
client: MinimalMatrixClient;
|
||
agent: HeartbeatAgentIdentity;
|
||
/** Fleet presence room id (or alias) to heartbeat into. */
|
||
roomId: string;
|
||
/** Heartbeat cadence; defaults to the policy interval. */
|
||
intervalMs?: number;
|
||
policy?: LivenessPolicy;
|
||
missionId?: string;
|
||
onError?: (err: unknown) => void;
|
||
}
|
||
|
||
export class PresenceAgent {
|
||
private readonly intervalMs: number;
|
||
private readonly emitter: HeartbeatEmitter;
|
||
private loop: HeartbeatLoopHandle | undefined;
|
||
private resolvedRoomId: string | undefined;
|
||
|
||
constructor(private readonly opts: PresenceAgentOptions) {
|
||
const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
|
||
this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs;
|
||
this.emitter = new HeartbeatEmitter({
|
||
agent: opts.agent,
|
||
intervalMs: this.intervalMs,
|
||
missionId: opts.missionId,
|
||
});
|
||
}
|
||
|
||
/** Join the fleet room and go present. Returns the resolved room id. */
|
||
async connect(): Promise<string> {
|
||
this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId);
|
||
await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat');
|
||
return this.resolvedRoomId;
|
||
}
|
||
|
||
/** Start the heartbeat loop (emits immediately, then every intervalMs). */
|
||
start(status: () => PresenceStatus = () => 'online'): void {
|
||
const roomId = this.resolvedRoomId ?? this.opts.roomId;
|
||
this.loop = startHeartbeatLoop({
|
||
emitter: this.emitter,
|
||
intervalMs: this.intervalMs,
|
||
status,
|
||
onError: this.opts.onError,
|
||
send: async (content) => {
|
||
await this.opts.client.sendHeartbeat(roomId, content);
|
||
},
|
||
});
|
||
}
|
||
|
||
get currentSeq(): number {
|
||
return this.emitter.currentSeq;
|
||
}
|
||
|
||
/**
|
||
* Stop only the heartbeat loop, sending NO graceful signal. This models a
|
||
* hard crash/kill: the authoritative liveness path must detect it purely from
|
||
* the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence
|
||
* change. Idempotent.
|
||
*/
|
||
pauseHeartbeat(): void {
|
||
this.loop?.stop();
|
||
this.loop = undefined;
|
||
}
|
||
|
||
/** Graceful stop: stop heartbeating and drop native presence to offline. */
|
||
async stop(): Promise<void> {
|
||
this.pauseHeartbeat();
|
||
try {
|
||
await this.opts.client.setPresence(this.opts.agent.mxid, 'offline');
|
||
} catch (err) {
|
||
this.opts.onError?.(err);
|
||
}
|
||
}
|
||
}
|