feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed

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:
2026-07-25 21:03:18 +00:00
committed by Mos
parent fabde1c834
commit 2698ddb7b5
33 changed files with 2140 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
/**
* Fleet liveness reader (RFC-001 §4.5, A2/A4).
*
* Reads `mosaic.presence` heartbeats from the fleet presence room and computes
* deterministic online/away/offline for every agent. This is the surface a
* human (or the escalation watchdog, P2+) reads to answer "who's alive?".
*/
import { computeFleetLiveness } from './liveness.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type AgentLiveness, type LivenessPolicy } from './types.js';
export interface FleetLivenessReaderOptions {
client: MinimalMatrixClient;
/** The fleet presence room (id or resolved id). */
roomId: string;
policy?: LivenessPolicy;
/** Injectable clock for tests. Default Date.now. */
now?: () => number;
/** How many timeline events to scan back. Default 200. */
scanLimit?: number;
}
export class FleetLivenessReader {
private readonly policy: LivenessPolicy;
private readonly now: () => number;
constructor(private readonly opts: FleetLivenessReaderOptions) {
this.policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.now = opts.now ?? Date.now;
}
/** Read the room and compute current liveness for every seen agent. */
async read(): Promise<AgentLiveness[]> {
const observations = await this.opts.client.readHeartbeats(
this.opts.roomId,
this.opts.scanLimit ?? 200,
);
return computeFleetLiveness(observations, this.now(), this.policy);
}
/** A compact human-readable liveness board (A4 CLI view). */
async formatBoard(): Promise<string> {
const rows = await this.read();
rows.sort((a, b) => a.slug.localeCompare(b.slug));
const dot: Record<string, string> = { online: '🟢', away: '🟡', offline: '🔴' };
const lines = rows.map(
(r) =>
`${dot[r.status] ?? '⚪'} ${r.slug.padEnd(16)} ${r.status.padEnd(8)} ` +
`age=${(r.ageMs / 1000).toFixed(1)}s seq=${r.lastSeq} ${r.mxid}`,
);
const summary =
`online=${rows.filter((r) => r.status === 'online').length} ` +
`away=${rows.filter((r) => r.status === 'away').length} ` +
`offline=${rows.filter((r) => r.status === 'offline').length}`;
return [`Fleet presence — ${summary}`, ...lines].join('\n');
}
}