mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
Replaces the v2.10 `PushContextState` stub with a real coderd write path. Phase 1 of the chat-side persistence story; nothing reads these rows yet. Follows [#25983](https://github.com/coder/coder/pull/25983) and unblocks [CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd). ## What ships ### Schema (`000517_workspace_agent_context.{up,down}.sql`) Two new tables plus `api_key_scope` enum extensions: - `workspace_agent_context_snapshots` (PK `workspace_agent_id` to `workspace_agents(id) ON DELETE CASCADE`): one row per agent, overwritten per push. Holds `version`, `schema_version`, `aggregate_hash`, `snapshot_error`, `received_at`. - `workspace_agent_context_resources` (PK `(workspace_agent_id, source)`): per-resource state. `body_kind` and `status` are `TEXT` + `CHECK` so 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`) - `UpsertWorkspaceAgentContextSnapshot` - `UpsertWorkspaceAgentContextResource` - `DeleteStaleWorkspaceAgentContextResources` (delete-where-source-not-in) - `GetLatestWorkspaceAgentContextSnapshot` - `ListWorkspaceAgentContextResources` ### Handler (`coderd/agentapi/context.go`) `ContextAPI` is a new sub-API. `PushContextState`: 1. Rejects `schema_version > 1` with a non-`Unimplemented` error so a forward-incompatible agent fails loudly during rollout instead of slipping into the permanent fallback path the `Unimplemented` translation reserves for old coderd deployments. 2. Validates resources: no empty/duplicate sources, every variant maps to a known body kind, every status maps to a known enum value, the `Body` oneof is set (even when status is non-OK, mirroring the wire guarantee so coderd can attribute failures to a known kind). 3. Inside `Database.InTx`, reads the existing snapshot. If the push is not `initial` and `version` is not strictly greater, returns `accepted = false` and 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. 4. Returns `accepted = true` on success. Resource bodies are stored as `protojson(body oneof variant)` in `body JSONB` with `body_kind` as the discriminator. Adding a new field to an existing variant is zero work since `protojson` tolerates new fields; adding a new variant is a CHECK + switch case. ### RBAC + dbauthz - New `ResourceWorkspaceAgentContext` (Create/Read/Update/Delete). - New `SubjectTypeAgentContext` plus `subjectAgentContext` system role and `dbauthz.AsAgentContext` helper. The push handler elevates to this subject; the agent's own role does not get direct write access to the table. - New `workspace_agent_context:*` API key scopes registered in the enum migration; internal-only (not added to `externalLowLevel`). ### Audit These rows are agent-pushed state, not user-authored. They are intentionally not added to `AuditActionMap` and not enumerated in `enterprise/audit/table.go`, matching `boundary_logs`, `workspace_agent_memory_resource_monitor`, etc. `enterprise/audit` tests 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=true` overwrites lower version), variant coverage, non-OK status persistence, and the empty-active-set prune case. - `coderd/database/dbauthz/dbauthz_test.go`: 5 `MethodTestSuite` cases covering the new queries. - `coderd/rbac/roles_test.go`: `WorkspaceAgentContext` permission 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) - Chat hydration (`chats.context_aggregate_hash`, `last_injected_context`). - Dirty-bit fan-out and `PUT /chats/{id}/context`. - Agent-side `POST /api/v0/context/resync` barrier and the `coder exp chat context` CLI. - `codersdk` chat-context wire types and the dashboard Sources drawer. - Removal of the chatd per-turn pull fallback. ## Compat property This is a pure write path. If anything here returns errors the agent's `RunPush` loop backs off, no chat behavior changes, and the workspace keeps behaving exactly like it did before v2.10. <details> <summary>Implementation plan and decision log</summary> Key design calls: 1. **Concurrency**: Accept iff `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; honoring `initial=true` reflects the real reboot reality while still rejecting steady-state replays/out-of-order pushes. 2. **Body encoding**: `protojson` over the oneof variant body proto, stored in JSONB with `body_kind` discriminator. Structured at the API/Go layer, schema-tolerant at the storage layer, and Phase 2 readers round-trip back via `protojson.Unmarshal`. 3. **Schema version rejection**: returns a normal error, not `Unimplemented`. The agent's `RunPush` loop only short-circuits on `Unimplemented`; 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. 4. **Validation strictness**: empty sources, duplicate sources, `STATUS_UNSPECIFIED`, and missing `Body` oneof 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. </details> _This PR was authored by Coder Agents on Kyle Carberry's behalf._
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"context"
|
|
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
"storj.io/drpc/drpcerr"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
)
|
|
|
|
// DRPCPusher adapts a generated DRPCAgentClient to the
|
|
// agentcontext.Pusher interface. The adapter is the only place
|
|
// that knows about the wire protobuf types; the rest of the
|
|
// package operates on the Go Snapshot/Resource value types.
|
|
//
|
|
// Use NewDRPCPusher to construct an instance. The pusher's
|
|
// behavior is identical to invoking PushContextState directly:
|
|
// per-request retries are handled by Manager.RunPush.
|
|
type DRPCPusher struct {
|
|
client agentproto.DRPCAgentClient210
|
|
}
|
|
|
|
// NewDRPCPusher wraps the supplied drpc client. The client must
|
|
// implement the v2.10 Agent API.
|
|
func NewDRPCPusher(client agentproto.DRPCAgentClient210) *DRPCPusher {
|
|
return &DRPCPusher{client: client}
|
|
}
|
|
|
|
// PushContextState satisfies the Pusher interface.
|
|
//
|
|
// drpc returns an Unimplemented error when the peer's service
|
|
// definition does not include the RPC. The adapter translates
|
|
// that into ErrPushUnimplemented so RunPush stops gracefully
|
|
// when an old coderd is on the other end.
|
|
func (p *DRPCPusher) PushContextState(ctx context.Context, req *PushRequest) (*PushResponse, error) {
|
|
if p == nil || p.client == nil {
|
|
return nil, xerrors.New("agentcontext: DRPCPusher has no client")
|
|
}
|
|
resp, err := p.client.PushContextState(ctx, pushRequestToProto(req))
|
|
if err != nil {
|
|
if drpcerr.Code(err) == drpcerr.Unimplemented {
|
|
return nil, ErrPushUnimplemented
|
|
}
|
|
return nil, err
|
|
}
|
|
return &PushResponse{Accepted: resp.GetAccepted()}, nil
|
|
}
|
|
|
|
// pushRequestToProto converts the Go push payload to its
|
|
// generated protobuf equivalent. The Kind on each Resource
|
|
// selects which body variant of the proto oneof is set; a body
|
|
// is always set (zero-valued if necessary) so coderd can tell
|
|
// the kind even when Status != OK.
|
|
func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
|
|
pb := &agentproto.PushContextStateRequest{
|
|
Version: req.Version,
|
|
AggregateHash: append([]byte(nil), req.AggregateHash[:]...),
|
|
Initial: req.Initial,
|
|
SnapshotError: req.SnapshotError,
|
|
Resources: make([]*agentproto.ContextResource, 0, len(req.Resources)),
|
|
}
|
|
for i := range req.Resources {
|
|
r := req.Resources[i]
|
|
entry := &agentproto.ContextResource{
|
|
Source: r.Source,
|
|
ContentHash: append([]byte(nil), r.ContentHash[:]...),
|
|
Status: resourceStatusToProto(r.Status),
|
|
SizeBytes: r.SizeBytes,
|
|
Error: r.Error,
|
|
}
|
|
setResourceBody(entry, r)
|
|
if r.SourcePath != "" {
|
|
sp := r.SourcePath
|
|
entry.SourcePath = &sp
|
|
}
|
|
pb.Resources = append(pb.Resources, entry)
|
|
}
|
|
return pb
|
|
}
|
|
|
|
// setResourceBody picks the proto oneof variant for r's Kind and
|
|
// populates the kind-specific fields from r. A body is set even
|
|
// when status is not OK so coderd can attribute the failure to a
|
|
// known kind. Unknown kinds leave the body unset; the recipient
|
|
// can surface that as "kind not recognized".
|
|
func setResourceBody(entry *agentproto.ContextResource, r Resource) {
|
|
switch r.Kind {
|
|
case KindInstructionFile:
|
|
entry.Body = &agentproto.ContextResource_InstructionFile{
|
|
InstructionFile: &agentproto.InstructionFileBody{
|
|
Content: append([]byte(nil), r.Payload...),
|
|
},
|
|
}
|
|
case KindSkill:
|
|
entry.Body = &agentproto.ContextResource_Skill{
|
|
Skill: &agentproto.SkillMetaBody{
|
|
Meta: append([]byte(nil), r.Payload...),
|
|
Name: r.Name,
|
|
Description: r.Description,
|
|
},
|
|
}
|
|
case KindMCPConfig:
|
|
// MCPConfigBody is intentionally empty: secrets in env
|
|
// blocks must not leave the agent.
|
|
entry.Body = &agentproto.ContextResource_McpConfig{
|
|
McpConfig: &agentproto.MCPConfigBody{},
|
|
}
|
|
case KindMCPServer:
|
|
entry.Body = &agentproto.ContextResource_McpServer{
|
|
McpServer: &agentproto.MCPServerBody{
|
|
ServerName: serverNameOrSource(r),
|
|
Description: r.Description,
|
|
Tools: mcpToolsToProto(r.Tools),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverNameOrSource returns r.Name when populated and falls
|
|
// back to r.Source so providers that have not yet adopted the
|
|
// Name field still produce a usable wire value.
|
|
func serverNameOrSource(r Resource) string {
|
|
if r.Name != "" {
|
|
return r.Name
|
|
}
|
|
return r.Source
|
|
}
|
|
|
|
// mcpToolsToProto converts the Go MCPTool slice to its wire
|
|
// representation. InputSchema is marshaled via structpb.NewStruct;
|
|
// schemas that fail to convert are dropped from the wire copy
|
|
// (the resource ContentHash still detects the change) and the
|
|
// tool ships with InputSchema unset rather than failing the
|
|
// whole push.
|
|
func mcpToolsToProto(in []MCPTool) []*agentproto.MCPTool {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*agentproto.MCPTool, 0, len(in))
|
|
for _, t := range in {
|
|
entry := &agentproto.MCPTool{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
}
|
|
if len(t.InputSchema) > 0 {
|
|
if s, err := structpb.NewStruct(t.InputSchema); err == nil {
|
|
entry.InputSchema = s
|
|
}
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resourceStatusToProto maps a ResourceStatus to its proto enum.
|
|
func resourceStatusToProto(s ResourceStatus) agentproto.ContextResource_Status {
|
|
switch s {
|
|
case StatusOK:
|
|
return agentproto.ContextResource_OK
|
|
case StatusOversize:
|
|
return agentproto.ContextResource_OVERSIZE
|
|
case StatusUnreadable:
|
|
return agentproto.ContextResource_UNREADABLE
|
|
case StatusInvalid:
|
|
return agentproto.ContextResource_INVALID
|
|
case StatusExcluded:
|
|
return agentproto.ContextResource_EXCLUDED
|
|
default:
|
|
return agentproto.ContextResource_STATUS_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
// Ensure DRPCPusher continues to satisfy the Pusher interface
|
|
// even if the interface gains methods in the future.
|
|
var _ Pusher = (*DRPCPusher)(nil)
|