mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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._
676 lines
20 KiB
Go
676 lines
20 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"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 {
|
|
// Logger receives diagnostic messages. Required.
|
|
Logger slog.Logger
|
|
// Clock is the time source used for the watcher's
|
|
// debounce timer. Optional; defaults to quartz.NewReal().
|
|
Clock quartz.Clock
|
|
// WorkingDir is evaluated on every resolve, mirroring the
|
|
// existing agent convention. The result is used as a
|
|
// scan root.
|
|
WorkingDir func() string
|
|
// InitialSources seeds the Manager's source list at boot
|
|
// time. Sources from CODER_AGENT_EXP_*_DIRS env vars or
|
|
// startup scripts are layered here.
|
|
InitialSources []Source
|
|
// AllowedRoots restricts which paths may be added as
|
|
// sources at runtime. When empty the package falls back
|
|
// to [~, ~/.coder, ~/.claude] plus the working directory.
|
|
// Tests override this to exercise the validation logic
|
|
// directly; production callers leave it unset.
|
|
AllowedRoots []string
|
|
// Resolver, when non-nil, replaces the default resolver.
|
|
// Tests use this to inject MCP providers and tighten
|
|
// caps.
|
|
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
|
|
// in-memory list via the HTTP API or boot-time env seeding.
|
|
// Identity is the canonical absolute path.
|
|
type Source struct {
|
|
// Path is the canonical absolute path (symlinks resolved,
|
|
// ~ expanded). Empty means the zero value.
|
|
Path string
|
|
}
|
|
|
|
// Manager orchestrates source CRUD, resolution, watching, and
|
|
// 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
|
|
|
|
mu sync.Mutex
|
|
sources []Source
|
|
// sourceIndex maps canonical path -> position in sources
|
|
// for O(1) lookups during AddSource / RemoveSource.
|
|
sourceIndex map[string]int
|
|
|
|
// snapshot is the latest result of a resolver pass. It is
|
|
// replaced atomically under mu.
|
|
snapshot Snapshot
|
|
// version monotonically increases per resolve pass.
|
|
version uint64
|
|
// resolveEpoch increments at the start of every resolver
|
|
// pass that drops m.mu around the filesystem walk. Each
|
|
// pass captures the epoch it claimed; at publish time it
|
|
// compares its captured epoch against the current epoch and
|
|
// skips the publish if a newer pass has started, preventing
|
|
// an old walk's stale result from overwriting a newer one's
|
|
// fresh result at a higher version number.
|
|
resolveEpoch uint64
|
|
|
|
// subscribers receive a non-blocking signal whenever the
|
|
// snapshot changes. Subscribers must drain their channel
|
|
// promptly; the Manager drops sends to full channels.
|
|
subscribers map[chan struct{}]struct{}
|
|
|
|
// trigger fires when AddSource / RemoveSource / watcher
|
|
// observe a change.
|
|
trigger chan struct{}
|
|
|
|
// running tracks Run lifetime.
|
|
running bool
|
|
closed bool
|
|
closedCh chan struct{}
|
|
runDoneCh chan struct{}
|
|
runStartedCh chan struct{}
|
|
|
|
watcher *Watcher
|
|
}
|
|
|
|
// NewManager validates options, canonicalizes initial sources,
|
|
// performs the first resolver pass synchronously, and returns
|
|
// the resulting Manager. Run must be called separately to start
|
|
// the watcher and re-resolve goroutine.
|
|
func NewManager(opts ManagerOptions) *Manager {
|
|
clock := opts.Clock
|
|
if clock == nil {
|
|
clock = quartz.NewReal()
|
|
}
|
|
debounce := opts.Debounce
|
|
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{}),
|
|
}
|
|
|
|
for _, s := range opts.InitialSources {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
// Initial sources may not exist yet at boot
|
|
// time; log and skip rather than abort the
|
|
// agent.
|
|
m.logger.Warn(context.Background(),
|
|
"skipping invalid initial source",
|
|
slog.F("path", s.Path),
|
|
slog.Error(err))
|
|
continue
|
|
}
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
continue
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
}
|
|
|
|
// First snapshot is computed eagerly. The push protocol
|
|
// requires a snapshot to be present before the agent signals
|
|
// lifecycle = ready, so callers can rely on Snapshot() being
|
|
// populated immediately after NewManager returns.
|
|
m.resolveLocked()
|
|
|
|
return m
|
|
}
|
|
|
|
// Run starts the watcher and the re-resolve goroutine. Run
|
|
// blocks until ctx is canceled or Close is called. It is safe
|
|
// to call Run at most once per Manager.
|
|
func (m *Manager) Run(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
if m.running {
|
|
m.mu.Unlock()
|
|
return xerrors.New("agentcontext: Manager.Run called more than once")
|
|
}
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return xerrors.New("agentcontext: Manager already closed")
|
|
}
|
|
m.running = true
|
|
close(m.runStartedCh)
|
|
m.mu.Unlock()
|
|
// Close any early-exit path so Close does not block on
|
|
// runDoneCh after Run already set running=true. The deferred
|
|
// close runs even when NewWatcher fails.
|
|
defer close(m.runDoneCh)
|
|
|
|
watcher, err := NewWatcher(WatcherOptions{
|
|
Logger: m.logger.Named("watcher"),
|
|
Clock: m.clock,
|
|
Debounce: m.debounce,
|
|
MaxDepth: m.resolver.MaxDepth,
|
|
OnChange: m.signal,
|
|
})
|
|
if err != nil {
|
|
// NewWatcher already falls back to degraded mode on
|
|
// init failure, so an actual error here is
|
|
// exceptional.
|
|
return xerrors.Errorf("create watcher: %w", err)
|
|
}
|
|
m.mu.Lock()
|
|
m.watcher = watcher
|
|
roots := m.scanRootsLocked()
|
|
m.mu.Unlock()
|
|
watcher.Sync(ctx, roots)
|
|
|
|
defer watcher.Close()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-m.closedCh:
|
|
return nil
|
|
case <-m.trigger:
|
|
m.mu.Lock()
|
|
roots := m.scanRootsLocked()
|
|
m.mu.Unlock()
|
|
watcher.Sync(ctx, roots)
|
|
m.resolveAndBroadcast(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// started returns a channel that is closed once Run has
|
|
// claimed the running flag. Tests use it to coordinate with
|
|
// the watcher loop without polling; a closed channel never
|
|
// blocks, so this is safe to call repeatedly.
|
|
func (m *Manager) started() <-chan struct{} {
|
|
return m.runStartedCh
|
|
}
|
|
|
|
// Close stops the Manager. Close is idempotent; subsequent
|
|
// calls block until Run exits.
|
|
func (m *Manager) Close() error {
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
running := m.running
|
|
m.mu.Unlock()
|
|
if running {
|
|
<-m.runDoneCh
|
|
}
|
|
return nil
|
|
}
|
|
m.closed = true
|
|
running := m.running
|
|
close(m.closedCh)
|
|
m.mu.Unlock()
|
|
if running {
|
|
<-m.runDoneCh
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Sources returns a defensive copy of the current source list.
|
|
// The returned slice is safe to mutate.
|
|
func (m *Manager) Sources() []Source {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Source, len(m.sources))
|
|
copy(out, m.sources)
|
|
return out
|
|
}
|
|
|
|
// HasSource reports whether path matches an existing source
|
|
// after canonicalization. Returns the canonical path on
|
|
// success.
|
|
func (m *Manager) HasSource(path string) (canonical string, ok bool) {
|
|
c, err := CanonicalizePath(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
_, ok = m.sourceIndex[c]
|
|
return c, ok
|
|
}
|
|
|
|
// AddSource adds a new source. The path is canonicalized and
|
|
// validated against the AllowedRoots set. AddSource is
|
|
// idempotent.
|
|
func (m *Manager) AddSource(s Source) (Source, error) {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
return Source{}, xerrors.Errorf("canonicalize: %w", err)
|
|
}
|
|
if err := ValidateSourcePath(canonical, m.effectiveAllowedRoots()); err != nil {
|
|
return Source{}, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
out := m.sources[m.sourceIndex[canonical]]
|
|
m.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
m.mu.Unlock()
|
|
|
|
m.signal()
|
|
return Source{Path: canonical}, nil
|
|
}
|
|
|
|
// SeedSources canonicalizes and inserts a batch of trusted
|
|
// sources without applying AllowedRoots validation. It is the
|
|
// late-binding equivalent of ManagerOptions.InitialSources for
|
|
// callers that need the working directory to resolve relative
|
|
// paths but only learn the working directory after Run has
|
|
// started. Paths that fail canonicalization are silently
|
|
// skipped, matching the boot-time seeding contract. SeedSources
|
|
// is idempotent: previously seeded canonical paths are
|
|
// deduplicated via the existing source index.
|
|
//
|
|
// AddSource is the correct entry point for untrusted HTTP
|
|
// callers; this method exists only for the agent's manifest-
|
|
// triggered seeding from CODER_AGENT_EXP_*_DIRS, where the
|
|
// template author already authorized the paths.
|
|
func (m *Manager) SeedSources(sources []Source) {
|
|
if len(sources) == 0 {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
changed := false
|
|
for _, s := range sources {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
m.logger.Warn(context.Background(),
|
|
"skipping invalid seeded source",
|
|
slog.F("path", s.Path),
|
|
slog.Error(err))
|
|
continue
|
|
}
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
continue
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
changed = true
|
|
}
|
|
m.mu.Unlock()
|
|
if changed {
|
|
m.signal()
|
|
}
|
|
}
|
|
|
|
// RemoveSource removes the source matching path. Path is
|
|
// canonicalized before matching. Returns ErrSourceNotFound when
|
|
// no such source exists or when the path cannot be canonicalized.
|
|
func (m *Manager) RemoveSource(path string) error {
|
|
canonical, err := CanonicalizePath(path)
|
|
if err != nil {
|
|
// A path that does not canonicalize cannot match any
|
|
// existing source. Mirror HasSource semantics by
|
|
// reporting not-found rather than leaking the
|
|
// canonicalize error to API callers.
|
|
return ErrSourceNotFound
|
|
}
|
|
|
|
m.mu.Lock()
|
|
idx, ok := m.sourceIndex[canonical]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return ErrSourceNotFound
|
|
}
|
|
// O(n) compaction is fine for the typical handful of
|
|
// user-added sources.
|
|
m.sources = append(m.sources[:idx], m.sources[idx+1:]...)
|
|
delete(m.sourceIndex, canonical)
|
|
for i := idx; i < len(m.sources); i++ {
|
|
m.sourceIndex[m.sources[i].Path] = i
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.signal()
|
|
return nil
|
|
}
|
|
|
|
// Snapshot returns the latest Snapshot. The returned value is
|
|
// safe to share but shares the same Resources slice as the
|
|
// internal state; callers must not mutate it.
|
|
func (m *Manager) Snapshot() Snapshot {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.snapshot
|
|
}
|
|
|
|
// SubscribeChanges returns a buffered channel that receives a
|
|
// signal whenever the snapshot changes. The unsubscribe
|
|
// callback is safe to call from any goroutine and is
|
|
// idempotent.
|
|
func (m *Manager) SubscribeChanges() (<-chan struct{}, func()) {
|
|
ch := make(chan struct{}, 1)
|
|
m.mu.Lock()
|
|
m.subscribers[ch] = struct{}{}
|
|
m.mu.Unlock()
|
|
|
|
// OnceFunc returns a closure that runs the underlying
|
|
// function at most once. Subsequent invocations are no-ops,
|
|
// matching the idempotency contract callers rely on.
|
|
unsub := sync.OnceFunc(func() {
|
|
m.mu.Lock()
|
|
delete(m.subscribers, ch)
|
|
m.mu.Unlock()
|
|
// Don't close ch: readers may still be in flight.
|
|
})
|
|
return ch, unsub
|
|
}
|
|
|
|
// Resync forces an immediate re-resolve and returns the new
|
|
// Snapshot. Resync is safe to call regardless of whether Run is
|
|
// active. Like resolveAndBroadcast, Resync drops the Manager's
|
|
// mutex around the resolver pass so concurrent Sources,
|
|
// AddSource, RemoveSource, and Snapshot calls do not block on
|
|
// filesystem I/O. When the watcher is active, Resync also
|
|
// re-arms it so newly added scan roots are observed for
|
|
// subsequent edits.
|
|
func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return m.Snapshot(), ErrManagerClosed
|
|
}
|
|
roots := m.scanRootsLocked()
|
|
resolver := m.resolver
|
|
watcher := m.watcher
|
|
schemaVersion := m.schemaVersion
|
|
m.resolveEpoch++
|
|
myEpoch := m.resolveEpoch
|
|
m.mu.Unlock()
|
|
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
snap := resolver.ResolveContext(ctx, roots)
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
// Cancellation mid-walk yields a partial or empty
|
|
// Snapshot whose SnapshotError is set to
|
|
// "context canceled". Publishing it would replace
|
|
// the live Snapshot with empty resources until the
|
|
// next trigger, so bail without touching state.
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
if snap.SnapshotError == "" && watcher != nil {
|
|
if d := watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
snap.SchemaVersion = schemaVersion
|
|
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return m.Snapshot(), ErrManagerClosed
|
|
}
|
|
if m.resolveEpoch != myEpoch {
|
|
// A newer resolve pass started while this one was
|
|
// walking the filesystem. The newer pass's data
|
|
// strictly supersedes ours, so skip the publish to
|
|
// avoid overwriting a fresher Snapshot at a higher
|
|
// version. Return the currently published Snapshot,
|
|
// which is at least as fresh as ours. The watcher
|
|
// is NOT re-armed: the winning pass already synced
|
|
// with the current roots, and replaying our stale
|
|
// root set here would drop watches on sources that
|
|
// only the newer pass knows about.
|
|
published := m.snapshot
|
|
m.mu.Unlock()
|
|
return published, nil
|
|
}
|
|
m.version++
|
|
snap.Version = m.version
|
|
m.snapshot = snap
|
|
subs := make([]chan struct{}, 0, len(m.subscribers))
|
|
for ch := range m.subscribers {
|
|
subs = append(subs, ch)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if watcher != nil {
|
|
watcher.Sync(ctx, roots)
|
|
}
|
|
|
|
// The broadcast is unconditional: Resync waiters that
|
|
// triggered the pass without an actual content change
|
|
// still need to wake up. Subscribers compare snapshots via
|
|
// AggregateHash if they want to filter.
|
|
for _, ch := range subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
return snap, nil
|
|
}
|
|
|
|
// signal triggers a re-resolve. Sends are non-blocking; the
|
|
// trigger channel has a depth of 1, which coalesces bursts.
|
|
func (m *Manager) signal() {
|
|
select {
|
|
case m.trigger <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Trigger queues an asynchronous re-resolve. Trigger returns
|
|
// immediately; the Run goroutine performs the filesystem walk
|
|
// in the background and broadcasts when it finishes. Use
|
|
// Trigger when the caller wants the watcher to pick up an
|
|
// updated working directory or scan-root set but does not need
|
|
// the new Snapshot synchronously. Trigger is a no-op when Run
|
|
// has not started or the Manager is closed.
|
|
func (m *Manager) Trigger() {
|
|
m.signal()
|
|
}
|
|
|
|
// scanRootsLocked returns the list of ScanRoots to feed the
|
|
// resolver and watcher. The Manager's mutex must be held.
|
|
func (m *Manager) scanRootsLocked() []ScanRoot {
|
|
builtinRoots := defaultBuiltinRoots()
|
|
out := make([]ScanRoot, 0, 1+len(builtinRoots)+len(m.sources))
|
|
if m.workingDir != nil {
|
|
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
|
|
out = append(out, ScanRoot{Path: wd})
|
|
}
|
|
}
|
|
for _, r := range builtinRoots {
|
|
canonical, err := CanonicalizePath(r)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, ScanRoot{Path: canonical})
|
|
}
|
|
for _, s := range m.sources {
|
|
out = append(out, ScanRoot{Path: s.Path, UserSource: s.Path})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// effectiveAllowedRoots returns the AllowedRoots augmented
|
|
// with the current working directory. The working directory is
|
|
// evaluated on every call so it picks up the workspace's
|
|
// resolved path after the agent's manifest finishes loading.
|
|
// When AllowedRoots is empty the package falls back to its
|
|
// default policy ([~, ~/.coder, ~/.claude]).
|
|
func (m *Manager) effectiveAllowedRoots() []string {
|
|
var roots []string
|
|
if len(m.allowedRoots) > 0 {
|
|
roots = append(roots, m.allowedRoots...)
|
|
} else {
|
|
roots = append(roots, defaultAllowedRoots()...)
|
|
}
|
|
if m.workingDir != nil {
|
|
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
|
|
roots = append(roots, wd)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// resolveAndBroadcast computes a fresh snapshot and notifies
|
|
// every subscriber. The broadcast is unconditional: Resync
|
|
// waiters that triggered the pass without an actual content
|
|
// change still need to wake up. Subscribers compare snapshots
|
|
// via AggregateHash if they want to filter.
|
|
func (m *Manager) resolveAndBroadcast(ctx context.Context) {
|
|
// Snapshot the inputs under the lock, then release it
|
|
// before running the resolver. The resolver walks the
|
|
// filesystem, reads files, and hashes them; holding
|
|
// m.mu across that would block Sources, AddSource,
|
|
// RemoveSource, Snapshot, and SubscribeChanges for the
|
|
// duration of the pass.
|
|
m.mu.Lock()
|
|
roots := m.scanRootsLocked()
|
|
resolver := m.resolver
|
|
watcher := m.watcher
|
|
schemaVersion := m.schemaVersion
|
|
m.resolveEpoch++
|
|
myEpoch := m.resolveEpoch
|
|
m.mu.Unlock()
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
snap := resolver.ResolveContext(ctx, roots)
|
|
if err := ctx.Err(); err != nil {
|
|
// Cancellation mid-walk yields a partial or empty
|
|
// Snapshot. Publishing it would replace the live
|
|
// Snapshot with empty resources, so bail without
|
|
// touching state. The Run loop's gracefulCtx is
|
|
// canceled only at shutdown, but defensive checks
|
|
// keep the publish contract uniform with Resync.
|
|
return
|
|
}
|
|
// Surface watcher degradation as a snapshot-level error
|
|
// when the resolver did not already emit one.
|
|
if snap.SnapshotError == "" && watcher != nil {
|
|
if d := watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
snap.SchemaVersion = schemaVersion
|
|
|
|
m.mu.Lock()
|
|
if m.resolveEpoch != myEpoch {
|
|
// A newer resolve pass started while this one was
|
|
// walking the filesystem. Skip the publish so a
|
|
// stale-epoch result does not overwrite a fresher
|
|
// Snapshot at a higher version number. The newer
|
|
// pass will broadcast its own result.
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.version++
|
|
snap.Version = m.version
|
|
m.snapshot = snap
|
|
subs := make([]chan struct{}, 0, len(m.subscribers))
|
|
for ch := range m.subscribers {
|
|
subs = append(subs, ch)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
for _, ch := range subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveLocked runs the resolver inline while m.mu is held.
|
|
// It is used by the synchronous initial resolve in NewManager,
|
|
// where there is no concurrent reader. Background re-resolves
|
|
// must use resolveAndBroadcast, which drops the lock around
|
|
// filesystem I/O.
|
|
func (m *Manager) resolveLocked() {
|
|
roots := m.scanRootsLocked()
|
|
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 {
|
|
if d := m.watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
m.snapshot = snap
|
|
}
|
|
|
|
// ErrSourceNotFound is returned by RemoveSource when the
|
|
// requested path is not in the source list.
|
|
var ErrSourceNotFound = xerrors.New("source not found")
|
|
|
|
// ErrManagerClosed is returned by methods called after Close.
|
|
var ErrManagerClosed = xerrors.New("agentcontext: manager closed")
|