mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+74
@@ -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()
|
||||
|
||||
Generated
+81
@@ -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
@@ -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.';
|
||||
+95
@@ -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'
|
||||
);
|
||||
Generated
+179
@@ -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
|
||||
|
||||
Generated
+25
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+273
-27
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user