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..fcf2e381 --- /dev/null +++ b/.changelog/unreleased/changed-622-activity-from-presence.md @@ -0,0 +1,23 @@ +- **`tps roster dashboard` and `tps office status` read per-agent presence from the Presence roster of the Flair instance they query.** + + Both views read `GET /Presence` from the queried Flair instance instead of the + viewer's OrgEvent catch-up feed. Agents push heartbeats, their activity and + their current task to Flair; Flair derives each agent's status and whether its + activity is still fresh when it serves the read. 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 also the agent's current task + or activity in `ACTIVITY`, or `was: ` (Flair's `lastActivity`) + once Flair reports the activity stale. Only agents in the Agent list are + displayed. A listed agent with no stored Presence row on the queried instance + reads `no presence on this instance`. An older local row can remain after an + agent moves. Presence is not federated. The presence columns read + `unavailable`, not an empty column, when the read cannot be signed (it is then + not sent) or fails, or when the roster has a missing or invalid field the + views read or two records for one agent. Cross-field checks reject stale + task/activity (a stale record with a current task or non-`idle` activity), + fresh activity equality (fresh activity differing from lastActivity), and + offline freshness (an offline record with fresh activity). Other combinations + are not checked, including status versus a future heartbeat. Office status + prints no blocker line. It prints the table's values with C0, DEL and C1 + control characters replaced by a visible placeholder (U+FFFD); a long value + is not truncated and can widen its row. diff --git a/packages/cli/src/commands/office-status.ts b/packages/cli/src/commands/office-status.ts index af960391..d2820dbd 100644 --- a/packages/cli/src/commands/office-status.ts +++ b/packages/cli/src/commands/office-status.ts @@ -2,8 +2,10 @@ * office-status.ts — `tps office status` * * Full office state at a glance: - * - All agents: role, model, status, last heartbeat - * - Current task (from recent OrgEvents) + * - Agents in the Agent list: role, model, status, presence status and last + * heartbeat on the queried Flair instance (its Presence roster) + * - Current activity (Presence's currentTask, else its activity, shown as + * "was: " once Flair reports the activity stale) * - Open PRs (via gh-as) * - Task loop cursor age per agent * - Git branch + dirty status for known workspaces @@ -16,7 +18,7 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { cursorFilePosition, positionTimestamp, taskLoopCursorPath } from "../utils/flair-task-loop.js"; import { requireLocalAgentId } from "../utils/local-agent.js"; -import { activityState, orgEventReadNotice, readOrgEvents, type OrgEventRecord } from "../utils/org-event-read.js"; +import { NO_PRESENCE_TEXT, presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js"; interface FlairAgent { id?: string; @@ -54,7 +56,17 @@ 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); + +/** + * Replace terminal control characters (C0 0x00-0x1F, DEL 0x7F, C1 0x80-0x9F) + * in a table value with U+FFFD, so each becomes visible. Widths are minimums, not caps. + * Flair caps a current task but does not redact its content + * (resources/Presence.ts), so the renderer makes controls visible here. + */ +function visibleControls(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: deliberate — replace terminal control chars before they reach stdout + return text.replace(/[\x00-\x1f\x7f-\x9f]/g, "\uFFFD"); +} // ── Auth helper ─────────────────────────────────────────────────────────────── function makeAuth(viewerId: string, keyPath: string, method: string, urlPath: string): string | undefined { @@ -140,26 +152,37 @@ 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: the Presence + // roster of the Flair instance at flairUrl (see utils/presence-read.ts). + // Agents push heartbeats and activity there; Flair derives the status and + // whether the activity is fresh. Only agents in the Agent list are displayed. + // A listed agent with no stored Presence row on the queried instance reads + // "no presence on this instance". A read that cannot be signed (it is then + // not sent) or fails, or that returns a roster the reader does not accept, + // is reported, and the columns say "unavailable" instead. + 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)})` : NO_PRESENCE_TEXT; + }; + // What the agent is doing: its current task, else its activity, or + // "was: " once Flair reports the activity stale (Flair then + // serves no current task, and the reader refuses a record that has one). + // Null for a listed agent with no stored Presence row on the queried instance. + const doingText = (id: string): string | null => { + if (state === "unavailable") return "unavailable"; + const rec = presenceByAgent.get(id); + if (!rec) return null; + if (rec.currentTask !== null) return rec.currentTask; + return rec.activityFresh ? rec.activity : `was: ${rec.lastActivity}`; + }; // Fetch open PRs const openPrs: PrInfo[] = repo ? fetchOpenPrs(ghAgent, repo) : []; @@ -170,9 +193,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), }; @@ -187,29 +211,22 @@ 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(28)} ACTIVITY`, nc) ); console.log(dim("─".repeat(74), nc)); for (const ag of agents) { const id = ag.id ?? ag.agentId ?? "?"; - 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 name = visibleControls(ag.name ?? id).padEnd(12); + const role = visibleControls(ag.role ?? "?").padEnd(26); + const model= visibleControls((ag.model ?? "?").split("/").pop() ?? "?").padEnd(18); + const rec = presenceByAgent.get(id); + const lastSeen = visibleControls(lastSeenText(id)); + const doing = visibleControls(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(28)} ${doingColored}`); // Sub-lines: git + cursor const git = gitBranchStatus(ag.workspace); @@ -235,10 +252,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..c7192e9d 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 { NO_PRESENCE_TEXT, presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js"; interface RosterArgs { action: "list" | "show" | "find" | "invite"; @@ -260,64 +260,50 @@ 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: the Presence roster of the + // Flair instance at flairUrl (see utils/presence-read.ts), not the viewer's + // catch-up feed. Agents push heartbeats there; Flair derives the status. + // Only agents in the Agent list are displayed. A listed agent with no stored + // Presence row on the queried instance reads "no presence on this instance". + // A read that cannot be signed (it is then not sent) or fails, or that returns + // a roster the reader does not accept, is reported, and the column says + // "unavailable" instead. + 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)})` : NO_PRESENCE_TEXT; + }; 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 index 406dbbff..82de7ada 100644 --- a/packages/cli/src/utils/org-event-read.ts +++ b/packages/cli/src/utils/org-event-read.ts @@ -1,6 +1,8 @@ /** - * org-event-read.ts — the recent-OrgEvent read behind `tps roster dashboard` - * and `tps office status` (cli#606, cli#620). + * org-event-read.ts — the validated OrgEvent catch-up read behind + * `FlairClient.getEventsSince`: the task loop, `tps agent logs`, `tps status` + * and the agent boot context read through it (cli#606, cli#620, cli#624). The + * dashboard and office status read Presence instead (presence-read.ts, cli#622). * * `GET /OrgEventCatchup/` is the viewer's catch-up feed in Flair: the * events addressed to the viewer or broadcast, after the viewer's acknowledged @@ -104,9 +106,8 @@ const optionalString = (v: unknown) => v === undefined || v === null || typeof v * `parsePage` checks its value); and the optional `targetIds`, which may be * absent or null and is otherwise an array of strings, and `refId`, `detail`, * `scope` and `expiresAt`, which may be absent or null and are otherwise - * strings. These are the fields the dashboard, office status, `tps agent logs`, - * the task loop and its handlers read. Any other field is passed through - * unchecked. + * strings. These are the fields `tps agent logs`, the task loop and its + * handlers read. Any other field is passed through unchecked. */ function isEvent(value: unknown): value is OrgEventRecord { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; @@ -225,23 +226,3 @@ function ok(events: OrgEventRecord[], complete: boolean, nextAfter: string, serv ? { status: "ok", events, complete, nextAfter } : { status: "ok", events, complete, nextAfter, serverTime }; } - -/** 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..282f6cf3 --- /dev/null +++ b/packages/cli/src/utils/presence-read.ts @@ -0,0 +1,210 @@ +/** + * presence-read.ts — the per-agent presence read behind `tps roster dashboard` + * and `tps office status` (cli#622), from the Flair instance the command + * queries. + * + * `GET /Presence` returns one record per stored local Presence row, keyed by + * agent. Agents push heartbeats, their activity and their current task; Flair + * derives each record's status + * (`active` / `idle` / `offline`) and whether its activity is still fresh when + * it serves the read (resources/Presence.ts). Unlike the viewer's OrgEvent + * catch-up feed (events addressed to the viewer or broadcast, from the viewer's + * acknowledged position), it is per-agent — the source that matches "what is + * each agent doing / when was it last seen". + * + * SCOPE: the commands display only agents in the Agent list. A listed agent + * with no stored Presence row on the queried instance reads "no presence on + * this instance". An older local row can remain after an agent moves. Flair + * federation replicates Agent records but not Presence. + * + * Flair serves the roster to a verified reader (a registered agent's + * TPS-Ed25519 signature, which is what both commands sign with); an unverified + * caller gets 401 by default. `readPresence` signs one GET and sends it. When + * the signer throws or returns no signature, it sends nothing: it never makes + * an unsigned read, which an instance with Flair's public-roster opt-in would + * answer. It validates the body and returns either + * + * - `ok`, with one record per stored local Presence row, keyed by agent, + * holding only the fields it validated (`PresenceRecord`), or + * - `unavailable`, with a bounded reason: the HTTP status, a fixed note + * naming what is wrong with the request or the response, or the code or + * class of the error a failed signer or a rejected request raised. The + * reason never includes the response body or an error message. + * + * Validation covers every field the reader returns, and is all or nothing. The + * whole read is `unavailable` when one record has such a field missing or + * invalid (a status or activity outside Flair's vocabulary, a heartbeat time + * that is not a finite number, or a current task other than null or the output + * of slicing a non-empty trimmed string to 200 characters), or when two records + * share an id. Cross-field checks reject: stale task/activity (a stale record + * with a current task or non-`idle` activity), fresh activity equality (fresh + * activity differing from lastActivity), and offline freshness (an offline + * record with fresh activity). Other combinations are not checked, including + * status versus a future heartbeat. + */ + +/** Flair's derived presence status (resources/Presence.ts derivePresenceStatus). */ +export type PresenceStatus = "active" | "idle" | "offline"; +/** Flair's activity vocabulary (resources/Presence.ts VALID_ACTIVITIES). */ +export type PresenceActivity = "coding" | "reviewing" | "planning" | "debugging" | "idle"; + +const PRESENCE_STATUSES: ReadonlySet = new Set(["active", "idle", "offline"]); +const PRESENCE_ACTIVITIES: ReadonlySet = new Set(["coding", "reviewing", "planning", "debugging", "idle"]); +/** Flair caps a current task at this many characters (CURRENT_TASK_MAX_LENGTH). */ +const CURRENT_TASK_MAX_LENGTH = 200; + +/** + * A Presence record as the commands read it: the validated fields only. The + * reader drops every other field Flair serves. + */ +export interface PresenceRecord { + id: string; + /** Derived by Flair from the heartbeat's age. */ + presenceStatus: PresenceStatus; + /** Epoch ms of the agent's last heartbeat. */ + lastHeartbeatAt: number; + /** The current activity; Flair serves "idle" once the activity is stale. */ + activity: PresenceActivity; + /** The last activity the agent asserted, fresh or not; Flair serves "idle" when it asserted none. */ + lastActivity: PresenceActivity; + /** Flair's verdict: is the activity still current? */ + activityFresh: boolean; + /** The agent's current task; Flair serves null once the activity is stale. */ + currentTask: string | null; +} + +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"; +} + +/** + * The validated record, or a fixed note saying what is wrong with it. Field + * names in the notes are fixed strings, so a note is safe to print. + */ +function toPresenceRecord(value: unknown): PresenceRecord | { invalid: string } { + if (typeof value !== "object" || value === null || Array.isArray(value)) return { invalid: "an entry is not an object" }; + const r = value as Record; + const bad = (field: string) => ({ invalid: `a presence record has a missing or invalid ${field}` }); + if (typeof r.id !== "string" || r.id.length === 0) return bad("id"); + if (typeof r.presenceStatus !== "string" || !PRESENCE_STATUSES.has(r.presenceStatus)) return bad("presenceStatus"); + if (typeof r.lastHeartbeatAt !== "number" || !Number.isFinite(r.lastHeartbeatAt)) return bad("lastHeartbeatAt"); + if (typeof r.activity !== "string" || !PRESENCE_ACTIVITIES.has(r.activity)) return bad("activity"); + if (typeof r.lastActivity !== "string" || !PRESENCE_ACTIVITIES.has(r.lastActivity)) return bad("lastActivity"); + if (typeof r.activityFresh !== "boolean") return bad("activityFresh"); + const task = r.currentTask; + // Flair trims, then slices to the cap; the slice can end in whitespace. + if (task !== null && ( + typeof task !== "string" || task.length === 0 || task.length > CURRENT_TASK_MAX_LENGTH || + task.trimStart() !== task || (task.length < CURRENT_TASK_MAX_LENGTH && task.trimEnd() !== task) + )) + return bad("currentTask"); + // For rows written through Flair's heartbeat path, the activity stamp is + // never newer than the heartbeat. Flair's read path does not enforce this + // for stored rows. + if (!r.activityFresh && (task !== null || r.activity !== "idle")) + return { invalid: "a stale presence record carries a current task or an activity other than idle" }; + if (r.activityFresh && r.activity !== r.lastActivity) + return { invalid: "a fresh presence record's activity differs from its last activity" }; + if (r.activityFresh && r.presenceStatus === "offline") + return { invalid: "an offline presence record reports fresh activity" }; + return { + id: r.id, + presenceStatus: r.presenceStatus as PresenceStatus, + lastHeartbeatAt: r.lastHeartbeatAt, + activity: r.activity as PresenceActivity, + lastActivity: r.lastActivity as PresenceActivity, + activityFresh: r.activityFresh, + currentTask: task, + }; +} + +/** Read the queried instance's Presence roster; see the module doc. */ +export async function readPresence(opts: PresenceReadOpts): Promise { + const path = "/Presence"; + let auth: string | undefined; + try { + auth = opts.sign("GET", path); + } catch (err) { + return { status: "unavailable", reason: `signing failed (${errorTag(err)})` }; + } + if (!auth) return { status: "unavailable", reason: "not signed (no request sent)" }; + let res: Response; + try { + res = await fetch(`${opts.flairUrl}${path}`, { 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)" }; + const agents: PresenceRecord[] = []; + const ids = new Set(); + for (const value of body) { + const rec = toPresenceRecord(value); + if ("invalid" in rec) return { status: "unavailable", reason: `malformed response (${rec.invalid})` }; + if (ids.has(rec.id)) return { status: "unavailable", reason: "malformed response (two presence records share an id)" }; + ids.add(rec.id); + agents.push(rec); + } + return { status: "ok", agents }; +} + +/** + * The read's state, which both commands emit per agent in their JSON + * (`presenceState`). Their tables print the derived status, or `unavailable`. + */ +export type PresenceState = "ok" | "unavailable"; + +export function presenceState(read: PresenceRead): PresenceState { + return read.status === "ok" ? "ok" : "unavailable"; +} + +/** What both commands print for a listed agent with no stored Presence row on the queried instance. */ +export const NO_PRESENCE_TEXT = "no presence on this instance"; + +/** + * 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-signer-commands.test.ts b/packages/cli/test/flair-signer-commands.test.ts index 32541768..2b773578 100644 --- a/packages/cli/test/flair-signer-commands.test.ts +++ b/packages/cli/test/flair-signer-commands.test.ts @@ -1,7 +1,7 @@ /** * cli#554 — the command-level Flair signers. `runInit` registers through * `registerWithFlair`, and `runDashboard`'s nested `makeAuth` signs its Agent - * and OrgEvent reads. Each is driven against the shared verifying stub + * and Presence reads. Each is driven against the shared verifying stub * (`helpers/stub-flair.ts`): the stub accepts a request signed by a caller it * knows and refuses one it does not, exactly as real Flair does. */ @@ -12,11 +12,12 @@ 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, + type PresenceRow, + presenceRecords, + presenceRoute, pubkeyFromSeed, startStubFlair, type StubFlair, @@ -111,7 +112,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 +144,20 @@ 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 the queried Flair instance's + * Presence roster through `readPresence`. `/Presence` is served by + * `presenceRoute`, the shared stub's fake of Flair's coded contract: it derives + * each record's status from stored heartbeat rows as resources/Presence.ts + * does, and the stub refuses an unsigned read with Flair's 401 (see their + * docs). The fixtures name 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 stored Presence row: `id`'s last heartbeat `minutesAgo` minutes ago, asserting `coding`. */ + const beat = (id: string, minutesAgo: number, over: Partial = {}): PresenceRow => ({ + agentId: id, lastHeartbeatAt: Date.now() - minutesAgo * 60_000, activity: "coding", ...over, + }); let stub: StubFlair | undefined; afterEach(() => { @@ -173,92 +165,100 @@ 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. */ - async function dashboard(answer: FlairRoutes, json = false): Promise { + /** + * Run the dashboard against a viewer whose Presence read is answered by + * `answer`; return the output. `afterAgentRead` runs once the Agent list has + * been answered, before the Presence read. + */ + async function dashboard(answer: FlairRoutes, opts: { json?: boolean; afterAgentRead?: (keyPath: string) => void } = {}): 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/") - ? answer(req, url, caller) - : undefined, - ); - await runDashboard({ flairUrl: stub.url, agentId: VIEWER, keyPath, json }); + stub = installStubFlairFetch({ [VIEWER]: SEED }, (req, url, caller) => { + if (url.pathname === "/Agent/") { + opts.afterAgentRead?.(keyPath); + return Response.json(AGENTS); + } + return url.pathname === "/Presence" ? answer(req, url, caller) : undefined; + }); + await runDashboard({ flairUrl: stub.url, agentId: VIEWER, keyPath, json: opts.json }); return logs.join("\n"); } 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.each([ + ["under Flair's idle threshold", 0.5, "active (just now)"], + ["past Flair's idle threshold", 5, "idle (5m ago)"], + ["past Flair's offline threshold", 3 * 1440, "offline (3d ago)"], + ])("a heartbeat %s shows the status Flair derives and its age; a listed agent with no stored Presence row on the queried instance reads 'no presence on this instance'", async (_label, minutesAgo, shown) => { + const out = await dashboard(presenceRoute([beat("agent-a", minutesAgo)])); + expect(out).not.toContain("Cannot read Presence"); + expect(out).toContain("Last Seen"); + expect(row(out, "agent-a")).toContain(shown); + expect(row(out, "agent-b")).toContain("no presence on this instance"); + expect(out).not.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] }); + test("accepts a task whose trim-then-slice output ends in a space", async () => { + const task = "x".repeat(199) + " "; + const route = presenceRoute([beat("agent-a", 0.5, { currentTask: ` ${task}tail ` })]); 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"); + expect(out).not.toContain("Cannot read Presence"); + expect(row(out, "agent-a")).toContain("active (just now)"); + stub?.stop(); + logs.length = 0; + await dashboard(route, { json: true }); + expect(jsonRows()[0]!.presence.currentTask).toBe(task); + expect(jsonRows()[0]!.presenceState).toBe("ok"); + }); + + test("a Presence read signed with a key Flair does not hold for the viewer gets Flair's 401, is reported, and the column reads unavailable", async () => { + const out = await dashboard(presenceRoute([beat("agent-a", 0.5)]), { afterAgentRead: (keyPath) => writeFileSync(keyPath, Buffer.alloc(32, 0x52)) }); + expect(out).toContain(`Cannot read Presence from ${stub!.url} (HTTP 401); presence is unavailable.`); + expect(row(out, "agent-a")).toContain("unavailable"); + expect(row(out, "agent-b")).toContain("unavailable"); + expect(out).not.toContain("no presence on this instance"); }); - test("a refused read (HTTP 403) is reported, and activity reads unavailable", async () => { - const out = await dashboard(() => new Response(null, { status: 403 })); - expect(out).toContain(`Cannot read OrgEvents from ${stub!.url} (HTTP 403); activity is unavailable.`); + test("a Presence read the command cannot sign is not sent, is reported, and the column reads unavailable", async () => { + const out = await dashboard(presenceRoute([beat("agent-a", 0.5)]), { afterAgentRead: (keyPath) => rmSync(keyPath) }); + expect(out).toContain(`Cannot read Presence from ${stub!.url} (not signed (no request sent)); 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("no presence on this instance"); }); 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 lastHeartbeatAt", () => Response.json(presenceRecords([beat("agent-a", 0.5)]).map((r) => ({ ...r, lastHeartbeatAt: undefined }))), "a presence record has a missing or invalid lastHeartbeatAt"], + ["a status outside Flair's vocabulary", () => Response.json(presenceRecords([beat("agent-a", 0.5)]).map((r) => ({ ...r, presenceStatus: "busy" }))), "a presence record has a missing or invalid presenceStatus"], + ["two records for one agent", () => Response.json(presenceRecords([beat("agent-a", 0.5), beat("agent-a", 5)])), "two presence records share an id"], ["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("busy"); + expect(out).not.toContain("no presence on this instance"); }); - 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 validated Presence fields, last-seen text and presence state", async () => { + await dashboard(presenceRoute([beat("agent-a", 5, { currentTask: "reviewing agent-b PR" })]), { json: 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]!.presence.currentTask).toBe("reviewing agent-b PR"); + expect(out[0]!.lastSeen).toBe("idle (5m ago)"); + expect(out[1]!.presence).toBeNull(); + expect(out[1]!.lastSeen).toBe("no presence on this instance"); }); }); diff --git a/packages/cli/test/helpers/stub-flair.ts b/packages/cli/test/helpers/stub-flair.ts index 64f2e46c..2153d8dd 100644 --- a/packages/cli/test/helpers/stub-flair.ts +++ b/packages/cli/test/helpers/stub-flair.ts @@ -8,11 +8,17 @@ * Real Flair refuses an unverified caller, so a stub that ignores the * Authorization header lets a test keep passing after the client's signing * breaks (cli#554). `stubFlairHandler` verifies the caller before it answers: - * - no or malformed header -> 403 { type: "error:AccessViolation", ... } + * - a malformed TPS-Ed25519 header -> 401 { error: "invalid_authorization_header" } + * (Flair's auth middleware) + * - no header on `GET /Presence` -> 401 { error: "authentication required" } + * (Presence.get()'s default anonymous response; resources/Presence.ts) + * - malformed Bearer token -> 401 { error: "invalid token" } + * (Harper authentication rejects it before Presence runs) * - timestamp outside the window -> 401 { error: "timestamp_out_of_window" } * - a nonce seen before -> 401 { error: "nonce_replay_detected" } * - a caller with no registered key -> 401 { error: "unknown_agent" } * - a bad signature -> 401 { error: "invalid_signature" } + * The stub models no admin credential. * * Built in it answers `GET /Health` (unauth, a liveness probe) and * `GET /Agent/` (the public-key read the production client makes). A test @@ -64,6 +70,11 @@ function publicKeyBase64(caller: StubFlairCaller): string { return typeof caller === "string" ? caller : pubkeyFromSeed(caller).toString("base64"); } +/** The response Presence.get() returns to an anonymous reader (resources/Presence.ts, flair#1880). */ +function presenceAuthenticationRequired(): Response { + return Response.json({ error: "authentication required" }, { status: 401 }); +} + /** The response Flair returns to an unauthenticated caller. */ function accessViolation(url: URL): Response { return Response.json( @@ -105,8 +116,14 @@ export function stubFlairHandler( const url = new URL(req.url); if (url.pathname === "/Health") return new Response("ok"); const header = req.headers.get("Authorization") ?? ""; - const auth = header.length <= 4096 ? /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/.exec(header) : null; - if (!auth) return accessViolation(url); + const tpsScheme = /^TPS-Ed25519(?:\s|$)/i.test(header); + const auth = tpsScheme && header.length <= 4096 ? /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/i.exec(header) : null; + if (!auth) { + if (tpsScheme) return Response.json({ error: "invalid_authorization_header" }, { status: 401 }); + if (header.startsWith("Bearer ")) return Response.json({ error: "invalid token" }, { status: 401 }); + if (req.method === "GET" && url.pathname === "/Presence") return presenceAuthenticationRequired(); + return accessViolation(url); + } const [, caller, timestamp, nonce, signature] = auth; const refuse = (error: string) => Response.json({ error }, { status: 401 }); const now = Date.now(); @@ -285,6 +302,84 @@ export function orgEventCatchupRoute(feed: CatchupFeed): CatchupRoute { return route; } +/** A Presence row as a heartbeat stores it (Flair's buildPresenceRecord). */ +export interface PresenceRow { + agentId: string; + /** Epoch ms of the agent's last heartbeat. */ + lastHeartbeatAt: number; + /** The activity the agent last asserted; Flair reads a missing one as "idle". */ + activity?: string; + /** Epoch ms the activity was last asserted; null falls back to lastHeartbeatAt. */ + activityUpdatedAt?: number | null; + /** The current task the agent last asserted. */ + currentTask?: string | null; +} + +/** Flair's default presence thresholds (resources/Presence.ts idleThresholdMs / offlineThresholdMs). */ +export const PRESENCE_IDLE_THRESHOLD_MS = 90_000; +export const PRESENCE_OFFLINE_THRESHOLD_MS = 600_000; + +/** + * The records Flair's `GET /Presence` serves a signature-verified reader for + * `rows`, derived as resources/Presence.ts get() derives them at `now`, with + * the default thresholds: + * + * - `presenceStatus` from the heartbeat's age: `active` under 90 s (or in + * the future), `idle` under 10 min, else `offline`; + * - the activity is fresh while its stamp (`activityUpdatedAt`, else + * `lastHeartbeatAt`) is under 10 min old (or in the future); + * - `activity` is the asserted activity while fresh and `idle` once stale; + * `lastActivity` is the asserted activity either way (`idle` when none); + * - `currentTask` is trimmed, then sliced to 200 characters while fresh, + * else null (an empty task is null); + * - `activityUpdatedAt` is the resolved stamp and `activityAgeMs` its age. + * + * It is a model of that code, not Flair itself; the real resource is not + * exercised here. Agent metadata and the version stamps are not modelled: + * `displayName` is the id, `role` is "agent", and `runtime`, `flairVersion` and + * `harperVersion` are null. + */ +export function presenceRecords(rows: ReadonlyArray, now = Date.now()): Record[] { + return rows.map((row) => { + const freshnessAt = + typeof row.activityUpdatedAt === "number" && Number.isFinite(row.activityUpdatedAt) + ? row.activityUpdatedAt + : row.lastHeartbeatAt; + const sinceBeat = now - row.lastHeartbeatAt; + const presenceStatus = + sinceBeat < PRESENCE_IDLE_THRESHOLD_MS ? "active" : sinceBeat < PRESENCE_OFFLINE_THRESHOLD_MS ? "idle" : "offline"; + const activityFresh = now - freshnessAt < PRESENCE_OFFLINE_THRESHOLD_MS; + const rawActivity = typeof row.activity === "string" && row.activity.length > 0 ? row.activity : "idle"; + const task = typeof row.currentTask === "string" ? row.currentTask.trim().slice(0, 200) : ""; + return { + id: row.agentId, + displayName: row.agentId, + role: "agent", + runtime: null, + activity: activityFresh ? rawActivity : "idle", + lastActivity: rawActivity, + activityUpdatedAt: freshnessAt, + activityAgeMs: Math.max(0, now - freshnessAt), + activityFresh, + presenceStatus, + currentTask: activityFresh && task.length > 0 ? task : null, + lastHeartbeatAt: row.lastHeartbeatAt, + flairVersion: null, + harperVersion: null, + }; + }); +} + +/** + * A fake of Flair's `GET /Presence` for a verified caller: answers with + * `presenceRecords(rows)` at the time of the request. The shared stub has + * already refused an unverified caller with 401 (see `stubFlairHandler`). + */ +export function presenceRoute(rows: ReadonlyArray): FlairRoutes { + return (req, url) => + req.method === "GET" && url.pathname === "/Presence" ? Response.json(presenceRecords(rows)) : undefined; +} + /** 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..b61e0597 100644 --- a/packages/cli/test/no-identity-defaults-486.test.ts +++ b/packages/cli/test/no-identity-defaults-486.test.ts @@ -82,7 +82,8 @@ 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"]); + // No key exists for kern, so the Presence read cannot be signed and is not sent (utils/presence-read.ts). + expect(requests).toEqual(["/Agent/"]); } finally { fetchSpy.mockRestore(); stub.stop(); diff --git a/packages/cli/test/office-status.test.ts b/packages/cli/test/office-status.test.ts index 0576db4f..b6a528c2 100644 --- a/packages/cli/test/office-status.test.ts +++ b/packages/cli/test/office-status.test.ts @@ -5,17 +5,18 @@ * 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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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, + type PresenceRow, + presenceRecords, + presenceRoute, writeKeyFile, type StubFlair, } from "./helpers/stub-flair.js"; @@ -23,27 +24,24 @@ import { const patchShared = createPatchShared(); const SEED = Buffer.alloc(32, 0x71); +/** A key the stub does not hold for the viewer. */ +const OTHER_SEED = Buffer.alloc(32, 0x72); 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 stored Presence row: `id`'s last heartbeat `minutesAgo` minutes ago, asserting `coding`. */ +const beat = (id: string, minutesAgo: number, over: Partial = {}): PresenceRow => ({ + agentId: id, lastHeartbeatAt: Date.now() - minutesAgo * 60_000, activity: "coding", ...over, +}); /** Flair routes the stub serves once it has verified the caller. */ -function routes(req: Request, url: URL, caller: string): Response | undefined { +const routes: FlairRoutes = (req, url, caller) => { if (url.pathname === "/Agent/") return Response.json(AGENTS); - if (url.pathname.startsWith("/OrgEventCatchup/")) return orgEventCatchupRoute({ events: EVENTS })(req, url, caller); - return undefined; -} - -afterEach(() => { - mock.restore(); -}); + return presenceRoute([beat("agent-a", 5, { currentTask: "reviewing a PR" })])(req, url, caller); +}; let root: string; let stub: StubFlair; @@ -55,8 +53,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 +75,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 stored presence status, heartbeat age and current task on the queried instance", 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("idle (5m ago)"); + expect(joined).toContain("no presence on this instance"); }); test("json output includes agents and openPrs arrays", async () => { @@ -100,7 +101,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,175 +109,177 @@ 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 the queried Flair instance's Presence roster + * through `readPresence`. `/Presence` is served by `presenceRoute`, the shared + * stub's fake of Flair's coded contract: it derives each record's status and + * activity freshness from stored heartbeat rows as resources/Presence.ts does, + * and the stub refuses an unsigned read with Flair's 401 (see their docs). The + * fixtures name 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. */ - async function status(answer: FlairRoutes, json = false): Promise { + + /** + * Run office status against a viewer whose Presence read is answered by + * `answer`; return the output. `afterAgentRead` runs once the Agent list has + * been answered, before the Presence read. + */ + async function status(answer: FlairRoutes, opts: { json?: boolean; afterAgentRead?: (keyPath: string) => void } = {}): 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/") - ? answer(req, url, caller) - : undefined, - ); - await runOfficeStatus({ flairUrl: stub.url, agentId: VIEWER, keyPath: key, noColor: true, json }); + stub = installStubFlairFetch({ [VIEWER]: SEED }, (req, url, caller) => { + if (url.pathname === "/Agent/") { + opts.afterAgentRead?.(key); + return Response.json(AGENT_LIST); + } + return url.pathname === "/Presence" ? answer(req, url, caller) : undefined; + }); + await runOfficeStatus({ flairUrl: stub.url, agentId: VIEWER, keyPath: key, noColor: true, json: opts.json }); return output.join("\n"); } const row = (out: string, name: string) => out.split("\n").find((line) => line.includes(` ${name} `)) ?? ""; 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"); + /** Flair's records for `rows` with `over` applied to the first. */ + const corrupt = (rows: PresenceRow[], over: Record): FlairRoutes => () => + Response.json(presenceRecords(rows).map((r, i) => (i === 0 ? { ...r, ...over } : r))); + + test("shows presence status and current task, and 'no presence on this instance' for a listed agent with no stored Presence row on the queried instance", async () => { + const out = await status(presenceRoute([beat("agent-a", 0.5, { currentTask: "reviewing a PR" })])); + expect(out).not.toContain("Cannot read Presence"); + expect(out).toContain("LAST SEEN"); + expect(row(out, "Agent A")).toContain("active (just now)"); + expect(row(out, "Agent A")).toContain("reviewing a PR"); + expect(row(out, "Agent B")).toContain("no presence on this instance"); + expect(row(out, "Agent B").trimEnd().endsWith(" —")).toBe(true); + expect(out).not.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 current task's control characters are replaced in the table, keeping its other text", async () => { + // ESC (window/cursor control), BEL, an internal newline and a C1 byte. + const task = "deploy\x1b[2J\x1b[Howned\x1b]0;pwned\x07\nforged\u009fline"; + const out = await status(presenceRoute([beat("agent-a", 0.5, { currentTask: task })])); + const line = row(out, "Agent A"); + // The text survives, on the one row. + expect(line).toContain("deploy"); + expect(line).toContain("owned"); + expect(line).toContain("forged"); + // Each control became a visible placeholder; none reaches the terminal raw + // (the command runs noColor, so any raw byte here is the task's). + expect(line).toContain("\uFFFD"); + expect(out).not.toContain("\x1b"); + expect(out).not.toContain("\x07"); + expect(out).not.toContain("\u009f"); }); - 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("the --json output keeps a current task's control characters (JSON escapes them)", async () => { + const task = "deploy\x1b[2J\nnext\u009f"; + const out = await status(presenceRoute([beat("agent-a", 0.5, { currentTask: task })]), { json: true }); + expect(jsonAgents()[0]!.presence.currentTask).toBe(task); + expect(jsonAgents()[0]!.doing).toBe(task); + expect(out).toContain("\\u001b"); }); - 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] }); + test("accepts a task whose trim-then-slice output ends in a space", async () => { + const task = "x".repeat(199) + " "; + const route = presenceRoute([beat("agent-a", 0.5, { currentTask: ` ${task}tail ` })]); 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"); + expect(out).not.toContain("Cannot read Presence"); + expect(row(out, "Agent A")).toContain(task); + await status(route, { json: true }); + expect(jsonAgents()[0]!.presence.currentTask).toBe(task); + expect(jsonAgents()[0]!.doing).toBe(task); + }); + + test("a heartbeat past Flair's idle threshold reads idle, and its still-fresh activity shows", async () => { + const out = await status(presenceRoute([beat("agent-a", 5, { activity: "debugging" })])); + expect(row(out, "Agent A")).toContain("idle (5m ago)"); + expect(row(out, "Agent A")).toContain("debugging"); + expect(row(out, "Agent A")).not.toContain("was:"); + }); + + test("a heartbeat past Flair's offline threshold reads offline with its last-known activity, and its stale task is not shown", async () => { + const out = await status(presenceRoute([beat("agent-a", 3 * 1440, { activity: "debugging", currentTask: "a finished task" })])); + expect(row(out, "Agent A")).toContain("offline (3d ago)"); + expect(row(out, "Agent A")).toContain("was: debugging"); + expect(out).not.toContain("a finished task"); }); - test("a refused read (HTTP 403) is reported, and the task column reads unavailable", async () => { - const out = await status(() => new Response(null, { status: 403 })); - expect(out).toContain(`Cannot read OrgEvents from ${stub.url} (HTTP 403); activity is unavailable.`); + test("an activity Flair reports stale reads was: while the heartbeat is live", async () => { + const out = await status(presenceRoute([beat("agent-a", 0.5, { activity: "planning", activityUpdatedAt: Date.now() - 20 * 60_000 })])); + expect(row(out, "Agent A")).toContain("active (just now)"); + expect(row(out, "Agent A")).toContain("was: planning"); + }); + + test("a Presence read signed with a key Flair does not hold for the viewer gets Flair's 401, is reported, and the columns read unavailable", async () => { + const out = await status(presenceRoute([beat("agent-a", 0.5)]), { afterAgentRead: (key) => writeFileSync(key, OTHER_SEED) }); + expect(out).toContain(`Cannot read Presence from ${stub.url} (HTTP 401); 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("no presence on this instance"); + }); + + test("a Presence read the command cannot sign is not sent, is reported, and the columns read unavailable", async () => { + const out = await status(presenceRoute([beat("agent-a", 0.5)]), { afterAgentRead: (key) => rmSync(key) }); + expect(out).toContain(`Cannot read Presence from ${stub.url} (not signed (no request sent)); presence is unavailable.`); + expect(row(out, "Agent A")).toContain("unavailable"); + expect(row(out, "Agent B")).toContain("unavailable"); + expect(out).not.toContain("no presence on this instance"); }); 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, position: `${NEWER.createdAt}\t${NEWER.id}` }], 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 body that is not JSON", () => new Response("not json"), "body is not JSON"], + ["a status outside Flair's vocabulary", corrupt([beat("agent-a", 0.5)], { presenceStatus: "busy" }), "a presence record has a missing or invalid presenceStatus"], + ["a current task that is not a string", corrupt([beat("agent-a", 0.5)], { currentTask: 42 }), "a presence record has a missing or invalid currentTask"], + ["an activityFresh that is not a boolean", corrupt([beat("agent-a", 0.5)], { activityFresh: "yes" }), "a presence record has a missing or invalid activityFresh"], + ["a record missing lastActivity", corrupt([beat("agent-a", 0.5)], { lastActivity: undefined }), "a presence record has a missing or invalid lastActivity"], + ["a current task of only whitespace", corrupt([beat("agent-a", 0.5)], { currentTask: " " }), "a presence record has a missing or invalid currentTask"], + ["a stale record with a current task", corrupt([beat("agent-a", 60)], { currentTask: "a stale task" }), "a stale presence record carries a current task or an activity other than idle"], + ["two records for one agent", () => Response.json(presenceRecords([beat("agent-a", 0.5), beat("agent-a", 5)])), "two presence records share an id"], + ])("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(row(out, "Agent B")).toContain("unavailable"); + expect(out).not.toContain("a stale task"); + expect(out).not.toContain("no presence on this instance"); }); - 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 validated Presence fields, last-seen text, activity and presence state", async () => { + await status(presenceRoute([beat("agent-a", 5, { currentTask: "reviewing a PR" })]), { json: 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(Object.keys(agents[0]!.presence).sort()).toEqual( + ["activity", "activityFresh", "currentTask", "id", "lastActivity", "lastHeartbeatAt", "presenceStatus"], + ); + expect(agents[0]!.presence.presenceStatus).toBe("idle"); + expect(agents[0]!.lastSeen).toBe("idle (5m ago)"); + expect(agents[0]!.doing).toBe("reviewing a PR"); + expect(agents[1]!.presence).toBeNull(); + expect(agents[1]!.lastSeen).toBe("no presence on this instance"); + expect(agents[1]!.doing).toBeNull(); }); }); @@ -308,12 +311,12 @@ describe("cli#624 — office status reads the task loop cursor in either format" ["the legacy since format", (ts: string) => ({ since: ts })], ])("a cursor in %s gives its timestamp in JSON and its age in the table", async (_label, body) => { const ts = new Date(Date.now() - 7 * 60_000).toISOString(); - writeCursor("anvil", JSON.stringify(body(ts))); + writeCursor("agent-a", JSON.stringify(body(ts))); await runOfficeStatus({ ...baseOpts(), json: true }); const agents = jsonAgents(); - expect(agents.find((a) => a.id === "anvil")!.cursorSince).toBe(ts); - expect(agents.find((a) => a.id === "ember")!.cursorSince).toBeNull(); + expect(agents.find((a) => a.id === "agent-a")!.cursorSince).toBe(ts); + expect(agents.find((a) => a.id === "agent-b")!.cursorSince).toBeNull(); output.length = 0; await runOfficeStatus(baseOpts()); @@ -325,9 +328,9 @@ describe("cli#624 — office status reads the task loop cursor in either format" ["neither a position nor a since", JSON.stringify({ updatedAt: new Date().toISOString() })], ["a position with no parseable timestamp", JSON.stringify({ position: "not-a-date\te9" })], ])("a cursor file holding %s shows no cursor", async (_label, content) => { - writeCursor("anvil", content); + writeCursor("agent-a", content); await runOfficeStatus({ ...baseOpts(), json: true }); - expect(jsonAgents().find((a) => a.id === "anvil")!.cursorSince).toBeNull(); + expect(jsonAgents().find((a) => a.id === "agent-a")!.cursorSince).toBeNull(); output.length = 0; await runOfficeStatus(baseOpts()); expect(output.join("\n")).not.toContain("cursor:"); diff --git a/packages/cli/test/presence-flair-contract.test.ts b/packages/cli/test/presence-flair-contract.test.ts new file mode 100644 index 00000000..f76d506f --- /dev/null +++ b/packages/cli/test/presence-flair-contract.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { readPresence } from "../src/utils/presence-read.js"; +import { pubkeyFromSeed, requestSigner } from "./helpers/stub-flair.js"; + +test.skipIf(process.env.TPS_TEST_REAL_FLAIR !== "1")("real Flair serves a capped task and accepts a lowercase signed header", async () => { + const flairUrl = "http://127.0.0.1:9926"; + const opsUrl = "http://127.0.0.1:9925"; + const id = `presence-contract-${randomUUID()}`; + const seed = Buffer.alloc(32, 0x6b); + const signer = requestSigner(id, seed); + const sign = (method: string, path: string) => signer(method, path).replace("TPS-Ed25519", "tps-ed25519"); + const operation = (body: unknown) => fetch(opsUrl, { + method: "POST", + headers: { Authorization: `Basic ${Buffer.from("admin:test123").toString("base64")}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const task = "x".repeat(199) + " "; + try { + const inserted = await operation({ + operation: "insert", database: "flair", table: "Agent", + records: [{ id, name: id, publicKey: pubkeyFromSeed(seed).toString("base64"), createdAt: new Date().toISOString() }], + }); + expect(inserted.ok).toBe(true); + const now = Date.now(); + const stored = await operation({ + operation: "insert", database: "flair", table: "Presence", + records: [{ agentId: id, lastHeartbeatAt: now, activity: "coding", activityUpdatedAt: now, currentTask: ` ${task}tail ` }], + }); + expect(stored.ok).toBe(true); + const read = await readPresence({ flairUrl, sign }); + expect(read.status).toBe("ok"); + if (read.status === "ok") expect(read.agents.find((agent) => agent.id === id)?.currentTask).toBe(task); + } finally { + for (const table of ["Presence", "Agent"]) { + const deleted = await operation({ operation: "delete", database: "flair", table, hash_values: [id] }); + expect(deleted.ok).toBe(true); + } + } +}, 30_000); + +test.skipIf(process.env.TPS_TEST_REAL_FLAIR !== "1")("real Flair rejects an invalid Bearer token on GET /Presence", async () => { + const response = await fetch("http://127.0.0.1:9926/Presence", { + headers: { Authorization: "Bearer not-a-credential" }, + }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "invalid token" }); +}); diff --git a/packages/cli/test/presence-read.test.ts b/packages/cli/test/presence-read.test.ts new file mode 100644 index 00000000..bfa9ddc8 --- /dev/null +++ b/packages/cli/test/presence-read.test.ts @@ -0,0 +1,179 @@ +/** + * cli#622 — `readPresence` is driven against the shared + * verifying stub (`helpers/stub-flair.ts`), whose `presenceRoute` is a fake of + * Flair's coded `GET /Presence` contract (resources/Presence.ts): a verified + * reader gets records derived from stored heartbeat rows, and an unverified + * caller gets 401. A read the reader cannot sign is not sent, and a refused or + * rejected request, or a roster the reader does not accept (see its module + * doc), returns the bounded `unavailable`. Cross-field cases cover stale + * task/activity (a stale record with a current task or non-idle activity), + * fresh activity equality (fresh activity differing from lastActivity), and + * offline freshness (an offline record with fresh activity). Other combinations + * are not checked, including status versus a future heartbeat. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { readPresence } from "../src/utils/presence-read.js"; +import { + type FlairRoutes, + installStubFlairFetch, + type PresenceRow, + presenceRecords, + presenceRoute, + requestSigner, + type StubFlair, + stubFlairHandler, +} from "./helpers/stub-flair.js"; + +const VIEWER = "viewer-a"; +const SEED = Buffer.alloc(32, 0x3c); +const STRANGER_SEED = Buffer.alloc(32, 0x3d); + +/** A stored Presence row: `id`'s last heartbeat `minutesAgo` minutes ago, asserting `coding`. */ +const beat = (id: string, minutesAgo: number, over: Partial = {}): PresenceRow => ({ + agentId: id, lastHeartbeatAt: Date.now() - minutesAgo * 60_000, activity: "coding", ...over, +}); +const ROWS = [beat("agent-a", 0.5, { currentTask: "reviewing a PR" }), beat("agent-b", 5)]; + +let stub: StubFlair | undefined; +let presenceRequests = 0; +afterEach(() => { + stub?.stop(); + stub = undefined; +}); + +/** + * Serve `answer` for /Presence to the registered viewer from a stub Flair HTTP + * server, counting the Presence requests that reach it. + */ +function serve(answer: FlairRoutes): StubFlair { + presenceRequests = 0; + const handler = stubFlairHandler({ [VIEWER]: SEED }, answer); + const server = Bun.serve({ + port: 0, + fetch: (req: Request) => { + if (new URL(req.url).pathname === "/Presence") presenceRequests++; + return handler(req); + }, + }); + stub = { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) }; + return stub; +} + +describe("cli#622 — readPresence", () => { + test("a verified read returns one record per stored local Presence row, keyed by agent, holding only the validated fields", async () => { + const { url } = serve(presenceRoute(ROWS)); + const read = await readPresence({ flairUrl: url, sign: requestSigner(VIEWER, SEED) }); + if (read.status !== "ok") throw new Error(`expected ok, got ${read.reason}`); + expect(read.agents.map((a) => [a.id, a.presenceStatus, a.currentTask])).toEqual([ + ["agent-a", "active", "reviewing a PR"], + ["agent-b", "idle", null], + ]); + for (const agent of read.agents) { + expect(Object.keys(agent).sort()).toEqual( + ["activity", "activityFresh", "currentTask", "id", "lastActivity", "lastHeartbeatAt", "presenceStatus"], + ); + } + }); + + test.each([ + ["short", " reviewing a PR ", "reviewing a PR"], + ["at the cap", " " + "x".repeat(200) + "tail ", "x".repeat(200)], + ["ending in a space", " " + "x".repeat(199) + " tail ", "x".repeat(199) + " "], + ["ending in whitespace", " " + "x".repeat(198) + " \ttail ", "x".repeat(198) + " \t"], + ])("accepts Flair's trim-then-slice task output %s", async (_label, input, expected) => { + stub = installStubFlairFetch({ [VIEWER]: SEED }, presenceRoute([beat("agent-a", 0.5, { currentTask: input })])); + const read = await readPresence({ flairUrl: stub.url, sign: requestSigner(VIEWER, SEED) }); + expect(read.status).toBe("ok"); + if (read.status === "ok") expect(read.agents[0]!.currentTask).toBe(expected); + }); + + test("accepts a lowercase TPS-Ed25519 scheme with a valid signature", async () => { + stub = installStubFlairFetch({ [VIEWER]: SEED }, presenceRoute(ROWS)); + const signer = requestSigner(VIEWER, SEED); + const read = await readPresence({ flairUrl: stub.url, sign: (method, path) => signer(method, path).replace("TPS-Ed25519", "tps-ed25519") }); + expect(read.status).toBe("ok"); + if (read.status === "ok") expect(read.agents).toHaveLength(ROWS.length); + }); + + test.each([ + ["with a code", () => { throw Object.assign(new Error("signer-message-not-printed"), { code: "EACCES" }); }, "EACCES"], + ["with only a class", () => { throw new RangeError("signer-message-not-printed"); }, "RangeError"], + ["a non-error value", () => { throw "signer-message-not-printed"; }, "unknown error"], + ])("a signer that throws (%s) reads unavailable by its code or class, never its message, and sends no request", async (_label, sign, tag) => { + const { url } = serve(presenceRoute(ROWS)); + const read = await readPresence({ flairUrl: url, sign }); + expect(read).toEqual({ status: "unavailable", reason: `signing failed (${tag})` }); + expect(JSON.stringify(read)).not.toContain("signer-message-not-printed"); + expect(presenceRequests).toBe(0); + }); + + test("a signer that returns no signature reads unavailable and sends no unsigned request", async () => { + const { url } = serve(presenceRoute(ROWS)); + const read = await readPresence({ flairUrl: url, sign: () => undefined }); + expect(read).toEqual({ status: "unavailable", reason: "not signed (no request sent)" }); + expect(presenceRequests).toBe(0); + }); + + test.each([ + ["a read signed by an agent not registered on the instance", requestSigner("stranger", STRANGER_SEED)], + ["a read whose TPS-Ed25519 header is malformed", () => "TPS-Ed25519 not-a-credential"], + ])("%s gets Flair's 401 and reads unavailable", async (_label, sign) => { + const { url } = serve(presenceRoute(ROWS)); + const read = await readPresence({ flairUrl: url, sign }); + expect(read).toEqual({ status: "unavailable", reason: "HTTP 401" }); + expect(presenceRequests).toBe(1); + }); + + /** Flair's records for ROWS with `over` applied to the record at `index`. */ + const corrupt = (index: number, over: Record): FlairRoutes => () => + Response.json(presenceRecords(ROWS).map((r, i) => (i === index ? { ...r, ...over } : r))); + const invalid = (field: string) => `malformed response (a presence record has a missing or invalid ${field})`; + + test.each([ + ["an entry that is not an object", () => Response.json([null, ...presenceRecords(ROWS)]), "malformed response (an entry is not an object)"], + ["an empty id", corrupt(0, { id: "" }), invalid("id")], + ["an id that is not a string", corrupt(0, { id: 7 }), invalid("id")], + ["a status outside Flair's vocabulary", corrupt(0, { presenceStatus: "busy" }), invalid("presenceStatus")], + ["a missing status", corrupt(0, { presenceStatus: undefined }), invalid("presenceStatus")], + ["a heartbeat time that is not a number", corrupt(0, { lastHeartbeatAt: "1700000000000" }), invalid("lastHeartbeatAt")], + ["a missing heartbeat time", corrupt(0, { lastHeartbeatAt: null }), invalid("lastHeartbeatAt")], + ["an activity outside Flair's vocabulary", corrupt(0, { activity: "sleeping" }), invalid("activity")], + ["a missing activity", corrupt(0, { activity: undefined }), invalid("activity")], + ["a last activity outside Flair's vocabulary", corrupt(0, { lastActivity: "sleeping" }), invalid("lastActivity")], + ["a missing last activity", corrupt(0, { lastActivity: undefined }), invalid("lastActivity")], + ["an activityFresh that is not a boolean", corrupt(0, { activityFresh: "true" }), invalid("activityFresh")], + ["a missing activityFresh", corrupt(0, { activityFresh: undefined }), invalid("activityFresh")], + ["a current task that is not a string", corrupt(0, { currentTask: 42 }), invalid("currentTask")], + ["an empty current task", corrupt(0, { currentTask: "" }), invalid("currentTask")], + ["a current task over Flair's 200-character cap", corrupt(0, { currentTask: "x".repeat(201) }), invalid("currentTask")], + ["a current task of only whitespace", corrupt(0, { currentTask: " " }), invalid("currentTask")], + ["a current task with leading whitespace", corrupt(0, { currentTask: " reviewing a PR" }), invalid("currentTask")], + ["a short current task with trailing whitespace", corrupt(0, { currentTask: "reviewing a PR " }), invalid("currentTask")], + ["a capped current task with leading whitespace", corrupt(0, { currentTask: " " + "x".repeat(199) }), invalid("currentTask")], + ["a capped current task of only whitespace", corrupt(0, { currentTask: " ".repeat(200) }), invalid("currentTask")], + ["a missing current task", corrupt(0, { currentTask: undefined }), invalid("currentTask")], + ["a valid first record and an invalid second", corrupt(1, { presenceStatus: "busy" }), invalid("presenceStatus")], + ["a stale record with a current task", corrupt(0, { activityFresh: false }), "malformed response (a stale presence record carries a current task or an activity other than idle)"], + ["a stale record whose activity is not idle", corrupt(1, { activityFresh: false }), "malformed response (a stale presence record carries a current task or an activity other than idle)"], + ["a fresh record whose activity differs from its last activity", corrupt(0, { lastActivity: "planning" }), "malformed response (a fresh presence record's activity differs from its last activity)"], + ["an offline record with fresh activity", corrupt(1, { presenceStatus: "offline" }), "malformed response (an offline presence record reports fresh activity)"], + ["two records for one agent", () => Response.json([...presenceRecords(ROWS), presenceRecords(ROWS)[0]]), "malformed response (two presence records share an id)"], + ])("a roster with %s reads unavailable as a whole", async (_label, answer, reason) => { + const { url } = serve(answer); + const read = await readPresence({ flairUrl: url, sign: requestSigner(VIEWER, SEED) }); + expect(read).toEqual({ status: "unavailable", reason }); + }); +}); + +describe("cli#622 — the Presence fake authentication responses", () => { + test.each([ + ["no Authorization header", undefined, "authentication required"], + ["an invalid Bearer token", "Bearer not-a-credential", "invalid token"], + ["a malformed TPS-Ed25519 header", "TPS-Ed25519 not-a-credential", "invalid_authorization_header"], + ])("a GET /Presence with %s gets 401", async (_label, header, error) => { + const handler = stubFlairHandler({ [VIEWER]: SEED }, presenceRoute(ROWS)); + const res = await handler(new Request("http://flair.test/Presence", header === undefined ? {} : { headers: { Authorization: header } })); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error }); + }); +});