mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## 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._
334 lines
12 KiB
Go
334 lines
12 KiB
Go
package agentsocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"sync"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/agent/agentcontext"
|
|
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/agent/unit"
|
|
)
|
|
|
|
var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil)
|
|
|
|
var (
|
|
ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
|
|
ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd")
|
|
ErrContextManagerNotAvailable = xerrors.New("context manager not available")
|
|
ErrContextSourceNotFound = xerrors.New("context source not found")
|
|
)
|
|
|
|
// ContextManager is the subset of *agentcontext.Manager the socket
|
|
// service needs to serve workspace-context source CRUD. It is an
|
|
// interface so tests can supply a fake.
|
|
type ContextManager interface {
|
|
Sources() []agentcontext.Source
|
|
HasSource(path string) (canonical string, ok bool)
|
|
AddSource(s agentcontext.Source) (agentcontext.Source, error)
|
|
RemoveSource(path string) error
|
|
Snapshot() agentcontext.Snapshot
|
|
Resync(ctx context.Context) (agentcontext.Snapshot, error)
|
|
}
|
|
|
|
// DRPCAgentSocketService implements the DRPC agent socket service.
|
|
type DRPCAgentSocketService struct {
|
|
unitManager *unit.Manager
|
|
contextManager ContextManager
|
|
logger slog.Logger
|
|
|
|
mu sync.Mutex
|
|
agentAPI agentproto.DRPCAgentClient28
|
|
}
|
|
|
|
// SetAgentAPI sets the agent API client used to forward requests
|
|
// to coderd. This is called when the agent connects to coderd.
|
|
func (s *DRPCAgentSocketService) SetAgentAPI(api agentproto.DRPCAgentClient28) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.agentAPI = api
|
|
}
|
|
|
|
// ClearAgentAPI clears the agent API client. This is called when
|
|
// the agent disconnects from coderd.
|
|
func (s *DRPCAgentSocketService) ClearAgentAPI() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.agentAPI = nil
|
|
}
|
|
|
|
// Ping responds to a ping request to check if the service is alive.
|
|
func (*DRPCAgentSocketService) Ping(_ context.Context, _ *proto.PingRequest) (*proto.PingResponse, error) {
|
|
return &proto.PingResponse{}, nil
|
|
}
|
|
|
|
// SyncStart starts a unit in the dependency graph.
|
|
func (s *DRPCAgentSocketService) SyncStart(_ context.Context, req *proto.SyncStartRequest) (*proto.SyncStartResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("SyncStart: %w", ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
unitID := unit.ID(req.Unit)
|
|
|
|
if err := s.unitManager.Register(unitID); err != nil {
|
|
if !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
|
return nil, xerrors.Errorf("SyncStart: %w", err)
|
|
}
|
|
}
|
|
|
|
isReady, err := s.unitManager.IsReady(unitID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
|
}
|
|
if !isReady {
|
|
return nil, xerrors.Errorf("cannot start unit %q: unit not ready", req.Unit)
|
|
}
|
|
|
|
err = s.unitManager.UpdateStatus(unitID, unit.StatusStarted)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot start unit %q: %w", req.Unit, err)
|
|
}
|
|
|
|
return &proto.SyncStartResponse{}, nil
|
|
}
|
|
|
|
// SyncWant declares a dependency between units.
|
|
func (s *DRPCAgentSocketService) SyncWant(_ context.Context, req *proto.SyncWantRequest) (*proto.SyncWantResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("cannot add dependency: %w", ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
unitID := unit.ID(req.Unit)
|
|
dependsOnID := unit.ID(req.DependsOn)
|
|
|
|
if err := s.unitManager.Register(unitID); err != nil && !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
|
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
|
}
|
|
|
|
if err := s.unitManager.AddDependency(unitID, dependsOnID, unit.StatusComplete); err != nil {
|
|
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
|
}
|
|
|
|
return &proto.SyncWantResponse{}, nil
|
|
}
|
|
|
|
// SyncComplete marks a unit as complete in the dependency graph.
|
|
func (s *DRPCAgentSocketService) SyncComplete(_ context.Context, req *proto.SyncCompleteRequest) (*proto.SyncCompleteResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("cannot complete unit: %w", ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
unitID := unit.ID(req.Unit)
|
|
|
|
if err := s.unitManager.UpdateStatus(unitID, unit.StatusComplete); err != nil {
|
|
return nil, xerrors.Errorf("cannot complete unit %q: %w", req.Unit, err)
|
|
}
|
|
|
|
return &proto.SyncCompleteResponse{}, nil
|
|
}
|
|
|
|
// SyncReady checks whether a unit is ready to be started. That is, all dependencies are satisfied.
|
|
func (s *DRPCAgentSocketService) SyncReady(_ context.Context, req *proto.SyncReadyRequest) (*proto.SyncReadyResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("cannot check readiness: %w", ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
unitID := unit.ID(req.Unit)
|
|
isReady, err := s.unitManager.IsReady(unitID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
|
}
|
|
|
|
return &proto.SyncReadyResponse{
|
|
Ready: isReady,
|
|
}, nil
|
|
}
|
|
|
|
// SyncStatus gets the status of a unit and lists its dependencies.
|
|
func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncStatusRequest) (*proto.SyncStatusResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
unitID := unit.ID(req.Unit)
|
|
|
|
isReady, err := s.unitManager.IsReady(unitID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
|
}
|
|
|
|
dependencies, err := s.unitManager.GetAllDependencies(unitID)
|
|
switch {
|
|
case errors.Is(err, unit.ErrUnitNotFound):
|
|
dependencies = []unit.Dependency{}
|
|
case err != nil:
|
|
return nil, xerrors.Errorf("cannot get dependencies: %w", err)
|
|
}
|
|
|
|
var depInfos []*proto.DependencyInfo
|
|
for _, dep := range dependencies {
|
|
depInfos = append(depInfos, &proto.DependencyInfo{
|
|
Unit: string(dep.Unit),
|
|
DependsOn: string(dep.DependsOn),
|
|
RequiredStatus: string(dep.RequiredStatus),
|
|
CurrentStatus: string(dep.CurrentStatus),
|
|
IsSatisfied: dep.IsSatisfied,
|
|
})
|
|
}
|
|
|
|
u, err := s.unitManager.Unit(unitID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, err)
|
|
}
|
|
return &proto.SyncStatusResponse{
|
|
Status: string(u.Status()),
|
|
IsReady: isReady,
|
|
Dependencies: depInfos,
|
|
}, nil
|
|
}
|
|
|
|
// SyncList returns all registered units and their current statuses.
|
|
func (s *DRPCAgentSocketService) SyncList(_ context.Context, _ *proto.SyncListRequest) (*proto.SyncListResponse, error) {
|
|
if s.unitManager == nil {
|
|
return nil, xerrors.Errorf("cannot list units: %w", ErrUnitManagerNotAvailable)
|
|
}
|
|
|
|
units := s.unitManager.ListUnits()
|
|
var unitInfos []*proto.UnitInfo
|
|
for _, u := range units {
|
|
isReady, err := s.unitManager.IsReady(u.ID())
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot check readiness for unit %q: %w", u.ID(), err)
|
|
}
|
|
unitInfos = append(unitInfos, &proto.UnitInfo{
|
|
Unit: string(u.ID()),
|
|
Status: string(u.Status()),
|
|
IsReady: isReady,
|
|
})
|
|
}
|
|
|
|
return &proto.SyncListResponse{Units: unitInfos}, nil
|
|
}
|
|
|
|
// UpdateAppStatus forwards an app status update to coderd via the
|
|
// agent API. Returns an error if the agent is not connected.
|
|
func (s *DRPCAgentSocketService) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
|
|
s.mu.Lock()
|
|
api := s.agentAPI
|
|
s.mu.Unlock()
|
|
|
|
if api == nil {
|
|
return nil, ErrAgentAPINotConnected
|
|
}
|
|
return api.UpdateAppStatus(ctx, req)
|
|
}
|
|
|
|
// ContextSources lists the workspace-context sources registered on the agent.
|
|
func (s *DRPCAgentSocketService) ContextSources(_ context.Context, _ *proto.ContextSourcesRequest) (*proto.ContextSourcesResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
sources := s.contextManager.Sources()
|
|
out := &proto.ContextSourcesResponse{Sources: make([]*proto.ContextSource, 0, len(sources))}
|
|
for _, src := range sources {
|
|
out.Sources = append(out.Sources, &proto.ContextSource{Path: src.Path})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetContextSource returns a single registered source, canonicalizing the
|
|
// requested path before matching.
|
|
func (s *DRPCAgentSocketService) GetContextSource(_ context.Context, req *proto.GetContextSourceRequest) (*proto.GetContextSourceResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
canonical, ok := s.contextManager.HasSource(req.Path)
|
|
if !ok {
|
|
return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound)
|
|
}
|
|
return &proto.GetContextSourceResponse{Source: &proto.ContextSource{Path: canonical}}, nil
|
|
}
|
|
|
|
// AddContextSource registers a new scan root and triggers a re-resolve.
|
|
func (s *DRPCAgentSocketService) AddContextSource(_ context.Context, req *proto.AddContextSourceRequest) (*proto.AddContextSourceResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
src, err := s.contextManager.AddSource(agentcontext.Source{Path: req.Path})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("add context source: %w", err)
|
|
}
|
|
return &proto.AddContextSourceResponse{Source: &proto.ContextSource{Path: src.Path}}, nil
|
|
}
|
|
|
|
// RemoveContextSource removes a previously-registered scan root.
|
|
func (s *DRPCAgentSocketService) RemoveContextSource(_ context.Context, req *proto.RemoveContextSourceRequest) (*proto.RemoveContextSourceResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
if err := s.contextManager.RemoveSource(req.Path); err != nil {
|
|
if errors.Is(err, agentcontext.ErrSourceNotFound) {
|
|
return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound)
|
|
}
|
|
return nil, xerrors.Errorf("remove context source: %w", err)
|
|
}
|
|
return &proto.RemoveContextSourceResponse{}, nil
|
|
}
|
|
|
|
// GetContextSnapshot returns the agent's current resolved snapshot without
|
|
// forcing a re-walk.
|
|
func (s *DRPCAgentSocketService) GetContextSnapshot(_ context.Context, _ *proto.ContextSnapshotRequest) (*proto.ContextSnapshotResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
return &proto.ContextSnapshotResponse{Snapshot: contextSnapshotToProto(s.contextManager.Snapshot())}, nil
|
|
}
|
|
|
|
// ResyncContext forces a re-walk and synchronous push, returning the
|
|
// resulting snapshot. Callers use it as a barrier before fanning out a
|
|
// refresh.
|
|
func (s *DRPCAgentSocketService) ResyncContext(ctx context.Context, _ *proto.ResyncContextRequest) (*proto.ResyncContextResponse, error) {
|
|
if s.contextManager == nil {
|
|
return nil, ErrContextManagerNotAvailable
|
|
}
|
|
snap, err := s.contextManager.Resync(ctx)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("resync context: %w", err)
|
|
}
|
|
return &proto.ResyncContextResponse{Snapshot: contextSnapshotToProto(snap)}, nil
|
|
}
|
|
|
|
// contextSnapshotToProto converts an agentcontext.Snapshot to its on-wire
|
|
// form. Payload bytes are intentionally omitted; they reach coderd via the
|
|
// drpc PushContextState path. Keep the per-resource field mapping in sync
|
|
// with snapshotResponse in agent/agentcontext/api.go.
|
|
func contextSnapshotToProto(s agentcontext.Snapshot) *proto.ContextSnapshot {
|
|
out := &proto.ContextSnapshot{
|
|
Version: s.Version,
|
|
AggregateHash: hex.EncodeToString(s.AggregateHash[:]),
|
|
Resources: make([]*proto.ContextResource, 0, len(s.Resources)),
|
|
PayloadBytes: s.PayloadBytes,
|
|
SnapshotError: s.SnapshotError,
|
|
}
|
|
for _, r := range s.Resources {
|
|
out.Resources = append(out.Resources, &proto.ContextResource{
|
|
Id: r.ID,
|
|
Kind: r.Kind.String(),
|
|
Source: r.Source,
|
|
SourcePath: r.SourcePath,
|
|
ContentHash: hex.EncodeToString(r.ContentHash[:]),
|
|
SizeBytes: r.SizeBytes,
|
|
Status: r.Status.String(),
|
|
Error: r.Error,
|
|
Name: r.Name,
|
|
Description: r.Description,
|
|
})
|
|
}
|
|
return out
|
|
}
|