Files
coder/agent/agentcontext/manager.go
T
Kyle Carberry b439b06ee6 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._
2026-06-15 09:38:52 -07:00

656 lines
19 KiB
Go

package agentcontext
import (
"context"
"strings"
"sync"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/quartz"
)
// 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
}
// 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
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
}
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,
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
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
}
}
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
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
}
}
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
// 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")