feat: agents git watch backend (#22565)

Adds real-time git status watching for workspace agents, so the frontend
can subscribe over WebSocket and show
git file changes in near real-time.

1. Subscription is scoped to a **chat** via `GET
/api/experimental/chats/{chat}/git/watch`.
2. The workspace agent automatically determines which paths to watch
based on tool calls made by the chat (and its ancestor chats).
3. Workspace agent polls subscribed repo working trees on a 30s
interval, on tools calls, and on explicit `refresh` from the client.
4. Scans are rate-limited to at most once per second.
5. Edited paths are tracked **in-memory** inside the workspace agent.
There is no database persistence — state is lost on agent restart. This
will be addresses in a future PR.
6. Messages sent over WebSocket include a full-repo snapshot (unified
diff, branch, origin). A new message is emitted only when the snapshot
changes.

This PR was implemented with AI with me closely controlling what it's
doing. The code follows a plan file that was updated continuously during
implementation. Here's the file if you'd like to see it:
[project.md](https://gist.github.com/hugodutka/8722cf80c92f8a56555f7bc595b770e2).
It reflects the current state of the PR.
This commit is contained in:
Hugo Dutka
2026-03-06 10:47:55 +01:00
committed by GitHub
parent 81468323e0
commit 48ab492f49
28 changed files with 4421 additions and 14 deletions
+756
View File
@@ -0,0 +1,756 @@
// Package agentgit provides a WebSocket-based service for watching git
// repository changes on the agent. It is mounted at /api/v0/git/watch
// and allows clients to subscribe to file paths, triggering scans of
// the corresponding git repositories.
package agentgit
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/utils/diff"
dmp "github.com/sergi/go-diff/diffmatchpatch"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
// Option configures the git watch service.
type Option func(*Handler)
// WithClock sets a controllable clock for testing. Defaults to
// quartz.NewReal().
func WithClock(c quartz.Clock) Option {
return func(h *Handler) {
h.clock = c
}
}
const (
// scanCooldown is the minimum interval between successive scans.
scanCooldown = 1 * time.Second
// fallbackPollInterval is the safety-net poll period used when no
// filesystem events arrive.
fallbackPollInterval = 30 * time.Second
// maxFileReadSize is the maximum file size that will be read
// into memory. Files larger than this are tracked by status
// only, and their diffs show a placeholder message.
maxFileReadSize = 2 * 1024 * 1024 // 2 MiB
// maxFileDiffSize is the maximum encoded size of a single
// file's diff. If an individual file's diff exceeds this
// limit, it is replaced with a placeholder stub.
maxFileDiffSize = 256 * 1024 // 256 KiB
// maxTotalDiffSize is the maximum size of the combined
// unified diff for an entire repository sent over the wire.
// This must stay under the WebSocket message size limit.
maxTotalDiffSize = 3 * 1024 * 1024 // 3 MiB
)
// Handler manages per-connection git watch state.
type Handler struct {
logger slog.Logger
clock quartz.Clock
mu sync.Mutex
repoRoots map[string]struct{} // watched repo roots
lastSnapshots map[string]repoSnapshot // last emitted snapshot per repo
lastScanAt time.Time // when the last scan completed
scanTrigger chan struct{} // buffered(1), poked by triggers
}
// repoSnapshot captures the last emitted state for delta comparison.
type repoSnapshot struct {
branch string
remoteOrigin string
unifiedDiff string
}
// NewHandler creates a new git watch handler.
func NewHandler(logger slog.Logger, opts ...Option) *Handler {
h := &Handler{
logger: logger,
clock: quartz.NewReal(),
repoRoots: make(map[string]struct{}),
lastSnapshots: make(map[string]repoSnapshot),
scanTrigger: make(chan struct{}, 1),
}
for _, opt := range opts {
opt(h)
}
return h
}
// Subscribe processes a subscribe message, resolving paths to git repo
// roots and adding new repos to the watch set. Returns true if any new
// repo roots were added.
func (h *Handler) Subscribe(paths []string) bool {
h.mu.Lock()
defer h.mu.Unlock()
added := false
for _, p := range paths {
if !filepath.IsAbs(p) {
continue
}
p = filepath.Clean(p)
root, err := findRepoRoot(p)
if err != nil {
// Not a git path — silently ignore.
continue
}
if _, ok := h.repoRoots[root]; ok {
continue
}
h.repoRoots[root] = struct{}{}
added = true
}
return added
}
// RequestScan pokes the scan trigger so the run loop performs a scan.
func (h *Handler) RequestScan() {
select {
case h.scanTrigger <- struct{}{}:
default:
// Already pending.
}
}
// Scan performs a scan of all subscribed repos and computes deltas
// against the previously emitted snapshots.
func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMessage {
h.mu.Lock()
roots := make([]string, 0, len(h.repoRoots))
for r := range h.repoRoots {
roots = append(roots, r)
}
h.mu.Unlock()
if len(roots) == 0 {
return nil
}
now := h.clock.Now().UTC()
var repos []codersdk.WorkspaceAgentRepoChanges
// Perform all I/O outside the lock to avoid blocking
// AddPaths/GetPaths/Subscribe callers during disk-heavy scans.
type scanResult struct {
root string
changes codersdk.WorkspaceAgentRepoChanges
err error
}
results := make([]scanResult, 0, len(roots))
for _, root := range roots {
changes, err := getRepoChanges(ctx, h.logger, root)
results = append(results, scanResult{root: root, changes: changes, err: err})
}
// Re-acquire the lock only to commit snapshot updates.
h.mu.Lock()
defer h.mu.Unlock()
for _, res := range results {
if res.err != nil {
if isRepoDeleted(res.root) {
// Repo root or .git directory was removed.
// Emit a removal entry, then evict from watch set.
removal := codersdk.WorkspaceAgentRepoChanges{
RepoRoot: res.root,
Removed: true,
}
delete(h.repoRoots, res.root)
delete(h.lastSnapshots, res.root)
repos = append(repos, removal)
} else {
// Transient error — log and skip without
// removing the repo from the watch set.
h.logger.Warn(ctx, "scan repo failed",
slog.F("root", res.root),
slog.Error(res.err),
)
}
continue
}
prev, hasPrev := h.lastSnapshots[res.root]
if hasPrev &&
prev.branch == res.changes.Branch &&
prev.remoteOrigin == res.changes.RemoteOrigin &&
prev.unifiedDiff == res.changes.UnifiedDiff {
// No change in this repo since last emit.
continue
}
// Update snapshot.
h.lastSnapshots[res.root] = repoSnapshot{
branch: res.changes.Branch,
remoteOrigin: res.changes.RemoteOrigin,
unifiedDiff: res.changes.UnifiedDiff,
}
repos = append(repos, res.changes)
}
h.lastScanAt = now
if len(repos) == 0 {
return nil
}
return &codersdk.WorkspaceAgentGitServerMessage{
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
ScannedAt: &now,
Repositories: repos,
}
}
// RunLoop runs the main event loop that listens for refresh requests
// and fallback poll ticks. It calls scanFn whenever a scan should
// happen (rate-limited to scanCooldown). It blocks until ctx is
// canceled.
func (h *Handler) RunLoop(ctx context.Context, scanFn func()) {
fallbackTicker := h.clock.NewTicker(fallbackPollInterval)
defer fallbackTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-h.scanTrigger:
h.rateLimitedScan(ctx, scanFn)
case <-fallbackTicker.C:
h.rateLimitedScan(ctx, scanFn)
}
}
}
func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
h.mu.Lock()
elapsed := h.clock.Since(h.lastScanAt)
if elapsed < scanCooldown {
h.mu.Unlock()
// Wait for cooldown then scan.
remaining := scanCooldown - elapsed
timer := h.clock.NewTimer(remaining)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
scanFn()
return
}
h.mu.Unlock()
scanFn()
}
// isRepoDeleted returns true when the repo root directory or its .git
// entry no longer represents a valid git repository. This
// distinguishes a genuine repo deletion from a transient scan error
// (e.g. lock contention).
//
// It handles three deletion cases:
// 1. The repo root directory itself was removed.
// 2. The .git entry (directory or file) was removed.
// 3. The .git entry is a file (worktree/submodule) whose target
// gitdir was removed. In this case .git exists on disk but
// git.PlainOpen fails because the referenced directory is gone.
func isRepoDeleted(repoRoot string) bool {
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
return true
}
gitPath := filepath.Join(repoRoot, ".git")
fi, err := os.Stat(gitPath)
if os.IsNotExist(err) {
return true
}
// If .git is a regular file (worktree or submodule), the actual
// git object store lives elsewhere. Validate that the target is
// still reachable by attempting to open the repo.
if err == nil && !fi.IsDir() {
if _, openErr := git.PlainOpen(repoRoot); openErr != nil {
return true
}
}
return false
}
// findRepoRoot walks up from the given path to find a .git directory.
func findRepoRoot(p string) (string, error) {
// If p is a file, start from its directory.
dir := p
for {
_, err := git.PlainOpen(dir)
if err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", xerrors.Errorf("no git repo found for %s", p)
}
dir = parent
}
}
// getRepoChanges reads the current state of a git repository using
// go-git. It returns branch, remote origin, and per-file status.
func getRepoChanges(ctx context.Context, logger slog.Logger, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
repo, err := git.PlainOpen(repoRoot)
if err != nil {
return codersdk.WorkspaceAgentRepoChanges{}, xerrors.Errorf("open repo: %w", err)
}
result := codersdk.WorkspaceAgentRepoChanges{
RepoRoot: repoRoot,
}
// Read branch.
headRef, err := repo.Head()
if err != nil {
// Repo may have no commits yet.
logger.Debug(ctx, "failed to read HEAD", slog.F("root", repoRoot), slog.Error(err))
} else if headRef.Name().IsBranch() {
result.Branch = headRef.Name().Short()
}
// Read remote origin URL.
cfg, err := repo.Config()
if err == nil {
if origin, ok := cfg.Remotes["origin"]; ok && len(origin.URLs) > 0 {
result.RemoteOrigin = origin.URLs[0]
}
}
// Get worktree status.
wt, err := repo.Worktree()
if err != nil {
return result, xerrors.Errorf("get worktree: %w", err)
}
status, err := wt.Status()
if err != nil {
return result, xerrors.Errorf("worktree status: %w", err)
}
worktreeDiff, err := computeWorktreeDiff(repo, repoRoot, status)
if err != nil {
return result, xerrors.Errorf("compute worktree diff: %w", err)
}
result.UnifiedDiff = worktreeDiff.unifiedDiff
if len(result.UnifiedDiff) > maxTotalDiffSize {
result.UnifiedDiff = "Total diff too large to show. Size: " + humanize.IBytes(uint64(len(result.UnifiedDiff))) + ". Showing branch and remote only."
}
return result, nil
}
type worktreeDiffResult struct {
unifiedDiff string
additions int
deletions int
}
type fileSnapshot struct {
exists bool
content []byte
mode filemode.FileMode
binary bool
tooLarge bool
size int64 // actual file size on disk, set even when tooLarge
}
func computeWorktreeDiff(
repo *git.Repository,
repoRoot string,
status git.Status,
) (worktreeDiffResult, error) {
headTree, err := getHeadTree(repo)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("get head tree: %w", err)
}
paths := sortedStatusPaths(status)
filePatches := make([]fdiff.FilePatch, 0, len(paths))
totalAdditions := 0
totalDeletions := 0
for _, path := range paths {
fileStatus := status[path]
fromPath := path
if isRenamed(fileStatus) && fileStatus.Extra != "" {
fromPath = fileStatus.Extra
}
toPath := path
before, err := readHeadFileSnapshot(headTree, fromPath)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("read head file %q: %w", fromPath, err)
}
after, err := readWorktreeFileSnapshot(repoRoot, toPath)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("read worktree file %q: %w", toPath, err)
}
filePatch, additions, deletions := buildFilePatch(fromPath, toPath, before, after)
if filePatch == nil {
continue
}
// Check whether this single file's diff exceeds the
// per-file limit. If so, replace it with a stub.
encoded, err := encodeUnifiedDiff([]fdiff.FilePatch{filePatch})
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("encode file diff %q: %w", toPath, err)
}
if len(encoded) > maxFileDiffSize {
msg := "File diff too large to show. Diff size: " + humanize.IBytes(uint64(len(encoded)))
filePatch = buildStubFilePatch(fromPath, toPath, before, after, msg)
additions = 0
deletions = 0
}
filePatches = append(filePatches, filePatch)
totalAdditions += additions
totalDeletions += deletions
}
diffText, err := encodeUnifiedDiff(filePatches)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("encode unified diff: %w", err)
}
return worktreeDiffResult{
unifiedDiff: diffText,
additions: totalAdditions,
deletions: totalDeletions,
}, nil
}
func getHeadTree(repo *git.Repository) (*object.Tree, error) {
headRef, err := repo.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, nil
}
return nil, err
}
commit, err := repo.CommitObject(headRef.Hash())
if err != nil {
return nil, err
}
return commit.Tree()
}
func readHeadFileSnapshot(headTree *object.Tree, path string) (fileSnapshot, error) {
if headTree == nil {
return fileSnapshot{}, nil
}
file, err := headTree.File(path)
if err != nil {
if errors.Is(err, object.ErrFileNotFound) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
if file.Size > maxFileReadSize {
return fileSnapshot{
exists: true,
tooLarge: true,
size: file.Size,
mode: file.Mode,
}, nil
}
content, err := file.Contents()
if err != nil {
return fileSnapshot{}, err
}
isBinary, err := file.IsBinary()
if err != nil {
return fileSnapshot{}, err
}
return fileSnapshot{
exists: true,
content: []byte(content),
mode: file.Mode,
binary: isBinary,
}, nil
}
func readWorktreeFileSnapshot(repoRoot string, path string) (fileSnapshot, error) {
absPath := filepath.Join(repoRoot, filepath.FromSlash(path))
fileInfo, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
if fileInfo.IsDir() {
return fileSnapshot{}, nil
}
if fileInfo.Size() > maxFileReadSize {
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
if err != nil {
mode = filemode.Regular
}
return fileSnapshot{
exists: true,
tooLarge: true,
size: fileInfo.Size(),
mode: mode,
}, nil
}
content, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
if err != nil {
mode = filemode.Regular
}
return fileSnapshot{
exists: true,
content: content,
mode: mode,
binary: isBinaryContent(content),
size: fileInfo.Size(),
}, nil
}
func buildFilePatch(
fromPath string,
toPath string,
before fileSnapshot,
after fileSnapshot,
) (fdiff.FilePatch, int, int) {
if !before.exists && !after.exists {
return nil, 0, 0
}
unchangedContent := bytes.Equal(before.content, after.content)
if before.exists &&
after.exists &&
fromPath == toPath &&
before.mode == after.mode &&
unchangedContent {
return nil, 0, 0
}
// Files that exceed the read size limit get a stub patch
// instead of a full diff to avoid OOM.
if before.tooLarge || after.tooLarge {
sz := max(after.size, 0)
//nolint:gosec // sz is guaranteed to fit in uint64
msg := "File too large to diff. Current size: " + humanize.IBytes(uint64(sz))
return buildStubFilePatch(fromPath, toPath, before, after, msg), 0, 0
}
patch := &workspaceFilePatch{
from: snapshotToDiffFile(fromPath, before),
to: snapshotToDiffFile(toPath, after),
}
if before.binary || after.binary {
patch.binary = true
return patch, 0, 0
}
diffs := diff.Do(string(before.content), string(after.content))
chunks := make([]fdiff.Chunk, 0, len(diffs))
additions := 0
deletions := 0
for _, d := range diffs {
var operation fdiff.Operation
switch d.Type {
case dmp.DiffEqual:
operation = fdiff.Equal
case dmp.DiffDelete:
operation = fdiff.Delete
deletions += countChunkLines(d.Text)
case dmp.DiffInsert:
operation = fdiff.Add
additions += countChunkLines(d.Text)
default:
continue
}
chunks = append(chunks, workspaceDiffChunk{
content: d.Text,
op: operation,
})
}
patch.chunks = chunks
return patch, additions, deletions
}
func buildStubFilePatch(fromPath, toPath string, before, after fileSnapshot, message string) fdiff.FilePatch {
return &workspaceFilePatch{
from: snapshotToDiffFile(fromPath, before),
to: snapshotToDiffFile(toPath, after),
chunks: []fdiff.Chunk{
workspaceDiffChunk{
content: message + "\n",
op: fdiff.Add,
},
},
}
}
func snapshotToDiffFile(path string, snapshot fileSnapshot) fdiff.File {
if !snapshot.exists {
return nil
}
return workspaceDiffFile{
path: path,
mode: snapshot.mode,
hash: plumbing.ComputeHash(plumbing.BlobObject, snapshot.content),
}
}
func encodeUnifiedDiff(filePatches []fdiff.FilePatch) (string, error) {
if len(filePatches) == 0 {
return "", nil
}
patch := workspaceDiffPatch{filePatches: filePatches}
var builder strings.Builder
encoder := fdiff.NewUnifiedEncoder(&builder, fdiff.DefaultContextLines)
if err := encoder.Encode(patch); err != nil {
return "", err
}
return builder.String(), nil
}
func sortedStatusPaths(status git.Status) []string {
paths := make([]string, 0, len(status))
for path := range status {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func isRenamed(fileStatus *git.FileStatus) bool {
return fileStatus.Staging == git.Renamed || fileStatus.Worktree == git.Renamed
}
func countChunkLines(content string) int {
if content == "" {
return 0
}
lines := strings.Count(content, "\n")
if !strings.HasSuffix(content, "\n") {
lines++
}
return lines
}
func isBinaryContent(content []byte) bool {
return bytes.IndexByte(content, 0) >= 0
}
type workspaceDiffPatch struct {
filePatches []fdiff.FilePatch
}
func (p workspaceDiffPatch) FilePatches() []fdiff.FilePatch {
return p.filePatches
}
func (workspaceDiffPatch) Message() string {
return ""
}
type workspaceFilePatch struct {
from fdiff.File
to fdiff.File
chunks []fdiff.Chunk
binary bool
}
func (p *workspaceFilePatch) IsBinary() bool {
return p.binary
}
func (p *workspaceFilePatch) Files() (fdiff.File, fdiff.File) {
return p.from, p.to
}
func (p *workspaceFilePatch) Chunks() []fdiff.Chunk {
return p.chunks
}
type workspaceDiffFile struct {
path string
mode filemode.FileMode
hash plumbing.Hash
}
func (f workspaceDiffFile) Hash() plumbing.Hash {
return f.hash
}
func (f workspaceDiffFile) Mode() filemode.FileMode {
return f.mode
}
func (f workspaceDiffFile) Path() string {
return f.path
}
type workspaceDiffChunk struct {
content string
op fdiff.Operation
}
func (c workspaceDiffChunk) Content() string {
return c.content
}
func (c workspaceDiffChunk) Type() fdiff.Operation {
return c.op
}
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
package agentgit
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
// API exposes the git watch HTTP routes for the agent.
type API struct {
logger slog.Logger
opts []Option
pathStore *PathStore
}
// NewAPI creates a new git watch API.
func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
return &API{
logger: logger,
pathStore: pathStore,
opts: opts,
}
}
// Routes returns the chi router for mounting at /api/v0/git.
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/watch", a.handleWatch)
return r
}
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to accept WebSocket.",
Detail: err.Error(),
})
return
}
// 4 MiB read limit — subscribe messages with many paths can exceed the
// default 32 KB limit. Matches the SDK/proxy side.
conn.SetReadLimit(1 << 22)
stream := wsjson.NewStream[
codersdk.WorkspaceAgentGitClientMessage,
codersdk.WorkspaceAgentGitServerMessage,
](conn, websocket.MessageText, websocket.MessageText, a.logger)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go httpapi.HeartbeatClose(ctx, a.logger, cancel, conn)
handler := NewHandler(a.logger, a.opts...)
// scanAndSend performs a scan and sends results if there are
// changes.
scanAndSend := func() {
msg := handler.Scan(ctx)
if msg != nil {
if err := stream.Send(*msg); err != nil {
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
cancel()
}
}
}
// If a chat_id query parameter is provided and the PathStore is
// available, subscribe to path updates for this chat.
chatIDStr := r.URL.Query().Get("chat_id")
if chatIDStr != "" && a.pathStore != nil {
chatID, parseErr := uuid.Parse(chatIDStr)
if parseErr == nil {
// Load any paths that are already tracked for this chat.
existingPaths := a.pathStore.GetPaths(chatID)
if len(existingPaths) > 0 {
handler.Subscribe(existingPaths)
handler.RequestScan()
}
// Subscribe to future path updates.
notifyCh, unsubscribe := a.pathStore.Subscribe(chatID)
defer unsubscribe()
go func() {
for {
select {
case <-ctx.Done():
return
case <-notifyCh:
paths := a.pathStore.GetPaths(chatID)
handler.Subscribe(paths)
handler.RequestScan()
}
}
}()
}
}
// Start the main run loop in a goroutine.
go handler.RunLoop(ctx, scanAndSend)
// Read client messages.
updates := stream.Chan()
for {
select {
case <-ctx.Done():
_ = stream.Close(websocket.StatusGoingAway)
return
case msg, ok := <-updates:
if !ok {
return
}
switch msg.Type {
case codersdk.WorkspaceAgentGitClientMessageTypeRefresh:
handler.RequestScan()
default:
if err := stream.Send(codersdk.WorkspaceAgentGitServerMessage{
Type: codersdk.WorkspaceAgentGitServerMessageTypeError,
Message: "unknown message type",
}); err != nil {
return
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package agentgit
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// ExtractChatContext reads chat identity headers from the request.
// Returns zero values if headers are absent (non-chat request).
func ExtractChatContext(r *http.Request) (chatID uuid.UUID, ancestorIDs []uuid.UUID, ok bool) {
raw := r.Header.Get(workspacesdk.CoderChatIDHeader)
if raw == "" {
return uuid.Nil, nil, false
}
chatID, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, nil, false
}
rawAncestors := r.Header.Get(workspacesdk.CoderAncestorChatIDsHeader)
if rawAncestors != "" {
var ids []string
if err := json.Unmarshal([]byte(rawAncestors), &ids); err == nil {
for _, s := range ids {
if id, err := uuid.Parse(s); err == nil {
ancestorIDs = append(ancestorIDs, id)
}
}
}
}
return chatID, ancestorIDs, true
}
+148
View File
@@ -0,0 +1,148 @@
package agentgit_test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestExtractChatContext(t *testing.T) {
t.Parallel()
validID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
ancestor1 := uuid.MustParse("11111111-2222-3333-4444-555555555555")
ancestor2 := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
tests := []struct {
name string
chatID string // empty means header not set
setChatID bool // whether to set the chat ID header at all
ancestors string // empty means header not set
setAncestors bool // whether to set the ancestor header at all
wantChatID uuid.UUID
wantAncestorIDs []uuid.UUID
wantOK bool
}{
{
name: "NoHeadersPresent",
setChatID: false,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_NoAncestors",
chatID: validID.String(),
setChatID: true,
setAncestors: false,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
{
name: "ValidChatID_ValidAncestors",
chatID: validID.String(),
setChatID: true,
ancestors: mustMarshalJSON(t, []string{
ancestor1.String(),
ancestor2.String(),
}),
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
wantOK: true,
},
{
name: "MalformedChatID",
chatID: "not-a-uuid",
setChatID: true,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_MalformedAncestorJSON",
chatID: validID.String(),
setChatID: true,
ancestors: `{this is not json}`,
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
{
// Only valid UUIDs in the array are returned; invalid
// entries are silently skipped.
name: "ValidChatID_PartialValidAncestorUUIDs",
chatID: validID.String(),
setChatID: true,
ancestors: mustMarshalJSON(t, []string{
ancestor1.String(),
"bad-uuid",
ancestor2.String(),
}),
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
wantOK: true,
},
{
// Header is explicitly set to an empty string, which
// Header.Get returns as "".
name: "EmptyChatIDHeader",
chatID: "",
setChatID: true,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_EmptyAncestorHeader",
chatID: validID.String(),
setChatID: true,
ancestors: "",
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest("GET", "/", nil)
if tt.setChatID {
r.Header.Set(workspacesdk.CoderChatIDHeader, tt.chatID)
}
if tt.setAncestors {
r.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, tt.ancestors)
}
chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r)
require.Equal(t, tt.wantOK, ok, "ok mismatch")
require.Equal(t, tt.wantChatID, chatID, "chatID mismatch")
require.Equal(t, tt.wantAncestorIDs, ancestorIDs, "ancestorIDs mismatch")
})
}
}
// mustMarshalJSON marshals v to a JSON string, failing the test on error.
func mustMarshalJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
+136
View File
@@ -0,0 +1,136 @@
package agentgit
import (
"sort"
"sync"
"github.com/google/uuid"
)
// PathStore tracks which file paths each chat has touched.
// It is safe for concurrent use.
type PathStore struct {
mu sync.RWMutex
chatPaths map[uuid.UUID]map[string]struct{}
subscribers map[uuid.UUID][]chan<- struct{}
}
// NewPathStore creates a new PathStore.
func NewPathStore() *PathStore {
return &PathStore{
chatPaths: make(map[uuid.UUID]map[string]struct{}),
subscribers: make(map[uuid.UUID][]chan<- struct{}),
}
}
// AddPaths adds paths to every chat in chatIDs and notifies
// their subscribers. Zero-value UUIDs are silently skipped.
func (ps *PathStore) AddPaths(chatIDs []uuid.UUID, paths []string) {
affected := make([]uuid.UUID, 0, len(chatIDs))
for _, id := range chatIDs {
if id != uuid.Nil {
affected = append(affected, id)
}
}
if len(affected) == 0 {
return
}
ps.mu.Lock()
for _, id := range affected {
m, ok := ps.chatPaths[id]
if !ok {
m = make(map[string]struct{})
ps.chatPaths[id] = m
}
for _, p := range paths {
m[p] = struct{}{}
}
}
ps.mu.Unlock()
ps.notifySubscribers(affected)
}
// Notify sends a signal to all subscribers of the given chat IDs
// without adding any paths. Zero-value UUIDs are silently skipped.
func (ps *PathStore) Notify(chatIDs []uuid.UUID) {
affected := make([]uuid.UUID, 0, len(chatIDs))
for _, id := range chatIDs {
if id != uuid.Nil {
affected = append(affected, id)
}
}
if len(affected) == 0 {
return
}
ps.notifySubscribers(affected)
}
// notifySubscribers sends a non-blocking signal to all subscriber
// channels for the given chat IDs.
func (ps *PathStore) notifySubscribers(chatIDs []uuid.UUID) {
ps.mu.RLock()
toNotify := make([]chan<- struct{}, 0)
for _, id := range chatIDs {
toNotify = append(toNotify, ps.subscribers[id]...)
}
ps.mu.RUnlock()
for _, ch := range toNotify {
select {
case ch <- struct{}{}:
default:
}
}
}
// GetPaths returns all paths tracked for a chat, deduplicated
// and sorted lexicographically.
func (ps *PathStore) GetPaths(chatID uuid.UUID) []string {
ps.mu.RLock()
defer ps.mu.RUnlock()
m := ps.chatPaths[chatID]
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for p := range m {
out = append(out, p)
}
sort.Strings(out)
return out
}
// Len returns the number of chat IDs that have tracked paths.
func (ps *PathStore) Len() int {
ps.mu.RLock()
defer ps.mu.RUnlock()
return len(ps.chatPaths)
}
// Subscribe returns a channel that receives a signal whenever
// paths change for chatID, along with an unsubscribe function
// that removes the channel.
func (ps *PathStore) Subscribe(chatID uuid.UUID) (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
ps.mu.Lock()
ps.subscribers[chatID] = append(ps.subscribers[chatID], ch)
ps.mu.Unlock()
unsub := func() {
ps.mu.Lock()
defer ps.mu.Unlock()
subs := ps.subscribers[chatID]
for i, s := range subs {
if s == ch {
ps.subscribers[chatID] = append(subs[:i], subs[i+1:]...)
break
}
}
}
return ch, unsub
}
+268
View File
@@ -0,0 +1,268 @@
package agentgit_test
import (
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/testutil"
)
func TestPathStore_AddPaths_StoresForChatAndAncestors(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestor1 := uuid.New()
ancestor2 := uuid.New()
ps.AddPaths([]uuid.UUID{chatID, ancestor1, ancestor2}, []string{"/a", "/b"})
// All three IDs should see the paths.
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(chatID))
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor1))
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor2))
// An unrelated chat should see nothing.
require.Nil(t, ps.GetPaths(uuid.New()))
}
func TestPathStore_AddPaths_SkipsNilUUIDs(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
// A nil chatID should be a no-op.
ps.AddPaths([]uuid.UUID{uuid.Nil}, []string{"/x"})
require.Nil(t, ps.GetPaths(uuid.Nil))
// A nil ancestor should be silently skipped.
chatID := uuid.New()
ps.AddPaths([]uuid.UUID{chatID, uuid.Nil}, []string{"/y"})
require.Equal(t, []string{"/y"}, ps.GetPaths(chatID))
require.Nil(t, ps.GetPaths(uuid.Nil))
}
func TestPathStore_GetPaths_DeduplicatedSorted(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/z", "/a", "/m", "/a", "/z"})
ps.AddPaths([]uuid.UUID{chatID}, []string{"/a", "/b"})
got := ps.GetPaths(chatID)
require.Equal(t, []string{"/a", "/b", "/m", "/z"}, got)
}
func TestPathStore_Subscribe_ReceivesNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("timed out waiting for notification")
}
}
func TestPathStore_Subscribe_MultipleSubscribers(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch1, unsub1 := ps.Subscribe(chatID)
defer unsub1()
ch2, unsub2 := ps.Subscribe(chatID)
defer unsub2()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
for i, ch := range []<-chan struct{}{ch1, ch2} {
select {
case <-ch:
// OK
case <-ctx.Done():
t.Fatalf("subscriber %d did not receive notification", i)
}
}
}
func TestPathStore_Unsubscribe_StopsNotifications(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
unsub()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
// AddPaths sends synchronously via a non-blocking send to the
// buffered channel, so if a notification were going to arrive
// it would already be in the channel by now.
select {
case <-ch:
t.Fatal("received notification after unsubscribe")
default:
// Expected: no notification.
}
}
func TestPathStore_Subscribe_AncestorNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestor := uuid.New()
// Subscribe to the ancestor, then add paths via the child.
ch, unsub := ps.Subscribe(ancestor)
defer unsub()
ps.AddPaths([]uuid.UUID{chatID, ancestor}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("ancestor subscriber did not receive notification")
}
}
func TestPathStore_Notify_NotifiesWithoutAddingPaths(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.Notify([]uuid.UUID{chatID})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("timed out waiting for notification")
}
require.Nil(t, ps.GetPaths(chatID))
}
func TestPathStore_Notify_SkipsNilUUIDs(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.Notify([]uuid.UUID{uuid.Nil})
// Notify sends synchronously via a non-blocking send to the
// buffered channel, so if a notification were going to arrive
// it would already be in the channel by now.
select {
case <-ch:
t.Fatal("received notification for nil UUID")
default:
// Expected: no notification.
}
require.Nil(t, ps.GetPaths(chatID))
}
func TestPathStore_Notify_AncestorNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestorID := uuid.New()
// Subscribe to the ancestor, then notify via the child.
ch, unsub := ps.Subscribe(ancestorID)
defer unsub()
ps.Notify([]uuid.UUID{chatID, ancestorID})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("ancestor subscriber did not receive notification")
}
require.Nil(t, ps.GetPaths(ancestorID))
}
func TestPathStore_ConcurrentSafety(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
const goroutines = 20
const iterations = 50
chatIDs := make([]uuid.UUID, goroutines)
for i := range chatIDs {
chatIDs[i] = uuid.New()
}
var wg sync.WaitGroup
wg.Add(goroutines * 2) // writers + readers
// Writers.
for i := range goroutines {
go func(idx int) {
defer wg.Done()
for j := range iterations {
ancestors := []uuid.UUID{chatIDs[(idx+1)%goroutines]}
path := []string{
"/file-" + chatIDs[idx].String() + "-" + time.Now().Format(time.RFC3339Nano),
"/iter-" + string(rune('0'+j%10)),
}
ps.AddPaths(append([]uuid.UUID{chatIDs[idx]}, ancestors...), path)
}
}(i)
}
// Readers.
for i := range goroutines {
go func(idx int) {
defer wg.Done()
for range iterations {
_ = ps.GetPaths(chatIDs[idx])
}
}(i)
}
wg.Wait()
// Verify every chat has at least the paths it wrote.
for _, id := range chatIDs {
paths := ps.GetPaths(id)
require.NotEmpty(t, paths, "chat %s should have paths", id)
}
}