From b26263e59a3470c381a921a66615af9d93ac073a Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:09:27 +0100 Subject: [PATCH 01/35] docs: design for generic writable custom collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative to PR #4529: reach the comments feature through a generic, extensible custom-collection interface — opt-in router-writable collections, authenticated writes with the principal stamped into the change-event header and materialized into a virtual column, and schema-validated payloads. Comments becomes one such collection. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-10-generic-writable-collections-design.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md diff --git a/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md b/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md new file mode 100644 index 0000000000..c52a6c4209 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md @@ -0,0 +1,286 @@ +# Generic Writable Custom Collections (with Comments as a consumer) + +Date: 2026-06-10 +Status: Approved design — pending implementation plan +Branch: `vbalegas/custom-state` + +## Motivation + +PR #4529 ("feat(agents): Add session comments to agent timelines") adds comments +to agent sessions by **hardcoding** a `comments` collection into the built-in +entity schema: a `Comment*` type family in `entity-schema.ts`, a +`BUILT_IN_EVENT_SCHEMAS.comment`, a bespoke `EntityManager.createComment`, and a +dedicated `POST /:type/:instanceId/comments` route. + +This design reaches the same end-user feature through a **generic, extensible +interface**: arbitrary _custom collections_ layered on the agent's entity state +stream. Comments becomes just one such collection that the Horton and worker +entity definitions declare. The generic interface adds three things the agent +runtime does not have today: + +1. **Opt-in router-writable collections.** Entity state is owned by the agent. + A collection is writable from the HTTP router only when it explicitly opts in + via a `writable` safeguard. Everything else stays agent-only by default. +2. **Authenticated, auditable writes.** Router writes require authentication. The + server stamps the authenticated principal into the **change-event header** + (provenance, outside the user-supplied payload). The client materializes that + header into a read-only **virtual column** on the collection row. +3. **Schema-validated writes.** Each custom collection carries a schema. Router + writes validate the user payload against it server-side before the event is + appended to the stream. + +## Background: how change events and headers work + +Every entity write is appended to the entity's **main durable stream** as a +JSON-encoded **change-event envelope** (`EntityManager.encodeChangeEvent` → +`JSON.stringify`). The shape: + +```jsonc +{ + "type": "state:comments", // which collection/event-type this row belongs to + "key": "comment-abc", // the row's primary key + "headers": { + // metadata ABOUT the write — not user data + "operation": "insert", // insert | update | delete + "timestamp": "2026-06-10T…Z", + "offset": "…", // stamped by the stream; drives ordering + }, + "value": { "body": "looks good" }, // the row payload (user data) +} +``` + +On the **client** (`entity-stream-db.ts`), `materializeEventRow` builds a +TanStack DB collection row purely from `value` plus the primary key: + +```js +row = { ...event.value, [primaryKey]: event.key } +``` + +The only header projected onto a row today is `offset`, transformed into the +synthetic `_timeline_order` field. **There is no general header → column +mechanism.** This design adds one, modeled exactly on `_timeline_order`. + +### Why principal goes in the header, not the value + +PR #4529 puts `from_principal` inside `value`, conflating _who wrote this_ +(provenance the server vouches for) with _what they wrote_ (user data validated +against the collection schema). Putting the principal in `headers` instead: + +- the server stamps it authoritatively from the authenticated request; +- it sits outside the user's schema, so a client cannot spoof it by crafting a + different `value`; +- the collection's data schema stays clean (no principal field to validate). + +## Design + +### 1. Change-event header API (foundation) + +**Server** stamps a `principal` header on every router write: + +```jsonc +{ + "type": "state:comments", + "key": "comment-abc", + "headers": { + "operation": "insert", + "timestamp": "2026-06-10T…Z", + "principal": { + // NEW — from the authenticated request + "url": "/principal/user%3Aalice", + "kind": "user", + "id": "alice", + }, + }, + "value": { "body": "looks good", "timestamp": "…" }, // NO principal here +} +``` + +**Client** generalizes `materializeEventRow`: if a collection declares a +`principalColumn`, copy `headers.principal` onto the row under that name. + +```js +row = { ...event.value, [primaryKey]: event.key } +if (principalColumn) row[principalColumn] = event.headers?.principal +// e.g. row._principal = { url, kind, id } +``` + +The virtual column is read-only and server-vouched; it is never part of `value` +and never written by the client. + +### 2. Collection definition + `writable` safeguard + +Extend the runtime `CollectionDefinition` (`packages/agents-runtime/src/types.ts`) +with one optional field: + +```ts +interface CollectionDefinition { + schema?: StandardSchemaV1 + type?: string + primaryKey?: string + writable?: boolean | { principalColumn?: string } // NEW +} +``` + +- `writable` **absent or `false`** → the `/collections` endpoint rejects all + writes. This is the default for all existing state. +- `writable: true` → router-writable; `principalColumn` defaults to `_principal`. +- `writable: { principalColumn: '_author' }` → router-writable with a custom + virtual-column name. + +At registration (`packages/agents-runtime/src/create-handler.ts`), alongside the +existing `state_schemas` map (keyed by event type), emit a parallel +**`writable_collections`** map keyed by **collection name** so the server can map +a URL `:collection` segment to its event type, schema, and column name: + +```jsonc +"writable_collections": { + "comments": { "type": "state:comments", "principalColumn": "_principal" } +} +``` + +`writable_collections` is stored as a new field on `ElectricAgentsEntityType` +(`packages/agents-server/src/electric-agents-types.ts`) and merges additively the +same way `state_schemas` does (see `amendSchemas` / `getEffectiveSchemas`). + +### 3. Server write endpoint + +`POST /:type/:instanceId/collections/:collection` + +Registered in `packages/agents-server/src/routing/entities-router.ts`, with the +same middleware chain as `send`: + +``` +withExistingEntity → withSchema(writeCollectionBodySchema) → withEntityPermission('write') +``` + +Request body (single POST, operation in the body): + +```jsonc +{ "operation": "insert", "key": "…(optional for insert)", "value": { … } } +``` + +`operation` ∈ `insert | update | delete`. Any principal with entity `write` +permission may perform any operation. **No author/ownership checks** — writes are +auditable via the stamped principal header, not gated by authorship. + +Handler `writeCollection` (in `EntityManager`), in order: + +1. Resolve `:collection` against the entity type's `writable_collections`. + **Not found → 403** (`Collection is not writable`). This is the core safeguard. +2. `rejectsNormalWrites(entity.status)` → **409** (entity stopping/stopped/killed). +3. Build the envelope: `type` = the collection's registered event type, + `key` (provided or generated), `headers = { operation, timestamp, principal }` + where `principal = { url, kind, id }` from `ctx.principal`, and `value`. +4. `validateWriteEvent(entity, envelope)` → **422** if `type` is not a registered + state schema or `value` fails it. (See §4.) +5. `encodeChangeEvent` → `streamClient.append(entity.streams.main, …)`. +6. Return `201` (insert) / `200` (update/delete) with `{ key }`. + +This handler **replaces** PR #4529's `createComment` method and `/comments` route +entirely. + +### 4. Schema validation + +The canonical state-write validator already exists: +`EntityManager.validateWriteEvent(entity, event)` (`entity-manager.ts:3562`). It +looks up `event.type` in the entity type's effective `state_schemas` and validates +`event.value` against the matching schema (for `delete` it validates `old_value`). + +Today it is called from exactly one place — `routing/stream-append.ts`, the +durable-streams proxy path agents/adapters use to append state events. **PR #4529 +bypassed it** (`createComment` appended directly), so comment writes were never +schema-validated. The generic handler closes that gap by calling +`validateWriteEvent` itself. + +- **Where:** server-side, inside `writeCollection`, reusing `validateWriteEvent`. +- **When:** synchronously, after the writable/status checks and before + `streamClient.append`. +- **What:** only `value` (the user payload) is validated, against the collection's + registered schema. `headers.principal` is server-stamped provenance and is + deliberately _not_ validated, so it cannot be spoofed. `update` validates the new + `value`; `delete` carries only a key and skips payload validation. +- **Client-side:** the optimistic action MAY validate against the same Standard + Schema for instant feedback, but it is advisory — the server is authoritative. + +The registered `state_schema` therefore does double duty: it validates both +agent-internal state writes (via `stream-append.ts`) and router writes (via the +new handler) — one schema, one validator, two entry points. + +### 5. Client write actions + +Custom state collections **already** auto-generate `${name}_insert / _update / +_delete` TanStack DB actions in `entity-stream-db.ts`. For writable collections +these become the optimistic write path: + +1. UI calls the action → optimistic local insert/update/delete. +2. Action's `mutationFn` POSTs to `/collections/:collection`. +3. The synced stream row reconciles the optimistic row. +4. `materializeEventRow` attaches `_principal` (virtual column) when the synced + row arrives. + +No new client write primitive is needed beyond wiring the action's `mutationFn` to +the new endpoint. + +### 6. Comments as a consumer of the generic interface + +- **Remove** the hardcoded `comments` collection: the `Comment*` types, + `BUILT_IN_EVENT_SCHEMAS.comment`, the `comments` entry in `builtInCollections` / + `ENTITY_COLLECTIONS`, and `EntityManager.createComment` + `/comments` route. +- **Declare** `comments` as a custom `state` collection on the Horton and worker + entity definitions, with the comment schema (body, `reply_to`, + `target_snapshot`, edit/delete metadata) and + `writable: { principalColumn: '_principal' }`. +- `useEntityTimeline` projects the `comments` collection into the timeline (as in + #4529), reading the author from the `_principal` virtual column instead of + `value.from_principal`. +- The **UI is cloned verbatim from #4529** (`CommentBubble`, `MessageInput` reply + mode, `EntityTimeline` comment rows, comments-only view, reply previews) and + layered on the generic collection. Only the data source changes. +- The #4529 branch will be cloned to `~/workspace/tmp-1` to lift the UI files + exactly. + +## Scope + +Packages touched: + +- `@electric-ax/agents-runtime` — `CollectionDefinition.writable`; generalized + `materializeEventRow` (header → virtual column); registration emits + `writable_collections`; comment schema moved to a custom state collection + declared by Horton/worker. +- `@electric-ax/agents-server` — `writable_collections` on `ElectricAgentsEntityType`; + `writeCollection` handler + `/collections/:collection` route; reuse of + `validateWriteEvent` on the router path; removal of `createComment` + `/comments`. +- `@electric-ax/agents-server-ui` — comments UI cloned from #4529, sourced from + the generic collection + `_principal`. + +## Testing + +- Writable-vs-non-writable gating: non-writable collection → 403; unknown + collection → 403. +- Principal-header stamping: appended event carries `headers.principal = {url, +kind, id}` from the authenticated principal; `value` contains no principal. +- Virtual-column materialization: synced row exposes `_principal`; column is absent + when the collection declares no `principalColumn`. +- Schema validation on the router path: invalid `value` → 422; valid `value` + appends; `delete` skips payload validation. +- Operations: insert/update/delete each append with the correct + `headers.operation`; any authenticated writer succeeds (no author check). +- Comments timeline projection: comment rows interleave in timeline order; author + rendered from `_principal`. +- Cloned UI tests from #4529 adapted to the generic data source. + +A changeset covering all touched packages is added. + +## Decisions (resolved during brainstorming) + +- **Write URL:** `POST /:type/:instanceId/collections/:collection` (generic + `collections` noun, decoupled from the word "state"). +- **Safeguard:** `writable?: boolean | { principalColumn?: string }` on the + collection definition; boolean opt-in, no per-operation list. +- **Permissions:** any principal with entity `write` permission may + insert/update/delete; no author/ownership checks. +- **Principal header:** structured `{ url, kind, id }`, surfaced under a + configurable `principalColumn` (default `_principal`). +- **Endpoint shape:** single `POST` with `operation` in the body. +- **Validation:** server-authoritative via `validateWriteEvent`, validating `value` + only; principal header excluded from validation. From 3dee168c57b8d7cf0c3fc940604490060e7113fc Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:16:26 +0100 Subject: [PATCH 02/35] docs: implementation plan for generic writable custom collections Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-10-generic-writable-collections.md | 1232 +++++++++++++++++ 1 file changed, 1232 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-generic-writable-collections.md diff --git a/docs/superpowers/plans/2026-06-10-generic-writable-collections.md b/docs/superpowers/plans/2026-06-10-generic-writable-collections.md new file mode 100644 index 0000000000..b9e23a1209 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-generic-writable-collections.md @@ -0,0 +1,1232 @@ +# Generic Writable Custom Collections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reach PR #4529's comments feature through a generic, extensible custom-collection interface — opt-in router-writable entity-state collections with authenticated, schema-validated writes whose principal is stamped into the change-event header and materialized into a virtual column. + +**Architecture:** Three layers. (A) **Runtime**: a `writable` flag on the entity `CollectionDefinition`, a header→virtual-column projection in the entity stream DB, and a `writable_collections` registration map. (B) **Server**: storage of `writable_collections` on the entity type, a generic `EntityManager.writeCollection` method, and a `POST /:type/:instanceId/collections/:collection` route that authenticates, stamps the principal header, and validates the payload via the existing `validateWriteEvent`. (C) **Comments as a consumer**: comments declared as a custom `state` collection on the Horton and worker entity definitions, with the #4529 UI cloned verbatim and re-sourced onto the generic collection. + +**Tech Stack:** TypeScript, pnpm workspaces, Vitest, Zod / Standard Schema, TypeBox (`@sinclair/typebox` `Type.*`), TanStack DB, `@durable-streams/state`. + +**Spec:** `docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md` + +**Reference clone:** PR #4529 is cloned at `~/workspace/tmp-1` (branch `codex/session-comments`). Its full diff is at `/tmp/pr4529.diff`. Use these to lift UI files verbatim in Phase C. + +--- + +## File Structure + +**Phase A — Runtime (`packages/agents-runtime/src/`)** + +- Modify `types.ts` — add `writable` to `CollectionDefinition` (one responsibility: type definitions). +- Modify `entity-stream-db.ts` — build a `principalColumnByCollection` map and project `headers.principal` onto rows in both materialization paths. +- Modify `create-handler.ts` — emit `writable_collections` in the registration body. + +**Phase B — Server (`packages/agents-server/src/`)** + +- Modify `electric-agents-types.ts` — add `writable_collections` to `ElectricAgentsEntityType` + `RegisterEntityTypeRequest`. +- Modify `routing/entity-types-router.ts` — accept/normalize `writable_collections` in the register body and persist it. +- Modify `entity-manager.ts` — `registerEntityType` stores `writable_collections`; `getEffectiveSchemas` (rename usage) exposes effective `writable_collections`; new `writeCollection` method. +- Modify `routing/entities-router.ts` — `writeCollectionBodySchema`, the `/collections/:collection` route, and the `writeCollection` handler. + +**Phase C — Comments consumer** + +- Create `packages/agents-runtime/src/comments-collection.ts` — comment Zod schema, `Comment` types, and the reusable `commentsCollection` definition. (Replaces the hardcoded comment schema removed from `entity-schema.ts`.) +- Modify `packages/agents-runtime/src/entity-schema.ts` — remove the hardcoded `comments` built-in collection. +- Modify `packages/agents-runtime/src/index.ts` — export the comments-collection module. +- Modify `packages/agents-runtime/src/entity-timeline.ts` — project the custom `comments` collection using `_principal`. +- Modify `packages/agents/src/agents/horton.ts` and `worker.ts` — declare `state: { comments: commentsCollection }`. +- Modify `packages/agents-server-ui/src/...` — clone the #4529 UI and re-source onto the generic collection. + +--- + +## Phase A — Runtime generic interface + +### Task A1: Add `writable` to `CollectionDefinition` + +**Files:** + +- Modify: `packages/agents-runtime/src/types.ts:632-642` + +- [ ] **Step 1: Add the field** + +In `packages/agents-runtime/src/types.ts`, extend the `CollectionDefinition` interface (currently lines 632-642) so it reads: + +```ts +export interface CollectionDefinition< + TSchema extends StandardSchemaV1 | undefined = + | StandardSchemaV1 + | undefined, +> { + schema?: TSchema + /** Event type string used in the durable stream (e.g. `"counter_value"`). Defaults to `"state:${name}"`. */ + type?: string + /** Primary key field name. Defaults to `"key"`. */ + primaryKey?: string + /** + * Opt-in for HTTP-router writes via `POST /:type/:instanceId/collections/:name`. + * Absent/false ⇒ collection is agent-only and the endpoint rejects writes. + * `true` ⇒ writable; the principal is materialized into the `_principal` column. + * Object form lets a collection rename that virtual column. + */ + writable?: boolean | { principalColumn?: string } +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS (purely additive optional field). + +- [ ] **Step 3: Commit** + +```bash +git add packages/agents-runtime/src/types.ts +git commit -m "feat(agents-runtime): add writable flag to CollectionDefinition" +``` + +--- + +### Task A2: Project `headers.principal` into a virtual column + +The entity stream DB injects synthetic fields into `event.value` before materialization in two places: the wire-batch path (`onBeforeBatch`, ~lines 325-356) and the in-process `applyEvent` path (~lines 711-730). We add a parallel `principalColumnByCollection` map and inject `headers.principal` the same way `_timeline_order` is injected. + +**Files:** + +- Modify: `packages/agents-runtime/src/entity-stream-db.ts` +- Test: `packages/agents-runtime/test/entity-stream-db-principal.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `packages/agents-runtime/test/entity-stream-db-principal.test.ts`. (Model the harness on the existing `packages/agents-runtime/test/entity-timeline.test.ts` — read it first for how a stream DB is constructed and how batches are delivered.) The test declares a writable custom collection and asserts the principal header lands in the configured column while a non-writable collection does not get the column: + +```ts +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { createEntityStreamDB } from '../src/entity-stream-db' + +function principalHeader() { + return { url: `/principal/user%3Aalice`, kind: `user`, id: `alice` } +} + +describe(`entity-stream-db principal virtual column`, () => { + it(`projects headers.principal onto the configured column for writable collections`, () => { + const db = createEntityStreamDB(`/chat/sess-1`, { + comments: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: { principalColumn: `_principal` }, + }, + }) + + db.utils.applyEvent({ + type: `state:comments`, + key: `c1`, + headers: { operation: `insert`, principal: principalHeader() }, + value: { body: `hi` }, + } as any) + + const row = db.collections.comments.get(`c1`) as Record + expect(row.body).toBe(`hi`) + expect(row._principal).toEqual(principalHeader()) + }) + + it(`does not add a principal column when the collection is not writable`, () => { + const db = createEntityStreamDB(`/chat/sess-2`, { + notes: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + }, + }) + + db.utils.applyEvent({ + type: `state:notes`, + key: `n1`, + headers: { operation: `insert`, principal: principalHeader() }, + value: { body: `hi` }, + } as any) + + const row = db.collections.notes.get(`n1`) as Record + expect(row._principal).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-stream-db-principal.test.ts` +Expected: FAIL — first test's `row._principal` is `undefined`. + +- [ ] **Step 3: Build the `principalColumnByCollection` map** + +In `packages/agents-runtime/src/entity-stream-db.ts`, inside `createEntityStreamDB`, in the loop that converts `customState` (currently lines ~131-138, the `for (const [name, def] of Object.entries(customState))` block), capture the principal column. Add a map declaration just above the loop and populate it: + +```ts +const streamCustomState: Record = {} +const principalColumnByCollection = new Map() +if (customState) { + for (const [name, def] of Object.entries(customState)) { + streamCustomState[name] = { + schema: def.schema ?? passthrough(), + type: def.type ?? `state:${name}`, + primaryKey: def.primaryKey ?? `key`, + } + if (def.writable) { + principalColumnByCollection.set( + name, + def.writable === true + ? `_principal` + : (def.writable.principalColumn ?? `_principal`) + ) + } + } +} +``` + +- [ ] **Step 4: Inject in the wire-batch path** + +In the `onBeforeBatch` handler, immediately after the `_timeline_order` injection block (currently ending at line 356 `;(item.value as Record)._timeline_order = order`), add: + +```ts +const principalColumn = principalColumnByCollection.get(collectionName) +if (principalColumn) { + const principal = (item.headers as Record).principal + if (principal !== undefined) { + ;(item.value as Record)[principalColumn] = principal + } +} +``` + +- [ ] **Step 5: Inject in the `applyEvent` path** + +In `applyEvent`, after the `_timeline_order` injection (currently ending at line 729 `;(event.value as Record)._timeline_order = order`) — and still inside the `if (event.headers.operation !== 'delete' && ...)` block — add: + +```ts +const principalColumn = principalColumnByCollection.get(collectionName) +if (principalColumn) { + const principal = (event.headers as Record).principal + if (principal !== undefined) { + ;(event.value as Record)[principalColumn] = principal + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-stream-db-principal.test.ts` +Expected: PASS (both tests). + +- [ ] **Step 7: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS + +```bash +git add packages/agents-runtime/src/entity-stream-db.ts packages/agents-runtime/test/entity-stream-db-principal.test.ts +git commit -m "feat(agents-runtime): materialize principal header into virtual column" +``` + +--- + +### Task A3: Emit `writable_collections` at registration + +**Files:** + +- Modify: `packages/agents-runtime/src/create-handler.ts:488-519` +- Test: `packages/agents-runtime/test/create-handler-writable.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `packages/agents-runtime/test/create-handler-writable.test.ts`. The registration body is computed per entity type from `definition.state`. Read `packages/agents-runtime/src/create-handler.ts` around lines 484-525 first to see how `types`, `serveEndpoint`, and the POST are wired, then write a focused unit test that calls the same body-building logic. If the body-building is inline (not exported), extract it into a small exported pure helper `buildEntityTypeRegistrationBody(name, definition)` as part of this task and test that: + +```ts +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { buildEntityTypeRegistrationBody } from '../src/create-handler' + +describe(`buildEntityTypeRegistrationBody`, () => { + it(`emits writable_collections for writable state collections only`, () => { + const body = buildEntityTypeRegistrationBody(`chat`, { + description: `chat`, + handler: async () => {}, + state: { + comments: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: { principalColumn: `_principal` }, + }, + scratch: { + schema: z.object({ key: z.string().optional(), note: z.string() }), + }, + }, + } as any) + + expect(body.writable_collections).toEqual({ + comments: { type: `state:comments`, principalColumn: `_principal` }, + }) + }) + + it(`omits writable_collections when no collection opts in`, () => { + const body = buildEntityTypeRegistrationBody(`chat`, { + description: `chat`, + handler: async () => {}, + state: { + scratch: { schema: z.object({ note: z.string() }) }, + }, + } as any) + expect(body.writable_collections).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/create-handler-writable.test.ts` +Expected: FAIL — `buildEntityTypeRegistrationBody` not exported / `writable_collections` undefined. + +- [ ] **Step 3: Extract + extend the body builder** + +In `packages/agents-runtime/src/create-handler.ts`, extract the per-type body construction (currently inline at ~lines 488-519) into an exported pure function above the registration loop, and compute `writable_collections` alongside `state_schemas`: + +```ts +export function buildEntityTypeRegistrationBody( + name: string, + definition: AnyEntityDefinition +): Record { + const stateEntries = definition.state ? Object.entries(definition.state) : [] + + const stateSchemas = Object.fromEntries( + stateEntries.map(([collectionName, def]) => [ + def.type ?? `state:${collectionName}`, + toJsonSchema(def.schema ?? passthrough()), + ]) + ) + + const writableCollections: Record< + string, + { type: string; principalColumn: string } + > = {} + for (const [collectionName, def] of stateEntries) { + if (!def.writable) continue + writableCollections[collectionName] = { + type: def.type ?? `state:${collectionName}`, + principalColumn: + def.writable === true + ? `_principal` + : (def.writable.principalColumn ?? `_principal`), + } + } + + const body: Record = { + name, + description: definition.description ?? `${name} entity`, + ...(definition.creationSchema && { + creation_schema: toJsonSchema(definition.creationSchema), + }), + ...(definition.inboxSchemas && { + inbox_schemas: mapSchemas(definition.inboxSchemas), + }), + ...(definition.slashCommands && { + slash_commands: definition.slashCommands, + }), + state_schemas: { + ...DEFAULT_STATE_SCHEMAS, + ...stateSchemas, + ...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}), + }, + ...(Object.keys(writableCollections).length > 0 && { + writable_collections: writableCollections, + }), + ...(definition.permissionGrants && { + permission_grants: definition.permissionGrants, + }), + } + return body +} +``` + +Then in the registration loop, replace the inline body construction with `const body = buildEntityTypeRegistrationBody(name, definition)` and keep the subsequent mutations (`body.serve_endpoint = …`, etc.) as they are. Note: `mapSchemas`, `toJsonSchema`, `passthrough`, `DEFAULT_STATE_SCHEMAS` are already in scope in this file — keep using the existing references. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/create-handler-writable.test.ts` +Expected: PASS + +- [ ] **Step 5: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS + +```bash +git add packages/agents-runtime/src/create-handler.ts packages/agents-runtime/test/create-handler-writable.test.ts +git commit -m "feat(agents-runtime): emit writable_collections at entity-type registration" +``` + +--- + +## Phase B — Server generic interface + +### Task B1: Store `writable_collections` on the entity type + +**Files:** + +- Modify: `packages/agents-server/src/electric-agents-types.ts:500-520` (`ElectricAgentsEntityType`, `RegisterEntityTypeRequest`) + +- [ ] **Step 1: Add the type** + +In `packages/agents-server/src/electric-agents-types.ts`, define a shared shape and add the field to both `ElectricAgentsEntityType` and `RegisterEntityTypeRequest`: + +```ts +export interface WritableCollectionConfig { + /** Durable-stream event type for this collection, e.g. `state:comments`. */ + type: string + /** Row column the client materializes the principal header into. */ + principalColumn: string +} +``` + +Add to `ElectricAgentsEntityType` (after `state_schemas?`): + +```ts + writable_collections?: Record +``` + +Add the identical optional field to `RegisterEntityTypeRequest`. + +- [ ] **Step 2: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-server run typecheck` +Expected: PASS + +```bash +git add packages/agents-server/src/electric-agents-types.ts +git commit -m "feat(agents-server): add writable_collections to entity type" +``` + +--- + +### Task B2: Accept, persist, and resolve `writable_collections` + +**Files:** + +- Modify: `packages/agents-server/src/routing/entity-types-router.ts:47,83-97,448-...` (body schema + normalize) +- Modify: `packages/agents-server/src/entity-manager.ts:432-499` (`registerEntityType` stores it), `3871-3892` (`getEffectiveSchemas` → also return effective `writable_collections`) +- Test: `packages/agents-server/test/electric-agents-routes.test.ts` (extend) + +- [ ] **Step 1: Write the failing test** + +In `packages/agents-server/test/electric-agents-routes.test.ts`, add a test that registering an entity type with `writable_collections` round-trips it through the manager. Read the existing register-entity-type tests in that file first for the `routeResponse` / mock-manager harness, then add: + +```ts +it(`persists writable_collections on entity type registration`, async () => { + const registerEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + description: `chat`, + revision: 1, + created_at: `t`, + updated_at: `t`, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + const manager = { + registry: { getEntityType: vi.fn() }, + registerEntityType, + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entity-types`, + { + name: `chat`, + description: `chat`, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + } + ) + + expect(response.status).toBe(201) + expect(registerEntityType).toHaveBeenCalledWith( + expect.objectContaining({ + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + ) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t writable_collections` +Expected: FAIL — `additionalProperties: false` rejects `writable_collections`, or it is dropped by normalize. + +- [ ] **Step 3: Add the body schema + normalize** + +In `packages/agents-server/src/routing/entity-types-router.ts`, near `schemaMapSchema` (line 47) add: + +```ts +const writableCollectionsSchema = Type.Record( + Type.String(), + Type.Object( + { + type: Type.String(), + principalColumn: Type.String(), + }, + { additionalProperties: false } + ) +) +``` + +Add `writable_collections: Type.Optional(writableCollectionsSchema),` to `registerEntityTypeBodySchema` (lines 83-97). In `normalizeEntityTypeRequest` (line 448), thread the field through onto the normalized request object (follow how `state_schemas` is carried). In `registerEntityType` (line ~173, where the normalized request is passed to `ctx.entityManager.registerEntityType`), ensure `writable_collections` is included — it already will be if `normalizeEntityTypeRequest` carries it. + +- [ ] **Step 4: Store it in the manager** + +In `packages/agents-server/src/entity-manager.ts`, in `registerEntityType` (lines 432-499), copy the field onto the stored entity type object next to `state_schemas: req.state_schemas`: + +```ts + writable_collections: req.writable_collections, +``` + +- [ ] **Step 5: Resolve effective writable_collections** + +In `getEffectiveSchemas` (lines 3871-3892), extend the return to include `writableCollections`, merging entity-level then entity-type-level the same additive way as `stateSchemas`: + +```ts + private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{ + inboxSchemas?: Record> + stateSchemas?: Record> + writableCollections?: Record + }> { + if (!entity.type) { + return { + inboxSchemas: entity.inbox_schemas, + stateSchemas: entity.state_schemas, + } + } + const latestType = await this.registry.getEntityType(entity.type) + return { + inboxSchemas: latestType?.inbox_schemas + ? { ...(entity.inbox_schemas ?? {}), ...latestType.inbox_schemas } + : entity.inbox_schemas, + stateSchemas: latestType?.state_schemas + ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas } + : entity.state_schemas, + writableCollections: latestType?.writable_collections, + } + } +``` + +Import `WritableCollectionConfig` from `./electric-agents-types` at the top of `entity-manager.ts`. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t writable_collections` +Expected: PASS + +- [ ] **Step 7: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-server run typecheck` +Expected: PASS + +```bash +git add packages/agents-server/src/routing/entity-types-router.ts packages/agents-server/src/entity-manager.ts packages/agents-server/test/electric-agents-routes.test.ts +git commit -m "feat(agents-server): persist and resolve writable_collections" +``` + +--- + +### Task B3: `EntityManager.writeCollection` + +**Files:** + +- Modify: `packages/agents-server/src/entity-manager.ts` (new method near `createComment`'s old location / `send`, ~line 2285) +- Test: `packages/agents-server/test/electric-agents-manager-write-validation.test.ts` (extend) + +- [ ] **Step 1: Write the failing test** + +In `packages/agents-server/test/electric-agents-manager-write-validation.test.ts`, model on the existing `ElectricAgentsManager comments` describe block (it has a `decodeAppendEvent` helper and the `createAttachmentManager`/manager harness). Add a `writeCollection` describe block: + +```ts +describe(`ElectricAgentsManager.writeCollection`, () => { + it(`stamps the principal header and appends a generic collection insert`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + // Make the entity type expose `comments` as writable with a passthrough schema. + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { 'state:comments': {} }, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + + const result = await manager.writeCollection( + `/chat/session-1`, + `comments`, + { + operation: `insert`, + key: `c1`, + value: { body: `hi` }, + principal: { + url: `/principal/user%3Aalice`, + kind: `user`, + id: `alice`, + }, + } + ) + + expect(result).toEqual({ key: `c1` }) + const event = decodeAppendEvent(append.mock.calls[0]?.[1]) + expect(event).toMatchObject({ + type: `state:comments`, + key: `c1`, + headers: { + operation: `insert`, + principal: { + url: `/principal/user%3Aalice`, + kind: `user`, + id: `alice`, + }, + }, + value: { body: `hi` }, + }) + expect(event.value.from_principal).toBeUndefined() + }) + + it(`rejects writes to a collection that is not writable`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { 'state:notes': {} }, + writable_collections: {}, + }) + + await expect( + manager.writeCollection(`/chat/session-1`, `notes`, { + operation: `insert`, + value: { note: `x` }, + principal: { + url: `/principal/user%3Aalice`, + kind: `user`, + id: `alice`, + }, + }) + ).rejects.toMatchObject({ status: 403 }) + expect(append).not.toHaveBeenCalled() + }) +}) +``` + +(If `createAttachmentManager` does not let you set `registry.getEntityType`, set it on the returned `manager.registry` object after construction, as shown.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-manager-write-validation.test.ts -t writeCollection` +Expected: FAIL — `manager.writeCollection` is not a function. + +- [ ] **Step 3: Add the request types + method** + +In `packages/agents-server/src/entity-manager.ts`, near the other request interfaces (~line 135), add: + +```ts +export interface WriteCollectionPrincipal { + url: string + kind: string + id: string +} + +export interface WriteCollectionRequest { + operation: `insert` | `update` | `delete` + key?: string + value?: Record + principal: WriteCollectionPrincipal +} + +export interface WriteCollectionResult { + key: string +} +``` + +Add the method (place it next to `send`, ~line 2335, or where `createComment` lived): + +```ts + async writeCollection( + entityUrl: string, + collection: string, + req: WriteCollectionRequest + ): Promise { + const entity = await this.registry.getEntity(entityUrl) + if (!entity) { + throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404) + } + + const { writableCollections } = await this.getEffectiveSchemas(entity) + const config = writableCollections?.[collection] + if (!config) { + throw new ElectricAgentsError( + ErrCodeUnauthorized, + `Collection "${collection}" is not writable`, + 403 + ) + } + + if (rejectsNormalWrites(entity.status)) { + throw new ElectricAgentsError( + ErrCodeNotRunning, + `Entity is not accepting writes`, + 409 + ) + } + + if (req.operation !== `delete` && (req.value === undefined || req.value === null)) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `value is required for ${req.operation}`, + 400 + ) + } + if (req.operation !== `insert` && !req.key) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `key is required for ${req.operation}`, + 400 + ) + } + + const key = + req.key ?? + `${collection}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + const event: Record = { + type: config.type, + key, + headers: { + operation: req.operation, + timestamp: new Date().toISOString(), + principal: req.principal, + }, + } + if (req.operation === `delete`) { + // delete validation reads old_value; we don't have it here, so omit. + } else { + event.value = req.value + } + + const validationError = await this.validateWriteEvent(entity, event) + if (validationError) { + throw new ElectricAgentsError( + validationError.code, + validationError.message, + validationError.status + ) + } + + const encoded = this.encodeChangeEvent(event) + try { + await this.streamClient.append(entity.streams.main, encoded) + } catch (err) { + if (this.isClosedStreamError(err)) { + throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409) + } + throw err + } + + return { key } + } +``` + +Confirm the error-code identifiers (`ErrCodeNotFound`, `ErrCodeUnauthorized`, `ErrCodeNotRunning`, `ErrCodeInvalidRequest`) are already imported in this file (they are used by `createComment`/`send`); reuse them. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-manager-write-validation.test.ts -t writeCollection` +Expected: PASS (both tests). + +- [ ] **Step 5: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-server run typecheck` +Expected: PASS + +```bash +git add packages/agents-server/src/entity-manager.ts packages/agents-server/test/electric-agents-manager-write-validation.test.ts +git commit -m "feat(agents-server): generic writeCollection with principal-header stamping" +``` + +--- + +### Task B4: `/collections/:collection` route + +**Files:** + +- Modify: `packages/agents-server/src/routing/entities-router.ts` (body schema ~line 170, route ~line 421, handler ~line 1254) +- Test: `packages/agents-server/test/electric-agents-routes.test.ts` (extend) + +- [ ] **Step 1: Write the failing test** + +In `packages/agents-server/test/electric-agents-routes.test.ts`, model on the existing `comments endpoint` describe block and add: + +```ts +describe(`ElectricAgentsRoutes collections endpoint`, () => { + it(`routes a collection write to the manager with the authenticated principal`, async () => { + const manager = { + registry: { + getEntity: vi.fn().mockResolvedValue({ url: `/chat/test` }), + getEntityType: vi.fn(), + }, + ensurePrincipal: vi.fn().mockResolvedValue(undefined), + writeCollection: vi.fn().mockResolvedValue({ key: `c1` }), + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entities/chat/test/collections/comments`, + { operation: `insert`, key: `c1`, value: { body: `hi` } } + ) + + expect(response.status).toBe(201) + expect(await responseJson(response)).toEqual({ key: `c1` }) + expect(manager.writeCollection).toHaveBeenCalledWith( + `/chat/test`, + `comments`, + expect.objectContaining({ + operation: `insert`, + key: `c1`, + value: { body: `hi` }, + principal: expect.objectContaining({ url: expect.any(String) }), + }) + ) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t "collections endpoint"` +Expected: FAIL — route not found (404) / `writeCollection` not called. + +- [ ] **Step 3: Add the body schema** + +In `packages/agents-server/src/routing/entities-router.ts`, near `sendBodySchema` (line 167), add: + +```ts +const writeCollectionBodySchema = Type.Object( + { + operation: Type.Union([ + Type.Literal(`insert`), + Type.Literal(`update`), + Type.Literal(`delete`), + ]), + key: Type.Optional(Type.String()), + value: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + }, + { additionalProperties: false } +) +``` + +Add the type alias near the others (line ~342): `type WriteCollectionBody = Static`. + +- [ ] **Step 4: Register the route** + +Near the `send` route registration (line ~403), add (the `:collection` param sits under a `collections/` segment so it cannot collide with sibling routes like `send`, `attachments`, `tags`): + +```ts +entitiesRouter.post( + `/:type/:instanceId/collections/:collection`, + withExistingEntity, + withSchema(writeCollectionBodySchema), + withEntityPermission(`write`), + writeCollection +) +``` + +- [ ] **Step 5: Add the handler** + +Near `sendEntity` / the old `createComment` handler (line ~1254), add. Read `sendEntity` first to mirror how `ctx.principal` and `requireExistingEntityRoute` are used: + +```ts +async function writeCollection( + request: AgentsRouteRequest, + ctx: TenantContext +): Promise { + const parsed = routeBody(request) + await ctx.entityManager.ensurePrincipal(ctx.principal) + const { entityUrl } = requireExistingEntityRoute(request) + const collection = request.params.collection + const result = await ctx.entityManager.writeCollection( + entityUrl, + collection, + { + operation: parsed.operation, + key: parsed.key, + value: parsed.value, + principal: { + url: ctx.principal.url, + kind: ctx.principal.kind, + id: ctx.principal.id, + }, + } + ) + return json(result, { status: parsed.operation === `insert` ? 201 : 200 }) +} +``` + +Confirm `ctx.principal` exposes `url`, `kind`, `id` (it does — see `principal.ts` / `sendEntity`). If `id` is not directly present, derive it the same way `sendEntity` builds the principal subject. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t "collections endpoint"` +Expected: PASS + +- [ ] **Step 7: Full server test run + typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-server exec vitest run` +Expected: PASS (no regressions) +Run: `pnpm --filter @electric-ax/agents-server run typecheck` +Expected: PASS + +```bash +git add packages/agents-server/src/routing/entities-router.ts packages/agents-server/test/electric-agents-routes.test.ts +git commit -m "feat(agents-server): POST /collections/:collection generic write route" +``` + +--- + +## Phase C — Comments as a consumer + +### Task C1: Comments collection module + +**Files:** + +- Create: `packages/agents-runtime/src/comments-collection.ts` +- Modify: `packages/agents-runtime/src/index.ts` +- Reference: `/tmp/pr4529.diff` (the `entity-schema.ts` hunk: `CommentValue`, `CommentTargetValue`, `CommentSnapshotValue`, `createCommentSchema`) + +- [ ] **Step 1: Create the module** + +Create `packages/agents-runtime/src/comments-collection.ts`. Port the comment value/target/snapshot Zod schemas from the #4529 `entity-schema.ts` hunk in `/tmp/pr4529.diff` (the `createCommentSchema`, `createCommentTargetSchema`, `createCommentSnapshotSchema` functions and their `*Value` types), but **drop the `from_principal` field** — provenance now comes from the `_principal` virtual column. Export the schema, the value types, and a ready-to-use collection definition: + +```ts +import { z } from 'zod' +import type { CollectionDefinition } from './types' + +// ... CommentTargetValue, CommentSnapshotValue, CommentValue types and their +// z schemas, ported from /tmp/pr4529.diff WITHOUT `from_principal` ... + +export const commentSchema = createCommentSchema() + +export const commentsCollection: CollectionDefinition = { + schema: commentSchema, + type: `state:comments`, + primaryKey: `key`, + writable: { principalColumn: `_principal` }, +} + +export type { CommentValue, CommentTargetValue, CommentSnapshotValue } +``` + +Keep `timelineOrderField` semantics: the row carries `_timeline_order` automatically (injected by the stream DB), so it does **not** belong in the user-facing schema. Do not add it to `commentSchema`. + +- [ ] **Step 2: Export from the barrel** + +In `packages/agents-runtime/src/index.ts`, add: `export * from './comments-collection'`. + +- [ ] **Step 3: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS + +```bash +git add packages/agents-runtime/src/comments-collection.ts packages/agents-runtime/src/index.ts +git commit -m "feat(agents-runtime): comments collection definition on generic interface" +``` + +--- + +### Task C2: Remove the hardcoded comments built-in collection + +**Files:** + +- Modify: `packages/agents-runtime/src/entity-schema.ts` (revert the #4529 additions if present, or confirm absent on this branch) + +> NOTE: This plan's branch (`vbalegas/custom-state`) does NOT contain #4529, so `entity-schema.ts` has **no** `comments` built-in collection to remove. If you are instead building on top of #4529, remove: the `Comment*` types, `createComment*Schema` functions, `BUILT_IN_EVENT_SCHEMAS.comment`, the `comments` entries in `ENTITY_COLLECTIONS` / `builtInCollections` / `EntityCollectionsDefinition`, and the `Comment*` exports. Verify against `/tmp/pr4529.diff`. + +- [ ] **Step 1: Confirm clean state** + +Run: `grep -n "comment" packages/agents-runtime/src/entity-schema.ts` +Expected on `vbalegas/custom-state`: no matches → nothing to remove; skip to Step 2. If matches exist (building on #4529), delete each per the note above. + +- [ ] **Step 2: Typecheck + commit (only if changes were made)** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS + +```bash +git add packages/agents-runtime/src/entity-schema.ts +git commit -m "refactor(agents-runtime): drop hardcoded comments built-in collection" +``` + +--- + +### Task C3: Declare comments state on Horton and worker + +**Files:** + +- Modify: `packages/agents/src/agents/horton.ts` (the `registry.define('horton', {...})` call, ~line 759) +- Modify: `packages/agents/src/agents/worker.ts` (the `registry.define('worker', {...})` call, line 303) +- Test: `packages/agents/test/...` (extend an existing horton/worker registration test if present; otherwise create `packages/agents/test/comments-collection-registration.test.ts`) + +- [ ] **Step 1: Write the failing test** + +Check for an existing registration test: `ls packages/agents/test | grep -i "horton\|worker\|register"`. If one exercises the registry, extend it; otherwise create `packages/agents/test/comments-collection-registration.test.ts` that registers Horton into a fresh registry and asserts the definition declares a writable `comments` state collection: + +```ts +import { describe, it, expect } from 'vitest' +import { + createEntityRegistry, + getEntityType, +} from '@electric-ax/agents-runtime' +import { registerHorton } from '../src/agents/horton' +// import the model catalog the existing tests use; mirror their setup. + +describe(`comments collection registration`, () => { + it(`declares comments as a writable state collection on horton`, () => { + const registry = createEntityRegistry() + registerHorton(registry, { + workingDirectory: `/tmp`, + modelCatalog: + /* the test model catalog used elsewhere */ undefined as any, + }) + const def = getEntityType(`horton`)?.definition as any + expect(def.state?.comments?.writable).toEqual({ + principalColumn: `_principal`, + }) + }) +}) +``` + +(Read an existing `packages/agents` test to copy the exact `modelCatalog` test fixture; do not invent one.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @electric-ax/agents exec vitest run test/comments-collection-registration.test.ts` +Expected: FAIL — `def.state` is undefined. + +- [ ] **Step 3: Add the state to Horton** + +In `packages/agents/src/agents/horton.ts`, import the collection at the top: `import { commentsCollection } from '@electric-ax/agents-runtime'`. In the `registry.define('horton', { ... })` object (~line 759), add a `state` field: + +```ts + state: { + comments: commentsCollection, + }, +``` + +- [ ] **Step 4: Add the state to worker** + +In `packages/agents/src/agents/worker.ts`, import `commentsCollection` from `@electric-ax/agents-runtime` and add the same `state: { comments: commentsCollection }` to the `registry.define('worker', { ... })` object (line 303). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @electric-ax/agents exec vitest run test/comments-collection-registration.test.ts` +Expected: PASS + +- [ ] **Step 6: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents run typecheck` +Expected: PASS + +```bash +git add packages/agents/src/agents/horton.ts packages/agents/src/agents/worker.ts packages/agents/test/comments-collection-registration.test.ts +git commit -m "feat(agents): declare comments as a writable state collection on horton and worker" +``` + +--- + +### Task C4: Project the comments collection into the timeline + +**Files:** + +- Modify: `packages/agents-runtime/src/entity-timeline.ts` +- Test: `packages/agents-runtime/test/entity-timeline.test.ts` (extend with the #4529 comment cases) +- Reference: `/tmp/pr4529.diff` (the `entity-timeline.ts` and `entity-timeline.test.ts` hunks) + +- [ ] **Step 1: Port the #4529 timeline test cases** + +From `/tmp/pr4529.diff`, copy the added cases in `entity-timeline.test.ts` into `packages/agents-runtime/test/entity-timeline.test.ts`, adapting them so the comment row's author is read from `_principal` (object `{url,kind,id}`) instead of `value.from_principal`. The assertions should check that comment rows appear interleaved by `_timeline_order` and expose the principal from `_principal`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-timeline.test.ts` +Expected: FAIL — comments not projected into the timeline. + +- [ ] **Step 3: Port the timeline projection** + +From `/tmp/pr4529.diff`, port the `entity-timeline.ts` changes that project comment rows into the timeline row list. Two adaptations from #4529: + +1. Source comment rows from the custom `comments` collection (`db.collections.comments`) rather than a built-in collection. (If the timeline iterates a fixed set of built-in collections, add `comments` to that set guarded by `db.collections.comments != null` so non-comment entities are unaffected.) +2. Read the author from the `_principal` virtual column (`row._principal?.url`) instead of `value.from_principal`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-timeline.test.ts` +Expected: PASS + +- [ ] **Step 5: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` +Expected: PASS + +```bash +git add packages/agents-runtime/src/entity-timeline.ts packages/agents-runtime/test/entity-timeline.test.ts +git commit -m "feat(agents-runtime): project comments custom collection into timeline" +``` + +--- + +### Task C5: Clone the comments UI and re-source it + +The #4529 UI is a verbatim clone; only the data source changes (generic collection + `_principal`, generic write action instead of `createComment`). The UI files (from the PR file list) are: + +- `components/CommentBubble.tsx` + `.module.css` +- `components/EntityTimeline.tsx` + `.module.css` +- `components/MessageInput.tsx` + `.module.css` +- `components/AgentResponse.tsx`, `components/UserMessage.tsx` + css, `components/ToolCallView.tsx` + css, `components/toolBlock.module.css`, `components/InlineEventCard.tsx` +- `components/views/ChatView.tsx` +- `components/workspace/SplitMenu.tsx` + `.module.css` +- `hooks/useEntityTimeline.ts` +- `lib/comments.ts` +- `lib/workspace/registerViews.ts` +- Tests: `components/InlineEventCard.test.tsx`, `components/views/ChatView.test.ts`, `lib/comments.test.ts` + +**Files:** + +- Modify/Create: the files above under `packages/agents-server-ui/src/` +- Reference: `~/workspace/tmp-1/packages/agents-server-ui/src/` (exact files) and `/tmp/pr4529.diff` + +- [ ] **Step 1: Diff each UI file against the clone** + +For each file above, compare this repo's version with the clone to see exactly what #4529 added: + +```bash +for f in components/CommentBubble.tsx components/CommentBubble.module.css components/EntityTimeline.tsx components/MessageInput.tsx lib/comments.ts hooks/useEntityTimeline.ts components/views/ChatView.tsx; do + echo "=== $f ===" + diff -u "packages/agents-server-ui/src/$f" "$HOME/workspace/tmp-1/packages/agents-server-ui/src/$f" 2>&1 | head -80 +done +``` + +- [ ] **Step 2: Copy the net-new files verbatim** + +Net-new files (no local version) can be copied directly from the clone: + +```bash +cp ~/workspace/tmp-1/packages/agents-server-ui/src/components/CommentBubble.tsx packages/agents-server-ui/src/components/ +cp ~/workspace/tmp-1/packages/agents-server-ui/src/components/CommentBubble.module.css packages/agents-server-ui/src/components/ +cp ~/workspace/tmp-1/packages/agents-server-ui/src/lib/comments.ts packages/agents-server-ui/src/lib/ +cp ~/workspace/tmp-1/packages/agents-server-ui/src/lib/comments.test.ts packages/agents-server-ui/src/lib/ +``` + +(Confirm each has no pre-existing local version first with `ls`. For files that DO exist locally — `EntityTimeline.tsx`, `MessageInput.tsx`, `ChatView.tsx`, `useEntityTimeline.ts`, `AgentResponse.tsx`, `UserMessage.tsx`, `ToolCallView.tsx`, `InlineEventCard.tsx`, `SplitMenu.tsx`, `registerViews.ts`, and the `.module.css` siblings — apply the #4529 additions by hand using the diffs from Step 1, so local changes on this branch are preserved.) + +- [ ] **Step 3: Re-source the write path onto the generic action** + +In whichever module sends a comment (in #4529 this is the optimistic action that POSTs to `/comments`), change it to POST to `/collections/comments` with the generic body. Find it: + +```bash +grep -rn "/comments\|createComment\|from_principal" packages/agents-server-ui/src +``` + +Replace the request with: + +```ts +await fetch(`/_electric/entities/${type}/${instanceId}/collections/comments`, { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ operation: `insert`, key, value: commentValue }), +}) +``` + +where `commentValue` carries `body`, optional `reply_to`, optional `target_snapshot`, and `timestamp` — but **not** `from_principal`. Prefer wiring through the auto-generated `comments_insert` TanStack action (`db.actions.comments_insert`) if the UI already builds the stream DB with the comments collection; fall back to a direct `fetch` only if the action is unavailable in that component. + +- [ ] **Step 4: Re-source the read path onto `_principal`** + +Anywhere the UI read `comment.from_principal` (alignment "is this me?", sender label), read `comment._principal?.url` instead. Find them: + +```bash +grep -rn "from_principal" packages/agents-server-ui/src +``` + +Replace each with the `_principal.url` equivalent. The "right-align for current principal" check compares `comment._principal?.url === currentPrincipalUrl`. + +- [ ] **Step 5: Run the UI tests** + +Run: `pnpm --filter @electric-ax/agents-server-ui exec vitest run` +Expected: PASS — including the ported `comments.test.ts`, `InlineEventCard.test.tsx`, `ChatView.test.ts`. Fix any reference to `from_principal` in the test fixtures to use `_principal`. + +- [ ] **Step 6: Typecheck + commit** + +Run: `pnpm --filter @electric-ax/agents-server-ui run typecheck` +Expected: PASS + +```bash +git add packages/agents-server-ui/src +git commit -m "feat(agents-server-ui): comments UI on generic writable collection" +``` + +--- + +### Task C6: Changeset + full verification + +**Files:** + +- Create: `.changeset/generic-writable-collections.md` + +- [ ] **Step 1: Write the changeset** + +Create `.changeset/generic-writable-collections.md`: + +```markdown +--- +'@electric-ax/agents-runtime': minor +'@electric-ax/agents-server': minor +'@electric-ax/agents-server-ui': minor +'@electric-ax/agents': minor +--- + +Add generic writable custom collections for agent entity state. Collections opt in +with a `writable` flag; router writes (`POST /:type/:id/collections/:collection`) +are authenticated, schema-validated, and stamp the principal into the change-event +header, which the client materializes into a virtual column. Comments are +re-implemented as one such collection. +``` + +- [ ] **Step 2: Run all four package test suites + typechecks** + +```bash +pnpm --filter @electric-ax/agents-runtime run typecheck && pnpm --filter @electric-ax/agents-runtime exec vitest run +pnpm --filter @electric-ax/agents-server run typecheck && pnpm --filter @electric-ax/agents-server exec vitest run +pnpm --filter @electric-ax/agents run typecheck +pnpm --filter @electric-ax/agents-server-ui run typecheck && pnpm --filter @electric-ax/agents-server-ui exec vitest run +``` + +Expected: all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add .changeset/generic-writable-collections.md +git commit -m "chore: changeset for generic writable collections" +``` + +--- + +## Self-review notes + +- **Spec §1 (header API):** A2 (client virtual column) + B3 (server header stamping). +- **Spec §2 (writable safeguard):** A1 (type), A3 (registration emit), B1/B2 (server storage), B3 (403 enforcement). +- **Spec §3 (endpoint):** B4 (route + handler), B3 (manager). Single POST, operation in body, 201/200, 403 first. +- **Spec §4 (validation):** B3 reuses `validateWriteEvent`, validates `value` only, principal header excluded. +- **Spec §5 (client actions):** A2 + C5 (auto-generated `comments_insert` action / direct fetch fallback). +- **Spec §6 (comments consumer):** C1–C5. +- **Testing matrix (spec):** A2 (materialization, absent column), B3 (writable gating 403, principal stamping, value-only), B4 (route + principal), C3 (registration), C4 (timeline projection), C5 (UI tests). From bbe1118ae33e66d1407ac34f1f368bca67d90b42 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:19:27 +0100 Subject: [PATCH 03/35] feat(agents-runtime): add writable flag to CollectionDefinition --- packages/agents-runtime/src/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index ec366ab670..ec7a5336e1 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -639,6 +639,12 @@ export interface CollectionDefinition< type?: string /** Primary key field name. Defaults to `"key"`. */ primaryKey?: string + /** + * Opt-in for HTTP-router writes via `POST /:type/:instanceId/collections/:name`. + * Absent/false ⇒ agent-only; the endpoint rejects writes. `true` ⇒ writable, + * principal materialized into `_principal`. Object form renames that column. + */ + writable?: boolean | { principalColumn?: string } } export interface EntityTypeEntry< From 8199a7f88589ca0f478c94dd82956d8c19a5e306 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:27:31 +0100 Subject: [PATCH 04/35] feat(agents-runtime): materialize principal header into virtual column Projects headers.principal onto a configurable row column for collections declared writable, in both the wire-batch and applyEvent paths. Also wraps user-provided Zod schemas with a virtual-column-preserving validator so that injected synthetic fields survive TanStack DB's schema validation step. Co-Authored-By: Claude Sonnet 4.6 --- .../agents-runtime/src/entity-stream-db.ts | 74 ++++++++++++++++++- .../test/entity-stream-db-principal.test.ts | 43 +++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/agents-runtime/test/entity-stream-db-principal.test.ts diff --git a/packages/agents-runtime/src/entity-stream-db.ts b/packages/agents-runtime/src/entity-stream-db.ts index a1b7cc7d02..3f385e2f2b 100644 --- a/packages/agents-runtime/src/entity-stream-db.ts +++ b/packages/agents-runtime/src/entity-stream-db.ts @@ -10,6 +10,7 @@ import { getStreamDBCollectionId, } from '@durable-streams/state/db' import { builtInCollections, passthrough } from './entity-schema' +import type { StandardSchemaV1 } from '@standard-schema/spec' import { formatPointerOrderToken, type EventPointer } from './event-pointer' import type { ChangeEvent, @@ -105,6 +106,39 @@ type EntityStreamDBOptions = { const WRITE_TXID_TIMEOUT_MS = 20_000 +// Wrap a Standard Schema so that named virtual columns (e.g. `_timeline_order`, +// `_principal`) survive the validation step. TanStack DB calls the schema's +// validate() on every insert/update and uses result.value as the stored row, +// so any key not explicitly passed through by the schema is dropped. We +// extract the virtual fields before validation and re-attach them after. +function wrapSchemaWithVirtualColumns( + inner: StandardSchemaV1, + virtualColumns: Array +): StandardSchemaV1 { + return { + '~standard': { + version: 1 as const, + vendor: `electric-agents`, + validate: ( + value: unknown + ): StandardSchemaV1.Result | Promise> => { + if (typeof value !== `object` || value === null) { + return inner[`~standard`].validate(value) + } + const record = value as Record + const saved: Record = {} + for (const col of virtualColumns) { + if (col in record) saved[col] = record[col] + } + const result = inner[`~standard`].validate(value) + if (result instanceof Promise) return result + if (`issues` in result && result.issues) return result + return { value: Object.assign({}, result.value, saved) as T } + }, + }, + } +} + /** * Create a StreamDB connected to a Electric Agents entity stream. * @@ -127,10 +161,32 @@ export function createEntityStreamDB( // Convert entity-level CollectionDefinition (with optional JSON schema) to // stream-db CollectionDefinition (with Standard Schema validator + type + primaryKey) const streamCustomState: Record = {} + const principalColumnByCollection = new Map() if (customState) { for (const [name, def] of Object.entries(customState)) { + const principalColumn = def.writable + ? def.writable === true + ? `_principal` + : (def.writable.principalColumn ?? `_principal`) + : undefined + + if (principalColumn) { + principalColumnByCollection.set(name, principalColumn) + } + + // When virtual columns are projected onto the row, wrap the user schema + // to preserve those fields through TanStack DB's schema validation. + const baseSchema = def.schema ?? passthrough() + const virtualColumns = [ + `_timeline_order`, + ...(principalColumn ? [principalColumn] : []), + ] + const schema = def.schema + ? wrapSchemaWithVirtualColumns(baseSchema, virtualColumns) + : baseSchema + streamCustomState[name] = { - schema: def.schema ?? passthrough(), + schema, type: def.type ?? `state:${name}`, primaryKey: def.primaryKey ?? `key`, } @@ -354,6 +410,14 @@ export function createEntityStreamDB( orders.set(item.key, order) } ;(item.value as Record)._timeline_order = order + const principalColumn = principalColumnByCollection.get(collectionName) + if (principalColumn) { + const principal = (item.headers as Record).principal + if (principal !== undefined) { + ;(item.value as Record)[principalColumn] = + principal + } + } }) // After processing the batch, advance the anchor for next time. // `batch.offset` is the `Stream-Next-Offset` for this batch — @@ -727,6 +791,14 @@ export function createEntityStreamDB( const order = orders?.get(event.key) ?? formatPointerOrderToken(pointer) orders?.set(event.key, order) ;(event.value as Record)._timeline_order = order + const principalColumn = principalColumnByCollection.get(collectionName) + if (principalColumn) { + const principal = (event.headers as Record).principal + if (principal !== undefined) { + ;(event.value as Record)[principalColumn] = + principal + } + } } const transaction = createWriteTransaction({ debugOrigin: `apply-event:${event.type}:${event.headers.operation}`, diff --git a/packages/agents-runtime/test/entity-stream-db-principal.test.ts b/packages/agents-runtime/test/entity-stream-db-principal.test.ts new file mode 100644 index 0000000000..00e3684a30 --- /dev/null +++ b/packages/agents-runtime/test/entity-stream-db-principal.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { createEntityStreamDB } from '../src/entity-stream-db' + +function principalHeader() { + return { url: `/principal/user%3Aalice`, kind: `user`, id: `alice` } +} + +describe(`entity-stream-db principal virtual column`, () => { + it(`projects headers.principal onto the configured column for writable collections`, () => { + const db = createEntityStreamDB(`/chat/sess-1`, { + comments: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: { principalColumn: `_principal` }, + }, + }) + db.utils.applyEvent({ + type: `state:comments`, + key: `c1`, + headers: { operation: `insert`, principal: principalHeader() }, + value: { body: `hi` }, + } as any) + const row = db.collections.comments.get(`c1`) as Record + expect(row.body).toBe(`hi`) + expect(row._principal).toEqual(principalHeader()) + }) + + it(`does not add a principal column when the collection is not writable`, () => { + const db = createEntityStreamDB(`/chat/sess-2`, { + notes: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + }, + }) + db.utils.applyEvent({ + type: `state:notes`, + key: `n1`, + headers: { operation: `insert`, principal: principalHeader() }, + value: { body: `hi` }, + } as any) + const row = db.collections.notes.get(`n1`) as Record + expect(row._principal).toBeUndefined() + }) +}) From a60449c06a04c27b224cc7f6e1112ca831fc38e3 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:34:30 +0100 Subject: [PATCH 05/35] fix(agents-runtime): strip principal virtual column before client write-back cleanRow now deletes every registered principal column (computed once as the distinct values of principalColumnByCollection) in addition to _seq and _timeline_order, preventing the server-stamped _principal field from leaking into outgoing ChangeEvent.value when a client calls an auto-generated action. Also adds: (1) a test covering the writable: true default column path, and (2) a focused leak-specific test asserting the outgoing event value is clean. Co-Authored-By: Claude Sonnet 4.6 --- .../agents-runtime/src/entity-stream-db.ts | 4 ++ .../test/entity-stream-db-principal.test.ts | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/agents-runtime/src/entity-stream-db.ts b/packages/agents-runtime/src/entity-stream-db.ts index 3f385e2f2b..efabfd1b0e 100644 --- a/packages/agents-runtime/src/entity-stream-db.ts +++ b/packages/agents-runtime/src/entity-stream-db.ts @@ -240,10 +240,14 @@ export function createEntityStreamDB( key: string } + const principalColumns = new Set(principalColumnByCollection.values()) const cleanRow = (row: Record): Record => { const clone = { ...row } delete clone._seq delete clone._timeline_order + for (const col of principalColumns) { + delete clone[col] + } return clone } diff --git a/packages/agents-runtime/test/entity-stream-db-principal.test.ts b/packages/agents-runtime/test/entity-stream-db-principal.test.ts index 00e3684a30..3de9e6153e 100644 --- a/packages/agents-runtime/test/entity-stream-db-principal.test.ts +++ b/packages/agents-runtime/test/entity-stream-db-principal.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { z } from 'zod' import { createEntityStreamDB } from '../src/entity-stream-db' +import type { ChangeEvent } from '@durable-streams/state' function principalHeader() { return { url: `/principal/user%3Aalice`, kind: `user`, id: `alice` } @@ -25,6 +26,23 @@ describe(`entity-stream-db principal virtual column`, () => { expect(row._principal).toEqual(principalHeader()) }) + it(`projects headers.principal onto _principal when writable: true`, () => { + const db = createEntityStreamDB(`/chat/sess-3`, { + notes: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: true, + }, + }) + db.utils.applyEvent({ + type: `state:notes`, + key: `n2`, + headers: { operation: `insert`, principal: principalHeader() }, + value: { body: `hello` }, + } as any) + const row = db.collections.notes.get(`n2`) as Record + expect(row._principal).toEqual(principalHeader()) + }) + it(`does not add a principal column when the collection is not writable`, () => { const db = createEntityStreamDB(`/chat/sess-2`, { notes: { @@ -40,4 +58,50 @@ describe(`entity-stream-db principal virtual column`, () => { const row = db.collections.notes.get(`n1`) as Record expect(row._principal).toBeUndefined() }) + + it(`strips the principal column from outgoing ChangeEvent value on insert`, async () => { + const captured: Array = [] + let awaitTxIdResolve: (() => void) | undefined + const db = createEntityStreamDB( + `/chat/sess-4`, + { + comments: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: { principalColumn: `_principal` }, + }, + }, + undefined, + { + writeEvent: (ev) => captured.push(ev), + flushWrites: async () => {}, + } + ) + + // Stub awaitTxId so the action's mutationFn resolves immediately + ;(db.utils as any).awaitTxId = (_txid: string) => + new Promise((r) => { + awaitTxIdResolve = r + }) + + // Trigger an insert that carries a _principal field (simulating a row + // materialized with the principal virtual column writing back to the server) + const actionPromise = (db.actions as any).comments_insert({ + row: { key: `c1`, body: `hello`, _principal: principalHeader() }, + }) + + // writeEvent is called synchronously inside persistMutationsNow before + // flushWrites resolves, so captured should be populated already + await Promise.resolve() + + expect(captured).toHaveLength(1) + const ev = captured[0]! as ChangeEvent & { value: Record } + expect(ev.value._principal).toBeUndefined() + expect(ev.value._seq).toBeUndefined() + expect(ev.value._timeline_order).toBeUndefined() + expect(ev.value.body).toBe(`hello`) + + // Resolve the awaitTxId so the action promise doesn't hang + awaitTxIdResolve?.() + await actionPromise + }) }) From 8dfb9e9477277b08f48a35db4b1a25cddee05cab Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:39:56 +0100 Subject: [PATCH 06/35] feat(agents-runtime): emit writable_collections at entity-type registration Extract inline body-building into exported `buildEntityTypeRegistrationBody` and add `writable_collections` map so the server knows which state collections are router-writable and their principal column. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/create-handler.ts | 211 ++++++++++-------- .../test/create-handler-writable.test.ts | 33 +++ 2 files changed, 151 insertions(+), 93 deletions(-) create mode 100644 packages/agents-runtime/test/create-handler-writable.test.ts diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index c4008862d4..8741f9b977 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -17,6 +17,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { WebhookSignatureVerifierConfig } from './webhook-signature' import type { AgentTool, + AnyEntityDefinition, EntityStreamDBWithActions, HeadersProvider, ProcessWakeConfig, @@ -207,6 +208,122 @@ export interface RuntimeDebugState { export type RuntimeHandlerConfig = RuntimeRouterConfig export type RuntimeHandlerResult = RuntimeHandler +const JSON_SCHEMA_KEYWORDS = [ + `type`, + `properties`, + `items`, + `enum`, + `oneOf`, + `anyOf`, + `allOf`, + `additionalProperties`, +] as const + +function stripSchemaKeyword( + jsonSchema: Record +): Record { + const { $schema: _schema, ...rest } = jsonSchema + return rest +} + +function toJsonSchema(schema: unknown): Record { + if (!schema || typeof schema !== `object` || Array.isArray(schema)) { + return {} + } + + const standardSchema = schema as { + [`~standard`]?: { + jsonSchema?: { + input?: () => unknown + } + } + toJSONSchema?: () => Record + } + + const standardJsonSchema = standardSchema[`~standard`]?.jsonSchema?.input?.() + if (standardJsonSchema) { + return stripSchemaKeyword(standardJsonSchema as Record) + } + + if (typeof standardSchema.toJSONSchema === `function`) { + return stripSchemaKeyword(standardSchema.toJSONSchema()) + } + + if (`~standard` in standardSchema) { + return {} + } + + const jsonSchemaLike = schema as Record + if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) { + return stripSchemaKeyword(jsonSchemaLike) + } + + return zodToJsonSchema(schema as any, { target: `jsonSchema7` }) +} + +function mapSchemas( + schemas: Record +): Record> { + return Object.fromEntries( + Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)]) + ) +} + +export function buildEntityTypeRegistrationBody( + name: string, + definition: AnyEntityDefinition +): Record { + const stateEntries = definition.state ? Object.entries(definition.state) : [] + + const stateSchemas = Object.fromEntries( + stateEntries.map(([collectionName, def]) => [ + def.type ?? `state:${collectionName}`, + toJsonSchema(def.schema ?? passthrough()), + ]) + ) + + const writableCollections: Record< + string, + { type: string; principalColumn: string } + > = {} + for (const [collectionName, def] of stateEntries) { + if (!def.writable) continue + writableCollections[collectionName] = { + type: def.type ?? `state:${collectionName}`, + principalColumn: + def.writable === true + ? `_principal` + : (def.writable.principalColumn ?? `_principal`), + } + } + + const body: Record = { + name, + description: definition.description ?? `${name} entity`, + ...(definition.creationSchema && { + creation_schema: toJsonSchema(definition.creationSchema), + }), + ...(definition.inboxSchemas && { + inbox_schemas: mapSchemas(definition.inboxSchemas), + }), + ...(definition.slashCommands && { + slash_commands: definition.slashCommands, + }), + state_schemas: { + ...DEFAULT_STATE_SCHEMAS, + ...stateSchemas, + ...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}), + }, + ...(Object.keys(writableCollections).length > 0 && { + writable_collections: writableCollections, + }), + ...(definition.permissionGrants && { + permission_grants: definition.permissionGrants, + }), + } + return body +} + export function createRuntimeRouter( config: RuntimeRouterConfig ): RuntimeRouter { @@ -413,60 +530,6 @@ export function createRuntimeRouter( return handleWebhookRequest(request) } - const stripSchemaKeyword = ( - jsonSchema: Record - ): Record => { - const { $schema: _schema, ...rest } = jsonSchema - return rest - } - - const JSON_SCHEMA_KEYWORDS = [ - `type`, - `properties`, - `items`, - `enum`, - `oneOf`, - `anyOf`, - `allOf`, - `additionalProperties`, - ] as const - - const toJsonSchema = (schema: unknown): Record => { - if (!schema || typeof schema !== `object` || Array.isArray(schema)) { - return {} - } - - const standardSchema = schema as { - [`~standard`]?: { - jsonSchema?: { - input?: () => unknown - } - } - toJSONSchema?: () => Record - } - - const standardJsonSchema = - standardSchema[`~standard`]?.jsonSchema?.input?.() - if (standardJsonSchema) { - return stripSchemaKeyword(standardJsonSchema as Record) - } - - if (typeof standardSchema.toJSONSchema === `function`) { - return stripSchemaKeyword(standardSchema.toJSONSchema()) - } - - if (`~standard` in standardSchema) { - return {} - } - - const jsonSchemaLike = schema as Record - if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) { - return stripSchemaKeyword(jsonSchemaLike) - } - - return zodToJsonSchema(schema as any, { target: `jsonSchema7` }) - } - const registerTypes = async (): Promise => { const types = getRegisteredTypes() const registered: Array = [] @@ -474,49 +537,11 @@ export function createRuntimeRouter( const totalStart = performance.now() const effectiveConcurrency = Math.max(1, registrationConcurrency ?? 8) - const mapSchemas = ( - schemas: Record - ): Record> => - Object.fromEntries( - Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)]) - ) - await forEachWithConcurrency(types, effectiveConcurrency, async (entry) => { const registrationStart = performance.now() const { name, definition } = entry - const stateSchemas = definition.state - ? Object.fromEntries( - Object.entries(definition.state).map(([collectionName, def]) => [ - def.type ?? `state:${collectionName}`, - toJsonSchema(def.schema ?? passthrough()), - ]) - ) - : {} - - const body: Record = { - name, - description: definition.description ?? `${name} entity`, - ...(definition.creationSchema && { - creation_schema: toJsonSchema(definition.creationSchema), - }), - ...(definition.inboxSchemas && { - inbox_schemas: mapSchemas(definition.inboxSchemas), - }), - ...(definition.slashCommands && { - slash_commands: definition.slashCommands, - }), - state_schemas: { - ...DEFAULT_STATE_SCHEMAS, - ...stateSchemas, - ...(definition.stateSchemas - ? mapSchemas(definition.stateSchemas) - : {}), - }, - ...(definition.permissionGrants && { - permission_grants: definition.permissionGrants, - }), - } + const body = buildEntityTypeRegistrationBody(name, definition) const defaultDispatchPolicy = defaultDispatchPolicyForType?.(name) diff --git a/packages/agents-runtime/test/create-handler-writable.test.ts b/packages/agents-runtime/test/create-handler-writable.test.ts new file mode 100644 index 0000000000..5ff4900e0a --- /dev/null +++ b/packages/agents-runtime/test/create-handler-writable.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { buildEntityTypeRegistrationBody } from '../src/create-handler' + +describe(`buildEntityTypeRegistrationBody`, () => { + it(`emits writable_collections for writable state collections only`, () => { + const body = buildEntityTypeRegistrationBody(`chat`, { + description: `chat`, + handler: async () => {}, + state: { + comments: { + schema: z.object({ key: z.string().optional(), body: z.string() }), + writable: { principalColumn: `_principal` }, + }, + scratch: { + schema: z.object({ key: z.string().optional(), note: z.string() }), + }, + }, + } as any) + expect(body.writable_collections).toEqual({ + comments: { type: `state:comments`, principalColumn: `_principal` }, + }) + }) + + it(`omits writable_collections when no collection opts in`, () => { + const body = buildEntityTypeRegistrationBody(`chat`, { + description: `chat`, + handler: async () => {}, + state: { scratch: { schema: z.object({ note: z.string() }) } }, + } as any) + expect(body.writable_collections).toBeUndefined() + }) +}) From 6472e59810a0448bb7e8dbdddf8da8aad096050b Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:44:01 +0100 Subject: [PATCH 07/35] feat(agents-server): add writable_collections to entity type --- packages/agents-server/src/electric-agents-types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/agents-server/src/electric-agents-types.ts b/packages/agents-server/src/electric-agents-types.ts index 60ffefec96..980c5f7fcf 100644 --- a/packages/agents-server/src/electric-agents-types.ts +++ b/packages/agents-server/src/electric-agents-types.ts @@ -494,12 +494,21 @@ export function toPublicEntity( } } +/** Per-collection config making an entity-state collection writable via the router. */ +export interface WritableCollectionConfig { + /** Durable-stream event type for this collection, e.g. `state:comments`. */ + type: string + /** Row column the client materializes the principal header into. */ + principalColumn: string +} + export interface ElectricAgentsEntityType { name: string description: string creation_schema?: Record inbox_schemas?: Record> state_schemas?: Record> + writable_collections?: Record slash_commands?: Array serve_endpoint?: string default_dispatch_policy?: DispatchPolicy @@ -514,6 +523,7 @@ export interface RegisterEntityTypeRequest { creation_schema?: Record inbox_schemas?: Record> state_schemas?: Record> + writable_collections?: Record slash_commands?: Array serve_endpoint?: string default_dispatch_policy?: DispatchPolicy From 777e0c153370b7d5b168ae9c6d4c057c8867fef2 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:47:29 +0100 Subject: [PATCH 08/35] feat(agents-server): persist and resolve writable_collections Co-Authored-By: Claude Sonnet 4.6 --- packages/agents-server/src/entity-manager.ts | 5 +++ .../src/routing/entity-types-router.ts | 9 ++++ .../test/electric-agents-routes.test.ts | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index fbbb189028..51a602b649 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -71,6 +71,7 @@ import type { SignalRequest, SignalResponse, TypedSpawnRequest, + WritableCollectionConfig, } from './electric-agents-types.js' import type { EntityBridgeCoordinator } from './entity-bridge-manager.js' import type { Principal } from './principal.js' @@ -488,6 +489,7 @@ export class EntityManager { creation_schema: req.creation_schema, inbox_schemas: req.inbox_schemas, state_schemas: req.state_schemas, + writable_collections: req.writable_collections, slash_commands: req.slash_commands, serve_endpoint: req.serve_endpoint, default_dispatch_policy: defaultDispatchPolicy, @@ -3876,11 +3878,13 @@ export class EntityManager { private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{ inboxSchemas?: Record> stateSchemas?: Record> + writableCollections?: Record }> { if (!entity.type) { return { inboxSchemas: entity.inbox_schemas, stateSchemas: entity.state_schemas, + writableCollections: undefined, } } @@ -3893,6 +3897,7 @@ export class EntityManager { stateSchemas: latestType?.state_schemas ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas } : entity.state_schemas, + writableCollections: latestType?.writable_collections, } } diff --git a/packages/agents-server/src/routing/entity-types-router.ts b/packages/agents-server/src/routing/entity-types-router.ts index 76e7a50afe..47cc45cb54 100644 --- a/packages/agents-server/src/routing/entity-types-router.ts +++ b/packages/agents-server/src/routing/entity-types-router.ts @@ -45,6 +45,13 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & { const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown()) const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema) +const writableCollectionsSchema = Type.Record( + Type.String(), + Type.Object( + { type: Type.String(), principalColumn: Type.String() }, + { additionalProperties: false } + ) +) const slashCommandArgumentSchema = Type.Object( { name: Type.String(), @@ -93,6 +100,7 @@ const registerEntityTypeBodySchema = Type.Object( permission_grants: Type.Optional( Type.Array(typePermissionGrantInputSchema) ), + writable_collections: Type.Optional(writableCollectionsSchema), }, { additionalProperties: false } ) @@ -465,6 +473,7 @@ function normalizeEntityTypeRequest( } as RegisterEntityTypeRequest[`default_dispatch_policy`]) : undefined), permission_grants: parsed.permission_grants, + writable_collections: parsed.writable_collections, } } diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index b5a8f9ef57..86635ac465 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -1498,3 +1498,44 @@ describe(`ElectricAgentsRoutes fork endpoint`, () => { expect(manager.forkSubtree).not.toHaveBeenCalled() }) }) + +describe(`ElectricAgentsRoutes entity-type registration`, () => { + it(`persists writable_collections on entity type registration`, async () => { + const registerEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + description: `chat`, + revision: 1, + created_at: `t`, + updated_at: `t`, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + const manager = { + registry: { getEntityType: vi.fn() }, + registerEntityType, + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entity-types`, + { + name: `chat`, + description: `chat`, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + } + ) + + expect(response.status).toBe(201) + expect(registerEntityType).toHaveBeenCalledWith( + expect.objectContaining({ + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + ) + }) +}) From d8aa2af0b5e93e92ddb0afe5d4207115592f050f Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:55:16 +0100 Subject: [PATCH 09/35] fix(agents-server): persist writable_collections as jsonb column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add writable_collections jsonb column to entity_types table via migration 0015, wire it through schema.ts, all write paths (createEntityType, ensureEntityType, updateEntityTypeInPlace) and rowToEntityType so the field survives the createEntityType → getEntityType round-trip. Add a real-DB round-trip test in entity-type-registry.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0015_entity_type_writable_collections.sql | 1 + packages/agents-server/drizzle/meta/_journal.json | 7 +++++++ packages/agents-server/src/db/schema.ts | 5 +++++ packages/agents-server/src/entity-registry.ts | 10 ++++++++++ .../agents-server/test/entity-type-registry.test.ts | 12 ++++++++++++ packages/agents-server/test/test-backend.ts | 5 ++++- 6 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 packages/agents-server/drizzle/0015_entity_type_writable_collections.sql diff --git a/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql b/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql new file mode 100644 index 0000000000..35e65df53a --- /dev/null +++ b/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql @@ -0,0 +1 @@ +ALTER TABLE "entity_types" ADD COLUMN "writable_collections" jsonb; diff --git a/packages/agents-server/drizzle/meta/_journal.json b/packages/agents-server/drizzle/meta/_journal.json index 925f30d355..e1d7ed922f 100644 --- a/packages/agents-server/drizzle/meta/_journal.json +++ b/packages/agents-server/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1779728400000, "tag": "0015_pg_sync_bridges", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1781200000000, + "tag": "0015_entity_type_writable_collections", + "breakpoints": true } ] } diff --git a/packages/agents-server/src/db/schema.ts b/packages/agents-server/src/db/schema.ts index 683b649e74..beb6b93583 100644 --- a/packages/agents-server/src/db/schema.ts +++ b/packages/agents-server/src/db/schema.ts @@ -14,6 +14,7 @@ import { timestamp, unique, } from 'drizzle-orm/pg-core' +import type { WritableCollectionConfig } from '../electric-agents-types.js' export const entityTypes = pgTable( `entity_types`, @@ -24,6 +25,10 @@ export const entityTypes = pgTable( creationSchema: jsonb(`creation_schema`), inboxSchemas: jsonb(`inbox_schemas`), stateSchemas: jsonb(`state_schemas`), + writableCollections: + jsonb(`writable_collections`).$type< + Record + >(), slashCommands: jsonb(`slash_commands`), serveEndpoint: text(`serve_endpoint`), defaultDispatchPolicy: jsonb(`default_dispatch_policy`), diff --git a/packages/agents-server/src/entity-registry.ts b/packages/agents-server/src/entity-registry.ts index 28722afd13..8c0e0a0db4 100644 --- a/packages/agents-server/src/entity-registry.ts +++ b/packages/agents-server/src/entity-registry.ts @@ -43,6 +43,7 @@ import type { EntityTypePermission, EntityTypePermissionGrant, PermissionSubjectKind, + WritableCollectionConfig, } from './electric-agents-types.js' import type { EntityTags, PgSyncOptions } from '@electric-ax/agents-runtime' import type { Principal } from './principal.js' @@ -654,6 +655,7 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, + writableCollections: et.writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -668,6 +670,7 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, + writableCollections: et.writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -691,6 +694,7 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, + writableCollections: et.writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -733,6 +737,7 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, + writableCollections: et.writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -1957,6 +1962,11 @@ export class PostgresRegistry { state_schemas: row.stateSchemas as | Record> | undefined, + writable_collections: + (row.writableCollections as Record< + string, + WritableCollectionConfig + > | null) ?? undefined, slash_commands: (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ?? undefined, diff --git a/packages/agents-server/test/entity-type-registry.test.ts b/packages/agents-server/test/entity-type-registry.test.ts index 3fd1813343..92bd993937 100644 --- a/packages/agents-server/test/entity-type-registry.test.ts +++ b/packages/agents-server/test/entity-type-registry.test.ts @@ -41,6 +41,18 @@ describe(`PostgresRegistry entity type registration`, () => { await client?.end() }, 120_000) + it(`persists and retrieves writable_collections round-trip`, async () => { + const registry = new PostgresRegistry(db, `tenant-a`) + const writableCollections = { + comments: { type: `state:comments`, principalColumn: `author_id` }, + } + await registry.createEntityType( + entityType({ writable_collections: writableCollections }) + ) + const result = await registry.getEntityType(`horton`) + expect(result?.writable_collections).toEqual(writableCollections) + }) + it(`upserts entity types against the tenant-scoped primary key`, async () => { const tenantA = new PostgresRegistry(db, `tenant-a`) const tenantB = new PostgresRegistry(db, `tenant-b`) diff --git a/packages/agents-server/test/test-backend.ts b/packages/agents-server/test/test-backend.ts index 382ac22101..62010fa0c2 100644 --- a/packages/agents-server/test/test-backend.ts +++ b/packages/agents-server/test/test-backend.ts @@ -130,6 +130,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasSharedStateLinks, hasEntityBridgePrincipal, hasLegacyEntitiesMetadata, + hasEntityTypeWritableCollections, ] = await Promise.all([ hasColumn(postgresUrl, `entities`, `tags`), hasColumn(postgresUrl, `entities`, `tags_index`), @@ -140,6 +141,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasTable(postgresUrl, `shared_state_links`), hasColumn(postgresUrl, `entity_bridges`, `principal_url`), hasColumn(postgresUrl, `entities`, `metadata`), + hasColumn(postgresUrl, `entity_types`, `writable_collections`), ]) return ( @@ -151,7 +153,8 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasEntityEffectivePermissions && hasSharedStateLinks && hasEntityBridgePrincipal && - !hasLegacyEntitiesMetadata + !hasLegacyEntitiesMetadata && + hasEntityTypeWritableCollections ) } From e0f06253e8815893bb4b11e70a306a3a744c1951 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 07:59:01 +0100 Subject: [PATCH 10/35] feat(agents-server): generic writeCollection with principal-header stamping Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-server/src/entity-manager.ts | 106 ++++++++++++++++++ ...ic-agents-manager-write-validation.test.ts | 78 +++++++++++++ 2 files changed, 184 insertions(+) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index 51a602b649..2d76a61087 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -132,6 +132,23 @@ export interface CreateAttachmentRequest { meta?: Record } +export interface WriteCollectionPrincipal { + url: string + kind: string + id: string +} + +export interface WriteCollectionRequest { + operation: `insert` | `update` | `delete` + key?: string + value?: Record + principal: WriteCollectionPrincipal +} + +export interface WriteCollectionResult { + key: string +} + export interface ReadAttachmentResult { attachment: ManifestAttachmentEntry bytes: Uint8Array @@ -2436,6 +2453,95 @@ export class EntityManager { } } + async writeCollection( + entityUrl: string, + collection: string, + req: WriteCollectionRequest + ): Promise { + const entity = await this.registry.getEntity(entityUrl) + if (!entity) { + throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404) + } + + const { writableCollections } = await this.getEffectiveSchemas(entity) + const config = writableCollections?.[collection] + if (!config) { + throw new ElectricAgentsError( + ErrCodeUnauthorized, + `Collection "${collection}" is not writable`, + 403 + ) + } + + if (rejectsNormalWrites(entity.status)) { + throw new ElectricAgentsError( + ErrCodeNotRunning, + `Entity is not accepting writes`, + 409 + ) + } + + if ( + req.operation !== `delete` && + (req.value === undefined || req.value === null) + ) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `value is required for ${req.operation}`, + 400 + ) + } + if (req.operation !== `insert` && !req.key) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `key is required for ${req.operation}`, + 400 + ) + } + + const key = + req.key ?? + `${collection}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + const event: Record = { + type: config.type, + key, + headers: { + operation: req.operation, + timestamp: new Date().toISOString(), + principal: req.principal, + }, + } + if (req.operation !== `delete`) { + event.value = req.value + } + + const validationError = await this.validateWriteEvent(entity, event) + if (validationError) { + throw new ElectricAgentsError( + validationError.code, + validationError.message, + validationError.status + ) + } + + const encoded = this.encodeChangeEvent(event) + try { + await this.streamClient.append(entity.streams.main, encoded) + } catch (err) { + if (this.isClosedStreamError(err)) { + throw new ElectricAgentsError( + ErrCodeNotRunning, + `Entity is stopped`, + 409 + ) + } + throw err + } + + return { key } + } + async updateInboxMessage( entityUrl: string, key: string, diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index 4cab89f72b..2f9d9bef41 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -364,6 +364,84 @@ describe(`ElectricAgentsManager event source subscriptions`, () => { }) }) +function decodeAppendEvent(bytes: Uint8Array): Record { + return JSON.parse(new TextDecoder().decode(bytes)) as Record +} + +describe(`ElectricAgentsManager.writeCollection`, () => { + const principal = { + url: `/principal/user%3Aalice`, + kind: `user`, + id: `alice`, + } + + it(`stamps the principal header and appends a generic collection insert`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + manager.registry.getEntity = vi.fn().mockResolvedValue({ + url: `/chat/session-1`, + type: `chat`, + status: `running`, + streams: { main: `/chat/session-1` }, + }) + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { 'state:comments': {} }, + writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + + const result = await manager.writeCollection( + `/chat/session-1`, + `comments`, + { + operation: `insert`, + key: `c1`, + value: { body: `hi` }, + principal, + } + ) + + expect(result).toEqual({ key: `c1` }) + const event = decodeAppendEvent(append.mock.calls[0]?.[1]) + expect(event).toMatchObject({ + type: `state:comments`, + key: `c1`, + headers: { operation: `insert`, principal }, + value: { body: `hi` }, + }) + expect( + (event.value as Record).from_principal + ).toBeUndefined() + }) + + it(`rejects writes to a collection that is not writable`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + manager.registry.getEntity = vi.fn().mockResolvedValue({ + url: `/chat/session-1`, + type: `chat`, + status: `running`, + streams: { main: `/chat/session-1` }, + }) + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { 'state:notes': {} }, + writable_collections: {}, + }) + + await expect( + manager.writeCollection(`/chat/session-1`, `notes`, { + operation: `insert`, + value: { note: `x` }, + principal, + }) + ).rejects.toMatchObject({ status: 403 }) + expect(append).not.toHaveBeenCalled() + }) +}) + function createManifestManager(calls: Array) { return new EntityManager({ registry: { From 0b3b8f2e970155ad2d2af61d6b0be3e9aee7596d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:32:58 +0100 Subject: [PATCH 11/35] refactor: rename writable collections to externally writable Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/create-handler.ts | 14 ++++++------- .../agents-runtime/src/entity-stream-db.ts | 6 +++--- packages/agents-runtime/src/types.ts | 6 +++--- ...reate-handler-externally-writable.test.ts} | 10 +++++----- .../test/entity-stream-db-principal.test.ts | 10 +++++----- ...y_type_externally_writable_collections.sql | 1 + .../0015_entity_type_writable_collections.sql | 1 - .../agents-server/drizzle/meta/_journal.json | 2 +- packages/agents-server/src/db/schema.ts | 9 ++++----- .../src/electric-agents-types.ts | 14 +++++++++---- packages/agents-server/src/entity-manager.ts | 19 +++++++++++------- packages/agents-server/src/entity-registry.ts | 20 +++++++++++-------- .../src/routing/entity-types-router.ts | 8 +++++--- ...ic-agents-manager-write-validation.test.ts | 4 ++-- .../test/electric-agents-routes.test.ts | 8 ++++---- .../test/entity-type-registry.test.ts | 12 +++++++---- packages/agents-server/test/test-backend.ts | 6 +++--- 17 files changed, 85 insertions(+), 65 deletions(-) rename packages/agents-runtime/test/{create-handler-writable.test.ts => create-handler-externally-writable.test.ts} (69%) create mode 100644 packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql delete mode 100644 packages/agents-server/drizzle/0015_entity_type_writable_collections.sql diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index 8741f9b977..32567bd4c0 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -282,18 +282,18 @@ export function buildEntityTypeRegistrationBody( ]) ) - const writableCollections: Record< + const externallyWritableCollections: Record< string, { type: string; principalColumn: string } > = {} for (const [collectionName, def] of stateEntries) { - if (!def.writable) continue - writableCollections[collectionName] = { + if (!def.externallyWritable) continue + externallyWritableCollections[collectionName] = { type: def.type ?? `state:${collectionName}`, principalColumn: - def.writable === true + def.externallyWritable === true ? `_principal` - : (def.writable.principalColumn ?? `_principal`), + : (def.externallyWritable.principalColumn ?? `_principal`), } } @@ -314,8 +314,8 @@ export function buildEntityTypeRegistrationBody( ...stateSchemas, ...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}), }, - ...(Object.keys(writableCollections).length > 0 && { - writable_collections: writableCollections, + ...(Object.keys(externallyWritableCollections).length > 0 && { + externally_writable_collections: externallyWritableCollections, }), ...(definition.permissionGrants && { permission_grants: definition.permissionGrants, diff --git a/packages/agents-runtime/src/entity-stream-db.ts b/packages/agents-runtime/src/entity-stream-db.ts index efabfd1b0e..e05e2474f8 100644 --- a/packages/agents-runtime/src/entity-stream-db.ts +++ b/packages/agents-runtime/src/entity-stream-db.ts @@ -164,10 +164,10 @@ export function createEntityStreamDB( const principalColumnByCollection = new Map() if (customState) { for (const [name, def] of Object.entries(customState)) { - const principalColumn = def.writable - ? def.writable === true + const principalColumn = def.externallyWritable + ? def.externallyWritable === true ? `_principal` - : (def.writable.principalColumn ?? `_principal`) + : (def.externallyWritable.principalColumn ?? `_principal`) : undefined if (principalColumn) { diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index ec7a5336e1..7fb20af8fd 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -640,11 +640,11 @@ export interface CollectionDefinition< /** Primary key field name. Defaults to `"key"`. */ primaryKey?: string /** - * Opt-in for HTTP-router writes via `POST /:type/:instanceId/collections/:name`. - * Absent/false ⇒ agent-only; the endpoint rejects writes. `true` ⇒ writable, + * Opt-in for externally writable via the HTTP router: `POST /:type/:instanceId/collections/:name`. + * Absent/false ⇒ agent-only; the endpoint rejects writes. `true` ⇒ externally writable, * principal materialized into `_principal`. Object form renames that column. */ - writable?: boolean | { principalColumn?: string } + externallyWritable?: boolean | { principalColumn?: string } } export interface EntityTypeEntry< diff --git a/packages/agents-runtime/test/create-handler-writable.test.ts b/packages/agents-runtime/test/create-handler-externally-writable.test.ts similarity index 69% rename from packages/agents-runtime/test/create-handler-writable.test.ts rename to packages/agents-runtime/test/create-handler-externally-writable.test.ts index 5ff4900e0a..e6d7ada25e 100644 --- a/packages/agents-runtime/test/create-handler-writable.test.ts +++ b/packages/agents-runtime/test/create-handler-externally-writable.test.ts @@ -3,31 +3,31 @@ import { z } from 'zod' import { buildEntityTypeRegistrationBody } from '../src/create-handler' describe(`buildEntityTypeRegistrationBody`, () => { - it(`emits writable_collections for writable state collections only`, () => { + it(`emits externally_writable_collections for externally writable state collections only`, () => { const body = buildEntityTypeRegistrationBody(`chat`, { description: `chat`, handler: async () => {}, state: { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: { principalColumn: `_principal` }, + externallyWritable: { principalColumn: `_principal` }, }, scratch: { schema: z.object({ key: z.string().optional(), note: z.string() }), }, }, } as any) - expect(body.writable_collections).toEqual({ + expect(body.externally_writable_collections).toEqual({ comments: { type: `state:comments`, principalColumn: `_principal` }, }) }) - it(`omits writable_collections when no collection opts in`, () => { + it(`omits externally_writable_collections when no collection opts in`, () => { const body = buildEntityTypeRegistrationBody(`chat`, { description: `chat`, handler: async () => {}, state: { scratch: { schema: z.object({ note: z.string() }) } }, } as any) - expect(body.writable_collections).toBeUndefined() + expect(body.externally_writable_collections).toBeUndefined() }) }) diff --git a/packages/agents-runtime/test/entity-stream-db-principal.test.ts b/packages/agents-runtime/test/entity-stream-db-principal.test.ts index 3de9e6153e..f9506fbb77 100644 --- a/packages/agents-runtime/test/entity-stream-db-principal.test.ts +++ b/packages/agents-runtime/test/entity-stream-db-principal.test.ts @@ -8,11 +8,11 @@ function principalHeader() { } describe(`entity-stream-db principal virtual column`, () => { - it(`projects headers.principal onto the configured column for writable collections`, () => { + it(`projects headers.principal onto the configured column for externally writable collections`, () => { const db = createEntityStreamDB(`/chat/sess-1`, { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: { principalColumn: `_principal` }, + externallyWritable: { principalColumn: `_principal` }, }, }) db.utils.applyEvent({ @@ -26,11 +26,11 @@ describe(`entity-stream-db principal virtual column`, () => { expect(row._principal).toEqual(principalHeader()) }) - it(`projects headers.principal onto _principal when writable: true`, () => { + it(`projects headers.principal onto _principal when externallyWritable: true`, () => { const db = createEntityStreamDB(`/chat/sess-3`, { notes: { schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: true, + externallyWritable: true, }, }) db.utils.applyEvent({ @@ -67,7 +67,7 @@ describe(`entity-stream-db principal virtual column`, () => { { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: { principalColumn: `_principal` }, + externallyWritable: { principalColumn: `_principal` }, }, }, undefined, diff --git a/packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql b/packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql new file mode 100644 index 0000000000..63871f3427 --- /dev/null +++ b/packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql @@ -0,0 +1 @@ +ALTER TABLE "entity_types" ADD COLUMN "externally_writable_collections" jsonb; diff --git a/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql b/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql deleted file mode 100644 index 35e65df53a..0000000000 --- a/packages/agents-server/drizzle/0015_entity_type_writable_collections.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "entity_types" ADD COLUMN "writable_collections" jsonb; diff --git a/packages/agents-server/drizzle/meta/_journal.json b/packages/agents-server/drizzle/meta/_journal.json index e1d7ed922f..f4ce70ea05 100644 --- a/packages/agents-server/drizzle/meta/_journal.json +++ b/packages/agents-server/drizzle/meta/_journal.json @@ -118,7 +118,7 @@ "idx": 16, "version": "7", "when": 1781200000000, - "tag": "0015_entity_type_writable_collections", + "tag": "0015_entity_type_externally_writable_collections", "breakpoints": true } ] diff --git a/packages/agents-server/src/db/schema.ts b/packages/agents-server/src/db/schema.ts index beb6b93583..f65c77b1d5 100644 --- a/packages/agents-server/src/db/schema.ts +++ b/packages/agents-server/src/db/schema.ts @@ -14,7 +14,7 @@ import { timestamp, unique, } from 'drizzle-orm/pg-core' -import type { WritableCollectionConfig } from '../electric-agents-types.js' +import type { ExternallyWritableCollectionConfig } from '../electric-agents-types.js' export const entityTypes = pgTable( `entity_types`, @@ -25,10 +25,9 @@ export const entityTypes = pgTable( creationSchema: jsonb(`creation_schema`), inboxSchemas: jsonb(`inbox_schemas`), stateSchemas: jsonb(`state_schemas`), - writableCollections: - jsonb(`writable_collections`).$type< - Record - >(), + externallyWritableCollections: jsonb( + `externally_writable_collections` + ).$type>(), slashCommands: jsonb(`slash_commands`), serveEndpoint: text(`serve_endpoint`), defaultDispatchPolicy: jsonb(`default_dispatch_policy`), diff --git a/packages/agents-server/src/electric-agents-types.ts b/packages/agents-server/src/electric-agents-types.ts index 980c5f7fcf..4800663ee5 100644 --- a/packages/agents-server/src/electric-agents-types.ts +++ b/packages/agents-server/src/electric-agents-types.ts @@ -494,8 +494,8 @@ export function toPublicEntity( } } -/** Per-collection config making an entity-state collection writable via the router. */ -export interface WritableCollectionConfig { +/** Per-collection config making an entity-state collection externally writable via the router. */ +export interface ExternallyWritableCollectionConfig { /** Durable-stream event type for this collection, e.g. `state:comments`. */ type: string /** Row column the client materializes the principal header into. */ @@ -508,7 +508,10 @@ export interface ElectricAgentsEntityType { creation_schema?: Record inbox_schemas?: Record> state_schemas?: Record> - writable_collections?: Record + externally_writable_collections?: Record< + string, + ExternallyWritableCollectionConfig + > slash_commands?: Array serve_endpoint?: string default_dispatch_policy?: DispatchPolicy @@ -523,7 +526,10 @@ export interface RegisterEntityTypeRequest { creation_schema?: Record inbox_schemas?: Record> state_schemas?: Record> - writable_collections?: Record + externally_writable_collections?: Record< + string, + ExternallyWritableCollectionConfig + > slash_commands?: Array serve_endpoint?: string default_dispatch_policy?: DispatchPolicy diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index 2d76a61087..fd5280e4ea 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -71,7 +71,7 @@ import type { SignalRequest, SignalResponse, TypedSpawnRequest, - WritableCollectionConfig, + ExternallyWritableCollectionConfig, } from './electric-agents-types.js' import type { EntityBridgeCoordinator } from './entity-bridge-manager.js' import type { Principal } from './principal.js' @@ -506,7 +506,7 @@ export class EntityManager { creation_schema: req.creation_schema, inbox_schemas: req.inbox_schemas, state_schemas: req.state_schemas, - writable_collections: req.writable_collections, + externally_writable_collections: req.externally_writable_collections, slash_commands: req.slash_commands, serve_endpoint: req.serve_endpoint, default_dispatch_policy: defaultDispatchPolicy, @@ -2463,8 +2463,9 @@ export class EntityManager { throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404) } - const { writableCollections } = await this.getEffectiveSchemas(entity) - const config = writableCollections?.[collection] + const { externallyWritableCollections } = + await this.getEffectiveSchemas(entity) + const config = externallyWritableCollections?.[collection] if (!config) { throw new ElectricAgentsError( ErrCodeUnauthorized, @@ -3984,13 +3985,16 @@ export class EntityManager { private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{ inboxSchemas?: Record> stateSchemas?: Record> - writableCollections?: Record + externallyWritableCollections?: Record< + string, + ExternallyWritableCollectionConfig + > }> { if (!entity.type) { return { inboxSchemas: entity.inbox_schemas, stateSchemas: entity.state_schemas, - writableCollections: undefined, + externallyWritableCollections: undefined, } } @@ -4003,7 +4007,8 @@ export class EntityManager { stateSchemas: latestType?.state_schemas ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas } : entity.state_schemas, - writableCollections: latestType?.writable_collections, + externallyWritableCollections: + latestType?.externally_writable_collections, } } diff --git a/packages/agents-server/src/entity-registry.ts b/packages/agents-server/src/entity-registry.ts index 8c0e0a0db4..37d17195e9 100644 --- a/packages/agents-server/src/entity-registry.ts +++ b/packages/agents-server/src/entity-registry.ts @@ -43,7 +43,7 @@ import type { EntityTypePermission, EntityTypePermissionGrant, PermissionSubjectKind, - WritableCollectionConfig, + ExternallyWritableCollectionConfig, } from './electric-agents-types.js' import type { EntityTags, PgSyncOptions } from '@electric-ax/agents-runtime' import type { Principal } from './principal.js' @@ -655,7 +655,8 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, - writableCollections: et.writable_collections ?? null, + externallyWritableCollections: + et.externally_writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -670,7 +671,8 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, - writableCollections: et.writable_collections ?? null, + externallyWritableCollections: + et.externally_writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -694,7 +696,8 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, - writableCollections: et.writable_collections ?? null, + externallyWritableCollections: + et.externally_writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -737,7 +740,8 @@ export class PostgresRegistry { creationSchema: et.creation_schema ?? null, inboxSchemas: et.inbox_schemas ?? null, stateSchemas: et.state_schemas ?? null, - writableCollections: et.writable_collections ?? null, + externallyWritableCollections: + et.externally_writable_collections ?? null, slashCommands: et.slash_commands ?? null, serveEndpoint: et.serve_endpoint ?? null, defaultDispatchPolicy: et.default_dispatch_policy ?? null, @@ -1962,10 +1966,10 @@ export class PostgresRegistry { state_schemas: row.stateSchemas as | Record> | undefined, - writable_collections: - (row.writableCollections as Record< + externally_writable_collections: + (row.externallyWritableCollections as Record< string, - WritableCollectionConfig + ExternallyWritableCollectionConfig > | null) ?? undefined, slash_commands: (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ?? diff --git a/packages/agents-server/src/routing/entity-types-router.ts b/packages/agents-server/src/routing/entity-types-router.ts index 47cc45cb54..941f52566d 100644 --- a/packages/agents-server/src/routing/entity-types-router.ts +++ b/packages/agents-server/src/routing/entity-types-router.ts @@ -45,7 +45,7 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & { const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown()) const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema) -const writableCollectionsSchema = Type.Record( +const externallyWritableCollectionsSchema = Type.Record( Type.String(), Type.Object( { type: Type.String(), principalColumn: Type.String() }, @@ -100,7 +100,9 @@ const registerEntityTypeBodySchema = Type.Object( permission_grants: Type.Optional( Type.Array(typePermissionGrantInputSchema) ), - writable_collections: Type.Optional(writableCollectionsSchema), + externally_writable_collections: Type.Optional( + externallyWritableCollectionsSchema + ), }, { additionalProperties: false } ) @@ -473,7 +475,7 @@ function normalizeEntityTypeRequest( } as RegisterEntityTypeRequest[`default_dispatch_policy`]) : undefined), permission_grants: parsed.permission_grants, - writable_collections: parsed.writable_collections, + externally_writable_collections: parsed.externally_writable_collections, } } diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index 2f9d9bef41..b7015aa25c 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -387,7 +387,7 @@ describe(`ElectricAgentsManager.writeCollection`, () => { manager.registry.getEntityType = vi.fn().mockResolvedValue({ name: `chat`, state_schemas: { 'state:comments': {} }, - writable_collections: { + externally_writable_collections: { comments: { type: `state:comments`, principalColumn: `_principal` }, }, }) @@ -428,7 +428,7 @@ describe(`ElectricAgentsManager.writeCollection`, () => { manager.registry.getEntityType = vi.fn().mockResolvedValue({ name: `chat`, state_schemas: { 'state:notes': {} }, - writable_collections: {}, + externally_writable_collections: {}, }) await expect( diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index 86635ac465..9a60a87280 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -1500,14 +1500,14 @@ describe(`ElectricAgentsRoutes fork endpoint`, () => { }) describe(`ElectricAgentsRoutes entity-type registration`, () => { - it(`persists writable_collections on entity type registration`, async () => { + it(`persists externally_writable_collections on entity type registration`, async () => { const registerEntityType = vi.fn().mockResolvedValue({ name: `chat`, description: `chat`, revision: 1, created_at: `t`, updated_at: `t`, - writable_collections: { + externally_writable_collections: { comments: { type: `state:comments`, principalColumn: `_principal` }, }, }) @@ -1523,7 +1523,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { { name: `chat`, description: `chat`, - writable_collections: { + externally_writable_collections: { comments: { type: `state:comments`, principalColumn: `_principal` }, }, } @@ -1532,7 +1532,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { expect(response.status).toBe(201) expect(registerEntityType).toHaveBeenCalledWith( expect.objectContaining({ - writable_collections: { + externally_writable_collections: { comments: { type: `state:comments`, principalColumn: `_principal` }, }, }) diff --git a/packages/agents-server/test/entity-type-registry.test.ts b/packages/agents-server/test/entity-type-registry.test.ts index 92bd993937..27aa784451 100644 --- a/packages/agents-server/test/entity-type-registry.test.ts +++ b/packages/agents-server/test/entity-type-registry.test.ts @@ -41,16 +41,20 @@ describe(`PostgresRegistry entity type registration`, () => { await client?.end() }, 120_000) - it(`persists and retrieves writable_collections round-trip`, async () => { + it(`persists and retrieves externally_writable_collections round-trip`, async () => { const registry = new PostgresRegistry(db, `tenant-a`) - const writableCollections = { + const externallyWritableCollections = { comments: { type: `state:comments`, principalColumn: `author_id` }, } await registry.createEntityType( - entityType({ writable_collections: writableCollections }) + entityType({ + externally_writable_collections: externallyWritableCollections, + }) ) const result = await registry.getEntityType(`horton`) - expect(result?.writable_collections).toEqual(writableCollections) + expect(result?.externally_writable_collections).toEqual( + externallyWritableCollections + ) }) it(`upserts entity types against the tenant-scoped primary key`, async () => { diff --git a/packages/agents-server/test/test-backend.ts b/packages/agents-server/test/test-backend.ts index 62010fa0c2..49f767fa63 100644 --- a/packages/agents-server/test/test-backend.ts +++ b/packages/agents-server/test/test-backend.ts @@ -130,7 +130,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasSharedStateLinks, hasEntityBridgePrincipal, hasLegacyEntitiesMetadata, - hasEntityTypeWritableCollections, + hasEntityTypeExternallyWritableCollections, ] = await Promise.all([ hasColumn(postgresUrl, `entities`, `tags`), hasColumn(postgresUrl, `entities`, `tags_index`), @@ -141,7 +141,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasTable(postgresUrl, `shared_state_links`), hasColumn(postgresUrl, `entity_bridges`, `principal_url`), hasColumn(postgresUrl, `entities`, `metadata`), - hasColumn(postgresUrl, `entity_types`, `writable_collections`), + hasColumn(postgresUrl, `entity_types`, `externally_writable_collections`), ]) return ( @@ -154,7 +154,7 @@ async function ensureExpectedSchema(postgresUrl: string): Promise { hasSharedStateLinks && hasEntityBridgePrincipal && !hasLegacyEntitiesMetadata && - hasEntityTypeWritableCollections + hasEntityTypeExternallyWritableCollections ) } From 136e03a19046649ad1c274529775447d19fb1497 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:39:07 +0100 Subject: [PATCH 12/35] fix(agents-server): add fork-work-lock guard to writeCollection Add the missing isForkWorkLockedEntity/assertEntityNotForkWorkLocked guard to writeCollection, matching the pattern used by createAttachment and deleteAttachment. Add a test asserting stopped entities are rejected with 409. Co-Authored-By: Claude Sonnet 4.6 --- packages/agents-server/src/entity-manager.ts | 3 ++ ...ic-agents-manager-write-validation.test.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index fd5280e4ea..f5a098cd6b 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -2481,6 +2481,9 @@ export class EntityManager { 409 ) } + if (this.isForkWorkLockedEntity(entityUrl)) { + this.assertEntityNotForkWorkLocked(entityUrl) + } if ( req.operation !== `delete` && diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index b7015aa25c..49bc40b87a 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -440,6 +440,34 @@ describe(`ElectricAgentsManager.writeCollection`, () => { ).rejects.toMatchObject({ status: 403 }) expect(append).not.toHaveBeenCalled() }) + + it(`rejects writes to a stopped entity with 409`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + manager.registry.getEntity = vi.fn().mockResolvedValue({ + url: `/chat/session-1`, + type: `chat`, + status: `stopped`, + streams: { main: `/chat/session-1` }, + }) + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { 'state:comments': {} }, + externally_writable_collections: { + comments: { type: `state:comments`, principalColumn: `_principal` }, + }, + }) + + await expect( + manager.writeCollection(`/chat/session-1`, `comments`, { + operation: `insert`, + key: `c1`, + value: { body: `hi` }, + principal, + }) + ).rejects.toMatchObject({ status: 409 }) + expect(append).not.toHaveBeenCalled() + }) }) function createManifestManager(calls: Array) { From a3a8f3fac8d9c380bc8e0424e3b6eecdc1de6b8e Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:41:16 +0100 Subject: [PATCH 13/35] feat(agents-server): POST /collections/:collection generic write route Exposes EntityManager.writeCollection over HTTP, mirroring the send route, with 201 for inserts and 200 for updates/deletes. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routing/entities-router.ts | 46 +++++++++++++++++++ .../test/electric-agents-routes.test.ts | 33 +++++++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/agents-server/src/routing/entities-router.ts b/packages/agents-server/src/routing/entities-router.ts index 7a6189643b..1392dfda5d 100644 --- a/packages/agents-server/src/routing/entities-router.ts +++ b/packages/agents-server/src/routing/entities-router.ts @@ -149,6 +149,19 @@ const spawnBodySchema = Type.Object({ ), }) +const writeCollectionBodySchema = Type.Object( + { + operation: Type.Union([ + Type.Literal(`insert`), + Type.Literal(`update`), + Type.Literal(`delete`), + ]), + key: Type.Optional(Type.String()), + value: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + }, + { additionalProperties: false } +) + const sendBodySchema = Type.Object({ payload: Type.Optional(Type.Unknown()), key: Type.Optional(Type.String()), @@ -328,6 +341,7 @@ const eventSourceSubscriptionBodySchema = Type.Object({ }) type SpawnBody = Static +type WriteCollectionBody = Static type SendBody = Static type InboxMessageBody = Static type ForkBody = Static @@ -408,6 +422,13 @@ entitiesRouter.post( withEntityPermission(`write`), sendEntity ) +entitiesRouter.post( + `/:type/:instanceId/collections/:collection`, + withExistingEntity, + withSchema(writeCollectionBodySchema), + withEntityPermission(`write`), + writeCollection +) entitiesRouter.post( `/:type/:instanceId/attachments`, withExistingEntity, @@ -1308,6 +1329,31 @@ async function sendEntity( return json(result) } +async function writeCollection( + request: AgentsRouteRequest, + ctx: TenantContext +): Promise { + const parsed = routeBody(request) + await ctx.entityManager.ensurePrincipal(ctx.principal) + const { entityUrl } = requireExistingEntityRoute(request) + const collection = request.params.collection + const result = await ctx.entityManager.writeCollection( + entityUrl, + collection, + { + operation: parsed.operation, + key: parsed.key, + value: parsed.value, + principal: { + url: ctx.principal.url, + kind: ctx.principal.kind, + id: ctx.principal.id, + }, + } + ) + return json(result, { status: parsed.operation === `insert` ? 201 : 200 }) +} + async function createAttachment( request: AgentsRouteRequest, ctx: TenantContext diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index 9a60a87280..418f47db7a 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -1499,6 +1499,39 @@ describe(`ElectricAgentsRoutes fork endpoint`, () => { }) }) +describe(`ElectricAgentsRoutes collections endpoint`, () => { + it(`routes a collection write to the manager with the authenticated principal`, async () => { + const manager = { + registry: { + getEntity: vi.fn().mockResolvedValue({ url: `/chat/test` }), + getEntityType: vi.fn(), + }, + ensurePrincipal: vi.fn().mockResolvedValue(undefined), + writeCollection: vi.fn().mockResolvedValue({ key: `c1` }), + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entities/chat/test/collections/comments`, + { operation: `insert`, key: `c1`, value: { body: `hi` } } + ) + + expect(response.status).toBe(201) + expect(await responseJson(response)).toEqual({ key: `c1` }) + expect(manager.writeCollection).toHaveBeenCalledWith( + `/chat/test`, + `comments`, + expect.objectContaining({ + operation: `insert`, + key: `c1`, + value: { body: `hi` }, + principal: expect.objectContaining({ url: expect.any(String) }), + }) + ) + }) +}) + describe(`ElectricAgentsRoutes entity-type registration`, () => { it(`persists externally_writable_collections on entity type registration`, async () => { const registerEntityType = vi.fn().mockResolvedValue({ From d517867e8c00bc01fff2e1a9e75b9e8b817b3d90 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:47:47 +0100 Subject: [PATCH 14/35] feat(agents-runtime): comments collection on generic externally-writable interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable commentsCollection definition (schema + CollectionDefinition) that Horton and worker can declare as custom state, replacing the hardcoded built-in comment schema from PR #4529. Drops from_principal — provenance now comes from the server-stamped _principal virtual column. Co-Authored-By: Claude Sonnet 4.6 --- .../agents-runtime/src/comments-collection.ts | 81 +++++++++++++++++++ packages/agents-runtime/src/index.ts | 7 ++ .../test/comments-collection.test.ts | 29 +++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/agents-runtime/src/comments-collection.ts create mode 100644 packages/agents-runtime/test/comments-collection.test.ts diff --git a/packages/agents-runtime/src/comments-collection.ts b/packages/agents-runtime/src/comments-collection.ts new file mode 100644 index 0000000000..c73caafac3 --- /dev/null +++ b/packages/agents-runtime/src/comments-collection.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import type { CollectionDefinition } from './types' + +export type CommentTargetValue = + | { kind: `comment`; key: string } + | { + kind: `timeline` + collection: + | `inbox` + | `run` + | `text` + | `tool_call` + | `wake` + | `signal` + | `manifest` + key: string + run_id?: string + } + +export type CommentSnapshotValue = { + label: string + text?: string + from?: string + timestamp?: string + collection?: string +} + +export type CommentValue = { + key?: string + body: string + timestamp: string + reply_to?: CommentTargetValue + target_snapshot?: CommentSnapshotValue + edited_at?: string + deleted_at?: string + deleted_by?: string +} + +const commentTargetSchema = z.union([ + z.object({ kind: z.literal(`comment`), key: z.string() }), + z.object({ + kind: z.literal(`timeline`), + collection: z.enum([ + `inbox`, + `run`, + `text`, + `tool_call`, + `wake`, + `signal`, + `manifest`, + ]), + key: z.string(), + run_id: z.string().optional(), + }), +]) + +const commentSnapshotSchema = z.object({ + label: z.string(), + text: z.string().optional(), + from: z.string().optional(), + timestamp: z.string().optional(), + collection: z.string().optional(), +}) + +export const commentSchema = z.object({ + key: z.string().optional(), + body: z.string(), + timestamp: z.string(), + reply_to: commentTargetSchema.optional(), + target_snapshot: commentSnapshotSchema.optional(), + edited_at: z.string().optional(), + deleted_at: z.string().optional(), + deleted_by: z.string().optional(), +}) + +export const commentsCollection: CollectionDefinition = { + schema: commentSchema, + type: `state:comments`, + primaryKey: `key`, + externallyWritable: { principalColumn: `_principal` }, +} diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index 7942ae81e0..987533816d 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -398,3 +398,10 @@ export { STREAM_TOKEN_PREFIX, } from './event-pointer' export type { EventPointer } from './event-pointer' + +export { commentSchema, commentsCollection } from './comments-collection' +export type { + CommentTargetValue, + CommentSnapshotValue, + CommentValue, +} from './comments-collection' diff --git a/packages/agents-runtime/test/comments-collection.test.ts b/packages/agents-runtime/test/comments-collection.test.ts new file mode 100644 index 0000000000..ec0e9da2e7 --- /dev/null +++ b/packages/agents-runtime/test/comments-collection.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { commentSchema, commentsCollection } from '../src/comments-collection' + +describe(`commentsCollection`, () => { + it(`parses a valid comment with a timeline reply_to target`, () => { + const result = commentSchema.parse({ + key: `c-1`, + body: `LGTM`, + timestamp: `2024-01-01T00:00:00Z`, + reply_to: { + kind: `timeline`, + collection: `run`, + key: `run-42`, + run_id: `run-42`, + }, + }) + expect(result.body).toBe(`LGTM`) + expect(result.reply_to).toMatchObject({ + kind: `timeline`, + collection: `run`, + }) + }) + + it(`exports externallyWritable with the _principal column`, () => { + expect(commentsCollection.externallyWritable).toEqual({ + principalColumn: `_principal`, + }) + }) +}) From 4bdf8f315caaee81f10be4d4a3516721f9cc9421 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:51:55 +0100 Subject: [PATCH 15/35] feat(agents): declare comments as externally-writable state on horton and worker Adds `state: { comments: commentsCollection }` to both the horton and worker registry.define calls so the comments collection is registered as an externally-writable collection and materialized client-side. Co-Authored-By: Claude Sonnet 4.6 --- packages/agents/src/agents/horton.ts | 4 ++ packages/agents/src/agents/worker.ts | 5 ++- .../comments-collection-registration.test.ts | 41 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/agents/test/comments-collection-registration.test.ts diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 7c95f88b54..fa4c2b4119 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -20,6 +20,7 @@ import { buildSkillSlashCommands, createContextSkillLoader, completeWithLowCostModel, + commentsCollection, } from '@electric-ax/agents-runtime' import type { EntityRegistry, @@ -793,6 +794,9 @@ export function registerHorton( permission: `manage`, }, ], + state: { + comments: commentsCollection, + }, slashCommands: buildSkillSlashCommands(skillsRegistry), handler: assistantHandler, }) diff --git a/packages/agents/src/agents/worker.ts b/packages/agents/src/agents/worker.ts index da833feb01..ddb4a5bfc7 100644 --- a/packages/agents/src/agents/worker.ts +++ b/packages/agents/src/agents/worker.ts @@ -1,5 +1,5 @@ import { Type } from '@sinclair/typebox' -import { db } from '@electric-ax/agents-runtime' +import { db, commentsCollection } from '@electric-ax/agents-runtime' import { createBashTool, braveSearchTool, @@ -314,6 +314,9 @@ export function registerWorker( permission: `manage`, }, ], + state: { + comments: commentsCollection, + }, async handler(ctx) { const args = parseWorkerArgs(ctx.args) const readSet = new Set() diff --git a/packages/agents/test/comments-collection-registration.test.ts b/packages/agents/test/comments-collection-registration.test.ts new file mode 100644 index 0000000000..b16cec3cda --- /dev/null +++ b/packages/agents/test/comments-collection-registration.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { createEntityRegistry } from '@electric-ax/agents-runtime' +import { registerHorton } from '../src/agents/horton' +import { registerWorker } from '../src/agents/worker' +import type { BuiltinModelCatalog } from '../src/model-catalog' + +const modelCatalog: BuiltinModelCatalog = { + defaultChoice: { + provider: `anthropic` as const, + id: `claude-sonnet-4-6`, + label: `Anthropic Claude Sonnet 4.6`, + value: `anthropic:claude-sonnet-4-6`, + reasoning: true, + input: [`text`, `image`], + }, + choices: [ + { + provider: `anthropic` as const, + id: `claude-sonnet-4-6`, + label: `Anthropic Claude Sonnet 4.6`, + value: `anthropic:claude-sonnet-4-6`, + reasoning: true, + input: [`text`, `image`], + }, + ], +} + +describe(`comments collection registration`, () => { + it(`declares comments as an externally-writable state collection on horton and worker`, () => { + const registry = createEntityRegistry() + registerHorton(registry, { workingDirectory: `/tmp`, modelCatalog }) + registerWorker(registry, { workingDirectory: `/tmp`, modelCatalog }) + + for (const name of [`horton`, `worker`]) { + const def = registry.get(name)?.definition as any + expect(def.state?.comments?.externallyWritable).toEqual({ + principalColumn: `_principal`, + }) + } + }) +}) From d87086dee92a71ed7f4cefd8e6fcca163af8da72 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 08:57:55 +0100 Subject: [PATCH 16/35] feat(agents-runtime): project comments collection into timeline via _principal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port #4529's comment projection into createEntityTimelineQuery, adapted for the generic custom-state comments collection: guards on db.collections.comments existence so entities without comments are unaffected, and resolves the comment author from the _principal virtual column (row._principal?.url → comment.from) rather than a built-in from_principal field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-runtime/src/entity-timeline.ts | 89 ++++++++ .../test/entity-timeline.test.ts | 206 ++++++++++++++++++ 2 files changed, 295 insertions(+) diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 116818b246..942fb2dc1c 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -280,6 +280,18 @@ export interface EntityTimelineRunRow { } export type EntityTimelineInboxRow = IncludesInboxMessage +export type EntityTimelineCommentRow = { + key: string + order: TimelineOrder + body: string + from: string + timestamp: string + reply_to?: import(`./comments-collection`).CommentTargetValue + target_snapshot?: import(`./comments-collection`).CommentSnapshotValue + edited_at?: string + deleted_at?: string + deleted_by?: string +} export type EntityTimelineWakeRow = IncludesWakeMessage export type EntityTimelineSignalRow = IncludesSignal export type EntityTimelineErrorRow = EntityTimelineErrorItem & { @@ -291,6 +303,7 @@ export type EntityTimelineQueryRow = $key: string inbox: EntityTimelineInboxRow run?: undefined + comment?: undefined wake?: undefined signal?: undefined error?: undefined @@ -300,6 +313,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run: EntityTimelineRunRow + comment?: undefined wake?: undefined signal?: undefined error?: undefined @@ -309,6 +323,16 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment: EntityTimelineCommentRow + wake?: undefined + signal?: undefined + manifest?: undefined + } + | { + $key: string + inbox?: undefined + run?: undefined + comment?: undefined wake: EntityTimelineWakeRow signal?: undefined error?: undefined @@ -318,6 +342,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment?: undefined wake?: undefined signal: EntityTimelineSignalRow error?: undefined @@ -327,6 +352,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment?: undefined wake?: undefined signal?: undefined error: EntityTimelineErrorRow @@ -1340,6 +1366,24 @@ function buildEntityTimelineQuery( cancelled_at: inbox.cancelled_at, })) + const commentsCollection = (db.collections as Record) + .comments as typeof db.collections.wakes | undefined + + const commentSource = commentsCollection + ? q.from({ comment: commentsCollection }).select(({ comment }) => ({ + order: coalesce(comment._timeline_order, `~`), + key: comment.key, + body: (comment as any).body, + from: coalesce((comment as any)._principal?.url, ``), + timestamp: coalesce((comment as any).timestamp, ``), + reply_to: (comment as any).reply_to, + target_snapshot: (comment as any).target_snapshot, + edited_at: (comment as any).edited_at, + deleted_at: (comment as any).deleted_at, + deleted_by: (comment as any).deleted_by, + })) + : null + const wakeSource = q .from({ wake: db.collections.wakes }) .select(({ wake }) => ({ @@ -1513,6 +1557,51 @@ function buildEntityTimelineQuery( })), })) + if (commentSource) { + return q + .unionAll({ + inbox: inboxSource, + run: runSource, + comment: commentSource, + wake: wakeSource, + signal: signalSource, + manifest: db.collections.manifests, + }) + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => + coalesce( + inbox.order, + run.order, + comment.order, + wake.order, + signal.order, + manifest._timeline_order, + `~` + ) + ) + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => + coalesce( + caseWhen(inbox.key, `inbox`), + caseWhen(run.key, `run`), + caseWhen(comment.key, `comment`), + caseWhen(wake.key, `wake`), + caseWhen(signal.key, `signal`), + caseWhen(manifest.key, `manifest`), + `` + ) + ) + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => + coalesce( + inbox.key, + run.key, + comment.key, + wake.key, + signal.key, + manifest.key, + `` + ) + ) + } + return q .unionAll({ inbox: inboxSource, diff --git a/packages/agents-runtime/test/entity-timeline.test.ts b/packages/agents-runtime/test/entity-timeline.test.ts index 6528346bbc..5be450b979 100644 --- a/packages/agents-runtime/test/entity-timeline.test.ts +++ b/packages/agents-runtime/test/entity-timeline.test.ts @@ -22,6 +22,7 @@ import type { EventPointer } from '../src/event-pointer' import type { EntityTimelineContentItem, EntityTimelineData, + EntityTimelineQueryRow, IncludesInboxMessage, IncludesRun, IncludesWakeMessage, @@ -1864,6 +1865,211 @@ describe(`entity includes query`, () => { ) }) + describe(`comments collection projection`, () => { + function createEntityCollectionsWithComments() { + let nextOffset = 1 + let nextSeq = 1 + const takeOffset = () => offset(nextOffset++) + const takeSeq = () => nextSeq++ + const runs = createSyncCollection(`test-runs-c`, takeOffset) + const texts = createSyncCollection(`test-texts-c`, takeOffset) + const textDeltas = createSyncCollection(`test-textDeltas-c`, takeOffset) + const toolCalls = createSyncCollection(`test-toolCalls-c`, takeOffset) + const steps = createSyncCollection(`test-steps-c`, takeOffset) + const errors = createSyncCollection(`test-errors-c`, takeOffset) + const inbox = createSyncCollection(`test-inbox-c`, takeOffset) + const comments = createSyncCollection(`test-comments-c`, takeOffset) + const wakes = createSyncCollection(`test-wakes-c`, takeOffset) + const signals = createSyncCollection(`test-signals-c`, takeOffset) + const contextInserted = createSyncCollection( + `test-context-inserted-c`, + takeOffset + ) + const contextRemoved = createSyncCollection( + `test-context-removed-c`, + takeOffset + ) + const manifests = createSyncCollection(`test-manifests-c`, takeOffset) + const childStatus = createSyncCollection( + `test-child-status-c`, + takeOffset + ) + return { + collections: { + runs: runs.collection, + texts: texts.collection, + textDeltas: textDeltas.collection, + toolCalls: toolCalls.collection, + steps: steps.collection, + errors: errors.collection, + inbox: inbox.collection, + comments: comments.collection, + wakes: wakes.collection, + signals: signals.collection, + contextInserted: contextInserted.collection, + contextRemoved: contextRemoved.collection, + manifests: manifests.collection, + childStatus: childStatus.collection, + }, + sync: { + runs: withSeqInjection(runs, takeSeq), + texts: withSeqInjection(texts, takeSeq), + textDeltas: withSeqInjection(textDeltas, takeSeq), + toolCalls: withSeqInjection(toolCalls, takeSeq), + steps: withSeqInjection(steps, takeSeq), + errors: withSeqInjection(errors, takeSeq), + inbox: withSeqInjection(inbox, takeSeq), + comments: withSeqInjection(comments, takeSeq), + wakes: withSeqInjection(wakes, takeSeq), + signals: withSeqInjection(signals, takeSeq), + contextInserted: withSeqInjection(contextInserted, takeSeq), + contextRemoved: withSeqInjection(contextRemoved, takeSeq), + manifests: withSeqInjection(manifests, takeSeq), + childStatus: withSeqInjection(childStatus, takeSeq), + }, + } + } + + function timelineRowLabel(row: EntityTimelineQueryRow): string { + if (row.inbox) return `inbox:${row.inbox.key}` + if (row.run) return `run:${row.run.key}` + if (row.comment) return `comment:${row.comment.key}` + if (row.wake) return `wake:${row.wake.key}` + if (row.signal) return `signal:${row.signal.key}` + return `manifest:${row.manifest?.key}` + } + + it(`interleaves comments by _timeline_order`, async () => { + const { collections, sync } = createEntityCollectionsWithComments() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.inbox.insert({ + key: `msg-1`, + _timeline_order: order(1), + from: `user`, + payload: `start`, + timestamp: `2026-04-15T18:00:00.000Z`, + status: `processed`, + }) + sync.comments.insert({ + key: `comment-1`, + _timeline_order: order(2), + body: `between prompt and wake`, + timestamp: `2026-04-15T18:00:05.000Z`, + _principal: { url: `/principal/user%3Ame`, kind: `user`, id: `me` }, + }) + sync.wakes.insert({ + key: `wake-1`, + _timeline_order: order(3), + timestamp: `2026-04-15T18:00:10.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = Array.from((liveQuery as any).entries()).map( + ([, v]: any) => v + ) as Array + expect(rows.map(timelineRowLabel)).toEqual([ + `inbox:msg-1`, + `comment:comment-1`, + `wake:wake-1`, + ]) + }) + + it(`author resolves from _principal virtual column`, async () => { + const { collections, sync } = createEntityCollectionsWithComments() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.comments.insert({ + key: `comment-1`, + _timeline_order: order(1), + body: `hello`, + timestamp: `2026-04-15T18:00:00.000Z`, + _principal: { + url: `/principal/user%3Ajane`, + kind: `user`, + id: `jane`, + }, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = Array.from((liveQuery as any).entries()).map( + ([, v]: any) => v + ) as Array + expect(rows).toHaveLength(1) + expect(rows[0]?.comment?.from).toBe(`/principal/user%3Ajane`) + }) + + it(`preserves reply_to and target_snapshot on comment rows`, async () => { + const { collections, sync } = createEntityCollectionsWithComments() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.comments.insert({ + key: `comment-reply`, + _timeline_order: order(1), + body: `reply body`, + timestamp: `2026-04-15T18:00:00.000Z`, + reply_to: { kind: `timeline`, collection: `inbox`, key: `msg-1` }, + target_snapshot: { label: `User message`, text: `start` }, + _principal: { url: `/principal/user%3Ame`, kind: `user`, id: `me` }, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = Array.from((liveQuery as any).entries()).map( + ([, v]: any) => v + ) as Array + expect(rows).toHaveLength(1) + expect(rows[0]?.comment?.reply_to).toMatchObject({ + kind: `timeline`, + collection: `inbox`, + key: `msg-1`, + }) + expect(rows[0]?.comment?.target_snapshot).toMatchObject({ + label: `User message`, + text: `start`, + }) + }) + + it(`entities without a comments collection are unaffected`, async () => { + const { collections, sync } = createEntityCollections() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.inbox.insert({ + key: `msg-1`, + _timeline_order: order(1), + from: `user`, + payload: `start`, + timestamp: `2026-04-15T18:00:00.000Z`, + status: `processed`, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = Array.from((liveQuery as any).entries()).map( + ([, v]: any) => v + ) as Array + expect(rows).toHaveLength(1) + expect(rows[0]?.inbox?.key).toBe(`msg-1`) + }) + }) + it(`projects related entities from one manifest row per related entity`, () => { const timeline = buildEntityTimelineData({ collections: { From 8321189cac594971a40c132adc17edb2bcc852d3 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 09:03:29 +0100 Subject: [PATCH 17/35] fix(agents-runtime): use named imports for CommentTargetValue and CommentSnapshotValue Replace inline import() type expressions with named imports to fix esbuild transform failure in vitest when running the entity-timeline tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/entity-timeline.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 942fb2dc1c..261aa6d952 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -24,6 +24,10 @@ import type { import type { EntityStreamDB } from './entity-stream-db' import { formatPointerOrderToken, type EventPointer } from './event-pointer' import type { ChildStatusEntry, MessageReceived, Signal } from './entity-schema' +import type { + CommentSnapshotValue, + CommentTargetValue, +} from './comments-collection' import type { ManifestEntry, Wake, WakeMessage } from './types' export type EntityTimelineState = @@ -286,8 +290,8 @@ export type EntityTimelineCommentRow = { body: string from: string timestamp: string - reply_to?: import(`./comments-collection`).CommentTargetValue - target_snapshot?: import(`./comments-collection`).CommentSnapshotValue + reply_to?: CommentTargetValue + target_snapshot?: CommentSnapshotValue edited_at?: string deleted_at?: string deleted_by?: string From 9ad4ab05c76703e99f416418555f47f3ce4fecdd Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 09:30:41 +0100 Subject: [PATCH 18/35] feat(agents-server-ui): comments UI on generic externally-writable collection Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/client.ts | 6 + .../agents-runtime/src/entity-timeline.ts | 2 + .../src/components/AgentResponse.tsx | 34 +- .../src/components/CommentBubble.module.css | 176 ++++++++ .../src/components/CommentBubble.tsx | 180 ++++++++ .../src/components/EntityTimeline.module.css | 14 + .../src/components/EntityTimeline.tsx | 414 +++++++++++++++++- .../src/components/InlineEventCard.test.tsx | 39 ++ .../src/components/InlineEventCard.tsx | 57 ++- .../src/components/MessageInput.module.css | 79 ++++ .../src/components/MessageInput.tsx | 161 ++++++- .../src/components/ToolCallView.module.css | 13 + .../src/components/ToolCallView.tsx | 26 +- .../src/components/UserMessage.module.css | 25 +- .../src/components/UserMessage.tsx | 22 +- .../src/components/toolBlock.module.css | 58 +++ .../src/components/views/ChatView.test.ts | 129 ++++++ .../src/components/views/ChatView.tsx | 227 +++++++++- .../components/workspace/SplitMenu.module.css | 8 + .../src/components/workspace/SplitMenu.tsx | 45 ++ .../src/hooks/useEntityTimeline.ts | 4 +- .../agents-server-ui/src/lib/comments.test.ts | 144 ++++++ packages/agents-server-ui/src/lib/comments.ts | 128 ++++++ .../src/lib/workspace/registerViews.ts | 13 +- 24 files changed, 1947 insertions(+), 57 deletions(-) create mode 100644 packages/agents-server-ui/src/components/CommentBubble.module.css create mode 100644 packages/agents-server-ui/src/components/CommentBubble.tsx create mode 100644 packages/agents-server-ui/src/components/InlineEventCard.test.tsx create mode 100644 packages/agents-server-ui/src/components/views/ChatView.test.ts create mode 100644 packages/agents-server-ui/src/lib/comments.test.ts create mode 100644 packages/agents-server-ui/src/lib/comments.ts diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index c25001f662..2b17ce5991 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -10,6 +10,7 @@ export { getEntityState, normalizeEntityTimelineData, normalizeTimelineEntities, + TIMELINE_ORDER_FALLBACK, } from './entity-timeline' export { canonicalPgSyncOptions, @@ -80,6 +81,7 @@ export type { PgSyncRequestMetadata, } from './observation-sources' export type { + EntityTimelineCommentRow, EntityTimelineContentItem, EntityTimelineData, EntityTimelineInboxMode, @@ -96,4 +98,8 @@ export type { IncludesInboxMessage, IncludesRun, } from './entity-timeline' +export type { + CommentSnapshotValue as CommentSnapshot, + CommentTargetValue as CommentTarget, +} from './comments-collection' export type { EntityTimelineEntry } from './use-chat' diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 261aa6d952..f7e9797361 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -30,6 +30,8 @@ import type { } from './comments-collection' import type { ManifestEntry, Wake, WakeMessage } from './types' +export const TIMELINE_ORDER_FALLBACK = `zzzz:timeline-end` + export type EntityTimelineState = | `pending` | `queued` diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index a98dc51b21..1d05ccc0ca 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, GitFork } from 'lucide-react' +import { Check, Copy, GitFork, Reply } from 'lucide-react' import { memo, useEffect, @@ -386,6 +386,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({ timestamp, renderWidth = 0, forkFromHere, + onReply, + onReplyToToolCall, onSearchTextChange, }: { rowKey: string @@ -394,6 +396,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({ timestamp?: number | null renderWidth?: number forkFromHere?: ForkFromHereAction + onReply?: () => void + onReplyToToolCall?: (item: EntityTimelineToolCallItem) => void onSearchTextChange?: (rowKey: string, text: string) => void }): React.ReactElement { const { data: items = [] } = useLiveQuery( @@ -519,6 +523,11 @@ export const AgentResponseLive = memo(function AgentResponseLive({ onReplyToToolCall(item.toolCall!) + : undefined + } /> ) })} @@ -583,6 +592,7 @@ export const AgentResponseLive = memo(function AgentResponseLive({ copied={copied} onCopy={() => void copyResponseText()} forkFromHere={done ? forkFromHere : undefined} + onReply={onReply} /> @@ -594,20 +604,37 @@ function ResponseMetaActions({ copied, onCopy, forkFromHere, + onReply, }: { showCopy: boolean copied: boolean onCopy: () => void forkFromHere?: ForkFromHereAction + onReply?: () => void }): React.ReactElement | null { const showFork = forkFromHere !== undefined - if (!showCopy && !showFork) return null + if (!showCopy && !showFork && !onReply) return null const forkDisabled = forkFromHere?.disabled === true || !forkFromHere?.onFork const forkLabel = forkDisabled ? `Fork permission required` : `Fork from here` return ( + {onReply && ( + + + + + + )} {showFork && ( @@ -654,12 +681,14 @@ export const AgentResponse = memo(function AgentResponse({ timestamp, renderWidth = 0, forkFromHere, + onReply, }: { section: AgentResponseSection isStreaming: boolean timestamp?: number | null renderWidth?: number forkFromHere?: ForkFromHereAction + onReply?: () => void }): React.ReactElement { const canCache = !isStreaming && section.done === true const [copied, setCopied] = useState(false) @@ -803,6 +832,7 @@ export const AgentResponse = memo(function AgentResponse({ copied={copied} onCopy={() => void copyResponseText()} forkFromHere={section.done ? forkFromHere : undefined} + onReply={onReply} /> diff --git a/packages/agents-server-ui/src/components/CommentBubble.module.css b/packages/agents-server-ui/src/components/CommentBubble.module.css new file mode 100644 index 0000000000..9beebc9298 --- /dev/null +++ b/packages/agents-server-ui/src/components/CommentBubble.module.css @@ -0,0 +1,176 @@ +.root { + display: flex; + width: 100%; + box-sizing: border-box; +} + +.root[data-own='true'] { + justify-content: flex-end; +} + +.root[data-own='false'] { + justify-content: flex-start; +} + +.column { + display: grid; + gap: 4px; + max-width: min(72%, 640px); +} + +.root[data-own='true'] .column { + justify-items: end; +} + +.root[data-own='false'] .column { + justify-items: start; +} + +.message { + display: grid; + gap: 4px; + width: fit-content; + max-width: 100%; +} + +.root[data-own='true'] .message { + justify-self: end; +} + +.root[data-own='false'] .message { + justify-self: start; +} + +.bubble { + min-width: 0; + padding: 9px 12px; + border: 1px solid #000; + border-radius: var(--ds-radius-4); + background: #000; + color: #fff; + box-shadow: var(--ds-shadow-1); +} + +.root[data-single-line='true'] .bubble { + border-radius: var(--ds-radius-5); + padding-block: 7px; +} + +:global(html[data-theme='dark']) .bubble { + border-color: #fff; + background: #fff; + color: #000; +} + +.body, +.deletedBody { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--ds-chat-text); + line-height: var(--ds-chat-text-lh); +} + +.deletedBody { + color: inherit; + opacity: 0.64; + font-style: italic; +} + +.preview { + display: flex; + align-items: flex-start; + gap: 2px; + max-width: 100%; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--ds-text-3); + font: inherit; + text-align: left; +} + +.previewButton { + border-radius: var(--ds-radius-2); + cursor: pointer; +} + +.previewButton:hover .previewContent { + border-left-color: var(--ds-text-3); +} + +.previewButton:focus-visible { + outline: 2px solid var(--ds-focus-ring); + outline-offset: 2px; +} + +.previewIcon { + flex: 0 0 auto; + margin-top: 1px; + color: var(--ds-text-4); +} + +.previewContent { + display: grid; + gap: 2px; + min-width: 0; + padding-left: 6px; + border-left: 2px solid var(--ds-gray-a8); +} + +.previewLabel, +.previewText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.previewLabel { + white-space: nowrap; + font-size: var(--ds-text-xs); + font-weight: 600; + line-height: var(--ds-text-xs-lh); +} + +.previewText { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: var(--ds-text-xs); + line-height: var(--ds-text-xs-lh); +} + +.meta { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + box-sizing: border-box; + padding-inline: 8px; +} + +.meta > :not(.metaActions) { + opacity: 0.5; +} + +.metaActions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.metaActionButton { + color: var(--ds-text-4); + opacity: 0.7; +} + +.metaActionButton:hover { + opacity: 1; +} + +@media (max-width: 700px) { + .column { + max-width: min(88%, 640px); + } +} diff --git a/packages/agents-server-ui/src/components/CommentBubble.tsx b/packages/agents-server-ui/src/components/CommentBubble.tsx new file mode 100644 index 0000000000..c29bd6690a --- /dev/null +++ b/packages/agents-server-ui/src/components/CommentBubble.tsx @@ -0,0 +1,180 @@ +import { memo } from 'react' +import { Reply } from 'lucide-react' +import type { + CommentSnapshot, + CommentTarget, + EntityTimelineCommentRow, +} from '@electric-ax/agents-runtime/client' +import { Icon, IconButton, Text, Tooltip } from '../ui' +import { principalKeyFromInput } from '../lib/principals' +import { TimeText } from './TimeText' +import type { ElectricUser } from '../lib/ElectricAgentsProvider' +import styles from './CommentBubble.module.css' + +export const CommentBubble = memo(function CommentBubble({ + comment, + currentPrincipal, + usersById, + showMeta = true, + onReply, + onTargetClick, +}: { + comment: EntityTimelineCommentRow + currentPrincipal?: string + usersById?: Map + showMeta?: boolean + onReply?: (comment: EntityTimelineCommentRow) => void + onTargetClick?: (target: CommentTarget) => void +}): React.ReactElement { + const isOwn = + principalKeyFromInput(comment.from) === + principalKeyFromInput(currentPrincipal) + const sender = formatSender(comment.from, { + currentPrincipal, + usersById, + }) + const timestamp = Date.parse(comment.timestamp) + const deleted = Boolean(comment.deleted_at) + const singleLine = !deleted && !/[\r\n]/.test(comment.body) + + return ( +
+
+ {comment.target_snapshot && ( + onTargetClick(comment.reply_to!) + : undefined + } + /> + )} +
+
+
+ {deleted ? `Comment deleted` : comment.body} +
+
+ {showMeta && ( +
+ + {sender.label} + + {Number.isFinite(timestamp) && ( + <> + + - + + + + )} + {comment.edited_at && !deleted && ( + <> + + - + + + edited + + + )} + {onReply && !deleted && ( + + + onReply(comment)} + > + + + + + )} +
+ )} +
+
+
+ ) +}) + +function ReplyPreview({ + snapshot, + onClick, +}: { + snapshot: CommentSnapshot + onClick?: () => void +}): React.ReactElement { + const content = ( + <> + +
+
{snapshot.label}
+ {snapshot.text && ( +
{snapshot.text}
+ )} +
+ + ) + + if (onClick) { + return ( + + ) + } + + return
{content}
+} + +function formatSender( + from: string | null | undefined, + options: { + currentPrincipal?: string + usersById?: Map + } = {} +): { + label: string + title?: string +} { + const key = principalKeyFromInput(from) + if (!key) return { label: from || `user` } + if (key === principalKeyFromInput(options.currentPrincipal)) { + return { label: `Me`, title: key } + } + const colon = key.indexOf(`:`) + if (colon <= 0) return { label: key, title: key } + const kind = key.slice(0, colon) + const id = key.slice(colon + 1) + if (kind === `user`) { + const user = options.usersById?.get(id) + const label = user?.display_name || user?.email + if (label) return { label, title: key } + } + return { + label: `${kind}:${formatPrincipalId(id)}`, + title: key, + } +} + +function formatPrincipalId(id: string): string { + if (id.length <= 18) return id + return `${id.slice(0, 8)}...${id.slice(-6)}` +} diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index ca9e59c46d..3e38026550 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -191,6 +191,20 @@ box-sizing: border-box; } +.virtualRow[data-highlighted='true'] { + animation: targetPulse 1600ms ease-out; +} + +@keyframes targetPulse { + 0%, + 65% { + background: color-mix(in oklab, var(--ds-accent-a4) 70%, transparent); + } + 100% { + background: transparent; + } +} + /* Jump-to-bottom affordance — centered over the chat column so it relates to the conversation rather than the viewport edge. It stays mounted and toggles visibility with opacity/translate for a soft diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index d625c2c801..a29a4fa286 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -23,6 +23,7 @@ import { FileJson, GitBranch, Radio, + Reply, } from 'lucide-react' import { loadTimelineRowHeights, @@ -46,6 +47,7 @@ import { Icon, IconButton, ScrollArea, Stack, Text, Tooltip } from '../ui' import { UserMessage } from './UserMessage' import type { ForkFromHereAction, UserMessageAttachment } from './UserMessage' import { AgentResponseLive } from './AgentResponse' +import { CommentBubble } from './CommentBubble' import { InlineEventCard } from './InlineEventCard' import { InlineStatusBadge } from './InlineStatusBadge' import { @@ -57,13 +59,17 @@ import { formatChatTimestamp, } from '../lib/formatTime' import { readTextPayload } from '../lib/sendMessage' +import { principalKeyFromInput } from '../lib/principals' import styles from './EntityTimeline.module.css' import type { ElectricUser } from '../lib/ElectricAgentsProvider' +import type { SelectedCommentTarget } from '../lib/comments' import type { + CommentTarget, EntityTimelineSection, EntityTimelineQueryRow, EntityTimelineRunItem, EntityTimelineRunRow, + EntityTimelineToolCallItem, IncludesEntity, Manifest, } from '@electric-ax/agents-runtime/client' @@ -72,6 +78,10 @@ import type { PaneFindAdapter, PaneFindMatch } from '../hooks/usePaneFind' type RenderTimelineRow = EntityTimelineQueryRow type WakeSection = Extract +export type TimelineRowAdjacency = { + previousRow?: EntityTimelineQueryRow + nextRow?: EntityTimelineQueryRow +} function renderRowKey(row: RenderTimelineRow): string { return row.$key @@ -212,7 +222,8 @@ class TimelineRowErrorBoundary extends Component< */ function estimateRowHeight( row: RenderTimelineRow | undefined, - contentWidth: number + contentWidth: number, + nextRow?: RenderTimelineRow ): number { if (!row) return 120 @@ -227,12 +238,16 @@ function estimateRowHeight( 1, Math.ceil(readInboxText(row.inbox.payload).length / charsPerLine) ) - return Math.max(64, 48 + lines * lineHeight) + timelineRowGap(row) + return Math.max(64, 48 + lines * lineHeight) + timelineRowGap(row, nextRow) + } + if (row.comment) { + const lines = Math.max(1, Math.ceil(row.comment.body.length / charsPerLine)) + return Math.max(58, 42 + lines * lineHeight) + timelineRowGap(row, nextRow) } if (row.wake || row.signal || row.manifest) { - return 76 + timelineRowGap(row) + return 76 + timelineRowGap(row, nextRow) } - return 120 + timelineRowGap(row) + return 120 + timelineRowGap(row, nextRow) } const BOTTOM_PIN_THRESHOLD = 8 @@ -242,10 +257,37 @@ const MANIFEST_ROW_GAP = 10 const ROW_SETTLE_MS = 500 type EntityStatus = NonNullable -function timelineRowGap(row: RenderTimelineRow): number { +function timelineRowGap( + row: RenderTimelineRow, + nextRow?: RenderTimelineRow +): number { + if (shouldCollapseCommentMeta(row, nextRow)) return 6 return row.manifest || row.wake || row.signal ? MANIFEST_ROW_GAP : ROW_GAP } +function isPlainCommentRow(row: RenderTimelineRow | undefined): boolean { + const comment = row?.comment + if (!comment) return false + return !comment.deleted_at && !comment.reply_to && !comment.target_snapshot +} + +function shouldCollapseCommentMeta( + row: RenderTimelineRow | undefined, + nextRow: RenderTimelineRow | undefined +): boolean { + if (!isPlainCommentRow(row) || !isPlainCommentRow(nextRow)) return false + const principal = principalKeyFromInput(row?.comment?.from) + if (!principal) return false + return principal === principalKeyFromInput(nextRow?.comment?.from) +} + +function shouldShowCommentMeta( + row: RenderTimelineRow, + nextRow: RenderTimelineRow | undefined +): boolean { + return !shouldCollapseCommentMeta(row, nextRow) +} + type TimelinePaneFindMatch = PaneFindMatch & { rowKey: string rowIndex: number @@ -256,6 +298,7 @@ function timelineRowSearchText( row: RenderTimelineRow, runSearchTextByKey: Map ): string { + if (row.comment) return row.comment.body if (row.inbox) return readInboxText(row.inbox.payload) if (row.wake) { return wakeSectionText({ @@ -271,6 +314,7 @@ function timelineRowSearchText( } function timelineRowLabel(row: RenderTimelineRow): string { + if (row.comment) return `Comment` if (row.inbox?.from_agent) return `Agent message` if (row.inbox) return `User message` if (row.wake) return `Wake` @@ -280,6 +324,160 @@ function timelineRowLabel(row: RenderTimelineRow): string { return `Agent response` } +function truncateCommentPreview(text: string, maxLength = 280): string { + const compact = text.replace(/\s+/g, ` `).trim() + return compact.length <= maxLength + ? compact + : `${compact.slice(0, maxLength - 3)}...` +} + +function createReplyTargetForRow( + row: RenderTimelineRow, + runSearchTextByKey: Map +): SelectedCommentTarget | null { + if (row.comment) { + return { + target: { kind: `comment`, key: row.comment.key }, + snapshot: { + label: `Comment`, + text: truncateCommentPreview(row.comment.body), + from: row.comment.from, + timestamp: row.comment.timestamp, + collection: `comment`, + }, + } + } + + if (row.inbox) { + return { + target: { kind: `timeline`, collection: `inbox`, key: row.inbox.key }, + snapshot: { + label: row.inbox.from_agent ? `Agent message` : `User message`, + text: truncateCommentPreview(readInboxText(row.inbox.payload)), + from: row.inbox.from, + timestamp: row.inbox.timestamp, + collection: `inbox`, + }, + } + } + + if (row.run) { + return { + target: { kind: `timeline`, collection: `run`, key: row.run.key }, + snapshot: { + label: `Assistant response`, + text: truncateCommentPreview( + runSearchTextByKey.get(row.$key) ?? runSearchTextFromSnapshot(row.run) + ), + collection: `run`, + }, + } + } + + if (row.wake) { + return { + target: { kind: `timeline`, collection: `wake`, key: row.wake.key }, + snapshot: { + label: `Wake`, + text: truncateCommentPreview(stringifyPayload(row.wake.payload)), + timestamp: row.wake.payload.timestamp, + collection: `wake`, + }, + } + } + + if (row.signal) { + return { + target: { kind: `timeline`, collection: `signal`, key: row.signal.key }, + snapshot: { + label: `Signal`, + text: truncateCommentPreview(signalSearchText(row.signal)), + timestamp: row.signal.timestamp, + collection: `signal`, + }, + } + } + + if (row.manifest) { + return { + target: { + kind: `timeline`, + collection: `manifest`, + key: row.manifest.key, + }, + snapshot: { + label: manifestKindLabel(row.manifest), + text: truncateCommentPreview(manifestSearchText(row.manifest)), + collection: `manifest`, + }, + } + } + + return null +} + +function createReplyTargetForToolCall( + row: RenderTimelineRow, + toolCall: EntityTimelineToolCallItem +): SelectedCommentTarget { + const runId = row.run?.key ?? toolCall.run_id + return { + target: { + kind: `timeline`, + collection: `tool_call`, + key: toolCall.key, + ...(runId ? { run_id: runId } : {}), + }, + snapshot: { + label: `Tool call`, + text: truncateCommentPreview( + [ + toolCall.tool_name, + stringifySearchPayload(toolCall.args), + stringifySearchPayload(toolCall.result), + stringifySearchPayload(toolCall.error), + ] + .filter((text) => text.length > 0) + .join(` `) + ), + collection: `tool_call`, + }, + } +} + +function timelineRowMatchesCommentTarget( + row: RenderTimelineRow, + target: CommentTarget +): boolean { + if (target.kind === `comment`) { + return row.comment?.key === target.key + } + + switch (target.collection) { + case `inbox`: + return row.inbox?.key === target.key + case `run`: + return row.run?.key === target.key + case `wake`: + return row.wake?.key === target.key + case `signal`: + return row.signal?.key === target.key + case `manifest`: + return row.manifest?.key === target.key + case `text`: + case `tool_call`: { + const run = row.run + if (!run) return false + if (target.run_id && run.key === target.run_id) return true + return run.items.toArray.some((item) => + target.collection === `text` + ? item.text?.key === target.key + : item.toolCall?.key === target.key + ) + } + } +} + function firstSelfSendWakeChange( section: WakeSection, entityUrl?: string | null @@ -338,9 +536,11 @@ function wakeSectionText( function WakeTimelineRow({ section, entityUrl, + onReply, }: { section: WakeSection entityUrl?: string | null + onReply?: () => void }): React.ReactElement { const reason = wakeReason(section, entityUrl) const details = wakeDetails(section, entityUrl) @@ -352,7 +552,13 @@ function WakeTimelineRow({ icon={Radio} title="woke" summary={`${reason} · ${formatChatTimestamp(section.timestamp)}`} + actions={ + onReply ? ( + + ) : undefined + } defaultExpanded={false} + collapsible headerSurface >
@@ -377,9 +583,11 @@ function WakeTimelineRow({ function AgentInboxMessageRow({ inbox, entityUrl, + onReply, }: { inbox: NonNullable entityUrl?: string | null + onReply?: () => void }): React.ReactElement { const parsed = Date.parse(inbox.timestamp) const timestamp = Number.isFinite(parsed) ? parsed : Date.now() @@ -400,7 +608,16 @@ function AgentInboxMessageRow({ icon={Radio} title={isSelfSend ? `sent to itself` : `agent message`} summary={`${isSelfSend ? `self-send` : fromAgent} · ${formatChatTimestamp(timestamp)}`} + actions={ + onReply ? ( + + ) : undefined + } defaultExpanded={false} + collapsible headerSurface >
@@ -421,8 +638,10 @@ function AgentInboxMessageRow({ function SignalTimelineRow({ signal, + onReply, }: { signal: NonNullable + onReply?: () => void }): React.ReactElement { return (
@@ -430,6 +649,11 @@ function SignalTimelineRow({ icon={CircleStop} title={`signal ${signal.signal}`} summary={signalSummary(signal)} + actions={ + onReply ? ( + + ) : undefined + } headerSurface />
@@ -593,11 +817,13 @@ function ManifestTimelineRow({ manifest, entityUrl, entityStatus, + onReply, }: { manifest: Manifest entityUrl: string | null tileId: string | null entityStatus?: EntityStatus + onReply?: () => void }): React.ReactElement { const workspace = useOptionalWorkspace() const navigate = useNavigate() @@ -667,10 +893,17 @@ function ManifestTimelineRow({ ) : null + const replyAction = onReply ? ( + + ) : null const actions = - statusBadge || openAction ? ( + statusBadge || openAction || replyAction ? ( <> {statusBadge} + {replyAction} {openAction} ) : undefined @@ -910,6 +1143,32 @@ function titleCase(value: string): string { .join(` `) } +function TimelineReplyAction({ + label, + onReply, +}: { + label: string + onReply?: () => void +}): React.ReactElement | null { + if (!onReply) return null + return ( + + + + + + ) +} + function stableEntityUrlKey(urls: Iterable): string { return Array.from(new Set(urls)).sort().join(`\0`) } @@ -920,6 +1179,8 @@ function entityUrlsFromKey(key: string): Array { const TimelineRow = memo(function TimelineRow({ row, + previousRow, + nextRow, responseTimestamp, isInitialUserMessage, entityStopped, @@ -936,8 +1197,13 @@ const TimelineRow = memo(function TimelineRow({ onStopGeneration, onForkFromHere, onRunSearchTextChange, + onReplyToRow, + onReplyToToolCall, + onCommentTargetClick, }: { row: RenderTimelineRow + previousRow?: RenderTimelineRow + nextRow?: RenderTimelineRow responseTimestamp: number | null isInitialUserMessage: boolean entityStopped: boolean @@ -957,10 +1223,34 @@ const TimelineRow = memo(function TimelineRow({ * we just invoke. */ onForkFromHere?: ForkFromHereAction onRunSearchTextChange: (rowKey: string, text: string) => void + onReplyToRow?: () => void + onReplyToToolCall?: (toolCall: EntityTimelineToolCallItem) => void + onCommentTargetClick?: (target: CommentTarget) => void }): React.ReactElement { + void previousRow + + if (row.comment) { + return ( + onReplyToRow() : undefined} + onTargetClick={onCommentTargetClick} + /> + ) + } + if (row.inbox) { if (row.inbox.from_agent) { - return + return ( + + ) } const timestamp = Date.parse(row.inbox.timestamp) return ( @@ -980,6 +1270,7 @@ const TimelineRow = memo(function TimelineRow({ } stopPending={stopPending} onStop={onStopGeneration} + onReply={onReplyToRow} /> ) } @@ -993,12 +1284,13 @@ const TimelineRow = memo(function TimelineRow({ timestamp: Date.parse(row.wake.payload.timestamp), }} entityUrl={entityUrl} + onReply={onReplyToRow} /> ) } if (row.signal) { - return + return } if (row.error) { @@ -1016,6 +1308,7 @@ const TimelineRow = memo(function TimelineRow({ ? entityStatusByUrl.get(getManifestEntityUrl(row.manifest)!) : undefined } + onReply={onReplyToRow} /> ) } @@ -1029,12 +1322,15 @@ const TimelineRow = memo(function TimelineRow({ renderWidth={renderWidth} forkFromHere={onForkFromHere} onSearchTextChange={onRunSearchTextChange} + onReply={onReplyToRow} + onReplyToToolCall={onReplyToToolCall} /> ) }) export function EntityTimeline({ rows, + rowAdjacency, loading, error, entityStopped, @@ -1047,8 +1343,13 @@ export function EntityTimeline({ stopPending = false, onStopGeneration, forkFromHereByRunKey, + onReplyToRow, + focusTarget, + onFocusTargetHandled, + onCommentTargetClick, }: { rows: Array + rowAdjacency?: Array loading: boolean error: string | null entityStopped: boolean @@ -1067,6 +1368,10 @@ export function EntityTimeline({ * the fork pointer and runs the fork → navigate flow. */ forkFromHereByRunKey?: Map + onReplyToRow?: (target: SelectedCommentTarget) => void + focusTarget?: CommentTarget | null + onFocusTargetHandled?: () => void + onCommentTargetClick?: (target: CommentTarget) => void }): React.ReactElement { const { entitiesCollection, runnersCollection, usersCollection } = useElectricAgents() @@ -1156,6 +1461,9 @@ export function EntityTimeline({ const spawnMarkerRef = useRef(null) const [showJumpToBottom, setShowJumpToBottom] = useState(false) const [showTopDivider, setShowTopDivider] = useState(false) + const [highlightedRowKey, setHighlightedRowKey] = useState( + null + ) const [runSearchTextByKey, setRunSearchTextByKey] = useState( () => new Map() ) @@ -1163,6 +1471,7 @@ export function EntityTimeline({ const lastMeasureAtRef = useRef(new Map()) const settledKeysRef = useRef(new Set()) const settleCheckTimerRef = useRef | null>(null) + const highlightTimerRef = useRef | null>(null) const handledScrollSignalRef = useRef(scrollToBottomSignal) const previousStreamingAgentKeyRef = useRef(null) const textColumnWidth = Math.max(0, contentWidth - CHAT_SURFACE_GUTTER) @@ -1332,7 +1641,14 @@ export function EntityTimeline({ estimateSize: (index) => cachedSizeMapRef.current.get( displayRows[index] ? renderRowKey(displayRows[index]!) : `` - ) ?? estimateRowHeight(displayRows[index], textColumnWidth), + ) ?? + estimateRowHeight( + displayRows[index], + textColumnWidth, + displayRows[index] + ? (rowAdjacency?.[index]?.nextRow ?? displayRows[index + 1]) + : undefined + ), getItemKey: (index) => displayRows[index] ? renderRowKey(displayRows[index]!) : index, gap: 0, @@ -1341,6 +1657,50 @@ export function EntityTimeline({ enabled: displayRows.length > 0, }) + const revealCommentTarget = useCallback( + (target: CommentTarget): boolean => { + const targetIndex = displayRows.findIndex((row) => + timelineRowMatchesCommentTarget(row, target) + ) + if (targetIndex < 0) return false + + const row = displayRows[targetIndex] + if (!row) return false + + const rowKey = renderRowKey(row) + isNearBottom.current = false + setShowJumpToBottom(true) + rowVirtualizer.scrollToIndex(targetIndex, { align: `center` }) + setHighlightedRowKey(rowKey) + + if (highlightTimerRef.current !== null) { + clearTimeout(highlightTimerRef.current) + } + highlightTimerRef.current = setTimeout(() => { + highlightTimerRef.current = null + setHighlightedRowKey((current) => (current === rowKey ? null : current)) + }, 1600) + + return true + }, + [displayRows, rowVirtualizer] + ) + + const handleCommentTargetClick = useCallback( + (target: CommentTarget) => { + if (revealCommentTarget(target)) return + onCommentTargetClick?.(target) + }, + [onCommentTargetClick, revealCommentTarget] + ) + + useEffect(() => { + if (!focusTarget) return + if (revealCommentTarget(focusTarget)) { + onFocusTargetHandled?.() + } + }, [focusTarget, onFocusTargetHandled, revealCommentTarget]) + const paneFindAdapter = useMemo(() => { const getHighlightRoot = (match: PaneFindMatch): HTMLElement | null => { if (!contentElement || !isTimelineFindMatch(match)) return null @@ -1628,6 +1988,9 @@ export function EntityTimeline({ if (settleCheckTimerRef.current !== null) { clearTimeout(settleCheckTimerRef.current) } + if (highlightTimerRef.current !== null) { + clearTimeout(highlightTimerRef.current) + } }, [] ) @@ -1727,6 +2090,12 @@ export function EntityTimeline({ {rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = displayRows[virtualRow.index] const rowKey = renderRowKey(row) + const previousRow = + rowAdjacency?.[virtualRow.index]?.previousRow ?? + displayRows[virtualRow.index - 1] + const nextRow = + rowAdjacency?.[virtualRow.index]?.nextRow ?? + displayRows[virtualRow.index + 1] // Stable row key. The previous implementation appended // `:${contentWidth}` to force remount on every column-width @@ -1744,15 +2113,20 @@ export function EntityTimeline({ data-index={virtualRow.index} data-item-key={rowKey} data-pane-find-row-key={rowKey} + data-highlighted={ + highlightedRowKey === rowKey ? `true` : undefined + } className={styles.virtualRow} style={{ transform: `translateY(${virtualRow.start}px)`, - paddingBottom: timelineRowGap(row), + paddingBottom: timelineRowGap(row, nextRow), }} > { + const target = createReplyTargetForRow( + row, + runSearchTextByKey + ) + if (target) onReplyToRow(target) + } + : undefined + } + onReplyToToolCall={ + onReplyToRow && row.run + ? (toolCall) => + onReplyToRow( + createReplyTargetForToolCall(row, toolCall) + ) + : undefined + } />
diff --git a/packages/agents-server-ui/src/components/InlineEventCard.test.tsx b/packages/agents-server-ui/src/components/InlineEventCard.test.tsx new file mode 100644 index 0000000000..959ff16a53 --- /dev/null +++ b/packages/agents-server-ui/src/components/InlineEventCard.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { Wrench } from 'lucide-react' +import { InlineEventCard } from './InlineEventCard' + +function hasNestedButton(markup: string): boolean { + let buttonDepth = 0 + const buttonTag = /<\/?button\b[^>]*>/g + for (const match of markup.matchAll(buttonTag)) { + const tag = match[0] + if (tag.startsWith(` 0) return true + buttonDepth += 1 + } + return false +} + +describe(`InlineEventCard`, () => { + it(`keeps header actions outside the expandable header button`, () => { + const markup = renderToStaticMarkup( + Reply} + collapsible + defaultExpanded + > +
result
+
+ ) + + expect(hasNestedButton(markup)).toBe(false) + expect(markup).toContain(`aria-label="Collapse details"`) + }) +}) diff --git a/packages/agents-server-ui/src/components/InlineEventCard.tsx b/packages/agents-server-ui/src/components/InlineEventCard.tsx index 2956052305..9d7691ff8c 100644 --- a/packages/agents-server-ui/src/components/InlineEventCard.tsx +++ b/packages/agents-server-ui/src/components/InlineEventCard.tsx @@ -32,7 +32,17 @@ export function InlineEventCard({ const [expanded, setExpanded] = useState(defaultExpanded) const showBody = children !== undefined && (!expandable || expanded) const headerOnly = children === undefined - const headerContent = ( + const toggle = () => setExpanded((value) => !value) + const toggleIcon = expandable ? ( + + ) : null + const headerLeadContent = ( <> {summary ? {summary} : null} {badge} + + ) + const headerContent = ( + <> + {headerLeadContent} {actions ? ( {actions} ) : null} - {expandable ? ( - - ) : null} + {toggleIcon} ) @@ -67,10 +74,36 @@ export function InlineEventCard({ className={toolBlock.card} data-header-surface={headerOnly || headerSurface ? `true` : undefined} > - {expandable ? ( + {expandable && actions ? ( + + + {actions} + + + ) : expandable ? (
+ ) : isCommentMode && commentTarget ? ( +
+
+ + {replyPreviewLabel} + + {replyPreviewText && ( + + {replyPreviewText} + + )} +
+ +
) : null } attachments={ - imageAttachmentsEnabled ? ( + imageAttachmentsEnabled && !isCommentMode ? ( + + + )} ) } + +function formatReplyBannerLabel(target: SelectedCommentTarget | null): string { + const label = target?.snapshot.label.trim() + if (!label) return `Reply` + return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` +} diff --git a/packages/agents-server-ui/src/components/ToolCallView.module.css b/packages/agents-server-ui/src/components/ToolCallView.module.css index 6424f7f4a4..69df58a083 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.module.css +++ b/packages/agents-server-ui/src/components/ToolCallView.module.css @@ -18,6 +18,19 @@ white-space: pre-wrap; } +.actionButton { + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 4px; + color: var(--ds-gray-11); +} + +.actionButton:hover { + background: var(--ds-gray-a5); + color: var(--ds-gray-12); +} + .diffBlock { padding: 0; white-space: pre; diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index 30ce7c7f55..d736e58c8c 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -1,6 +1,6 @@ -import { Wrench } from 'lucide-react' +import { Reply, Wrench } from 'lucide-react' import type { EntityTimelineContentItem } from '@electric-ax/agents-runtime/client' -import { Badge, Stack, Text } from '../ui' +import { Badge, Icon, IconButton, Stack, Text, Tooltip } from '../ui' import type { BadgeTone } from '../ui' import { InlineEventCard } from './InlineEventCard' import { InlineStatusBadge } from './InlineStatusBadge' @@ -257,9 +257,28 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { export function ToolCallView({ item, + onReply, }: { item: ToolCallItem + onReply?: () => void }): React.ReactElement { + const replyAction = onReply ? ( + + + + + + ) : undefined + // send_message: same container style but always expanded with the message text if (item.toolName === `send_message` && typeof item.args.text === `string`) { const badge = statusBadge(item) @@ -270,6 +289,7 @@ export function ToolCallView({ title="send_message" titleFont="mono" collapsible={false} + actions={replyAction} badge={ badge ? ( @@ -299,6 +319,8 @@ export function ToolCallView({ titleFont="mono" summary={summary} defaultExpanded={shouldDefaultExpand} + collapsible + actions={replyAction} badge={ badge ? ( {badge.label} diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 2dc522a98f..c4f357f3fb 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -77,6 +77,26 @@ } } +.meta { + width: 100%; +} + +.metaActions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.metaActionButton { + color: var(--ds-text-4); + opacity: 0.7; +} + +.metaActionButton:hover { + opacity: 1; +} + .body { font-size: var(--ds-chat-text); line-height: var(--ds-chat-text-lh); @@ -220,9 +240,12 @@ } .meta { - opacity: 0.4; /* Match the bubble's 12px horizontal padding so the timestamp/sender * row aligns with the agent text column rather than with the wider * bubble background. */ padding-inline: 12px; } + +.meta > :not(.metaActions) { + opacity: 0.4; +} diff --git a/packages/agents-server-ui/src/components/UserMessage.tsx b/packages/agents-server-ui/src/components/UserMessage.tsx index d9bce06bd0..726b4e9e31 100644 --- a/packages/agents-server-ui/src/components/UserMessage.tsx +++ b/packages/agents-server-ui/src/components/UserMessage.tsx @@ -4,10 +4,11 @@ import { Download, File as FileIcon, Image as ImageIcon, + Reply, Square, } from 'lucide-react' import type { EntityTimelineSection } from '@electric-ax/agents-runtime/client' -import { Icon, Stack, Text } from '../ui' +import { Icon, IconButton, Stack, Text, Tooltip } from '../ui' import { downloadAttachment, formatAttachmentSize } from '../lib/attachments' import { streamdownComponents, @@ -49,6 +50,7 @@ export const UserMessage = memo(function UserMessage({ currentPrincipal, usersById, onStop, + onReply, }: { section: UserMessageSection attachments?: Array @@ -57,6 +59,7 @@ export const UserMessage = memo(function UserMessage({ currentPrincipal?: string usersById?: Map onStop?: () => void + onReply?: () => void }): React.ReactElement { const sender = formatSender(section.from, { currentPrincipal, usersById }) @@ -112,6 +115,23 @@ export const UserMessage = memo(function UserMessage({ )} + {onReply && ( + + + + + + + + )} ) diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index dca3ea9c04..4ef2aea5cd 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -92,6 +92,59 @@ background: var(--ds-accent-a2); } +.headerWithActions { + padding: 0; + gap: 0; +} + +.headerContentToggle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + align-self: stretch; + padding: 7px 0 7px 10px; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + outline: none; + transition: background 0.08s ease; +} + +.headerContentToggle:hover, +.headerChevronButton:hover { + background: var(--ds-bg-hover); +} + +.headerContentToggle:focus-visible, +.headerChevronButton:focus-visible { + background: var(--ds-accent-a2); +} + +.headerChevronButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + align-self: stretch; + flex-shrink: 0; + padding: 0; + background: none; + border: none; + color: inherit; + cursor: pointer; + outline: none; + transition: background 0.08s ease; +} + +.headerChevronButton .toggleArrow { + margin-left: 0; +} + /* Chevron lives in a fixed-size 16px slot so the row height doesn't jiggle when expand/collapse swaps glyphs. */ .toggleArrow { @@ -130,6 +183,11 @@ margin-left: 0; } +.headerWithActions .headerActions { + margin-left: 0; + margin-right: 4px; +} + /* Tool name is the only mono token in the row — it's an identifier, so reading it as code helps. Summary + everything else stays in the body font for legibility at small sizes. */ diff --git a/packages/agents-server-ui/src/components/views/ChatView.test.ts b/packages/agents-server-ui/src/components/views/ChatView.test.ts new file mode 100644 index 0000000000..27de30ecab --- /dev/null +++ b/packages/agents-server-ui/src/components/views/ChatView.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { + buildCommentsTimeline, + commentFocusViewParams, + decodeCommentTargetParam, +} from './ChatView' +import type { + CommentTarget, + EntityTimelineQueryRow, +} from '@electric-ax/agents-runtime/client' + +function commentRow( + key: string, + fromPrincipal = `/principal/user%3Ame` +): EntityTimelineQueryRow { + return { + $key: `comment:${key}`, + comment: { + key, + order: key, + body: key, + from: fromPrincipal, + timestamp: `2026-04-15T18:00:00.000Z`, + }, + } as EntityTimelineQueryRow +} + +function wakeRow(key: string): EntityTimelineQueryRow { + return { + $key: `wake:${key}`, + wake: { + key, + order: key, + payload: { + type: `wake`, + timestamp: `2026-04-15T18:00:00.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }, + }, + } as EntityTimelineQueryRow +} + +function attachmentRow(key: string): EntityTimelineQueryRow { + return { + $key: `manifest:${key}`, + manifest: { + key, + kind: `attachment`, + id: key, + streamPath: `/chat/test/attachments/${key}`, + status: `complete`, + subject: { type: `inbox`, key: `msg-1` }, + mimeType: `text/plain`, + byteLength: 12, + createdAt: `2026-04-15T18:00:00.000Z`, + }, + } as EntityTimelineQueryRow +} + +describe(`buildCommentsTimeline`, () => { + it(`keeps comments in stream order while using full-timeline adjacency`, () => { + const first = commentRow(`first`) + const wake = wakeRow(`wake-1`) + const second = commentRow(`second`) + const third = commentRow(`third`) + const attachment = attachmentRow(`att-1`) + const fourth = commentRow(`fourth`) + + const timeline = buildCommentsTimeline([ + first, + wake, + second, + third, + attachment, + fourth, + ]) + + expect(timeline.rows.map((row) => row.comment?.key)).toEqual([ + `first`, + `second`, + `third`, + `fourth`, + ]) + expect(timeline.adjacency[0]).toEqual({ + previousRow: undefined, + nextRow: wake, + }) + expect(timeline.adjacency[1]).toEqual({ + previousRow: wake, + nextRow: third, + }) + expect(timeline.adjacency[2]).toEqual({ + previousRow: second, + nextRow: fourth, + }) + expect(timeline.adjacency[3]).toEqual({ + previousRow: third, + }) + }) +}) + +describe(`comment focus view params`, () => { + it(`round-trips timeline targets for comments-view navigation`, () => { + const target: CommentTarget = { + kind: `timeline`, + collection: `tool_call`, + key: `tool-call-1`, + run_id: `run-1`, + } + + const params = commentFocusViewParams(target) + + expect(decodeCommentTargetParam(params.focus)).toEqual(target) + }) + + it(`rejects invalid encoded target collections`, () => { + const encoded = encodeURIComponent( + JSON.stringify({ + kind: `timeline`, + collection: `unknown`, + key: `thing-1`, + }) + ) + + expect(decodeCommentTargetParam(encoded)).toBeNull() + }) +}) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 0f64c6c239..68dcabd6b7 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -2,18 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useEntityTimeline } from '../../hooks/useEntityTimeline' -import { EntityTimeline } from '../EntityTimeline' +import { EntityTimeline, type TimelineRowAdjacency } from '../EntityTimeline' import { MessageInput } from '../MessageInput' import { EntityContextDrawer } from '../EntityContextDrawer' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useWorkspace } from '../../hooks/useWorkspace' +import { isAttachmentManifest } from '../../lib/attachments' import { schemaModelSupportsImageInput } from '../../lib/modelCapabilities' +import type { SelectedCommentTarget } from '../../lib/comments' import { useEntityPermission, useEntityPermissions, type EntityPermission, } from '../../hooks/useEntityPermission' import type { ViewProps } from '../../lib/workspace/viewRegistry' -import type { EntityTimelineQueryRow } from '@electric-ax/agents-runtime/client' +import type { + CommentTarget, + EntityTimelineQueryRow, +} from '@electric-ax/agents-runtime/client' import type { EventPointer } from '@electric-ax/agents-runtime' import type { OptimisticInboxMessage } from '../../lib/sendMessage' import type { SlashCommandRow } from '@electric-ax/agents-runtime/client' @@ -24,6 +30,95 @@ const CHAT_VIEW_PERMISSIONS: ReadonlyArray = [ `signal`, `fork`, ] +const COMMENT_FOCUS_PARAM = `focus` +const COMMENT_TARGET_COLLECTIONS = new Set([ + `inbox`, + `run`, + `text`, + `tool_call`, + `wake`, + `signal`, + `manifest`, +]) + +export function encodeCommentTargetParam(target: CommentTarget): string { + return encodeURIComponent(JSON.stringify(target)) +} + +export function decodeCommentTargetParam( + value: string | undefined +): CommentTarget | null { + if (!value) return null + try { + const decoded = JSON.parse(decodeURIComponent(value)) as unknown + if (!isCommentTarget(decoded)) return null + return decoded + } catch { + return null + } +} + +export function commentFocusViewParams( + target: CommentTarget +): Record { + return { [COMMENT_FOCUS_PARAM]: encodeCommentTargetParam(target) } +} + +function isCommentTarget(value: unknown): value is CommentTarget { + if (!value || typeof value !== `object`) return false + const target = value as Partial + if (target.kind === `comment`) { + return typeof target.key === `string` + } + if (target.kind !== `timeline`) return false + const timelineTarget = target as Partial< + Extract + > + return ( + typeof timelineTarget.key === `string` && + typeof timelineTarget.collection === `string` && + COMMENT_TARGET_COLLECTIONS.has(timelineTarget.collection) && + (timelineTarget.run_id === undefined || + typeof timelineTarget.run_id === `string`) + ) +} + +export function buildCommentsTimeline( + timelineRows: Array +): { + rows: Array + adjacency: Array +} { + const rows: Array = [] + const adjacency: Array = [] + let previousRenderableRow: EntityTimelineQueryRow | undefined + let pendingCommentAdjacencyIndex: number | null = null + + for (const row of timelineRows) { + if (isAttachmentManifest(row.manifest)) continue + + if (pendingCommentAdjacencyIndex !== null) { + const pendingAdjacency = adjacency[pendingCommentAdjacencyIndex]! + adjacency[pendingCommentAdjacencyIndex] = { + ...pendingAdjacency, + nextRow: row, + } + pendingCommentAdjacencyIndex = null + } + + if (row.comment) { + rows.push(row) + adjacency.push({ + previousRow: previousRenderableRow, + }) + pendingCommentAdjacencyIndex = adjacency.length - 1 + } + + previousRenderableRow = row + } + + return { rows, adjacency } +} /** * The default view: chat / timeline + message composer. @@ -40,6 +135,7 @@ export function ChatView({ entityStopped, isSpawning, tileId, + viewParams, }: ViewProps): React.ReactElement { // While `spawning`, the entity has no inbox yet — `connectUrl` is null // so `useEntityTimeline` doesn't try to subscribe and we render an empty @@ -54,6 +150,7 @@ export function ChatView({ entityStopped={entityStopped} isSpawning={isSpawning} tileId={tileId} + viewParams={viewParams} /> ) } @@ -171,6 +268,84 @@ export function ChatLogView({ ) } +export function CommentsView({ + baseUrl, + entityUrl, + entity, + entityStopped, + isSpawning, + tileId, +}: ViewProps): React.ReactElement { + const connectUrl = isSpawning ? null : entityUrl + const { timelineRows, entities, db, loading, error } = useEntityTimeline( + baseUrl || null, + connectUrl + ) + const navigate = useNavigate() + const { helpers } = useWorkspace() + const canWrite = useEntityPermission(entity, `write`) + const [sentCommentSignal, setSentCommentSignal] = useState(0) + const [selectedCommentTarget, setSelectedCommentTarget] = + useState(null) + const commentsTimeline = useMemo( + () => buildCommentsTimeline(timelineRows), + [timelineRows] + ) + + useEffect(() => { + if (error && !isSpawning) { + void navigate({ to: `/` }) + } + }, [error, navigate, isSpawning]) + + useEffect(() => { + setSelectedCommentTarget(null) + }, [connectUrl]) + + const openFullTimelineTarget = useCallback( + (target: CommentTarget) => { + helpers.setTileView(tileId, `chat`, { + viewParams: commentFocusViewParams(target), + }) + }, + [helpers, tileId] + ) + + return ( + <> + + setSelectedCommentTarget(null)} + onSend={() => setSentCommentSignal((value) => value + 1)} + /> + + ) +} + function GenericChatBody({ baseUrl, entityUrl, @@ -178,6 +353,7 @@ function GenericChatBody({ entityStopped, isSpawning, tileId, + viewParams, }: { baseUrl: string entityUrl: string | null @@ -185,6 +361,7 @@ function GenericChatBody({ entityStopped: boolean isSpawning: boolean tileId: string + viewParams?: ViewProps[`viewParams`] }): React.ReactElement { const { timelineRows, @@ -202,8 +379,11 @@ function GenericChatBody({ const canSignal = permissions.signal const canFork = permissions.fork const navigate = useNavigate() + const { helpers } = useWorkspace() const [sentMessageSignal, setSentMessageSignal] = useState(0) const [stopPending, setStopPending] = useState(false) + const [selectedCommentTarget, setSelectedCommentTarget] = + useState(null) const { data: matchingEntityTypes = [] } = useLiveQuery( (query) => { if (!entityTypesCollection) return undefined @@ -248,6 +428,29 @@ function GenericChatBody({ : timelineRows, [inlinePendingInbox, timelineRows] ) + const showComments = viewParams?.comments !== `hidden` + const displayTimelineRows = useMemo>( + () => + showComments + ? timelineRowsWithInlinePending + : timelineRowsWithInlinePending.filter((row) => !row.comment), + [showComments, timelineRowsWithInlinePending] + ) + const focusTarget = useMemo( + () => decodeCommentTargetParam(viewParams?.[COMMENT_FOCUS_PARAM]), + [viewParams] + ) + const clearFocusTarget = useCallback(() => { + if (!viewParams?.[COMMENT_FOCUS_PARAM]) return + const nextParams = { ...viewParams } + delete nextParams[COMMENT_FOCUS_PARAM] + helpers.setTileView(tileId, `chat`, { + viewParams: Object.keys(nextParams).length > 0 ? nextParams : undefined, + }) + }, [helpers, tileId, viewParams]) + useEffect(() => { + if (!showComments) setSelectedCommentTarget(null) + }, [showComments]) const drawerPendingInbox = inlinePendingInbox ? visiblePendingInbox.slice(1) : visiblePendingInbox @@ -308,7 +511,7 @@ function GenericChatBody({ if (!runOffsets) return undefined const map = new Map() let anchor: { rowKey: string; pointer: EventPointer } | null = null - for (const row of timelineRowsWithInlinePending) { + for (const row of displayTimelineRows) { if (row.run && row.run.status === `completed`) { const pointer = runOffsets.get(row.run.key) anchor = pointer ? { rowKey: row.$key, pointer } : null @@ -338,24 +541,17 @@ function GenericChatBody({ } } return map - }, [ - timelineRowsWithInlinePending, - canFork, - db, - forkEntity, - entityUrl, - navigate, - ]) + }, [displayTimelineRows, canFork, db, forkEntity, entityUrl, navigate]) return ( <> setSelectedCommentTarget(null)} drawer={(pending) => ( setMenuOpen(false) /** Wraps a handler so it dispatches and then closes the menu. */ @@ -272,6 +275,15 @@ export function SplitMenu({ void navigator.clipboard.writeText(url.toString()) } + const setChatCommentsVisible = (visible: boolean) => { + const nextParams = { ...(tile.viewParams ?? {}) } + if (visible) delete nextParams.comments + else nextParams.comments = `hidden` + helpers.setTileView(tile.id, tile.viewId, { + viewParams: Object.keys(nextParams).length > 0 ? nextParams : undefined, + }) + } + // The menu and the dialogs are siblings — keeping them in the same // portal subtree caused focus / unmount races (Base UI // tears the menu popup down on close, and any dialog mounted inside @@ -328,6 +340,39 @@ export function SplitMenu({ )} + {showDisplayOptions && ( + <> + + + + Display options + + + + + setChatCommentsVisible(!chatCommentsVisible) + )} + > + + Show comments + + + + + + + )} + helpers.splitTile(tile.id, `right`)}> Split right diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index eba430f748..66d2e59c08 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -4,6 +4,7 @@ import { compareTimelineOrders, createEntityTimelineQuery, normalizeTimelineEntities, + TIMELINE_ORDER_FALLBACK, } from '@electric-ax/agents-runtime/client' import { coalesce, eq } from '@durable-streams/state/db' import { connectEntityStream } from '../lib/entity-connection' @@ -125,7 +126,8 @@ export function useEntityTimeline( .from({ inbox: db.collections.inbox as any }) .where(({ inbox }: any) => eq(inbox.status, `pending`)) .orderBy( - ({ inbox }: any) => coalesce(inbox._timeline_order, `~`), + ({ inbox }: any) => + coalesce(inbox._timeline_order, TIMELINE_ORDER_FALLBACK), `asc` ) .orderBy(({ inbox }: any) => diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts new file mode 100644 index 0000000000..3f57e949ee --- /dev/null +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' +import { compareTimelineOrders } from '@electric-ax/agents-runtime/client' +import { registerActiveServerHeaders } from './auth-fetch' +import { createSendCommentAction } from './comments' +import type { + CommentSnapshot, + CommentTarget, + EntityStreamDBWithActions, +} from '@electric-ax/agents-runtime/client' +import type { OptimisticComment } from './comments' + +function createCommentsDb() { + const comments = createCollection( + localOnlyCollectionOptions({ + id: `test-comments-${Math.random().toString(36).slice(2)}`, + getKey: (comment: OptimisticComment) => comment.key, + }) + ) + return { + db: { + collections: { + comments, + }, + } as unknown as EntityStreamDBWithActions, + comments, + } +} + +describe(`createSendCommentAction`, () => { + afterEach(() => { + vi.restoreAllMocks() + registerActiveServerHeaders(null) + }) + + it(`inserts optimistic comments at increasing pending timeline orders`, async () => { + const fetchMock = vi + .spyOn(globalThis, `fetch`) + .mockResolvedValue(new Response(`{}`, { status: 201 })) + const { db } = createCommentsDb() + const optimistic: Array = [] + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + from: `/principal/user%3Ame`, + onOptimisticComment: (comment) => optimistic.push(comment), + }) + + const firstTx = sendComment({ body: `first` }) + const secondTx = sendComment({ body: `second` }) + await Promise.all([ + firstTx.isPersisted.promise, + secondTx.isPersisted.promise, + ]) + + expect(optimistic).toHaveLength(2) + expect(optimistic[0]?._timeline_order).toMatch(/^~pending:/) + expect(optimistic[1]?._timeline_order).toMatch(/^~pending:/) + expect( + compareTimelineOrders( + optimistic[0]!._timeline_order, + optimistic[1]!._timeline_order + ) + ).toBeLessThan(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it(`posts reply metadata with the same key as the optimistic row`, async () => { + const fetchMock = vi + .spyOn(globalThis, `fetch`) + .mockResolvedValue(new Response(`{}`, { status: 201 })) + const { db } = createCommentsDb() + const optimistic: Array = [] + const replyTo: CommentTarget = { + kind: `timeline`, + collection: `run`, + key: `run-1`, + } + const targetSnapshot: CommentSnapshot = { + label: `Assistant response`, + text: `Draft reply`, + collection: `run`, + } + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + from: `/principal/user%3Ame`, + onOptimisticComment: (comment) => optimistic.push(comment), + }) + + const tx = sendComment({ + body: `looks right`, + replyTo, + targetSnapshot, + }) + await tx.isPersisted.promise + + expect(optimistic).toHaveLength(1) + expect(optimistic[0]).toMatchObject({ + body: `looks right`, + from: `/principal/user%3Ame`, + reply_to: replyTo, + target_snapshot: targetSnapshot, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe( + `http://localhost:4437/_electric/entities/chat/test/collections/comments` + ) + expect(init?.method).toBe(`POST`) + expect(new Headers(init?.headers).get(`content-type`)).toBe( + `application/json` + ) + const parsed = JSON.parse(String(init?.body)) + expect(parsed.operation).toBe(`insert`) + expect(parsed.key).toBe(optimistic[0]!.key) + expect(parsed.value).toMatchObject({ + body: `looks right`, + reply_to: replyTo, + target_snapshot: targetSnapshot, + }) + expect(parsed.value).not.toHaveProperty(`from_principal`) + }) + + it(`rejects the persistence promise when the server rejects the comment`, async () => { + vi.spyOn(globalThis, `fetch`).mockResolvedValue( + new Response(JSON.stringify({ message: `No write access` }), { + status: 403, + }) + ) + const { db } = createCommentsDb() + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + }) + + const tx = sendComment({ body: `blocked` }) + + await expect(tx.isPersisted.promise).rejects.toThrow(`No write access`) + }) +}) diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts new file mode 100644 index 0000000000..c1f2e2ac7c --- /dev/null +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -0,0 +1,128 @@ +import { createOptimisticAction } from '@tanstack/db' +import { createPendingTimelineOrder } from '@electric-ax/agents-runtime/client' +import { getActivePrincipal, serverFetch } from './auth-fetch' +import { entityApiUrl } from './entity-api' +import type { + CommentSnapshot, + CommentTarget, + EntityStreamDBWithActions, + EntityTimelineCommentRow, +} from '@electric-ax/agents-runtime/client' + +const OPTIMISTIC_COMMENT_ORDER_START = Number.MAX_SAFE_INTEGER - 2_000_000 + +let optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START + +export type OptimisticComment = EntityTimelineCommentRow & { + _timeline_order: string +} + +export type SelectedCommentTarget = { + target: CommentTarget + snapshot: CommentSnapshot +} + +type SendCommentInput = { + key: string + body: string + replyTo?: CommentTarget + targetSnapshot?: CommentSnapshot + pendingOrderIndex: number +} + +function nextOptimisticCommentOrderIndex(): number { + optimisticCommentOrderIndex += 1 + if (optimisticCommentOrderIndex >= Number.MAX_SAFE_INTEGER) { + optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START + } + return optimisticCommentOrderIndex +} + +function createClientCommentKey(pendingOrderIndex: number): string { + return `client-comment-${Date.now()}-${pendingOrderIndex}` +} + +function readCommentError(status: number, body: string): Error { + let message = `Failed to post comment (${status})` + if (body) { + try { + const data = JSON.parse(body) as Record + if (data.message) message = String(data.message) + } catch { + message = body + } + } + return new Error(message) +} + +export function createSendCommentAction({ + db, + baseUrl, + entityUrl, + from, + onOptimisticComment, +}: { + db: EntityStreamDBWithActions + baseUrl: string + entityUrl: string + from?: string + onOptimisticComment?: (comment: OptimisticComment) => void +}) { + const action = createOptimisticAction({ + onMutate: ({ key, body, replyTo, targetSnapshot, pendingOrderIndex }) => { + const now = new Date().toISOString() + const comment: OptimisticComment = { + key, + order: createPendingTimelineOrder(pendingOrderIndex), + _timeline_order: createPendingTimelineOrder(pendingOrderIndex), + body, + from: from ?? getActivePrincipal(), + timestamp: now, + ...(replyTo ? { reply_to: replyTo } : {}), + ...(targetSnapshot ? { target_snapshot: targetSnapshot } : {}), + } + onOptimisticComment?.(comment) + db.collections.comments.insert(comment) + }, + mutationFn: async ({ key, body, replyTo, targetSnapshot }) => { + const now = new Date().toISOString() + const value = { + body, + timestamp: now, + ...(replyTo ? { reply_to: replyTo } : {}), + ...(targetSnapshot ? { target_snapshot: targetSnapshot } : {}), + } + const res = await serverFetch( + entityApiUrl(baseUrl, entityUrl, `/collections/comments`), + { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ operation: `insert`, key, value }), + } + ) + if (!res.ok) { + const body = await res.text().catch(() => ``) + throw readCommentError(res.status, body) + } + }, + }) + + return ({ + body, + replyTo, + targetSnapshot, + }: { + body: string + replyTo?: CommentTarget + targetSnapshot?: CommentSnapshot + }) => { + const pendingOrderIndex = nextOptimisticCommentOrderIndex() + return action({ + key: createClientCommentKey(pendingOrderIndex), + body, + replyTo, + targetSnapshot, + pendingOrderIndex, + }) + } +} diff --git a/packages/agents-server-ui/src/lib/workspace/registerViews.ts b/packages/agents-server-ui/src/lib/workspace/registerViews.ts index e887aeb9bb..7667f5f816 100644 --- a/packages/agents-server-ui/src/lib/workspace/registerViews.ts +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -1,7 +1,7 @@ -import { Database, MessageSquare, SquarePen } from 'lucide-react' +import { Database, MessageCircle, MessageSquare, SquarePen } from 'lucide-react' import { registerView } from './viewRegistry' import { NEW_SESSION_VIEW_ID } from './types' -import { ChatView } from '../../components/views/ChatView' +import { ChatView, CommentsView } from '../../components/views/ChatView' import { StateExplorerView } from '../../components/views/StateExplorerView' import { NewSessionView } from '../../components/views/NewSessionView' @@ -23,6 +23,15 @@ registerView({ Component: ChatView, }) +registerView({ + kind: `entity`, + id: `comments`, + label: `Comments`, + icon: MessageCircle, + description: `Comment-only timeline`, + Component: CommentsView, +}) + registerView({ kind: `entity`, id: `state-explorer`, From 275a738c688b088d5ce1538c20f76f4e508345f0 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 09:38:19 +0100 Subject: [PATCH 19/35] fix(agents-runtime): align timeline fallback sentinel with ~ convention C5 clone introduced TIMELINE_ORDER_FALLBACK='zzzz:timeline-end' to satisfy the cloned UI, conflicting with the codebase's '~' sentinel (and '~pending:' orders). Make '~' the single source of truth and derive the pending prefix from it. --- packages/agents-runtime/src/entity-timeline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index f7e9797361..5388f5a448 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -30,7 +30,7 @@ import type { } from './comments-collection' import type { ManifestEntry, Wake, WakeMessage } from './types' -export const TIMELINE_ORDER_FALLBACK = `zzzz:timeline-end` +export const TIMELINE_ORDER_FALLBACK = `~` export type EntityTimelineState = | `pending` @@ -543,7 +543,7 @@ function readTimelineOrder(row: object): string | undefined { } export function createPendingTimelineOrder(index: number): string { - return `~pending:${index.toString().padStart(12, `0`)}` + return `${TIMELINE_ORDER_FALLBACK}pending:${index.toString().padStart(12, `0`)}` } function toSeqOrderToken(seq: number): string { From 4c8187b387381bd8a0a5e791fa5011019452dfbb Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 09:57:50 +0100 Subject: [PATCH 20/35] chore: changeset for generic externally-writable collections --- .changeset/generic-externally-writable-collections.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/generic-externally-writable-collections.md diff --git a/.changeset/generic-externally-writable-collections.md b/.changeset/generic-externally-writable-collections.md new file mode 100644 index 0000000000..5a5a4d9ee1 --- /dev/null +++ b/.changeset/generic-externally-writable-collections.md @@ -0,0 +1,8 @@ +--- +'@electric-ax/agents-runtime': minor +'@electric-ax/agents-server': minor +'@electric-ax/agents-server-ui': minor +'@electric-ax/agents': minor +--- + +Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers this and the principal column. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. From 91b7f5280de00890d709ff666916652ec0914b50 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 10:05:25 +0100 Subject: [PATCH 21/35] fix(agents-server-ui): stamp _principal on optimistic comments for immediate author display Set _principal: { url: principalUrl } on the optimistic row in createSendCommentAction so the runtime timeline projection resolves the author immediately (rather than waiting for the server roundtrip). Extends OptimisticComment type with _principal?: { url: string }. Also adds a one-line comment in entity-timeline.ts noting the comments projection is not generic. Test asserts _principal.url is set on the optimistic row. Co-Authored-By: Claude Sonnet 4.6 --- packages/agents-runtime/src/entity-timeline.ts | 2 ++ packages/agents-server-ui/src/lib/comments.test.ts | 1 + packages/agents-server-ui/src/lib/comments.ts | 5 ++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 5388f5a448..e7772c2e04 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -1372,6 +1372,8 @@ function buildEntityTimelineQuery( cancelled_at: inbox.cancelled_at, })) + // This projection is specific to the `comments` collection and hardcodes the + // `_principal` column — it is not generic over arbitrary externallyWritable collections. const commentsCollection = (db.collections as Record) .comments as typeof db.collections.wakes | undefined diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts index 3f57e949ee..c522fbe43e 100644 --- a/packages/agents-server-ui/src/lib/comments.test.ts +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -55,6 +55,7 @@ describe(`createSendCommentAction`, () => { ]) expect(optimistic).toHaveLength(2) + expect(optimistic[0]?._principal?.url).toBe(`/principal/user%3Ame`) expect(optimistic[0]?._timeline_order).toMatch(/^~pending:/) expect(optimistic[1]?._timeline_order).toMatch(/^~pending:/) expect( diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index c1f2e2ac7c..471a83e5ce 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -15,6 +15,7 @@ let optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START export type OptimisticComment = EntityTimelineCommentRow & { _timeline_order: string + _principal?: { url: string } } export type SelectedCommentTarget = { @@ -71,12 +72,14 @@ export function createSendCommentAction({ const action = createOptimisticAction({ onMutate: ({ key, body, replyTo, targetSnapshot, pendingOrderIndex }) => { const now = new Date().toISOString() + const principalUrl = from ?? getActivePrincipal() const comment: OptimisticComment = { key, order: createPendingTimelineOrder(pendingOrderIndex), _timeline_order: createPendingTimelineOrder(pendingOrderIndex), body, - from: from ?? getActivePrincipal(), + from: principalUrl, + _principal: { url: principalUrl }, timestamp: now, ...(replyTo ? { reply_to: replyTo } : {}), ...(targetSnapshot ? { target_snapshot: targetSnapshot } : {}), From d58507b583e236cadbf247f2461178dc798a2dd7 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Wed, 10 Jun 2026 18:19:24 +0100 Subject: [PATCH 22/35] fix(agents-server-ui): register comments custom collection on entity streams Export commentsCollection from agents-runtime/client and define UI_ENTITY_CUSTOM_STATE in entity-connection.ts, merging it into every createEntityStreamDB call so db.collections.comments is always defined. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents-runtime/src/client.ts | 1 + .../lib/entity-connection.custom-state.test.ts | 18 ++++++++++++++++++ .../src/lib/entity-connection.test.ts | 6 ++++++ .../src/lib/entity-connection.ts | 15 ++++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 2b17ce5991..ca326b8770 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -98,6 +98,7 @@ export type { IncludesInboxMessage, IncludesRun, } from './entity-timeline' +export { commentsCollection } from './comments-collection' export type { CommentSnapshotValue as CommentSnapshot, CommentTargetValue as CommentTarget, diff --git a/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts new file mode 100644 index 0000000000..e6fa4e976e --- /dev/null +++ b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { UI_ENTITY_CUSTOM_STATE } from './entity-connection' + +describe(`UI_ENTITY_CUSTOM_STATE`, () => { + it(`exposes a comments collection so db.collections.comments is defined`, () => { + expect(UI_ENTITY_CUSTOM_STATE.comments).toBeDefined() + }) + + it(`comments collection has the correct type`, () => { + expect(UI_ENTITY_CUSTOM_STATE.comments.type).toBe(`state:comments`) + }) + + it(`comments collection is externally writable with _principal column`, () => { + expect(UI_ENTITY_CUSTOM_STATE.comments.externallyWritable).toEqual({ + principalColumn: `_principal`, + }) + }) +}) diff --git a/packages/agents-server-ui/src/lib/entity-connection.test.ts b/packages/agents-server-ui/src/lib/entity-connection.test.ts index 701d26f308..b423e1cc45 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.test.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.test.ts @@ -11,6 +11,12 @@ vi.mock(`./auth-fetch`, () => ({ vi.mock(`@electric-ax/agents-runtime/client`, () => ({ appendPathToUrl: (baseUrl: string, path: string) => `${baseUrl.replace(/\/+$/, ``)}${path}`, + commentsCollection: { + schema: {}, + type: `state:comments`, + primaryKey: `key`, + externallyWritable: { principalColumn: `_principal` }, + }, createEntityStreamDB: vi.fn(() => ({ preload: preloadMock, close: closeMock, diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index 8e4b5a9032..55fe17ac5d 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -4,6 +4,7 @@ import { DurableStream } from '@durable-streams/client' import type { StreamOptions } from '@durable-streams/client' import { appendPathToUrl, + commentsCollection, createEntityStreamDB, type EntityStreamDBWithActions, } from '@electric-ax/agents-runtime/client' @@ -20,6 +21,15 @@ function getMainStreamPath(entityUrl: string): string { */ export type UICustomState = Record +/** + * Collections the UI always registers on every entity stream so that + * `db.collections.comments` (and any future UI-specific collections) are + * guaranteed to be defined. Callers may overlay their own customState on + * top; explicitly-passed entries take precedence. + */ +export const UI_ENTITY_CUSTOM_STATE: Record = + { comments: commentsCollection } + let activeBaseUrl: string | null = null const ENTITY_METADATA_RETRY_DELAYS_MS = [250, 500, 1000, 2000] @@ -265,7 +275,10 @@ async function connectEntityStreamFresh(opts: { }) as unknown as EntityStreamHandle) const db = createEntityStreamDB( streamUrl, - customState as unknown as Parameters[1], + { + ...UI_ENTITY_CUSTOM_STATE, + ...(customState ?? {}), + } as unknown as Parameters[1], undefined, { stream } ) From 31f2bb3cbc555847be7bdf070dc0d12012a55d24 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 10:57:07 +0100 Subject: [PATCH 23/35] refactor(agents): move comments timeline projection out of the runtime The runtime timeline query no longer knows about comments. Instead, createEntityTimelineQuery accepts a generic extraSources option and builds the union + ordering dynamically over the source map (also removing the duplicated with/without-comments union block). The UI passes a comments source (createCommentsTimelineSource) so comment rows are merged by the live-query engine, and EntityTimelineCommentRow / the TimelineRow union now live in agents-server-ui. Co-Authored-By: Claude Fable 5 --- packages/agents-runtime/src/client.ts | 2 +- .../agents-runtime/src/entity-timeline.ts | 179 ++++--------- .../test/entity-timeline.test.ts | 253 ++++-------------- .../src/components/CommentBubble.tsx | 2 +- .../src/components/EntityTimeline.tsx | 11 +- .../src/components/views/ChatView.test.ts | 18 +- .../src/components/views/ChatView.tsx | 27 +- .../src/hooks/useEntityTimeline.ts | 11 +- .../agents-server-ui/src/lib/comments.test.ts | 42 ++- packages/agents-server-ui/src/lib/comments.ts | 67 ++++- 10 files changed, 242 insertions(+), 370 deletions(-) diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index ca326b8770..9c6d0813d0 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -81,9 +81,9 @@ export type { PgSyncRequestMetadata, } from './observation-sources' export type { - EntityTimelineCommentRow, EntityTimelineContentItem, EntityTimelineData, + EntityTimelineExtraSource, EntityTimelineInboxMode, EntityTimelineQueryOptions, EntityTimelineQueryRow, diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index e7772c2e04..d925e6e8bf 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -24,10 +24,6 @@ import type { import type { EntityStreamDB } from './entity-stream-db' import { formatPointerOrderToken, type EventPointer } from './event-pointer' import type { ChildStatusEntry, MessageReceived, Signal } from './entity-schema' -import type { - CommentSnapshotValue, - CommentTargetValue, -} from './comments-collection' import type { ManifestEntry, Wake, WakeMessage } from './types' export const TIMELINE_ORDER_FALLBACK = `~` @@ -204,8 +200,23 @@ export interface EntityTimelineData { export type EntityTimelineInboxMode = `processed` | `all` +/** + * A consumer-provided source unioned into the timeline query under its own + * row key. The projection must include `order` (timeline order token) and + * `key`; all other fields are passed through to the timeline row. + */ +export type EntityTimelineExtraSource = ( + q: InitialQueryBuilder +) => QueryBuilder + export interface EntityTimelineQueryOptions { inboxMode?: EntityTimelineInboxMode + /** + * Additional sources merged into the timeline, keyed by row name. Names + * must not collide with the built-in sources (`inbox`, `run`, `wake`, + * `signal`, `manifest`). + */ + extraSources?: Record } export interface EntityTimelineTextChunk { @@ -286,18 +297,6 @@ export interface EntityTimelineRunRow { } export type EntityTimelineInboxRow = IncludesInboxMessage -export type EntityTimelineCommentRow = { - key: string - order: TimelineOrder - body: string - from: string - timestamp: string - reply_to?: CommentTargetValue - target_snapshot?: CommentSnapshotValue - edited_at?: string - deleted_at?: string - deleted_by?: string -} export type EntityTimelineWakeRow = IncludesWakeMessage export type EntityTimelineSignalRow = IncludesSignal export type EntityTimelineErrorRow = EntityTimelineErrorItem & { @@ -309,7 +308,6 @@ export type EntityTimelineQueryRow = $key: string inbox: EntityTimelineInboxRow run?: undefined - comment?: undefined wake?: undefined signal?: undefined error?: undefined @@ -319,7 +317,6 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run: EntityTimelineRunRow - comment?: undefined wake?: undefined signal?: undefined error?: undefined @@ -329,16 +326,6 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined - comment: EntityTimelineCommentRow - wake?: undefined - signal?: undefined - manifest?: undefined - } - | { - $key: string - inbox?: undefined - run?: undefined - comment?: undefined wake: EntityTimelineWakeRow signal?: undefined error?: undefined @@ -348,7 +335,6 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined - comment?: undefined wake?: undefined signal: EntityTimelineSignalRow error?: undefined @@ -358,7 +344,6 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined - comment?: undefined wake?: undefined signal?: undefined error: EntityTimelineErrorRow @@ -1372,26 +1357,6 @@ function buildEntityTimelineQuery( cancelled_at: inbox.cancelled_at, })) - // This projection is specific to the `comments` collection and hardcodes the - // `_principal` column — it is not generic over arbitrary externallyWritable collections. - const commentsCollection = (db.collections as Record) - .comments as typeof db.collections.wakes | undefined - - const commentSource = commentsCollection - ? q.from({ comment: commentsCollection }).select(({ comment }) => ({ - order: coalesce(comment._timeline_order, `~`), - key: comment.key, - body: (comment as any).body, - from: coalesce((comment as any)._principal?.url, ``), - timestamp: coalesce((comment as any).timestamp, ``), - reply_to: (comment as any).reply_to, - target_snapshot: (comment as any).target_snapshot, - edited_at: (comment as any).edited_at, - deleted_at: (comment as any).deleted_at, - deleted_by: (comment as any).deleted_by, - })) - : null - const wakeSource = q .from({ wake: db.collections.wakes }) .select(({ wake }) => ({ @@ -1565,92 +1530,46 @@ function buildEntityTimelineQuery( })), })) - if (commentSource) { - return q - .unionAll({ - inbox: inboxSource, - run: runSource, - comment: commentSource, - wake: wakeSource, - signal: signalSource, - manifest: db.collections.manifests, - }) - .orderBy(({ inbox, run, comment, wake, signal, manifest }) => - coalesce( - inbox.order, - run.order, - comment.order, - wake.order, - signal.order, - manifest._timeline_order, - `~` - ) - ) - .orderBy(({ inbox, run, comment, wake, signal, manifest }) => - coalesce( - caseWhen(inbox.key, `inbox`), - caseWhen(run.key, `run`), - caseWhen(comment.key, `comment`), - caseWhen(wake.key, `wake`), - caseWhen(signal.key, `signal`), - caseWhen(manifest.key, `manifest`), - `` - ) - ) - .orderBy(({ inbox, run, comment, wake, signal, manifest }) => - coalesce( - inbox.key, - run.key, - comment.key, - wake.key, - signal.key, - manifest.key, - `` - ) + const sources: Record = { + inbox: inboxSource, + run: runSource, + wake: wakeSource, + signal: signalSource, + error: errorSource, + manifest: db.collections.manifests, + } + for (const [name, buildSource] of Object.entries(opts.extraSources ?? {})) { + if (name in sources) { + throw new Error( + `extraSources name "${name}" collides with a built-in timeline source` ) + } + sources[name] = buildSource(q) } + const sourceNames = Object.keys(sources) + // The manifests collection joins the union raw, so its order lives on + // `_timeline_order` rather than a projected `order` field. + const orderRef = (refs: any, name: string) => + name === `manifest` ? refs.manifest._timeline_order : refs[name].order + const coalesceAll = (exprs: Array) => + coalesce(...(exprs as [any, ...Array])) return q - .unionAll({ - inbox: inboxSource, - run: runSource, - wake: wakeSource, - signal: signalSource, - error: errorSource, - manifest: db.collections.manifests, - }) - .orderBy(({ inbox, run, wake, signal, error, manifest }) => - coalesce( - inbox.order, - run.order, - wake.order, - signal.order, - error.order, - manifest._timeline_order, - `~` - ) + .unionAll(sources) + .orderBy((refs: any) => + coalesceAll([ + ...sourceNames.map((name) => orderRef(refs, name)), + TIMELINE_ORDER_FALLBACK, + ]) ) - .orderBy(({ inbox, run, wake, signal, error, manifest }) => - coalesce( - caseWhen(inbox.key, `inbox`), - caseWhen(run.key, `run`), - caseWhen(wake.key, `wake`), - caseWhen(signal.key, `signal`), - caseWhen(error.key, `error`), - caseWhen(manifest.key, `manifest`), - `` - ) + .orderBy((refs: any) => + coalesceAll([ + ...sourceNames.map((name) => caseWhen(refs[name].key, name)), + ``, + ]) ) - .orderBy(({ inbox, run, wake, signal, error, manifest }) => - coalesce( - inbox.key, - run.key, - wake.key, - signal.key, - error.key, - manifest.key, - `` - ) + .orderBy((refs: any) => + coalesceAll([...sourceNames.map((name) => refs[name].key), ``]) ) } diff --git a/packages/agents-runtime/test/entity-timeline.test.ts b/packages/agents-runtime/test/entity-timeline.test.ts index 5be450b979..367ade64e9 100644 --- a/packages/agents-runtime/test/entity-timeline.test.ts +++ b/packages/agents-runtime/test/entity-timeline.test.ts @@ -22,7 +22,6 @@ import type { EventPointer } from '../src/event-pointer' import type { EntityTimelineContentItem, EntityTimelineData, - EntityTimelineQueryRow, IncludesInboxMessage, IncludesRun, IncludesWakeMessage, @@ -1865,209 +1864,65 @@ describe(`entity includes query`, () => { ) }) - describe(`comments collection projection`, () => { - function createEntityCollectionsWithComments() { - let nextOffset = 1 - let nextSeq = 1 - const takeOffset = () => offset(nextOffset++) - const takeSeq = () => nextSeq++ - const runs = createSyncCollection(`test-runs-c`, takeOffset) - const texts = createSyncCollection(`test-texts-c`, takeOffset) - const textDeltas = createSyncCollection(`test-textDeltas-c`, takeOffset) - const toolCalls = createSyncCollection(`test-toolCalls-c`, takeOffset) - const steps = createSyncCollection(`test-steps-c`, takeOffset) - const errors = createSyncCollection(`test-errors-c`, takeOffset) - const inbox = createSyncCollection(`test-inbox-c`, takeOffset) - const comments = createSyncCollection(`test-comments-c`, takeOffset) - const wakes = createSyncCollection(`test-wakes-c`, takeOffset) - const signals = createSyncCollection(`test-signals-c`, takeOffset) - const contextInserted = createSyncCollection( - `test-context-inserted-c`, - takeOffset - ) - const contextRemoved = createSyncCollection( - `test-context-removed-c`, - takeOffset - ) - const manifests = createSyncCollection(`test-manifests-c`, takeOffset) - const childStatus = createSyncCollection( - `test-child-status-c`, - takeOffset - ) - return { - collections: { - runs: runs.collection, - texts: texts.collection, - textDeltas: textDeltas.collection, - toolCalls: toolCalls.collection, - steps: steps.collection, - errors: errors.collection, - inbox: inbox.collection, - comments: comments.collection, - wakes: wakes.collection, - signals: signals.collection, - contextInserted: contextInserted.collection, - contextRemoved: contextRemoved.collection, - manifests: manifests.collection, - childStatus: childStatus.collection, - }, - sync: { - runs: withSeqInjection(runs, takeSeq), - texts: withSeqInjection(texts, takeSeq), - textDeltas: withSeqInjection(textDeltas, takeSeq), - toolCalls: withSeqInjection(toolCalls, takeSeq), - steps: withSeqInjection(steps, takeSeq), - errors: withSeqInjection(errors, takeSeq), - inbox: withSeqInjection(inbox, takeSeq), - comments: withSeqInjection(comments, takeSeq), - wakes: withSeqInjection(wakes, takeSeq), - signals: withSeqInjection(signals, takeSeq), - contextInserted: withSeqInjection(contextInserted, takeSeq), - contextRemoved: withSeqInjection(contextRemoved, takeSeq), - manifests: withSeqInjection(manifests, takeSeq), - childStatus: withSeqInjection(childStatus, takeSeq), - }, - } - } - - function timelineRowLabel(row: EntityTimelineQueryRow): string { - if (row.inbox) return `inbox:${row.inbox.key}` - if (row.run) return `run:${row.run.key}` - if (row.comment) return `comment:${row.comment.key}` - if (row.wake) return `wake:${row.wake.key}` - if (row.signal) return `signal:${row.signal.key}` - return `manifest:${row.manifest?.key}` - } - - it(`interleaves comments by _timeline_order`, async () => { - const { collections, sync } = createEntityCollectionsWithComments() - const liveQuery = createLiveQueryCollection({ - query: createEntityTimelineQuery({ collections } as any), - startSync: true, - }) - await liveQuery.preload() - - sync.inbox.insert({ - key: `msg-1`, - _timeline_order: order(1), - from: `user`, - payload: `start`, - timestamp: `2026-04-15T18:00:00.000Z`, - status: `processed`, - }) - sync.comments.insert({ - key: `comment-1`, - _timeline_order: order(2), - body: `between prompt and wake`, - timestamp: `2026-04-15T18:00:05.000Z`, - _principal: { url: `/principal/user%3Ame`, kind: `user`, id: `me` }, - }) - sync.wakes.insert({ - key: `wake-1`, - _timeline_order: order(3), - timestamp: `2026-04-15T18:00:10.000Z`, - source: `/chat/test`, - timeout: false, - changes: [], - }) - await new Promise((r) => setTimeout(r, 50)) - - const rows = Array.from((liveQuery as any).entries()).map( - ([, v]: any) => v - ) as Array - expect(rows.map(timelineRowLabel)).toEqual([ - `inbox:msg-1`, - `comment:comment-1`, - `wake:wake-1`, - ]) - }) - - it(`author resolves from _principal virtual column`, async () => { - const { collections, sync } = createEntityCollectionsWithComments() - const liveQuery = createLiveQueryCollection({ - query: createEntityTimelineQuery({ collections } as any), - startSync: true, - }) - await liveQuery.preload() - - sync.comments.insert({ - key: `comment-1`, - _timeline_order: order(1), - body: `hello`, - timestamp: `2026-04-15T18:00:00.000Z`, - _principal: { - url: `/principal/user%3Ajane`, - kind: `user`, - id: `jane`, + it(`unions extraSources into the timeline by order`, async () => { + const { collections, sync } = createEntityCollections() + let extraOffset = 1000 + const annotations = createSyncCollection(`test-annotations`, () => + offset(extraOffset++) + ) + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any, { + extraSources: { + annotation: (q) => + q + .from({ annotation: annotations.collection }) + .select(({ annotation }: any) => ({ + order: annotation._timeline_order, + key: annotation.key, + note: annotation.note, + })), }, - }) - await new Promise((r) => setTimeout(r, 50)) - - const rows = Array.from((liveQuery as any).entries()).map( - ([, v]: any) => v - ) as Array - expect(rows).toHaveLength(1) - expect(rows[0]?.comment?.from).toBe(`/principal/user%3Ajane`) + }), + startSync: true, }) + await liveQuery.preload() - it(`preserves reply_to and target_snapshot on comment rows`, async () => { - const { collections, sync } = createEntityCollectionsWithComments() - const liveQuery = createLiveQueryCollection({ - query: createEntityTimelineQuery({ collections } as any), - startSync: true, - }) - await liveQuery.preload() - - sync.comments.insert({ - key: `comment-reply`, - _timeline_order: order(1), - body: `reply body`, - timestamp: `2026-04-15T18:00:00.000Z`, - reply_to: { kind: `timeline`, collection: `inbox`, key: `msg-1` }, - target_snapshot: { label: `User message`, text: `start` }, - _principal: { url: `/principal/user%3Ame`, kind: `user`, id: `me` }, - }) - await new Promise((r) => setTimeout(r, 50)) - - const rows = Array.from((liveQuery as any).entries()).map( - ([, v]: any) => v - ) as Array - expect(rows).toHaveLength(1) - expect(rows[0]?.comment?.reply_to).toMatchObject({ - kind: `timeline`, - collection: `inbox`, - key: `msg-1`, - }) - expect(rows[0]?.comment?.target_snapshot).toMatchObject({ - label: `User message`, - text: `start`, - }) + sync.inbox.insert({ + key: `msg-1`, + _timeline_order: order(1), + from: `user`, + payload: `start`, + timestamp: `2026-04-15T18:00:00.000Z`, + status: `processed`, }) - - it(`entities without a comments collection are unaffected`, async () => { - const { collections, sync } = createEntityCollections() - const liveQuery = createLiveQueryCollection({ - query: createEntityTimelineQuery({ collections } as any), - startSync: true, - }) - await liveQuery.preload() - - sync.inbox.insert({ - key: `msg-1`, - _timeline_order: order(1), - from: `user`, - payload: `start`, - timestamp: `2026-04-15T18:00:00.000Z`, - status: `processed`, - }) - await new Promise((r) => setTimeout(r, 50)) - - const rows = Array.from((liveQuery as any).entries()).map( - ([, v]: any) => v - ) as Array - expect(rows).toHaveLength(1) - expect(rows[0]?.inbox?.key).toBe(`msg-1`) + annotations.insert({ + key: `note-1`, + _timeline_order: order(2), + note: `between`, }) + sync.wakes.insert({ + key: `wake-1`, + _timeline_order: order(3), + timestamp: `2026-04-15T18:00:10.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = Array.from((liveQuery as any).entries()).map( + ([, v]: any) => v + ) + expect( + rows.map((row: any) => + row.inbox + ? `inbox:${row.inbox.key}` + : row.annotation + ? `annotation:${row.annotation.key}` + : `wake:${row.wake?.key}` + ) + ).toEqual([`inbox:msg-1`, `annotation:note-1`, `wake:wake-1`]) + expect(rows[1]?.annotation?.note).toBe(`between`) }) it(`projects related entities from one manifest row per related entity`, () => { diff --git a/packages/agents-server-ui/src/components/CommentBubble.tsx b/packages/agents-server-ui/src/components/CommentBubble.tsx index c29bd6690a..842ead74ea 100644 --- a/packages/agents-server-ui/src/components/CommentBubble.tsx +++ b/packages/agents-server-ui/src/components/CommentBubble.tsx @@ -3,8 +3,8 @@ import { Reply } from 'lucide-react' import type { CommentSnapshot, CommentTarget, - EntityTimelineCommentRow, } from '@electric-ax/agents-runtime/client' +import type { EntityTimelineCommentRow } from '../lib/comments' import { Icon, IconButton, Text, Tooltip } from '../ui' import { principalKeyFromInput } from '../lib/principals' import { TimeText } from './TimeText' diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index a29a4fa286..87ce812a07 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -62,11 +62,10 @@ import { readTextPayload } from '../lib/sendMessage' import { principalKeyFromInput } from '../lib/principals' import styles from './EntityTimeline.module.css' import type { ElectricUser } from '../lib/ElectricAgentsProvider' -import type { SelectedCommentTarget } from '../lib/comments' +import type { SelectedCommentTarget, TimelineRow } from '../lib/comments' import type { CommentTarget, EntityTimelineSection, - EntityTimelineQueryRow, EntityTimelineRunItem, EntityTimelineRunRow, EntityTimelineToolCallItem, @@ -76,11 +75,11 @@ import type { import type { ErrorInfo, ReactNode } from 'react' import type { PaneFindAdapter, PaneFindMatch } from '../hooks/usePaneFind' -type RenderTimelineRow = EntityTimelineQueryRow +type RenderTimelineRow = TimelineRow type WakeSection = Extract export type TimelineRowAdjacency = { - previousRow?: EntityTimelineQueryRow - nextRow?: EntityTimelineQueryRow + previousRow?: TimelineRow + nextRow?: TimelineRow } function renderRowKey(row: RenderTimelineRow): string { @@ -1348,7 +1347,7 @@ export function EntityTimeline({ onFocusTargetHandled, onCommentTargetClick, }: { - rows: Array + rows: Array rowAdjacency?: Array loading: boolean error: string | null diff --git a/packages/agents-server-ui/src/components/views/ChatView.test.ts b/packages/agents-server-ui/src/components/views/ChatView.test.ts index 27de30ecab..9aac3826a0 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.test.ts +++ b/packages/agents-server-ui/src/components/views/ChatView.test.ts @@ -4,15 +4,13 @@ import { commentFocusViewParams, decodeCommentTargetParam, } from './ChatView' -import type { - CommentTarget, - EntityTimelineQueryRow, -} from '@electric-ax/agents-runtime/client' +import type { CommentTarget } from '@electric-ax/agents-runtime/client' +import type { TimelineRow } from '../../lib/comments' function commentRow( key: string, fromPrincipal = `/principal/user%3Ame` -): EntityTimelineQueryRow { +): TimelineRow { return { $key: `comment:${key}`, comment: { @@ -22,10 +20,10 @@ function commentRow( from: fromPrincipal, timestamp: `2026-04-15T18:00:00.000Z`, }, - } as EntityTimelineQueryRow + } as TimelineRow } -function wakeRow(key: string): EntityTimelineQueryRow { +function wakeRow(key: string): TimelineRow { return { $key: `wake:${key}`, wake: { @@ -39,10 +37,10 @@ function wakeRow(key: string): EntityTimelineQueryRow { changes: [], }, }, - } as EntityTimelineQueryRow + } as TimelineRow } -function attachmentRow(key: string): EntityTimelineQueryRow { +function attachmentRow(key: string): TimelineRow { return { $key: `manifest:${key}`, manifest: { @@ -56,7 +54,7 @@ function attachmentRow(key: string): EntityTimelineQueryRow { byteLength: 12, createdAt: `2026-04-15T18:00:00.000Z`, }, - } as EntityTimelineQueryRow + } as TimelineRow } describe(`buildCommentsTimeline`, () => { diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 68dcabd6b7..873d2fefe2 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -9,17 +9,14 @@ import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useWorkspace } from '../../hooks/useWorkspace' import { isAttachmentManifest } from '../../lib/attachments' import { schemaModelSupportsImageInput } from '../../lib/modelCapabilities' -import type { SelectedCommentTarget } from '../../lib/comments' +import type { SelectedCommentTarget, TimelineRow } from '../../lib/comments' import { useEntityPermission, useEntityPermissions, type EntityPermission, } from '../../hooks/useEntityPermission' import type { ViewProps } from '../../lib/workspace/viewRegistry' -import type { - CommentTarget, - EntityTimelineQueryRow, -} from '@electric-ax/agents-runtime/client' +import type { CommentTarget } from '@electric-ax/agents-runtime/client' import type { EventPointer } from '@electric-ax/agents-runtime' import type { OptimisticInboxMessage } from '../../lib/sendMessage' import type { SlashCommandRow } from '@electric-ax/agents-runtime/client' @@ -83,15 +80,13 @@ function isCommentTarget(value: unknown): value is CommentTarget { ) } -export function buildCommentsTimeline( - timelineRows: Array -): { - rows: Array +export function buildCommentsTimeline(timelineRows: Array): { + rows: Array adjacency: Array } { - const rows: Array = [] + const rows: Array = [] const adjacency: Array = [] - let previousRenderableRow: EntityTimelineQueryRow | undefined + let previousRenderableRow: TimelineRow | undefined let pendingCommentAdjacencyIndex: number | null = null for (const row of timelineRows) { @@ -198,14 +193,14 @@ export function ChatLogView({ pendingInboxByKey, processedInboxKeys, ]) - const visibleRows = useMemo>(() => { + const visibleRows = useMemo>(() => { if (!projectedPendingMessage) return timelineRows return [ ...timelineRows, { $key: `pending-inbox:${projectedPendingMessage.key}`, inbox: projectedPendingMessage, - } as EntityTimelineQueryRow, + } as TimelineRow, ] }, [projectedPendingMessage, timelineRows]) @@ -415,7 +410,7 @@ function GenericChatBody({ ) const inlinePendingInbox = !entityStopped && !generationActive ? visiblePendingInbox[0] : undefined - const timelineRowsWithInlinePending = useMemo>( + const timelineRowsWithInlinePending = useMemo>( () => inlinePendingInbox ? [ @@ -423,13 +418,13 @@ function GenericChatBody({ { $key: `pending-inbox:${inlinePendingInbox.key}`, inbox: inlinePendingInbox, - } as EntityTimelineQueryRow, + } as TimelineRow, ] : timelineRows, [inlinePendingInbox, timelineRows] ) const showComments = viewParams?.comments !== `hidden` - const displayTimelineRows = useMemo>( + const displayTimelineRows = useMemo>( () => showComments ? timelineRowsWithInlinePending diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index 66d2e59c08..3962151e55 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -8,9 +8,10 @@ import { } from '@electric-ax/agents-runtime/client' import { coalesce, eq } from '@durable-streams/state/db' import { connectEntityStream } from '../lib/entity-connection' +import { createCommentsTimelineSource } from '../lib/comments' +import type { TimelineRow } from '../lib/comments' import type { EntityStreamDBWithActions, - EntityTimelineQueryRow, IncludesInboxMessage, IncludesEntity, Manifest, @@ -49,7 +50,7 @@ export function useEntityTimeline( baseUrl: string | null, entityUrl: string | null ): { - timelineRows: Array + timelineRows: Array pendingInbox: Array entities: Array generationActive: boolean @@ -106,7 +107,9 @@ export function useEntityTimeline( const { data: timelineRows = [] } = useLiveQuery( (q) => { if (!db) return undefined - return createEntityTimelineQuery(db)(q) + return createEntityTimelineQuery(db, { + extraSources: { comment: createCommentsTimelineSource(db) }, + })(q) }, [db] ) @@ -136,7 +139,7 @@ export function useEntityTimeline( : undefined, [db] ) - const typedTimelineRows = timelineRows as Array + const typedTimelineRows = timelineRows as Array const pendingInbox = useMemo( () => diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts index c522fbe43e..01d57877d3 100644 --- a/packages/agents-server-ui/src/lib/comments.test.ts +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' import { compareTimelineOrders } from '@electric-ax/agents-runtime/client' +import { createLiveQueryCollection } from '@durable-streams/state/db' import { registerActiveServerHeaders } from './auth-fetch' -import { createSendCommentAction } from './comments' +import { + createCommentsTimelineSource, + createSendCommentAction, +} from './comments' import type { CommentSnapshot, CommentTarget, @@ -27,6 +31,42 @@ function createCommentsDb() { } } +describe(`createCommentsTimelineSource`, () => { + it(`projects author from _principal, falling back to the optimistic from`, async () => { + const { db, comments } = createCommentsDb() + const liveQuery = createLiveQueryCollection({ + query: createCommentsTimelineSource(db), + startSync: true, + }) + await liveQuery.preload() + + comments.insert({ + key: `c-synced`, + _timeline_order: `00000002`, + body: `hello`, + timestamp: `2026-04-15T18:00:00.000Z`, + _principal: { url: `/principal/user%3Ajane`, kind: `user`, id: `jane` }, + } as any) + comments.insert({ + key: `c-optimistic`, + _timeline_order: `~pending:000000000001`, + body: `mine`, + from: `/principal/user%3Ame`, + } as any) + await new Promise((r) => setTimeout(r, 50)) + + const rows = new Map(liveQuery.toArray.map((row: any) => [row.key, row])) + expect(rows.get(`c-synced`)).toMatchObject({ + order: `00000002`, + body: `hello`, + from: `/principal/user%3Ajane`, + }) + expect(rows.get(`c-optimistic`)).toMatchObject({ + from: `/principal/user%3Ame`, + }) + }) +}) + describe(`createSendCommentAction`, () => { afterEach(() => { vi.restoreAllMocks() diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index 471a83e5ce..bd99e84a41 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -1,14 +1,77 @@ import { createOptimisticAction } from '@tanstack/db' -import { createPendingTimelineOrder } from '@electric-ax/agents-runtime/client' +import { coalesce } from '@durable-streams/state/db' +import { + createPendingTimelineOrder, + TIMELINE_ORDER_FALLBACK, +} from '@electric-ax/agents-runtime/client' import { getActivePrincipal, serverFetch } from './auth-fetch' import { entityApiUrl } from './entity-api' import type { CommentSnapshot, CommentTarget, EntityStreamDBWithActions, - EntityTimelineCommentRow, + EntityTimelineExtraSource, + EntityTimelineQueryRow, } from '@electric-ax/agents-runtime/client' +/** + * Comments are a UI-level concern: the runtime timeline query knows nothing + * about them. `useEntityTimeline` reads the `comments` collection directly + * and merges these rows into the timeline with `mergeCommentRows`. + */ +export type EntityTimelineCommentRow = { + key: string + order: string + body: string + from: string + timestamp: string + reply_to?: CommentTarget + target_snapshot?: CommentSnapshot + edited_at?: string + deleted_at?: string + deleted_by?: string +} + +export type CommentTimelineRow = { + $key: string + comment: EntityTimelineCommentRow + inbox?: undefined + run?: undefined + wake?: undefined + signal?: undefined + manifest?: undefined +} + +/** Timeline row as consumed by UI views: runtime rows plus merged comment rows. */ +export type TimelineRow = + | (EntityTimelineQueryRow & { comment?: undefined }) + | CommentTimelineRow + +/** + * Timeline source for the `comments` collection, passed to the runtime's + * `createEntityTimelineQuery` via `extraSources`. The author resolves from + * the `_principal` virtual column (server-stamped, spoof-proof), falling back + * to the optimistic row's `from`. + */ +export function createCommentsTimelineSource( + db: EntityStreamDBWithActions +): EntityTimelineExtraSource { + const comments = (db.collections as Record).comments + return (q) => + q.from({ comment: comments }).select(({ comment }: any) => ({ + order: coalesce(comment._timeline_order, TIMELINE_ORDER_FALLBACK), + key: comment.key, + body: comment.body, + from: coalesce(comment._principal?.url, comment.from, ``), + timestamp: coalesce(comment.timestamp, ``), + reply_to: comment.reply_to, + target_snapshot: comment.target_snapshot, + edited_at: comment.edited_at, + deleted_at: comment.deleted_at, + deleted_by: comment.deleted_by, + })) +} + const OPTIMISTIC_COMMENT_ORDER_START = Number.MAX_SAFE_INTEGER - 2_000_000 let optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START From f0e3d7efd5c3bbe9732b2f090390b88a73ead10d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 11:31:10 +0100 Subject: [PATCH 24/35] refactor(agents): drop principalColumn configurability, fix _principal as a reserved virtual column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The principal column name was persisted server-side but never read — the server only uses the collection's event type, and clients resolve the column from their local CollectionDefinition. externallyWritable is now a plain boolean and the registration/DB config is { type } per collection. Co-Authored-By: Claude Fable 5 --- .../agents-runtime/src/comments-collection.ts | 2 +- packages/agents-runtime/src/create-handler.ts | 9 +---- .../agents-runtime/src/entity-stream-db.ts | 36 +++++++++---------- packages/agents-runtime/src/types.ts | 4 +-- .../test/comments-collection.test.ts | 6 ++-- ...create-handler-externally-writable.test.ts | 4 +-- .../test/entity-stream-db-principal.test.ts | 4 +-- .../entity-connection.custom-state.test.ts | 6 ++-- .../src/lib/entity-connection.test.ts | 2 +- .../src/electric-agents-types.ts | 2 -- .../src/routing/entity-types-router.ts | 5 +-- ...ic-agents-manager-write-validation.test.ts | 4 +-- .../test/electric-agents-routes.test.ts | 6 ++-- .../test/entity-type-registry.test.ts | 2 +- .../comments-collection-registration.test.ts | 4 +-- 15 files changed, 37 insertions(+), 59 deletions(-) diff --git a/packages/agents-runtime/src/comments-collection.ts b/packages/agents-runtime/src/comments-collection.ts index c73caafac3..2d56216a7b 100644 --- a/packages/agents-runtime/src/comments-collection.ts +++ b/packages/agents-runtime/src/comments-collection.ts @@ -77,5 +77,5 @@ export const commentsCollection: CollectionDefinition = { schema: commentSchema, type: `state:comments`, primaryKey: `key`, - externallyWritable: { principalColumn: `_principal` }, + externallyWritable: true, } diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index 32567bd4c0..56e1b130da 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -282,18 +282,11 @@ export function buildEntityTypeRegistrationBody( ]) ) - const externallyWritableCollections: Record< - string, - { type: string; principalColumn: string } - > = {} + const externallyWritableCollections: Record = {} for (const [collectionName, def] of stateEntries) { if (!def.externallyWritable) continue externallyWritableCollections[collectionName] = { type: def.type ?? `state:${collectionName}`, - principalColumn: - def.externallyWritable === true - ? `_principal` - : (def.externallyWritable.principalColumn ?? `_principal`), } } diff --git a/packages/agents-runtime/src/entity-stream-db.ts b/packages/agents-runtime/src/entity-stream-db.ts index e05e2474f8..a48c4c965c 100644 --- a/packages/agents-runtime/src/entity-stream-db.ts +++ b/packages/agents-runtime/src/entity-stream-db.ts @@ -106,6 +106,13 @@ type EntityStreamDBOptions = { const WRITE_TXID_TIMEOUT_MS = 20_000 +/** + * Virtual column the authenticated principal (from the change-event header) is + * materialized into for externally writable collections. Like `_timeline_order`, + * it is stripped before client write-back. + */ +export const PRINCIPAL_COLUMN = `_principal` + // Wrap a Standard Schema so that named virtual columns (e.g. `_timeline_order`, // `_principal`) survive the validation step. TanStack DB calls the schema's // validate() on every insert/update and uses result.value as the stored row, @@ -161,17 +168,11 @@ export function createEntityStreamDB( // Convert entity-level CollectionDefinition (with optional JSON schema) to // stream-db CollectionDefinition (with Standard Schema validator + type + primaryKey) const streamCustomState: Record = {} - const principalColumnByCollection = new Map() + const externallyWritableCollections = new Set() if (customState) { for (const [name, def] of Object.entries(customState)) { - const principalColumn = def.externallyWritable - ? def.externallyWritable === true - ? `_principal` - : (def.externallyWritable.principalColumn ?? `_principal`) - : undefined - - if (principalColumn) { - principalColumnByCollection.set(name, principalColumn) + if (def.externallyWritable) { + externallyWritableCollections.add(name) } // When virtual columns are projected onto the row, wrap the user schema @@ -179,7 +180,7 @@ export function createEntityStreamDB( const baseSchema = def.schema ?? passthrough() const virtualColumns = [ `_timeline_order`, - ...(principalColumn ? [principalColumn] : []), + ...(def.externallyWritable ? [PRINCIPAL_COLUMN] : []), ] const schema = def.schema ? wrapSchemaWithVirtualColumns(baseSchema, virtualColumns) @@ -240,14 +241,11 @@ export function createEntityStreamDB( key: string } - const principalColumns = new Set(principalColumnByCollection.values()) const cleanRow = (row: Record): Record => { const clone = { ...row } delete clone._seq delete clone._timeline_order - for (const col of principalColumns) { - delete clone[col] - } + delete clone[PRINCIPAL_COLUMN] return clone } @@ -414,11 +412,10 @@ export function createEntityStreamDB( orders.set(item.key, order) } ;(item.value as Record)._timeline_order = order - const principalColumn = principalColumnByCollection.get(collectionName) - if (principalColumn) { + if (externallyWritableCollections.has(collectionName)) { const principal = (item.headers as Record).principal if (principal !== undefined) { - ;(item.value as Record)[principalColumn] = + ;(item.value as Record)[PRINCIPAL_COLUMN] = principal } } @@ -795,11 +792,10 @@ export function createEntityStreamDB( const order = orders?.get(event.key) ?? formatPointerOrderToken(pointer) orders?.set(event.key, order) ;(event.value as Record)._timeline_order = order - const principalColumn = principalColumnByCollection.get(collectionName) - if (principalColumn) { + if (externallyWritableCollections.has(collectionName)) { const principal = (event.headers as Record).principal if (principal !== undefined) { - ;(event.value as Record)[principalColumn] = + ;(event.value as Record)[PRINCIPAL_COLUMN] = principal } } diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 7fb20af8fd..427270b514 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -642,9 +642,9 @@ export interface CollectionDefinition< /** * Opt-in for externally writable via the HTTP router: `POST /:type/:instanceId/collections/:name`. * Absent/false ⇒ agent-only; the endpoint rejects writes. `true` ⇒ externally writable, - * principal materialized into `_principal`. Object form renames that column. + * with the authenticated principal materialized into the `_principal` virtual column. */ - externallyWritable?: boolean | { principalColumn?: string } + externallyWritable?: boolean } export interface EntityTypeEntry< diff --git a/packages/agents-runtime/test/comments-collection.test.ts b/packages/agents-runtime/test/comments-collection.test.ts index ec0e9da2e7..fabb9d9fe4 100644 --- a/packages/agents-runtime/test/comments-collection.test.ts +++ b/packages/agents-runtime/test/comments-collection.test.ts @@ -21,9 +21,7 @@ describe(`commentsCollection`, () => { }) }) - it(`exports externallyWritable with the _principal column`, () => { - expect(commentsCollection.externallyWritable).toEqual({ - principalColumn: `_principal`, - }) + it(`is externally writable`, () => { + expect(commentsCollection.externallyWritable).toBe(true) }) }) diff --git a/packages/agents-runtime/test/create-handler-externally-writable.test.ts b/packages/agents-runtime/test/create-handler-externally-writable.test.ts index e6d7ada25e..7e48db2ad9 100644 --- a/packages/agents-runtime/test/create-handler-externally-writable.test.ts +++ b/packages/agents-runtime/test/create-handler-externally-writable.test.ts @@ -10,7 +10,7 @@ describe(`buildEntityTypeRegistrationBody`, () => { state: { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - externallyWritable: { principalColumn: `_principal` }, + externallyWritable: true, }, scratch: { schema: z.object({ key: z.string().optional(), note: z.string() }), @@ -18,7 +18,7 @@ describe(`buildEntityTypeRegistrationBody`, () => { }, } as any) expect(body.externally_writable_collections).toEqual({ - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }) }) diff --git a/packages/agents-runtime/test/entity-stream-db-principal.test.ts b/packages/agents-runtime/test/entity-stream-db-principal.test.ts index f9506fbb77..087ae02e75 100644 --- a/packages/agents-runtime/test/entity-stream-db-principal.test.ts +++ b/packages/agents-runtime/test/entity-stream-db-principal.test.ts @@ -12,7 +12,7 @@ describe(`entity-stream-db principal virtual column`, () => { const db = createEntityStreamDB(`/chat/sess-1`, { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - externallyWritable: { principalColumn: `_principal` }, + externallyWritable: true, }, }) db.utils.applyEvent({ @@ -67,7 +67,7 @@ describe(`entity-stream-db principal virtual column`, () => { { comments: { schema: z.object({ key: z.string().optional(), body: z.string() }), - externallyWritable: { principalColumn: `_principal` }, + externallyWritable: true, }, }, undefined, diff --git a/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts index e6fa4e976e..b9f40293a8 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts @@ -10,9 +10,7 @@ describe(`UI_ENTITY_CUSTOM_STATE`, () => { expect(UI_ENTITY_CUSTOM_STATE.comments.type).toBe(`state:comments`) }) - it(`comments collection is externally writable with _principal column`, () => { - expect(UI_ENTITY_CUSTOM_STATE.comments.externallyWritable).toEqual({ - principalColumn: `_principal`, - }) + it(`comments collection is externally writable`, () => { + expect(UI_ENTITY_CUSTOM_STATE.comments.externallyWritable).toBe(true) }) }) diff --git a/packages/agents-server-ui/src/lib/entity-connection.test.ts b/packages/agents-server-ui/src/lib/entity-connection.test.ts index b423e1cc45..adebaeb73f 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.test.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.test.ts @@ -15,7 +15,7 @@ vi.mock(`@electric-ax/agents-runtime/client`, () => ({ schema: {}, type: `state:comments`, primaryKey: `key`, - externallyWritable: { principalColumn: `_principal` }, + externallyWritable: true, }, createEntityStreamDB: vi.fn(() => ({ preload: preloadMock, diff --git a/packages/agents-server/src/electric-agents-types.ts b/packages/agents-server/src/electric-agents-types.ts index 4800663ee5..632e47addf 100644 --- a/packages/agents-server/src/electric-agents-types.ts +++ b/packages/agents-server/src/electric-agents-types.ts @@ -498,8 +498,6 @@ export function toPublicEntity( export interface ExternallyWritableCollectionConfig { /** Durable-stream event type for this collection, e.g. `state:comments`. */ type: string - /** Row column the client materializes the principal header into. */ - principalColumn: string } export interface ElectricAgentsEntityType { diff --git a/packages/agents-server/src/routing/entity-types-router.ts b/packages/agents-server/src/routing/entity-types-router.ts index 941f52566d..25d635a073 100644 --- a/packages/agents-server/src/routing/entity-types-router.ts +++ b/packages/agents-server/src/routing/entity-types-router.ts @@ -47,10 +47,7 @@ const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown()) const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema) const externallyWritableCollectionsSchema = Type.Record( Type.String(), - Type.Object( - { type: Type.String(), principalColumn: Type.String() }, - { additionalProperties: false } - ) + Type.Object({ type: Type.String() }, { additionalProperties: false }) ) const slashCommandArgumentSchema = Type.Object( { diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index 49bc40b87a..122069d4a3 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -388,7 +388,7 @@ describe(`ElectricAgentsManager.writeCollection`, () => { name: `chat`, state_schemas: { 'state:comments': {} }, externally_writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }, }) @@ -454,7 +454,7 @@ describe(`ElectricAgentsManager.writeCollection`, () => { name: `chat`, state_schemas: { 'state:comments': {} }, externally_writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }, }) diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index 418f47db7a..594f36ec76 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -1541,7 +1541,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { created_at: `t`, updated_at: `t`, externally_writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }, }) const manager = { @@ -1557,7 +1557,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { name: `chat`, description: `chat`, externally_writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }, } ) @@ -1566,7 +1566,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { expect(registerEntityType).toHaveBeenCalledWith( expect.objectContaining({ externally_writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, + comments: { type: `state:comments` }, }, }) ) diff --git a/packages/agents-server/test/entity-type-registry.test.ts b/packages/agents-server/test/entity-type-registry.test.ts index 27aa784451..f80745cc41 100644 --- a/packages/agents-server/test/entity-type-registry.test.ts +++ b/packages/agents-server/test/entity-type-registry.test.ts @@ -44,7 +44,7 @@ describe(`PostgresRegistry entity type registration`, () => { it(`persists and retrieves externally_writable_collections round-trip`, async () => { const registry = new PostgresRegistry(db, `tenant-a`) const externallyWritableCollections = { - comments: { type: `state:comments`, principalColumn: `author_id` }, + comments: { type: `state:comments` }, } await registry.createEntityType( entityType({ diff --git a/packages/agents/test/comments-collection-registration.test.ts b/packages/agents/test/comments-collection-registration.test.ts index b16cec3cda..85a85fd9a8 100644 --- a/packages/agents/test/comments-collection-registration.test.ts +++ b/packages/agents/test/comments-collection-registration.test.ts @@ -33,9 +33,7 @@ describe(`comments collection registration`, () => { for (const name of [`horton`, `worker`]) { const def = registry.get(name)?.definition as any - expect(def.state?.comments?.externallyWritable).toEqual({ - principalColumn: `_principal`, - }) + expect(def.state?.comments?.externallyWritable).toBe(true) } }) }) From d217f43c652335ff96f81b9e6959a99f685745cb Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 12:17:30 +0100 Subject: [PATCH 25/35] refactor(agents-server-ui): comments visibility via live query, shared fork-from-here hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenericChatBody no longer filters comment rows in JS — useEntityTimeline takes a comments flag and omits the comments extraSource when the view hides them, so the query engine maintains the row set. The duplicated fork-from-here anchor map moves to a shared useForkFromHere hook. Co-Authored-By: Claude Fable 5 --- .../src/components/views/ChatView.tsx | 110 +++--------------- .../src/hooks/useEntityTimeline.ts | 13 ++- .../src/hooks/useForkFromHere.ts | 67 +++++++++++ 3 files changed, 94 insertions(+), 96 deletions(-) create mode 100644 packages/agents-server-ui/src/hooks/useForkFromHere.ts diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 873d2fefe2..24d872b14a 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useEntityTimeline } from '../../hooks/useEntityTimeline' +import { useForkFromHere } from '../../hooks/useForkFromHere' import { EntityTimeline, type TimelineRowAdjacency } from '../EntityTimeline' import { MessageInput } from '../MessageInput' import { EntityContextDrawer } from '../EntityContextDrawer' @@ -17,10 +18,8 @@ import { } from '../../hooks/useEntityPermission' import type { ViewProps } from '../../lib/workspace/viewRegistry' import type { CommentTarget } from '@electric-ax/agents-runtime/client' -import type { EventPointer } from '@electric-ax/agents-runtime' import type { OptimisticInboxMessage } from '../../lib/sendMessage' import type { SlashCommandRow } from '@electric-ax/agents-runtime/client' -import type { ForkFromHereAction } from '../UserMessage' const CHAT_VIEW_PERMISSIONS: ReadonlyArray = [ `write`, @@ -166,7 +165,6 @@ export function ChatLogView({ const connectUrl = isSpawning ? null : entityUrl const { timelineRows, pendingInbox, entities, db, loading, error } = useEntityTimeline(baseUrl || null, connectUrl) - const { forkEntity } = useElectricAgents() const canFork = useEntityPermission(entity, `fork`) const navigate = useNavigate() const processedInboxKeys = useMemo( @@ -210,41 +208,12 @@ export function ChatLogView({ } }, [error, navigate, isSpawning]) - const forkFromHereByRunKey = useMemo(() => { - if (!forkEntity || !connectUrl || !db) return undefined - const runOffsets = db.collections.runs.__electricRowOffsets - if (!runOffsets) return undefined - const map = new Map() - let anchor: { rowKey: string; pointer: EventPointer } | null = null - for (const row of visibleRows) { - if (row.run && row.run.status === `completed`) { - const pointer = runOffsets.get(row.run.key) - anchor = pointer ? { rowKey: row.$key, pointer } : null - } - if (row.inbox && anchor) { - const capturedAnchor = anchor.pointer - const capturedRunKey = anchor.rowKey - map.set( - capturedRunKey, - canFork - ? { - onFork: () => { - void forkEntity(connectUrl, { pointer: capturedAnchor }) - .then((res) => - navigate({ - to: `/entity/$`, - params: { _splat: res.url.replace(/^\//, ``) }, - }) - ) - .catch(() => {}) - }, - } - : { disabled: true } - ) - } - } - return map - }, [visibleRows, canFork, db, forkEntity, connectUrl, navigate]) + const forkFromHereByRunKey = useForkFromHere({ + rows: visibleRows, + db, + entityUrl: connectUrl, + canFork, + }) return ( >( - () => - showComments - ? timelineRowsWithInlinePending - : timelineRowsWithInlinePending.filter((row) => !row.comment), - [showComments, timelineRowsWithInlinePending] - ) const focusTarget = useMemo( () => decodeCommentTargetParam(viewParams?.[COMMENT_FOCUS_PARAM]), [viewParams] @@ -494,54 +455,17 @@ function GenericChatBody({ }) }, [canSignal, entityUrl, generationActive, signalEntity, stopPending]) - // "Fork from here" anchor map. For each completed `runs` row that is - // followed by a user-message inbox row, the run pointer identifies - // "fork up to and including this response, drop everything after." - // Completed runs without a following prompt (usually the current end - // of the conversation) get no entry, preserving the old "historic - // prompt" affordance while moving it to the response footer. - const forkFromHereByRunKey = useMemo(() => { - if (!forkEntity || !entityUrl || !db) return undefined - const runOffsets = db.collections.runs.__electricRowOffsets - if (!runOffsets) return undefined - const map = new Map() - let anchor: { rowKey: string; pointer: EventPointer } | null = null - for (const row of displayTimelineRows) { - if (row.run && row.run.status === `completed`) { - const pointer = runOffsets.get(row.run.key) - anchor = pointer ? { rowKey: row.$key, pointer } : null - } - if (row.inbox && anchor) { - const capturedAnchor = anchor.pointer - const capturedRunKey = anchor.rowKey - map.set( - capturedRunKey, - canFork - ? { - onFork: () => { - // forkEntity surfaces failures via a danger toast before - // rejecting, so the caller just needs to swallow the rejection. - void forkEntity(entityUrl, { pointer: capturedAnchor }) - .then((res) => - navigate({ - to: `/entity/$`, - params: { _splat: res.url.replace(/^\//, ``) }, - }) - ) - .catch(() => {}) - }, - } - : { disabled: true } - ) - } - } - return map - }, [displayTimelineRows, canFork, db, forkEntity, entityUrl, navigate]) + const forkFromHereByRunKey = useForkFromHere({ + rows: timelineRowsWithInlinePending, + db, + entityUrl, + canFork, + }) return ( <> pendingInbox: Array @@ -104,14 +108,17 @@ export function useEntityTimeline( } }, [baseUrl, entityUrl]) + const includeComments = opts?.comments ?? true const { data: timelineRows = [] } = useLiveQuery( (q) => { if (!db) return undefined return createEntityTimelineQuery(db, { - extraSources: { comment: createCommentsTimelineSource(db) }, + ...(includeComments && { + extraSources: { comment: createCommentsTimelineSource(db) }, + }), })(q) }, - [db] + [db, includeComments] ) const { data: manifests = [] } = useLiveQuery( (q) => diff --git a/packages/agents-server-ui/src/hooks/useForkFromHere.ts b/packages/agents-server-ui/src/hooks/useForkFromHere.ts new file mode 100644 index 0000000000..ac5240b105 --- /dev/null +++ b/packages/agents-server-ui/src/hooks/useForkFromHere.ts @@ -0,0 +1,67 @@ +import { useMemo } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useElectricAgents } from '../lib/ElectricAgentsProvider' +import type { EventPointer } from '@electric-ax/agents-runtime' +import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime/client' +import type { TimelineRow } from '../lib/comments' +import type { ForkFromHereAction } from '../components/UserMessage' + +/** + * "Fork from here" anchor map. For each completed `runs` row that is + * followed by a user-message inbox row, the run pointer identifies + * "fork up to and including this response, drop everything after." + * Completed runs without a following prompt (usually the current end + * of the conversation) get no entry, preserving the old "historic + * prompt" affordance while moving it to the response footer. + */ +export function useForkFromHere({ + rows, + db, + entityUrl, + canFork, +}: { + rows: Array + db: EntityStreamDBWithActions | null + entityUrl: string | null + canFork: boolean +}): Map | undefined { + const { forkEntity } = useElectricAgents() + const navigate = useNavigate() + return useMemo(() => { + if (!forkEntity || !entityUrl || !db) return undefined + const runOffsets = db.collections.runs.__electricRowOffsets + if (!runOffsets) return undefined + const map = new Map() + let anchor: { rowKey: string; pointer: EventPointer } | null = null + for (const row of rows) { + if (row.run && row.run.status === `completed`) { + const pointer = runOffsets.get(row.run.key) + anchor = pointer ? { rowKey: row.$key, pointer } : null + } + if (row.inbox && anchor) { + const capturedAnchor = anchor.pointer + const capturedRunKey = anchor.rowKey + map.set( + capturedRunKey, + canFork + ? { + onFork: () => { + // forkEntity surfaces failures via a danger toast before + // rejecting, so the caller just needs to swallow the rejection. + void forkEntity(entityUrl, { pointer: capturedAnchor }) + .then((res) => + navigate({ + to: `/entity/$`, + params: { _splat: res.url.replace(/^\//, ``) }, + }) + ) + .catch(() => {}) + }, + } + : { disabled: true } + ) + } + } + return map + }, [rows, canFork, db, forkEntity, entityUrl, navigate]) +} From 29d778efbde2fd126ddb86ce64a4d2bcfc56c872 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 12:21:52 +0100 Subject: [PATCH 26/35] refactor(agents-server-ui): dedupe formatSender, move comment helpers to lib formatSender/userDisplayName live once in lib/principals.ts (CommentBubble had re-implemented UserMessage's copy with drifting ellipsis). The pure comment-target codecs, buildCommentsTimeline, and TimelineRowAdjacency move from ChatView.tsx to lib/comments.ts with their tests. Co-Authored-By: Claude Fable 5 --- .../src/components/CommentBubble.tsx | 37 +---- .../src/components/EntityTimeline.tsx | 10 +- .../src/components/UserMessage.tsx | 42 +----- .../src/components/views/ChatView.test.ts | 127 ------------------ .../src/components/views/ChatView.tsx | 97 +------------ .../agents-server-ui/src/lib/comments.test.ts | 124 ++++++++++++++++- packages/agents-server-ui/src/lib/comments.ts | 103 +++++++++++++- .../agents-server-ui/src/lib/principals.ts | 47 +++++++ 8 files changed, 285 insertions(+), 302 deletions(-) delete mode 100644 packages/agents-server-ui/src/components/views/ChatView.test.ts diff --git a/packages/agents-server-ui/src/components/CommentBubble.tsx b/packages/agents-server-ui/src/components/CommentBubble.tsx index 842ead74ea..6ffecede52 100644 --- a/packages/agents-server-ui/src/components/CommentBubble.tsx +++ b/packages/agents-server-ui/src/components/CommentBubble.tsx @@ -6,7 +6,7 @@ import type { } from '@electric-ax/agents-runtime/client' import type { EntityTimelineCommentRow } from '../lib/comments' import { Icon, IconButton, Text, Tooltip } from '../ui' -import { principalKeyFromInput } from '../lib/principals' +import { formatSender, principalKeyFromInput } from '../lib/principals' import { TimeText } from './TimeText' import type { ElectricUser } from '../lib/ElectricAgentsProvider' import styles from './CommentBubble.module.css' @@ -143,38 +143,3 @@ function ReplyPreview({ return
{content}
} - -function formatSender( - from: string | null | undefined, - options: { - currentPrincipal?: string - usersById?: Map - } = {} -): { - label: string - title?: string -} { - const key = principalKeyFromInput(from) - if (!key) return { label: from || `user` } - if (key === principalKeyFromInput(options.currentPrincipal)) { - return { label: `Me`, title: key } - } - const colon = key.indexOf(`:`) - if (colon <= 0) return { label: key, title: key } - const kind = key.slice(0, colon) - const id = key.slice(colon + 1) - if (kind === `user`) { - const user = options.usersById?.get(id) - const label = user?.display_name || user?.email - if (label) return { label, title: key } - } - return { - label: `${kind}:${formatPrincipalId(id)}`, - title: key, - } -} - -function formatPrincipalId(id: string): string { - if (id.length <= 18) return id - return `${id.slice(0, 8)}...${id.slice(-6)}` -} diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index 87ce812a07..c55c1382be 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -62,7 +62,11 @@ import { readTextPayload } from '../lib/sendMessage' import { principalKeyFromInput } from '../lib/principals' import styles from './EntityTimeline.module.css' import type { ElectricUser } from '../lib/ElectricAgentsProvider' -import type { SelectedCommentTarget, TimelineRow } from '../lib/comments' +import type { + SelectedCommentTarget, + TimelineRow, + TimelineRowAdjacency, +} from '../lib/comments' import type { CommentTarget, EntityTimelineSection, @@ -77,10 +81,6 @@ import type { PaneFindAdapter, PaneFindMatch } from '../hooks/usePaneFind' type RenderTimelineRow = TimelineRow type WakeSection = Extract -export type TimelineRowAdjacency = { - previousRow?: TimelineRow - nextRow?: TimelineRow -} function renderRowKey(row: RenderTimelineRow): string { return row.$key diff --git a/packages/agents-server-ui/src/components/UserMessage.tsx b/packages/agents-server-ui/src/components/UserMessage.tsx index 726b4e9e31..7d85b5d688 100644 --- a/packages/agents-server-ui/src/components/UserMessage.tsx +++ b/packages/agents-server-ui/src/components/UserMessage.tsx @@ -20,7 +20,7 @@ import { } from './AttachmentImagePreviewDialog' import { TimeText } from './TimeText' import styles from './UserMessage.module.css' -import { principalKeyFromInput } from '../lib/principals' +import { formatSender } from '../lib/principals' import type { ElectricUser } from '../lib/ElectricAgentsProvider' type UserMessageSection = Extract< @@ -240,43 +240,3 @@ function AttachmentPreview({ ) } - -function formatSender( - from: string | null | undefined, - options: { - currentPrincipal?: string - usersById?: Map - } = {} -): { - label: string - title?: string -} { - const key = principalKeyFromInput(from) - if (!key) return { label: from || `user` } - if (key === principalKeyFromInput(options.currentPrincipal)) { - return { label: `Me`, title: key } - } - const colon = key.indexOf(`:`) - if (colon <= 0) return { label: key, title: key } - const kind = key.slice(0, colon) - const id = key.slice(colon + 1) - if (kind === `user`) { - const user = options.usersById?.get(id) - const label = userDisplayName(user) - if (label) return { label, title: key } - } - return { - label: `${kind}:${formatPrincipalId(id)}`, - title: key, - } -} - -function formatPrincipalId(id: string): string { - if (id.length <= 18) return id - return `${id.slice(0, 8)}…${id.slice(-6)}` -} - -function userDisplayName(user: ElectricUser | undefined): string | null { - if (!user) return null - return user.display_name || user.email || null -} diff --git a/packages/agents-server-ui/src/components/views/ChatView.test.ts b/packages/agents-server-ui/src/components/views/ChatView.test.ts deleted file mode 100644 index 9aac3826a0..0000000000 --- a/packages/agents-server-ui/src/components/views/ChatView.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildCommentsTimeline, - commentFocusViewParams, - decodeCommentTargetParam, -} from './ChatView' -import type { CommentTarget } from '@electric-ax/agents-runtime/client' -import type { TimelineRow } from '../../lib/comments' - -function commentRow( - key: string, - fromPrincipal = `/principal/user%3Ame` -): TimelineRow { - return { - $key: `comment:${key}`, - comment: { - key, - order: key, - body: key, - from: fromPrincipal, - timestamp: `2026-04-15T18:00:00.000Z`, - }, - } as TimelineRow -} - -function wakeRow(key: string): TimelineRow { - return { - $key: `wake:${key}`, - wake: { - key, - order: key, - payload: { - type: `wake`, - timestamp: `2026-04-15T18:00:00.000Z`, - source: `/chat/test`, - timeout: false, - changes: [], - }, - }, - } as TimelineRow -} - -function attachmentRow(key: string): TimelineRow { - return { - $key: `manifest:${key}`, - manifest: { - key, - kind: `attachment`, - id: key, - streamPath: `/chat/test/attachments/${key}`, - status: `complete`, - subject: { type: `inbox`, key: `msg-1` }, - mimeType: `text/plain`, - byteLength: 12, - createdAt: `2026-04-15T18:00:00.000Z`, - }, - } as TimelineRow -} - -describe(`buildCommentsTimeline`, () => { - it(`keeps comments in stream order while using full-timeline adjacency`, () => { - const first = commentRow(`first`) - const wake = wakeRow(`wake-1`) - const second = commentRow(`second`) - const third = commentRow(`third`) - const attachment = attachmentRow(`att-1`) - const fourth = commentRow(`fourth`) - - const timeline = buildCommentsTimeline([ - first, - wake, - second, - third, - attachment, - fourth, - ]) - - expect(timeline.rows.map((row) => row.comment?.key)).toEqual([ - `first`, - `second`, - `third`, - `fourth`, - ]) - expect(timeline.adjacency[0]).toEqual({ - previousRow: undefined, - nextRow: wake, - }) - expect(timeline.adjacency[1]).toEqual({ - previousRow: wake, - nextRow: third, - }) - expect(timeline.adjacency[2]).toEqual({ - previousRow: second, - nextRow: fourth, - }) - expect(timeline.adjacency[3]).toEqual({ - previousRow: third, - }) - }) -}) - -describe(`comment focus view params`, () => { - it(`round-trips timeline targets for comments-view navigation`, () => { - const target: CommentTarget = { - kind: `timeline`, - collection: `tool_call`, - key: `tool-call-1`, - run_id: `run-1`, - } - - const params = commentFocusViewParams(target) - - expect(decodeCommentTargetParam(params.focus)).toEqual(target) - }) - - it(`rejects invalid encoded target collections`, () => { - const encoded = encodeURIComponent( - JSON.stringify({ - kind: `timeline`, - collection: `unknown`, - key: `thing-1`, - }) - ) - - expect(decodeCommentTargetParam(encoded)).toBeNull() - }) -}) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 24d872b14a..d7c3b7e7ff 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -3,13 +3,18 @@ import { useNavigate } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useEntityTimeline } from '../../hooks/useEntityTimeline' import { useForkFromHere } from '../../hooks/useForkFromHere' -import { EntityTimeline, type TimelineRowAdjacency } from '../EntityTimeline' +import { EntityTimeline } from '../EntityTimeline' import { MessageInput } from '../MessageInput' import { EntityContextDrawer } from '../EntityContextDrawer' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' import { useWorkspace } from '../../hooks/useWorkspace' -import { isAttachmentManifest } from '../../lib/attachments' import { schemaModelSupportsImageInput } from '../../lib/modelCapabilities' +import { + buildCommentsTimeline, + COMMENT_FOCUS_PARAM, + commentFocusViewParams, + decodeCommentTargetParam, +} from '../../lib/comments' import type { SelectedCommentTarget, TimelineRow } from '../../lib/comments' import { useEntityPermission, @@ -26,94 +31,6 @@ const CHAT_VIEW_PERMISSIONS: ReadonlyArray = [ `signal`, `fork`, ] -const COMMENT_FOCUS_PARAM = `focus` -const COMMENT_TARGET_COLLECTIONS = new Set([ - `inbox`, - `run`, - `text`, - `tool_call`, - `wake`, - `signal`, - `manifest`, -]) - -export function encodeCommentTargetParam(target: CommentTarget): string { - return encodeURIComponent(JSON.stringify(target)) -} - -export function decodeCommentTargetParam( - value: string | undefined -): CommentTarget | null { - if (!value) return null - try { - const decoded = JSON.parse(decodeURIComponent(value)) as unknown - if (!isCommentTarget(decoded)) return null - return decoded - } catch { - return null - } -} - -export function commentFocusViewParams( - target: CommentTarget -): Record { - return { [COMMENT_FOCUS_PARAM]: encodeCommentTargetParam(target) } -} - -function isCommentTarget(value: unknown): value is CommentTarget { - if (!value || typeof value !== `object`) return false - const target = value as Partial - if (target.kind === `comment`) { - return typeof target.key === `string` - } - if (target.kind !== `timeline`) return false - const timelineTarget = target as Partial< - Extract - > - return ( - typeof timelineTarget.key === `string` && - typeof timelineTarget.collection === `string` && - COMMENT_TARGET_COLLECTIONS.has(timelineTarget.collection) && - (timelineTarget.run_id === undefined || - typeof timelineTarget.run_id === `string`) - ) -} - -export function buildCommentsTimeline(timelineRows: Array): { - rows: Array - adjacency: Array -} { - const rows: Array = [] - const adjacency: Array = [] - let previousRenderableRow: TimelineRow | undefined - let pendingCommentAdjacencyIndex: number | null = null - - for (const row of timelineRows) { - if (isAttachmentManifest(row.manifest)) continue - - if (pendingCommentAdjacencyIndex !== null) { - const pendingAdjacency = adjacency[pendingCommentAdjacencyIndex]! - adjacency[pendingCommentAdjacencyIndex] = { - ...pendingAdjacency, - nextRow: row, - } - pendingCommentAdjacencyIndex = null - } - - if (row.comment) { - rows.push(row) - adjacency.push({ - previousRow: previousRenderableRow, - }) - pendingCommentAdjacencyIndex = adjacency.length - 1 - } - - previousRenderableRow = row - } - - return { rows, adjacency } -} - /** * The default view: chat / timeline + message composer. * diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts index 01d57877d3..d81ef03409 100644 --- a/packages/agents-server-ui/src/lib/comments.test.ts +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -4,15 +4,18 @@ import { compareTimelineOrders } from '@electric-ax/agents-runtime/client' import { createLiveQueryCollection } from '@durable-streams/state/db' import { registerActiveServerHeaders } from './auth-fetch' import { + buildCommentsTimeline, + commentFocusViewParams, createCommentsTimelineSource, createSendCommentAction, + decodeCommentTargetParam, } from './comments' import type { CommentSnapshot, CommentTarget, EntityStreamDBWithActions, } from '@electric-ax/agents-runtime/client' -import type { OptimisticComment } from './comments' +import type { OptimisticComment, TimelineRow } from './comments' function createCommentsDb() { const comments = createCollection( @@ -183,3 +186,122 @@ describe(`createSendCommentAction`, () => { await expect(tx.isPersisted.promise).rejects.toThrow(`No write access`) }) }) + +function commentRow( + key: string, + fromPrincipal = `/principal/user%3Ame` +): TimelineRow { + return { + $key: `comment:${key}`, + comment: { + key, + order: key, + body: key, + from: fromPrincipal, + timestamp: `2026-04-15T18:00:00.000Z`, + }, + } as TimelineRow +} + +function wakeRow(key: string): TimelineRow { + return { + $key: `wake:${key}`, + wake: { + key, + order: key, + payload: { + type: `wake`, + timestamp: `2026-04-15T18:00:00.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }, + }, + } as TimelineRow +} + +function attachmentRow(key: string): TimelineRow { + return { + $key: `manifest:${key}`, + manifest: { + key, + kind: `attachment`, + id: key, + streamPath: `/chat/test/attachments/${key}`, + status: `complete`, + subject: { type: `inbox`, key: `msg-1` }, + mimeType: `text/plain`, + byteLength: 12, + createdAt: `2026-04-15T18:00:00.000Z`, + }, + } as TimelineRow +} + +describe(`buildCommentsTimeline`, () => { + it(`keeps comments in stream order while using full-timeline adjacency`, () => { + const first = commentRow(`first`) + const wake = wakeRow(`wake-1`) + const second = commentRow(`second`) + const third = commentRow(`third`) + const attachment = attachmentRow(`att-1`) + const fourth = commentRow(`fourth`) + + const timeline = buildCommentsTimeline([ + first, + wake, + second, + third, + attachment, + fourth, + ]) + + expect(timeline.rows.map((row) => row.comment?.key)).toEqual([ + `first`, + `second`, + `third`, + `fourth`, + ]) + expect(timeline.adjacency[0]).toEqual({ + previousRow: undefined, + nextRow: wake, + }) + expect(timeline.adjacency[1]).toEqual({ + previousRow: wake, + nextRow: third, + }) + expect(timeline.adjacency[2]).toEqual({ + previousRow: second, + nextRow: fourth, + }) + expect(timeline.adjacency[3]).toEqual({ + previousRow: third, + }) + }) +}) + +describe(`comment focus view params`, () => { + it(`round-trips timeline targets for comments-view navigation`, () => { + const target: CommentTarget = { + kind: `timeline`, + collection: `tool_call`, + key: `tool-call-1`, + run_id: `run-1`, + } + + const params = commentFocusViewParams(target) + + expect(decodeCommentTargetParam(params.focus)).toEqual(target) + }) + + it(`rejects invalid encoded target collections`, () => { + const encoded = encodeURIComponent( + JSON.stringify({ + kind: `timeline`, + collection: `unknown`, + key: `thing-1`, + }) + ) + + expect(decodeCommentTargetParam(encoded)).toBeNull() + }) +}) diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index bd99e84a41..1644880891 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -5,6 +5,7 @@ import { TIMELINE_ORDER_FALLBACK, } from '@electric-ax/agents-runtime/client' import { getActivePrincipal, serverFetch } from './auth-fetch' +import { isAttachmentManifest } from './attachments' import { entityApiUrl } from './entity-api' import type { CommentSnapshot, @@ -16,8 +17,8 @@ import type { /** * Comments are a UI-level concern: the runtime timeline query knows nothing - * about them. `useEntityTimeline` reads the `comments` collection directly - * and merges these rows into the timeline with `mergeCommentRows`. + * about them. `useEntityTimeline` merges them in by passing + * `createCommentsTimelineSource` as an extra timeline source. */ export type EntityTimelineCommentRow = { key: string @@ -192,3 +193,101 @@ export function createSendCommentAction({ }) } } + +export const COMMENT_FOCUS_PARAM = `focus` + +const COMMENT_TARGET_COLLECTIONS = new Set([ + `inbox`, + `run`, + `text`, + `tool_call`, + `wake`, + `signal`, + `manifest`, +]) + +export function encodeCommentTargetParam(target: CommentTarget): string { + return encodeURIComponent(JSON.stringify(target)) +} + +export function decodeCommentTargetParam( + value: string | undefined +): CommentTarget | null { + if (!value) return null + try { + const decoded = JSON.parse(decodeURIComponent(value)) as unknown + if (!isCommentTarget(decoded)) return null + return decoded + } catch { + return null + } +} + +export function commentFocusViewParams( + target: CommentTarget +): Record { + return { [COMMENT_FOCUS_PARAM]: encodeCommentTargetParam(target) } +} + +function isCommentTarget(value: unknown): value is CommentTarget { + if (!value || typeof value !== `object`) return false + const target = value as Partial + if (target.kind === `comment`) { + return typeof target.key === `string` + } + if (target.kind !== `timeline`) return false + const timelineTarget = target as Partial< + Extract + > + return ( + typeof timelineTarget.key === `string` && + typeof timelineTarget.collection === `string` && + COMMENT_TARGET_COLLECTIONS.has(timelineTarget.collection) && + (timelineTarget.run_id === undefined || + typeof timelineTarget.run_id === `string`) + ) +} + +export type TimelineRowAdjacency = { + previousRow?: TimelineRow + nextRow?: TimelineRow +} + +/** + * Comment-only timeline: keeps just the comment rows, recording each one's + * neighboring renderable rows so the view can show surrounding context. + */ +export function buildCommentsTimeline(timelineRows: Array): { + rows: Array + adjacency: Array +} { + const rows: Array = [] + const adjacency: Array = [] + let previousRenderableRow: TimelineRow | undefined + let pendingCommentAdjacencyIndex: number | null = null + + for (const row of timelineRows) { + if (isAttachmentManifest(row.manifest)) continue + + if (pendingCommentAdjacencyIndex !== null) { + const pendingAdjacency = adjacency[pendingCommentAdjacencyIndex]! + adjacency[pendingCommentAdjacencyIndex] = { + ...pendingAdjacency, + nextRow: row, + } + pendingCommentAdjacencyIndex = null + } + + if (row.comment) { + rows.push(row) + adjacency.push({ + previousRow: previousRenderableRow, + }) + pendingCommentAdjacencyIndex = adjacency.length - 1 + } + + previousRenderableRow = row + } + + return { rows, adjacency } +} diff --git a/packages/agents-server-ui/src/lib/principals.ts b/packages/agents-server-ui/src/lib/principals.ts index b4995e73a3..4d37d67279 100644 --- a/packages/agents-server-ui/src/lib/principals.ts +++ b/packages/agents-server-ui/src/lib/principals.ts @@ -1,3 +1,5 @@ +import type { ElectricUser } from './ElectricAgentsProvider' + export function principalUrlFromKey(principalKey: string): string { const trimmed = principalKey.trim() return trimmed.startsWith(`/principal/`) @@ -39,3 +41,48 @@ export function userIdFromPrincipal( const key = principalKeyFromInput(value) return key?.startsWith(`user:`) ? key.slice(`user:`.length) : null } + +/** + * Display label for a message/comment sender principal: "Me" for the current + * principal, the user's display name when known, otherwise `kind:id` with a + * truncated id. `title` always carries the full principal key for tooltips. + */ +export function formatSender( + from: string | null | undefined, + options: { + currentPrincipal?: string + usersById?: Map + } = {} +): { + label: string + title?: string +} { + const key = principalKeyFromInput(from) + if (!key) return { label: from || `user` } + if (key === principalKeyFromInput(options.currentPrincipal)) { + return { label: `Me`, title: key } + } + const colon = key.indexOf(`:`) + if (colon <= 0) return { label: key, title: key } + const kind = key.slice(0, colon) + const id = key.slice(colon + 1) + if (kind === `user`) { + const user = options.usersById?.get(id) + const label = userDisplayName(user) + if (label) return { label, title: key } + } + return { + label: `${kind}:${formatPrincipalId(id)}`, + title: key, + } +} + +export function userDisplayName(user: ElectricUser | undefined): string | null { + if (!user) return null + return user.display_name || user.email || null +} + +function formatPrincipalId(id: string): string { + if (id.length <= 18) return id + return `${id.slice(0, 8)}…${id.slice(-6)}` +} From a8df99a04444a8ecdfc1c9acc0efc254d485b61f Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 12:34:00 +0100 Subject: [PATCH 27/35] fix(agents): tolerate legacy principalColumn in registration, misc review cleanups The entity-type schema accepts and ignores principalColumn so registration survives version skew with older runtimes. Also: remove the double cast on the UI customState merge, use randomUUID for server-generated collection keys, re-attach virtual columns for async schema validators, and drop the implementation plan doc. Co-Authored-By: Claude Fable 5 --- ...2026-06-10-generic-writable-collections.md | 1232 ----------------- .../agents-runtime/src/entity-stream-db.ts | 12 +- .../src/lib/entity-connection.ts | 16 +- packages/agents-server/src/entity-manager.ts | 4 +- .../src/routing/entity-types-router.ts | 8 +- 5 files changed, 24 insertions(+), 1248 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-10-generic-writable-collections.md diff --git a/docs/superpowers/plans/2026-06-10-generic-writable-collections.md b/docs/superpowers/plans/2026-06-10-generic-writable-collections.md deleted file mode 100644 index b9e23a1209..0000000000 --- a/docs/superpowers/plans/2026-06-10-generic-writable-collections.md +++ /dev/null @@ -1,1232 +0,0 @@ -# Generic Writable Custom Collections Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Reach PR #4529's comments feature through a generic, extensible custom-collection interface — opt-in router-writable entity-state collections with authenticated, schema-validated writes whose principal is stamped into the change-event header and materialized into a virtual column. - -**Architecture:** Three layers. (A) **Runtime**: a `writable` flag on the entity `CollectionDefinition`, a header→virtual-column projection in the entity stream DB, and a `writable_collections` registration map. (B) **Server**: storage of `writable_collections` on the entity type, a generic `EntityManager.writeCollection` method, and a `POST /:type/:instanceId/collections/:collection` route that authenticates, stamps the principal header, and validates the payload via the existing `validateWriteEvent`. (C) **Comments as a consumer**: comments declared as a custom `state` collection on the Horton and worker entity definitions, with the #4529 UI cloned verbatim and re-sourced onto the generic collection. - -**Tech Stack:** TypeScript, pnpm workspaces, Vitest, Zod / Standard Schema, TypeBox (`@sinclair/typebox` `Type.*`), TanStack DB, `@durable-streams/state`. - -**Spec:** `docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md` - -**Reference clone:** PR #4529 is cloned at `~/workspace/tmp-1` (branch `codex/session-comments`). Its full diff is at `/tmp/pr4529.diff`. Use these to lift UI files verbatim in Phase C. - ---- - -## File Structure - -**Phase A — Runtime (`packages/agents-runtime/src/`)** - -- Modify `types.ts` — add `writable` to `CollectionDefinition` (one responsibility: type definitions). -- Modify `entity-stream-db.ts` — build a `principalColumnByCollection` map and project `headers.principal` onto rows in both materialization paths. -- Modify `create-handler.ts` — emit `writable_collections` in the registration body. - -**Phase B — Server (`packages/agents-server/src/`)** - -- Modify `electric-agents-types.ts` — add `writable_collections` to `ElectricAgentsEntityType` + `RegisterEntityTypeRequest`. -- Modify `routing/entity-types-router.ts` — accept/normalize `writable_collections` in the register body and persist it. -- Modify `entity-manager.ts` — `registerEntityType` stores `writable_collections`; `getEffectiveSchemas` (rename usage) exposes effective `writable_collections`; new `writeCollection` method. -- Modify `routing/entities-router.ts` — `writeCollectionBodySchema`, the `/collections/:collection` route, and the `writeCollection` handler. - -**Phase C — Comments consumer** - -- Create `packages/agents-runtime/src/comments-collection.ts` — comment Zod schema, `Comment` types, and the reusable `commentsCollection` definition. (Replaces the hardcoded comment schema removed from `entity-schema.ts`.) -- Modify `packages/agents-runtime/src/entity-schema.ts` — remove the hardcoded `comments` built-in collection. -- Modify `packages/agents-runtime/src/index.ts` — export the comments-collection module. -- Modify `packages/agents-runtime/src/entity-timeline.ts` — project the custom `comments` collection using `_principal`. -- Modify `packages/agents/src/agents/horton.ts` and `worker.ts` — declare `state: { comments: commentsCollection }`. -- Modify `packages/agents-server-ui/src/...` — clone the #4529 UI and re-source onto the generic collection. - ---- - -## Phase A — Runtime generic interface - -### Task A1: Add `writable` to `CollectionDefinition` - -**Files:** - -- Modify: `packages/agents-runtime/src/types.ts:632-642` - -- [ ] **Step 1: Add the field** - -In `packages/agents-runtime/src/types.ts`, extend the `CollectionDefinition` interface (currently lines 632-642) so it reads: - -```ts -export interface CollectionDefinition< - TSchema extends StandardSchemaV1 | undefined = - | StandardSchemaV1 - | undefined, -> { - schema?: TSchema - /** Event type string used in the durable stream (e.g. `"counter_value"`). Defaults to `"state:${name}"`. */ - type?: string - /** Primary key field name. Defaults to `"key"`. */ - primaryKey?: string - /** - * Opt-in for HTTP-router writes via `POST /:type/:instanceId/collections/:name`. - * Absent/false ⇒ collection is agent-only and the endpoint rejects writes. - * `true` ⇒ writable; the principal is materialized into the `_principal` column. - * Object form lets a collection rename that virtual column. - */ - writable?: boolean | { principalColumn?: string } -} -``` - -- [ ] **Step 2: Typecheck** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS (purely additive optional field). - -- [ ] **Step 3: Commit** - -```bash -git add packages/agents-runtime/src/types.ts -git commit -m "feat(agents-runtime): add writable flag to CollectionDefinition" -``` - ---- - -### Task A2: Project `headers.principal` into a virtual column - -The entity stream DB injects synthetic fields into `event.value` before materialization in two places: the wire-batch path (`onBeforeBatch`, ~lines 325-356) and the in-process `applyEvent` path (~lines 711-730). We add a parallel `principalColumnByCollection` map and inject `headers.principal` the same way `_timeline_order` is injected. - -**Files:** - -- Modify: `packages/agents-runtime/src/entity-stream-db.ts` -- Test: `packages/agents-runtime/test/entity-stream-db-principal.test.ts` (create) - -- [ ] **Step 1: Write the failing test** - -Create `packages/agents-runtime/test/entity-stream-db-principal.test.ts`. (Model the harness on the existing `packages/agents-runtime/test/entity-timeline.test.ts` — read it first for how a stream DB is constructed and how batches are delivered.) The test declares a writable custom collection and asserts the principal header lands in the configured column while a non-writable collection does not get the column: - -```ts -import { describe, it, expect } from 'vitest' -import { z } from 'zod' -import { createEntityStreamDB } from '../src/entity-stream-db' - -function principalHeader() { - return { url: `/principal/user%3Aalice`, kind: `user`, id: `alice` } -} - -describe(`entity-stream-db principal virtual column`, () => { - it(`projects headers.principal onto the configured column for writable collections`, () => { - const db = createEntityStreamDB(`/chat/sess-1`, { - comments: { - schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: { principalColumn: `_principal` }, - }, - }) - - db.utils.applyEvent({ - type: `state:comments`, - key: `c1`, - headers: { operation: `insert`, principal: principalHeader() }, - value: { body: `hi` }, - } as any) - - const row = db.collections.comments.get(`c1`) as Record - expect(row.body).toBe(`hi`) - expect(row._principal).toEqual(principalHeader()) - }) - - it(`does not add a principal column when the collection is not writable`, () => { - const db = createEntityStreamDB(`/chat/sess-2`, { - notes: { - schema: z.object({ key: z.string().optional(), body: z.string() }), - }, - }) - - db.utils.applyEvent({ - type: `state:notes`, - key: `n1`, - headers: { operation: `insert`, principal: principalHeader() }, - value: { body: `hi` }, - } as any) - - const row = db.collections.notes.get(`n1`) as Record - expect(row._principal).toBeUndefined() - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-stream-db-principal.test.ts` -Expected: FAIL — first test's `row._principal` is `undefined`. - -- [ ] **Step 3: Build the `principalColumnByCollection` map** - -In `packages/agents-runtime/src/entity-stream-db.ts`, inside `createEntityStreamDB`, in the loop that converts `customState` (currently lines ~131-138, the `for (const [name, def] of Object.entries(customState))` block), capture the principal column. Add a map declaration just above the loop and populate it: - -```ts -const streamCustomState: Record = {} -const principalColumnByCollection = new Map() -if (customState) { - for (const [name, def] of Object.entries(customState)) { - streamCustomState[name] = { - schema: def.schema ?? passthrough(), - type: def.type ?? `state:${name}`, - primaryKey: def.primaryKey ?? `key`, - } - if (def.writable) { - principalColumnByCollection.set( - name, - def.writable === true - ? `_principal` - : (def.writable.principalColumn ?? `_principal`) - ) - } - } -} -``` - -- [ ] **Step 4: Inject in the wire-batch path** - -In the `onBeforeBatch` handler, immediately after the `_timeline_order` injection block (currently ending at line 356 `;(item.value as Record)._timeline_order = order`), add: - -```ts -const principalColumn = principalColumnByCollection.get(collectionName) -if (principalColumn) { - const principal = (item.headers as Record).principal - if (principal !== undefined) { - ;(item.value as Record)[principalColumn] = principal - } -} -``` - -- [ ] **Step 5: Inject in the `applyEvent` path** - -In `applyEvent`, after the `_timeline_order` injection (currently ending at line 729 `;(event.value as Record)._timeline_order = order`) — and still inside the `if (event.headers.operation !== 'delete' && ...)` block — add: - -```ts -const principalColumn = principalColumnByCollection.get(collectionName) -if (principalColumn) { - const principal = (event.headers as Record).principal - if (principal !== undefined) { - ;(event.value as Record)[principalColumn] = principal - } -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-stream-db-principal.test.ts` -Expected: PASS (both tests). - -- [ ] **Step 7: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS - -```bash -git add packages/agents-runtime/src/entity-stream-db.ts packages/agents-runtime/test/entity-stream-db-principal.test.ts -git commit -m "feat(agents-runtime): materialize principal header into virtual column" -``` - ---- - -### Task A3: Emit `writable_collections` at registration - -**Files:** - -- Modify: `packages/agents-runtime/src/create-handler.ts:488-519` -- Test: `packages/agents-runtime/test/create-handler-writable.test.ts` (create) - -- [ ] **Step 1: Write the failing test** - -Create `packages/agents-runtime/test/create-handler-writable.test.ts`. The registration body is computed per entity type from `definition.state`. Read `packages/agents-runtime/src/create-handler.ts` around lines 484-525 first to see how `types`, `serveEndpoint`, and the POST are wired, then write a focused unit test that calls the same body-building logic. If the body-building is inline (not exported), extract it into a small exported pure helper `buildEntityTypeRegistrationBody(name, definition)` as part of this task and test that: - -```ts -import { describe, it, expect } from 'vitest' -import { z } from 'zod' -import { buildEntityTypeRegistrationBody } from '../src/create-handler' - -describe(`buildEntityTypeRegistrationBody`, () => { - it(`emits writable_collections for writable state collections only`, () => { - const body = buildEntityTypeRegistrationBody(`chat`, { - description: `chat`, - handler: async () => {}, - state: { - comments: { - schema: z.object({ key: z.string().optional(), body: z.string() }), - writable: { principalColumn: `_principal` }, - }, - scratch: { - schema: z.object({ key: z.string().optional(), note: z.string() }), - }, - }, - } as any) - - expect(body.writable_collections).toEqual({ - comments: { type: `state:comments`, principalColumn: `_principal` }, - }) - }) - - it(`omits writable_collections when no collection opts in`, () => { - const body = buildEntityTypeRegistrationBody(`chat`, { - description: `chat`, - handler: async () => {}, - state: { - scratch: { schema: z.object({ note: z.string() }) }, - }, - } as any) - expect(body.writable_collections).toBeUndefined() - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/create-handler-writable.test.ts` -Expected: FAIL — `buildEntityTypeRegistrationBody` not exported / `writable_collections` undefined. - -- [ ] **Step 3: Extract + extend the body builder** - -In `packages/agents-runtime/src/create-handler.ts`, extract the per-type body construction (currently inline at ~lines 488-519) into an exported pure function above the registration loop, and compute `writable_collections` alongside `state_schemas`: - -```ts -export function buildEntityTypeRegistrationBody( - name: string, - definition: AnyEntityDefinition -): Record { - const stateEntries = definition.state ? Object.entries(definition.state) : [] - - const stateSchemas = Object.fromEntries( - stateEntries.map(([collectionName, def]) => [ - def.type ?? `state:${collectionName}`, - toJsonSchema(def.schema ?? passthrough()), - ]) - ) - - const writableCollections: Record< - string, - { type: string; principalColumn: string } - > = {} - for (const [collectionName, def] of stateEntries) { - if (!def.writable) continue - writableCollections[collectionName] = { - type: def.type ?? `state:${collectionName}`, - principalColumn: - def.writable === true - ? `_principal` - : (def.writable.principalColumn ?? `_principal`), - } - } - - const body: Record = { - name, - description: definition.description ?? `${name} entity`, - ...(definition.creationSchema && { - creation_schema: toJsonSchema(definition.creationSchema), - }), - ...(definition.inboxSchemas && { - inbox_schemas: mapSchemas(definition.inboxSchemas), - }), - ...(definition.slashCommands && { - slash_commands: definition.slashCommands, - }), - state_schemas: { - ...DEFAULT_STATE_SCHEMAS, - ...stateSchemas, - ...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}), - }, - ...(Object.keys(writableCollections).length > 0 && { - writable_collections: writableCollections, - }), - ...(definition.permissionGrants && { - permission_grants: definition.permissionGrants, - }), - } - return body -} -``` - -Then in the registration loop, replace the inline body construction with `const body = buildEntityTypeRegistrationBody(name, definition)` and keep the subsequent mutations (`body.serve_endpoint = …`, etc.) as they are. Note: `mapSchemas`, `toJsonSchema`, `passthrough`, `DEFAULT_STATE_SCHEMAS` are already in scope in this file — keep using the existing references. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/create-handler-writable.test.ts` -Expected: PASS - -- [ ] **Step 5: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS - -```bash -git add packages/agents-runtime/src/create-handler.ts packages/agents-runtime/test/create-handler-writable.test.ts -git commit -m "feat(agents-runtime): emit writable_collections at entity-type registration" -``` - ---- - -## Phase B — Server generic interface - -### Task B1: Store `writable_collections` on the entity type - -**Files:** - -- Modify: `packages/agents-server/src/electric-agents-types.ts:500-520` (`ElectricAgentsEntityType`, `RegisterEntityTypeRequest`) - -- [ ] **Step 1: Add the type** - -In `packages/agents-server/src/electric-agents-types.ts`, define a shared shape and add the field to both `ElectricAgentsEntityType` and `RegisterEntityTypeRequest`: - -```ts -export interface WritableCollectionConfig { - /** Durable-stream event type for this collection, e.g. `state:comments`. */ - type: string - /** Row column the client materializes the principal header into. */ - principalColumn: string -} -``` - -Add to `ElectricAgentsEntityType` (after `state_schemas?`): - -```ts - writable_collections?: Record -``` - -Add the identical optional field to `RegisterEntityTypeRequest`. - -- [ ] **Step 2: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-server run typecheck` -Expected: PASS - -```bash -git add packages/agents-server/src/electric-agents-types.ts -git commit -m "feat(agents-server): add writable_collections to entity type" -``` - ---- - -### Task B2: Accept, persist, and resolve `writable_collections` - -**Files:** - -- Modify: `packages/agents-server/src/routing/entity-types-router.ts:47,83-97,448-...` (body schema + normalize) -- Modify: `packages/agents-server/src/entity-manager.ts:432-499` (`registerEntityType` stores it), `3871-3892` (`getEffectiveSchemas` → also return effective `writable_collections`) -- Test: `packages/agents-server/test/electric-agents-routes.test.ts` (extend) - -- [ ] **Step 1: Write the failing test** - -In `packages/agents-server/test/electric-agents-routes.test.ts`, add a test that registering an entity type with `writable_collections` round-trips it through the manager. Read the existing register-entity-type tests in that file first for the `routeResponse` / mock-manager harness, then add: - -```ts -it(`persists writable_collections on entity type registration`, async () => { - const registerEntityType = vi.fn().mockResolvedValue({ - name: `chat`, - description: `chat`, - revision: 1, - created_at: `t`, - updated_at: `t`, - writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, - }, - }) - const manager = { - registry: { getEntityType: vi.fn() }, - registerEntityType, - } as any - - const response = await routeResponse( - manager, - `POST`, - `/_electric/entity-types`, - { - name: `chat`, - description: `chat`, - writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, - }, - } - ) - - expect(response.status).toBe(201) - expect(registerEntityType).toHaveBeenCalledWith( - expect.objectContaining({ - writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, - }, - }) - ) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t writable_collections` -Expected: FAIL — `additionalProperties: false` rejects `writable_collections`, or it is dropped by normalize. - -- [ ] **Step 3: Add the body schema + normalize** - -In `packages/agents-server/src/routing/entity-types-router.ts`, near `schemaMapSchema` (line 47) add: - -```ts -const writableCollectionsSchema = Type.Record( - Type.String(), - Type.Object( - { - type: Type.String(), - principalColumn: Type.String(), - }, - { additionalProperties: false } - ) -) -``` - -Add `writable_collections: Type.Optional(writableCollectionsSchema),` to `registerEntityTypeBodySchema` (lines 83-97). In `normalizeEntityTypeRequest` (line 448), thread the field through onto the normalized request object (follow how `state_schemas` is carried). In `registerEntityType` (line ~173, where the normalized request is passed to `ctx.entityManager.registerEntityType`), ensure `writable_collections` is included — it already will be if `normalizeEntityTypeRequest` carries it. - -- [ ] **Step 4: Store it in the manager** - -In `packages/agents-server/src/entity-manager.ts`, in `registerEntityType` (lines 432-499), copy the field onto the stored entity type object next to `state_schemas: req.state_schemas`: - -```ts - writable_collections: req.writable_collections, -``` - -- [ ] **Step 5: Resolve effective writable_collections** - -In `getEffectiveSchemas` (lines 3871-3892), extend the return to include `writableCollections`, merging entity-level then entity-type-level the same additive way as `stateSchemas`: - -```ts - private async getEffectiveSchemas(entity: ElectricAgentsEntity): Promise<{ - inboxSchemas?: Record> - stateSchemas?: Record> - writableCollections?: Record - }> { - if (!entity.type) { - return { - inboxSchemas: entity.inbox_schemas, - stateSchemas: entity.state_schemas, - } - } - const latestType = await this.registry.getEntityType(entity.type) - return { - inboxSchemas: latestType?.inbox_schemas - ? { ...(entity.inbox_schemas ?? {}), ...latestType.inbox_schemas } - : entity.inbox_schemas, - stateSchemas: latestType?.state_schemas - ? { ...(entity.state_schemas ?? {}), ...latestType.state_schemas } - : entity.state_schemas, - writableCollections: latestType?.writable_collections, - } - } -``` - -Import `WritableCollectionConfig` from `./electric-agents-types` at the top of `entity-manager.ts`. - -- [ ] **Step 6: Run test to verify it passes** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t writable_collections` -Expected: PASS - -- [ ] **Step 7: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-server run typecheck` -Expected: PASS - -```bash -git add packages/agents-server/src/routing/entity-types-router.ts packages/agents-server/src/entity-manager.ts packages/agents-server/test/electric-agents-routes.test.ts -git commit -m "feat(agents-server): persist and resolve writable_collections" -``` - ---- - -### Task B3: `EntityManager.writeCollection` - -**Files:** - -- Modify: `packages/agents-server/src/entity-manager.ts` (new method near `createComment`'s old location / `send`, ~line 2285) -- Test: `packages/agents-server/test/electric-agents-manager-write-validation.test.ts` (extend) - -- [ ] **Step 1: Write the failing test** - -In `packages/agents-server/test/electric-agents-manager-write-validation.test.ts`, model on the existing `ElectricAgentsManager comments` describe block (it has a `decodeAppendEvent` helper and the `createAttachmentManager`/manager harness). Add a `writeCollection` describe block: - -```ts -describe(`ElectricAgentsManager.writeCollection`, () => { - it(`stamps the principal header and appends a generic collection insert`, async () => { - const append = vi.fn() - const { manager } = createAttachmentManager({ streamClient: { append } }) - // Make the entity type expose `comments` as writable with a passthrough schema. - manager.registry.getEntityType = vi.fn().mockResolvedValue({ - name: `chat`, - state_schemas: { 'state:comments': {} }, - writable_collections: { - comments: { type: `state:comments`, principalColumn: `_principal` }, - }, - }) - - const result = await manager.writeCollection( - `/chat/session-1`, - `comments`, - { - operation: `insert`, - key: `c1`, - value: { body: `hi` }, - principal: { - url: `/principal/user%3Aalice`, - kind: `user`, - id: `alice`, - }, - } - ) - - expect(result).toEqual({ key: `c1` }) - const event = decodeAppendEvent(append.mock.calls[0]?.[1]) - expect(event).toMatchObject({ - type: `state:comments`, - key: `c1`, - headers: { - operation: `insert`, - principal: { - url: `/principal/user%3Aalice`, - kind: `user`, - id: `alice`, - }, - }, - value: { body: `hi` }, - }) - expect(event.value.from_principal).toBeUndefined() - }) - - it(`rejects writes to a collection that is not writable`, async () => { - const append = vi.fn() - const { manager } = createAttachmentManager({ streamClient: { append } }) - manager.registry.getEntityType = vi.fn().mockResolvedValue({ - name: `chat`, - state_schemas: { 'state:notes': {} }, - writable_collections: {}, - }) - - await expect( - manager.writeCollection(`/chat/session-1`, `notes`, { - operation: `insert`, - value: { note: `x` }, - principal: { - url: `/principal/user%3Aalice`, - kind: `user`, - id: `alice`, - }, - }) - ).rejects.toMatchObject({ status: 403 }) - expect(append).not.toHaveBeenCalled() - }) -}) -``` - -(If `createAttachmentManager` does not let you set `registry.getEntityType`, set it on the returned `manager.registry` object after construction, as shown.) - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-manager-write-validation.test.ts -t writeCollection` -Expected: FAIL — `manager.writeCollection` is not a function. - -- [ ] **Step 3: Add the request types + method** - -In `packages/agents-server/src/entity-manager.ts`, near the other request interfaces (~line 135), add: - -```ts -export interface WriteCollectionPrincipal { - url: string - kind: string - id: string -} - -export interface WriteCollectionRequest { - operation: `insert` | `update` | `delete` - key?: string - value?: Record - principal: WriteCollectionPrincipal -} - -export interface WriteCollectionResult { - key: string -} -``` - -Add the method (place it next to `send`, ~line 2335, or where `createComment` lived): - -```ts - async writeCollection( - entityUrl: string, - collection: string, - req: WriteCollectionRequest - ): Promise { - const entity = await this.registry.getEntity(entityUrl) - if (!entity) { - throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404) - } - - const { writableCollections } = await this.getEffectiveSchemas(entity) - const config = writableCollections?.[collection] - if (!config) { - throw new ElectricAgentsError( - ErrCodeUnauthorized, - `Collection "${collection}" is not writable`, - 403 - ) - } - - if (rejectsNormalWrites(entity.status)) { - throw new ElectricAgentsError( - ErrCodeNotRunning, - `Entity is not accepting writes`, - 409 - ) - } - - if (req.operation !== `delete` && (req.value === undefined || req.value === null)) { - throw new ElectricAgentsError( - ErrCodeInvalidRequest, - `value is required for ${req.operation}`, - 400 - ) - } - if (req.operation !== `insert` && !req.key) { - throw new ElectricAgentsError( - ErrCodeInvalidRequest, - `key is required for ${req.operation}`, - 400 - ) - } - - const key = - req.key ?? - `${collection}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - - const event: Record = { - type: config.type, - key, - headers: { - operation: req.operation, - timestamp: new Date().toISOString(), - principal: req.principal, - }, - } - if (req.operation === `delete`) { - // delete validation reads old_value; we don't have it here, so omit. - } else { - event.value = req.value - } - - const validationError = await this.validateWriteEvent(entity, event) - if (validationError) { - throw new ElectricAgentsError( - validationError.code, - validationError.message, - validationError.status - ) - } - - const encoded = this.encodeChangeEvent(event) - try { - await this.streamClient.append(entity.streams.main, encoded) - } catch (err) { - if (this.isClosedStreamError(err)) { - throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is stopped`, 409) - } - throw err - } - - return { key } - } -``` - -Confirm the error-code identifiers (`ErrCodeNotFound`, `ErrCodeUnauthorized`, `ErrCodeNotRunning`, `ErrCodeInvalidRequest`) are already imported in this file (they are used by `createComment`/`send`); reuse them. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-manager-write-validation.test.ts -t writeCollection` -Expected: PASS (both tests). - -- [ ] **Step 5: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-server run typecheck` -Expected: PASS - -```bash -git add packages/agents-server/src/entity-manager.ts packages/agents-server/test/electric-agents-manager-write-validation.test.ts -git commit -m "feat(agents-server): generic writeCollection with principal-header stamping" -``` - ---- - -### Task B4: `/collections/:collection` route - -**Files:** - -- Modify: `packages/agents-server/src/routing/entities-router.ts` (body schema ~line 170, route ~line 421, handler ~line 1254) -- Test: `packages/agents-server/test/electric-agents-routes.test.ts` (extend) - -- [ ] **Step 1: Write the failing test** - -In `packages/agents-server/test/electric-agents-routes.test.ts`, model on the existing `comments endpoint` describe block and add: - -```ts -describe(`ElectricAgentsRoutes collections endpoint`, () => { - it(`routes a collection write to the manager with the authenticated principal`, async () => { - const manager = { - registry: { - getEntity: vi.fn().mockResolvedValue({ url: `/chat/test` }), - getEntityType: vi.fn(), - }, - ensurePrincipal: vi.fn().mockResolvedValue(undefined), - writeCollection: vi.fn().mockResolvedValue({ key: `c1` }), - } as any - - const response = await routeResponse( - manager, - `POST`, - `/_electric/entities/chat/test/collections/comments`, - { operation: `insert`, key: `c1`, value: { body: `hi` } } - ) - - expect(response.status).toBe(201) - expect(await responseJson(response)).toEqual({ key: `c1` }) - expect(manager.writeCollection).toHaveBeenCalledWith( - `/chat/test`, - `comments`, - expect.objectContaining({ - operation: `insert`, - key: `c1`, - value: { body: `hi` }, - principal: expect.objectContaining({ url: expect.any(String) }), - }) - ) - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t "collections endpoint"` -Expected: FAIL — route not found (404) / `writeCollection` not called. - -- [ ] **Step 3: Add the body schema** - -In `packages/agents-server/src/routing/entities-router.ts`, near `sendBodySchema` (line 167), add: - -```ts -const writeCollectionBodySchema = Type.Object( - { - operation: Type.Union([ - Type.Literal(`insert`), - Type.Literal(`update`), - Type.Literal(`delete`), - ]), - key: Type.Optional(Type.String()), - value: Type.Optional(Type.Record(Type.String(), Type.Unknown())), - }, - { additionalProperties: false } -) -``` - -Add the type alias near the others (line ~342): `type WriteCollectionBody = Static`. - -- [ ] **Step 4: Register the route** - -Near the `send` route registration (line ~403), add (the `:collection` param sits under a `collections/` segment so it cannot collide with sibling routes like `send`, `attachments`, `tags`): - -```ts -entitiesRouter.post( - `/:type/:instanceId/collections/:collection`, - withExistingEntity, - withSchema(writeCollectionBodySchema), - withEntityPermission(`write`), - writeCollection -) -``` - -- [ ] **Step 5: Add the handler** - -Near `sendEntity` / the old `createComment` handler (line ~1254), add. Read `sendEntity` first to mirror how `ctx.principal` and `requireExistingEntityRoute` are used: - -```ts -async function writeCollection( - request: AgentsRouteRequest, - ctx: TenantContext -): Promise { - const parsed = routeBody(request) - await ctx.entityManager.ensurePrincipal(ctx.principal) - const { entityUrl } = requireExistingEntityRoute(request) - const collection = request.params.collection - const result = await ctx.entityManager.writeCollection( - entityUrl, - collection, - { - operation: parsed.operation, - key: parsed.key, - value: parsed.value, - principal: { - url: ctx.principal.url, - kind: ctx.principal.kind, - id: ctx.principal.id, - }, - } - ) - return json(result, { status: parsed.operation === `insert` ? 201 : 200 }) -} -``` - -Confirm `ctx.principal` exposes `url`, `kind`, `id` (it does — see `principal.ts` / `sendEntity`). If `id` is not directly present, derive it the same way `sendEntity` builds the principal subject. - -- [ ] **Step 6: Run test to verify it passes** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run test/electric-agents-routes.test.ts -t "collections endpoint"` -Expected: PASS - -- [ ] **Step 7: Full server test run + typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-server exec vitest run` -Expected: PASS (no regressions) -Run: `pnpm --filter @electric-ax/agents-server run typecheck` -Expected: PASS - -```bash -git add packages/agents-server/src/routing/entities-router.ts packages/agents-server/test/electric-agents-routes.test.ts -git commit -m "feat(agents-server): POST /collections/:collection generic write route" -``` - ---- - -## Phase C — Comments as a consumer - -### Task C1: Comments collection module - -**Files:** - -- Create: `packages/agents-runtime/src/comments-collection.ts` -- Modify: `packages/agents-runtime/src/index.ts` -- Reference: `/tmp/pr4529.diff` (the `entity-schema.ts` hunk: `CommentValue`, `CommentTargetValue`, `CommentSnapshotValue`, `createCommentSchema`) - -- [ ] **Step 1: Create the module** - -Create `packages/agents-runtime/src/comments-collection.ts`. Port the comment value/target/snapshot Zod schemas from the #4529 `entity-schema.ts` hunk in `/tmp/pr4529.diff` (the `createCommentSchema`, `createCommentTargetSchema`, `createCommentSnapshotSchema` functions and their `*Value` types), but **drop the `from_principal` field** — provenance now comes from the `_principal` virtual column. Export the schema, the value types, and a ready-to-use collection definition: - -```ts -import { z } from 'zod' -import type { CollectionDefinition } from './types' - -// ... CommentTargetValue, CommentSnapshotValue, CommentValue types and their -// z schemas, ported from /tmp/pr4529.diff WITHOUT `from_principal` ... - -export const commentSchema = createCommentSchema() - -export const commentsCollection: CollectionDefinition = { - schema: commentSchema, - type: `state:comments`, - primaryKey: `key`, - writable: { principalColumn: `_principal` }, -} - -export type { CommentValue, CommentTargetValue, CommentSnapshotValue } -``` - -Keep `timelineOrderField` semantics: the row carries `_timeline_order` automatically (injected by the stream DB), so it does **not** belong in the user-facing schema. Do not add it to `commentSchema`. - -- [ ] **Step 2: Export from the barrel** - -In `packages/agents-runtime/src/index.ts`, add: `export * from './comments-collection'`. - -- [ ] **Step 3: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS - -```bash -git add packages/agents-runtime/src/comments-collection.ts packages/agents-runtime/src/index.ts -git commit -m "feat(agents-runtime): comments collection definition on generic interface" -``` - ---- - -### Task C2: Remove the hardcoded comments built-in collection - -**Files:** - -- Modify: `packages/agents-runtime/src/entity-schema.ts` (revert the #4529 additions if present, or confirm absent on this branch) - -> NOTE: This plan's branch (`vbalegas/custom-state`) does NOT contain #4529, so `entity-schema.ts` has **no** `comments` built-in collection to remove. If you are instead building on top of #4529, remove: the `Comment*` types, `createComment*Schema` functions, `BUILT_IN_EVENT_SCHEMAS.comment`, the `comments` entries in `ENTITY_COLLECTIONS` / `builtInCollections` / `EntityCollectionsDefinition`, and the `Comment*` exports. Verify against `/tmp/pr4529.diff`. - -- [ ] **Step 1: Confirm clean state** - -Run: `grep -n "comment" packages/agents-runtime/src/entity-schema.ts` -Expected on `vbalegas/custom-state`: no matches → nothing to remove; skip to Step 2. If matches exist (building on #4529), delete each per the note above. - -- [ ] **Step 2: Typecheck + commit (only if changes were made)** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS - -```bash -git add packages/agents-runtime/src/entity-schema.ts -git commit -m "refactor(agents-runtime): drop hardcoded comments built-in collection" -``` - ---- - -### Task C3: Declare comments state on Horton and worker - -**Files:** - -- Modify: `packages/agents/src/agents/horton.ts` (the `registry.define('horton', {...})` call, ~line 759) -- Modify: `packages/agents/src/agents/worker.ts` (the `registry.define('worker', {...})` call, line 303) -- Test: `packages/agents/test/...` (extend an existing horton/worker registration test if present; otherwise create `packages/agents/test/comments-collection-registration.test.ts`) - -- [ ] **Step 1: Write the failing test** - -Check for an existing registration test: `ls packages/agents/test | grep -i "horton\|worker\|register"`. If one exercises the registry, extend it; otherwise create `packages/agents/test/comments-collection-registration.test.ts` that registers Horton into a fresh registry and asserts the definition declares a writable `comments` state collection: - -```ts -import { describe, it, expect } from 'vitest' -import { - createEntityRegistry, - getEntityType, -} from '@electric-ax/agents-runtime' -import { registerHorton } from '../src/agents/horton' -// import the model catalog the existing tests use; mirror their setup. - -describe(`comments collection registration`, () => { - it(`declares comments as a writable state collection on horton`, () => { - const registry = createEntityRegistry() - registerHorton(registry, { - workingDirectory: `/tmp`, - modelCatalog: - /* the test model catalog used elsewhere */ undefined as any, - }) - const def = getEntityType(`horton`)?.definition as any - expect(def.state?.comments?.writable).toEqual({ - principalColumn: `_principal`, - }) - }) -}) -``` - -(Read an existing `packages/agents` test to copy the exact `modelCatalog` test fixture; do not invent one.) - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm --filter @electric-ax/agents exec vitest run test/comments-collection-registration.test.ts` -Expected: FAIL — `def.state` is undefined. - -- [ ] **Step 3: Add the state to Horton** - -In `packages/agents/src/agents/horton.ts`, import the collection at the top: `import { commentsCollection } from '@electric-ax/agents-runtime'`. In the `registry.define('horton', { ... })` object (~line 759), add a `state` field: - -```ts - state: { - comments: commentsCollection, - }, -``` - -- [ ] **Step 4: Add the state to worker** - -In `packages/agents/src/agents/worker.ts`, import `commentsCollection` from `@electric-ax/agents-runtime` and add the same `state: { comments: commentsCollection }` to the `registry.define('worker', { ... })` object (line 303). - -- [ ] **Step 5: Run test to verify it passes** - -Run: `pnpm --filter @electric-ax/agents exec vitest run test/comments-collection-registration.test.ts` -Expected: PASS - -- [ ] **Step 6: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents run typecheck` -Expected: PASS - -```bash -git add packages/agents/src/agents/horton.ts packages/agents/src/agents/worker.ts packages/agents/test/comments-collection-registration.test.ts -git commit -m "feat(agents): declare comments as a writable state collection on horton and worker" -``` - ---- - -### Task C4: Project the comments collection into the timeline - -**Files:** - -- Modify: `packages/agents-runtime/src/entity-timeline.ts` -- Test: `packages/agents-runtime/test/entity-timeline.test.ts` (extend with the #4529 comment cases) -- Reference: `/tmp/pr4529.diff` (the `entity-timeline.ts` and `entity-timeline.test.ts` hunks) - -- [ ] **Step 1: Port the #4529 timeline test cases** - -From `/tmp/pr4529.diff`, copy the added cases in `entity-timeline.test.ts` into `packages/agents-runtime/test/entity-timeline.test.ts`, adapting them so the comment row's author is read from `_principal` (object `{url,kind,id}`) instead of `value.from_principal`. The assertions should check that comment rows appear interleaved by `_timeline_order` and expose the principal from `_principal`. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-timeline.test.ts` -Expected: FAIL — comments not projected into the timeline. - -- [ ] **Step 3: Port the timeline projection** - -From `/tmp/pr4529.diff`, port the `entity-timeline.ts` changes that project comment rows into the timeline row list. Two adaptations from #4529: - -1. Source comment rows from the custom `comments` collection (`db.collections.comments`) rather than a built-in collection. (If the timeline iterates a fixed set of built-in collections, add `comments` to that set guarded by `db.collections.comments != null` so non-comment entities are unaffected.) -2. Read the author from the `_principal` virtual column (`row._principal?.url`) instead of `value.from_principal`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-timeline.test.ts` -Expected: PASS - -- [ ] **Step 5: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-runtime run typecheck` -Expected: PASS - -```bash -git add packages/agents-runtime/src/entity-timeline.ts packages/agents-runtime/test/entity-timeline.test.ts -git commit -m "feat(agents-runtime): project comments custom collection into timeline" -``` - ---- - -### Task C5: Clone the comments UI and re-source it - -The #4529 UI is a verbatim clone; only the data source changes (generic collection + `_principal`, generic write action instead of `createComment`). The UI files (from the PR file list) are: - -- `components/CommentBubble.tsx` + `.module.css` -- `components/EntityTimeline.tsx` + `.module.css` -- `components/MessageInput.tsx` + `.module.css` -- `components/AgentResponse.tsx`, `components/UserMessage.tsx` + css, `components/ToolCallView.tsx` + css, `components/toolBlock.module.css`, `components/InlineEventCard.tsx` -- `components/views/ChatView.tsx` -- `components/workspace/SplitMenu.tsx` + `.module.css` -- `hooks/useEntityTimeline.ts` -- `lib/comments.ts` -- `lib/workspace/registerViews.ts` -- Tests: `components/InlineEventCard.test.tsx`, `components/views/ChatView.test.ts`, `lib/comments.test.ts` - -**Files:** - -- Modify/Create: the files above under `packages/agents-server-ui/src/` -- Reference: `~/workspace/tmp-1/packages/agents-server-ui/src/` (exact files) and `/tmp/pr4529.diff` - -- [ ] **Step 1: Diff each UI file against the clone** - -For each file above, compare this repo's version with the clone to see exactly what #4529 added: - -```bash -for f in components/CommentBubble.tsx components/CommentBubble.module.css components/EntityTimeline.tsx components/MessageInput.tsx lib/comments.ts hooks/useEntityTimeline.ts components/views/ChatView.tsx; do - echo "=== $f ===" - diff -u "packages/agents-server-ui/src/$f" "$HOME/workspace/tmp-1/packages/agents-server-ui/src/$f" 2>&1 | head -80 -done -``` - -- [ ] **Step 2: Copy the net-new files verbatim** - -Net-new files (no local version) can be copied directly from the clone: - -```bash -cp ~/workspace/tmp-1/packages/agents-server-ui/src/components/CommentBubble.tsx packages/agents-server-ui/src/components/ -cp ~/workspace/tmp-1/packages/agents-server-ui/src/components/CommentBubble.module.css packages/agents-server-ui/src/components/ -cp ~/workspace/tmp-1/packages/agents-server-ui/src/lib/comments.ts packages/agents-server-ui/src/lib/ -cp ~/workspace/tmp-1/packages/agents-server-ui/src/lib/comments.test.ts packages/agents-server-ui/src/lib/ -``` - -(Confirm each has no pre-existing local version first with `ls`. For files that DO exist locally — `EntityTimeline.tsx`, `MessageInput.tsx`, `ChatView.tsx`, `useEntityTimeline.ts`, `AgentResponse.tsx`, `UserMessage.tsx`, `ToolCallView.tsx`, `InlineEventCard.tsx`, `SplitMenu.tsx`, `registerViews.ts`, and the `.module.css` siblings — apply the #4529 additions by hand using the diffs from Step 1, so local changes on this branch are preserved.) - -- [ ] **Step 3: Re-source the write path onto the generic action** - -In whichever module sends a comment (in #4529 this is the optimistic action that POSTs to `/comments`), change it to POST to `/collections/comments` with the generic body. Find it: - -```bash -grep -rn "/comments\|createComment\|from_principal" packages/agents-server-ui/src -``` - -Replace the request with: - -```ts -await fetch(`/_electric/entities/${type}/${instanceId}/collections/comments`, { - method: `POST`, - headers: { 'content-type': `application/json` }, - body: JSON.stringify({ operation: `insert`, key, value: commentValue }), -}) -``` - -where `commentValue` carries `body`, optional `reply_to`, optional `target_snapshot`, and `timestamp` — but **not** `from_principal`. Prefer wiring through the auto-generated `comments_insert` TanStack action (`db.actions.comments_insert`) if the UI already builds the stream DB with the comments collection; fall back to a direct `fetch` only if the action is unavailable in that component. - -- [ ] **Step 4: Re-source the read path onto `_principal`** - -Anywhere the UI read `comment.from_principal` (alignment "is this me?", sender label), read `comment._principal?.url` instead. Find them: - -```bash -grep -rn "from_principal" packages/agents-server-ui/src -``` - -Replace each with the `_principal.url` equivalent. The "right-align for current principal" check compares `comment._principal?.url === currentPrincipalUrl`. - -- [ ] **Step 5: Run the UI tests** - -Run: `pnpm --filter @electric-ax/agents-server-ui exec vitest run` -Expected: PASS — including the ported `comments.test.ts`, `InlineEventCard.test.tsx`, `ChatView.test.ts`. Fix any reference to `from_principal` in the test fixtures to use `_principal`. - -- [ ] **Step 6: Typecheck + commit** - -Run: `pnpm --filter @electric-ax/agents-server-ui run typecheck` -Expected: PASS - -```bash -git add packages/agents-server-ui/src -git commit -m "feat(agents-server-ui): comments UI on generic writable collection" -``` - ---- - -### Task C6: Changeset + full verification - -**Files:** - -- Create: `.changeset/generic-writable-collections.md` - -- [ ] **Step 1: Write the changeset** - -Create `.changeset/generic-writable-collections.md`: - -```markdown ---- -'@electric-ax/agents-runtime': minor -'@electric-ax/agents-server': minor -'@electric-ax/agents-server-ui': minor -'@electric-ax/agents': minor ---- - -Add generic writable custom collections for agent entity state. Collections opt in -with a `writable` flag; router writes (`POST /:type/:id/collections/:collection`) -are authenticated, schema-validated, and stamp the principal into the change-event -header, which the client materializes into a virtual column. Comments are -re-implemented as one such collection. -``` - -- [ ] **Step 2: Run all four package test suites + typechecks** - -```bash -pnpm --filter @electric-ax/agents-runtime run typecheck && pnpm --filter @electric-ax/agents-runtime exec vitest run -pnpm --filter @electric-ax/agents-server run typecheck && pnpm --filter @electric-ax/agents-server exec vitest run -pnpm --filter @electric-ax/agents run typecheck -pnpm --filter @electric-ax/agents-server-ui run typecheck && pnpm --filter @electric-ax/agents-server-ui exec vitest run -``` - -Expected: all PASS. - -- [ ] **Step 3: Commit** - -```bash -git add .changeset/generic-writable-collections.md -git commit -m "chore: changeset for generic writable collections" -``` - ---- - -## Self-review notes - -- **Spec §1 (header API):** A2 (client virtual column) + B3 (server header stamping). -- **Spec §2 (writable safeguard):** A1 (type), A3 (registration emit), B1/B2 (server storage), B3 (403 enforcement). -- **Spec §3 (endpoint):** B4 (route + handler), B3 (manager). Single POST, operation in body, 201/200, 403 first. -- **Spec §4 (validation):** B3 reuses `validateWriteEvent`, validates `value` only, principal header excluded. -- **Spec §5 (client actions):** A2 + C5 (auto-generated `comments_insert` action / direct fetch fallback). -- **Spec §6 (comments consumer):** C1–C5. -- **Testing matrix (spec):** A2 (materialization, absent column), B3 (writable gating 403, principal stamping, value-only), B4 (route + principal), C3 (registration), C4 (timeline projection), C5 (UI tests). diff --git a/packages/agents-runtime/src/entity-stream-db.ts b/packages/agents-runtime/src/entity-stream-db.ts index a48c4c965c..37a87b11df 100644 --- a/packages/agents-runtime/src/entity-stream-db.ts +++ b/packages/agents-runtime/src/entity-stream-db.ts @@ -137,10 +137,16 @@ function wrapSchemaWithVirtualColumns( for (const col of virtualColumns) { if (col in record) saved[col] = record[col] } + const reattach = ( + result: StandardSchemaV1.Result + ): StandardSchemaV1.Result => { + if (`issues` in result && result.issues) return result + return { value: Object.assign({}, result.value, saved) as T } + } const result = inner[`~standard`].validate(value) - if (result instanceof Promise) return result - if (`issues` in result && result.issues) return result - return { value: Object.assign({}, result.value, saved) as T } + return result instanceof Promise + ? result.then(reattach) + : reattach(result) }, }, } diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index 55fe17ac5d..56c4b50b35 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -273,15 +273,13 @@ async function connectEntityStreamFresh(opts: { contentType: `application/json`, fetch: serverFetch, }) as unknown as EntityStreamHandle) - const db = createEntityStreamDB( - streamUrl, - { - ...UI_ENTITY_CUSTOM_STATE, - ...(customState ?? {}), - } as unknown as Parameters[1], - undefined, - { stream } - ) + const mergedCustomState: Parameters[1] = { + ...UI_ENTITY_CUSTOM_STATE, + ...(customState ?? {}), + } + const db = createEntityStreamDB(streamUrl, mergedCustomState, undefined, { + stream, + }) try { await preloadWithAbort(db, signal) } catch (err) { diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index f5a098cd6b..945be61237 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -2503,9 +2503,7 @@ export class EntityManager { ) } - const key = - req.key ?? - `${collection}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const key = req.key ?? `${collection}-${randomUUID()}` const event: Record = { type: config.type, diff --git a/packages/agents-server/src/routing/entity-types-router.ts b/packages/agents-server/src/routing/entity-types-router.ts index 25d635a073..7d04c78aa9 100644 --- a/packages/agents-server/src/routing/entity-types-router.ts +++ b/packages/agents-server/src/routing/entity-types-router.ts @@ -45,9 +45,15 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & { const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown()) const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema) +// `principalColumn` is accepted and ignored: older runtimes still send it +// (the column is fixed to `_principal` now), and rejecting it would break +// registration during version skew. const externallyWritableCollectionsSchema = Type.Record( Type.String(), - Type.Object({ type: Type.String() }, { additionalProperties: false }) + Type.Object( + { type: Type.String(), principalColumn: Type.Optional(Type.String()) }, + { additionalProperties: false } + ) ) const slashCommandArgumentSchema = Type.Object( { From a68792477c5fbc89f41df0859c31c34230326bb1 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 12:44:00 +0100 Subject: [PATCH 28/35] chore(agents-server): renumber externally-writable-collections migration to 0016 after rebase Co-Authored-By: Claude Fable 5 --- ...sql => 0016_entity_type_externally_writable_collections.sql} | 0 packages/agents-server/drizzle/meta/_journal.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/agents-server/drizzle/{0015_entity_type_externally_writable_collections.sql => 0016_entity_type_externally_writable_collections.sql} (100%) diff --git a/packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql b/packages/agents-server/drizzle/0016_entity_type_externally_writable_collections.sql similarity index 100% rename from packages/agents-server/drizzle/0015_entity_type_externally_writable_collections.sql rename to packages/agents-server/drizzle/0016_entity_type_externally_writable_collections.sql diff --git a/packages/agents-server/drizzle/meta/_journal.json b/packages/agents-server/drizzle/meta/_journal.json index f4ce70ea05..cd05bd62e3 100644 --- a/packages/agents-server/drizzle/meta/_journal.json +++ b/packages/agents-server/drizzle/meta/_journal.json @@ -118,7 +118,7 @@ "idx": 16, "version": "7", "when": 1781200000000, - "tag": "0015_entity_type_externally_writable_collections", + "tag": "0016_entity_type_externally_writable_collections", "breakpoints": true } ] From 6043ed8c45bec7c96f5fa37e9d2cbf7dc2edd1e9 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 12:46:19 +0100 Subject: [PATCH 29/35] fix(agents-server-ui): carry error discriminant on comment timeline rows Main's standalone error rows (#4547) added an error variant to the timeline union; CommentTimelineRow needs the matching undefined field. Co-Authored-By: Claude Fable 5 --- packages/agents-server-ui/src/lib/comments.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index 1644880891..ceeae9a64a 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -40,6 +40,7 @@ export type CommentTimelineRow = { run?: undefined wake?: undefined signal?: undefined + error?: undefined manifest?: undefined } From 13050d02c1cc1f0d79d2f10bf554e1700f6cf23d Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 13:00:42 +0100 Subject: [PATCH 30/35] chore: drop design spec doc from PR Co-Authored-By: Claude Fable 5 --- ...-10-generic-writable-collections-design.md | 286 ------------------ 1 file changed, 286 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md diff --git a/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md b/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md deleted file mode 100644 index c52a6c4209..0000000000 --- a/docs/superpowers/specs/2026-06-10-generic-writable-collections-design.md +++ /dev/null @@ -1,286 +0,0 @@ -# Generic Writable Custom Collections (with Comments as a consumer) - -Date: 2026-06-10 -Status: Approved design — pending implementation plan -Branch: `vbalegas/custom-state` - -## Motivation - -PR #4529 ("feat(agents): Add session comments to agent timelines") adds comments -to agent sessions by **hardcoding** a `comments` collection into the built-in -entity schema: a `Comment*` type family in `entity-schema.ts`, a -`BUILT_IN_EVENT_SCHEMAS.comment`, a bespoke `EntityManager.createComment`, and a -dedicated `POST /:type/:instanceId/comments` route. - -This design reaches the same end-user feature through a **generic, extensible -interface**: arbitrary _custom collections_ layered on the agent's entity state -stream. Comments becomes just one such collection that the Horton and worker -entity definitions declare. The generic interface adds three things the agent -runtime does not have today: - -1. **Opt-in router-writable collections.** Entity state is owned by the agent. - A collection is writable from the HTTP router only when it explicitly opts in - via a `writable` safeguard. Everything else stays agent-only by default. -2. **Authenticated, auditable writes.** Router writes require authentication. The - server stamps the authenticated principal into the **change-event header** - (provenance, outside the user-supplied payload). The client materializes that - header into a read-only **virtual column** on the collection row. -3. **Schema-validated writes.** Each custom collection carries a schema. Router - writes validate the user payload against it server-side before the event is - appended to the stream. - -## Background: how change events and headers work - -Every entity write is appended to the entity's **main durable stream** as a -JSON-encoded **change-event envelope** (`EntityManager.encodeChangeEvent` → -`JSON.stringify`). The shape: - -```jsonc -{ - "type": "state:comments", // which collection/event-type this row belongs to - "key": "comment-abc", // the row's primary key - "headers": { - // metadata ABOUT the write — not user data - "operation": "insert", // insert | update | delete - "timestamp": "2026-06-10T…Z", - "offset": "…", // stamped by the stream; drives ordering - }, - "value": { "body": "looks good" }, // the row payload (user data) -} -``` - -On the **client** (`entity-stream-db.ts`), `materializeEventRow` builds a -TanStack DB collection row purely from `value` plus the primary key: - -```js -row = { ...event.value, [primaryKey]: event.key } -``` - -The only header projected onto a row today is `offset`, transformed into the -synthetic `_timeline_order` field. **There is no general header → column -mechanism.** This design adds one, modeled exactly on `_timeline_order`. - -### Why principal goes in the header, not the value - -PR #4529 puts `from_principal` inside `value`, conflating _who wrote this_ -(provenance the server vouches for) with _what they wrote_ (user data validated -against the collection schema). Putting the principal in `headers` instead: - -- the server stamps it authoritatively from the authenticated request; -- it sits outside the user's schema, so a client cannot spoof it by crafting a - different `value`; -- the collection's data schema stays clean (no principal field to validate). - -## Design - -### 1. Change-event header API (foundation) - -**Server** stamps a `principal` header on every router write: - -```jsonc -{ - "type": "state:comments", - "key": "comment-abc", - "headers": { - "operation": "insert", - "timestamp": "2026-06-10T…Z", - "principal": { - // NEW — from the authenticated request - "url": "/principal/user%3Aalice", - "kind": "user", - "id": "alice", - }, - }, - "value": { "body": "looks good", "timestamp": "…" }, // NO principal here -} -``` - -**Client** generalizes `materializeEventRow`: if a collection declares a -`principalColumn`, copy `headers.principal` onto the row under that name. - -```js -row = { ...event.value, [primaryKey]: event.key } -if (principalColumn) row[principalColumn] = event.headers?.principal -// e.g. row._principal = { url, kind, id } -``` - -The virtual column is read-only and server-vouched; it is never part of `value` -and never written by the client. - -### 2. Collection definition + `writable` safeguard - -Extend the runtime `CollectionDefinition` (`packages/agents-runtime/src/types.ts`) -with one optional field: - -```ts -interface CollectionDefinition { - schema?: StandardSchemaV1 - type?: string - primaryKey?: string - writable?: boolean | { principalColumn?: string } // NEW -} -``` - -- `writable` **absent or `false`** → the `/collections` endpoint rejects all - writes. This is the default for all existing state. -- `writable: true` → router-writable; `principalColumn` defaults to `_principal`. -- `writable: { principalColumn: '_author' }` → router-writable with a custom - virtual-column name. - -At registration (`packages/agents-runtime/src/create-handler.ts`), alongside the -existing `state_schemas` map (keyed by event type), emit a parallel -**`writable_collections`** map keyed by **collection name** so the server can map -a URL `:collection` segment to its event type, schema, and column name: - -```jsonc -"writable_collections": { - "comments": { "type": "state:comments", "principalColumn": "_principal" } -} -``` - -`writable_collections` is stored as a new field on `ElectricAgentsEntityType` -(`packages/agents-server/src/electric-agents-types.ts`) and merges additively the -same way `state_schemas` does (see `amendSchemas` / `getEffectiveSchemas`). - -### 3. Server write endpoint - -`POST /:type/:instanceId/collections/:collection` - -Registered in `packages/agents-server/src/routing/entities-router.ts`, with the -same middleware chain as `send`: - -``` -withExistingEntity → withSchema(writeCollectionBodySchema) → withEntityPermission('write') -``` - -Request body (single POST, operation in the body): - -```jsonc -{ "operation": "insert", "key": "…(optional for insert)", "value": { … } } -``` - -`operation` ∈ `insert | update | delete`. Any principal with entity `write` -permission may perform any operation. **No author/ownership checks** — writes are -auditable via the stamped principal header, not gated by authorship. - -Handler `writeCollection` (in `EntityManager`), in order: - -1. Resolve `:collection` against the entity type's `writable_collections`. - **Not found → 403** (`Collection is not writable`). This is the core safeguard. -2. `rejectsNormalWrites(entity.status)` → **409** (entity stopping/stopped/killed). -3. Build the envelope: `type` = the collection's registered event type, - `key` (provided or generated), `headers = { operation, timestamp, principal }` - where `principal = { url, kind, id }` from `ctx.principal`, and `value`. -4. `validateWriteEvent(entity, envelope)` → **422** if `type` is not a registered - state schema or `value` fails it. (See §4.) -5. `encodeChangeEvent` → `streamClient.append(entity.streams.main, …)`. -6. Return `201` (insert) / `200` (update/delete) with `{ key }`. - -This handler **replaces** PR #4529's `createComment` method and `/comments` route -entirely. - -### 4. Schema validation - -The canonical state-write validator already exists: -`EntityManager.validateWriteEvent(entity, event)` (`entity-manager.ts:3562`). It -looks up `event.type` in the entity type's effective `state_schemas` and validates -`event.value` against the matching schema (for `delete` it validates `old_value`). - -Today it is called from exactly one place — `routing/stream-append.ts`, the -durable-streams proxy path agents/adapters use to append state events. **PR #4529 -bypassed it** (`createComment` appended directly), so comment writes were never -schema-validated. The generic handler closes that gap by calling -`validateWriteEvent` itself. - -- **Where:** server-side, inside `writeCollection`, reusing `validateWriteEvent`. -- **When:** synchronously, after the writable/status checks and before - `streamClient.append`. -- **What:** only `value` (the user payload) is validated, against the collection's - registered schema. `headers.principal` is server-stamped provenance and is - deliberately _not_ validated, so it cannot be spoofed. `update` validates the new - `value`; `delete` carries only a key and skips payload validation. -- **Client-side:** the optimistic action MAY validate against the same Standard - Schema for instant feedback, but it is advisory — the server is authoritative. - -The registered `state_schema` therefore does double duty: it validates both -agent-internal state writes (via `stream-append.ts`) and router writes (via the -new handler) — one schema, one validator, two entry points. - -### 5. Client write actions - -Custom state collections **already** auto-generate `${name}_insert / _update / -_delete` TanStack DB actions in `entity-stream-db.ts`. For writable collections -these become the optimistic write path: - -1. UI calls the action → optimistic local insert/update/delete. -2. Action's `mutationFn` POSTs to `/collections/:collection`. -3. The synced stream row reconciles the optimistic row. -4. `materializeEventRow` attaches `_principal` (virtual column) when the synced - row arrives. - -No new client write primitive is needed beyond wiring the action's `mutationFn` to -the new endpoint. - -### 6. Comments as a consumer of the generic interface - -- **Remove** the hardcoded `comments` collection: the `Comment*` types, - `BUILT_IN_EVENT_SCHEMAS.comment`, the `comments` entry in `builtInCollections` / - `ENTITY_COLLECTIONS`, and `EntityManager.createComment` + `/comments` route. -- **Declare** `comments` as a custom `state` collection on the Horton and worker - entity definitions, with the comment schema (body, `reply_to`, - `target_snapshot`, edit/delete metadata) and - `writable: { principalColumn: '_principal' }`. -- `useEntityTimeline` projects the `comments` collection into the timeline (as in - #4529), reading the author from the `_principal` virtual column instead of - `value.from_principal`. -- The **UI is cloned verbatim from #4529** (`CommentBubble`, `MessageInput` reply - mode, `EntityTimeline` comment rows, comments-only view, reply previews) and - layered on the generic collection. Only the data source changes. -- The #4529 branch will be cloned to `~/workspace/tmp-1` to lift the UI files - exactly. - -## Scope - -Packages touched: - -- `@electric-ax/agents-runtime` — `CollectionDefinition.writable`; generalized - `materializeEventRow` (header → virtual column); registration emits - `writable_collections`; comment schema moved to a custom state collection - declared by Horton/worker. -- `@electric-ax/agents-server` — `writable_collections` on `ElectricAgentsEntityType`; - `writeCollection` handler + `/collections/:collection` route; reuse of - `validateWriteEvent` on the router path; removal of `createComment` + `/comments`. -- `@electric-ax/agents-server-ui` — comments UI cloned from #4529, sourced from - the generic collection + `_principal`. - -## Testing - -- Writable-vs-non-writable gating: non-writable collection → 403; unknown - collection → 403. -- Principal-header stamping: appended event carries `headers.principal = {url, -kind, id}` from the authenticated principal; `value` contains no principal. -- Virtual-column materialization: synced row exposes `_principal`; column is absent - when the collection declares no `principalColumn`. -- Schema validation on the router path: invalid `value` → 422; valid `value` - appends; `delete` skips payload validation. -- Operations: insert/update/delete each append with the correct - `headers.operation`; any authenticated writer succeeds (no author check). -- Comments timeline projection: comment rows interleave in timeline order; author - rendered from `_principal`. -- Cloned UI tests from #4529 adapted to the generic data source. - -A changeset covering all touched packages is added. - -## Decisions (resolved during brainstorming) - -- **Write URL:** `POST /:type/:instanceId/collections/:collection` (generic - `collections` noun, decoupled from the word "state"). -- **Safeguard:** `writable?: boolean | { principalColumn?: string }` on the - collection definition; boolean opt-in, no per-operation list. -- **Permissions:** any principal with entity `write` permission may - insert/update/delete; no author/ownership checks. -- **Principal header:** structured `{ url, kind, id }`, surfaced under a - configurable `principalColumn` (default `_principal`). -- **Endpoint shape:** single `POST` with `operation` in the body. -- **Validation:** server-authoritative via `validateWriteEvent`, validating `value` - only; principal header excluded from validation. From 633434c41e3966862f4c5c6ddfb37cba57fbfc05 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 13:06:36 +0100 Subject: [PATCH 31/35] refactor(agents-runtime): rename timeline extraSources to customSources Aligns with the customState vocabulary for consumer-defined collections. Co-Authored-By: Claude Fable 5 --- packages/agents-runtime/src/client.ts | 2 +- packages/agents-runtime/src/entity-timeline.ts | 8 ++++---- packages/agents-runtime/test/entity-timeline.test.ts | 4 ++-- packages/agents-server-ui/src/hooks/useEntityTimeline.ts | 2 +- packages/agents-server-ui/src/lib/comments.ts | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 9c6d0813d0..3ff3c6b076 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -83,7 +83,7 @@ export type { export type { EntityTimelineContentItem, EntityTimelineData, - EntityTimelineExtraSource, + EntityTimelineCustomSource, EntityTimelineInboxMode, EntityTimelineQueryOptions, EntityTimelineQueryRow, diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index d925e6e8bf..e21c29e440 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -205,7 +205,7 @@ export type EntityTimelineInboxMode = `processed` | `all` * row key. The projection must include `order` (timeline order token) and * `key`; all other fields are passed through to the timeline row. */ -export type EntityTimelineExtraSource = ( +export type EntityTimelineCustomSource = ( q: InitialQueryBuilder ) => QueryBuilder @@ -216,7 +216,7 @@ export interface EntityTimelineQueryOptions { * must not collide with the built-in sources (`inbox`, `run`, `wake`, * `signal`, `manifest`). */ - extraSources?: Record + customSources?: Record } export interface EntityTimelineTextChunk { @@ -1538,10 +1538,10 @@ function buildEntityTimelineQuery( error: errorSource, manifest: db.collections.manifests, } - for (const [name, buildSource] of Object.entries(opts.extraSources ?? {})) { + for (const [name, buildSource] of Object.entries(opts.customSources ?? {})) { if (name in sources) { throw new Error( - `extraSources name "${name}" collides with a built-in timeline source` + `customSources name "${name}" collides with a built-in timeline source` ) } sources[name] = buildSource(q) diff --git a/packages/agents-runtime/test/entity-timeline.test.ts b/packages/agents-runtime/test/entity-timeline.test.ts index 367ade64e9..0b15577c6a 100644 --- a/packages/agents-runtime/test/entity-timeline.test.ts +++ b/packages/agents-runtime/test/entity-timeline.test.ts @@ -1864,7 +1864,7 @@ describe(`entity includes query`, () => { ) }) - it(`unions extraSources into the timeline by order`, async () => { + it(`unions customSources into the timeline by order`, async () => { const { collections, sync } = createEntityCollections() let extraOffset = 1000 const annotations = createSyncCollection(`test-annotations`, () => @@ -1872,7 +1872,7 @@ describe(`entity includes query`, () => { ) const liveQuery = createLiveQueryCollection({ query: createEntityTimelineQuery({ collections } as any, { - extraSources: { + customSources: { annotation: (q) => q .from({ annotation: annotations.collection }) diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index 0f5dccb573..cfbeba5a96 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -114,7 +114,7 @@ export function useEntityTimeline( if (!db) return undefined return createEntityTimelineQuery(db, { ...(includeComments && { - extraSources: { comment: createCommentsTimelineSource(db) }, + customSources: { comment: createCommentsTimelineSource(db) }, }), })(q) }, diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts index ceeae9a64a..768cb9919e 100644 --- a/packages/agents-server-ui/src/lib/comments.ts +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -11,14 +11,14 @@ import type { CommentSnapshot, CommentTarget, EntityStreamDBWithActions, - EntityTimelineExtraSource, + EntityTimelineCustomSource, EntityTimelineQueryRow, } from '@electric-ax/agents-runtime/client' /** * Comments are a UI-level concern: the runtime timeline query knows nothing * about them. `useEntityTimeline` merges them in by passing - * `createCommentsTimelineSource` as an extra timeline source. + * `createCommentsTimelineSource` as a custom timeline source. */ export type EntityTimelineCommentRow = { key: string @@ -51,13 +51,13 @@ export type TimelineRow = /** * Timeline source for the `comments` collection, passed to the runtime's - * `createEntityTimelineQuery` via `extraSources`. The author resolves from + * `createEntityTimelineQuery` via `customSources`. The author resolves from * the `_principal` virtual column (server-stamped, spoof-proof), falling back * to the optimistic row's `from`. */ export function createCommentsTimelineSource( db: EntityStreamDBWithActions -): EntityTimelineExtraSource { +): EntityTimelineCustomSource { const comments = (db.collections as Record).comments return (q) => q.from({ comment: comments }).select(({ comment }: any) => ({ From 517e7792505bc2b7276f92713b27f305db5e2575 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 13:10:22 +0100 Subject: [PATCH 32/35] test(agents-server): cover schema rejection on writeCollection; refresh changeset wording Co-Authored-By: Claude Fable 5 --- ...generic-externally-writable-collections.md | 2 +- ...ic-agents-manager-write-validation.test.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.changeset/generic-externally-writable-collections.md b/.changeset/generic-externally-writable-collections.md index 5a5a4d9ee1..889093f336 100644 --- a/.changeset/generic-externally-writable-collections.md +++ b/.changeset/generic-externally-writable-collections.md @@ -5,4 +5,4 @@ '@electric-ax/agents': minor --- -Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers this and the principal column. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. +Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers it with the server. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). Consumers can project custom collections into the entity timeline via the new `customSources` option on `createEntityTimelineQuery`. All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index 122069d4a3..72a75ee3ca 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -441,6 +441,49 @@ describe(`ElectricAgentsManager.writeCollection`, () => { expect(append).not.toHaveBeenCalled() }) + it(`rejects values that fail the collection schema with 422`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ streamClient: { append } }) + manager.registry.getEntity = vi.fn().mockResolvedValue({ + url: `/chat/session-1`, + type: `chat`, + status: `running`, + streams: { main: `/chat/session-1` }, + }) + manager.registry.getEntityType = vi.fn().mockResolvedValue({ + name: `chat`, + state_schemas: { + 'state:comments': { + type: `object`, + properties: { body: { type: `string` } }, + required: [`body`], + additionalProperties: false, + }, + }, + externally_writable_collections: { + comments: { type: `state:comments` }, + }, + }) + + await expect( + manager.writeCollection(`/chat/session-1`, `comments`, { + operation: `insert`, + key: `c1`, + value: { body: 42 }, + principal, + }) + ).rejects.toMatchObject({ status: 422 }) + expect(append).not.toHaveBeenCalled() + + await manager.writeCollection(`/chat/session-1`, `comments`, { + operation: `insert`, + key: `c2`, + value: { body: `valid` }, + principal, + }) + expect(append).toHaveBeenCalledTimes(1) + }) + it(`rejects writes to a stopped entity with 409`, async () => { const append = vi.fn() const { manager } = createAttachmentManager({ streamClient: { append } }) From 96731120a5ff0993a64772d73250929a36cd2c64 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 15:48:52 +0100 Subject: [PATCH 33/35] chore: bump changeset entries to patch per team convention Co-Authored-By: Claude Fable 5 --- .changeset/generic-externally-writable-collections.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/generic-externally-writable-collections.md b/.changeset/generic-externally-writable-collections.md index 889093f336..5c9ac389b8 100644 --- a/.changeset/generic-externally-writable-collections.md +++ b/.changeset/generic-externally-writable-collections.md @@ -1,8 +1,8 @@ --- -'@electric-ax/agents-runtime': minor -'@electric-ax/agents-server': minor -'@electric-ax/agents-server-ui': minor -'@electric-ax/agents': minor +'@electric-ax/agents-runtime': patch +'@electric-ax/agents-server': patch +'@electric-ax/agents-server-ui': patch +'@electric-ax/agents': patch --- Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers it with the server. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). Consumers can project custom collections into the entity timeline via the new `customSources` option on `createEntityTimelineQuery`. All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. From c9220df232810b713057b8541ed2b50ab885529a Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Thu, 11 Jun 2026 17:31:00 +0100 Subject: [PATCH 34/35] feat(agents): gate comments per-agent via comments/v1 contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments existence is now genuinely per-agent instead of assumed platform-wide by the UI. The canonical commentsCollection declares a comments/v1 contract forwarded in type registration; the server reserves the comments collection name for that contract (and the contract for that name); the entity GET response exposes the type's externally_writable_collections so the UI registers the comments collection — and shows the Comments view, composer mode, timeline merge, and display toggle — only for types that declared it. Co-Authored-By: Claude Fable 5 --- ...generic-externally-writable-collections.md | 2 +- packages/agents-runtime/src/client.ts | 2 +- .../agents-runtime/src/comments-collection.ts | 10 ++++ packages/agents-runtime/src/create-handler.ts | 6 ++- packages/agents-runtime/src/index.ts | 6 ++- packages/agents-runtime/src/types.ts | 6 +++ .../test/comments-collection.test.ts | 3 +- ...create-handler-externally-writable.test.ts | 19 ++++++- .../src/components/MessageInput.tsx | 9 ++-- .../src/components/views/ChatView.tsx | 7 ++- .../src/components/workspace/SplitMenu.tsx | 8 ++- .../src/hooks/useEntityTimeline.ts | 12 ++++- .../src/lib/ElectricAgentsProvider.tsx | 21 ++++++++ .../src/lib/comments-capability.ts | 45 ++++++++++++++++ .../entity-connection.custom-state.test.ts | 26 ++++++--- .../src/lib/entity-connection.test.ts | 54 +++++++++++++++++-- .../src/lib/entity-connection.ts | 26 ++++++--- .../src/lib/workspace/registerViews.ts | 4 ++ .../src/electric-agents-types.ts | 2 + .../src/routing/entities-router.ts | 17 +++++- .../src/routing/entity-types-router.ts | 35 +++++++++++- .../test/electric-agents-routes.test.ts | 52 ++++++++++++++++-- .../test/entity-type-registry.test.ts | 2 +- 23 files changed, 332 insertions(+), 42 deletions(-) create mode 100644 packages/agents-server-ui/src/lib/comments-capability.ts diff --git a/.changeset/generic-externally-writable-collections.md b/.changeset/generic-externally-writable-collections.md index 5c9ac389b8..700956fc7b 100644 --- a/.changeset/generic-externally-writable-collections.md +++ b/.changeset/generic-externally-writable-collections.md @@ -5,4 +5,4 @@ '@electric-ax/agents': patch --- -Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers it with the server. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). Consumers can project custom collections into the entity timeline via the new `customSources` option on `createEntityTimelineQuery`. All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. +Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers it with the server. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). Consumers can project custom collections into the entity timeline via the new `customSources` option on `createEntityTimelineQuery`. All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. Comments are genuinely per-agent: the canonical collection carries a `comments/v1` contract marker, the server reserves the `comments` collection name for that contract, and the UI only surfaces comment affordances (tab, composer mode, timeline merge) for entity types whose registration advertises it. diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 3ff3c6b076..dfdabe8ccd 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -98,7 +98,7 @@ export type { IncludesInboxMessage, IncludesRun, } from './entity-timeline' -export { commentsCollection } from './comments-collection' +export { COMMENTS_CONTRACT, commentsCollection } from './comments-collection' export type { CommentSnapshotValue as CommentSnapshot, CommentTargetValue as CommentTarget, diff --git a/packages/agents-runtime/src/comments-collection.ts b/packages/agents-runtime/src/comments-collection.ts index 2d56216a7b..e03fdf1b53 100644 --- a/packages/agents-runtime/src/comments-collection.ts +++ b/packages/agents-runtime/src/comments-collection.ts @@ -73,9 +73,19 @@ export const commentSchema = z.object({ deleted_by: z.string().optional(), }) +/** + * Contract identifier for the canonical comments collection. The server + * reserves the `comments` collection name for this contract, and the UI + * only surfaces comment affordances for entity types whose registration + * advertises it — so an agent's unrelated `comments` state can never be + * mistaken for platform comments. + */ +export const COMMENTS_CONTRACT = `comments/v1` + export const commentsCollection: CollectionDefinition = { schema: commentSchema, type: `state:comments`, primaryKey: `key`, externallyWritable: true, + contract: COMMENTS_CONTRACT, } diff --git a/packages/agents-runtime/src/create-handler.ts b/packages/agents-runtime/src/create-handler.ts index 56e1b130da..3a18c56a44 100644 --- a/packages/agents-runtime/src/create-handler.ts +++ b/packages/agents-runtime/src/create-handler.ts @@ -282,11 +282,15 @@ export function buildEntityTypeRegistrationBody( ]) ) - const externallyWritableCollections: Record = {} + const externallyWritableCollections: Record< + string, + { type: string; contract?: string } + > = {} for (const [collectionName, def] of stateEntries) { if (!def.externallyWritable) continue externallyWritableCollections[collectionName] = { type: def.type ?? `state:${collectionName}`, + ...(def.contract && { contract: def.contract }), } } diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index 987533816d..832ecfe849 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -399,7 +399,11 @@ export { } from './event-pointer' export type { EventPointer } from './event-pointer' -export { commentSchema, commentsCollection } from './comments-collection' +export { + COMMENTS_CONTRACT, + commentSchema, + commentsCollection, +} from './comments-collection' export type { CommentTargetValue, CommentSnapshotValue, diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 427270b514..32a4d841c5 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -645,6 +645,12 @@ export interface CollectionDefinition< * with the authenticated principal materialized into the `_principal` virtual column. */ externallyWritable?: boolean + /** + * Well-known contract this collection implements (e.g. `comments/v1`). + * Forwarded in the type registration so clients can recognize the + * collection by capability instead of by name. + */ + contract?: string } export interface EntityTypeEntry< diff --git a/packages/agents-runtime/test/comments-collection.test.ts b/packages/agents-runtime/test/comments-collection.test.ts index fabb9d9fe4..866d3081a2 100644 --- a/packages/agents-runtime/test/comments-collection.test.ts +++ b/packages/agents-runtime/test/comments-collection.test.ts @@ -21,7 +21,8 @@ describe(`commentsCollection`, () => { }) }) - it(`is externally writable`, () => { + it(`is externally writable and declares the comments contract`, () => { expect(commentsCollection.externallyWritable).toBe(true) + expect(commentsCollection.contract).toBe(`comments/v1`) }) }) diff --git a/packages/agents-runtime/test/create-handler-externally-writable.test.ts b/packages/agents-runtime/test/create-handler-externally-writable.test.ts index 7e48db2ad9..9e8fba224e 100644 --- a/packages/agents-runtime/test/create-handler-externally-writable.test.ts +++ b/packages/agents-runtime/test/create-handler-externally-writable.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' import { z } from 'zod' +import { + COMMENTS_CONTRACT, + commentsCollection, +} from '../src/comments-collection' import { buildEntityTypeRegistrationBody } from '../src/create-handler' describe(`buildEntityTypeRegistrationBody`, () => { @@ -8,7 +12,7 @@ describe(`buildEntityTypeRegistrationBody`, () => { description: `chat`, handler: async () => {}, state: { - comments: { + feedback: { schema: z.object({ key: z.string().optional(), body: z.string() }), externallyWritable: true, }, @@ -18,7 +22,18 @@ describe(`buildEntityTypeRegistrationBody`, () => { }, } as any) expect(body.externally_writable_collections).toEqual({ - comments: { type: `state:comments` }, + feedback: { type: `state:feedback` }, + }) + }) + + it(`forwards the collection contract when declared`, () => { + const body = buildEntityTypeRegistrationBody(`chat`, { + description: `chat`, + handler: async () => {}, + state: { comments: commentsCollection }, + } as any) + expect(body.externally_writable_collections).toEqual({ + comments: { type: `state:comments`, contract: COMMENTS_CONTRACT }, }) }) diff --git a/packages/agents-server-ui/src/components/MessageInput.tsx b/packages/agents-server-ui/src/components/MessageInput.tsx index 8127126795..8111c48615 100644 --- a/packages/agents-server-ui/src/components/MessageInput.tsx +++ b/packages/agents-server-ui/src/components/MessageInput.tsx @@ -170,10 +170,13 @@ export function MessageInput({ }, }) }, [db, baseUrl, entityUrl, inlineQueuedSubmits, onOptimisticQueuedMessage]) + const commentsAvailable = Boolean( + db && (db.collections as Record).comments + ) const sendCommentAction = useMemo(() => { - if (!db) return null + if (!db || !commentsAvailable) return null return createSendCommentAction({ db, baseUrl, entityUrl }) - }, [db, baseUrl, entityUrl]) + }, [db, commentsAvailable, baseUrl, entityUrl]) const updateAction = useMemo(() => { if (!db) return null return createUpdateInboxMessageAction({ db, baseUrl, entityUrl }) @@ -514,7 +517,7 @@ export function MessageInput({ disabled={inputDisabled} /> - {!editingMessage && !commentOnly && ( + {!editingMessage && !commentOnly && commentsAvailable && (
setMenuOpen(false) /** Wraps a handler so it dispatches and then closes the menu. */ diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index cfbeba5a96..8834485356 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -61,6 +61,12 @@ export function useEntityTimeline( db: EntityStreamDBWithActions | null loading: boolean error: string | null + /** + * True when the entity's type declares the comments collection — the + * stream connection only registers `db.collections.comments` for types + * whose registration advertises the comments contract. + */ + commentsEnabled: boolean } { const [db, setDb] = useState(null) const [loading, setLoading] = useState(false) @@ -108,7 +114,10 @@ export function useEntityTimeline( } }, [baseUrl, entityUrl]) - const includeComments = opts?.comments ?? true + const commentsEnabled = Boolean( + db && (db.collections as Record).comments + ) + const includeComments = commentsEnabled && (opts?.comments ?? true) const { data: timelineRows = [] } = useLiveQuery( (q) => { if (!db) return undefined @@ -225,5 +234,6 @@ export function useEntityTimeline( db, loading, error, + commentsEnabled, } } diff --git a/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx b/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx index 7b745ad582..255bc6e69c 100644 --- a/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx +++ b/packages/agents-server-ui/src/lib/ElectricAgentsProvider.tsx @@ -6,6 +6,7 @@ import { appendPathToUrl } from '@electric-ax/agents-runtime/client' import type { EventPointer } from '@electric-ax/agents-runtime' import type { ReactNode } from 'react' import { serverFetch } from './auth-fetch' +import { registerWritableCollectionsLookup } from './comments-capability' import { entityApiUrl, entitySpawnApiUrl } from './entity-api' import { showToast } from './toast' @@ -104,6 +105,12 @@ const entityTypeSchema = z.object({ .nullable() .optional(), serve_endpoint: z.string().nullable(), + externally_writable_collections: z + .record( + z.object({ type: z.string(), contract: z.string().optional() }).partial() + ) + .nullable() + .optional(), created_at: z.string(), updated_at: z.string(), }) @@ -839,6 +846,20 @@ export function ElectricAgentsProvider({ }) }, [baseUrl]) + // Expose a synchronous type → writable-collections lookup for non-React + // gates (the view registry's `isAvailable`). + useEffect(() => { + if (!baseUrl) { + registerWritableCollectionsLookup(null) + return + } + const { entityTypes } = getOrCreateAppCollections(baseUrl) + registerWritableCollectionsLookup( + (typeName) => entityTypes.get(typeName)?.externally_writable_collections + ) + return () => registerWritableCollectionsLookup(null) + }, [baseUrl]) + const state = useMemo(() => { if (!baseUrl) { return { diff --git a/packages/agents-server-ui/src/lib/comments-capability.ts b/packages/agents-server-ui/src/lib/comments-capability.ts new file mode 100644 index 0000000000..93a861a5b0 --- /dev/null +++ b/packages/agents-server-ui/src/lib/comments-capability.ts @@ -0,0 +1,45 @@ +import { COMMENTS_CONTRACT } from '@electric-ax/agents-runtime/client' + +/** + * Shape of an entity type's `externally_writable_collections` registration + * as seen by the client (entity GET response / synced `entity_types` rows). + */ +export type ExternallyWritableCollections = + | Record + | null + | undefined + +/** + * True when the map advertises the canonical comments contract. Keyed on + * both the reserved `comments` name and the contract marker so an agent's + * unrelated writable collection can never light up the comment UI. + */ +export function supportsComments( + collections: ExternallyWritableCollections +): boolean { + return collections?.comments?.contract === COMMENTS_CONTRACT +} + +type WritableCollectionsLookup = ( + typeName: string +) => ExternallyWritableCollections + +let lookup: WritableCollectionsLookup | null = null + +/** + * Registered by `ElectricAgentsProvider` (backed by the synced + * `entity_types` collection) so non-React callers — the view registry's + * `isAvailable` gate — can resolve a type's writable collections. + */ +export function registerWritableCollectionsLookup( + fn: WritableCollectionsLookup | null +): void { + lookup = fn +} + +export function entityTypeSupportsComments( + typeName: string | null | undefined +): boolean { + if (!typeName || !lookup) return false + return supportsComments(lookup(typeName)) +} diff --git a/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts index b9f40293a8..77e7b70d7d 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.custom-state.test.ts @@ -1,16 +1,26 @@ import { describe, expect, it } from 'vitest' -import { UI_ENTITY_CUSTOM_STATE } from './entity-connection' +import { COMMENTS_CONTRACT } from '@electric-ax/agents-runtime/client' +import { uiCustomStateForEntity } from './entity-connection' -describe(`UI_ENTITY_CUSTOM_STATE`, () => { - it(`exposes a comments collection so db.collections.comments is defined`, () => { - expect(UI_ENTITY_CUSTOM_STATE.comments).toBeDefined() +describe(`uiCustomStateForEntity`, () => { + it(`registers comments when the type advertises the comments contract`, () => { + const customState = uiCustomStateForEntity({ + comments: { type: `state:comments`, contract: COMMENTS_CONTRACT }, + }) + expect(customState.comments).toBeDefined() + expect(customState.comments!.type).toBe(`state:comments`) + expect(customState.comments!.externallyWritable).toBe(true) }) - it(`comments collection has the correct type`, () => { - expect(UI_ENTITY_CUSTOM_STATE.comments.type).toBe(`state:comments`) + it(`registers nothing when the type declares no writable collections`, () => { + expect(uiCustomStateForEntity(undefined)).toEqual({}) + expect(uiCustomStateForEntity(null)).toEqual({}) + expect(uiCustomStateForEntity({})).toEqual({}) }) - it(`comments collection is externally writable`, () => { - expect(UI_ENTITY_CUSTOM_STATE.comments.externallyWritable).toBe(true) + it(`ignores a comments entry without the canonical contract`, () => { + expect( + uiCustomStateForEntity({ comments: { type: `state:comments` } }) + ).toEqual({}) }) }) diff --git a/packages/agents-server-ui/src/lib/entity-connection.test.ts b/packages/agents-server-ui/src/lib/entity-connection.test.ts index adebaeb73f..5b5762b69b 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.test.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.test.ts @@ -8,20 +8,24 @@ vi.mock(`./auth-fetch`, () => ({ serverFetch: fetchMock, })) +const createEntityStreamDBMock = vi.fn((..._args: Array) => ({ + preload: preloadMock, + close: closeMock, + collections: {}, +})) + vi.mock(`@electric-ax/agents-runtime/client`, () => ({ appendPathToUrl: (baseUrl: string, path: string) => `${baseUrl.replace(/\/+$/, ``)}${path}`, + COMMENTS_CONTRACT: `comments/v1`, commentsCollection: { schema: {}, type: `state:comments`, primaryKey: `key`, externallyWritable: true, + contract: `comments/v1`, }, - createEntityStreamDB: vi.fn(() => ({ - preload: preloadMock, - close: closeMock, - collections: {}, - })), + createEntityStreamDB: createEntityStreamDBMock, })) describe(`connectEntityStream`, () => { @@ -59,4 +63,44 @@ describe(`connectEntityStream`, () => { ) expect(connection.db).toBeTruthy() }) + + it(`registers the comments collection only when the type advertises the contract`, async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + url: `/horton/abc`, + externally_writable_collections: { + comments: { type: `state:comments`, contract: `comments/v1` }, + }, + }), + { status: 200 } + ) + ) + preloadMock.mockResolvedValue(undefined) + + const { connectEntityStream } = await import(`./entity-connection`) + await connectEntityStream({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + }) + + const customState = createEntityStreamDBMock.mock.calls.at(-1)![1] as any + expect(customState.comments).toMatchObject({ type: `state:comments` }) + }) + + it(`registers no comments collection when the type does not declare it`, async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ url: `/worker/abc` }), { status: 200 }) + ) + preloadMock.mockResolvedValue(undefined) + + const { connectEntityStream } = await import(`./entity-connection`) + await connectEntityStream({ + baseUrl: `http://server`, + entityUrl: `/worker/abc`, + }) + + const customState = createEntityStreamDBMock.mock.calls.at(-1)![1] as any + expect(customState.comments).toBeUndefined() + }) }) diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index 56c4b50b35..35df0e3d2d 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -9,6 +9,9 @@ import { type EntityStreamDBWithActions, } from '@electric-ax/agents-runtime/client' +import { supportsComments } from './comments-capability' +import type { ExternallyWritableCollections } from './comments-capability' + function getMainStreamPath(entityUrl: string): string { return `${entityUrl}/main` } @@ -22,13 +25,18 @@ function getMainStreamPath(entityUrl: string): string { export type UICustomState = Record /** - * Collections the UI always registers on every entity stream so that - * `db.collections.comments` (and any future UI-specific collections) are - * guaranteed to be defined. Callers may overlay their own customState on - * top; explicitly-passed entries take precedence. + * Collections the UI registers on the entity stream when the entity's + * type advertises the matching contract in its + * `externally_writable_collections` registration. `db.collections.comments` + * is therefore only defined for types that declared comments — its absence + * is what gates the comment affordances. Callers may overlay their own + * customState on top; explicitly-passed entries take precedence. */ -export const UI_ENTITY_CUSTOM_STATE: Record = - { comments: commentsCollection } +export function uiCustomStateForEntity( + collections: ExternallyWritableCollections +): Record { + return supportsComments(collections) ? { comments: commentsCollection } : {} +} let activeBaseUrl: string | null = null @@ -263,7 +271,9 @@ async function connectEntityStreamFresh(opts: { entityUrl, signal, }) - await res.body?.cancel() + const metadata = (await res.json().catch(() => null)) as { + externally_writable_collections?: ExternallyWritableCollections + } | null throwIfAborted(signal) const streamUrl = appendPathToUrl(baseUrl, getMainStreamPath(entityUrl)) const stream: EntityStreamHandle = isReactNativeRuntime() @@ -274,7 +284,7 @@ async function connectEntityStreamFresh(opts: { fetch: serverFetch, }) as unknown as EntityStreamHandle) const mergedCustomState: Parameters[1] = { - ...UI_ENTITY_CUSTOM_STATE, + ...uiCustomStateForEntity(metadata?.externally_writable_collections), ...(customState ?? {}), } const db = createEntityStreamDB(streamUrl, mergedCustomState, undefined, { diff --git a/packages/agents-server-ui/src/lib/workspace/registerViews.ts b/packages/agents-server-ui/src/lib/workspace/registerViews.ts index 7667f5f816..20f29596ca 100644 --- a/packages/agents-server-ui/src/lib/workspace/registerViews.ts +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -1,4 +1,5 @@ import { Database, MessageCircle, MessageSquare, SquarePen } from 'lucide-react' +import { entityTypeSupportsComments } from '../comments-capability' import { registerView } from './viewRegistry' import { NEW_SESSION_VIEW_ID } from './types' import { ChatView, CommentsView } from '../../components/views/ChatView' @@ -29,6 +30,9 @@ registerView({ label: `Comments`, icon: MessageCircle, description: `Comment-only timeline`, + // Only entity types whose registration declares the comments collection + // get the comment-only view (and the rest of the comment affordances). + isAvailable: (entity) => entityTypeSupportsComments(entity.type), Component: CommentsView, }) diff --git a/packages/agents-server/src/electric-agents-types.ts b/packages/agents-server/src/electric-agents-types.ts index 632e47addf..aca2e1de40 100644 --- a/packages/agents-server/src/electric-agents-types.ts +++ b/packages/agents-server/src/electric-agents-types.ts @@ -498,6 +498,8 @@ export function toPublicEntity( export interface ExternallyWritableCollectionConfig { /** Durable-stream event type for this collection, e.g. `state:comments`. */ type: string + /** Well-known contract this collection implements, e.g. `comments/v1`. */ + contract?: string } export interface ElectricAgentsEntityType { diff --git a/packages/agents-server/src/routing/entities-router.ts b/packages/agents-server/src/routing/entities-router.ts index 1392dfda5d..1bbce19f8a 100644 --- a/packages/agents-server/src/routing/entities-router.ts +++ b/packages/agents-server/src/routing/entities-router.ts @@ -1519,8 +1519,21 @@ async function spawnEntity( ) } -function getEntity(request: AgentsRouteRequest): Response { - return json(toPublicEntity(requireExistingEntityRoute(request).entity)) +async function getEntity( + request: AgentsRouteRequest, + ctx: TenantContext +): Promise { + const { entity } = requireExistingEntityRoute(request) + const entityType = entity.type + ? await ctx.entityManager.registry.getEntityType(entity.type) + : null + return json({ + ...toPublicEntity(entity), + ...(entityType?.externally_writable_collections && { + externally_writable_collections: + entityType.externally_writable_collections, + }), + }) } function headEntity(): Response { diff --git a/packages/agents-server/src/routing/entity-types-router.ts b/packages/agents-server/src/routing/entity-types-router.ts index 7d04c78aa9..61266a660c 100644 --- a/packages/agents-server/src/routing/entity-types-router.ts +++ b/packages/agents-server/src/routing/entity-types-router.ts @@ -4,6 +4,7 @@ import { Type, type Static } from '@sinclair/typebox' import { Router, json, status } from 'itty-router' +import { COMMENTS_CONTRACT } from '@electric-ax/agents-runtime' import { dispatchPolicySchema } from '../dispatch-policy-schema.js' import { ElectricAgentsError } from '../entity-manager.js' import { @@ -51,7 +52,11 @@ const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema) const externallyWritableCollectionsSchema = Type.Record( Type.String(), Type.Object( - { type: Type.String(), principalColumn: Type.Optional(Type.String()) }, + { + type: Type.String(), + contract: Type.Optional(Type.String()), + principalColumn: Type.Optional(Type.String()), + }, { additionalProperties: false } ) ) @@ -458,9 +463,37 @@ function parseExpiresAt(value: string | undefined): Date | undefined { return expiresAt } +/** + * The `comments` collection name is reserved for the canonical comments + * contract: the UI keys its comment affordances on it, so a divergent + * collection registered under that name (or the contract mounted under + * another name) would break that assumption silently. + */ +function validateExternallyWritableCollections( + collections: RegisterEntityTypeRequest[`externally_writable_collections`] +): void { + for (const [name, config] of Object.entries(collections ?? {})) { + if (name === `comments` && config.contract !== COMMENTS_CONTRACT) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`, + 400 + ) + } + if (config.contract === COMMENTS_CONTRACT && name !== `comments`) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`, + 400 + ) + } + } +} + function normalizeEntityTypeRequest( parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest ): RegisterEntityTypeRequest { + validateExternallyWritableCollections(parsed.externally_writable_collections) const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint) return { name: parsed.name ?? ``, diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index 594f36ec76..020de60f8e 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -1541,7 +1541,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { created_at: `t`, updated_at: `t`, externally_writable_collections: { - comments: { type: `state:comments` }, + comments: { type: `state:comments`, contract: `comments/v1` }, }, }) const manager = { @@ -1557,7 +1557,7 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { name: `chat`, description: `chat`, externally_writable_collections: { - comments: { type: `state:comments` }, + comments: { type: `state:comments`, contract: `comments/v1` }, }, } ) @@ -1566,9 +1566,55 @@ describe(`ElectricAgentsRoutes entity-type registration`, () => { expect(registerEntityType).toHaveBeenCalledWith( expect.objectContaining({ externally_writable_collections: { - comments: { type: `state:comments` }, + comments: { type: `state:comments`, contract: `comments/v1` }, }, }) ) }) + + it(`rejects a writable "comments" collection without the canonical contract`, async () => { + const manager = { + registry: { getEntityType: vi.fn() }, + registerEntityType: vi.fn(), + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entity-types`, + { + name: `chat`, + description: `chat`, + externally_writable_collections: { + comments: { type: `state:comments` }, + }, + } + ) + + expect(response.status).toBe(400) + expect(manager.registerEntityType).not.toHaveBeenCalled() + }) + + it(`rejects the comments contract registered under another collection name`, async () => { + const manager = { + registry: { getEntityType: vi.fn() }, + registerEntityType: vi.fn(), + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entity-types`, + { + name: `chat`, + description: `chat`, + externally_writable_collections: { + feedback: { type: `state:feedback`, contract: `comments/v1` }, + }, + } + ) + + expect(response.status).toBe(400) + expect(manager.registerEntityType).not.toHaveBeenCalled() + }) }) diff --git a/packages/agents-server/test/entity-type-registry.test.ts b/packages/agents-server/test/entity-type-registry.test.ts index f80745cc41..1128053668 100644 --- a/packages/agents-server/test/entity-type-registry.test.ts +++ b/packages/agents-server/test/entity-type-registry.test.ts @@ -44,7 +44,7 @@ describe(`PostgresRegistry entity type registration`, () => { it(`persists and retrieves externally_writable_collections round-trip`, async () => { const registry = new PostgresRegistry(db, `tenant-a`) const externallyWritableCollections = { - comments: { type: `state:comments` }, + comments: { type: `state:comments`, contract: `comments/v1` }, } await registry.createEntityType( entityType({ From 31db3c0784463d25195ee361092af0f806463728 Mon Sep 17 00:00:00 2001 From: Valter Balegas Date: Fri, 12 Jun 2026 09:00:33 +0100 Subject: [PATCH 35/35] chore: shorten changeset message Co-Authored-By: Claude Fable 5 --- .changeset/generic-externally-writable-collections.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/generic-externally-writable-collections.md b/.changeset/generic-externally-writable-collections.md index 700956fc7b..76fe6c8be0 100644 --- a/.changeset/generic-externally-writable-collections.md +++ b/.changeset/generic-externally-writable-collections.md @@ -5,4 +5,4 @@ '@electric-ax/agents': patch --- -Add generic externally-writable custom collections for agent entity state. A collection opts in via `externallyWritable` on its definition; the runtime registers it with the server. Router writes go through `POST /:type/:id/collections/:collection`, which is authenticated, schema-validated, and stamps the authenticated principal into the change-event header — the client materializes that header into a read-only virtual column (`_principal`). Consumers can project custom collections into the entity timeline via the new `customSources` option on `createEntityTimelineQuery`. All other state stays agent-only by default. Comments are reimplemented as one such collection (declared on Horton and worker), with the UI writing via an optimistic action backed by the authenticated endpoint. Comments are genuinely per-agent: the canonical collection carries a `comments/v1` contract marker, the server reserves the `comments` collection name for that contract, and the UI only surfaces comment affordances (tab, composer mode, timeline merge) for entity types whose registration advertises it. +Add generic externally-writable custom collections for agent entity state: collections opt in via `externallyWritable`, writes go through an authenticated schema-validated endpoint that stamps the principal into a read-only `_principal` column, and `createEntityTimelineQuery` can project them into the timeline via `customSources`. Comments are reimplemented as one such collection, gated per agent type through a reserved `comments/v1` contract that the UI keys its comment affordances on.