mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
Adds the agent half of the workspace context sources RFC. The agent now resolves instruction files, skills, and MCP configs into a typed `Snapshot`, watches the relevant paths recursively, exposes the source list over a workspace-agent HTTP API, and pushes each `Snapshot` to coderd over a new `PushContextState` RPC on Agent API v2.10. The coderd-side handler is a stub returning `Unimplemented` for now. Real persistence to `workspace_agent_context`, chatd hydration on dirty events, and the `KindMCPServer` MCP provider are tracked by [CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd). This matches the pattern used for v2.7 `ReportBoundaryLogs` in [#21293](https://github.com/coder/coder/pull/21293), which bumped the version and shipped a stub server so the wire and client could iterate before the persistence layer landed. ## What ships ### agent/agentcontext (new package) - `Source`, `Resource` (kinds `instruction_file`, `skill`, `mcp_config`, `mcp_server` plus reserved `plugin`/`hook`/`subagent`/`command`), `ResourceStatus`, `Snapshot`, `ComputeAggregateHash`. - `Manager` owns the in-memory source list, performs the initial resolve synchronously in `NewManager`, runs a re-resolve/watcher loop in `Run`, exposes `AddSource`/`RemoveSource`/`Sources`/`HasSource`/`Snapshot`/`SubscribeChanges`/`Resync`/`SeedSources`/`Close`. - `Resolver` walks scan roots, classifies recognized files, enforces 64 KiB per-resource, 2 MiB aggregate, and 500-resource caps with `StatusOversize`/`StatusExcluded`/`StatusUnreadable`/`StatusInvalid` outcomes, skips `node_modules`/`vendor`/etc., validates symlink targets stay inside the scan root, stamps `SourcePath` on user-derived resources, and optionally pulls MCP server tool lists via an `MCPProvider` interface. MCP config resources ship metadata only (size, hash) so secrets in env blocks never leave the agent. - `Watcher` is a recursive `fsnotify` wrapper with a 250 ms debounce, dynamic arming of newly created directories, and an ENOSPC-tolerant degraded mode that no-ops further syncs until the manager resyncs explicitly. - HTTP API for `GET/POST /sources`, `GET/DELETE /sources/{path}`, `POST /resync` mounted at `/api/v0/context`. - `Pusher` interface plus `RunPush` goroutine with exponential backoff capped at 30 s. `DRPCPusher` adapts the generated `DRPCAgentClient210` to `Pusher` and translates `drpcerr.Unimplemented` to `ErrPushUnimplemented` so the push loop exits cleanly when talking to coderd deployments that have not enabled the real handler. ### agent/proto (v2.10) - New messages `ContextResource`, `PushContextStateRequest`, `PushContextStateResponse` and the `PushContextState` RPC on `service Agent`. - Generated `DRPCAgentClient210` interface and `codersdk/agentsdk.Client.ConnectRPC210` / `ConnectRPC210WithRole`. - `tailnet/proto.CurrentMinor` bumped from `9` to `10`. ### Agent wiring - `agent.Options.Client` declares both v2.9 and v2.10 connectors; `run()` dials with `ConnectRPC210WithRole`. - `apiConnRoutineManager` holds a `DRPCAgentClient210`. Existing v2.8 routines keep their narrower `DRPCAgentClient28` signature thanks to interface embedding. - `startAgentAPI210` is the v2.10 counterpart to `startAgentAPI` for routines that need the new client. The push context state routine uses it. - A `contextManager` is constructed in `agent.init()`, seeded from the existing `CODER_AGENT_EXP_*_DIRS` env vars, started in its own goroutine under `gracefulCtx`, and closed in `agent.Close`. - `handleManifest` calls `Manager.SeedSources` for sources rooted at the manifest directory, then `Resync` after `manifest.Swap`, so the snapshot reflects the workspace working directory immediately instead of waiting for the next filesystem event. - HTTP routes mounted at `/api/v0/context` when the manager is up. ### Coderd stub `coderd/agentapi/context.go` returns `drpcerr.Unimplemented` for `PushContextState`. The real handler that persists `workspace_agent_context` rows, hydrates chats, and emits dirty events lives in CODAGT-569. ## Tests 24 tests across `agent/agentcontext` cover types, paths, resolver behavior with file caps, skill containers, MCP secret omission, symlink target validation, the recursive watcher firing on real fsnotify events, manager source CRUD / `Resync` / `SeedSources` / `Run` lifetime, the HTTP API, the DRPC adapter, and the push retry / initial-flag / unimplemented paths. Passes `go test -race -count=2`. `TestAgent_ContextStatePushed` boots a full agent against `agenttest.FakeAgentAPI` (which now records `PushContextState` traffic) and asserts the seeded `AGENTS.md` appears in a snapshot push with `schema_version = 1`. <details> <summary>Notes for reviewers</summary> - Source CRUD is workspace-agent-token only; coderd is not in the path for source mutation. - Per-resource cap 64 KiB, aggregate 2 MiB, count cap 500; resources past the cap ship with `StatusExcluded` and an empty payload so the aggregate hash still detects content edits. MCP-emitted resources enforce both a per-provider count cap and the aggregate byte cap. - Symlinks inside the scan root are followed; symlinks pointing outside (or broken) are rejected with `StatusExcluded` so credentials reachable via a stray symlink stay off the wire. - The initial push gates `lifecycle = ready` in the eventual full design. For this PR the `SeedSources` plus `handleManifest`-driven `Resync` keeps the snapshot fresh; the live push loop ships now and DRPCPusher translates the coderd `Unimplemented` stub into a clean exit. - The `PLUGIN`/`HOOK`/`SUBAGENT`/`COMMAND` kinds are reserved in proto and Go enums but unused; the Claude Code plugin resolver ships in a follow-up that does not need a schema migration. - Two follow-ups remain, both tracked by [CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd): (1) the chatd-side handler that persists snapshots and dirties chats; (2) the `coder exp chat context` CLI command set for `list`/`show`/`add`/`remove`/`refresh`. </details> _This PR was authored by Coder Agents on Kyle Carberry's behalf._
683 lines
21 KiB
Go
683 lines
21 KiB
Go
package agenttest
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
"storj.io/drpc/drpcmux"
|
|
"storj.io/drpc/drpcserver"
|
|
"tailscale.com/tailcfg"
|
|
|
|
"cdr.dev/slog/v3"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
"github.com/coder/coder/v2/tailnet/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
// StatsInterval is the report interval returned by FakeAgentAPI.UpdateStats.
|
|
const StatsInterval = 500 * time.Millisecond
|
|
|
|
func NewClient(t testing.TB,
|
|
logger slog.Logger,
|
|
agentID uuid.UUID,
|
|
manifest agentsdk.Manifest,
|
|
statsChan chan *agentproto.Stats,
|
|
coordinator tailnet.Coordinator,
|
|
) *Client {
|
|
return NewClientWithSecrets(t, logger, agentID, manifest, nil, statsChan, coordinator)
|
|
}
|
|
|
|
// NewClientWithSecrets is like NewClient but also injects user
|
|
// secrets into the agent's proto manifest. Separate from NewClient
|
|
// because agentsdk.Manifest intentionally does not carry secrets;
|
|
// see the Manifest doc comment in codersdk/agentsdk.
|
|
func NewClientWithSecrets(t testing.TB,
|
|
logger slog.Logger,
|
|
agentID uuid.UUID,
|
|
manifest agentsdk.Manifest,
|
|
secrets []agentsdk.WorkspaceSecret,
|
|
statsChan chan *agentproto.Stats,
|
|
coordinator tailnet.Coordinator,
|
|
) *Client {
|
|
if manifest.AgentID == uuid.Nil {
|
|
manifest.AgentID = agentID
|
|
}
|
|
coordPtr := atomic.Pointer[tailnet.Coordinator]{}
|
|
coordPtr.Store(&coordinator)
|
|
mux := drpcmux.New()
|
|
derpMapUpdates := make(chan *tailcfg.DERPMap)
|
|
drpcService := &tailnet.DRPCService{
|
|
CoordPtr: &coordPtr,
|
|
Logger: logger.Named("tailnetsvc"),
|
|
DerpMapUpdateFrequency: time.Microsecond,
|
|
DerpMapFn: func() *tailcfg.DERPMap { return <-derpMapUpdates },
|
|
}
|
|
err := proto.DRPCRegisterTailnet(mux, drpcService)
|
|
require.NoError(t, err)
|
|
mp, err := agentsdk.ProtoFromManifest(manifest)
|
|
require.NoError(t, err)
|
|
mp.Secrets = agentsdk.ProtoFromSecrets(secrets)
|
|
fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan)
|
|
err = agentproto.DRPCRegisterAgent(mux, fakeAAPI)
|
|
require.NoError(t, err)
|
|
server := drpcserver.NewWithOptions(mux, drpcserver.Options{
|
|
Manager: drpcsdk.DefaultDRPCOptions(nil),
|
|
Log: func(err error) {
|
|
if xerrors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
logger.Debug(context.Background(), "drpc server error", slog.Error(err))
|
|
},
|
|
})
|
|
return &Client{
|
|
t: t,
|
|
logger: logger.Named("client"),
|
|
agentID: agentID,
|
|
server: server,
|
|
fakeAgentAPI: fakeAAPI,
|
|
derpMapUpdates: derpMapUpdates,
|
|
}
|
|
}
|
|
|
|
type Client struct {
|
|
t testing.TB
|
|
logger slog.Logger
|
|
agentID uuid.UUID
|
|
server *drpcserver.Server
|
|
fakeAgentAPI *FakeAgentAPI
|
|
LastWorkspaceAgent func()
|
|
|
|
mu sync.Mutex // Protects following.
|
|
logs []agentsdk.Log
|
|
derpMapUpdates chan *tailcfg.DERPMap
|
|
derpMapOnce sync.Once
|
|
refreshTokenCalls int
|
|
}
|
|
|
|
func (*Client) AsRequestOption() codersdk.RequestOption {
|
|
return func(_ *http.Request) {}
|
|
}
|
|
|
|
func (*Client) SetDialOption(*websocket.DialOptions) {}
|
|
|
|
func (*Client) GetSessionToken() string {
|
|
return "agenttest-token"
|
|
}
|
|
|
|
func (c *Client) RefreshToken(context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.refreshTokenCalls++
|
|
return nil
|
|
}
|
|
|
|
// SetUpdateStatsOverride sets a function that wraps UpdateStats calls.
|
|
// The provided function receives a next callback for the default behavior.
|
|
func (c *Client) SetUpdateStatsOverride(fn func(
|
|
ctx context.Context,
|
|
req *agentproto.UpdateStatsRequest,
|
|
next func(context.Context, *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error),
|
|
) (*agentproto.UpdateStatsResponse, error),
|
|
) {
|
|
c.fakeAgentAPI.SetUpdateStatsOverride(fn)
|
|
}
|
|
|
|
func (c *Client) GetNumRefreshTokenCalls() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.refreshTokenCalls
|
|
}
|
|
|
|
func (*Client) RewriteDERPMap(*tailcfg.DERPMap) {}
|
|
|
|
func (c *Client) Close() {
|
|
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
|
|
}
|
|
|
|
func (c *Client) ConnectRPC29WithRole(ctx context.Context, _ string) (
|
|
agentproto.DRPCAgentClient29, proto.DRPCTailnetClient28, error,
|
|
) {
|
|
return c.ConnectRPC29(ctx)
|
|
}
|
|
|
|
func (c *Client) ConnectRPC210(ctx context.Context) (
|
|
agentproto.DRPCAgentClient210, proto.DRPCTailnetClient28, error,
|
|
) {
|
|
aAPI, tAPI, err := c.ConnectRPC29(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// The concrete drpcAgentClient implements every method on
|
|
// the generated DRPCAgentClient interface, including
|
|
// PushContextState, so the assertion always succeeds for
|
|
// the fixture's own connections.
|
|
client, ok := aAPI.(agentproto.DRPCAgentClient210)
|
|
if !ok {
|
|
return nil, nil, xerrors.Errorf("agenttest: connection does not implement DRPCAgentClient210; got %T", aAPI)
|
|
}
|
|
return client, tAPI, nil
|
|
}
|
|
|
|
func (c *Client) ConnectRPC210WithRole(ctx context.Context, _ string) (
|
|
agentproto.DRPCAgentClient210, proto.DRPCTailnetClient28, error,
|
|
) {
|
|
return c.ConnectRPC210(ctx)
|
|
}
|
|
|
|
func (c *Client) ConnectRPC29(ctx context.Context) (
|
|
agentproto.DRPCAgentClient29, proto.DRPCTailnetClient28, error,
|
|
) {
|
|
conn, lis := drpcsdk.MemTransportPipe()
|
|
c.LastWorkspaceAgent = func() {
|
|
_ = conn.Close()
|
|
_ = lis.Close()
|
|
}
|
|
c.t.Cleanup(c.LastWorkspaceAgent)
|
|
serveCtx, cancel := context.WithCancel(ctx)
|
|
c.t.Cleanup(cancel)
|
|
streamID := tailnet.StreamID{
|
|
Name: "agenttest",
|
|
ID: c.agentID,
|
|
Auth: tailnet.AgentCoordinateeAuth{ID: c.agentID},
|
|
}
|
|
serveCtx = tailnet.WithStreamID(serveCtx, streamID)
|
|
go func() {
|
|
_ = c.server.Serve(serveCtx, lis)
|
|
}()
|
|
return agentproto.NewDRPCAgentClient(conn), proto.NewDRPCTailnetClient(conn), nil
|
|
}
|
|
|
|
func (c *Client) GetLifecycleStates() []codersdk.WorkspaceAgentLifecycle {
|
|
return c.fakeAgentAPI.GetLifecycleStates()
|
|
}
|
|
|
|
func (c *Client) GetStartup() <-chan *agentproto.Startup {
|
|
return c.fakeAgentAPI.startupCh
|
|
}
|
|
|
|
func (c *Client) GetMetadata() map[string]agentsdk.Metadata {
|
|
return c.fakeAgentAPI.GetMetadata()
|
|
}
|
|
|
|
func (c *Client) GetStartupLogs() []agentsdk.Log {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.logs
|
|
}
|
|
|
|
func (c *Client) SetAnnouncementBannersFunc(f func() ([]codersdk.BannerConfig, error)) {
|
|
c.fakeAgentAPI.SetAnnouncementBannersFunc(f)
|
|
}
|
|
|
|
func (c *Client) PushDERPMapUpdate(update *tailcfg.DERPMap) error {
|
|
timer := time.NewTimer(testutil.WaitShort)
|
|
defer timer.Stop()
|
|
select {
|
|
case c.derpMapUpdates <- update:
|
|
case <-timer.C:
|
|
return xerrors.New("timeout waiting to push derp map update")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) SetLogsChannel(ch chan<- *agentproto.BatchCreateLogsRequest) {
|
|
c.fakeAgentAPI.SetLogsChannel(ch)
|
|
}
|
|
|
|
func (c *Client) GetConnectionReports() []*agentproto.ReportConnectionRequest {
|
|
return c.fakeAgentAPI.GetConnectionReports()
|
|
}
|
|
|
|
func (c *Client) GetSubAgents() []*agentproto.SubAgent {
|
|
return c.fakeAgentAPI.GetSubAgents()
|
|
}
|
|
|
|
func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) {
|
|
return c.fakeAgentAPI.GetSubAgentDirectory(id)
|
|
}
|
|
|
|
func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) {
|
|
return c.fakeAgentAPI.GetSubAgentDisplayApps(id)
|
|
}
|
|
|
|
func (c *Client) GetSubAgentApps(id uuid.UUID) ([]*agentproto.CreateSubAgentRequest_App, error) {
|
|
return c.fakeAgentAPI.GetSubAgentApps(id)
|
|
}
|
|
|
|
// ContextStatePushes returns every PushContextState request the
|
|
// agent has issued to the fake server so far.
|
|
func (c *Client) ContextStatePushes() []*agentproto.PushContextStateRequest {
|
|
return c.fakeAgentAPI.ContextStatePushes()
|
|
}
|
|
|
|
type FakeAgentAPI struct {
|
|
sync.Mutex
|
|
t testing.TB
|
|
logger slog.Logger
|
|
|
|
manifest *agentproto.Manifest
|
|
startupCh chan *agentproto.Startup
|
|
statsCh chan *agentproto.Stats
|
|
appHealthCh chan *agentproto.BatchUpdateAppHealthRequest
|
|
logsCh chan<- *agentproto.BatchCreateLogsRequest
|
|
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
|
metadata map[string]agentsdk.Metadata
|
|
timings []*agentproto.Timing
|
|
connectionReports []*agentproto.ReportConnectionRequest
|
|
subAgents map[uuid.UUID]*agentproto.SubAgent
|
|
subAgentDirs map[uuid.UUID]string
|
|
subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp
|
|
subAgentApps map[uuid.UUID][]*agentproto.CreateSubAgentRequest_App
|
|
|
|
updateStatsOverride func(
|
|
ctx context.Context,
|
|
req *agentproto.UpdateStatsRequest,
|
|
next func(context.Context, *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error),
|
|
) (*agentproto.UpdateStatsResponse, error)
|
|
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
|
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
|
|
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
|
|
|
|
contextStatePushes []*agentproto.PushContextStateRequest
|
|
}
|
|
|
|
func (*FakeAgentAPI) UpdateAppStatus(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
|
|
panic("unimplemented")
|
|
}
|
|
|
|
// PushContextState records the incoming snapshot and returns
|
|
// Accepted=true. Tests that need to assert against the captured
|
|
// pushes can read them via ContextStatePushes.
|
|
func (f *FakeAgentAPI) PushContextState(_ context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
f.contextStatePushes = append(f.contextStatePushes, req)
|
|
return &agentproto.PushContextStateResponse{Accepted: true}, nil
|
|
}
|
|
|
|
// ContextStatePushes returns a snapshot of every
|
|
// PushContextState request received so far.
|
|
func (f *FakeAgentAPI) ContextStatePushes() []*agentproto.PushContextStateRequest {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
out := make([]*agentproto.PushContextStateRequest, len(f.contextStatePushes))
|
|
copy(out, f.contextStatePushes)
|
|
return out
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
|
return f.manifest, nil
|
|
}
|
|
|
|
func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
|
|
return &agentproto.ServiceBanner{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetTimings() []*agentproto.Timing {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
return slices.Clone(f.timings)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) SetAnnouncementBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
f.getAnnouncementBannersFunc = fn
|
|
f.logger.Info(context.Background(), "updated notification banners")
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetAnnouncementBanners(context.Context, *agentproto.GetAnnouncementBannersRequest) (*agentproto.GetAnnouncementBannersResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
if f.getAnnouncementBannersFunc == nil {
|
|
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: []*agentproto.BannerConfig{}}, nil
|
|
}
|
|
banners, err := f.getAnnouncementBannersFunc()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bannersProto := make([]*agentproto.BannerConfig, 0, len(banners))
|
|
for _, banner := range banners {
|
|
bannersProto = append(bannersProto, agentsdk.ProtoFromBannerConfig(banner))
|
|
}
|
|
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: bannersProto}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetResourcesMonitoringConfiguration(_ context.Context, _ *agentproto.GetResourcesMonitoringConfigurationRequest) (*agentproto.GetResourcesMonitoringConfigurationResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
if f.getResourcesMonitoringConfigurationFunc == nil {
|
|
return &agentproto.GetResourcesMonitoringConfigurationResponse{
|
|
Config: &agentproto.GetResourcesMonitoringConfigurationResponse_Config{
|
|
CollectionIntervalSeconds: 10,
|
|
NumDatapoints: 20,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
return f.getResourcesMonitoringConfigurationFunc()
|
|
}
|
|
|
|
func (f *FakeAgentAPI) PushResourcesMonitoringUsage(_ context.Context, req *agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
if f.pushResourcesMonitoringUsageFunc == nil {
|
|
return &agentproto.PushResourcesMonitoringUsageResponse{}, nil
|
|
}
|
|
|
|
return f.pushResourcesMonitoringUsageFunc(req)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) SetUpdateStatsOverride(fn func(
|
|
ctx context.Context,
|
|
req *agentproto.UpdateStatsRequest,
|
|
next func(context.Context, *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error),
|
|
) (*agentproto.UpdateStatsResponse, error),
|
|
) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
f.updateStatsOverride = fn
|
|
}
|
|
|
|
func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
|
|
f.logger.Debug(ctx, "update stats called", slog.F("req", req))
|
|
if f.updateStatsOverride != nil {
|
|
return f.updateStatsOverride(ctx, req, f.updateStatsDefault)
|
|
}
|
|
return f.updateStatsDefault(ctx, req)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) updateStatsDefault(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
|
|
// empty request is sent to get the interval; but our tests don't want empty stats requests
|
|
if req.Stats != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case f.statsCh <- req.Stats:
|
|
// OK!
|
|
}
|
|
}
|
|
return &agentproto.UpdateStatsResponse{ReportInterval: durationpb.New(StatsInterval)}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetLifecycleStates() []codersdk.WorkspaceAgentLifecycle {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
return slices.Clone(f.lifecycleStates)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) UpdateLifecycle(_ context.Context, req *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
s, err := agentsdk.LifecycleStateFromProto(req.GetLifecycle().GetState())
|
|
if assert.NoError(f.t, err) {
|
|
f.lifecycleStates = append(f.lifecycleStates, s)
|
|
}
|
|
return req.GetLifecycle(), nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
|
|
f.logger.Debug(ctx, "batch update app health", slog.F("req", req))
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case f.appHealthCh <- req:
|
|
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
|
}
|
|
}
|
|
|
|
func (f *FakeAgentAPI) AppHealthCh() <-chan *agentproto.BatchUpdateAppHealthRequest {
|
|
return f.appHealthCh
|
|
}
|
|
|
|
func (f *FakeAgentAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case f.startupCh <- req.GetStartup():
|
|
return req.GetStartup(), nil
|
|
}
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetMetadata() map[string]agentsdk.Metadata {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
return maps.Clone(f.metadata)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
if f.metadata == nil {
|
|
f.metadata = make(map[string]agentsdk.Metadata)
|
|
}
|
|
for _, md := range req.Metadata {
|
|
smd := agentsdk.MetadataFromProto(md)
|
|
f.metadata[md.Key] = smd
|
|
f.logger.Debug(ctx, "post metadata", slog.F("key", md.Key), slog.F("md", md))
|
|
}
|
|
return &agentproto.BatchUpdateMetadataResponse{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) SetLogsChannel(ch chan<- *agentproto.BatchCreateLogsRequest) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
f.logsCh = ch
|
|
}
|
|
|
|
func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.BatchCreateLogsRequest) (*agentproto.BatchCreateLogsResponse, error) {
|
|
f.logger.Info(ctx, "batch create logs called", slog.F("req", req))
|
|
f.Lock()
|
|
ch := f.logsCh
|
|
f.Unlock()
|
|
if ch != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case ch <- req:
|
|
// ok
|
|
}
|
|
}
|
|
return &agentproto.BatchCreateLogsResponse{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
|
f.Lock()
|
|
f.timings = append(f.timings, req.GetTiming())
|
|
f.Unlock()
|
|
|
|
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) ReportConnection(_ context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
|
f.Lock()
|
|
f.connectionReports = append(f.connectionReports, req)
|
|
f.Unlock()
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func (*FakeAgentAPI) ReportBoundaryLogs(_ context.Context, _ *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) {
|
|
return &agentproto.ReportBoundaryLogsResponse{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetConnectionReports() []*agentproto.ReportConnectionRequest {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
return slices.Clone(f.connectionReports)
|
|
}
|
|
|
|
func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.CreateSubAgentRequest) (*agentproto.CreateSubAgentResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
f.logger.Debug(ctx, "create sub agent called", slog.F("req", req))
|
|
|
|
// Generate IDs for the new sub-agent.
|
|
subAgentID := uuid.New()
|
|
authToken := uuid.New()
|
|
|
|
// Create the sub-agent proto object.
|
|
subAgent := &agentproto.SubAgent{
|
|
Id: subAgentID[:],
|
|
Name: req.Name,
|
|
AuthToken: authToken[:],
|
|
}
|
|
|
|
// Store the sub-agent in our map.
|
|
if f.subAgents == nil {
|
|
f.subAgents = make(map[uuid.UUID]*agentproto.SubAgent)
|
|
}
|
|
f.subAgents[subAgentID] = subAgent
|
|
if f.subAgentDirs == nil {
|
|
f.subAgentDirs = make(map[uuid.UUID]string)
|
|
}
|
|
f.subAgentDirs[subAgentID] = req.GetDirectory()
|
|
if f.subAgentDisplayApps == nil {
|
|
f.subAgentDisplayApps = make(map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp)
|
|
}
|
|
f.subAgentDisplayApps[subAgentID] = req.GetDisplayApps()
|
|
if f.subAgentApps == nil {
|
|
f.subAgentApps = make(map[uuid.UUID][]*agentproto.CreateSubAgentRequest_App)
|
|
}
|
|
f.subAgentApps[subAgentID] = req.GetApps()
|
|
|
|
// For a fake implementation, we don't create workspace apps.
|
|
// Real implementations would handle req.Apps here.
|
|
return &agentproto.CreateSubAgentResponse{
|
|
Agent: subAgent,
|
|
AppCreationErrors: nil,
|
|
}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) DeleteSubAgent(ctx context.Context, req *agentproto.DeleteSubAgentRequest) (*agentproto.DeleteSubAgentResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
f.logger.Debug(ctx, "delete sub agent called", slog.F("req", req))
|
|
|
|
subAgentID, err := uuid.FromBytes(req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Remove the sub-agent from our map.
|
|
if f.subAgents != nil {
|
|
delete(f.subAgents, subAgentID)
|
|
}
|
|
|
|
return &agentproto.DeleteSubAgentResponse{}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) ListSubAgents(ctx context.Context, req *agentproto.ListSubAgentsRequest) (*agentproto.ListSubAgentsResponse, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
f.logger.Debug(ctx, "list sub agents called", slog.F("req", req))
|
|
|
|
var agents []*agentproto.SubAgent
|
|
if f.subAgents != nil {
|
|
agents = make([]*agentproto.SubAgent, 0, len(f.subAgents))
|
|
for _, agent := range f.subAgents {
|
|
agents = append(agents, agent)
|
|
}
|
|
}
|
|
|
|
return &agentproto.ListSubAgentsResponse{
|
|
Agents: agents,
|
|
}, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetSubAgents() []*agentproto.SubAgent {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
var agents []*agentproto.SubAgent
|
|
if f.subAgents != nil {
|
|
agents = make([]*agentproto.SubAgent, 0, len(f.subAgents))
|
|
for _, agent := range f.subAgents {
|
|
agents = append(agents, agent)
|
|
}
|
|
}
|
|
return agents
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
if f.subAgentDirs == nil {
|
|
return "", xerrors.New("no sub-agent directories available")
|
|
}
|
|
|
|
dir, ok := f.subAgentDirs[id]
|
|
if !ok {
|
|
return "", xerrors.New("sub-agent directory not found")
|
|
}
|
|
|
|
return dir, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
if f.subAgentDisplayApps == nil {
|
|
return nil, xerrors.New("no sub-agent display apps available")
|
|
}
|
|
|
|
displayApps, ok := f.subAgentDisplayApps[id]
|
|
if !ok {
|
|
return nil, xerrors.New("sub-agent display apps not found")
|
|
}
|
|
|
|
return displayApps, nil
|
|
}
|
|
|
|
func (f *FakeAgentAPI) GetSubAgentApps(id uuid.UUID) ([]*agentproto.CreateSubAgentRequest_App, error) {
|
|
f.Lock()
|
|
defer f.Unlock()
|
|
|
|
if f.subAgentApps == nil {
|
|
return nil, xerrors.New("no sub-agent apps available")
|
|
}
|
|
|
|
apps, ok := f.subAgentApps[id]
|
|
if !ok {
|
|
return nil, xerrors.New("sub-agent apps not found")
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
|
return &FakeAgentAPI{
|
|
t: t,
|
|
logger: logger.Named("FakeAgentAPI"),
|
|
manifest: manifest,
|
|
statsCh: statsCh,
|
|
startupCh: make(chan *agentproto.Startup, 100),
|
|
appHealthCh: make(chan *agentproto.BatchUpdateAppHealthRequest, 100),
|
|
}
|
|
}
|