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>
7.7 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. |
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:
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.