Files
coder/agent/agentsocket/client.go
T
Kyle Carberry 992b1ffed1 feat(agent): serve context sources over the agent socket (#26526)
## Overview

Split from #26466, scoped to **agent-only** changes. This PR exposes the
agent's context sources and snapshots over the existing agent socket.
There
are no changes outside `agent/`.

## What's included

- **agentsocket**: context source CRUD (`ContextSources`,
`GetContextSource`,
`AddContextSource`, `RemoveContextSource`) plus `GetContextSnapshot` and
`ResyncContext` RPCs, with matching client methods and proto. The server
receives the context `Manager` via `WithContextManager` and returns a
clean
  error when it is absent.
- **agentcontext**: the resync JSON response now carries the
per-resource
  `Name`, keeping the HTTP resync payload in sync with the drpc
  `PushContextState` path in `agentsocket`.
- **agent**: passes the context `Manager` to the socket server via
  `WithContextManager`.

## What's intentionally NOT here

- No MCP wiring. There are no MCP additions in `agent.go` or
`agentcontext`.
MCP ownership will land later in `agentcontext`; this PR does not build
on
  `agent/x/agentmcp`.
- No changes to `agent/x/agentmcp` or the `agentcontext` resolver. The
socket
  serves whatever context resources the `Manager` already resolves.

<details>
<summary>Context for reviewers</summary>

This is one of several PRs split out of #26466. Earlier revisions also
wired
live MCP servers through the socket; that scope was removed so this PR
stays
purely socket + context plumbing inside `agent/`. The agentcontext
resolver,
`agent/x/agentmcp`, and `agent.go` MCP startup behavior are unchanged
from
`main`.
</details>

---
_Created by Coder Agents on behalf of @kylecarbs._
2026-06-18 13:00:28 -07:00

306 lines
9.6 KiB
Go

package agentsocket
import (
"context"
"golang.org/x/xerrors"
"storj.io/drpc"
"storj.io/drpc/drpcconn"
"github.com/coder/coder/v2/agent/agentsocket/proto"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/unit"
)
// Option represents a configuration option for NewClient.
type Option func(*options)
type options struct {
path string
contextManager ContextManager
}
// WithPath sets the socket path. If not provided or empty, the client will
// auto-discover the default socket path.
func WithPath(path string) Option {
return func(opts *options) {
if path == "" {
return
}
opts.path = path
}
}
// WithContextManager supplies the workspace-context Manager the server uses to
// serve context source CRUD. Server-only; ignored by the client.
func WithContextManager(cm ContextManager) Option {
return func(opts *options) {
opts.contextManager = cm
}
}
// Client provides a client for communicating with the workspace agentsocket API.
type Client struct {
client proto.DRPCAgentSocketClient
conn drpc.Conn
}
// NewClient creates a new socket client and opens a connection to the socket.
// If path is not provided via WithPath or is empty, it will auto-discover the
// default socket path.
func NewClient(ctx context.Context, opts ...Option) (*Client, error) {
options := &options{}
for _, opt := range opts {
opt(options)
}
conn, err := dialSocket(ctx, options.path)
if err != nil {
return nil, xerrors.Errorf("connect to socket: %w", err)
}
drpcConn := drpcconn.New(conn)
client := proto.NewDRPCAgentSocketClient(drpcConn)
return &Client{
client: client,
conn: drpcConn,
}, nil
}
// Close closes the socket connection.
func (c *Client) Close() error {
return c.conn.Close()
}
// Ping sends a ping request to the agent.
func (c *Client) Ping(ctx context.Context) error {
_, err := c.client.Ping(ctx, &proto.PingRequest{})
return err
}
// SyncStart starts a unit in the dependency graph.
func (c *Client) SyncStart(ctx context.Context, unitName unit.ID) error {
_, err := c.client.SyncStart(ctx, &proto.SyncStartRequest{
Unit: string(unitName),
})
return err
}
// SyncWant declares a dependency between units.
func (c *Client) SyncWant(ctx context.Context, unitName, dependsOn unit.ID) error {
_, err := c.client.SyncWant(ctx, &proto.SyncWantRequest{
Unit: string(unitName),
DependsOn: string(dependsOn),
})
return err
}
// SyncComplete marks a unit as complete in the dependency graph.
func (c *Client) SyncComplete(ctx context.Context, unitName unit.ID) error {
_, err := c.client.SyncComplete(ctx, &proto.SyncCompleteRequest{
Unit: string(unitName),
})
return err
}
// SyncReady requests whether a unit is ready to be started. That is, all dependencies are satisfied.
func (c *Client) SyncReady(ctx context.Context, unitName unit.ID) (bool, error) {
resp, err := c.client.SyncReady(ctx, &proto.SyncReadyRequest{
Unit: string(unitName),
})
if err != nil {
return false, xerrors.Errorf("sync ready: %w", err)
}
return resp.Ready, nil
}
// SyncStatus gets the status of a unit and its dependencies.
func (c *Client) SyncStatus(ctx context.Context, unitName unit.ID) (SyncStatusResponse, error) {
resp, err := c.client.SyncStatus(ctx, &proto.SyncStatusRequest{
Unit: string(unitName),
})
if err != nil {
return SyncStatusResponse{}, err
}
var dependencies []DependencyInfo
for _, dep := range resp.Dependencies {
dependencies = append(dependencies, DependencyInfo{
DependsOn: unit.ID(dep.DependsOn),
RequiredStatus: unit.Status(dep.RequiredStatus),
CurrentStatus: unit.Status(dep.CurrentStatus),
IsSatisfied: dep.IsSatisfied,
})
}
return SyncStatusResponse{
UnitName: unitName,
Status: unit.Status(resp.Status),
IsReady: resp.IsReady,
Dependencies: dependencies,
}, nil
}
// SyncList returns all registered units and their current statuses.
func (c *Client) SyncList(ctx context.Context) ([]SyncListItem, error) {
resp, err := c.client.SyncList(ctx, &proto.SyncListRequest{})
if err != nil {
return nil, err
}
var items []SyncListItem
for _, u := range resp.Units {
items = append(items, SyncListItem{
UnitName: unit.ID(u.Unit),
Status: unit.Status(u.Status),
IsReady: u.IsReady,
})
}
return items, nil
}
// UpdateAppStatus forwards an app status update to coderd via the agent.
func (c *Client) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
return c.client.UpdateAppStatus(ctx, req)
}
// ContextSources lists the workspace-context sources registered on the agent.
func (c *Client) ContextSources(ctx context.Context) ([]ContextSource, error) {
resp, err := c.client.ContextSources(ctx, &proto.ContextSourcesRequest{})
if err != nil {
return nil, err
}
sources := make([]ContextSource, 0, len(resp.Sources))
for _, s := range resp.Sources {
sources = append(sources, ContextSource{Path: s.GetPath()})
}
return sources, nil
}
// GetContextSource returns a single registered source. The path is
// canonicalized by the agent before matching.
func (c *Client) GetContextSource(ctx context.Context, path string) (ContextSource, error) {
resp, err := c.client.GetContextSource(ctx, &proto.GetContextSourceRequest{Path: path})
if err != nil {
return ContextSource{}, err
}
return ContextSource{Path: resp.GetSource().GetPath()}, nil
}
// AddContextSource registers a new scan root on the agent.
func (c *Client) AddContextSource(ctx context.Context, path string) (ContextSource, error) {
resp, err := c.client.AddContextSource(ctx, &proto.AddContextSourceRequest{Path: path})
if err != nil {
return ContextSource{}, err
}
return ContextSource{Path: resp.GetSource().GetPath()}, nil
}
// RemoveContextSource removes a previously-registered scan root.
func (c *Client) RemoveContextSource(ctx context.Context, path string) error {
_, err := c.client.RemoveContextSource(ctx, &proto.RemoveContextSourceRequest{Path: path})
return err
}
// GetContextSnapshot returns the agent's current resolved snapshot without
// forcing a re-walk.
func (c *Client) GetContextSnapshot(ctx context.Context) (ContextSnapshot, error) {
resp, err := c.client.GetContextSnapshot(ctx, &proto.ContextSnapshotRequest{})
if err != nil {
return ContextSnapshot{}, err
}
return contextSnapshotFromProto(resp.GetSnapshot()), nil
}
// ResyncContext forces a re-walk and synchronous push, returning the resulting
// snapshot. Use it as a barrier before fanning out a refresh.
func (c *Client) ResyncContext(ctx context.Context) (ContextSnapshot, error) {
resp, err := c.client.ResyncContext(ctx, &proto.ResyncContextRequest{})
if err != nil {
return ContextSnapshot{}, err
}
return contextSnapshotFromProto(resp.GetSnapshot()), nil
}
func contextSnapshotFromProto(s *proto.ContextSnapshot) ContextSnapshot {
if s == nil {
return ContextSnapshot{}
}
out := ContextSnapshot{
Version: s.GetVersion(),
AggregateHash: s.GetAggregateHash(),
Resources: make([]ContextResource, 0, len(s.GetResources())),
PayloadBytes: s.GetPayloadBytes(),
SnapshotError: s.GetSnapshotError(),
}
for _, r := range s.GetResources() {
out.Resources = append(out.Resources, ContextResource{
ID: r.GetId(),
Kind: r.GetKind(),
Source: r.GetSource(),
SourcePath: r.GetSourcePath(),
ContentHash: r.GetContentHash(),
SizeBytes: r.GetSizeBytes(),
Status: r.GetStatus(),
Error: r.GetError(),
Name: r.GetName(),
Description: r.GetDescription(),
})
}
return out
}
// SyncStatusResponse contains the status information for a unit.
type SyncStatusResponse struct {
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
Status unit.Status `table:"status" json:"status"`
IsReady bool `table:"ready" json:"is_ready"`
Dependencies []DependencyInfo `table:"dependencies" json:"dependencies"`
}
// SyncListItem contains summary information for a single unit.
type SyncListItem struct {
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
Status unit.Status `table:"status" json:"status"`
IsReady bool `table:"ready" json:"is_ready"`
}
// DependencyInfo contains information about a unit dependency.
type DependencyInfo struct {
DependsOn unit.ID `table:"depends on,default_sort" json:"depends_on"`
RequiredStatus unit.Status `table:"required status" json:"required_status"`
CurrentStatus unit.Status `table:"current status" json:"current_status"`
IsSatisfied bool `table:"satisfied" json:"is_satisfied"`
}
// ContextSource is a registered workspace-context scan root.
type ContextSource struct {
Path string `table:"path,default_sort" json:"path"`
}
// ContextResource is a resolved workspace-context resource. Payload bytes are
// never carried over the socket.
type ContextResource struct {
Kind string `table:"kind,default_sort" json:"kind"`
Name string `table:"name" json:"name"`
Source string `table:"source" json:"source"`
SourcePath string `table:"source path" json:"source_path"`
Status string `table:"status" json:"status"`
SizeBytes uint64 `table:"size bytes" json:"size_bytes"`
Error string `table:"error" json:"error"`
Description string `table:"-" json:"description"`
ID string `table:"-" json:"id"`
ContentHash string `table:"-" json:"content_hash"`
}
// ContextSnapshot is the agent's resolved workspace-context state.
type ContextSnapshot struct {
Version uint64 `json:"version"`
AggregateHash string `json:"aggregate_hash"`
Resources []ContextResource `json:"resources"`
PayloadBytes uint64 `json:"payload_bytes"`
SnapshotError string `json:"snapshot_error"`
}