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>
95 lines
7.3 KiB
Markdown
95 lines
7.3 KiB
Markdown
# 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`](../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:
|
|
|
|
```ts
|
|
{ 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:
|
|
|
|
```text
|
|
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.
|