feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #888.
This commit is contained in:
92
packages/comms/src/presence-agent.ts
Normal file
92
packages/comms/src/presence-agent.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user