feat: persist agent-pushed workspace context snapshots in coderd (#26145)

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._
This commit is contained in:
Kyle Carberry
2026-06-15 09:38:52 -07:00
committed by GitHub
parent e019210f4b
commit b439b06ee6
31 changed files with 2443 additions and 271 deletions
-1
View File
@@ -56,7 +56,6 @@ func TestAgent_ContextStatePushed(t *testing.T) {
require.NotEmpty(t, pushes)
first := pushes[0]
assert.True(t, first.GetInitial(), "first push must carry Initial=true")
assert.Equal(t, uint64(1), first.GetSchemaVersion(), "schema_version must be the v1 wire shape")
assert.NotEmpty(t, first.GetAggregateHash(), "aggregate_hash must be populated")
// Subsequent pushes must not be Initial.
-2
View File
@@ -45,7 +45,6 @@ type SnapshotResource struct {
// returned by the resync endpoint.
type SnapshotResponse struct {
Version uint64 `json:"version"`
SchemaVersion uint64 `json:"schema_version"`
AggregateHash string `json:"aggregate_hash"`
Resources []SnapshotResource `json:"resources"`
PayloadBytes uint64 `json:"payload_bytes"`
@@ -181,7 +180,6 @@ func (a *API) handleResync(rw http.ResponseWriter, r *http.Request) {
func snapshotResponse(s Snapshot) SnapshotResponse {
out := SnapshotResponse{
Version: s.Version,
SchemaVersion: s.SchemaVersion,
AggregateHash: hex.EncodeToString(s.AggregateHash[:]),
Resources: make([]SnapshotResource, 0, len(s.Resources)),
PayloadBytes: s.PayloadBytes,
-1
View File
@@ -159,7 +159,6 @@ func TestAPI_Resync(t *testing.T) {
var snap agentcontext.SnapshotResponse
require.NoError(t, json.Unmarshal(body, &snap))
require.Equal(t, uint64(1), snap.SchemaVersion)
require.NotEmpty(t, snap.AggregateHash)
require.Len(t, snap.Resources, 1)
require.Equal(t, "instruction_file", snap.Resources[0].Kind)
-1
View File
@@ -58,7 +58,6 @@ func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
Version: req.Version,
AggregateHash: append([]byte(nil), req.AggregateHash[:]...),
Initial: req.Initial,
SchemaVersion: req.SchemaVersion,
SnapshotError: req.SnapshotError,
Resources: make([]*agentproto.ContextResource, 0, len(req.Resources)),
}
-2
View File
@@ -42,7 +42,6 @@ func TestDRPCPusher_HappyPathSerializesAllFields(t *testing.T) {
Version: 7,
AggregateHash: [32]byte{0xaa, 0xbb, 0xcc},
Initial: true,
SchemaVersion: 1,
SnapshotError: "watcher degraded",
Resources: []agentcontext.Resource{
{
@@ -116,7 +115,6 @@ func TestDRPCPusher_HappyPathSerializesAllFields(t *testing.T) {
require.Equal(t, uint64(7), pb.Version)
require.Equal(t, []byte{0xaa, 0xbb, 0xcc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, pb.AggregateHash)
require.True(t, pb.Initial)
require.Equal(t, uint64(1), pb.SchemaVersion)
require.Equal(t, "watcher degraded", pb.SnapshotError)
require.Len(t, pb.Resources, 5)
+19 -39
View File
@@ -12,11 +12,6 @@ import (
"github.com/coder/quartz"
)
// CurrentSchemaVersion is the on-wire shape version. Bump
// whenever the resource format changes in a way that requires
// coderd-side awareness.
const CurrentSchemaVersion uint64 = 1
// ManagerOptions configures a Manager. Zero values get sensible
// defaults.
type ManagerOptions struct {
@@ -45,10 +40,6 @@ type ManagerOptions struct {
Resolver *Resolver
// Debounce overrides the watcher's debounce window.
Debounce time.Duration
// SchemaVersion is the version stamped on each Snapshot.
// Use CurrentSchemaVersion (the default) unless rolling
// out a schema change.
SchemaVersion uint64
}
// Source is a user-declared scan root added to the agent's
@@ -64,13 +55,12 @@ type Source struct {
// Pusher fan-out. Construct with NewManager; start its lifecycle
// goroutines with Run; tear down with Close.
type Manager struct {
logger slog.Logger
clock quartz.Clock
workingDir func() string
allowedRoots []string
resolver *Resolver
debounce time.Duration
schemaVersion uint64
logger slog.Logger
clock quartz.Clock
workingDir func() string
allowedRoots []string
resolver *Resolver
debounce time.Duration
mu sync.Mutex
sources []Source
@@ -124,30 +114,25 @@ func NewManager(opts ManagerOptions) *Manager {
if debounce <= 0 {
debounce = DefaultWatchDebounce
}
schemaVersion := opts.SchemaVersion
if schemaVersion == 0 {
schemaVersion = CurrentSchemaVersion
}
resolver := opts.Resolver
if resolver == nil {
resolver = &Resolver{}
}
m := &Manager{
logger: opts.Logger,
clock: clock,
workingDir: opts.WorkingDir,
allowedRoots: append([]string(nil), opts.AllowedRoots...),
resolver: resolver,
debounce: debounce,
schemaVersion: schemaVersion,
sources: make([]Source, 0),
sourceIndex: make(map[string]int),
subscribers: make(map[chan struct{}]struct{}),
trigger: make(chan struct{}, 1),
closedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
runStartedCh: make(chan struct{}),
logger: opts.Logger,
clock: clock,
workingDir: opts.WorkingDir,
allowedRoots: append([]string(nil), opts.AllowedRoots...),
resolver: resolver,
debounce: debounce,
sources: make([]Source, 0),
sourceIndex: make(map[string]int),
subscribers: make(map[chan struct{}]struct{}),
trigger: make(chan struct{}, 1),
closedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
runStartedCh: make(chan struct{}),
}
for _, s := range opts.InitialSources {
@@ -442,7 +427,6 @@ func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
roots := m.scanRootsLocked()
resolver := m.resolver
watcher := m.watcher
schemaVersion := m.schemaVersion
m.resolveEpoch++
myEpoch := m.resolveEpoch
m.mu.Unlock()
@@ -464,7 +448,6 @@ func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
snap.SnapshotError = d
}
}
snap.SchemaVersion = schemaVersion
m.mu.Lock()
if m.closed {
@@ -592,7 +575,6 @@ func (m *Manager) resolveAndBroadcast(ctx context.Context) {
roots := m.scanRootsLocked()
resolver := m.resolver
watcher := m.watcher
schemaVersion := m.schemaVersion
m.resolveEpoch++
myEpoch := m.resolveEpoch
m.mu.Unlock()
@@ -617,7 +599,6 @@ func (m *Manager) resolveAndBroadcast(ctx context.Context) {
snap.SnapshotError = d
}
}
snap.SchemaVersion = schemaVersion
m.mu.Lock()
if m.resolveEpoch != myEpoch {
@@ -656,7 +637,6 @@ func (m *Manager) resolveLocked() {
snap := m.resolver.Resolve(roots)
m.version++
snap.Version = m.version
snap.SchemaVersion = m.schemaVersion
// Surface watcher degradation as a snapshot-level error
// when the resolver did not already emit one.
if snap.SnapshotError == "" && m.watcher != nil {
-1
View File
@@ -61,7 +61,6 @@ func TestManager_InitialSnapshotIsPopulated(t *testing.T) {
snap := m.Snapshot()
require.Equal(t, uint64(1), snap.Version)
require.Equal(t, agentcontext.CurrentSchemaVersion, snap.SchemaVersion)
require.Len(t, snap.Resources, 1)
}
-2
View File
@@ -24,7 +24,6 @@ type PushRequest struct {
AggregateHash [32]byte
Resources []Resource
Initial bool
SchemaVersion uint64
SnapshotError string
}
@@ -196,7 +195,6 @@ func snapshotToPushRequest(s Snapshot, initial bool) *PushRequest {
AggregateHash: s.AggregateHash,
Resources: s.Resources,
Initial: initial,
SchemaVersion: s.SchemaVersion,
SnapshotError: s.SnapshotError,
}
}
-3
View File
@@ -936,9 +936,6 @@ type Snapshot struct {
// Version is monotonically increasing per Manager
// instance; resets when the agent process restarts.
Version uint64
// SchemaVersion is bumped if the resource shape on the
// wire changes.
SchemaVersion uint64
// AggregateHash is sha256 over a canonical encoding of
// (ID, Kind, Source, ContentHash, Status) for every
// resource. Identical inputs always produce identical
+136 -146
View File
@@ -4361,7 +4361,6 @@ type PushContextStateRequest struct {
AggregateHash []byte `protobuf:"bytes,2,opt,name=aggregate_hash,json=aggregateHash,proto3" json:"aggregate_hash,omitempty"`
Resources []*ContextResource `protobuf:"bytes,3,rep,name=resources,proto3" json:"resources,omitempty"`
Initial bool `protobuf:"varint,4,opt,name=initial,proto3" json:"initial,omitempty"`
SchemaVersion uint64 `protobuf:"varint,5,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"`
SnapshotError string `protobuf:"bytes,6,opt,name=snapshot_error,json=snapshotError,proto3" json:"snapshot_error,omitempty"`
}
@@ -4425,13 +4424,6 @@ func (x *PushContextStateRequest) GetInitial() bool {
return false
}
func (x *PushContextStateRequest) GetSchemaVersion() uint64 {
if x != nil {
return x.SchemaVersion
}
return 0
}
func (x *PushContextStateRequest) GetSnapshotError() string {
if x != nil {
return x.SnapshotError
@@ -6321,7 +6313,7 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
0x12, 0x3a, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61,
0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52,
0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x22, 0x81, 0x02, 0x0a,
0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x22, 0xe0, 0x01, 0x0a,
0x17, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x74, 0x61, 0x74,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69,
@@ -6333,149 +6325,147 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x69, 0x74,
0x69, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x6e, 0x69, 0x74, 0x69,
0x61, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x76, 0x65, 0x72,
0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x73, 0x63, 0x68, 0x65,
0x6d, 0x61, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e, 0x61,
0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72,
0x22, 0x36, 0x0a, 0x18, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53,
0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08,
0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08,
0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48,
0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41,
0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10,
0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12,
0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10,
0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d,
0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xc9, 0x0f,
0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61,
0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66,
0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69,
0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72,
0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72,
0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72,
0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70,
0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70,
0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x61, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70,
0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22,
0x36, 0x0a, 0x18, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x74,
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x61,
0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61,
0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65,
0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c,
0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10,
0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02,
0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a,
0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xc9, 0x0f, 0x0a,
0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e,
0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66,
0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12,
0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a,
0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48,
0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74,
0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72,
0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74,
0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74,
0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61,
0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42,
0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e,
0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73,
0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61,
0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f,
0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x12,
0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x7e, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
0x65, 0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70,
0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f,
0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x9e, 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73,
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42,
0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e,
0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e,
0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61,
0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x9e,
0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d,
0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75,
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73,
0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67,
0x65, 0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73,
0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10,
0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67,
0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65,
0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d,
0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73,
0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52,
0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67,
0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67,
0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65,
0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41,
0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41,
0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c,
0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x6b, 0x0a, 0x12, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x6f, 0x75, 0x6e, 0x64,
0x61, 0x72, 0x79, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42,
0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x61,
0x72, 0x79, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62,
0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x65, 0x0a, 0x10, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78,
0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74,
0x65, 0x78, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x74, 0x61, 0x74,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e,
0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53,
0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x6b, 0x0a, 0x12, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x61,
0x72, 0x79, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x6f,
0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72,
0x79, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a,
0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x65, 0x0a, 0x10, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65,
0x78, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
0x50, 0x75, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68,
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
+7 -1
View File
@@ -643,8 +643,14 @@ message PushContextStateRequest {
bytes aggregate_hash = 2;
repeated ContextResource resources = 3;
bool initial = 4;
uint64 schema_version = 5;
string snapshot_error = 6;
// Reserved tags from the pre-release v2.10 schema. schema_version
// was removed before the first release that ships v2.10 because
// it duplicated the agent API minor version (tailnet/proto.
// CurrentMinor); the proto bump and the existing Unimplemented
// fallback cover every forward-compat case it tried to address.
reserved 5;
}
message PushContextStateResponse {
+9
View File
@@ -58,6 +58,7 @@ type API struct {
*ConnLogAPI
*SubAgentAPI
*BoundaryLogsAPI
*ContextAPI
*tailnet.DRPCService
cachedWorkspaceFields *CachedWorkspaceFields
@@ -246,6 +247,14 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge
BoundaryUsageTracker: opts.BoundaryUsageTracker,
}
api.ContextAPI = &ContextAPI{
AgentID: agent.ID,
Workspace: api.cachedWorkspaceFields,
Log: opts.Log,
Clock: opts.Clock,
Database: opts.Database,
}
// Start background cache refresh loop to handle workspace changes
// like prebuild claims where owner_id and other fields may be modified in the DB.
go api.startCacheRefreshLoop(opts.AuthenticatedCtx)
+374 -16
View File
@@ -2,29 +2,387 @@ package agentapi
import (
"context"
"database/sql"
"errors"
"math"
"sort"
"storj.io/drpc/drpcerr"
"github.com/google/uuid"
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/quartz"
)
// PushContextState is the server-side stub for the v2.10
// PushContextState RPC. Coderd does not yet persist context
// snapshots; the chatd integration that consumes pushes lives
// in a follow-up change.
// Server-side caps on a single PushContextState request. The agent
// enforces its own caps (64KiB per resource payload, 2MiB aggregate,
// 500 resources; see agent/agentcontext/resolve.go), but coderd
// cannot trust a workspace process, so pushes are re-validated here
// with headroom above the agent caps:
//
// Returning Unimplemented signals the agent to stop pushing for
// the remainder of the connection. The agent.Manager.RunPush
// loop translates this into a clean shutdown rather than a
// retry storm.
func (*API) PushContextState(_ context.Context, _ *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
return nil, drpcerr.WithCode(errPushContextStateUnimplemented, drpcerr.Unimplemented)
// - maxContextResourcesPerPush allows excluded stub entries past
// the agent's 500-resource cap.
// - maxContextResourceBodyBytes covers protojson and base64
// expansion of a 64KiB payload.
// - maxContextAggregateBodyBytes matches the 4MiB DRPC message
// cap so the invariant survives transport changes.
// - The string and hash caps bound the remaining row columns;
// source doubles as a btree primary key column, which PostgreSQL
// limits to roughly 2704 bytes per index entry.
const (
maxContextResourcesPerPush = 1000
maxContextResourceBodyBytes = 256 * 1024
maxContextAggregateBodyBytes = 4 * 1024 * 1024
maxContextSourceBytes = 1024
maxContextErrorBytes = 4096
maxContextHashBytes = 64
)
// ContextAPI implements the v2.10 PushContextState RPC. It persists
// the latest pushed snapshot per workspace agent across two tables
// (workspace_agent_context_snapshots and
// workspace_agent_context_resources) so later phases can hydrate
// chats and surface drift to the dashboard.
//
// The handler is a pure write path: nothing else in coderd reads
// these rows yet. If a bug here returns errors the agent's RunPush
// loop backs off and the workspace keeps behaving exactly like it
// did before v2.10.
type ContextAPI struct {
AgentID uuid.UUID
// Workspace caches workspace fields for the duration of the agent
// connection so dbauthz can authorize against the workspace RBAC
// object without re-fetching the workspace on every push.
Workspace *CachedWorkspaceFields
Log slog.Logger
Clock quartz.Clock
Database database.Store
}
// errPushContextStateUnimplemented is the static error returned
// by PushContextState before the chatd integration lands.
var errPushContextStateUnimplemented = stringError("agentapi: PushContextState is not implemented yet")
// PushContextState persists a snapshot pushed by the workspace
// agent. The transaction upserts the snapshot row, upserts each
// resource, then deletes any resources whose source is not in the
// incoming set so the stored snapshot and resource table always
// agree. It runs at repeatable read isolation (with retries) so two
// concurrent pushes cannot interleave their writes; the loser of the
// conflict re-runs the version gate against the winner's committed
// state.
//
// Returns accepted = false (without writing) when the push is a
// replay or out-of-order resend: the agent's per-process version
// counter is monotonic, and only an initial = true push from a
// freshly-booted agent resets that baseline. Replays and stale
// retransmits leave the stored state untouched.
//
// Authorization happens in dbauthz: every query in the transaction
// authorizes the actor (the agent's token subject) against the
// workspace that owns the agent.
func (a *ContextAPI) PushContextState(ctx context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
if req == nil {
return nil, xerrors.New("agentapi: PushContextState request is nil")
}
if err := validateContextPushRequest(req); err != nil {
return nil, err
}
type stringError string
rows, err := validateAndConvertContextResources(req.Resources)
if err != nil {
return nil, err
}
func (e stringError) Error() string { return string(e) }
// Attach the cached workspace RBAC object so dbauthz can take its
// fast path. On failure (or when unset, e.g. prebuilds) dbauthz
// falls back to fetching the workspace by agent ID.
if a.Workspace != nil {
injected, err := a.Workspace.ContextInject(ctx)
if err != nil {
a.Log.Debug(ctx, "failed to inject cached workspace RBAC object", slog.Error(err))
} else {
ctx = injected
}
}
clock := a.Clock
if clock == nil {
clock = quartz.NewReal()
}
now := dbtime.Time(clock.Now())
activeSources := make([]string, 0, len(rows))
for _, r := range rows {
activeSources = append(activeSources, r.Source)
}
sort.Strings(activeSources)
var accepted bool
err = database.ReadModifyUpdate(a.Database, func(tx database.Store) error {
// The closure re-runs on serialization conflicts; reset any
// state carried over from a rolled-back attempt.
accepted = false
existing, err := tx.GetLatestWorkspaceAgentContextSnapshot(ctx, a.AgentID)
switch {
case errors.Is(err, sql.ErrNoRows):
// No previous snapshot; first push always wins.
case err != nil:
return xerrors.Errorf("get latest snapshot: %w", err)
default:
// Accept either a fresh agent process (initial) or
// a strictly newer version. Out-of-order or replayed
// pushes leave the stored state untouched.
//
//nolint:gosec // existing.Version is a uint64 round-tripped via BIGINT; non-negative by construction.
if !req.Initial && req.Version <= uint64(existing.Version) {
return nil
}
}
_, err = tx.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: a.AgentID,
//nolint:gosec // Bounded by validateContextPushRequest.
Version: int64(req.Version),
AggregateHash: append([]byte(nil), req.AggregateHash...),
SnapshotError: req.SnapshotError,
ReceivedAt: now,
})
if err != nil {
return xerrors.Errorf("upsert snapshot: %w", err)
}
for _, r := range rows {
r.WorkspaceAgentID = a.AgentID
r.Now = now
_, err = tx.UpsertWorkspaceAgentContextResource(ctx, r)
if err != nil {
return xerrors.Errorf("upsert resource %q: %w", r.Source, err)
}
}
err = tx.DeleteStaleWorkspaceAgentContextResources(ctx, database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: a.AgentID,
ActiveSources: activeSources,
})
if err != nil {
return xerrors.Errorf("delete stale resources: %w", err)
}
accepted = true
return nil
})
if err != nil {
return nil, err
}
if !accepted {
a.Log.Debug(ctx, "PushContextState dropped: replay or out-of-order",
slog.F("agent_id", a.AgentID),
slog.F("version", req.Version),
slog.F("initial", req.Initial),
)
return &agentproto.PushContextStateResponse{Accepted: false}, nil
}
a.Log.Debug(ctx, "PushContextState accepted",
slog.F("agent_id", a.AgentID),
slog.F("version", req.Version),
slog.F("initial", req.Initial),
slog.F("resources", len(rows)),
)
return &agentproto.PushContextStateResponse{Accepted: true}, nil
}
// validateContextPushRequest enforces the request-level caps: counts
// and sizes a compromised workspace could otherwise inflate to DoS
// coderd or bloat the database.
func validateContextPushRequest(req *agentproto.PushContextStateRequest) error {
if req.Version > math.MaxInt64 {
return xerrors.Errorf("agentapi: PushContextState version %d exceeds int64 range", req.Version)
}
if len(req.AggregateHash) > maxContextHashBytes {
return xerrors.Errorf("agentapi: PushContextState aggregate hash is %d bytes, exceeds %d byte cap", len(req.AggregateHash), maxContextHashBytes)
}
if len(req.SnapshotError) > maxContextErrorBytes {
return xerrors.Errorf("agentapi: PushContextState snapshot error is %d bytes, exceeds %d byte cap", len(req.SnapshotError), maxContextErrorBytes)
}
if len(req.Resources) > maxContextResourcesPerPush {
return xerrors.Errorf("agentapi: PushContextState has %d resources, exceeds %d resource cap", len(req.Resources), maxContextResourcesPerPush)
}
return nil
}
// validateAndConvertContextResources translates wire resources into
// upsert parameters while rejecting structurally invalid input:
//
// - empty, oversized, or duplicate sources (the PK depends on
// uniqueness and indexes the source column),
// - unknown body variants (kept extensible by emitting the proto's
// reserved kinds via dedicated body messages),
// - unknown status enum values,
// - per-resource and aggregate body sizes past the server caps.
//
// Validation is deliberately strict here so a misbehaving agent
// cannot poison the snapshot table. Phase 2 readers can then trust
// that every row maps to a known proto variant.
//
// WorkspaceAgentID and Now are left unset; the caller fills them at
// upsert time.
func validateAndConvertContextResources(resources []*agentproto.ContextResource) ([]database.UpsertWorkspaceAgentContextResourceParams, error) {
rows := make([]database.UpsertWorkspaceAgentContextResourceParams, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
aggregateBodyBytes := 0
for i, r := range resources {
if r == nil {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d is nil", i)
}
if r.Source == "" {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has empty source", i)
}
if len(r.Source) > maxContextSourceBytes {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has %d byte source, exceeds %d byte cap", i, len(r.Source), maxContextSourceBytes)
}
if _, ok := seen[r.Source]; ok {
return nil, xerrors.Errorf("agentapi: PushContextState duplicate source %q", r.Source)
}
seen[r.Source] = struct{}{}
if len(r.GetSourcePath()) > maxContextSourceBytes {
return nil, xerrors.Errorf("resource %q: source path is %d bytes, exceeds %d byte cap", r.Source, len(r.GetSourcePath()), maxContextSourceBytes)
}
if len(r.Error) > maxContextErrorBytes {
return nil, xerrors.Errorf("resource %q: error is %d bytes, exceeds %d byte cap", r.Source, len(r.Error), maxContextErrorBytes)
}
if len(r.ContentHash) > maxContextHashBytes {
return nil, xerrors.Errorf("resource %q: content hash is %d bytes, exceeds %d byte cap", r.Source, len(r.ContentHash), maxContextHashBytes)
}
if r.SizeBytes > math.MaxInt64 {
return nil, xerrors.Errorf("resource %q: size %d exceeds int64 range", r.Source, r.SizeBytes)
}
kind, body, err := marshalContextResourceBody(r)
if err != nil {
return nil, xerrors.Errorf("resource %q: %w", r.Source, err)
}
if len(body) > maxContextResourceBodyBytes {
return nil, xerrors.Errorf("resource %q: body is %d bytes, exceeds %d byte cap", r.Source, len(body), maxContextResourceBodyBytes)
}
aggregateBodyBytes += len(body)
if aggregateBodyBytes > maxContextAggregateBodyBytes {
return nil, xerrors.Errorf("agentapi: PushContextState aggregate body size exceeds %d byte cap", maxContextAggregateBodyBytes)
}
status, err := contextResourceStatus(r.Status)
if err != nil {
return nil, xerrors.Errorf("resource %q: %w", r.Source, err)
}
//nolint:exhaustruct // WorkspaceAgentID and Now are filled by the caller at upsert time.
rows = append(rows, database.UpsertWorkspaceAgentContextResourceParams{
Source: r.Source,
SourcePath: r.GetSourcePath(),
BodyKind: kind,
Body: body,
ContentHash: append([]byte(nil), r.ContentHash...),
//nolint:gosec // Bounded above.
SizeBytes: int64(r.SizeBytes),
Status: status,
Error: r.Error,
})
}
return rows, nil
}
// marshalContextResourceBody picks the body variant set on the wire
// resource and returns the (body_kind, body_jsonb) pair stored in
// the resource row. The body is protojson encoded so the schema can
// be evolved by adding fields to the proto without coderd changes,
// and a future reader can round-trip back to the proto type by
// switching on body_kind.
//
// Body is always populated, even on non-OK statuses: the wire
// guarantees the oneof variant is set so coderd can still attribute
// the failure to a known kind. For variants with no content fields
// (mcp_config), an empty JSON object is stored.
func marshalContextResourceBody(r *agentproto.ContextResource) (kind database.WorkspaceAgentContextBodyKind, body []byte, err error) {
switch b := r.Body.(type) {
case *agentproto.ContextResource_InstructionFile:
payload := b.InstructionFile
if payload == nil {
payload = &agentproto.InstructionFileBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindInstructionFile, body, err
case *agentproto.ContextResource_Skill:
payload := b.Skill
if payload == nil {
payload = &agentproto.SkillMetaBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindSkill, body, err
case *agentproto.ContextResource_McpConfig:
payload := b.McpConfig
if payload == nil {
payload = &agentproto.MCPConfigBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindMcpConfig, body, err
case *agentproto.ContextResource_McpServer:
payload := b.McpServer
if payload == nil {
payload = &agentproto.MCPServerBody{}
}
body, err = marshalBody(payload)
return database.WorkspaceAgentContextBodyKindMcpServer, body, err
case nil:
return "", nil, xerrors.Errorf("missing body variant; status %s requires a typed body", r.Status)
default:
return "", nil, xerrors.Errorf("unsupported body variant %T", r.Body)
}
}
// contextBodyMarshalOptions produces deterministic-ish JSON for the
// body so the stored value compares equal across pushes that yield
// equivalent protos. Strict canonicalization (RFC 8785) is not
// required here; the enum column plus the protojson round trip give
// us a stable enough store.
var contextBodyMarshalOptions = protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
}
// marshalBody is a small wrapper around protojson.Marshal that
// keeps the body encoding in one place; future phases that read
// these rows mirror the call with protojson.Unmarshal into the
// matching proto.Message.
func marshalBody(msg proto.Message) ([]byte, error) {
out, err := contextBodyMarshalOptions.Marshal(msg)
if err != nil {
return nil, xerrors.Errorf("marshal body: %w", err)
}
return out, nil
}
// contextResourceStatus translates the wire status enum to the
// database enum. STATUS_UNSPECIFIED is rejected: every well-formed
// snapshot row needs an explicit status so cache invalidation, dirty
// fan-out, and the Sources drawer can reason about partial pushes
// deterministically.
func contextResourceStatus(s agentproto.ContextResource_Status) (database.WorkspaceAgentContextResourceStatus, error) {
switch s {
case agentproto.ContextResource_OK:
return database.WorkspaceAgentContextResourceStatusOk, nil
case agentproto.ContextResource_OVERSIZE:
return database.WorkspaceAgentContextResourceStatusOversize, nil
case agentproto.ContextResource_UNREADABLE:
return database.WorkspaceAgentContextResourceStatusUnreadable, nil
case agentproto.ContextResource_INVALID:
return database.WorkspaceAgentContextResourceStatusInvalid, nil
case agentproto.ContextResource_EXCLUDED:
return database.WorkspaceAgentContextResourceStatusExcluded, nil
default:
return "", xerrors.Errorf("unknown status %d", s)
}
}
+600
View File
@@ -0,0 +1,600 @@
package agentapi_test
import (
"context"
"database/sql"
"encoding/json"
"math"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/agentapi"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/quartz"
)
func TestPushContextState(t *testing.T) {
t.Parallel()
now := dbtime.Time(time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC))
agentID := uuid.New()
clock := quartz.NewMock(t)
clock.Set(now)
makeAPI := func(t *testing.T) (*agentapi.ContextAPI, *dbmock.MockStore) {
t.Helper()
ctrl := gomock.NewController(t)
dbm := dbmock.NewMockStore(ctrl)
return &agentapi.ContextAPI{
AgentID: agentID,
Log: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
Clock: clock,
Database: dbm,
}, dbm
}
// expectInTx wires the dbmock so InTx invokes the closure on the
// same mock; tests then set per-method expectations on the same
// dbm. The push transaction must run at repeatable read isolation
// so concurrent pushes cannot clobber each other.
expectInTx := func(dbm *dbmock.MockStore) {
dbm.EXPECT().InTx(gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
func(f func(database.Store) error, opts *database.TxOptions) error {
require.NotNil(t, opts)
require.Equal(t, sql.LevelRepeatableRead, opts.Isolation)
return f(dbm)
},
)
}
t.Run("AcceptsInitialPush", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{}, errNoRows())
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextResource{}, nil).Times(2)
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: agentID,
ActiveSources: []string{"/home/coder/.mcp.json", "/home/coder/AGENTS.md"},
}).Return(nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
AggregateHash: []byte{0x01, 0x02, 0x03},
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/home/coder/AGENTS.md", "hello"),
mcpConfigResource("/home/coder/.mcp.json"),
},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
})
t.Run("RejectsEmptyAndDuplicateSources", func(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("", "x"),
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "empty source")
})
t.Run("Duplicate", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/a", "x"),
instructionResource("/a", "y"),
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "duplicate source")
})
})
t.Run("RejectsUnknownStatus", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
// STATUS_UNSPECIFIED is the zero value and must be rejected so
// every persisted row has a meaningful status.
resource := instructionResource("/a", "x")
resource.Status = agentproto.ContextResource_STATUS_UNSPECIFIED
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{resource},
})
require.Error(t, err)
require.Nil(t, resp)
})
t.Run("RejectsMissingBody", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
{
Source: "/a",
ContentHash: []byte{0x01},
Status: agentproto.ContextResource_OK,
// Body deliberately unset.
},
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "missing body")
})
t.Run("StaleVersionDropped", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
// Existing version 5 stored; incoming version 3 with initial=false
// is a replay/out-of-order push and must be silently dropped
// (accepted=false) without writing.
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{Version: 5}, nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 3,
Initial: false,
Resources: []*agentproto.ContextResource{
instructionResource("/a", "stale"),
},
})
require.NoError(t, err)
require.False(t, resp.GetAccepted())
})
t.Run("SameVersionReplayDropped", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{Version: 5}, nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 5,
Initial: false,
})
require.NoError(t, err)
require.False(t, resp.GetAccepted())
})
t.Run("InitialOverwritesLowerVersion", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
// Agent rebooted: in-memory counter back to 1 but the stored
// version from the previous process boot is 5. initial=true is
// authoritative and the push is accepted.
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{Version: 5}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextResource{}, nil)
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), gomock.Any()).
Return(nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/a", "fresh"),
},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
})
t.Run("PrunesStaleResources", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{Version: 1}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextResource{}, nil)
// Even with one active resource the prune call still runs so
// any resource not in the active set is removed in the same
// transaction.
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: agentID,
ActiveSources: []string{"/a"},
}).Return(nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 2,
Initial: false,
Resources: []*agentproto.ContextResource{
instructionResource("/a", "still here"),
},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
})
t.Run("EmptyResourceListAcceptedAndPrunesAll", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{}, errNoRows())
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
// Active sources is an explicitly empty slice (not nil) so the
// generated SQL deletes every row for this agent rather than
// no-oping on a NULL array.
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: agentID,
ActiveSources: []string{},
}).Return(nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
})
t.Run("PersistsAllKnownBodyVariants", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{}, errNoRows())
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
gotKinds := map[database.WorkspaceAgentContextBodyKind][]byte{}
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
Times(4).
DoAndReturn(func(_ context.Context, arg database.UpsertWorkspaceAgentContextResourceParams) (database.WorkspaceAgentContextResource, error) {
gotKinds[arg.BodyKind] = arg.Body
return database.WorkspaceAgentContextResource{}, nil
})
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), gomock.Any()).Return(nil)
mcpServer := mcpServerResource("/srv/mcp/echo", "echo", "echo server")
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/a/AGENTS.md", "hi"),
skillResource("/a/.agents/skills/example/SKILL.md", "example", "an example"),
mcpConfigResource("/a/.mcp.json"),
mcpServer,
},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
require.Contains(t, gotKinds, database.WorkspaceAgentContextBodyKindInstructionFile)
require.Contains(t, gotKinds, database.WorkspaceAgentContextBodyKindSkill)
require.Contains(t, gotKinds, database.WorkspaceAgentContextBodyKindMcpConfig)
require.Contains(t, gotKinds, database.WorkspaceAgentContextBodyKindMcpServer)
// Confirm each body deserializes as JSON; the actual proto
// roundtrip is exercised by the resolver tests on the agent
// side. We just sanity-check the encoding here.
for kind, body := range gotKinds {
var raw map[string]any
err := json.Unmarshal(body, &raw)
require.NoErrorf(t, err, "kind %q body not valid JSON: %s", kind, string(body))
}
})
t.Run("NonOKStatusStillPersisted", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
expectInTx(dbm)
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{}, errNoRows())
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
var got database.UpsertWorkspaceAgentContextResourceParams
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertWorkspaceAgentContextResourceParams) (database.WorkspaceAgentContextResource, error) {
got = arg
return database.WorkspaceAgentContextResource{}, nil
})
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), gomock.Any()).Return(nil)
oversized := instructionResource("/a/AGENTS.md", "")
oversized.Status = agentproto.ContextResource_OVERSIZE
oversized.SizeBytes = 65 * 1024
oversized.Error = "file exceeds 64KiB per-resource cap"
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{oversized},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
require.Equal(t, database.WorkspaceAgentContextBodyKindInstructionFile, got.BodyKind)
require.Equal(t, database.WorkspaceAgentContextResourceStatusOversize, got.Status)
require.Equal(t, int64(65*1024), got.SizeBytes)
require.Equal(t, "file exceeds 64KiB per-resource cap", got.Error)
})
t.Run("SerializationConflictRetries", func(t *testing.T) {
t.Parallel()
api, dbm := makeAPI(t)
// First attempt: the closure runs fully but the commit fails
// with a serialization error because a concurrent push won the
// race. Second attempt: the re-read gate sees the winner's
// committed version and drops this push. The response must
// report accepted=false even though the first attempt reached
// the accepting branch before rolling back.
gomock.InOrder(
dbm.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
func(f func(database.Store) error, opts *database.TxOptions) error {
require.Equal(t, sql.LevelRepeatableRead, opts.Isolation)
err := f(dbm)
require.NoError(t, err)
return &pq.Error{Code: "40001"}
},
),
dbm.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
func(f func(database.Store) error, _ *database.TxOptions) error {
return f(dbm)
},
),
)
gomock.InOrder(
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{}, errNoRows()),
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(database.WorkspaceAgentContextSnapshot{Version: 7}, nil),
)
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextSnapshot{}, nil)
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), gomock.Any()).
Return(database.WorkspaceAgentContextResource{}, nil)
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), gomock.Any()).Return(nil)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 6,
Initial: false,
Resources: []*agentproto.ContextResource{
instructionResource("/a", "racy"),
},
})
require.NoError(t, err)
require.False(t, resp.GetAccepted())
})
t.Run("ServerSideLimits", func(t *testing.T) {
t.Parallel()
// All limit violations fail validation before the transaction
// starts, so no database expectations are needed.
t.Run("TooManyResources", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resources := make([]*agentproto.ContextResource, 0, 1001)
for i := 0; i < 1001; i++ {
resources = append(resources, instructionResource("/r/"+string(rune('a'+i%26))+"/"+uuid.NewString(), "x"))
}
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: resources,
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "resource cap")
})
t.Run("VersionOverflowsInt64", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: uint64(math.MaxInt64) + 1,
Initial: true,
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "int64 range")
})
t.Run("SourceTooLong", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/"+strings.Repeat("a", 1024), "x"),
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "byte cap")
})
t.Run("BodyTooLarge", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
// 256KiB of content base64-expands past the 256KiB body cap.
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{
instructionResource("/big", strings.Repeat("x", 256*1024)),
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "byte cap")
})
t.Run("AggregateTooLarge", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
// 25 resources just under the per-resource cap together
// exceed the 4MiB aggregate cap.
content := strings.Repeat("x", 140*1024)
resources := make([]*agentproto.ContextResource, 0, 25)
for i := 0; i < 25; i++ {
resources = append(resources, instructionResource("/agg/"+uuid.NewString(), content))
}
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: resources,
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "aggregate body size")
})
t.Run("ContentHashTooLong", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resource := instructionResource("/a", "x")
resource.ContentHash = make([]byte, 65)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
Resources: []*agentproto.ContextResource{resource},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "byte cap")
})
t.Run("SnapshotErrorTooLong", func(t *testing.T) {
t.Parallel()
api, _ := makeAPI(t)
resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
SnapshotError: strings.Repeat("e", 4097),
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "byte cap")
})
})
}
// errNoRows returns the database "no rows" sentinel for the mocks;
// the handler uses errors.Is(err, sql.ErrNoRows) to recognize first
// pushes vs. updates.
func errNoRows() error {
return sql.ErrNoRows
}
func instructionResource(source, content string) *agentproto.ContextResource {
return &agentproto.ContextResource{
Source: source,
ContentHash: []byte{0xaa, 0xbb, 0xcc},
Status: agentproto.ContextResource_OK,
SizeBytes: uint64(len(content)),
Body: &agentproto.ContextResource_InstructionFile{
InstructionFile: &agentproto.InstructionFileBody{
Content: []byte(content),
},
},
}
}
func skillResource(source, name, description string) *agentproto.ContextResource {
return &agentproto.ContextResource{
Source: source,
ContentHash: []byte{0x01, 0x02, 0x03},
Status: agentproto.ContextResource_OK,
Body: &agentproto.ContextResource_Skill{
Skill: &agentproto.SkillMetaBody{
Meta: []byte("---\nname: " + name + "\n---\nbody"),
Name: name,
Description: description,
},
},
}
}
func mcpConfigResource(source string) *agentproto.ContextResource {
return &agentproto.ContextResource{
Source: source,
ContentHash: []byte{0xde, 0xad, 0xbe, 0xef},
Status: agentproto.ContextResource_OK,
Body: &agentproto.ContextResource_McpConfig{
McpConfig: &agentproto.MCPConfigBody{},
},
}
}
func mcpServerResource(source, serverName, description string) *agentproto.ContextResource {
return &agentproto.ContextResource{
Source: source,
ContentHash: []byte{0x10, 0x20, 0x30},
Status: agentproto.ContextResource_OK,
Body: &agentproto.ContextResource_McpServer{
McpServer: &agentproto.MCPServerBody{
ServerName: serverName,
Description: description,
},
},
}
}
+63
View File
@@ -148,6 +148,31 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob
return nil
}
// authorizeWorkspaceByAgentID authorizes an action against the workspace
// that owns the given agent.
//
// Fast path: a workspace RBAC object cached in the context by the agent
// API connection avoids the GetWorkspaceByAgentID query. The cached
// object is refreshed every 5 minutes in agentapi/api.go; authorization
// failures fall back to the slow path in case it is stale.
//
// Slow path: fetch the workspace by agent ID and authorize against it.
func (q *querier) authorizeWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID, action policy.Action) error {
if rbacObj, ok := WorkspaceRBACFromContext(ctx); ok {
if err := q.authorizeContext(ctx, action, rbacObj); err == nil {
return nil
}
q.log.Debug(ctx, "fast path authorization failed for workspace by agent ID, using slow path",
slog.F("agent_id", agentID))
}
workspace, err := q.db.GetWorkspaceByAgentID(ctx, agentID)
if err != nil {
return err
}
return q.authorizeContext(ctx, action, workspace)
}
// authorizePrebuiltWorkspace handles authorization for workspace resource types.
// prebuilt_workspaces are a subset of workspaces, currently limited to
// supporting delete operations. This function first attempts normal workspace
@@ -2377,6 +2402,16 @@ func (q *querier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds in
return q.db.DeleteStaleChatHeartbeats(ctx, staleSeconds)
}
func (q *querier) DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg database.DeleteStaleWorkspaceAgentContextResourcesParams) error {
// Deleting stale context resources is part of updating the agent's
// pushed context state, so it authorizes as an update on the
// workspace rather than a delete of the workspace itself.
if err := q.authorizeWorkspaceByAgentID(ctx, arg.WorkspaceAgentID, policy.ActionUpdate); err != nil {
return err
}
return q.db.DeleteStaleWorkspaceAgentContextResources(ctx, arg)
}
func (q *querier) DeleteTailnetPeer(ctx context.Context, arg database.DeleteTailnetPeerParams) (database.DeleteTailnetPeerRow, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil {
return database.DeleteTailnetPeerRow{}, err
@@ -3812,6 +3847,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab
return q.db.GetLatestCryptoKeyByFeature(ctx, feature)
}
func (q *querier) GetLatestWorkspaceAgentContextSnapshot(ctx context.Context, workspaceAgentID uuid.UUID) (database.WorkspaceAgentContextSnapshot, error) {
if err := q.authorizeWorkspaceByAgentID(ctx, workspaceAgentID, policy.ActionRead); err != nil {
return database.WorkspaceAgentContextSnapshot{}, err
}
return q.db.GetLatestWorkspaceAgentContextSnapshot(ctx, workspaceAgentID)
}
func (q *querier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return database.WorkspaceAppStatus{}, err
@@ -6557,6 +6599,13 @@ func (q *querier) ListUserSkillMetadataByUserID(ctx context.Context, userID uuid
return q.db.ListUserSkillMetadataByUserID(ctx, userID)
}
func (q *querier) ListWorkspaceAgentContextResources(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentContextResource, error) {
if err := q.authorizeWorkspaceByAgentID(ctx, workspaceAgentID, policy.ActionRead); err != nil {
return nil, err
}
return q.db.ListWorkspaceAgentContextResources(ctx, workspaceAgentID)
}
func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID)
if err != nil {
@@ -8773,6 +8822,20 @@ func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.Upser
return q.db.UpsertWebpushVAPIDKeys(ctx, arg)
}
func (q *querier) UpsertWorkspaceAgentContextResource(ctx context.Context, arg database.UpsertWorkspaceAgentContextResourceParams) (database.WorkspaceAgentContextResource, error) {
if err := q.authorizeWorkspaceByAgentID(ctx, arg.WorkspaceAgentID, policy.ActionUpdate); err != nil {
return database.WorkspaceAgentContextResource{}, err
}
return q.db.UpsertWorkspaceAgentContextResource(ctx, arg)
}
func (q *querier) UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg database.UpsertWorkspaceAgentContextSnapshotParams) (database.WorkspaceAgentContextSnapshot, error) {
if err := q.authorizeWorkspaceByAgentID(ctx, arg.WorkspaceAgentID, policy.ActionUpdate); err != nil {
return database.WorkspaceAgentContextSnapshot{}, err
}
return q.db.UpsertWorkspaceAgentContextSnapshot(ctx, arg)
}
func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {
+54
View File
@@ -6073,6 +6073,60 @@ func (s *MethodTestSuite) TestResourcesMonitor() {
}))
}
func (s *MethodTestSuite) TestWorkspaceAgentContext() {
s.Run("UpsertWorkspaceAgentContextSnapshot", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
arg := database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: agt.ID,
}
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpsertWorkspaceAgentContextSnapshot(gomock.Any(), arg).Return(database.WorkspaceAgentContextSnapshot{}, nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("UpsertWorkspaceAgentContextResource", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
arg := database.UpsertWorkspaceAgentContextResourceParams{
WorkspaceAgentID: agt.ID,
Source: "/workspace/AGENTS.md",
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
Body: []byte(`{}`),
Status: database.WorkspaceAgentContextResourceStatusOk,
}
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpsertWorkspaceAgentContextResource(gomock.Any(), arg).Return(database.WorkspaceAgentContextResource{}, nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("DeleteStaleWorkspaceAgentContextResources", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
arg := database.DeleteStaleWorkspaceAgentContextResourcesParams{
WorkspaceAgentID: agt.ID,
ActiveSources: []string{"/workspace/AGENTS.md"},
}
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().DeleteStaleWorkspaceAgentContextResources(gomock.Any(), arg).Return(nil).AnyTimes()
// Stale-resource deletion is part of updating the agent's
// context state, so it asserts ActionUpdate on the workspace.
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("GetLatestWorkspaceAgentContextSnapshot", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agt.ID).Return(database.WorkspaceAgentContextSnapshot{}, nil).AnyTimes()
check.Args(agt.ID).Asserts(w, policy.ActionRead)
}))
s.Run("ListWorkspaceAgentContextResources", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().ListWorkspaceAgentContextResources(gomock.Any(), agt.ID).Return(nil, nil).AnyTimes()
check.Args(agt.ID).Asserts(w, policy.ActionRead)
}))
}
func (s *MethodTestSuite) TestResourcesProvisionerdserver() {
createAgent := func(t *testing.T, db database.Store) (database.WorkspaceAgent, database.WorkspaceTable) {
t.Helper()
+40
View File
@@ -834,6 +834,14 @@ func (m queryMetricsStore) DeleteStaleChatHeartbeats(ctx context.Context, staleS
return r0, r1
}
func (m queryMetricsStore) DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg database.DeleteStaleWorkspaceAgentContextResourcesParams) error {
start := time.Now()
r0 := m.s.DeleteStaleWorkspaceAgentContextResources(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteStaleWorkspaceAgentContextResources").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteStaleWorkspaceAgentContextResources").Inc()
return r0
}
func (m queryMetricsStore) DeleteTailnetPeer(ctx context.Context, arg database.DeleteTailnetPeerParams) (database.DeleteTailnetPeerRow, error) {
start := time.Now()
r0, r1 := m.s.DeleteTailnetPeer(ctx, arg)
@@ -2202,6 +2210,14 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat
return r0, r1
}
func (m queryMetricsStore) GetLatestWorkspaceAgentContextSnapshot(ctx context.Context, workspaceAgentID uuid.UUID) (database.WorkspaceAgentContextSnapshot, error) {
start := time.Now()
r0, r1 := m.s.GetLatestWorkspaceAgentContextSnapshot(ctx, workspaceAgentID)
m.queryLatencies.WithLabelValues("GetLatestWorkspaceAgentContextSnapshot").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetLatestWorkspaceAgentContextSnapshot").Inc()
return r0, r1
}
func (m queryMetricsStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
start := time.Now()
r0, r1 := m.s.GetLatestWorkspaceAppStatusByAppID(ctx, appID)
@@ -4714,6 +4730,14 @@ func (m queryMetricsStore) ListUserSkillMetadataByUserID(ctx context.Context, us
return r0, r1
}
func (m queryMetricsStore) ListWorkspaceAgentContextResources(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentContextResource, error) {
start := time.Now()
r0, r1 := m.s.ListWorkspaceAgentContextResources(ctx, workspaceAgentID)
m.queryLatencies.WithLabelValues("ListWorkspaceAgentContextResources").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListWorkspaceAgentContextResources").Inc()
return r0, r1
}
func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
start := time.Now()
r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID)
@@ -6362,6 +6386,22 @@ func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg datab
return r0
}
func (m queryMetricsStore) UpsertWorkspaceAgentContextResource(ctx context.Context, arg database.UpsertWorkspaceAgentContextResourceParams) (database.WorkspaceAgentContextResource, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceAgentContextResource(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertWorkspaceAgentContextResource").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertWorkspaceAgentContextResource").Inc()
return r0, r1
}
func (m queryMetricsStore) UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg database.UpsertWorkspaceAgentContextSnapshotParams) (database.WorkspaceAgentContextSnapshot, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceAgentContextSnapshot(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertWorkspaceAgentContextSnapshot").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertWorkspaceAgentContextSnapshot").Inc()
return r0, r1
}
func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceAgentPortShare(ctx, arg)
+74
View File
@@ -1408,6 +1408,20 @@ func (mr *MockStoreMockRecorder) DeleteStaleChatHeartbeats(ctx, staleSeconds any
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteStaleChatHeartbeats", reflect.TypeOf((*MockStore)(nil).DeleteStaleChatHeartbeats), ctx, staleSeconds)
}
// DeleteStaleWorkspaceAgentContextResources mocks base method.
func (m *MockStore) DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg database.DeleteStaleWorkspaceAgentContextResourcesParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteStaleWorkspaceAgentContextResources", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteStaleWorkspaceAgentContextResources indicates an expected call of DeleteStaleWorkspaceAgentContextResources.
func (mr *MockStoreMockRecorder) DeleteStaleWorkspaceAgentContextResources(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteStaleWorkspaceAgentContextResources", reflect.TypeOf((*MockStore)(nil).DeleteStaleWorkspaceAgentContextResources), ctx, arg)
}
// DeleteTailnetPeer mocks base method.
func (m *MockStore) DeleteTailnetPeer(ctx context.Context, arg database.DeleteTailnetPeerParams) (database.DeleteTailnetPeerRow, error) {
m.ctrl.T.Helper()
@@ -4079,6 +4093,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature)
}
// GetLatestWorkspaceAgentContextSnapshot mocks base method.
func (m *MockStore) GetLatestWorkspaceAgentContextSnapshot(ctx context.Context, workspaceAgentID uuid.UUID) (database.WorkspaceAgentContextSnapshot, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLatestWorkspaceAgentContextSnapshot", ctx, workspaceAgentID)
ret0, _ := ret[0].(database.WorkspaceAgentContextSnapshot)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetLatestWorkspaceAgentContextSnapshot indicates an expected call of GetLatestWorkspaceAgentContextSnapshot.
func (mr *MockStoreMockRecorder) GetLatestWorkspaceAgentContextSnapshot(ctx, workspaceAgentID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAgentContextSnapshot", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAgentContextSnapshot), ctx, workspaceAgentID)
}
// GetLatestWorkspaceAppStatusByAppID mocks base method.
func (m *MockStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
m.ctrl.T.Helper()
@@ -8878,6 +8907,21 @@ func (mr *MockStoreMockRecorder) ListUserSkillMetadataByUserID(ctx, userID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSkillMetadataByUserID", reflect.TypeOf((*MockStore)(nil).ListUserSkillMetadataByUserID), ctx, userID)
}
// ListWorkspaceAgentContextResources mocks base method.
func (m *MockStore) ListWorkspaceAgentContextResources(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentContextResource, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListWorkspaceAgentContextResources", ctx, workspaceAgentID)
ret0, _ := ret[0].([]database.WorkspaceAgentContextResource)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListWorkspaceAgentContextResources indicates an expected call of ListWorkspaceAgentContextResources.
func (mr *MockStoreMockRecorder) ListWorkspaceAgentContextResources(ctx, workspaceAgentID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceAgentContextResources", reflect.TypeOf((*MockStore)(nil).ListWorkspaceAgentContextResources), ctx, workspaceAgentID)
}
// ListWorkspaceAgentPortShares mocks base method.
func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
m.ctrl.T.Helper()
@@ -11894,6 +11938,36 @@ func (mr *MockStoreMockRecorder) UpsertWebpushVAPIDKeys(ctx, arg any) *gomock.Ca
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).UpsertWebpushVAPIDKeys), ctx, arg)
}
// UpsertWorkspaceAgentContextResource mocks base method.
func (m *MockStore) UpsertWorkspaceAgentContextResource(ctx context.Context, arg database.UpsertWorkspaceAgentContextResourceParams) (database.WorkspaceAgentContextResource, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertWorkspaceAgentContextResource", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceAgentContextResource)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpsertWorkspaceAgentContextResource indicates an expected call of UpsertWorkspaceAgentContextResource.
func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentContextResource(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentContextResource", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentContextResource), ctx, arg)
}
// UpsertWorkspaceAgentContextSnapshot mocks base method.
func (m *MockStore) UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg database.UpsertWorkspaceAgentContextSnapshotParams) (database.WorkspaceAgentContextSnapshot, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertWorkspaceAgentContextSnapshot", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceAgentContextSnapshot)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpsertWorkspaceAgentContextSnapshot indicates an expected call of UpsertWorkspaceAgentContextSnapshot.
func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentContextSnapshot(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentContextSnapshot", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentContextSnapshot), ctx, arg)
}
// UpsertWorkspaceAgentPortShare mocks base method.
func (m *MockStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
m.ctrl.T.Helper()
+81
View File
@@ -611,6 +611,25 @@ CREATE TYPE user_status AS ENUM (
COMMENT ON TYPE user_status IS 'Defines the users status: active, dormant, or suspended.';
CREATE TYPE workspace_agent_context_body_kind AS ENUM (
'instruction_file',
'skill',
'mcp_config',
'mcp_server',
'plugin',
'hook',
'subagent',
'command'
);
CREATE TYPE workspace_agent_context_resource_status AS ENUM (
'ok',
'oversize',
'unreadable',
'invalid',
'excluded'
);
CREATE TYPE workspace_agent_lifecycle_state AS ENUM (
'created',
'starting',
@@ -3473,6 +3492,56 @@ CREATE TABLE webpush_subscriptions (
endpoint_auth_key text NOT NULL
);
CREATE TABLE workspace_agent_context_resources (
workspace_agent_id uuid NOT NULL,
source text NOT NULL,
body_kind workspace_agent_context_body_kind NOT NULL,
body jsonb NOT NULL,
content_hash bytea NOT NULL,
size_bytes bigint NOT NULL,
status workspace_agent_context_resource_status NOT NULL,
error text DEFAULT ''::text NOT NULL,
source_path text DEFAULT ''::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
COMMENT ON TABLE workspace_agent_context_resources IS 'Per-resource state for the latest pushed workspace agent context snapshot.';
COMMENT ON COLUMN workspace_agent_context_resources.source IS 'Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources.';
COMMENT ON COLUMN workspace_agent_context_resources.body_kind IS 'Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC.';
COMMENT ON COLUMN workspace_agent_context_resources.body IS 'protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable.';
COMMENT ON COLUMN workspace_agent_context_resources.content_hash IS 'sha256 over the resource''s original bytes (or transport-encoded server tool list).';
COMMENT ON COLUMN workspace_agent_context_resources.size_bytes IS 'Original payload size in bytes; populated regardless of status.';
COMMENT ON COLUMN workspace_agent_context_resources.status IS 'Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string.';
COMMENT ON COLUMN workspace_agent_context_resources.error IS 'Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok.';
COMMENT ON COLUMN workspace_agent_context_resources.source_path IS 'User-declared scan root that produced this resource. Empty for built-in scan roots.';
CREATE TABLE workspace_agent_context_snapshots (
workspace_agent_id uuid NOT NULL,
version bigint NOT NULL,
aggregate_hash bytea NOT NULL,
snapshot_error text DEFAULT ''::text NOT NULL,
received_at timestamp with time zone DEFAULT now() NOT NULL
);
COMMENT ON TABLE workspace_agent_context_snapshots IS 'Latest workspace agent context snapshot received via PushContextState. One row per workspace agent, overwritten in place.';
COMMENT ON COLUMN workspace_agent_context_snapshots.version IS 'Monotonic per-agent-process push counter. Resets to one when the agent process restarts; combined with the initial flag on the wire to detect agent reboots.';
COMMENT ON COLUMN workspace_agent_context_snapshots.aggregate_hash IS 'sha256 over a canonical encoding of every resource in the snapshot. Identical inputs always produce identical hashes; chat hydration uses this to detect drift.';
COMMENT ON COLUMN workspace_agent_context_snapshots.snapshot_error IS 'Singular snapshot-level error string (count cap exceeded, watcher degraded, etc.). Empty when healthy.';
COMMENT ON COLUMN workspace_agent_context_snapshots.received_at IS 'Time at which coderd received the push.';
CREATE TABLE workspace_agent_devcontainers (
id uuid NOT NULL,
workspace_agent_id uuid NOT NULL,
@@ -4267,6 +4336,12 @@ ALTER TABLE ONLY users
ALTER TABLE ONLY webpush_subscriptions
ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY workspace_agent_context_resources
ADD CONSTRAINT workspace_agent_context_resources_pkey PRIMARY KEY (workspace_agent_id, source);
ALTER TABLE ONLY workspace_agent_context_snapshots
ADD CONSTRAINT workspace_agent_context_snapshots_pkey PRIMARY KEY (workspace_agent_id);
ALTER TABLE ONLY workspace_agent_devcontainers
ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id);
@@ -5125,6 +5200,12 @@ ALTER TABLE ONLY user_status_changes
ALTER TABLE ONLY webpush_subscriptions
ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_agent_context_resources
ADD CONSTRAINT workspace_agent_context_resources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_agent_context_snapshots
ADD CONSTRAINT workspace_agent_context_snapshots_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_agent_devcontainers
ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
+2
View File
@@ -121,6 +121,8 @@ const (
ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentContextResourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_resources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentContextSnapshotsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_snapshots_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS workspace_agent_context_resources;
DROP TABLE IF EXISTS workspace_agent_context_snapshots;
DROP TYPE IF EXISTS workspace_agent_context_resource_status;
DROP TYPE IF EXISTS workspace_agent_context_body_kind;
@@ -0,0 +1,67 @@
-- Discriminator for the body JSON shape stored with each context
-- resource. Matches the proto oneof variant names. plugin, hook,
-- subagent, and command are reserved for the Claude Code plugin RFC.
CREATE TYPE workspace_agent_context_body_kind AS ENUM (
'instruction_file',
'skill',
'mcp_config',
'mcp_server',
'plugin',
'hook',
'subagent',
'command'
);
-- Per-resource resolution status reported by the agent.
CREATE TYPE workspace_agent_context_resource_status AS ENUM (
'ok',
'oversize',
'unreadable',
'invalid',
'excluded'
);
-- Latest workspace agent context snapshot, one row per agent.
-- Overwritten on each PushContextState; no history.
CREATE TABLE workspace_agent_context_snapshots (
workspace_agent_id UUID PRIMARY KEY REFERENCES workspace_agents(id) ON DELETE CASCADE,
version BIGINT NOT NULL,
aggregate_hash BYTEA NOT NULL,
snapshot_error TEXT NOT NULL DEFAULT '',
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE workspace_agent_context_snapshots IS 'Latest workspace agent context snapshot received via PushContextState. One row per workspace agent, overwritten in place.';
COMMENT ON COLUMN workspace_agent_context_snapshots.version IS 'Monotonic per-agent-process push counter. Resets to one when the agent process restarts; combined with the initial flag on the wire to detect agent reboots.';
COMMENT ON COLUMN workspace_agent_context_snapshots.aggregate_hash IS 'sha256 over a canonical encoding of every resource in the snapshot. Identical inputs always produce identical hashes; chat hydration uses this to detect drift.';
COMMENT ON COLUMN workspace_agent_context_snapshots.snapshot_error IS 'Singular snapshot-level error string (count cap exceeded, watcher degraded, etc.). Empty when healthy.';
COMMENT ON COLUMN workspace_agent_context_snapshots.received_at IS 'Time at which coderd received the push.';
-- Resolved resources within a snapshot. Keyed by (agent, source); a
-- subsequent push upserts known sources and the agentapi handler
-- 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,
source TEXT NOT NULL,
body_kind workspace_agent_context_body_kind NOT NULL,
body JSONB NOT NULL,
content_hash BYTEA NOT NULL,
size_bytes BIGINT NOT NULL,
status workspace_agent_context_resource_status NOT NULL,
error TEXT NOT NULL DEFAULT '',
source_path TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_agent_id, source)
);
COMMENT ON TABLE workspace_agent_context_resources IS 'Per-resource state for the latest pushed workspace agent context snapshot.';
COMMENT ON COLUMN workspace_agent_context_resources.source IS 'Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources.';
COMMENT ON COLUMN workspace_agent_context_resources.body_kind IS 'Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC.';
COMMENT ON COLUMN workspace_agent_context_resources.body IS 'protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable.';
COMMENT ON COLUMN workspace_agent_context_resources.content_hash IS 'sha256 over the resource''s original bytes (or transport-encoded server tool list).';
COMMENT ON COLUMN workspace_agent_context_resources.size_bytes IS 'Original payload size in bytes; populated regardless of status.';
COMMENT ON COLUMN workspace_agent_context_resources.status IS 'Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string.';
COMMENT ON COLUMN workspace_agent_context_resources.error IS 'Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok.';
COMMENT ON COLUMN workspace_agent_context_resources.source_path IS 'User-declared scan root that produced this resource. Empty for built-in scan roots.';
@@ -0,0 +1,95 @@
-- Snapshot row and a representative set of resources covering each
-- v1 body kind plus a non-OK status. workspace_agent_id matches an
-- existing fixture row from 000507_boundary_sessions_and_logs.
INSERT INTO workspace_agent_context_snapshots (
workspace_agent_id,
version,
aggregate_hash,
snapshot_error,
received_at
) VALUES (
'45e89705-e09d-4850-bcec-f9a937f5d78d',
1,
'\x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
'',
'2026-06-01 12:00:00+00'
);
INSERT INTO workspace_agent_context_resources (
workspace_agent_id,
source,
body_kind,
body,
content_hash,
size_bytes,
status,
error,
source_path,
created_at,
updated_at
) VALUES
(
'45e89705-e09d-4850-bcec-f9a937f5d78d',
'/home/coder/workspace/AGENTS.md',
'instruction_file',
'{"content":"aGVsbG8="}',
'\x1111111111111111111111111111111111111111111111111111111111111111',
5,
'ok',
'',
'',
'2026-06-01 12:00:00+00',
'2026-06-01 12:00:00+00'
),
(
'45e89705-e09d-4850-bcec-f9a937f5d78d',
'/home/coder/workspace/.agents/skills/example/SKILL.md',
'skill',
'{"meta":"LS0tCm5hbWU6IGV4YW1wbGUKLS0tCmJvZHk=","name":"example","description":"Example skill"}',
'\x2222222222222222222222222222222222222222222222222222222222222222',
32,
'ok',
'',
'/home/coder/workspace',
'2026-06-01 12:00:00+00',
'2026-06-01 12:00:00+00'
),
(
'45e89705-e09d-4850-bcec-f9a937f5d78d',
'/home/coder/workspace/.mcp.json',
'mcp_config',
'{}',
'\x3333333333333333333333333333333333333333333333333333333333333333',
128,
'ok',
'',
'',
'2026-06-01 12:00:00+00',
'2026-06-01 12:00:00+00'
),
(
'45e89705-e09d-4850-bcec-f9a937f5d78d',
'mcp:echo',
'mcp_server',
'{"server_name":"echo","description":"echoes input"}',
'\x4444444444444444444444444444444444444444444444444444444444444444',
256,
'ok',
'',
'/home/coder/workspace/.mcp.json',
'2026-06-01 12:00:00+00',
'2026-06-01 12:00:00+00'
),
(
'45e89705-e09d-4850-bcec-f9a937f5d78d',
'/home/coder/workspace/big.md',
'instruction_file',
'{}',
'\x5555555555555555555555555555555555555555555555555555555555555555',
99999,
'oversize',
'file exceeds 64KiB per-resource cap',
'',
'2026-06-01 12:00:00+00',
'2026-06-01 12:00:00+00'
);
+179
View File
@@ -3798,6 +3798,149 @@ func AllUserStatusValues() []UserStatus {
}
}
type WorkspaceAgentContextBodyKind string
const (
WorkspaceAgentContextBodyKindInstructionFile WorkspaceAgentContextBodyKind = "instruction_file"
WorkspaceAgentContextBodyKindSkill WorkspaceAgentContextBodyKind = "skill"
WorkspaceAgentContextBodyKindMcpConfig WorkspaceAgentContextBodyKind = "mcp_config"
WorkspaceAgentContextBodyKindMcpServer WorkspaceAgentContextBodyKind = "mcp_server"
WorkspaceAgentContextBodyKindPlugin WorkspaceAgentContextBodyKind = "plugin"
WorkspaceAgentContextBodyKindHook WorkspaceAgentContextBodyKind = "hook"
WorkspaceAgentContextBodyKindSubagent WorkspaceAgentContextBodyKind = "subagent"
WorkspaceAgentContextBodyKindCommand WorkspaceAgentContextBodyKind = "command"
)
func (e *WorkspaceAgentContextBodyKind) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WorkspaceAgentContextBodyKind(s)
case string:
*e = WorkspaceAgentContextBodyKind(s)
default:
return fmt.Errorf("unsupported scan type for WorkspaceAgentContextBodyKind: %T", src)
}
return nil
}
type NullWorkspaceAgentContextBodyKind struct {
WorkspaceAgentContextBodyKind WorkspaceAgentContextBodyKind `json:"workspace_agent_context_body_kind"`
Valid bool `json:"valid"` // Valid is true if WorkspaceAgentContextBodyKind is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWorkspaceAgentContextBodyKind) Scan(value interface{}) error {
if value == nil {
ns.WorkspaceAgentContextBodyKind, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WorkspaceAgentContextBodyKind.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWorkspaceAgentContextBodyKind) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WorkspaceAgentContextBodyKind), nil
}
func (e WorkspaceAgentContextBodyKind) Valid() bool {
switch e {
case WorkspaceAgentContextBodyKindInstructionFile,
WorkspaceAgentContextBodyKindSkill,
WorkspaceAgentContextBodyKindMcpConfig,
WorkspaceAgentContextBodyKindMcpServer,
WorkspaceAgentContextBodyKindPlugin,
WorkspaceAgentContextBodyKindHook,
WorkspaceAgentContextBodyKindSubagent,
WorkspaceAgentContextBodyKindCommand:
return true
}
return false
}
func AllWorkspaceAgentContextBodyKindValues() []WorkspaceAgentContextBodyKind {
return []WorkspaceAgentContextBodyKind{
WorkspaceAgentContextBodyKindInstructionFile,
WorkspaceAgentContextBodyKindSkill,
WorkspaceAgentContextBodyKindMcpConfig,
WorkspaceAgentContextBodyKindMcpServer,
WorkspaceAgentContextBodyKindPlugin,
WorkspaceAgentContextBodyKindHook,
WorkspaceAgentContextBodyKindSubagent,
WorkspaceAgentContextBodyKindCommand,
}
}
type WorkspaceAgentContextResourceStatus string
const (
WorkspaceAgentContextResourceStatusOk WorkspaceAgentContextResourceStatus = "ok"
WorkspaceAgentContextResourceStatusOversize WorkspaceAgentContextResourceStatus = "oversize"
WorkspaceAgentContextResourceStatusUnreadable WorkspaceAgentContextResourceStatus = "unreadable"
WorkspaceAgentContextResourceStatusInvalid WorkspaceAgentContextResourceStatus = "invalid"
WorkspaceAgentContextResourceStatusExcluded WorkspaceAgentContextResourceStatus = "excluded"
)
func (e *WorkspaceAgentContextResourceStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WorkspaceAgentContextResourceStatus(s)
case string:
*e = WorkspaceAgentContextResourceStatus(s)
default:
return fmt.Errorf("unsupported scan type for WorkspaceAgentContextResourceStatus: %T", src)
}
return nil
}
type NullWorkspaceAgentContextResourceStatus struct {
WorkspaceAgentContextResourceStatus WorkspaceAgentContextResourceStatus `json:"workspace_agent_context_resource_status"`
Valid bool `json:"valid"` // Valid is true if WorkspaceAgentContextResourceStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWorkspaceAgentContextResourceStatus) Scan(value interface{}) error {
if value == nil {
ns.WorkspaceAgentContextResourceStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WorkspaceAgentContextResourceStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWorkspaceAgentContextResourceStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WorkspaceAgentContextResourceStatus), nil
}
func (e WorkspaceAgentContextResourceStatus) Valid() bool {
switch e {
case WorkspaceAgentContextResourceStatusOk,
WorkspaceAgentContextResourceStatusOversize,
WorkspaceAgentContextResourceStatusUnreadable,
WorkspaceAgentContextResourceStatusInvalid,
WorkspaceAgentContextResourceStatusExcluded:
return true
}
return false
}
func AllWorkspaceAgentContextResourceStatusValues() []WorkspaceAgentContextResourceStatus {
return []WorkspaceAgentContextResourceStatus{
WorkspaceAgentContextResourceStatusOk,
WorkspaceAgentContextResourceStatusOversize,
WorkspaceAgentContextResourceStatusUnreadable,
WorkspaceAgentContextResourceStatusInvalid,
WorkspaceAgentContextResourceStatusExcluded,
}
}
type WorkspaceAgentLifecycleState string
const (
@@ -5983,6 +6126,42 @@ type WorkspaceAgent struct {
Deleted bool `db:"deleted" json:"deleted"`
}
// Per-resource state for the latest pushed workspace agent context snapshot.
type WorkspaceAgentContextResource struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
// Resource locator: canonical file path for file-backed kinds, or the MCP server name for mcp_server resources.
Source string `db:"source" json:"source"`
// Discriminator for the body JSON shape. Matches the proto oneof variant: instruction_file, skill, mcp_config, mcp_server. PLUGIN/HOOK/SUBAGENT/COMMAND are reserved for the Claude Code plugin RFC.
BodyKind WorkspaceAgentContextBodyKind `db:"body_kind" json:"body_kind"`
// protojson-encoded variant body matching body_kind. Always populated; non-OK statuses use the variant zero value so the wire kind is still attributable.
Body json.RawMessage `db:"body" json:"body"`
// sha256 over the resource's original bytes (or transport-encoded server tool list).
ContentHash []byte `db:"content_hash" json:"content_hash"`
// Original payload size in bytes; populated regardless of status.
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
// Per-resource status. ok carries a populated body; oversize, unreadable, invalid, and excluded carry an empty body plus an error string.
Status WorkspaceAgentContextResourceStatus `db:"status" json:"status"`
// Per-resource error or warning string. Populated whenever status is non-ok; may also carry a non-fatal warning when status is ok.
Error string `db:"error" json:"error"`
// User-declared scan root that produced this resource. Empty for built-in scan roots.
SourcePath string `db:"source_path" json:"source_path"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Latest workspace agent context snapshot received via PushContextState. One row per workspace agent, overwritten in place.
type WorkspaceAgentContextSnapshot struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
// Monotonic per-agent-process push counter. Resets to one when the agent process restarts; combined with the initial flag on the wire to detect agent reboots.
Version int64 `db:"version" json:"version"`
// sha256 over a canonical encoding of every resource in the snapshot. Identical inputs always produce identical hashes; chat hydration uses this to detect drift.
AggregateHash []byte `db:"aggregate_hash" json:"aggregate_hash"`
// Singular snapshot-level error string (count cap exceeded, watcher degraded, etc.). Empty when healthy.
SnapshotError string `db:"snapshot_error" json:"snapshot_error"`
// Time at which coderd received the push.
ReceivedAt time.Time `db:"received_at" json:"received_at"`
}
// Workspace agent devcontainer configuration
type WorkspaceAgentDevcontainer struct {
// Unique identifier
+25
View File
@@ -216,6 +216,10 @@ type sqlcQuerier interface {
DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error
DeleteRuntimeConfig(ctx context.Context, key string) error
DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds int32) (int64, error)
// Deletes any resources for the agent whose source is not in the
// supplied active set. Atomic alongside the snapshot upsert so the
// stored snapshot and resource rows always agree.
DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg DeleteStaleWorkspaceAgentContextResourcesParams) error
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error)
@@ -231,6 +235,15 @@ type sqlcQuerier interface {
DeleteWorkspaceACLsByOrganization(ctx context.Context, arg DeleteWorkspaceACLsByOrganizationParams) error
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
// Soft-deletes a single sub-agent (a child agent such as a devcontainer
// agent). Called from the DeleteSubAgent RPC when a sub-agent is torn
// down, which can happen mid-build without a full workspace rebuild.
//
// Agent context rows are hard-deleted for the same reason as in
// SoftDeletePriorWorkspaceAgents: they only describe live agents, the
// rebuild-time soft-delete queries skip already-deleted agents, and
// agents are never hard-deleted, so the rows would otherwise orphan
// forever.
DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error
// Disable foreign keys and triggers for all tables.
// Deprecated: disable foreign keys was created to aid in migrating off
@@ -574,6 +587,7 @@ type sqlcQuerier interface {
GetLastChatMessageByRole(ctx context.Context, arg GetLastChatMessageByRoleParams) (ChatMessage, error)
GetLastUpdateCheck(ctx context.Context) (string, error)
GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error)
GetLatestWorkspaceAgentContextSnapshot(ctx context.Context, workspaceAgentID uuid.UUID) (WorkspaceAgentContextSnapshot, error)
GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (WorkspaceAppStatus, error)
GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error)
GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error)
@@ -1153,6 +1167,7 @@ type sqlcQuerier interface {
// (runtime injection).
ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
ListUserSkillMetadataByUserID(ctx context.Context, userID uuid.UUID) ([]ListUserSkillMetadataByUserIDRow, error)
ListWorkspaceAgentContextResources(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentContextResource, error)
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
// Locks the chat row with FOR UPDATE and atomically increments its
// snapshot_version, returning the post-bump chat. This is the single
@@ -1212,12 +1227,20 @@ type sqlcQuerier interface {
// provisionerdserver when a workspace build completes, after the new
// build's agents have been inserted, so running agents are not
// deleted while a build is still queued or provisioning.
//
// Agent context rows (workspace_agent_context_snapshots and
// workspace_agent_context_resources) only describe live agents, and
// agents are never un-deleted, so they are hard-deleted here instead
// of accumulating alongside the soft-deleted agent rows.
SoftDeletePriorWorkspaceAgents(ctx context.Context, arg SoftDeletePriorWorkspaceAgentsParams) error
// Marks every non-deleted agent belonging to the given workspace as
// deleted. Called alongside UpdateWorkspaceDeletedByID when a workspace
// itself is soft-deleted, so the agent instance-identity auth path
// (which filters on workspace_agents.deleted) doesn't keep seeing
// orphaned rows.
//
// Agent context rows are hard-deleted for the same reason as in
// SoftDeletePriorWorkspaceAgents.
SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error
// Overrides updated_at on the parent run without touching any
// other column. Used by tests that need to stamp a run with a
@@ -1524,6 +1547,8 @@ type sqlcQuerier interface {
UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error
UpsertUserChatPersonalModelOverride(ctx context.Context, arg UpsertUserChatPersonalModelOverrideParams) error
UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error
UpsertWorkspaceAgentContextResource(ctx context.Context, arg UpsertWorkspaceAgentContextResourceParams) (WorkspaceAgentContextResource, error)
UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg UpsertWorkspaceAgentContextSnapshotParams) (WorkspaceAgentContextSnapshot, error)
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error)
//
+133
View File
@@ -15122,6 +15122,139 @@ func TestSoftDeleteWorkspaceAgentsByWorkspaceID(t *testing.T) {
require.NoError(t, err)
}
// TestSoftDeleteWorkspaceAgentsPurgesContext verifies that both agent
// soft-delete queries hard-delete the agents' pushed context rows
// (workspace_agent_context_snapshots and
// workspace_agent_context_resources). Agents are only ever
// soft-deleted, so without this the context rows would accumulate
// forever.
func TestSoftDeleteWorkspaceAgentsPurgesContext(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
tpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tplVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
OrganizationID: org.ID,
CreatedBy: user.ID,
})
type buildBundle struct {
buildID uuid.UUID
agentID uuid.UUID
agent database.WorkspaceAgent
}
newBuild := func(t *testing.T, wsID uuid.UUID, buildNumber int32) buildBundle {
t.Helper()
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: wsID,
JobID: job.ID,
TemplateVersionID: tplVersion.ID,
BuildNumber: buildNumber,
Transition: database.WorkspaceTransitionStart,
})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID})
return buildBundle{buildID: build.ID, agentID: agent.ID, agent: agent}
}
pushContext := func(t *testing.T, agentID uuid.UUID) {
t.Helper()
_, err := db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: agentID,
Version: 1,
AggregateHash: []byte{0x01},
ReceivedAt: dbtime.Now(),
})
require.NoError(t, err)
_, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{
WorkspaceAgentID: agentID,
Source: "/workspace/AGENTS.md",
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
Body: []byte(`{}`),
ContentHash: []byte{0x02},
SizeBytes: 2,
Status: database.WorkspaceAgentContextResourceStatusOk,
Now: dbtime.Now(),
})
require.NoError(t, err)
}
hasContext := func(t *testing.T, agentID uuid.UUID) bool {
t.Helper()
_, err := db.GetLatestWorkspaceAgentContextSnapshot(ctx, agentID)
if errors.Is(err, sql.ErrNoRows) {
resources, err := db.ListWorkspaceAgentContextResources(ctx, agentID)
require.NoError(t, err)
require.Empty(t, resources, "snapshot and resource rows must be deleted together")
return false
}
require.NoError(t, err)
return true
}
wsA := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
TemplateID: tpl.ID,
OwnerID: user.ID,
}).ID
wsB := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
TemplateID: tpl.ID,
OwnerID: user.ID,
}).ID
a1 := newBuild(t, wsA, 1)
a2 := newBuild(t, wsA, 2)
b1 := newBuild(t, wsB, 1)
pushContext(t, a1.agentID)
pushContext(t, a2.agentID)
pushContext(t, b1.agentID)
// Soft-deleting wsA's prior agents purges a1's context but leaves
// the current build's agent and other workspaces untouched.
err := db.SoftDeletePriorWorkspaceAgents(ctx, database.SoftDeletePriorWorkspaceAgentsParams{
WorkspaceID: wsA,
CurrentBuildID: a2.buildID,
})
require.NoError(t, err)
assert.False(t, hasContext(t, a1.agentID), "prior build agent context must be purged")
assert.True(t, hasContext(t, a2.agentID), "current build agent context must remain")
assert.True(t, hasContext(t, b1.agentID), "other workspace agent context must remain")
// Soft-deleting all of wsB's agents purges b1's context.
err = db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, wsB)
require.NoError(t, err)
assert.True(t, hasContext(t, a2.agentID), "other workspace agent context must remain")
assert.False(t, hasContext(t, b1.agentID), "deleted workspace agent context must be purged")
// Removing a sub-agent mid-build via DeleteWorkspaceSubAgentByID purges
// only that sub-agent's context. The rebuild-time queries skip
// already-deleted agents, so this is the sole cleanup opportunity.
c1 := newBuild(t, wsA, 3)
subAgent := dbgen.WorkspaceSubAgent(t, db, c1.agent, database.WorkspaceAgent{})
pushContext(t, c1.agentID)
pushContext(t, subAgent.ID)
err = db.DeleteWorkspaceSubAgentByID(ctx, subAgent.ID)
require.NoError(t, err)
assert.True(t, hasContext(t, c1.agentID), "parent agent context must remain")
assert.False(t, hasContext(t, subAgent.ID), "deleted sub-agent context must be purged")
}
func TestAIGatewayKeysTableConstraints(t *testing.T) {
t.Parallel()
+273 -27
View File
@@ -31114,6 +31114,214 @@ func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (
return i, err
}
const deleteStaleWorkspaceAgentContextResources = `-- name: DeleteStaleWorkspaceAgentContextResources :exec
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id = $1
AND NOT (source = ANY($2 :: text[]))
`
type DeleteStaleWorkspaceAgentContextResourcesParams struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
ActiveSources []string `db:"active_sources" json:"active_sources"`
}
// Deletes any resources for the agent whose source is not in the
// supplied active set. Atomic alongside the snapshot upsert so the
// stored snapshot and resource rows always agree.
func (q *sqlQuerier) DeleteStaleWorkspaceAgentContextResources(ctx context.Context, arg DeleteStaleWorkspaceAgentContextResourcesParams) error {
_, err := q.db.ExecContext(ctx, deleteStaleWorkspaceAgentContextResources, arg.WorkspaceAgentID, pq.Array(arg.ActiveSources))
return err
}
const getLatestWorkspaceAgentContextSnapshot = `-- name: GetLatestWorkspaceAgentContextSnapshot :one
SELECT workspace_agent_id, version, aggregate_hash, snapshot_error, received_at FROM workspace_agent_context_snapshots
WHERE workspace_agent_id = $1
`
func (q *sqlQuerier) GetLatestWorkspaceAgentContextSnapshot(ctx context.Context, workspaceAgentID uuid.UUID) (WorkspaceAgentContextSnapshot, error) {
row := q.db.QueryRowContext(ctx, getLatestWorkspaceAgentContextSnapshot, workspaceAgentID)
var i WorkspaceAgentContextSnapshot
err := row.Scan(
&i.WorkspaceAgentID,
&i.Version,
&i.AggregateHash,
&i.SnapshotError,
&i.ReceivedAt,
)
return i, err
}
const listWorkspaceAgentContextResources = `-- name: ListWorkspaceAgentContextResources :many
SELECT workspace_agent_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path, created_at, updated_at FROM workspace_agent_context_resources
WHERE workspace_agent_id = $1
ORDER BY source ASC
`
func (q *sqlQuerier) ListWorkspaceAgentContextResources(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentContextResource, error) {
rows, err := q.db.QueryContext(ctx, listWorkspaceAgentContextResources, workspaceAgentID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceAgentContextResource
for rows.Next() {
var i WorkspaceAgentContextResource
if err := rows.Scan(
&i.WorkspaceAgentID,
&i.Source,
&i.BodyKind,
&i.Body,
&i.ContentHash,
&i.SizeBytes,
&i.Status,
&i.Error,
&i.SourcePath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertWorkspaceAgentContextResource = `-- name: UpsertWorkspaceAgentContextResource :one
INSERT INTO workspace_agent_context_resources (
workspace_agent_id,
source,
body_kind,
body,
content_hash,
size_bytes,
status,
error,
source_path,
created_at,
updated_at
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$10
)
ON CONFLICT (workspace_agent_id, source) DO UPDATE SET
body_kind = EXCLUDED.body_kind,
body = EXCLUDED.body,
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
status = EXCLUDED.status,
error = EXCLUDED.error,
source_path = EXCLUDED.source_path,
updated_at = EXCLUDED.updated_at
RETURNING workspace_agent_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path, created_at, updated_at
`
type UpsertWorkspaceAgentContextResourceParams struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Source string `db:"source" json:"source"`
BodyKind WorkspaceAgentContextBodyKind `db:"body_kind" json:"body_kind"`
Body json.RawMessage `db:"body" json:"body"`
ContentHash []byte `db:"content_hash" json:"content_hash"`
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
Status WorkspaceAgentContextResourceStatus `db:"status" json:"status"`
Error string `db:"error" json:"error"`
SourcePath string `db:"source_path" json:"source_path"`
Now time.Time `db:"now" json:"now"`
}
func (q *sqlQuerier) UpsertWorkspaceAgentContextResource(ctx context.Context, arg UpsertWorkspaceAgentContextResourceParams) (WorkspaceAgentContextResource, error) {
row := q.db.QueryRowContext(ctx, upsertWorkspaceAgentContextResource,
arg.WorkspaceAgentID,
arg.Source,
arg.BodyKind,
arg.Body,
arg.ContentHash,
arg.SizeBytes,
arg.Status,
arg.Error,
arg.SourcePath,
arg.Now,
)
var i WorkspaceAgentContextResource
err := row.Scan(
&i.WorkspaceAgentID,
&i.Source,
&i.BodyKind,
&i.Body,
&i.ContentHash,
&i.SizeBytes,
&i.Status,
&i.Error,
&i.SourcePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertWorkspaceAgentContextSnapshot = `-- name: UpsertWorkspaceAgentContextSnapshot :one
INSERT INTO workspace_agent_context_snapshots (
workspace_agent_id,
version,
aggregate_hash,
snapshot_error,
received_at
) VALUES (
$1,
$2,
$3,
$4,
$5
)
ON CONFLICT (workspace_agent_id) DO UPDATE SET
version = EXCLUDED.version,
aggregate_hash = EXCLUDED.aggregate_hash,
snapshot_error = EXCLUDED.snapshot_error,
received_at = EXCLUDED.received_at
RETURNING workspace_agent_id, version, aggregate_hash, snapshot_error, received_at
`
type UpsertWorkspaceAgentContextSnapshotParams struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Version int64 `db:"version" json:"version"`
AggregateHash []byte `db:"aggregate_hash" json:"aggregate_hash"`
SnapshotError string `db:"snapshot_error" json:"snapshot_error"`
ReceivedAt time.Time `db:"received_at" json:"received_at"`
}
func (q *sqlQuerier) UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg UpsertWorkspaceAgentContextSnapshotParams) (WorkspaceAgentContextSnapshot, error) {
row := q.db.QueryRowContext(ctx, upsertWorkspaceAgentContextSnapshot,
arg.WorkspaceAgentID,
arg.Version,
arg.AggregateHash,
arg.SnapshotError,
arg.ReceivedAt,
)
var i WorkspaceAgentContextSnapshot
err := row.Scan(
&i.WorkspaceAgentID,
&i.Version,
&i.AggregateHash,
&i.SnapshotError,
&i.ReceivedAt,
)
return i, err
}
const getWorkspaceAgentDevcontainersByAgentID = `-- name: GetWorkspaceAgentDevcontainersByAgentID :many
SELECT
id, workspace_agent_id, created_at, workspace_folder, config_path, name, subagent_id
@@ -31800,16 +32008,30 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold
}
const deleteWorkspaceSubAgentByID = `-- name: DeleteWorkspaceSubAgentByID :exec
UPDATE
workspace_agents
SET
deleted = TRUE
WHERE
id = $1
AND parent_id IS NOT NULL
AND deleted = FALSE
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id = $1
AND parent_id IS NOT NULL
AND deleted = FALSE
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
`
// Soft-deletes a single sub-agent (a child agent such as a devcontainer
// agent). Called from the DeleteSubAgent RPC when a sub-agent is torn
// down, which can happen mid-build without a full workspace rebuild.
//
// Agent context rows are hard-deleted for the same reason as in
// SoftDeletePriorWorkspaceAgents: they only describe live agents, the
// rebuild-time soft-delete queries skip already-deleted agents, and
// agents are never hard-deleted, so the rows would otherwise orphan
// forever.
func (q *sqlQuerier) DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteWorkspaceSubAgentByID, id)
return err
@@ -33391,17 +33613,25 @@ func (q *sqlQuerier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg
}
const softDeletePriorWorkspaceAgents = `-- name: SoftDeletePriorWorkspaceAgents :exec
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = $1
AND wb.id <> $2
AND wa.deleted = FALSE
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = $1
AND wb.id <> $2
AND wa.deleted = FALSE
)
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
`
type SoftDeletePriorWorkspaceAgentsParams struct {
@@ -33414,22 +33644,35 @@ type SoftDeletePriorWorkspaceAgentsParams struct {
// provisionerdserver when a workspace build completes, after the new
// build's agents have been inserted, so running agents are not
// deleted while a build is still queued or provisioning.
//
// Agent context rows (workspace_agent_context_snapshots and
// workspace_agent_context_resources) only describe live agents, and
// agents are never un-deleted, so they are hard-deleted here instead
// of accumulating alongside the soft-deleted agent rows.
func (q *sqlQuerier) SoftDeletePriorWorkspaceAgents(ctx context.Context, arg SoftDeletePriorWorkspaceAgentsParams) error {
_, err := q.db.ExecContext(ctx, softDeletePriorWorkspaceAgents, arg.WorkspaceID, arg.CurrentBuildID)
return err
}
const softDeleteWorkspaceAgentsByWorkspaceID = `-- name: SoftDeleteWorkspaceAgentsByWorkspaceID :exec
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = $1
AND wa.deleted = FALSE
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = $1
AND wa.deleted = FALSE
)
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
`
// Marks every non-deleted agent belonging to the given workspace as
@@ -33437,6 +33680,9 @@ WHERE id IN (
// itself is soft-deleted, so the agent instance-identity auth path
// (which filters on workspace_agents.deleted) doesn't keep seeing
// orphaned rows.
//
// Agent context rows are hard-deleted for the same reason as in
// SoftDeletePriorWorkspaceAgents.
func (q *sqlQuerier) SoftDeleteWorkspaceAgentsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, softDeleteWorkspaceAgentsByWorkspaceID, workspaceID)
return err
@@ -0,0 +1,74 @@
-- name: UpsertWorkspaceAgentContextSnapshot :one
INSERT INTO workspace_agent_context_snapshots (
workspace_agent_id,
version,
aggregate_hash,
snapshot_error,
received_at
) VALUES (
@workspace_agent_id,
@version,
@aggregate_hash,
@snapshot_error,
@received_at
)
ON CONFLICT (workspace_agent_id) DO UPDATE SET
version = EXCLUDED.version,
aggregate_hash = EXCLUDED.aggregate_hash,
snapshot_error = EXCLUDED.snapshot_error,
received_at = EXCLUDED.received_at
RETURNING *;
-- name: UpsertWorkspaceAgentContextResource :one
INSERT INTO workspace_agent_context_resources (
workspace_agent_id,
source,
body_kind,
body,
content_hash,
size_bytes,
status,
error,
source_path,
created_at,
updated_at
) VALUES (
@workspace_agent_id,
@source,
@body_kind,
@body,
@content_hash,
@size_bytes,
@status,
@error,
@source_path,
@now,
@now
)
ON CONFLICT (workspace_agent_id, source) DO UPDATE SET
body_kind = EXCLUDED.body_kind,
body = EXCLUDED.body,
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
status = EXCLUDED.status,
error = EXCLUDED.error,
source_path = EXCLUDED.source_path,
updated_at = EXCLUDED.updated_at
RETURNING *;
-- name: DeleteStaleWorkspaceAgentContextResources :exec
-- Deletes any resources for the agent whose source is not in the
-- supplied active set. Atomic alongside the snapshot upsert so the
-- stored snapshot and resource rows always agree.
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id = @workspace_agent_id
AND NOT (source = ANY(@active_sources :: text[]));
-- name: GetLatestWorkspaceAgentContextSnapshot :one
SELECT * FROM workspace_agent_context_snapshots
WHERE workspace_agent_id = @workspace_agent_id;
-- name: ListWorkspaceAgentContextResources :many
SELECT * FROM workspace_agent_context_resources
WHERE workspace_agent_id = @workspace_agent_id
ORDER BY source ASC;
+67 -29
View File
@@ -514,14 +514,28 @@ WHERE
AND deleted = FALSE;
-- name: DeleteWorkspaceSubAgentByID :exec
UPDATE
workspace_agents
SET
deleted = TRUE
WHERE
id = $1
AND parent_id IS NOT NULL
AND deleted = FALSE;
-- Soft-deletes a single sub-agent (a child agent such as a devcontainer
-- agent). Called from the DeleteSubAgent RPC when a sub-agent is torn
-- down, which can happen mid-build without a full workspace rebuild.
--
-- Agent context rows are hard-deleted for the same reason as in
-- SoftDeletePriorWorkspaceAgents: they only describe live agents, the
-- rebuild-time soft-delete queries skip already-deleted agents, and
-- agents are never hard-deleted, so the rows would otherwise orphan
-- forever.
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id = @id
AND parent_id IS NOT NULL
AND deleted = FALSE
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents);
-- name: GetWorkspaceAgentsForMetrics :many
SELECT
@@ -577,17 +591,30 @@ LIMIT 1;
-- provisionerdserver when a workspace build completes, after the new
-- build's agents have been inserted, so running agents are not
-- deleted while a build is still queued or provisioning.
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = @workspace_id
AND wb.id <> @current_build_id
AND wa.deleted = FALSE
);
--
-- Agent context rows (workspace_agent_context_snapshots and
-- workspace_agent_context_resources) only describe live agents, and
-- agents are never un-deleted, so they are hard-deleted here instead
-- of accumulating alongside the soft-deleted agent rows.
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = @workspace_id
AND wb.id <> @current_build_id
AND wa.deleted = FALSE
)
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents);
-- name: SoftDeleteWorkspaceAgentsByWorkspaceID :exec
-- Marks every non-deleted agent belonging to the given workspace as
@@ -595,13 +622,24 @@ WHERE id IN (
-- itself is soft-deleted, so the agent instance-identity auth path
-- (which filters on workspace_agents.deleted) doesn't keep seeing
-- orphaned rows.
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = @workspace_id
AND wa.deleted = FALSE
);
--
-- Agent context rows are hard-deleted for the same reason as in
-- SoftDeletePriorWorkspaceAgents.
WITH soft_deleted_agents AS (
UPDATE workspace_agents
SET deleted = TRUE
WHERE id IN (
SELECT wa.id
FROM workspace_agents wa
JOIN workspace_resources wr ON wr.id = wa.resource_id
JOIN workspace_builds wb ON wb.job_id = wr.job_id
WHERE wb.workspace_id = @workspace_id
AND wa.deleted = FALSE
)
RETURNING id
), purged_context_resources AS (
DELETE FROM workspace_agent_context_resources
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents)
)
DELETE FROM workspace_agent_context_snapshots
WHERE workspace_agent_id IN (SELECT id FROM soft_deleted_agents);
+2
View File
@@ -110,6 +110,8 @@ const (
UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id);
UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id);
UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id);
UniqueWorkspaceAgentContextResourcesPkey UniqueConstraint = "workspace_agent_context_resources_pkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_pkey PRIMARY KEY (workspace_agent_id, source);
UniqueWorkspaceAgentContextSnapshotsPkey UniqueConstraint = "workspace_agent_context_snapshots_pkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_pkey PRIMARY KEY (workspace_agent_id);
UniqueWorkspaceAgentDevcontainersPkey UniqueConstraint = "workspace_agent_devcontainers_pkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id);
UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id);
UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id);
+65
View File
@@ -3175,6 +3175,71 @@ func buildWorkspaceWithAgent(
return r.Workspace
}
// TestWorkspaceAgentPushContextState exercises the full agent RPC path
// for PushContextState: agent token auth middleware, the v2.10 DRPC
// API, the dbauthz workspace authorization boundary, and persistence.
// The push must succeed using only the agent's own token subject.
func TestWorkspaceAgentPushContextState(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
require.Len(t, r.Agents, 1)
agentID := r.Agents[0].ID
ctx := testutil.Context(t, testutil.WaitLong)
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
aAPI, _, err := agentClient.ConnectRPC210(ctx)
require.NoError(t, err)
defer func() {
cErr := aAPI.DRPCConn().Close()
require.NoError(t, cErr)
}()
resp, err := aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{
Version: 1,
Initial: true,
AggregateHash: []byte{0x01, 0x02},
Resources: []*agentproto.ContextResource{
{
Source: "/workspace/AGENTS.md",
ContentHash: []byte{0x03, 0x04},
SizeBytes: 5,
Status: agentproto.ContextResource_OK,
Body: &agentproto.ContextResource_InstructionFile{
InstructionFile: &agentproto.InstructionFileBody{Content: []byte("hello")},
},
},
},
})
require.NoError(t, err)
require.True(t, resp.GetAccepted())
snapshot, err := db.GetLatestWorkspaceAgentContextSnapshot(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // Test assertions read agent-pushed rows directly from the store.
require.NoError(t, err)
require.EqualValues(t, 1, snapshot.Version)
resources, err := db.ListWorkspaceAgentContextResources(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // Same as above.
require.NoError(t, err)
require.Len(t, resources, 1)
require.Equal(t, "/workspace/AGENTS.md", resources[0].Source)
require.Equal(t, database.WorkspaceAgentContextBodyKindInstructionFile, resources[0].BodyKind)
require.Equal(t, database.WorkspaceAgentContextResourceStatusOk, resources[0].Status)
// A non-initial replay of the same version is dropped without error.
resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{
Version: 1,
Initial: false,
AggregateHash: []byte{0x01, 0x02},
})
require.NoError(t, err)
require.False(t, resp.GetAccepted())
}
func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest {
mp, err := aAPI.GetManifest(ctx, &agentproto.GetManifestRequest{})
require.NoError(t, err)