Publish an inspectable contract for durable agent-activity history and pagination. - Define the route wire shape, cursor semantics, retention, and SSE recovery behavior. - Add PostgreSQL and dashboard coverage for documented pagination and truncation guarantees. - Link architecture and diagnostics guidance to the canonical contract and validate its prerequisite lineage. Files changed: .changeset/fn-8864-agent-activity-events.md | 2 +- AGENTS.md | 1 + docs/agent-activity-contract.md | 94 ++++++++++++++++++++++ docs/architecture.md | 2 +- docs/diagnostics.md | 2 +- .../agent-activity-cursor-contract.pg.test.ts | 90 +++++++++++++++++++++ .../src/__tests__/agent-activity-route.test.ts | 10 +++ .../src/__tests__/sse-agent-activity.test.ts | 12 ++- scripts/check-fn-8864-ancestry.sh | 35 ++++++++ 9 files changed, 244 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8915 Fusion-Task-Lineage: da3f8c96-3b58-4e6d-a413-c51bdd643f26 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
7.3 KiB
Agent activity API contract
GET /api/agent-activity is the project-scoped, durable activity-history API. This document is the canonical wire and cursor contract for consumers such as the agent-activity UI. Before depending on it, run scripts/check-fn-8864-ancestry.sh: it fails closed unless the FN-8864 implementation is on the current base.
Wire shape
The response is exactly:
{ events: AgentActivityEvent[]; nextCursor: string | null }
Each event has these 12 fields, in the shape exported as AgentActivityEvent:
| Field | Wire value |
|---|---|
seq |
Decimal string representation of a PostgreSQL bigint. |
eventId |
Deterministic event identifier. |
projectId |
Owning project identifier. |
agentId |
Attributed agent, lane, or actor identifier. |
agentAttribution |
"agent", "lane", or "actor"; only agent is roster-proven. |
taskId |
Task identifier or null. |
type |
One of the exported agent-activity event types. |
fromAgentId |
Roster-proven source agent identifier or null. |
toAgentId |
Roster-proven destination agent identifier or null. |
summary |
Generated event summary. |
occurredAt |
ISO timestamp string. |
metadata |
Sanitized metadata object or null. |
Bounds and ordering
before is an exclusive upper bound and since is an exclusive lower bound. Supplying both selects the open interval (since, before). It is not a half-open interval: neither endpoint is returned.
The helper uses literal ORDER BY seq DESC by default and ORDER BY seq ASC only for its in-process ascending mode. (project_id, seq) is the primary key, so seq is unique inside a project. The ordering is therefore total; no secondary tiebreak column exists or is needed.
Filters and limits
All filters are AND-composed and always additionally scoped to the current project. The filter names are limit, before, since, agentId, taskId, and type.
The helper defaults absent or non-finite limit to 100 and clamps finite values to min(max(0, trunc(limit)), 1000). The HTTP route also defaults to 100, permits limit=0 (an empty page with a null cursor), and has a hard maximum of 1000. HTTP cursors and limits must be decimal strings; invalid cursors and unknown type values receive 400. order is not an HTTP query parameter and is never forwarded, so HTTP responses are always newest-first.
Cursor and continuation
The helper reads limit + 1 rows to determine hasMore, then returns at most limit events. For a descending page, nextCursor is the last (lowest-seq) returned event only when another row exists behind it. It is null for an empty page or an exactly-full final page, preventing a dead cursor.
Descending scroll-back (HTTP)
Start without before; for each later request send the preceding response's nextCursor verbatim as before; stop at nextCursor === null. The exclusive upper bound means the boundary event is never repeated. Higher-seq events appended during this walk are outside the range being walked, so they are neither duplicates nor gaps in that range; fetch them separately with a since tail.
Ascending tail (in-process and SSE)
Supply since=<highest seq already consumed> and helper-only order: "asc". Ascending mode always returns nextCursor: null; the caller owns continuation. SSE advances lastDeliveredSeq after each delivered event and keeps paging while it receives a full page. If a frame fails, the mark is unchanged, so delivery is at-least-once: a redelivery is possible, but an event is not intentionally lost.
SSE truncation repair
A truncation frame is { truncated: true, fromSeq, toSeq }. Its omitted rows are exactly the half-open range (fromSeq, toSeq], which is intentionally distinct from the helper's open query interval. Repair it by paging descending through:
GET /api/agent-activity?since=<fromSeq>&before=<toSeq + 1>
Continue with before=nextCursor until nextCursor is null.
Append-only and retention
Rows are inserted, not updated. eventId is deterministic from project, type, agent, task, and discriminator, and (project_id, event_id) is unique. The per-project counter is locked before the duplicate probe; a deduplicated retry returns null without consuming a seq. Thus committed sequence positions are unique and commit-ordered without gaps for rows that never existed.
Retention removes rows older than 30 days, then retains at most the newest 50,000 rows per project by descending seq. Long-lived scroll-back cursors can therefore encounter rows pruned below their boundary; treat a missing older sequence as retention, not a pagination bug. Concurrent append is not a loss case. The other loss boundary is an SSE truncation marker, repaired as described above.
Lossless cursor mode
Descending before scroll-back is lossless over the range it walks: unique commit-ordered seqs, exclusive boundaries, and no burned seq for deduplicated retries prevent duplicates and skips. Ascending since tailing is lossless-forward with at-least-once delivery. Retention pruning and an explicit SSE truncation marker are the two documented cases that require the caller to reconcile rather than assuming retained history is complete.
Claim-to-proof coverage
| Claim | Bucket | Proof |
|---|---|---|
Exact event field set, decimal seq, and envelope keys |
NEW | agent-activity-cursor-contract.pg.test.ts — wire/envelope case |
| Open exclusive bounds; ordering; cursor edges; ascending null cursor; lossless walk; attribution nulling; age retention | NEW | agent-activity-cursor-contract.pg.test.ts |
limit=0 route acceptance |
NEW | agent-activity-route.test.ts — accepts zero limit |
| SSE truncation repair endpoints | NEW | sse-agent-activity.test.ts — bounded truncation marker |
Route default/clamp, composable filters, and no HTTP order |
EXISTING | agent-activity-route.test.ts — “defaults and clamps limits while forwarding composable filters” |
| Route invalid input and unchanged response | EXISTING | agent-activity-route.test.ts — “rejects invalid limits, cursors, and event types”; “returns the core newest-first page shape unchanged” |
| Default non-finite helper limit, dedupe/no burned seq, filters, no timestamp tie-break, and project isolation | EXISTING | agent-activity-events.pg.test.ts named cases |
| SSE ascending drain, retry, and bounded truncation behavior | EXISTING | sse-agent-activity.test.ts named cases |
| 30-day retention and 50,000 row cap values | CONSTANT-ONLY | agent-activity-cursor-contract.pg.test.ts; large row-cap seed would violate the no-slow-tests rule |
Non-contractual internals
These implementation details are deliberately not API guarantees.
- The SSE backlog limit, page size, and maximum pages per drain are module-local constants. Their literal values are not exported, so callers must depend on the tested truncation and ascending-delivery behaviors instead.
- Pruning is currently invoked through
TaskStore.pruneAgentActivityEventsAsync()from engine self-healing rather than a read path. This call-site ownership is not a wire behavior and is not test-pinned. - The read mapper defensively converts array/non-object
metadatatonull. Sanitized writes cannot produce that state, so exercising it requires artificial SQL and is not contract-pinned.