Files
fusion/docs/agent-activity-contract.md
gsxdsm b7604a94bf FN-9041: remove agent state-change activity logs
Remove durable agent state-change events and hide retained state-change rows from activity displays.

- Stop AgentStore update and reconciliation paths from writing state-change activity events.
- Filter historical state-change events in activity stores and panels while retaining live roster notifications.
- Document the retained activity contract, add coverage, and include a patch changeset.

Files changed:
 .changeset/fn-9041-removal.md                      |  7 ++++
 docs/agent-activity-contract.md                    |  4 ++
 docs/architecture.md                               |  2 +-
 docs/dashboard-guide.md                            |  2 +-
 .../src/__tests__/agent-activity-writers.test.ts   | 23 ++++++-----
 packages/core/src/agents/agent-store.ts            | 28 +++++--------
 .../app/components/agentsOrgChartActivity.ts       | 12 ++++++
 .../command-center/AgentActivityPanel.tsx          | 25 +++++++----
 .../__tests__/AgentActivityPanel.test.tsx          | 48 ++++++++++++++++++++++
 .../app/hooks/__tests__/agentActivityStore.test.ts | 39 +++++++++++++++++-
 packages/dashboard/app/hooks/agentActivityStore.ts |  3 +-
 11 files changed, 153 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-9041

Fusion-Task-Lineage: 532724b1-5945-4904-b5cd-2c27909805de

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-14 12:41:03 -07:00

99 lines
7.7 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`. |
## Event type compatibility
`agent:state-changed` remains a valid event enum value and `type` filter value so historical durable rows remain readable through this unchanged wire shape. No current writer produces that type; agent roster state is instead observed through the roster and its separate live state channel. Consumers that present work activity hide historical state-change rows without treating the enum as invalid.
## 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.