feat(agents): generic externally-writable custom collections (comments as first consumer)#4551
feat(agents): generic externally-writable custom collections (comments as first consumer)#4551balegas wants to merge 35 commits into
Conversation
Electric Agents Mobile BuildLocal mobile checks ran for commit The EAS Android preview build was skipped because the |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4551 +/- ##
===========================================
- Coverage 70.96% 58.53% -12.44%
===========================================
Files 83 375 +292
Lines 9856 41246 +31390
Branches 3124 11826 +8702
===========================================
+ Hits 6994 24142 +17148
- Misses 2844 17028 +14184
- Partials 18 76 +58
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
…te-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 <noreply@anthropic.com>
…ration 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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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) <noreply@anthropic.com>
…amping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ble interface 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…principal 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) <noreply@anthropic.com>
…mentSnapshotValue 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) <noreply@anthropic.com>
…llection Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
…mediate 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 <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
…l as a reserved virtual column
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 <noreply@anthropic.com>
…d fork-from-here hook 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…view 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 <noreply@anthropic.com>
…ion to 0016 after rebase Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
ca266da to
6043ed8
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aligns with the customState vocabulary for consumer-defined collections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sh changeset wording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| '@electric-ax/agents-runtime': minor | ||
| '@electric-ax/agents-server': minor | ||
| '@electric-ax/agents-server-ui': minor | ||
| '@electric-ax/agents': minor |
There was a problem hiding this comment.
We generally do patch changes still even for breaking changes
There was a problem hiding this comment.
Done — switched all four entries to patch in 9673112.
|
Design question: is The externally-writable safeguard itself makes sense to me — default-deny, agent owns its state. But the PR seems ambiguous about whether the comments collection's existence is meant to be optional:
The design spec (dropped in 13050d0) justifies the writability opt-in, but I couldn't find a rationale for making existence opt-in per agent — it seems to just fall out of the mechanism. If comments is a platform feature, could it join the default layer instead — e.g. merged into every registration alongside |
|
Provenance is "last touch", which the comments feature can't actually be built on The header-principal design answers who performed this write — but comments need who authored this row, and for any operation other than
Combined with no ownership checks (the dropped spec records this as a decision: any principal with entity Insert-only is the case where the provenance model is actually sufficient, so concretely: could |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
We decided that comments would only be a feature for horton and hard-code it to the ui. I think this is limiting, so I introduced contracts and entities can opt-in for them. The ui will render comments for any entity with that capability.
|
Provenance here is intentionally attributed to the mutation: The fuller treatment, if/when we want it, is additive on top of this design rather than a rework of it:
So: deferring both and leaving the scope of this PR as is. Happy to open a follow-up issue capturing the moderation/authorship gaps so they don't get lost. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
An alternative to #4529 that delivers the same session-comments feature, but built on a generic, extensible interface instead of hardcoding
commentsinto the entity schema. Comments becomes one custom collection among potentially many; the mechanism underneath is reusable.Three capabilities the agent runtime didn't have before:
externallyWritable— everything else stays agent-only by default. (Every collection is always writable by the agent; only an opt-in subset is writable externally, hence the name.)_principal). A client can't spoof it viavalue.valueserver-side via the existingvalidateWriteEventbefore append.Design
Runtime (
@electric-ax/agents-runtime)CollectionDefinition.externallyWritable?: boolean.externally_writable_collections({ type }per collection).headers.principalinto the fixed_principalvirtual column, mirroring_timeline_order, and strips it (with_seq/_timeline_order) before client write-back.createEntityTimelineQueryacceptscustomSources— consumer-provided sources unioned into the timeline query. The runtime itself knows nothing about specific custom collections, and the agent's LLM context never includes them.Server (
@electric-ax/agents-server)externally_writable_collectionspersisted as a jsonb column onentity_types(migration 0016) and resolved viagetEffectiveSchemas. LegacyprincipalColumnin registration payloads is accepted and ignored for version-skew tolerance.EntityManager.writeCollectiongates on it (403 if not writable), enforces entity-status/fork-lock, stampsheaders.principal = { url, kind, id }, validatesvalue, and appends.POST /:type/:instanceId/collections/:collectionexposes it (auth +writepermission, same middleware chain assend). Single POST,operationin the body, 201 insert / 200 update·delete.Comments consumer (
@electric-ax/agents+@electric-ax/agents-server-ui)commentsCollection(schema ported from feat(agents): Add session comments to agent timelines #4529, minusfrom_principal) declared asstateon Horton and worker.customSourcesentry to the timeline query (author resolved from_principal); comments are a UI-level concern, hardcoded only inagents-server-ui./collections/commentsendpoint; readscomment.from.Test Plan
@electric-ax/agents-runtime— typecheck + 839 tests (incl. principal virtual-column materialization, write-back stripping, registration emit, timeline projection)@electric-ax/agents-server— typecheck + suite (incl.writeCollection403/409 gating, principal-header stamping, schema validation,externally_writable_collectionsjsonb round-trip)@electric-ax/agents— typecheck + 60 tests (comments declared on Horton/worker)@electric-ax/agents-server-ui— typecheck + 88 tests (optimistic comment write to/collections/comments, author from_principal)🤖 Generated with Claude Code