Files
fusion/docs/agent-activity-contract.md
gsxdsm 6bd8004ba1 FN-8915: document agent activity API contract
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>
2026-08-09 15:58:54 -07:00

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 metadata to null. Sanitized writes cannot produce that state, so exercising it requires artificial SQL and is not contract-pinned.