From f6b7d08227cf68bc3e388ce099aaf86abe657302 Mon Sep 17 00:00:00 2001 From: flint Date: Tue, 13 Oct 2026 22:25:18 +0000 Subject: [PATCH 1/2] fix(cli): dashboard and office status activity read from Presence (#622) Both views read Flair's office-wide Presence roster instead of the viewer's OrgEvent catch-up feed, and label the columns for what they show: the dashboard's activity column becomes Last Seen (presence status and the age of the agent's last heartbeat), and office status shows the same plus the agent's current task or activity. An agent with no presence record reads "never seen"; a read that fails reads "unavailable". The catch-up reader and its fake go with the reads. --- .../changed-622-activity-from-presence.md | 9 + packages/cli/src/commands/office-status.ts | 79 ++++--- packages/cli/src/commands/roster.ts | 72 +++---- packages/cli/src/utils/org-event-read.ts | 149 ------------- packages/cli/src/utils/presence-read.ts | 131 ++++++++++++ packages/cli/test/flair-catchup-fake.test.ts | 161 -------------- .../cli/test/flair-signer-commands.test.ts | 114 ++++------ packages/cli/test/helpers/stub-flair.ts | 109 ---------- .../cli/test/no-identity-defaults-486.test.ts | 2 +- packages/cli/test/office-status.test.ts | 199 ++++++------------ 10 files changed, 308 insertions(+), 717 deletions(-) create mode 100644 .changelog/unreleased/changed-622-activity-from-presence.md delete mode 100644 packages/cli/src/utils/org-event-read.ts create mode 100644 packages/cli/src/utils/presence-read.ts delete mode 100644 packages/cli/test/flair-catchup-fake.test.ts diff --git a/.changelog/unreleased/changed-622-activity-from-presence.md b/.changelog/unreleased/changed-622-activity-from-presence.md new file mode 100644 index 00000000..20d4228d --- /dev/null +++ b/.changelog/unreleased/changed-622-activity-from-presence.md @@ -0,0 +1,9 @@ +- **`tps dashboard` and `tps office status` read per-agent activity from Flair's Presence roster and label the column for what it shows.** + + Both views read the office-wide `GET /Presence` roster — the per-agent state + each agent pushes — instead of the viewer's OrgEvent catch-up feed. The + dashboard's last column is `Last Seen`, the presence status and the age of the + agent's last heartbeat; office status shows the same in `LAST SEEN` and the + agent's current task or activity in `ACTIVITY`. An agent with no presence + record reads `never seen`; a read that fails reads `unavailable`, not an empty + column. Office status prints no blocker line. diff --git a/packages/cli/src/commands/office-status.ts b/packages/cli/src/commands/office-status.ts index d160e41b..509050c7 100644 --- a/packages/cli/src/commands/office-status.ts +++ b/packages/cli/src/commands/office-status.ts @@ -15,7 +15,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { requireLocalAgentId } from "../utils/local-agent.js"; -import { activityState, orgEventReadNotice, readOrgEvents, type OrgEventRecord } from "../utils/org-event-read.js"; +import { presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js"; interface FlairAgent { id?: string; @@ -53,7 +53,6 @@ const green = (t: string, nc: boolean) => esc(32, t, nc); const yellow = (t: string, nc: boolean) => esc(33, t, nc); const cyan = (t: string, nc: boolean) => esc(36, t, nc); const dim = (t: string, nc: boolean) => esc(2, t, nc); -const red = (t: string, nc: boolean) => esc(31, t, nc); // ── Auth helper ─────────────────────────────────────────────────────────────── function makeAuth(viewerId: string, keyPath: string, method: string, urlPath: string): string | undefined { @@ -131,26 +130,31 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise { else { console.error(`Flair unreachable at ${flairUrl} (HTTP ${res.status})`); process.exit(1); } } catch { console.error(`Flair unreachable at ${flairUrl}. Is it running?`); process.exit(1); } - // Recent OrgEvents for task + heartbeat status: the viewer's catch-up feed - // over the last 6h (see utils/org-event-read.ts). A read that does not return - // the events is reported, and the task column says "unavailable", not "idle". - const read = await readOrgEvents({ flairUrl, viewerId, since: new Date(Date.now() - 6 * 3_600_000), sign: a }); - const notice = orgEventReadNotice(flairUrl, read); + // Per-agent presence for the last-seen and activity columns: Flair's + // office-wide Presence roster (see utils/presence-read.ts), each agent's own + // heartbeat. A read that does not return the roster is reported, and the + // columns say "unavailable", not "never seen" / "idle". + const read = await readPresence({ flairUrl, sign: a }); + const notice = presenceReadNotice(flairUrl, read); if (notice) console.error(notice); - const activity = activityState(read); - const events = read.status === "ok" ? read.events : []; - - // Per-agent newest task event of the kinds task.assigned, task.started and - // task.completed (other task kinds, such as task.failed, are not considered), - // and newest heartbeat event. The feed is oldest first, so a later event - // replaces an earlier one. - const lastTask = new Map(); - const lastHbTime = new Map(); - for (const ev of events) { - const id = ev.authorId; - if (["task.assigned","task.completed","task.started"].includes(ev.kind)) lastTask.set(id, ev); - if (ev.kind === "agent.heartbeat") lastHbTime.set(id, ev.createdAt); - } + const presenceByAgent = new Map(); + for (const rec of read.status === "ok" ? read.agents : []) presenceByAgent.set(rec.id, rec); + const state = presenceState(read); + + const lastSeenText = (id: string): string => { + if (state === "unavailable") return "unavailable"; + const rec = presenceByAgent.get(id); + return rec ? `${rec.presenceStatus} (${relativeAgo(rec.lastHeartbeatAt)})` : "never seen"; + }; + // What the agent is doing: its last asserted current task, else its activity + // (labelled "was: …" once the Presence record has gone stale). + const doingText = (id: string): string => { + if (state === "unavailable") return "unavailable"; + const rec = presenceByAgent.get(id); + if (!rec) return "never seen"; + if (rec.currentTask) return rec.currentTask; + return rec.activityFresh ? rec.activity : `was: ${rec.lastActivity ?? rec.activity}`; + }; // Fetch open PRs const openPrs: PrInfo[] = repo ? fetchOpenPrs(ghAgent, repo) : []; @@ -161,9 +165,10 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise { const id = ag.id ?? ag.agentId ?? "?"; return { id, name: ag.name, role: ag.role, model: ag.model, status: ag.status, - lastHeartbeat: ag.lastHeartbeat ?? lastHbTime.get(id), - lastTask: lastTask.get(id), - activity, + presence: presenceByAgent.get(id) ?? null, + lastSeen: lastSeenText(id), + doing: doingText(id), + presenceState: state, cursorSince: loadCursorIso(id), git: gitBranchStatus(ag.workspace), }; @@ -178,7 +183,7 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise { console.log(`${bold("⚒️ TPS Office", nc)} ${dim(now, nc)}`); console.log(dim("─".repeat(74), nc)); console.log( - dim(` ${"AGENT".padEnd(12)} ${"ROLE".padEnd(26)} ${"MODEL".padEnd(18)} ${"HEARTBEAT".padEnd(12)} TASK`, nc) + dim(` ${"AGENT".padEnd(12)} ${"ROLE".padEnd(26)} ${"MODEL".padEnd(18)} ${"LAST SEEN".padEnd(20)} ACTIVITY`, nc) ); console.log(dim("─".repeat(74), nc)); @@ -187,20 +192,13 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise { const name = (ag.name ?? id).padEnd(12); const role = (ag.role ?? "?").padEnd(26); const model= ((ag.model ?? "?").split("/").pop() ?? "?").padEnd(18); - const hbIso= ag.lastHeartbeat ?? lastHbTime.get(id); - const hb = (!hbIso && activity === "unavailable" ? "unavailable" : relTime(hbIso)).padEnd(12); + const rec = presenceByAgent.get(id); + const lastSeen = lastSeenText(id); + const doing = doingText(id); const dot = ag.status === "active" ? green("●", nc) : yellow("○", nc); - const task = lastTask.get(id); - const partial = activity === "incomplete" ? dim(" (incomplete)", nc) : ""; - const taskStr = activity === "unavailable" - ? dim("unavailable", nc) - : task - ? (task.kind === "task.completed" - ? green("✓ " + (task.summary.length > 30 ? task.summary.slice(0,30)+"…" : task.summary), nc) - : yellow("→ " + (task.summary.length > 30 ? task.summary.slice(0,30)+"…" : task.summary), nc)) + partial - : dim(activity === "incomplete" ? "incomplete" : "idle", nc); - - console.log(`${dot} ${cyan(name, nc)} ${role} ${dim(model, nc)} ${hb} ${taskStr}`); + const doingColored = state !== "unavailable" && rec?.currentTask ? cyan(doing, nc) : dim(doing, nc); + + console.log(`${dot} ${cyan(name, nc)} ${role} ${dim(model, nc)} ${lastSeen.padEnd(20)} ${doingColored}`); // Sub-lines: git + cursor const git = gitBranchStatus(ag.workspace); @@ -226,10 +224,5 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise { } } - // Blockers: the newest one read. - let blocker: OrgEventRecord | undefined; - for (const e of events) if (e.kind === "blocker") blocker = e; - if (blocker) console.log(` ${red(`⚠ BLOCKER: ${blocker.summary}`, nc)}`); - console.log(); } diff --git a/packages/cli/src/commands/roster.ts b/packages/cli/src/commands/roster.ts index 21630a60..4063b22c 100644 --- a/packages/cli/src/commands/roster.ts +++ b/packages/cli/src/commands/roster.ts @@ -5,7 +5,7 @@ import { createFlairClient, defaultFlairKeyPath } from "../utils/flair-client.js import { sendSignedMail } from "../utils/mail-producer.js"; import { resolveCliSenderId } from "../utils/sender-id.js"; import { requireLocalAgentId } from "../utils/local-agent.js"; -import { activityState, orgEventReadNotice, readOrgEvents, type OrgEventRecord } from "../utils/org-event-read.js"; +import { presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js"; interface RosterArgs { action: "list" | "show" | "find" | "invite"; @@ -260,64 +260,46 @@ export async function runDashboard(opts: { flairUrl?: string; json?: boolean; ke return; } - // Recent OrgEvents for the activity column: the viewer's catch-up feed over - // the last 24h (see utils/org-event-read.ts). A read that does not return the - // events is reported, and the column says "unavailable", not "no activity". - const read = await readOrgEvents({ - flairUrl, - viewerId, - since: new Date(Date.now() - 24 * 60 * 60 * 1000), - sign: makeAuth, - }); - const notice = orgEventReadNotice(flairUrl, read); + // Per-agent presence for the last-seen column: Flair's office-wide Presence + // roster (see utils/presence-read.ts). It holds each agent's own heartbeat — + // not the viewer's catch-up feed. A read that does not return the roster is + // reported, and the column says "unavailable", not "never seen". + const read = await readPresence({ flairUrl, sign: makeAuth }); + const notice = presenceReadNotice(flairUrl, read); if (notice) console.error(notice); - const activity = activityState(read); + const presenceByAgent = new Map(); + for (const rec of read.status === "ok" ? read.agents : []) presenceByAgent.set(rec.id, rec); + const state = presenceState(read); - // Per-agent newest event of any kind, and newest task event of the kinds - // task.assigned and task.completed (other task kinds, such as task.failed, - // are not considered). The feed is oldest first, so a later event replaces an - // earlier one. - const lastEvent = new Map(); - const lastTask = new Map(); - for (const ev of read.status === "ok" ? read.events : []) { - lastEvent.set(ev.authorId, ev); - if (ev.kind === "task.assigned" || ev.kind === "task.completed") lastTask.set(ev.authorId, ev); - } + const lastSeenText = (agId: string): string => { + if (state === "unavailable") return "unavailable"; + const rec = presenceByAgent.get(agId); + return rec ? `${rec.presenceStatus} (${relativeAgo(rec.lastHeartbeatAt)})` : "never seen"; + }; if (opts.json) { - console.log(JSON.stringify(agents.map(a => ({ - ...a, - lastEvent: lastEvent.get(a.id ?? a.agentId ?? ""), - lastTask: lastTask.get(a.id ?? a.agentId ?? ""), - activity, - })), null, 2)); + console.log(JSON.stringify(agents.map(a => { + const agId = a.id ?? a.agentId ?? ""; + return { + ...a, + presence: presenceByAgent.get(agId) ?? null, + lastSeen: lastSeenText(agId), + presenceState: state, + }; + }), null, 2)); return; } // Pretty print - const now = Date.now(); console.log(`\n⚒️ TPS Office Dashboard — ${agents.length} agents\n`); - console.log(`${"Agent".padEnd(16)} ${"Role".padEnd(12)} ${"Status".padEnd(10)} ${"Last Activity"}`); + console.log(`${"Agent".padEnd(16)} ${"Role".padEnd(12)} ${"Status".padEnd(10)} ${"Last Seen"}`); console.log("─".repeat(72)); for (const agent of agents) { - const agId = agent.id ?? agent.agentId ?? "?"; const last = lastEvent.get(agId); - const task = lastTask.get(agId); - const ago = activity === "unavailable" - ? "unavailable" - : last - ? (() => { - const ms = now - new Date(last.createdAt).getTime(); - const when = ms < 60000 ? `${Math.round(ms / 1000)}s ago` - : ms < 3600000 ? `${Math.round(ms / 60000)}m ago` - : `${Math.round(ms / 3600000)}h ago`; - return activity === "incomplete" ? `${when} (incomplete)` : when; - })() - : activity === "incomplete" ? "incomplete" : "no activity"; - const taskInfo = task ? ` [${task.kind}: ${(task.refId ?? task.summary).slice(0, 30)}]` : ""; + const agId = agent.id ?? agent.agentId ?? "?"; const status = agent.status ?? "unknown"; console.log( - `${agId.padEnd(16)} ${(agent.role ?? "agent").padEnd(12)} ${status.padEnd(10)} ${ago}${taskInfo}` + `${agId.padEnd(16)} ${(agent.role ?? "agent").padEnd(12)} ${status.padEnd(10)} ${lastSeenText(agId)}` ); } console.log(); diff --git a/packages/cli/src/utils/org-event-read.ts b/packages/cli/src/utils/org-event-read.ts deleted file mode 100644 index b37b4100..00000000 --- a/packages/cli/src/utils/org-event-read.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * org-event-read.ts — the recent-OrgEvent read behind `tps roster dashboard` - * and `tps office status` (cli#606, cli#620). - * - * `GET /OrgEventCatchup/` is the viewer's catch-up feed in Flair: the - * events addressed to the viewer or broadcast, after the viewer's acknowledged - * position when it has one, created at or after the `since` floor. Flair - * answers one page at a time, in position order (oldest first), as an object - * `{ events, nextAfter, hasMore, ... }`, and refuses a `since` it cannot parse - * with HTTP 400. A GET acknowledges nothing. - * - * `readOrgEvents` sends `since` as a plain ISO timestamp, signs each page - * request with the caller's signer, validates the envelope and every event in - * it, and follows `nextAfter` while `hasMore`, for at most `maxPages` pages. - * The result is either - * - * - `ok`, with the events in feed order and `complete: false` when the page - * bound stopped the read before the feed was drained (the newest events - * are the ones not read), or - * - `unavailable`, with a bounded reason: the HTTP status, a fixed note - * naming the malformed part of the response, or the code or class of the - * error a rejected request raised. The reason never includes the response - * body or an error message. - */ - -/** An OrgEvent as the dashboard and office status read it. */ -export interface OrgEventRecord { - kind: string; - authorId: string; - summary: string; - createdAt: string; - refId?: string | null; - [field: string]: unknown; -} - -export type OrgEventRead = - | { status: "ok"; events: OrgEventRecord[]; complete: boolean } - | { status: "unavailable"; reason: string }; - -/** Signs a Flair request: the Authorization header value, or undefined to send none. */ -export type FlairRequestSigner = (method: string, path: string) => string | undefined; - -/** Events requested per page (Flair caps a page at 500). */ -export const ORG_EVENT_PAGE_SIZE = 200; -/** Pages read before the result is marked incomplete. */ -export const ORG_EVENT_MAX_PAGES = 10; - -export interface OrgEventReadOpts { - flairUrl: string; - viewerId: string; - /** The createdAt floor. */ - since: Date; - sign: FlairRequestSigner; - pageSize?: number; - maxPages?: number; -} - -/** A code or class name safe to print: a short identifier, nothing else. */ -const TAG = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; - -function errorTag(err: unknown): string { - if (typeof err === "object" && err !== null) { - const e = err as { code?: unknown; name?: unknown; cause?: unknown }; - const cause = typeof e.cause === "object" && e.cause !== null ? (e.cause as { code?: unknown }) : undefined; - for (const candidate of [e.code, cause?.code, e.name]) { - if (typeof candidate === "string" && TAG.test(candidate)) return candidate; - } - } - return "unknown error"; -} - -function isEvent(value: unknown): value is OrgEventRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const e = value as Record; - return ( - typeof e.kind === "string" && - typeof e.authorId === "string" && - typeof e.summary === "string" && - typeof e.createdAt === "string" && - !Number.isNaN(Date.parse(e.createdAt)) && - (e.refId === undefined || e.refId === null || typeof e.refId === "string") - ); -} - -/** The page's events and cursor, or the fixed note naming what is malformed. */ -function parsePage(body: unknown): { events: OrgEventRecord[]; hasMore: boolean; nextAfter: string } | string { - if (typeof body !== "object" || body === null || Array.isArray(body)) return "not an object"; - const page = body as Record; - if (!Array.isArray(page.events)) return "events is not an array"; - if (!page.events.every(isEvent)) return "an event lacks a field the command reads"; - if (typeof page.hasMore !== "boolean") return "hasMore is not a boolean"; - if (page.hasMore && (typeof page.nextAfter !== "string" || page.nextAfter === "")) return "nextAfter is missing"; - return { events: page.events, hasMore: page.hasMore, nextAfter: typeof page.nextAfter === "string" ? page.nextAfter : "" }; -} - -/** Read the viewer's OrgEvent catch-up feed since `since`; see the module doc. */ -export async function readOrgEvents(opts: OrgEventReadOpts): Promise { - const pageSize = opts.pageSize ?? ORG_EVENT_PAGE_SIZE; - const maxPages = opts.maxPages ?? ORG_EVENT_MAX_PAGES; - const base = `/OrgEventCatchup/${encodeURIComponent(opts.viewerId)}`; - const since = opts.since.toISOString(); - const events: OrgEventRecord[] = []; - let after: string | undefined; - for (let page = 0; page < maxPages; page++) { - const params = new URLSearchParams({ since, limit: String(pageSize) }); - if (after !== undefined) params.set("after", after); - const path = `${base}?${params.toString()}`; - const auth = opts.sign("GET", path); - let res: Response; - try { - res = await fetch(`${opts.flairUrl}${path}`, auth ? { headers: { Authorization: auth } } : {}); - } catch (err) { - return { status: "unavailable", reason: `request failed (${errorTag(err)})` }; - } - if (!res.ok) return { status: "unavailable", reason: `HTTP ${res.status}` }; - let body: unknown; - try { - body = await res.json(); - } catch { - return { status: "unavailable", reason: "malformed response (body is not JSON)" }; - } - const parsed = parsePage(body); - if (typeof parsed === "string") return { status: "unavailable", reason: `malformed response (${parsed})` }; - events.push(...parsed.events); - if (!parsed.hasMore) return { status: "ok", events, complete: true }; - after = parsed.nextAfter; - } - return { status: "ok", events, complete: false }; -} - -/** The per-agent activity state both commands print and emit as JSON. */ -export type ActivityState = "ok" | "incomplete" | "unavailable"; - -export function activityState(read: OrgEventRead): ActivityState { - if (read.status === "unavailable") return "unavailable"; - return read.complete ? "ok" : "incomplete"; -} - -/** - * The one line a command prints about the read, or null when the read was - * complete. It names the Flair URL and, for an unavailable read, the bounded - * reason. - */ -export function orgEventReadNotice(flairUrl: string, read: OrgEventRead, maxPages = ORG_EVENT_MAX_PAGES): string | null { - if (read.status === "unavailable") return `Cannot read OrgEvents from ${flairUrl} (${read.reason}); activity is unavailable.`; - if (!read.complete) - return `OrgEvents from ${flairUrl} exceed the ${maxPages}-page read bound; newer events were not read and activity is incomplete.`; - return null; -} diff --git a/packages/cli/src/utils/presence-read.ts b/packages/cli/src/utils/presence-read.ts new file mode 100644 index 00000000..fe7abc1c --- /dev/null +++ b/packages/cli/src/utils/presence-read.ts @@ -0,0 +1,131 @@ +/** + * presence-read.ts — the office-wide per-agent presence read behind + * `tps roster dashboard` and `tps office status` (cli#622). + * + * `GET /Presence` is Flair's roster of per-agent presence: one record per + * agent that has pushed presence, each carrying the liveness and activity the + * agent last asserted. Unlike the viewer's OrgEvent catch-up feed + * (events addressed to the viewer or broadcast, from the viewer's + * acknowledged position), this is office-wide and per-agent — the source that + * matches "what is each agent doing / when was it last active". + * + * A verified reader (a registered agent's TPS-Ed25519 signature, which is what + * both commands sign with) may read the roster. `readPresence` sends one signed + * GET, validates the body is an array of records carrying the fields the + * commands read, and returns it. The result is either + * + * - `ok`, with the presence records, or + * - `unavailable`, with a bounded reason: the HTTP status, a fixed note + * naming the malformed part of the response, or the code or class of the + * error a rejected request raised. The reason never includes the response + * body or an error message. + */ + +/** A Presence record as the commands read it. */ +export interface PresenceRecord { + id: string; + /** "active" | "idle" | "offline" (Flair's derivePresenceStatus). */ + presenceStatus: string; + /** Epoch ms of the agent's last heartbeat. */ + lastHeartbeatAt: number; + /** "coding" | "reviewing" | "planning" | "debugging" | "idle" once stale. */ + activity: string; + lastActivity?: string | null; + activityUpdatedAt?: number | null; + activityAgeMs?: number | null; + activityFresh?: boolean; + currentTask?: string | null; + displayName?: string | null; + role?: string | null; + runtime?: string | null; + [field: string]: unknown; +} + +export type PresenceRead = + | { status: "ok"; agents: PresenceRecord[] } + | { status: "unavailable"; reason: string }; + +/** Signs a Flair request: the Authorization header value, or undefined to send none. */ +export type FlairRequestSigner = (method: string, path: string) => string | undefined; + +export interface PresenceReadOpts { + flairUrl: string; + sign: FlairRequestSigner; +} + +/** A code or class name safe to print: a short identifier, nothing else. */ +const TAG = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + +function errorTag(err: unknown): string { + if (typeof err === "object" && err !== null) { + const e = err as { code?: unknown; name?: unknown; cause?: unknown }; + const cause = typeof e.cause === "object" && e.cause !== null ? (e.cause as { code?: unknown }) : undefined; + for (const candidate of [e.code, cause?.code, e.name]) { + if (typeof candidate === "string" && TAG.test(candidate)) return candidate; + } + } + return "unknown error"; +} + +/** Does this record carry every field the commands read? */ +function isPresenceRecord(value: unknown): value is PresenceRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const r = value as Record; + return ( + typeof r.id === "string" && + r.id.length > 0 && + typeof r.presenceStatus === "string" && + typeof r.lastHeartbeatAt === "number" && + Number.isFinite(r.lastHeartbeatAt) && + typeof r.activity === "string" + ); +} + +/** Read the office-wide Presence roster; see the module doc. */ +export async function readPresence(opts: PresenceReadOpts): Promise { + const path = "/Presence"; + const auth = opts.sign("GET", path); + let res: Response; + try { + res = await fetch(`${opts.flairUrl}${path}`, auth ? { headers: { Authorization: auth } } : {}); + } catch (err) { + return { status: "unavailable", reason: `request failed (${errorTag(err)})` }; + } + if (!res.ok) return { status: "unavailable", reason: `HTTP ${res.status}` }; + let body: unknown; + try { + body = await res.json(); + } catch { + return { status: "unavailable", reason: "malformed response (body is not JSON)" }; + } + if (!Array.isArray(body)) return { status: "unavailable", reason: "malformed response (body is not an array)" }; + if (!body.every(isPresenceRecord)) return { status: "unavailable", reason: "malformed response (a presence record lacks a field the command reads)" }; + return { status: "ok", agents: body }; +} + +/** The per-agent presence state both commands print and emit as JSON. */ +export type PresenceState = "ok" | "unavailable"; + +export function presenceState(read: PresenceRead): PresenceState { + return read.status === "ok" ? "ok" : "unavailable"; +} + +/** + * The one line a command prints about the read, or null when it was ok. It + * names the Flair URL and, for an unavailable read, the bounded reason. + */ +export function presenceReadNotice(flairUrl: string, read: PresenceRead): string | null { + if (read.status === "unavailable") return `Cannot read Presence from ${flairUrl} (${read.reason}); presence is unavailable.`; + return null; +} + +/** "3m ago" for a past epoch-ms timestamp; "just now" for the future or <1m. */ +export function relativeAgo(at: number, now = Date.now()): string { + const diff = now - at; + if (diff < 60_000) return "just now"; + const m = Math.floor(diff / 60_000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} diff --git a/packages/cli/test/flair-catchup-fake.test.ts b/packages/cli/test/flair-catchup-fake.test.ts deleted file mode 100644 index 1088e190..00000000 --- a/packages/cli/test/flair-catchup-fake.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * cli#606 — the catch-up fake (`orgEventCatchupRoute` in helpers/stub-flair.ts) - * holds to the coded contract of Flair's `GET /OrgEventCatchup/` - * (resources/OrgEventCatchup.ts, org-event-catchup-lib.ts and - * agent-read-position-lib.ts): participant ownership, the `since`, target, - * expiry and watermark filters, the requested page size, an unknown `after` used - * as a filter, and the prior cursor returned as `nextAfter` on an empty page. - * Each case reads through the production reader (`readOrgEvents`) or a signed - * request against the shared verifying stub. This checks the fake against a - * reading of Flair's code; it does not exercise a running Flair. - */ -import { afterEach, describe, expect, test } from "bun:test"; -import { readOrgEvents } from "../src/utils/org-event-read.js"; -import { - type CatchupFeed, - type CatchupRoute, - installStubFlairFetch, - orgEventCatchupRoute, - requestSigner, - type StubFlair, -} from "./helpers/stub-flair.js"; - -const SEEDS: Record = { "viewer-a": Buffer.alloc(32, 0x41), "viewer-b": Buffer.alloc(32, 0x42) }; - -let stub: StubFlair | undefined; -afterEach(() => { - stub?.stop(); - stub = undefined; -}); - -/** Serve `feed` behind the verifying stub, for callers viewer-a and viewer-b. */ -function serve(feed: CatchupFeed): CatchupRoute { - const route = orgEventCatchupRoute(feed); - stub = installStubFlairFetch(SEEDS, (req, url, caller) => - url.pathname.startsWith("/OrgEventCatchup/") ? route(req, url, caller) : undefined, - ); - return route; -} - -const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); -const event = (id: string, minutesAgo: number, extra: Record = {}) => ({ - id, - kind: "note", - authorId: "agent-x", - summary: id, - createdAt: at(minutesAgo), - ...extra, -}); -const position = (e: { id: string; createdAt: string }) => `${e.createdAt}\t${e.id}`; - -/** Read `viewerId`'s feed over the last `sinceMinutes`, signed as `signerId`; the ids read, or the unavailable result. */ -async function readIds(viewerId: string, signerId = viewerId, sinceMinutes = 60) { - const result = await readOrgEvents({ - flairUrl: stub!.url, - viewerId, - since: new Date(Date.now() - sinceMinutes * 60_000), - sign: requestSigner(signerId, SEEDS[signerId]!), - }); - return result.status === "ok" ? result.events.map((e) => e.id) : result; -} - -/** A signed GET of viewer-a's feed with `query`; the status and parsed body. */ -async function getFeed(query: Record): Promise<{ status: number; body: Record }> { - const search = new URLSearchParams(query).toString(); - const path = search ? `/OrgEventCatchup/viewer-a?${search}` : "/OrgEventCatchup/viewer-a"; - const res = await fetch(`${stub!.url}${path}`, { - headers: { Authorization: requestSigner("viewer-a", SEEDS["viewer-a"]!)("GET", path) }, - }); - return { status: res.status, body: (await res.json()) as Record }; -} - -describe("cli#606 — the catch-up fake holds to Flair's coded catch-up contract", () => { - test("a signed non-admin request for another participant's feed is refused (HTTP 403)", async () => { - serve({ events: [event("e1", 5)] }); - expect(await readIds("viewer-b", "viewer-a")).toEqual({ status: "unavailable", reason: "HTTP 403" }); - }); - - test("an admin may read another participant's feed", async () => { - serve({ events: [event("e1", 5)], admins: ["viewer-a"] }); - expect(await readIds("viewer-b", "viewer-a")).toEqual(["e1"]); - }); - - test("an event older than since is not served, even when it sorts after the viewer's watermark", async () => { - const watermarks = { "viewer-a": `${at(120)}\t` }; - serve({ events: [event("old", 90), event("new", 30)], watermarks }); - expect(await readIds("viewer-a")).toEqual(["new"]); - }); - - test("an event targeted at another participant is not served; broadcast and own-targeted ones are", async () => { - serve({ - events: [ - event("other", 40, { targetIds: ["viewer-b"] }), - event("empty", 30, { targetIds: [] }), - event("mine", 20, { targetIds: ["viewer-a"] }), - event("none", 10), - ], - }); - expect(await readIds("viewer-a")).toEqual(["empty", "mine", "none"]); - }); - - test("an expired event is not served", async () => { - serve({ events: [event("expired", 30, { expiresAt: at(1) }), event("live", 20, { expiresAt: at(-60) })] }); - expect(await readIds("viewer-a")).toEqual(["live"]); - }); - - test("the feed resumes after the viewer's acknowledged position, and a read leaves it", async () => { - const seen = event("seen", 30); - const watermarks = { "viewer-a": position(seen) }; - serve({ events: [event("before", 40), seen, event("after", 20)], watermarks }); - expect(await readIds("viewer-a")).toEqual(["after"]); - expect(watermarks).toEqual({ "viewer-a": position(seen) }); - }); - - test("pageSize is the requested page size, clamped to 1..500, and 50 by default", async () => { - serve({ events: [1, 2, 3, 4, 5].map((n) => event(`e${n}`, 10 - n)) }); - const since = at(60); - const two = await getFeed({ since, limit: "2" }); - expect(two.body.pageSize).toBe(2); - expect(two.body.events.map((e: { id: string }) => e.id)).toEqual(["e1", "e2"]); - expect(two.body.hasMore).toBe(true); - expect((await getFeed({ since })).body.pageSize).toBe(50); - expect((await getFeed({ since, limit: "9999" })).body.pageSize).toBe(500); - expect((await getFeed({ since, limit: "0" })).body.pageSize).toBe(1); - }); - - test("an unknown after cursor filters the feed instead of being refused", async () => { - serve({ events: [event("e1", 30), event("e2", 10)] }); - const res = await getFeed({ since: at(60), after: `${at(20)}\tno-such-event` }); - expect(res.status).toBe(200); - expect(res.body.events.map((e: { id: string }) => e.id)).toEqual(["e2"]); - }); - - test("an empty page returns the prior after cursor as nextAfter", async () => { - serve({ events: [event("e1", 30)] }); - const after = `${at(5)}\tpast-every-event`; - const res = await getFeed({ since: at(60), after }); - expect(res.body.events).toEqual([]); - expect(res.body.hasMore).toBe(false); - expect(res.body.nextAfter).toBe(after); - }); - - test("with no since and no watermark, a read creates the viewer's watermark the default 24h back and serves after it", async () => { - const watermarks: Record = {}; - serve({ events: [event("day-old", 25 * 60), event("recent", 10)], watermarks }); - const res = await getFeed({}); - expect(res.body.events.map((e: { id: string }) => e.id)).toEqual(["recent"]); - expect(Object.keys(watermarks)).toEqual(["viewer-a"]); - expect(res.body.watermark).toBe(watermarks["viewer-a"]); - }); - - test("with a zero backfill, the created watermark starts at now and serves nothing earlier", async () => { - const watermarks: Record = {}; - serve({ events: [event("day-old", 25 * 60), event("recent", 10)], watermarks, backfillMs: 0 }); - const before = new Date().toISOString(); - const res = await getFeed({}); - expect(res.body.events).toEqual([]); - expect(Object.keys(watermarks)).toEqual(["viewer-a"]); - expect(watermarks["viewer-a"]! >= `${before}\t`).toBe(true); - expect(res.body.watermark).toBe(watermarks["viewer-a"]); - }); -}); diff --git a/packages/cli/test/flair-signer-commands.test.ts b/packages/cli/test/flair-signer-commands.test.ts index 32541768..8309a49c 100644 --- a/packages/cli/test/flair-signer-commands.test.ts +++ b/packages/cli/test/flair-signer-commands.test.ts @@ -12,11 +12,9 @@ import { join } from "node:path"; import { createPatchShared } from "./helpers/patch-shared.js"; import { runInit } from "../src/commands/init.js"; import { runDashboard } from "../src/commands/roster.js"; -import { ORG_EVENT_MAX_PAGES, ORG_EVENT_PAGE_SIZE } from "../src/utils/org-event-read.js"; import { installStubFlairFetch, type FlairRoutes, - orgEventCatchupRoute, pubkeyFromSeed, startStubFlair, type StubFlair, @@ -111,7 +109,7 @@ describe("cli#554 — runInit registers signed with Flair", () => { describe("cli#554 — runDashboard signs its Flair reads", () => { function routes(req: Request, url: URL, caller: string): Response | undefined { if (req.method === "GET" && url.pathname === "/Agent/") return Response.json([{ id: "ember", name: "Ember" }]); - if (req.method === "GET" && url.pathname.startsWith("/OrgEventCatchup/")) return orgEventCatchupRoute({ events: [] })(req, url, caller); + if (req.method === "GET" && url.pathname === "/Presence") return Response.json([]); return undefined; } @@ -143,29 +141,23 @@ describe("cli#554 — runDashboard signs its Flair reads", () => { }); /** - * cli#606 — the dashboard's activity column reads the viewer's OrgEvent - * catch-up feed through `readOrgEvents`. The catch-up route is - * `orgEventCatchupRoute`, a fake modelled on Flair's coded catch-up contract - * (see its doc; the real resource is not exercised). The fixtures name no - * production agent. + * cli#622 — the dashboard's last-seen column reads Flair's office-wide Presence + * roster through `readPresence`. The /Presence fixture is served by the shared + * verifying stub (see its doc); the fixture names no production agent. */ -describe("cli#606 — runDashboard reads the OrgEvent catch-up feed", () => { +describe("cli#622 — runDashboard reads the Presence roster", () => { const VIEWER = "viewer-a"; const AGENTS = [{ id: "agent-a", name: "Agent A" }, { id: "agent-b", name: "Agent B" }]; - const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); - const OLDER = { id: "e1", kind: "task.completed", authorId: "agent-a", summary: "older task", createdAt: at(50) }; - const NEWER = { id: "e2", kind: "task.completed", authorId: "agent-a", summary: "newer task", createdAt: at(5) }; - /** `count` distinct events by `authorId`, one millisecond apart from `minutesAgo`, ids ``. */ - const rows = (count: number, authorId: string, minutesAgo: number, prefix: string) => - Array.from({ length: count }, (_, n) => ({ - id: `${prefix}${String(n).padStart(5, "0")}`, - kind: "task.completed", - authorId, - summary: `${prefix} task ${n}`, - createdAt: new Date(Date.now() - minutesAgo * 60_000 + n).toISOString(), - })); - /** More distinct events than the read bound holds: the bound's pages, plus one. */ - const PAST_BOUND = ORG_EVENT_MAX_PAGES * ORG_EVENT_PAGE_SIZE + 1; + /** A Presence record for `id`, 5 minutes stale by default. */ + const presence = (id: string, over: Record = {}) => ({ + id, displayName: id, role: "agent", runtime: "cli", + activity: "coding", lastActivity: "coding", + activityUpdatedAt: Date.now() - 5 * 60_000, activityAgeMs: 5 * 60_000, activityFresh: true, + presenceStatus: "active", currentTask: null, + lastHeartbeatAt: Date.now() - 5 * 60_000, + flairVersion: "1.2.3", harperVersion: "4.5.6", + ...over, + }); let stub: StubFlair | undefined; afterEach(() => { @@ -173,14 +165,14 @@ describe("cli#606 — runDashboard reads the OrgEvent catch-up feed", () => { stub = undefined; }); - /** Run the dashboard against a viewer whose OrgEvent read is answered by `answer`; return the output. */ + /** Run the dashboard against a viewer whose Presence read is answered by `answer`; return the output. */ async function dashboard(answer: FlairRoutes, json = false): Promise { const keyPath = join(root, `${VIEWER}.key`); writeFileSync(keyPath, SEED); stub = installStubFlairFetch({ [VIEWER]: SEED }, (req, url, caller) => url.pathname === "/Agent/" ? Response.json(AGENTS) - : url.pathname.startsWith("/OrgEventCatchup/") + : url.pathname === "/Presence" ? answer(req, url, caller) : undefined, ); @@ -190,75 +182,55 @@ describe("cli#606 — runDashboard reads the OrgEvent catch-up feed", () => { const row = (out: string, id: string) => out.split("\n").find((line) => line.startsWith(`${id} `)) ?? ""; const jsonRows = () => JSON.parse(logs.filter((l) => !l.startsWith("[ERR] ")).join("\n")) as Array>; - test("a nonempty read shows each agent's newest activity", async () => { - const out = await dashboard(orgEventCatchupRoute({ events: [OLDER, NEWER] })); - expect(out).not.toContain("Cannot read OrgEvents"); - expect(row(out, "agent-a")).toContain("5m ago"); - expect(row(out, "agent-a")).toContain("newer task"); - expect(row(out, "agent-a")).not.toContain("older task"); - expect(row(out, "agent-b")).toContain("no activity"); + test("shows each agent's presence status and last-seen time, and 'never seen' for an agent with no record", async () => { + const out = await dashboard(() => Response.json([presence("agent-a", { presenceStatus: "active", lastHeartbeatAt: Date.now() - 5 * 60_000 })])); + expect(out).not.toContain("Cannot read Presence"); + expect(out).toContain("Last Seen"); + expect(row(out, "agent-a")).toContain("active (5m ago)"); + expect(row(out, "agent-b")).toContain("never seen"); }); - test("a feed of several pages is read to the end, each page after the last", async () => { - const route = orgEventCatchupRoute({ events: [OLDER, ...rows(ORG_EVENT_PAGE_SIZE, "agent-c", 40, "f"), NEWER] }); - const out = await dashboard(route); - expect(route.afters).toHaveLength(2); - expect(route.afters[1]! > route.afters[0]!).toBe(true); - expect(new Set(route.served).size).toBe(ORG_EVENT_PAGE_SIZE + 2); - expect(out).not.toContain("incomplete"); - expect(row(out, "agent-a")).toContain("newer task"); + test("an agent whose record has gone stale shows the offline status and its heartbeat time", async () => { + const out = await dashboard(() => Response.json([presence("agent-a", { presenceStatus: "offline", lastHeartbeatAt: Date.now() - 3 * 86_400_000 })])); + expect(row(out, "agent-a")).toContain("offline (3d ago)"); + expect(row(out, "agent-b")).toContain("never seen"); }); - test("a refused read (HTTP 403) is reported, and activity reads unavailable", async () => { + test("a refused read (HTTP 403) is reported, and the column reads unavailable, not never seen", async () => { const out = await dashboard(() => new Response(null, { status: 403 })); - expect(out).toContain(`Cannot read OrgEvents from ${stub!.url} (HTTP 403); activity is unavailable.`); + expect(out).toContain(`Cannot read Presence from ${stub!.url} (HTTP 403); presence is unavailable.`); expect(row(out, "agent-a")).toContain("unavailable"); expect(row(out, "agent-b")).toContain("unavailable"); - expect(out).not.toContain("no activity"); + expect(out).not.toContain("never seen"); }); test("a rejected request is reported with its code, never its message", async () => { const out = await dashboard(() => { throw Object.assign(new TypeError("message-text-not-printed"), { code: "ECONNREFUSED" }); }); - expect(out).toContain(`Cannot read OrgEvents from ${stub!.url} (request failed (ECONNREFUSED)); activity is unavailable.`); + expect(out).toContain(`Cannot read Presence from ${stub!.url} (request failed (ECONNREFUSED)); presence is unavailable.`); expect(out).not.toContain("message-text-not-printed"); expect(row(out, "agent-a")).toContain("unavailable"); }); test.each([ - ["an array body", () => Response.json([NEWER]), "not an object"], - ["an event missing a field", () => Response.json({ events: [{ ...NEWER, summary: undefined }], hasMore: false }), "an event lacks a field the command reads"], + ["a body that is not an array", () => Response.json({ agents: [] }), "body is not an array"], + ["a record missing a field", () => Response.json([{ ...presence("agent-a"), lastHeartbeatAt: undefined }]), "a presence record lacks a field the command reads"], ["a body that is not JSON", () => new Response("not json"), "body is not JSON"], - ])("a malformed response (%s) is reported, and activity reads unavailable", async (_label, answer, note) => { + ])("a malformed response (%s) is reported, and the column reads unavailable", async (_label, answer, note) => { const out = await dashboard(answer); - expect(out).toContain(`Cannot read OrgEvents from ${stub!.url} (malformed response (${note})); activity is unavailable.`); - expect(out).not.toContain("newer task"); + expect(out).toContain(`Cannot read Presence from ${stub!.url} (malformed response (${note})); presence is unavailable.`); expect(row(out, "agent-a")).toContain("unavailable"); + expect(out).not.toContain("never seen"); }); - test("a feed past the page bound is read up to the bound, with advancing cursors over distinct events, and marked incomplete", async () => { - const route = orgEventCatchupRoute({ events: rows(PAST_BOUND, "agent-a", 60, "b") }); - const out = await dashboard(route); - expect(route.afters).toHaveLength(ORG_EVENT_MAX_PAGES); - for (let i = 1; i < route.afters.length; i++) expect(route.afters[i]! > route.afters[i - 1]!).toBe(true); - expect(route.served).toHaveLength(ORG_EVENT_MAX_PAGES * ORG_EVENT_PAGE_SIZE); - expect(new Set(route.served).size).toBe(route.served.length); - expect(out).toContain(`OrgEvents from ${stub!.url} exceed the ${ORG_EVENT_MAX_PAGES}-page read bound; newer events were not read and activity is incomplete.`); - expect(row(out, "agent-a")).toContain("(incomplete)"); - expect(row(out, "agent-a")).not.toContain(`b task ${PAST_BOUND - 1}`); - expect(row(out, "agent-b")).toContain("incomplete"); - expect(out).not.toContain("no activity"); - }); - - test.each([ - ["ok", () => orgEventCatchupRoute({ events: [OLDER, NEWER] }), "newer task"], - ["unavailable", () => () => new Response(null, { status: 403 }), undefined], - ["incomplete", () => orgEventCatchupRoute({ events: rows(PAST_BOUND, "agent-a", 60, "b") }), `b task ${PAST_BOUND - 2}`], - ])("JSON output gives each agent the activity state %s", async (state, answer, summary) => { - await dashboard(answer(), true); + test("JSON output carries each agent's Presence record, last-seen text and presence state", async () => { + await dashboard(() => Response.json([presence("agent-a", { currentTask: "reviewing agent-b PR" })]), true); const out = jsonRows(); - expect(out.map((r) => r.activity)).toEqual([state, state]); - expect(out[0]!.lastEvent?.summary).toBe(summary); + expect(out.map((r) => r.presenceState)).toEqual(["ok", "ok"]); + expect(out[0]!.presence.id).toBe("agent-a"); + expect(out[0]!.lastSeen).toBe("active (5m ago)"); + expect(out[1]!.presence).toBeNull(); + expect(out[1]!.lastSeen).toBe("never seen"); }); }); diff --git a/packages/cli/test/helpers/stub-flair.ts b/packages/cli/test/helpers/stub-flair.ts index 64f2e46c..14a4afcc 100644 --- a/packages/cli/test/helpers/stub-flair.ts +++ b/packages/cli/test/helpers/stub-flair.ts @@ -176,115 +176,6 @@ export function requestSigner(agentId: string, seed: Buffer): (method: string, p }; } -/** The OrgEvent rows and read positions a stub catch-up feed serves. */ -export interface CatchupFeed { - /** OrgEvent rows. `createdAt` and `id` order them; `targetIds` and `expiresAt` are honored. */ - events: ReadonlyArray>; - /** Acknowledged positions (watermarks) by participant id. A GET only creates one when it has none and sends no `since`. */ - watermarks?: Record; - /** Callers treated as admins, who may read any participant's feed. */ - admins?: readonly string[]; - /** - * How far back a first watermark starts, in ms: Flair's - * `FLAIR_CATCHUP_BACKFILL_MS`, whose default is 24h; `0` starts it at now. - * A negative or non-finite value falls back to the default, as Flair's - * parseBackfillMs does. - */ - backfillMs?: number; -} - -/** Flair's default first-watermark backfill (agent-read-position-lib.ts DEFAULT_CATCHUP_BACKFILL_MS). */ -export const DEFAULT_CATCHUP_BACKFILL_MS = 24 * 3_600_000; - -/** A catch-up route, plus a record of what it was asked for and what it served. */ -export interface CatchupRoute { - (req: Request, url: URL, caller: string): Response; - /** Each request's `after` query value, in order (`""` when absent). */ - afters: string[]; - /** The id of every event served, in order. */ - served: string[]; -} - -/** The position of a row: `\t`, compared as a string. */ -const positionOf = (e: Record) => `${String(e.createdAt ?? "")}\t${String(e.id ?? "")}`; - -/** - * A fake of Flair's `GET /OrgEventCatchup/`, modelled on the - * coded contract in Flair's resources/OrgEventCatchup.ts, - * org-event-catchup-lib.ts and agent-read-position-lib.ts. It is a model of - * that code, not Flair itself; the real resource is not exercised here. - * - * - a non-admin caller reading another participant's feed gets 403; - * - a `since` that does not parse as a date gets 400; - * - the cursor is the `after` query when non-empty (an unknown value is used - * as a filter, not refused), else the participant's watermark, else just - * before `since`, else a new watermark `backfillMs` back (created and - * kept; 24h by default, as Flair's default, and now when it is 0); - * - an event is served when it has `createdAt`, is not older than `since`, - * sorts after the cursor, is broadcast (no `targetIds`) or targets the - * participant, and has not expired; - * - events are ordered by position, `limit` sets the page size (default 50, - * clamped to 1..500), and the envelope is - * `{ events, watermark, after, nextAfter, hasMore, pageSize }`, where - * `nextAfter` is the last served event's position, or the cursor itself - * when the page is empty. - * - * A GET never advances an existing watermark. - */ -export function orgEventCatchupRoute(feed: CatchupFeed): CatchupRoute { - const watermarks = feed.watermarks ?? {}; - const admins = new Set(feed.admins ?? []); - const route = ((req: Request, url: URL, caller: string): Response => { - const m = /^\/OrgEventCatchup\/([^/]+)$/.exec(url.pathname); - if (req.method !== "GET" || !m) return Response.json({ error: "not found" }, { status: 404 }); - const participant = decodeURIComponent(m[1]!); - if (!admins.has(caller) && caller !== participant) - return Response.json({ error: "forbidden: can only fetch events for yourself" }, { status: 403 }); - const since = url.searchParams.get("since") || null; - if (since && Number.isNaN(new Date(since).getTime())) - return Response.json({ error: "invalid since timestamp" }, { status: 400 }); - const afterParam = url.searchParams.get("after"); - route.afters.push(afterParam ?? ""); - let watermark: string | null = Object.hasOwn(watermarks, participant) ? watermarks[participant]! : null; - let after: string; - if (afterParam) after = afterParam; - else if (watermark) after = watermark; - else if (since) after = `${since}\t`; - else { - const raw = feed.backfillMs; - const backfill = raw === undefined || !Number.isFinite(raw) || raw < 0 ? DEFAULT_CATCHUP_BACKFILL_MS : raw; - after = `${new Date(Math.max(0, Date.now() - backfill)).toISOString()}\t`; - watermarks[participant] = after; - watermark = after; - } - const raw = url.searchParams.get("limit"); - const n = raw !== null && raw !== "" ? Number(raw) : 50; - const pageSize = Number.isFinite(n) ? Math.min(500, Math.max(1, Math.floor(n))) : 50; - const now = new Date(); - const eligible = feed.events - .filter((e) => { - const createdAt = typeof e.createdAt === "string" ? e.createdAt : ""; - if (!createdAt) return false; - if (since && createdAt < since) return false; - if (positionOf(e) <= after) return false; - const targets = e.targetIds as string[] | null | undefined; - if (targets && targets.length > 0 && !targets.includes(participant)) return false; - if (typeof e.expiresAt === "string" && new Date(e.expiresAt) < now) return false; - return true; - }) - .map((e) => ({ ...e, position: positionOf(e) })) - .sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0)); - const hasMore = eligible.length > pageSize; - const page = hasMore ? eligible.slice(0, pageSize) : eligible; - const nextAfter = page.length > 0 ? page[page.length - 1]!.position : after; - for (const e of page) route.served.push(String(e.id)); - return Response.json({ events: page, watermark, after, nextAfter, hasMore, pageSize }); - }) as CatchupRoute; - route.afters = []; - route.served = []; - return route; -} - /** Write a raw 32-byte key file usable for both request signing and FLAIR_KEY_PATH. */ export function writeKeyFile(dir: string, agent: string, seed: Buffer): string { mkdirSync(dir, { recursive: true }); diff --git a/packages/cli/test/no-identity-defaults-486.test.ts b/packages/cli/test/no-identity-defaults-486.test.ts index ad0d1ed3..3e9db8c2 100644 --- a/packages/cli/test/no-identity-defaults-486.test.ts +++ b/packages/cli/test/no-identity-defaults-486.test.ts @@ -82,7 +82,7 @@ describe("cli#486 — configured identity, no anvil default", () => { process.env.TPS_AGENT_ID = "kern"; await expect(runDashboard({ flairUrl: stub.url })).resolves.toBeUndefined(); - expect(requests).toEqual(["/Agent/", "/OrgEventCatchup/kern"]); + expect(requests).toEqual(["/Agent/", "/Presence"]); } finally { fetchSpy.mockRestore(); stub.stop(); diff --git a/packages/cli/test/office-status.test.ts b/packages/cli/test/office-status.test.ts index 7cc97655..17ae5a7a 100644 --- a/packages/cli/test/office-status.test.ts +++ b/packages/cli/test/office-status.test.ts @@ -5,17 +5,15 @@ * requests, and the no-key test asserts the refusal the real server returns * (HTTP 403) and how office-status reports it. */ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPatchShared } from "./helpers/patch-shared.js"; import { runOfficeStatus } from "../src/commands/office-status.js"; -import { ORG_EVENT_MAX_PAGES, ORG_EVENT_PAGE_SIZE } from "../src/utils/org-event-read.js"; import { type FlairRoutes, installStubFlairFetch, - orgEventCatchupRoute, writeKeyFile, type StubFlair, } from "./helpers/stub-flair.js"; @@ -25,26 +23,28 @@ const patchShared = createPatchShared(); const SEED = Buffer.alloc(32, 0x71); const AGENTS = [ - { id: "anvil", name: "Anvil", role: "engineer", model: "anthropic/claude-sonnet-4-6", status: "active", lastHeartbeat: new Date(Date.now() - 3 * 60_000).toISOString() }, - { id: "ember", name: "Ember", role: "implementer", status: "idle" }, + { id: "agent-a", name: "Agent A", role: "engineer", model: "vendor/model-a", status: "active" }, + { id: "agent-b", name: "Agent B", role: "implementer", status: "idle" }, ]; -const EVENTS = [ - { id: "e1", kind: "task.completed", authorId: "ember", summary: "Implemented ops-71", createdAt: new Date().toISOString() }, - { id: "e2", kind: "agent.heartbeat", authorId: "anvil", summary: "heartbeat", createdAt: new Date().toISOString() }, -]; +/** A Presence record for `id`, 5 minutes stale by default. */ +const presence = (id: string, over: Record = {}) => ({ + id, displayName: id, role: "agent", runtime: "cli", + activity: "coding", lastActivity: "coding", + activityUpdatedAt: Date.now() - 5 * 60_000, activityAgeMs: 5 * 60_000, activityFresh: true, + presenceStatus: "active", currentTask: null, + lastHeartbeatAt: Date.now() - 5 * 60_000, + flairVersion: "1.2.3", harperVersion: "4.5.6", + ...over, +}); /** Flair routes the stub serves once it has verified the caller. */ function routes(req: Request, url: URL, caller: string): Response | undefined { if (url.pathname === "/Agent/") return Response.json(AGENTS); - if (url.pathname.startsWith("/OrgEventCatchup/")) return orgEventCatchupRoute({ events: EVENTS })(req, url, caller); + if (url.pathname === "/Presence") return Response.json([presence("agent-a", { currentTask: "reviewing a PR" })]); return undefined; } -afterEach(() => { - mock.restore(); -}); - let root: string; let stub: StubFlair; let keyPath: string; @@ -55,8 +55,8 @@ let savedExit: typeof process.exit; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "cli554-office-status-")); - keyPath = writeKeyFile(root, "anvil", SEED); - stub = installStubFlairFetch({ anvil: SEED }, routes); + keyPath = writeKeyFile(root, "viewer-a", SEED); + stub = installStubFlairFetch({ "viewer-a": SEED }, routes); output = []; savedLog = console.log; savedError = console.error; @@ -77,22 +77,25 @@ afterEach(() => { }); function baseOpts() { - return { flairUrl: stub.url, agentId: "anvil", keyPath, noColor: true }; + return { flairUrl: stub.url, agentId: "viewer-a", keyPath, noColor: true }; } describe("tps office status", () => { test("renders agent table from Flair", async () => { await runOfficeStatus(baseOpts()); const joined = output.join("\n"); - expect(joined).toContain("Anvil"); - expect(joined).toContain("Ember"); + expect(joined).toContain("Agent A"); + expect(joined).toContain("Agent B"); expect(joined).toContain("implementer"); expect(joined).toContain("engineer"); }); - test("shows task status from OrgEvents", async () => { + test("shows each agent's last-seen presence and current activity", async () => { await runOfficeStatus(baseOpts()); - expect(output.join("\n")).toContain("Implemented ops-71"); + const joined = output.join("\n"); + expect(joined).toContain("reviewing a PR"); + expect(joined).toContain("active (5m ago)"); + expect(joined).toContain("never seen"); }); test("json output includes agents and openPrs arrays", async () => { @@ -100,7 +103,7 @@ describe("tps office status", () => { const parsed = JSON.parse(output.join("")); expect(parsed.agents).toHaveLength(2); expect(parsed.openPrs).toBeArray(); - expect(parsed.agents[0].id).toBe("anvil"); + expect(parsed.agents[0].id).toBe("agent-a"); }); test("no PR section when repo not configured", async () => { @@ -108,59 +111,33 @@ describe("tps office status", () => { expect(output.join("\n")).not.toContain("open PR"); }); - test("shows blocker from OrgEvents", async () => { - stub.stop(); - const blocker = [{ id: "b1", kind: "blocker", authorId: "ember", summary: "Missing Ember PAT", createdAt: new Date().toISOString() }]; - stub = installStubFlairFetch({ anvil: SEED }, (req, url, caller) => - url.pathname === "/Agent/" ? Response.json(AGENTS) : url.pathname.startsWith("/OrgEventCatchup/") ? orgEventCatchupRoute({ events: blocker })(req, url, caller) : undefined, - ); - await runOfficeStatus(baseOpts()); - expect(output.join("\n")).toContain("BLOCKER: Missing Ember PAT"); - }); - test("a missing key sends no Authorization and office-status reports the refusal (HTTP 403)", async () => { - patchShared(process, "exit", mock(((code?: number) => { throw new Error(`exit:${code ?? 0}`); }) as typeof process.exit)); await expect(runOfficeStatus({ ...baseOpts(), keyPath: "/nonexistent" })).rejects.toThrow("exit:1"); expect(output.join("\n")).toContain(`Flair unreachable at ${stub.url} (HTTP 403)`); }); }); /** - * cli#620 — office status reads the viewer's OrgEvent catch-up feed through - * `readOrgEvents`. The catch-up route is `orgEventCatchupRoute`, a fake - * modelled on Flair's coded catch-up contract (see its doc; the real resource - * is not exercised). It registers its own viewer, so the fixtures here name no - * production agent. + * cli#622 — office status reads Flair's office-wide Presence roster through + * `readPresence`. The /Presence fixture is served by the shared verifying stub + * (see its doc); the fixture names no production agent. */ -describe("cli#620 — office status reads the OrgEvent catch-up feed", () => { +describe("cli#622 — office status reads the Presence roster", () => { const VIEWER = "viewer-a"; const AGENT_LIST = [ { id: "agent-a", name: "Agent A", role: "engineer", status: "active" }, { id: "agent-b", name: "Agent B", role: "reviewer", status: "active" }, ]; - const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); - const OLDER = { id: "e1", kind: "task.completed", authorId: "agent-a", summary: "older task", createdAt: at(50) }; - const NEWER = { id: "e2", kind: "task.completed", authorId: "agent-a", summary: "newer task", createdAt: at(5) }; - /** `count` distinct events by `authorId`, one millisecond apart from `minutesAgo`, ids ``. */ - const rows = (count: number, authorId: string, minutesAgo: number, prefix: string) => - Array.from({ length: count }, (_, n) => ({ - id: `${prefix}${String(n).padStart(5, "0")}`, - kind: "task.completed", - authorId, - summary: `${prefix} task ${n}`, - createdAt: new Date(Date.now() - minutesAgo * 60_000 + n).toISOString(), - })); - /** More distinct events than the read bound holds: the bound's pages, plus one. */ - const PAST_BOUND = ORG_EVENT_MAX_PAGES * ORG_EVENT_PAGE_SIZE + 1; - /** Run office status against a viewer whose OrgEvent read is answered by `answer`; return the output. */ + /** Run office status against a viewer whose Presence read is answered by `answer`; return the output. */ async function status(answer: FlairRoutes, json = false): Promise { stub.stop(); + output.length = 0; const key = writeKeyFile(root, VIEWER, SEED); stub = installStubFlairFetch({ [VIEWER]: SEED }, (req, url, caller) => url.pathname === "/Agent/" ? Response.json(AGENT_LIST) - : url.pathname.startsWith("/OrgEventCatchup/") + : url.pathname === "/Presence" ? answer(req, url, caller) : undefined, ); @@ -171,111 +148,57 @@ describe("cli#620 — office status reads the OrgEvent catch-up feed", () => { const jsonAgents = () => (JSON.parse(output.filter((l) => !l.startsWith("[ERR] ")).join("\n")) as { agents: Array> }).agents; - test("a nonempty read shows each agent's newest selected task event and, with no record heartbeat, the newest feed heartbeat", async () => { - const beats = [ - { id: "h1", kind: "agent.heartbeat", authorId: "agent-b", summary: "heartbeat", createdAt: at(50) }, - { id: "h2", kind: "agent.heartbeat", authorId: "agent-b", summary: "heartbeat", createdAt: at(3) }, - ]; - const out = await status(orgEventCatchupRoute({ events: [OLDER, NEWER, ...beats] })); - expect(out).not.toContain("Cannot read OrgEvents"); - expect(row(out, "Agent A")).toContain("newer task"); - expect(row(out, "Agent A")).not.toContain("older task"); - expect(row(out, "Agent B")).toContain("3m ago"); - expect(row(out, "Agent B")).toContain("idle"); + test("shows each agent's last-seen presence and current activity, and 'never seen' for an agent with no record", async () => { + const out = await status(() => Response.json([presence("agent-a", { presenceStatus: "active", lastHeartbeatAt: Date.now() - 3 * 60_000, currentTask: "reviewing a PR" })])); + expect(out).not.toContain("Cannot read Presence"); + expect(out).toContain("LAST SEEN"); + expect(row(out, "Agent A")).toContain("active (3m ago)"); + expect(row(out, "Agent A")).toContain("reviewing a PR"); + expect(row(out, "Agent B")).toContain("never seen"); }); - test("the Agent record's heartbeat takes precedence over a newer feed heartbeat, in the table and JSON", async () => { - const recordBeat = at(30); - const agents = [{ ...AGENT_LIST[0]!, lastHeartbeat: recordBeat }, AGENT_LIST[1]!]; - const feed = orgEventCatchupRoute({ - events: [{ id: "h1", kind: "agent.heartbeat", authorId: "agent-a", summary: "heartbeat", createdAt: at(3) }], - }); - const run = async (json: boolean) => { - stub.stop(); - output.length = 0; - const key = writeKeyFile(root, VIEWER, SEED); - stub = installStubFlairFetch({ [VIEWER]: SEED }, (req, url, caller) => - url.pathname === "/Agent/" ? Response.json(agents) : url.pathname.startsWith("/OrgEventCatchup/") ? feed(req, url, caller) : undefined, - ); - await runOfficeStatus({ flairUrl: stub.url, agentId: VIEWER, keyPath: key, noColor: true, json }); - return output.join("\n"); - }; - const table = await run(false); - expect(row(table, "Agent A")).toContain("30m ago"); - expect(row(table, "Agent A")).not.toContain("3m ago"); - await run(true); - expect(jsonAgents()[0]!.lastHeartbeat).toBe(recordBeat); + test("a stale record shows the offline status and its last-known activity", async () => { + const out = await status(() => Response.json([presence("agent-a", { presenceStatus: "offline", activityFresh: false, currentTask: null, lastActivity: "debugging", lastHeartbeatAt: Date.now() - 3 * 86_400_000 })])); + expect(row(out, "Agent A")).toContain("offline (3d ago)"); + expect(row(out, "Agent A")).toContain("was: debugging"); }); - test("the blocker line shows the newest blocker read, not the first", async () => { - const blockers = [ - { id: "b1", kind: "blocker", authorId: "agent-a", summary: "older blocker", createdAt: at(40) }, - { id: "b2", kind: "blocker", authorId: "agent-b", summary: "newer blocker", createdAt: at(4) }, - ]; - const out = await status(orgEventCatchupRoute({ events: blockers })); - expect(out).toContain("BLOCKER: newer blocker"); - expect(out).not.toContain("older blocker"); - }); - - test("a feed of several pages is read to the end, each page after the last", async () => { - const route = orgEventCatchupRoute({ events: [OLDER, ...rows(ORG_EVENT_PAGE_SIZE, "agent-c", 40, "f"), NEWER] }); - const out = await status(route); - expect(route.afters).toHaveLength(2); - expect(route.afters[1]! > route.afters[0]!).toBe(true); - expect(new Set(route.served).size).toBe(ORG_EVENT_PAGE_SIZE + 2); - expect(out).not.toContain("incomplete"); - expect(row(out, "Agent A")).toContain("newer task"); - }); - - test("a refused read (HTTP 403) is reported, and the task column reads unavailable", async () => { + test("a refused read (HTTP 403) is reported, and the columns read unavailable, not never seen", async () => { const out = await status(() => new Response(null, { status: 403 })); - expect(out).toContain(`Cannot read OrgEvents from ${stub.url} (HTTP 403); activity is unavailable.`); + expect(out).toContain(`Cannot read Presence from ${stub.url} (HTTP 403); presence is unavailable.`); expect(row(out, "Agent A")).toContain("unavailable"); expect(row(out, "Agent B")).toContain("unavailable"); - expect(out).not.toContain("idle"); + expect(out).not.toContain("never seen"); }); test("a rejected request is reported with its code, never its message", async () => { const out = await status(() => { throw Object.assign(new TypeError("message-text-not-printed"), { code: "ECONNREFUSED" }); }); - expect(out).toContain(`Cannot read OrgEvents from ${stub.url} (request failed (ECONNREFUSED)); activity is unavailable.`); + expect(out).toContain(`Cannot read Presence from ${stub.url} (request failed (ECONNREFUSED)); presence is unavailable.`); expect(out).not.toContain("message-text-not-printed"); expect(row(out, "Agent A")).toContain("unavailable"); }); test.each([ - ["an array body", () => Response.json([NEWER]), "not an object"], - ["an event missing a field", () => Response.json({ events: [{ ...NEWER, authorId: 7 }], hasMore: false }), "an event lacks a field the command reads"], - ["a page with no cursor", () => Response.json({ events: [NEWER], hasMore: true }), "nextAfter is missing"], - ])("a malformed response (%s) is reported, and the task column reads unavailable", async (_label, answer, note) => { + ["a body that is not an array", () => Response.json({ agents: [] }), "body is not an array"], + ["a record missing a field", () => Response.json([{ ...presence("agent-a"), presenceStatus: undefined }]), "a presence record lacks a field the command reads"], + ["a body that is not JSON", () => new Response("not json"), "body is not JSON"], + ])("a malformed response (%s) is reported, and the columns read unavailable", async (_label, answer, note) => { const out = await status(answer); - expect(out).toContain(`Cannot read OrgEvents from ${stub.url} (malformed response (${note})); activity is unavailable.`); - expect(out).not.toContain("newer task"); + expect(out).toContain(`Cannot read Presence from ${stub.url} (malformed response (${note})); presence is unavailable.`); expect(row(out, "Agent A")).toContain("unavailable"); + expect(out).not.toContain("never seen"); }); - test("a feed past the page bound is read up to the bound, with advancing cursors over distinct events, and marked incomplete", async () => { - const route = orgEventCatchupRoute({ events: rows(PAST_BOUND, "agent-a", 60, "b") }); - const out = await status(route); - expect(route.afters).toHaveLength(ORG_EVENT_MAX_PAGES); - for (let i = 1; i < route.afters.length; i++) expect(route.afters[i]! > route.afters[i - 1]!).toBe(true); - expect(route.served).toHaveLength(ORG_EVENT_MAX_PAGES * ORG_EVENT_PAGE_SIZE); - expect(new Set(route.served).size).toBe(route.served.length); - expect(out).toContain(`OrgEvents from ${stub.url} exceed the ${ORG_EVENT_MAX_PAGES}-page read bound; newer events were not read and activity is incomplete.`); - expect(row(out, "Agent A")).toContain("(incomplete)"); - expect(row(out, "Agent B")).toContain("incomplete"); - expect(out).not.toContain("idle"); - }); - - test.each([ - ["ok", () => orgEventCatchupRoute({ events: [OLDER, NEWER] }), "newer task"], - ["unavailable", () => () => new Response(null, { status: 403 }), undefined], - ["incomplete", () => orgEventCatchupRoute({ events: rows(PAST_BOUND, "agent-a", 60, "b") }), `b task ${PAST_BOUND - 2}`], - ])("JSON output gives each agent the activity state %s", async (state, answer, summary) => { - await status(answer(), true); + test("JSON output carries each agent's Presence record, last-seen text and presence state", async () => { + await status(() => Response.json([presence("agent-a", { currentTask: "reviewing a PR" })]), true); const agents = jsonAgents(); - expect(agents.map((a) => a.activity)).toEqual([state, state]); - expect(agents[0]!.lastTask?.summary).toBe(summary); + expect(agents.map((a) => a.presenceState)).toEqual(["ok", "ok"]); + expect(agents[0]!.presence.id).toBe("agent-a"); + expect(agents[0]!.lastSeen).toBe("active (5m ago)"); + expect(agents[0]!.doing).toBe("reviewing a PR"); + expect(agents[1]!.presence).toBeNull(); + expect(agents[1]!.lastSeen).toBe("never seen"); }); }); From 568cc37021ce0743e0e1d4e33a21fd44a3dc133a Mon Sep 17 00:00:00 2001 From: flint Date: Tue, 13 Oct 2026 16:45:47 -0700 Subject: [PATCH 2/2] docs(office-status): the header names Presence as the activity source Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/office-status.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/office-status.ts b/packages/cli/src/commands/office-status.ts index 9d8079dc..2aa25016 100644 --- a/packages/cli/src/commands/office-status.ts +++ b/packages/cli/src/commands/office-status.ts @@ -2,8 +2,8 @@ * office-status.ts — `tps office status` * * Full office state at a glance: - * - All agents: role, model, status, last heartbeat - * - Current task (from recent OrgEvents) + * - All agents: role, model, status, and when Flair Presence last saw them + * - Current activity (Presence's currentTask, else its activity) * - Open PRs (via gh-as) * - Task loop cursor age per agent * - Git branch + dirty status for known workspaces