feat: persist agent-pushed workspace context snapshots in coderd - #26145
Conversation
Docs preview📖 View docs preview for |
2b23687 to
540dacf
Compare
DanielleMaywood
left a comment
There was a problem hiding this comment.
I'd prefer better dbauthz usage here but that can be iterated on
| //nolint:gocritic // We need permissions to write to the DB here and we are in the context of the agent. | ||
| ctx = dbauthz.AsProvisionerd(ctx) |
There was a problem hiding this comment.
TODO: Create a proper dbauthz boundary for this
There was a problem hiding this comment.
We need to clean this up before merging. History tells me that if we don't get this right before main we won't come back around.
There was a problem hiding this comment.
Yup, I'll pause before merging.
There was a problem hiding this comment.
Done in ae5b7ea. Dropped AsProvisionerd/ResourceSystem entirely. The handler relies on the agent token subject already in the RPC context, and each query authorizes against the workspace that owns the agent in dbauthz (fast path via the cached workspace RBAC object, slow path via GetWorkspaceByAgentID), same as UpdateWorkspaceAgentMetadata. Added TestWorkspaceAgentPushContextState, which pushes through the real agent RPC path (agent token middleware, DRPC v2.10, dbauthz, Postgres) to pin the boundary.
Posted by Coder Agents on Kyle's behalf.
| } | ||
|
|
||
| func (q *querier) DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg database.DeleteStaleWorkspaceAgentContextResourcesParams) error { | ||
| if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { |
There was a problem hiding this comment.
TODO: Proper resource tags for this, not ResourceSystem
There was a problem hiding this comment.
Resolved in ae5b7ea alongside the boundary change: these wrappers no longer use ResourceSystem. They now fetch the workspace by agent ID and authorize the action against that workspace object (with a fast path through the cached workspace RBAC object from the agent connection).
Posted by Coder Agents on Kyle's behalf.
| updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| PRIMARY KEY (workspace_agent_id, source), | ||
| CONSTRAINT workspace_agent_context_resources_body_kind_chk | ||
| CHECK (body_kind IN ( |
There was a problem hiding this comment.
For most other enumerated types in the database we use a SQL type, which then generates a Go type via sqlc --- why aren't we doing that here?
There was a problem hiding this comment.
No good reason; fixed in ae5b7ea. body_kind and status are now proper SQL enum types (workspace_agent_context_body_kind, workspace_agent_context_resource_status) and the handler uses the sqlc-generated constants. The reserved plugin RFC kinds are pre-seeded in the enum, so future variants are still just an ALTER TYPE ... ADD VALUE plus a Go switch case.
Posted by Coder Agents on Kyle's behalf.
| -- deletes any sources absent from the latest push in the same | ||
| -- transaction. | ||
| CREATE TABLE workspace_agent_context_resources ( | ||
| workspace_agent_id UUID NOT NULL REFERENCES workspace_agents(id) ON DELETE CASCADE, |
There was a problem hiding this comment.
We only soft-delete agents, so these will accumulate in the database forever. I think we should at least update SoftDeletePriorWorkspaceAgents and SoftDeleteWorkspaceAgentsByWorkspaceID to clean this up, or define a dbpurge job to do so.
There was a problem hiding this comment.
Done in ae5b7ea. Updated both SoftDeletePriorWorkspaceAgents and SoftDeleteWorkspaceAgentsByWorkspaceID to hard-delete the context rows for the agents being soft-deleted (CTE in the same statement), with a querier test (TestSoftDeleteWorkspaceAgentsPurgesContext). Went with this over a dbpurge job since the rows only describe live agents and agents are never un-deleted.
Posted by Coder Agents on Kyle's behalf.
There was a problem hiding this comment.
Following the spirit of this, I found a third soft-delete path that would have leaked: DeleteWorkspaceSubAgentByID (the DeleteSubAgent RPC), which removes a devcontainer sub-agent mid-build without a full workspace rebuild. Because the rebuild-time queries both filter deleted = FALSE (so they skip an already-deleted sub-agent), the FK ON DELETE CASCADE only fires on hard delete, and we never hard-delete agents, that sub-agent's context rows would have orphaned forever.
Fixed in de2c1be by applying the same purge CTE to DeleteWorkspaceSubAgentByID, and extended TestSoftDeleteWorkspaceAgentsPurgesContext to cover the sub-agent case.
Posted by Coder Agents on Kyle's behalf.
|
|
||
| accepted = true | ||
| return nil | ||
| }, &database.TxOptions{TxIdentifier: "push_agent_context_state"}) |
There was a problem hiding this comment.
I think we need repeatable read isolation here to avoid situations where two different updates are sent simultaneously and klobber each other.
There was a problem hiding this comment.
Done in ae5b7ea. Switched to database.ReadModifyUpdate, which runs the transaction at repeatable read and retries serialization failures (40001). The loser of a conflict re-runs the version gate against the winner's committed state, so stale pushes get dropped rather than clobbering. Added a unit test (SerializationConflictRetries) covering the retry path.
Posted by Coder Agents on Kyle's behalf.
| // resourceRow is the validated, ready-to-persist form of a single | ||
| // incoming ContextResource. It collapses the proto oneof to (kind, | ||
| // JSONB) so the DB upsert is uniform across kinds. | ||
| type resourceRow struct { |
There was a problem hiding this comment.
why not reuse database.UpsertWorkspaceAgentContextResourceParams here instead of defining a new type?
There was a problem hiding this comment.
No reason, fixed in ae5b7ea. resourceRow is gone; validation now produces database.UpsertWorkspaceAgentContextResourceParams directly and the handler fills WorkspaceAgentID/Now at upsert time.
Posted by Coder Agents on Kyle's behalf.
| } | ||
| seen[r.Source] = struct{}{} | ||
|
|
||
| kind, body, err := marshalContextResourceBody(r) |
There was a problem hiding this comment.
We need to validate the resource sizes and numbers server-side, otherwise a compromised Workspace can DoS Coderd.
There was a problem hiding this comment.
Done in ae5b7ea. Added server-side caps, validated before the transaction starts: at most 1000 resources per push, 256KiB per marshaled body, 4MiB aggregate body size (aligned with the DRPC message cap), 1KiB source/source_path (source is a btree PK column, which Postgres limits to ~2704 bytes per index entry), 4KiB error strings, 64B hashes, and explicit uint64 to int64 overflow checks on version/size_bytes. Each cap has a unit test.
Posted by Coder Agents on Kyle's behalf.
|
QQ on scope: Should we move the removal of |
03358ed to
abc1985
Compare
de2c1be to
056b3ff
Compare
Replaces the v2.10
PushContextStatestub with a real coderd write path. Phase 1 of the chat-side persistence story; nothing reads these rows yet.Follows #25983 and unblocks CODAGT-569.
What ships
Schema (
000517_workspace_agent_context.{up,down}.sql)Two new tables plus
api_key_scopeenum extensions:workspace_agent_context_snapshots(PKworkspace_agent_idtoworkspace_agents(id) ON DELETE CASCADE): one row per agent, overwritten per push. Holdsversion,schema_version,aggregate_hash,snapshot_error,received_at.workspace_agent_context_resources(PK(workspace_agent_id, source)): per-resource state.body_kindandstatusareTEXT+CHECKso adding new wire kinds (the RFC's reserved PLUGIN/HOOK/SUBAGENT/COMMAND) is a one-line CHECK update plus a Go switch case.SQLC queries (
coderd/database/queries/workspaceagentcontext.sql)UpsertWorkspaceAgentContextSnapshotUpsertWorkspaceAgentContextResourceDeleteStaleWorkspaceAgentContextResources(delete-where-source-not-in)GetLatestWorkspaceAgentContextSnapshotListWorkspaceAgentContextResourcesHandler (
coderd/agentapi/context.go)ContextAPIis a new sub-API.PushContextState:schema_version > 1with a non-Unimplementederror so a forward-incompatible agent fails loudly during rollout instead of slipping into the permanent fallback path theUnimplementedtranslation reserves for old coderd deployments.Bodyoneof is set (even when status is non-OK, mirroring the wire guarantee so coderd can attribute failures to a known kind).Database.InTx, reads the existing snapshot. If the push is notinitialandversionis not strictly greater, returnsaccepted = falseand leaves stored state untouched. Otherwise upserts the snapshot row, upserts each resource, then runs the stale-source prune so the snapshot and resource rows always agree.accepted = trueon success.Resource bodies are stored as
protojson(body oneof variant)inbody JSONBwithbody_kindas the discriminator. Adding a new field to an existing variant is zero work sinceprotojsontolerates new fields; adding a new variant is a CHECK + switch case.RBAC + dbauthz
ResourceWorkspaceAgentContext(Create/Read/Update/Delete).SubjectTypeAgentContextplussubjectAgentContextsystem role anddbauthz.AsAgentContexthelper. The push handler elevates to this subject; the agent's own role does not get direct write access to the table.workspace_agent_context:*API key scopes registered in the enum migration; internal-only (not added toexternalLowLevel).Audit
These rows are agent-pushed state, not user-authored. They are intentionally not added to
AuditActionMapand not enumerated inenterprise/audit/table.go, matchingboundary_logs,workspace_agent_memory_resource_monitor, etc.enterprise/audittests pass unchanged.Tests
coderd/agentapi/context_test.go: 12 subtests covering accepts/rejects (schema version, empty/duplicate source, unknown status, missing body), version semantics (stale dropped, same-version replay dropped,initial=trueoverwrites lower version), variant coverage, non-OK status persistence, and the empty-active-set prune case.coderd/database/dbauthz/dbauthz_test.go: 5MethodTestSuitecases covering the new queries.coderd/rbac/roles_test.go:WorkspaceAgentContextpermission row asserting no human role currently has access.coderd/database/migrations/testdata/fixtures/000517_workspace_agent_context.up.sql: one snapshot + one resource per known body kind plus a non-OK status, so the migration test suite never lands with these tables empty.Out of scope (later phases)
chats.context_aggregate_hash,last_injected_context).PUT /chats/{id}/context.POST /api/v0/context/resyncbarrier and thecoder exp chat contextCLI.codersdkchat-context wire types and the dashboard Sources drawer.Compat property
This is a pure write path. If anything here returns errors the agent's
RunPushloop backs off, no chat behavior changes, and the workspace keeps behaving exactly like it did before v2.10.Implementation plan and decision log
Key design calls:
req.Initial || req.Version > existing.Version. The strict RFC reading ("version comparison is authoritative") locks restarted agents out because their per-process counter resets to 1; honoringinitial=truereflects the real reboot reality while still rejecting steady-state replays/out-of-order pushes.protojsonover the oneof variant body proto, stored in JSONB withbody_kinddiscriminator. Structured at the API/Go layer, schema-tolerant at the storage layer, and Phase 2 readers round-trip back viaprotojson.Unmarshal.Unimplemented. The agent'sRunPushloop only short-circuits onUnimplemented; that escape hatch is reserved for old coderd deployments. A forward-incompatible agent should retry-and-back-off, not flip the connection into permanent fallback.STATUS_UNSPECIFIED, and missingBodyoneof variants are rejected before any write so a misbehaving agent cannot poison the snapshot table. Phase 2 readers can trust every row maps to a known proto variant.This PR was authored by Coder Agents on Kyle Carberry's behalf.