diff --git a/agent/agent.go b/agent/agent.go index bfbe6be536..53f5fc1167 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -41,6 +41,7 @@ import ( "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" "github.com/coder/coder/v2/agent/agentfiles" + "github.com/coder/coder/v2/agent/agentgit" "github.com/coder/coder/v2/agent/agentproc" "github.com/coder/coder/v2/agent/agentscripts" "github.com/coder/coder/v2/agent/agentsocket" @@ -102,6 +103,7 @@ type Options struct { Execer agentexec.Execer Devcontainers bool DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective. + GitAPIOptions []agentgit.Option Clock quartz.Clock SocketServerEnabled bool SocketPath string // Path for the agent socket server socket @@ -217,6 +219,7 @@ func New(options Options) Agent { devcontainers: options.Devcontainers, containerAPIOptions: options.DevcontainerAPIOptions, + gitAPIOptions: options.GitAPIOptions, socketPath: options.SocketPath, socketServerEnabled: options.SocketServerEnabled, boundaryLogProxySocketPath: options.BoundaryLogProxySocketPath, @@ -302,8 +305,10 @@ type agent struct { devcontainers bool containerAPIOptions []agentcontainers.Option containerAPI *agentcontainers.API + gitAPIOptions []agentgit.Option filesAPI *agentfiles.API + gitAPI *agentgit.API processAPI *agentproc.API socketServerEnabled bool @@ -376,8 +381,11 @@ func (a *agent) init() { a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...) - a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem) - a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv) + pathStore := agentgit.NewPathStore() + a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore) + a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore) + gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...) + a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...) a.reconnectingPTYServer = reconnectingpty.NewServer( a.logger.Named("reconnecting-pty"), diff --git a/agent/agentfiles/api.go b/agent/agentfiles/api.go index 51e27dfae6..8cfe10c65a 100644 --- a/agent/agentfiles/api.go +++ b/agent/agentfiles/api.go @@ -7,18 +7,21 @@ import ( "github.com/spf13/afero" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentgit" ) // API exposes file-related operations performed through the agent. type API struct { logger slog.Logger filesystem afero.Fs + pathStore *agentgit.PathStore } -func NewAPI(logger slog.Logger, filesystem afero.Fs) *API { +func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore) *API { api := &API{ logger: logger, filesystem: filesystem, + pathStore: pathStore, } return api } diff --git a/agent/agentfiles/files.go b/agent/agentfiles/files.go index 3e1657db24..a541165abe 100644 --- a/agent/agentfiles/files.go +++ b/agent/agentfiles/files.go @@ -13,10 +13,12 @@ import ( "strings" "syscall" + "github.com/google/uuid" "github.com/spf13/afero" "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentgit" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -301,6 +303,13 @@ func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) { return } + // Track edited path for git watch. + if api.pathStore != nil { + if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok { + api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), []string{path}) + } + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ Message: fmt.Sprintf("Successfully wrote to %q", path), }) @@ -380,6 +389,17 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) { return } + // Track edited paths for git watch. + if api.pathStore != nil { + if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok { + filePaths := make([]string, 0, len(req.Files)) + for _, f := range req.Files { + filePaths = append(filePaths, f.Path) + } + api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), filePaths) + } + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ Message: "Successfully edited file(s)", }) diff --git a/agent/agentfiles/files_test.go b/agent/agentfiles/files_test.go index 85a9bcd32c..1428bb9c28 100644 --- a/agent/agentfiles/files_test.go +++ b/agent/agentfiles/files_test.go @@ -11,9 +11,12 @@ import ( "os" "path/filepath" "runtime" + "strings" "syscall" "testing" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/spf13/afero" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -21,6 +24,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentfiles" + "github.com/coder/coder/v2/agent/agentgit" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/testutil" @@ -116,7 +120,7 @@ func TestReadFile(t *testing.T) { } return nil }) - api := agentfiles.NewAPI(logger, fs) + api := agentfiles.NewAPI(logger, fs, nil) dirPath := filepath.Join(tmpdir, "a-directory") err := fs.MkdirAll(dirPath, 0o755) @@ -296,7 +300,7 @@ func TestWriteFile(t *testing.T) { } return nil }) - api := agentfiles.NewAPI(logger, fs) + api := agentfiles.NewAPI(logger, fs, nil) dirPath := filepath.Join(tmpdir, "directory") err := fs.MkdirAll(dirPath, 0o755) @@ -414,7 +418,7 @@ func TestEditFiles(t *testing.T) { } return nil }) - api := agentfiles.NewAPI(logger, fs) + api := agentfiles.NewAPI(logger, fs, nil) dirPath := filepath.Join(tmpdir, "directory") err := fs.MkdirAll(dirPath, 0o755) @@ -838,6 +842,169 @@ func TestEditFiles(t *testing.T) { } } +func TestHandleWriteFile_ChatHeaders_UpdatesPathStore(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + logger := slogtest.Make(t, nil) + fs := afero.NewMemMapFs() + api := agentfiles.NewAPI(logger, fs, pathStore) + + testPath := filepath.Join(os.TempDir(), "test.txt") + + chatID := uuid.New() + ancestorID := uuid.New() + ancestorJSON, _ := json.Marshal([]string{ancestorID.String()}) + + body := strings.NewReader("hello world") + req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body) + req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String()) + req.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, string(ancestorJSON)) + + rr := httptest.NewRecorder() + r := chi.NewRouter() + r.Post("/write-file", api.HandleWriteFile) + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + // Verify PathStore was updated for both chat and ancestor. + paths := pathStore.GetPaths(chatID) + require.Equal(t, []string{testPath}, paths) + + ancestorPaths := pathStore.GetPaths(ancestorID) + require.Equal(t, []string{testPath}, ancestorPaths) +} + +func TestHandleWriteFile_NoChatHeaders_NoPathStoreUpdate(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + logger := slogtest.Make(t, nil) + fs := afero.NewMemMapFs() + api := agentfiles.NewAPI(logger, fs, pathStore) + + testPath := filepath.Join(os.TempDir(), "test.txt") + + body := strings.NewReader("hello world") + req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body) + + rr := httptest.NewRecorder() + r := chi.NewRouter() + r.Post("/write-file", api.HandleWriteFile) + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + // PathStore should be globally empty since no chat headers were set. + require.Equal(t, 0, pathStore.Len()) +} + +func TestHandleWriteFile_Failure_NoPathStoreUpdate(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + logger := slogtest.Make(t, nil) + fs := afero.NewMemMapFs() + api := agentfiles.NewAPI(logger, fs, pathStore) + + chatID := uuid.New() + + // Write to a relative path (should fail with 400). + body := strings.NewReader("hello world") + req := httptest.NewRequest(http.MethodPost, "/write-file?path=relative/path.txt", body) + req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String()) + + rr := httptest.NewRecorder() + r := chi.NewRouter() + r.Post("/write-file", api.HandleWriteFile) + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusBadRequest, rr.Code) + + // PathStore should NOT be updated on failure. + paths := pathStore.GetPaths(chatID) + require.Empty(t, paths) +} + +func TestHandleEditFiles_ChatHeaders_UpdatesPathStore(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + logger := slogtest.Make(t, nil) + fs := afero.NewMemMapFs() + api := agentfiles.NewAPI(logger, fs, pathStore) + + testPath := filepath.Join(os.TempDir(), "test.txt") + + // Create the file first. + require.NoError(t, afero.WriteFile(fs, testPath, []byte("hello"), 0o644)) + + chatID := uuid.New() + editReq := workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{ + { + Path: testPath, + Edits: []workspacesdk.FileEdit{ + {Search: "hello", Replace: "world"}, + }, + }, + }, + } + body, _ := json.Marshal(editReq) + req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String()) + + rr := httptest.NewRecorder() + r := chi.NewRouter() + r.Post("/edit-files", api.HandleEditFiles) + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + paths := pathStore.GetPaths(chatID) + require.Equal(t, []string{testPath}, paths) +} + +func TestHandleEditFiles_Failure_NoPathStoreUpdate(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + logger := slogtest.Make(t, nil) + fs := afero.NewMemMapFs() + api := agentfiles.NewAPI(logger, fs, pathStore) + + chatID := uuid.New() + + // Edit a non-existent file (should fail with 404). + editReq := workspacesdk.FileEditRequest{ + Files: []workspacesdk.FileEdits{ + { + Path: "/nonexistent/file.txt", + Edits: []workspacesdk.FileEdit{ + {Search: "hello", Replace: "world"}, + }, + }, + }, + } + body, _ := json.Marshal(editReq) + req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String()) + + rr := httptest.NewRecorder() + r := chi.NewRouter() + r.Post("/edit-files", api.HandleEditFiles) + r.ServeHTTP(rr, req) + + require.NotEqual(t, http.StatusOK, rr.Code) + + // PathStore should NOT be updated on failure. + paths := pathStore.GetPaths(chatID) + require.Empty(t, paths) +} + func TestReadFileLines(t *testing.T) { t.Parallel() @@ -851,7 +1018,7 @@ func TestReadFileLines(t *testing.T) { } return nil }) - api := agentfiles.NewAPI(logger, fs) + api := agentfiles.NewAPI(logger, fs, nil) dirPath := filepath.Join(tmpdir, "a-directory-lines") err := fs.MkdirAll(dirPath, 0o755) diff --git a/agent/agentgit/agentgit.go b/agent/agentgit/agentgit.go new file mode 100644 index 0000000000..c602e393eb --- /dev/null +++ b/agent/agentgit/agentgit.go @@ -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 +} diff --git a/agent/agentgit/agentgit_test.go b/agent/agentgit/agentgit_test.go new file mode 100644 index 0000000000..1bc1baf3cf --- /dev/null +++ b/agent/agentgit/agentgit_test.go @@ -0,0 +1,1399 @@ +package agentgit_test + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentgit" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/wsjson" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" + "github.com/coder/websocket" +) + +// initTestRepo creates a temporary git repo with an initial commit +// and returns the repo root path. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + // Create a file and commit it so the repo has HEAD. + testFile := filepath.Join(dir, "README.md") + require.NoError(t, os.WriteFile(testFile, []byte("# Test\n"), 0o600)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("README.md") + require.NoError(t, err) + _, err = wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + return dir +} + +func TestSubscribeBulkPathsAndDedupes(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Subscribe with multiple paths in the same repo — should dedupe + // to one repo root. + filePath1 := filepath.Join(repoDir, "a.go") + filePath2 := filepath.Join(repoDir, "b.go") + added := h.Subscribe([]string{filePath1, filePath2}) + require.True(t, added, "first subscribe should add a repo") + + // Subscribing again with the same paths should not add new repos. + added = h.Subscribe([]string{filePath1}) + require.False(t, added, "duplicate subscribe should not add repos") +} + +func TestSubscribeNonGitPathsIgnored(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + + nonGitDir := t.TempDir() + added := h.Subscribe([]string{filepath.Join(nonGitDir, "file.txt")}) + require.False(t, added, "non-git paths should be ignored") +} + +func TestSubscribeRelativePathsIgnored(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + + added := h.Subscribe([]string{"relative/path.go"}) + require.False(t, added, "relative paths should be ignored") +} + +func TestSubscribeEmptyPaths(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + + added := h.Subscribe([]string{}) + require.False(t, added, "empty slice should not add any repos") + + added = h.Subscribe(nil) + require.False(t, added, "nil slice should not add any repos") + + ctx := context.Background() + msg := h.Scan(ctx) + require.Nil(t, msg, "scan should return nil with no repos") +} + +func TestScanReturnsRepoChanges(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a dirty file. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "new.go"), []byte("package main\n"), 0o600)) + + h.Subscribe([]string{filepath.Join(repoDir, "new.go")}) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.Len(t, msg.Repositories, 1) + + repo := msg.Repositories[0] + require.Equal(t, repoDir, repo.RepoRoot) + require.NotEmpty(t, repo.Branch) + require.NotEmpty(t, repo.UnifiedDiff) + + // Verify the new file appears in the unified diff. + require.Contains(t, repo.UnifiedDiff, "new.go") +} + +func TestScanDeltaEmission(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a dirty file. + dirtyFile := filepath.Join(repoDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package dirty\n"), 0o600)) + + h.Subscribe([]string{dirtyFile}) + ctx := context.Background() + + // First scan — returns all files (no previous snapshot). + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + require.Len(t, msg1.Repositories, 1) + + // Second scan with no changes — should return nil (no delta). + msg2 := h.Scan(ctx) + require.Nil(t, msg2, "no changes since last scan should return nil") + + // Revert the dirty file (make repo clean). + require.NoError(t, os.Remove(dirtyFile)) + + // Third scan — should emit a "clean" delta for dirty.go. + msg3 := h.Scan(ctx) + require.NotNil(t, msg3) + require.Len(t, msg3.Repositories, 1) + + // The file was reverted, so it should no longer appear in the diff. + require.NotContains(t, msg3.Repositories[0].UnifiedDiff, "dirty.go") +} + +func TestScanDeltaDetectsContentChanges(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Modify a committed file. + readmePath := filepath.Join(repoDir, "README.md") + require.NoError(t, os.WriteFile(readmePath, []byte("# Edit 1\n"), 0o600)) + + h.Subscribe([]string{readmePath}) + ctx := context.Background() + + // First scan — returns the initial dirty state. + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + require.Len(t, msg1.Repositories, 1) + + require.Contains(t, msg1.Repositories[0].UnifiedDiff, "README.md") + + // Second scan with no changes — should return nil (no delta). + msg2 := h.Scan(ctx) + require.Nil(t, msg2, "no changes since last scan should return nil") + + // Now modify the SAME file further (still "Modified" status, but + // different content). + require.NoError(t, os.WriteFile(readmePath, []byte("# Edit 2\nMore lines\nEven more\n"), 0o600)) + + // Third scan — should detect the content change even though the + // status is still "Modified". + msg3 := h.Scan(ctx) + require.NotNil(t, msg3, "content change in already-dirty file should emit delta") + require.Len(t, msg3.Repositories, 1) + + require.Contains(t, msg3.Repositories[0].UnifiedDiff, "README.md") + + // Also test an untracked (unstaged) file — its status is "Added" + // throughout, but further edits should still emit deltas. + untrackedPath := filepath.Join(repoDir, "untracked.go") + require.NoError(t, os.WriteFile(untrackedPath, []byte("package main\n"), 0o600)) + + h.Subscribe([]string{untrackedPath}) + msg4 := h.Scan(ctx) + require.NotNil(t, msg4) + + require.Contains(t, msg4.Repositories[0].UnifiedDiff, "untracked.go") + + // No changes — should return nil. + msg5 := h.Scan(ctx) + require.Nil(t, msg5, "no changes since last scan should return nil") + + // Modify the untracked file further. + require.NoError(t, os.WriteFile(untrackedPath, []byte("package main\n\nfunc init() {}\n"), 0o600)) + + msg6 := h.Scan(ctx) + require.NotNil(t, msg6, "content change in untracked file should emit delta") + + require.Contains(t, msg6.Repositories[0].UnifiedDiff, "untracked.go") +} + +func TestScanRateLimiting(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + h.Subscribe([]string{filepath.Join(repoDir, "file.go")}) + + // First scan should succeed. + ctx := context.Background() + msg1 := h.Scan(ctx) + // Even if no dirty files, the first scan always runs. + // The important thing is it doesn't panic. + _ = msg1 + + // Create a dirty file so the next scan has something to report. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "new.go"), []byte("package x\n"), 0o600)) + + msg2 := h.Scan(ctx) + require.NotNil(t, msg2, "scan with new dirty file should return changes") +} + +func TestSubscribeDeeplyNestedFile(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + // Create a deeply nested directory structure inside the repo. + nestedDir := filepath.Join(repoDir, "a", "b", "c") + require.NoError(t, os.MkdirAll(nestedDir, 0o700)) + nestedFile := filepath.Join(nestedDir, "deep.go") + require.NoError(t, os.WriteFile(nestedFile, []byte("package deep\n"), 0o600)) + + h := agentgit.NewHandler(logger) + + added := h.Subscribe([]string{nestedFile}) + require.True(t, added, "deeply nested file should resolve to repo root") + + msg := h.Scan(context.Background()) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + require.Equal(t, repoDir, msg.Repositories[0].RepoRoot) + + // The nested file should appear in the unified diff. + require.Contains(t, msg.Repositories[0].UnifiedDiff, "a/b/c/deep.go") +} + +func TestSubscribeNestedGitRepos(t *testing.T) { + t.Parallel() + + // Create an outer repo. + outerDir := initTestRepo(t) + + // Create an inner repo nested inside the outer one. + innerDir := filepath.Join(outerDir, "subproject") + require.NoError(t, os.MkdirAll(innerDir, 0o700)) + innerRepo, err := git.PlainInit(innerDir, false) + require.NoError(t, err) + + // Commit a file in the inner repo so it has HEAD. + innerFile := filepath.Join(innerDir, "inner.go") + require.NoError(t, os.WriteFile(innerFile, []byte("package inner\n"), 0o600)) + innerWt, err := innerRepo.Worktree() + require.NoError(t, err) + _, err = innerWt.Add("inner.go") + require.NoError(t, err) + _, err = innerWt.Commit("inner commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + // Now create a dirty file in the inner repo. + dirtyFile := filepath.Join(innerDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package inner\n"), 0o600)) + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + + // Subscribe with the path inside the inner repo. + added := h.Subscribe([]string{dirtyFile}) + require.True(t, added) + + msg := h.Scan(context.Background()) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1, "should track only one repo") + + // The tracked repo should be the inner repo, not the outer one. + require.Equal(t, innerDir, msg.Repositories[0].RepoRoot, + "should track the inner (nearest) repo, not the outer one") +} + +func TestScanDeletedRepoEmitsRemoved(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a dirty file so the initial scan has something to track. + dirtyFile := filepath.Join(repoDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package dirty\n"), 0o600)) + + h.Subscribe([]string{dirtyFile}) + ctx := context.Background() + + // Initial scan — populates the snapshot with the dirty file. + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + require.Len(t, msg1.Repositories, 1) + require.False(t, msg1.Repositories[0].Removed) + + // Delete the entire repo directory. + require.NoError(t, os.RemoveAll(repoDir)) + + // Next scan should emit a removal entry. + msg2 := h.Scan(ctx) + require.NotNil(t, msg2) + require.Len(t, msg2.Repositories, 1) + + removed := msg2.Repositories[0] + require.True(t, removed.Removed, "repo should be marked as removed") + require.Equal(t, repoDir, removed.RepoRoot) + require.Empty(t, removed.Branch) + + // Removed repo should have an empty diff. + require.Empty(t, removed.UnifiedDiff) + + // Subsequent scan should return nil — the repo was evicted from + // the watch set. + msg3 := h.Scan(ctx) + require.Nil(t, msg3, "evicted repo should not appear in subsequent scans") +} + +func TestScanDeletedGitDirEmitsRemoved(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + dirtyFile := filepath.Join(repoDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package dirty\n"), 0o600)) + + h.Subscribe([]string{dirtyFile}) + ctx := context.Background() + + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + + // Remove only the .git directory (repo root still exists). + require.NoError(t, os.RemoveAll(filepath.Join(repoDir, ".git"))) + + msg2 := h.Scan(ctx) + require.NotNil(t, msg2) + require.Len(t, msg2.Repositories, 1) + require.True(t, msg2.Repositories[0].Removed, + "removing .git dir should trigger removal") +} + +func TestScanDeletedWorktreeGitdirEmitsRemoved(t *testing.T) { + t.Parallel() + + // Set up a main repo that we'll use as the source for a worktree. + mainRepoDir := initTestRepo(t) + + // Create a linked worktree using git. + worktreeDir := t.TempDir() + mainRepo, err := git.PlainOpen(mainRepoDir) + require.NoError(t, err) + + // Create a branch for the worktree. + headRef, err := mainRepo.Head() + require.NoError(t, err) + err = mainRepo.Storer.SetReference( + //nolint:revive // plumbing.NewBranchReferenceName is not available. + plumbing.NewHashReference("refs/heads/worktree-branch", headRef.Hash()), + ) + require.NoError(t, err) + + // Manually construct the worktree linkage: + // 1. Create worktree gitdir inside main repo's worktrees/ + // 2. Write a .git file in the worktree dir pointing to that gitdir. + gitdirPath := filepath.Join(mainRepoDir, ".git", "worktrees", "wt") + require.NoError(t, os.MkdirAll(gitdirPath, 0o755)) + + // The worktree gitdir needs HEAD and commondir files. + require.NoError(t, os.WriteFile( + filepath.Join(gitdirPath, "HEAD"), + []byte("ref: refs/heads/worktree-branch\n"), 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(gitdirPath, "commondir"), + []byte(filepath.Join(mainRepoDir, ".git")+"\n"), 0o600, + )) + + // Write the .git file in the worktree directory. + gitFileContent := "gitdir: " + gitdirPath + "\n" + require.NoError(t, os.WriteFile( + filepath.Join(worktreeDir, ".git"), + []byte(gitFileContent), 0o600, + )) + + // Verify the worktree is a valid repo before we break it. + _, err = git.PlainOpen(worktreeDir) + require.NoError(t, err, "worktree should be openable before deletion") + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + + // Create a dirty file so the initial scan has something to report. + dirtyFile := filepath.Join(worktreeDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package dirty\n"), 0o600)) + + h.Subscribe([]string{dirtyFile}) + ctx := context.Background() + + // Initial scan should succeed. + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + require.Len(t, msg1.Repositories, 1) + require.False(t, msg1.Repositories[0].Removed) + + // Now delete the target gitdir. The .git file in the worktree + // still exists, but it points to a directory that is gone. + require.NoError(t, os.RemoveAll(gitdirPath)) + + // Verify the .git file still exists (this is the bug scenario). + _, err = os.Stat(filepath.Join(worktreeDir, ".git")) + require.NoError(t, err, ".git file should still exist") + + // Next scan should detect the broken worktree and emit removal. + msg2 := h.Scan(ctx) + require.NotNil(t, msg2) + require.Len(t, msg2.Repositories, 1) + require.True(t, msg2.Repositories[0].Removed, + "worktree with deleted gitdir should be marked as removed") + require.Equal(t, worktreeDir, msg2.Repositories[0].RepoRoot) + + // Repo should be evicted — subsequent scan returns nil. + msg3 := h.Scan(ctx) + require.Nil(t, msg3, "evicted worktree should not appear in subsequent scans") +} + +func TestScanTransientErrorDoesNotRemoveRepo(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + dirtyFile := filepath.Join(repoDir, "dirty.go") + require.NoError(t, os.WriteFile(dirtyFile, []byte("package dirty\n"), 0o600)) + + h.Subscribe([]string{dirtyFile}) + ctx := context.Background() + + // Initial scan succeeds. + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + require.Len(t, msg1.Repositories, 1) + require.False(t, msg1.Repositories[0].Removed) + + // Corrupt the repo by replacing HEAD with invalid content. + // The directory and .git still exist, so this is a transient + // error, not a deletion. + headPath := filepath.Join(repoDir, ".git", "HEAD") + require.NoError(t, os.WriteFile(headPath, []byte("corrupt"), 0o600)) + + // The scan should log a warning but not emit a removal. The + // repo stays in the watch set. + msg2 := h.Scan(ctx) + // msg2 may be nil (no results) since the scan error is + // transient. Importantly, it must NOT contain a removed entry. + if msg2 != nil { + for _, repo := range msg2.Repositories { + require.False(t, repo.Removed, + "transient error should not trigger removal") + } + } + + // Repair the repo and verify it's still being watched. + require.NoError(t, os.WriteFile(headPath, []byte("ref: refs/heads/master\n"), 0o600)) + + // Modify a file so the next scan has something new to report. + require.NoError(t, os.WriteFile( + filepath.Join(repoDir, "new.go"), + []byte("package main\n"), 0o600, + )) + + msg3 := h.Scan(ctx) + require.NotNil(t, msg3, "repo should still be watched after transient error") + require.Len(t, msg3.Repositories, 1) + require.False(t, msg3.Repositories[0].Removed) + require.Equal(t, repoDir, msg3.Repositories[0].RepoRoot) +} + +// --- WebSocket end-to-end tests --- + +// dialGitWatch starts an httptest server with the agentgit API and +// returns a wsjson.Stream connected to it. The server and connection +// are cleaned up when the test ends. +func dialGitWatch(t *testing.T, opts ...agentgit.Option) *wsjson.Stream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, +] { + t.Helper() + logger := slogtest.Make(t, nil) + api := agentgit.NewAPI(logger, nil, opts...) + srv := httptest.NewServer(api.Routes()) + t.Cleanup(srv.Close) + + wsURL := "ws" + srv.URL[len("http"):] + "/watch" + conn, _, err := websocket.Dial(context.Background(), wsURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") }) + + return wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn, websocket.MessageText, websocket.MessageText, logger) +} + +// dialGitWatchWithPathStore starts an httptest server backed by the +// given PathStore and returns a stream connected with the given +// chat ID. The PathStore is used to feed paths into the handler +// instead of client-side subscribe messages. +func dialGitWatchWithPathStore( + t *testing.T, + ps *agentgit.PathStore, + chatID uuid.UUID, + opts ...agentgit.Option, +) *wsjson.Stream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, +] { + t.Helper() + logger := slogtest.Make(t, nil) + api := agentgit.NewAPI(logger, ps, opts...) + srv := httptest.NewServer(api.Routes()) + t.Cleanup(srv.Close) + + wsURL := "ws" + srv.URL[len("http"):] + "/watch?chat_id=" + chatID.String() + conn, _, err := websocket.Dial(context.Background(), wsURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") }) + + return wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn, websocket.MessageText, websocket.MessageText, logger) +} + +// recvMsg reads the next server message, using the provided +// context for the timeout instead of a raw time.After. +func recvMsg(ctx context.Context, t *testing.T, ch <-chan codersdk.WorkspaceAgentGitServerMessage) codersdk.WorkspaceAgentGitServerMessage { + t.Helper() + select { + case msg, ok := <-ch: + require.True(t, ok, "channel closed unexpectedly") + return msg + case <-ctx.Done(): + t.Fatal("timed out waiting for server message") + return codersdk.WorkspaceAgentGitServerMessage{} + } +} + +func TestWebSocketSubscribeAndReceiveChanges(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "ws.go"), []byte("package ws\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + + // Add paths before connecting so the handler picks them up on + // startup. + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "ws.go")}) + + stream := dialGitWatchWithPathStore(t, ps, chatID) + ch := stream.Chan() + + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.NotNil(t, msg.ScannedAt) + require.NotEmpty(t, msg.Repositories) + require.Equal(t, repoDir, msg.Repositories[0].RepoRoot) +} + +func TestWebSocketMultipleRepos(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoA := initTestRepo(t) + repoB := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoA, "a.go"), []byte("package a\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(repoB, "b.go"), []byte("package b\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + ps.AddPaths([]uuid.UUID{chatID}, []string{ + filepath.Join(repoA, "a.go"), + filepath.Join(repoB, "b.go"), + }) + + stream := dialGitWatchWithPathStore(t, ps, chatID) + ch := stream.Chan() + + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.Len(t, msg.Repositories, 2, "should include both repos") + + roots := map[string]bool{} + for _, r := range msg.Repositories { + roots[r.RepoRoot] = true + } + require.True(t, roots[repoA], "repo A missing") + require.True(t, roots[repoB], "repo B missing") +} + +func TestWebSocketIncrementalSubscribe(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoA := initTestRepo(t) + repoB := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoA, "a.go"), []byte("package a\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(repoB, "b.go"), []byte("package b\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + mClock := quartz.NewMock(t) + + // Seed repo A before connecting. + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoA, "a.go")}) + + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + msg1 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type) + require.Len(t, msg1.Repositories, 1) + require.Equal(t, repoA, msg1.Repositories[0].RepoRoot) + + // Advance past the scan cooldown so the next scan fires + // immediately. + mClock.Advance(2 * time.Second).MustWait(context.Background()) + + // Now add repo B via the PathStore (incremental). + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoB, "b.go")}) + + msg2 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type) + // The second message should include repo B. It may or may not + // include repo A depending on delta logic (no change in A since + // last emit), but repo B must be present. + foundB := false + for _, r := range msg2.Repositories { + if r.RepoRoot == repoB { + foundB = true + } + } + require.True(t, foundB, "incremental subscribe should include repo B") +} + +func TestWebSocketRefreshTriggersChanges(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "r.go"), []byte("package r\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "r.go")}) + + mClock := quartz.NewMock(t) + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // Consume initial changes. + _ = recvMsg(ctx, t, ch) + + // Advance past cooldown so the refresh scan fires immediately. + mClock.Advance(2 * time.Second).MustWait(context.Background()) + + // Modify a file, then send refresh. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "r2.go"), []byte("package r\n"), 0o600)) + err := stream.Send(codersdk.WorkspaceAgentGitClientMessage{ + Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh, + }) + require.NoError(t, err) + + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.NotEmpty(t, msg.Repositories) +} + +func TestWebSocketUnknownMessageType(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + stream := dialGitWatch(t) + ch := stream.Chan() + + err := stream.Send(codersdk.WorkspaceAgentGitClientMessage{ + Type: "bogus", + }) + require.NoError(t, err) + + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeError, msg.Type) + require.Contains(t, msg.Message, "unknown") +} + +func TestGetRepoChangesStagedModifiedDeleted(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Modify the committed file (worktree modified). + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("# Modified\n"), 0o600)) + + // Stage a new file. + repo, err := git.PlainOpen(repoDir) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "staged.go"), []byte("package staged\n"), 0o600)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("staged.go") + require.NoError(t, err) + + // Create an untracked file. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "untracked.txt"), []byte("hello\n"), 0o600)) + + h.Subscribe([]string{filepath.Join(repoDir, "README.md")}) + msg := h.Scan(context.Background()) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + // README.md was committed then modified in worktree. + require.Contains(t, msg.Repositories[0].UnifiedDiff, "README.md") + // staged.go was added to the staging area. + require.Contains(t, msg.Repositories[0].UnifiedDiff, "staged.go") + // untracked.txt is untracked. + require.Contains(t, msg.Repositories[0].UnifiedDiff, "untracked.txt") + require.Equal(t, `diff --git a/README.md b/README.md +index 8ae056963b8b4664c9059e30bc8b834151e03950..6c31532bd0a2258bcfa88789d20d50574cfcc3da 100644 +--- a/README.md ++++ b/README.md +@@ -1 +1 @@ +-# Test ++# Modified +diff --git a/staged.go b/staged.go +new file mode 100644 +index 0000000000000000000000000000000000000000..98a5a992ed2bc4b17d078d396ba034c8064079b4 +--- /dev/null ++++ b/staged.go +@@ -0,0 +1 @@ ++package staged +diff --git a/untracked.txt b/untracked.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..ce013625030ba8dba906f756967f9e9ca394464a +--- /dev/null ++++ b/untracked.txt +@@ -0,0 +1 @@ ++hello +`, msg.Repositories[0].UnifiedDiff) +} + +func TestFallbackPollTriggersScan(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + mClock := quartz.NewMock(t) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll.go"), []byte("package poll\n"), 0o600)) + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "poll.go")}) + + // Only the 30s fallback poll can trigger scans (no filesystem + // watcher). + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // We should get an initial scan from subscribe. + msg1 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type) + + // Add a new dirty file so the next scan has a delta to report. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll2.go"), []byte("package poll\n"), 0o600)) + + // Advance to the 30s fallback poll interval. This should + // trigger a scan without any explicit refresh. + mClock.Advance(30 * time.Second).MustWait(context.Background()) + + msg2 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type) + require.NotEmpty(t, msg2.Repositories) +} + +func TestMultipleConcurrentConnections(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "c.go"), []byte("package c\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "c.go")}) + + logger := slogtest.Make(t, nil) + api := agentgit.NewAPI(logger, ps) + srv := httptest.NewServer(api.Routes()) + t.Cleanup(srv.Close) + + wsURL := "ws" + srv.URL[len("http"):] + "/watch?chat_id=" + chatID.String() + + // Create two independent connections. + conn1, _, err := websocket.Dial(context.Background(), wsURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn1.Close(websocket.StatusNormalClosure, "") }) + + conn2, _, err := websocket.Dial(context.Background(), wsURL, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn2.Close(websocket.StatusNormalClosure, "") }) + + stream1 := wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn1, websocket.MessageText, websocket.MessageText, logger) + ch1 := stream1.Chan() + + stream2 := wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn2, websocket.MessageText, websocket.MessageText, logger) + ch2 := stream2.Chan() + + // Both should receive independent responses. + msg1 := recvMsg(ctx, t, ch1) + msg2 := recvMsg(ctx, t, ch2) + + assert.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type) + assert.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type) + assert.NotEmpty(t, msg1.Repositories) + assert.NotEmpty(t, msg2.Repositories) +} + +func TestScanLargeFileTooLargeToDiff(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a file larger than maxFileReadSize (2 MiB). + largeContent := make([]byte, 3*1024*1024) + for i := range largeContent { + largeContent[i] = byte('A' + (i % 26)) + } + largeFile := filepath.Join(repoDir, "large.bin") + require.NoError(t, os.WriteFile(largeFile, largeContent, 0o600)) + + h.Subscribe([]string{largeFile}) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + repo := msg.Repositories[0] + + // The large file should appear in the unified diff. + require.Contains(t, repo.UnifiedDiff, "large.bin") + + // The unified diff should contain the "too large" message, + // NOT the actual file content. + require.Contains(t, repo.UnifiedDiff, "File too large to diff") + require.NotContains(t, repo.UnifiedDiff, "AAAA", + "actual file content should not appear in diff") +} + +func TestScanLargeFileDeltaTracking(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a large file (3 MiB). + largeContent := make([]byte, 3*1024*1024) + for i := range largeContent { + largeContent[i] = byte('X') + } + largeFile := filepath.Join(repoDir, "big.dat") + require.NoError(t, os.WriteFile(largeFile, largeContent, 0o600)) + + h.Subscribe([]string{largeFile}) + ctx := context.Background() + + // First scan — should include the large file. + msg1 := h.Scan(ctx) + require.NotNil(t, msg1) + + // Second scan with no changes — should return nil (no delta). + msg2 := h.Scan(ctx) + require.Nil(t, msg2, "no changes should mean no delta") + + // Remove the large file — should emit a clean delta. + require.NoError(t, os.Remove(largeFile)) + msg3 := h.Scan(ctx) + require.NotNil(t, msg3) + + // The file was removed, so it should no longer appear in the diff. + require.NotContains(t, msg3.Repositories[0].UnifiedDiff, "big.dat") +} + +func TestScanFileDiffTooLargeForWire(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a single file whose diff exceeds maxFileDiffSize + // (256 KiB) but stays under maxFileReadSize (2 MiB). + content := make([]byte, 512*1024) + for i := range content { + content[i] = byte('A' + (i % 26)) + } + bigFile := filepath.Join(repoDir, "big_diff.txt") + require.NoError(t, os.WriteFile(bigFile, content, 0o600)) + + h.Subscribe([]string{bigFile}) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + repo := msg.Repositories[0] + + // The single file diff exceeds 256 KiB, so it should be + // replaced with a per-file stub. + require.Contains(t, repo.UnifiedDiff, "File diff too large to show") + require.Contains(t, repo.UnifiedDiff, "big_diff.txt") + + // The stub should NOT contain the actual file content. + require.NotContains(t, repo.UnifiedDiff, "ABCDEFGHIJ", + "actual file content should not appear in diff") + + // Branch metadata should still be present. + require.NotEmpty(t, repo.Branch) +} + +func TestScanTotalDiffTooLargeForWire(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create many files whose individual diffs are under 256 KiB + // but whose total exceeds maxTotalDiffSize (4 MiB). + // ~100 files x 50 KiB content each = ~5 MiB of diffs. + var paths []string + for i := range 100 { + content := make([]byte, 50*1024) + for j := range content { + content[j] = byte('A' + (i+j)%26) + } + name := fmt.Sprintf("file_%03d.txt", i) + fullPath := filepath.Join(repoDir, name) + require.NoError(t, os.WriteFile(fullPath, content, 0o600)) + paths = append(paths, fullPath) + } + + h.Subscribe(paths) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + repo := msg.Repositories[0] + + // The total diff exceeds 4 MiB, so we should get the + // total-diff placeholder. + require.Contains(t, repo.UnifiedDiff, "Total diff too large to show") + + // Branch and remote metadata should still be present. + require.NotEmpty(t, repo.Branch, "branch should still be populated") + + // The placeholder message should be well under 4 MiB. + require.Less(t, len(repo.UnifiedDiff), 4*1024*1024, + "placeholder diff should be much smaller than maxTotalDiffSize") +} + +func TestScanBinaryFileDiff(t *testing.T) { + t.Parallel() + + repoDir := initTestRepo(t) + logger := slogtest.Make(t, nil) + + h := agentgit.NewHandler(logger) + + // Create a new binary file (contains null bytes). + binaryContent := []byte("hello\x00world\x00binary") + binaryFile := filepath.Join(repoDir, "image.png") + require.NoError(t, os.WriteFile(binaryFile, binaryContent, 0o600)) + + h.Subscribe([]string{binaryFile}) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + repo := msg.Repositories[0] + + // The binary file should appear in the unified diff. + require.Contains(t, repo.UnifiedDiff, "image.png") + + // The unified diff should contain the go-git binary marker, + // not the raw binary content. + require.Contains(t, repo.UnifiedDiff, "Binary files") + require.Contains(t, repo.UnifiedDiff, "image.png") + require.NotContains(t, repo.UnifiedDiff, "\x00", + "raw binary content should not appear in diff") +} + +func TestScanBinaryFileModifiedDiff(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + require.NoError(t, err) + + // Commit a binary file. + binPath := filepath.Join(dir, "data.bin") + require.NoError(t, os.WriteFile(binPath, []byte("v1\x00\x01\x02"), 0o600)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("data.bin") + require.NoError(t, err) + _, err = wt.Commit("add binary", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + // Modify the binary file in the worktree. + require.NoError(t, os.WriteFile(binPath, []byte("v2\x00\x03\x04\x05"), 0o600)) + + logger := slogtest.Make(t, nil) + h := agentgit.NewHandler(logger) + h.Subscribe([]string{binPath}) + + ctx := context.Background() + msg := h.Scan(ctx) + require.NotNil(t, msg) + require.Len(t, msg.Repositories, 1) + + repoChanges := msg.Repositories[0] + + // The binary file should appear in the unified diff. + require.Contains(t, repoChanges.UnifiedDiff, "data.bin") + + // Diff should show binary marker for modification too. + require.Contains(t, repoChanges.UnifiedDiff, "Binary files") + require.Contains(t, repoChanges.UnifiedDiff, "data.bin") + require.NotContains(t, repoChanges.UnifiedDiff, "\x00", + "raw binary content should not appear in diff") +} + +func TestWebSocketLargePathStoreSubscription(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + + // Create a dirty file so we get a response. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "large.go"), []byte("package large\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + + // Build a path list with 500 paths — one real repo path and 499 + // long non-git paths that will be silently ignored. + paths := make([]string, 500) + for i := range paths { + if i == 0 { + paths[i] = filepath.Join(repoDir, "large.go") + } else { + // ~100 chars of padding. + padding := filepath.Join("/tmp", t.Name(), "deep", "nested", + "directory", "structure", "to", "pad", "the", "path", + "even", "more", "so", "it", "is", "long", "enough", + string(rune('a'+i%26))+".go") + paths[i] = padding + } + } + ps.AddPaths([]uuid.UUID{chatID}, paths) + + stream := dialGitWatchWithPathStore(t, ps, chatID) + ch := stream.Chan() + + // The handler must process the large path set and respond with + // changes. + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.Len(t, msg.Repositories, 1) + require.Equal(t, repoDir, msg.Repositories[0].RepoRoot) +} + +// --- End-to-end integration tests (PathStore → git watch pipeline) --- + +func TestE2E_WriteFileTriggersGitWatch(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + + // Write a dirty file into the repo. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "newfile.go"), []byte("package newfile\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + mClock := quartz.NewMock(t) + + // Connect the git watch WebSocket BEFORE adding any paths. + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // Simulate what HandleWriteFile does: add a path to the + // PathStore. This triggers a notification → subscribe → scan. + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "newfile.go")}) + + // The WebSocket should receive a changes message showing the + // repo with the dirty file. + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.NotEmpty(t, msg.Repositories) + + foundRepo := false + for _, r := range msg.Repositories { + if r.RepoRoot == repoDir { + foundRepo = true + require.Contains(t, r.UnifiedDiff, "newfile.go") + } + } + require.True(t, foundRepo, "expected repo %s in changes message", repoDir) +} + +func TestE2E_SubagentAncestorWatch(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + + // Write a dirty file that the child agent will "touch". + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "child.go"), []byte("package child\n"), 0o600)) + + ps := agentgit.NewPathStore() + parentChatID := uuid.New() + childChatID := uuid.New() + mClock := quartz.NewMock(t) + + // Connect a git watch WebSocket for the PARENT chat. + stream := dialGitWatchWithPathStore(t, ps, parentChatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // Simulate a tool call from the CHILD chat with the parent as + // ancestor. The PathStore propagates the paths to all ancestor + // chat IDs. + ps.AddPaths([]uuid.UUID{childChatID, parentChatID}, []string{filepath.Join(repoDir, "child.go")}) + + // The parent's git watch connection should receive a changes + // message because AddPaths notified parentChatID's subscribers. + msg := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg.Type) + require.NotEmpty(t, msg.Repositories) + + foundRepo := false + for _, r := range msg.Repositories { + if r.RepoRoot == repoDir { + foundRepo = true + require.Contains(t, r.UnifiedDiff, "child.go") + } + } + require.True(t, foundRepo, "parent watcher should see repo from child's tool call") +} + +func TestE2E_MultipleConcurrentChatWatchers(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create two separate git repos. + repoA := initTestRepo(t) + repoB := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoA, "a.go"), []byte("package a\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(repoB, "b.go"), []byte("package b\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatA := uuid.New() + chatB := uuid.New() + + // Pre-populate each chat with its own repo's paths. + ps.AddPaths([]uuid.UUID{chatA}, []string{filepath.Join(repoA, "a.go")}) + ps.AddPaths([]uuid.UUID{chatB}, []string{filepath.Join(repoB, "b.go")}) + + // Connect two separate git watch WebSockets, one per chat. + streamA := dialGitWatchWithPathStore(t, ps, chatA) + chA := streamA.Chan() + + streamB := dialGitWatchWithPathStore(t, ps, chatB) + chB := streamB.Chan() + + // Chat A should only see repoA. + msgA := recvMsg(ctx, t, chA) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msgA.Type) + require.NotEmpty(t, msgA.Repositories) + for _, r := range msgA.Repositories { + require.Equal(t, repoA, r.RepoRoot, + "chatA should only see repoA, got %s", r.RepoRoot) + } + + // Chat B should only see repoB. + msgB := recvMsg(ctx, t, chB) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msgB.Type) + require.NotEmpty(t, msgB.Repositories) + for _, r := range msgB.Repositories { + require.Equal(t, repoB, r.RepoRoot, + "chatB should only see repoB, got %s", r.RepoRoot) + } +} + +func TestE2E_ReEditedFileTriggersRescan(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + + // Write initial dirty file. + filePath := filepath.Join(repoDir, "edited.go") + require.NoError(t, os.WriteFile(filePath, []byte("package v1\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + mClock := quartz.NewMock(t) + + // First AddPaths — registers the path and repo. + ps.AddPaths([]uuid.UUID{chatID}, []string{filePath}) + + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // Receive the initial scan showing the dirty file. + msg1 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type) + require.NotEmpty(t, msg1.Repositories) + require.Contains(t, msg1.Repositories[0].UnifiedDiff, "v1") + + // Modify the same file again — the repo is already watched, + // so Subscribe returns false. The handler must still scan. + require.NoError(t, os.WriteFile(filePath, []byte("package v2\n"), 0o600)) + + // Advance past the scan cooldown so the second scan fires + // immediately. + mClock.Advance(2 * time.Second).MustWait(context.Background()) + + // AddPaths with the same path — triggers PathStore notification. + ps.AddPaths([]uuid.UUID{chatID}, []string{filePath}) + + // The handler should rescan and send an updated diff. + msg2 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type) + require.NotEmpty(t, msg2.Repositories) + require.Contains(t, msg2.Repositories[0].UnifiedDiff, "v2") +} + +func TestE2E_RepoDeletionEmitsRemoved(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + repoDir := initTestRepo(t) + + // Write a dirty file so the initial scan has something to track. + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "doomed.go"), []byte("package doomed\n"), 0o600)) + + ps := agentgit.NewPathStore() + chatID := uuid.New() + mClock := quartz.NewMock(t) + + // Pre-populate paths and connect. + ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "doomed.go")}) + + stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock)) + ch := stream.Chan() + + // Receive the initial changes message. + msg1 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type) + require.NotEmpty(t, msg1.Repositories) + require.False(t, msg1.Repositories[0].Removed) + + // Delete the entire repo directory. + require.NoError(t, os.RemoveAll(repoDir)) + + // Advance past the scan cooldown so the refresh fires + // immediately. + mClock.Advance(2 * time.Second).MustWait(context.Background()) + + // Send a refresh message to trigger a new scan. + err := stream.Send(codersdk.WorkspaceAgentGitClientMessage{ + Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh, + }) + require.NoError(t, err) + + // The next message should indicate the repo was removed. + msg2 := recvMsg(ctx, t, ch) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type) + require.NotEmpty(t, msg2.Repositories) + + foundRemoved := false + for _, r := range msg2.Repositories { + if r.RepoRoot == repoDir && r.Removed { + foundRemoved = true + } + } + require.True(t, foundRemoved, "expected repo %s to be marked as removed", repoDir) +} diff --git a/agent/agentgit/api.go b/agent/agentgit/api.go new file mode 100644 index 0000000000..6666112028 --- /dev/null +++ b/agent/agentgit/api.go @@ -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 + } + } + } + } +} diff --git a/agent/agentgit/chatheaders.go b/agent/agentgit/chatheaders.go new file mode 100644 index 0000000000..d516173ec8 --- /dev/null +++ b/agent/agentgit/chatheaders.go @@ -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 +} diff --git a/agent/agentgit/chatheaders_test.go b/agent/agentgit/chatheaders_test.go new file mode 100644 index 0000000000..3242c7b40a --- /dev/null +++ b/agent/agentgit/chatheaders_test.go @@ -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) +} diff --git a/agent/agentgit/pathstore.go b/agent/agentgit/pathstore.go new file mode 100644 index 0000000000..02d3d2af89 --- /dev/null +++ b/agent/agentgit/pathstore.go @@ -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 +} diff --git a/agent/agentgit/pathstore_test.go b/agent/agentgit/pathstore_test.go new file mode 100644 index 0000000000..b5e239c55f --- /dev/null +++ b/agent/agentgit/pathstore_test.go @@ -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) + } +} diff --git a/agent/agentproc/api.go b/agent/agentproc/api.go index 5a8c6d42d1..681faa05d5 100644 --- a/agent/agentproc/api.go +++ b/agent/agentproc/api.go @@ -7,9 +7,11 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "cdr.dev/slog/v3" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/agentgit" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -17,15 +19,17 @@ import ( // API exposes process-related operations through the agent. type API struct { - logger slog.Logger - manager *manager + logger slog.Logger + manager *manager + pathStore *agentgit.PathStore } // NewAPI creates a new process API handler. -func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *API { +func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore) *API { return &API{ - logger: logger, - manager: newManager(logger, execer, updateEnv), + logger: logger, + manager: newManager(logger, execer, updateEnv), + pathStore: pathStore, } } @@ -74,6 +78,23 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) { return } + // Notify git watchers after the process finishes so that + // file changes made by the command are visible in the scan. + // If a workdir is provided, track it as a path as well. + if api.pathStore != nil { + if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok { + allIDs := append([]uuid.UUID{chatID}, ancestorIDs...) + go func() { + <-proc.done + if req.WorkDir != "" { + api.pathStore.AddPaths(allIDs, []string{req.WorkDir}) + } else { + api.pathStore.Notify(allIDs) + } + }() + } + } + httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{ ID: proc.id, Started: true, diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index dc204c9076..38566bc85f 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -12,12 +12,14 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/agentgit" "github.com/coder/coder/v2/agent/agentproc" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" @@ -99,7 +101,7 @@ func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, e logger := slogtest.Make(t, &slogtest.Options{ IgnoreErrors: true, }).Leveled(slog.LevelDebug) - api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv) + api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil) t.Cleanup(func() { _ = api.Close() }) @@ -570,6 +572,46 @@ func TestSignalProcess(t *testing.T) { }) } +func TestHandleStartProcess_ChatHeaders_EmptyWorkDir_StillNotifies(t *testing.T) { + t.Parallel() + + pathStore := agentgit.NewPathStore() + chatID := uuid.New() + ch, unsub := pathStore.Subscribe(chatID) + defer unsub() + + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) { + return current, nil + }, pathStore) + defer api.Close() + + routes := api.Routes() + + body, err := json.Marshal(workspacesdk.StartProcessRequest{ + Command: "echo hello", + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/start", bytes.NewReader(body)) + req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String()) + rw := httptest.NewRecorder() + routes.ServeHTTP(rw, req) + + require.Equal(t, http.StatusOK, rw.Code) + + // The subscriber should be notified even though no paths + // were added. + select { + case <-ch: + case <-time.After(testutil.WaitShort): + t.Fatal("timed out waiting for path store notification") + } + + // No paths should have been stored for this chat. + require.Nil(t, pathStore.GetPaths(chatID)) +} + func TestProcessLifecycle(t *testing.T) { t.Parallel() diff --git a/agent/api.go b/agent/api.go index ce0b93f2c9..b98294ffbf 100644 --- a/agent/api.go +++ b/agent/api.go @@ -28,6 +28,7 @@ func (a *agent) apiHandler() http.Handler { }) r.Mount("/api/v0", a.filesAPI.Routes()) + r.Mount("/api/v0/git", a.gitAPI.Routes()) r.Mount("/api/v0/processes", a.processAPI.Routes()) if a.devcontainers { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e2922344eb..53b65527f0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -495,6 +495,35 @@ const docTemplate = `{ } } }, + "/chats/{chat}/git/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Chats" + ], + "summary": "Watch git changes for a chat.", + "operationId": "watch-chat-git", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/chats/{chat}/unarchive": { "post": { "tags": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0561c2f6b7..b24b130c48 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -422,6 +422,33 @@ } } }, + "/chats/{chat}/git/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Chats"], + "summary": "Watch git changes for a chat.", + "operationId": "watch-chat-git", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "101": { + "description": "Switching Protocols" + } + } + } + }, "/chats/{chat}/unarchive": { "post": { "tags": ["Chats"], diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index c3c8aacfcf..6b6c4beffb 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -2009,6 +2009,23 @@ func (p *Server) runChat( conn = agentConn releaseConn = agentRelease chatStateMu.Unlock() + + // Inject chat identity headers so agent-side + // handlers can track which paths this chat edits. + var ancestorIDs []string + if chatSnapshot.ParentChatID.Valid { + ancestorIDs = append(ancestorIDs, chatSnapshot.ParentChatID.UUID.String()) + } + ancestorJSON, err := json.Marshal(ancestorIDs) + if err != nil { + logger.Warn(ctx, "failed to marshal ancestor chat IDs", slog.Error(err)) + ancestorJSON = []byte("[]") + } + agentConn.SetExtraHeaders(http.Header{ + workspacesdk.CoderChatIDHeader: {chatSnapshot.ID.String()}, + workspacesdk.CoderAncestorChatIDsHeader: {string(ancestorJSON)}, + }) + return agentConn, nil } currentConn := conn diff --git a/coderd/chatd/chatd_test.go b/coderd/chatd/chatd_test.go index 9d66fba899..94f9b5b742 100644 --- a/coderd/chatd/chatd_test.go +++ b/coderd/chatd/chatd_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "strings" "sync" "sync/atomic" @@ -16,6 +17,8 @@ import ( "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/agent/agenttest" @@ -29,6 +32,8 @@ import ( dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" "github.com/coder/coder/v2/provisioner/echo" proto "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" @@ -1662,3 +1667,260 @@ func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T) !fromDB.LastError.Valid }, testutil.WaitMedium, testutil.IntervalFast) } + +func TestHeaderInjection(t *testing.T) { + t.Parallel() + + // seedWorkspaceAgent creates the DB entities needed so that + // GetWorkspaceAgentsInLatestBuildByWorkspaceID returns an + // agent for the given workspace. + seedWorkspaceAgent := func( + t *testing.T, + db database.Store, + ps dbpubsub.Pubsub, + ownerID uuid.UUID, + orgID uuid.UUID, + ) (workspaceID uuid.UUID, agentID uuid.UUID) { + t.Helper() + + // TemplateVersion needs its own provisioner job. + versionJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + OrganizationID: orgID, + InitiatorID: ownerID, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: orgID, + CreatedBy: ownerID, + JobID: versionJob.ID, + }) + templ := dbgen.Template(t, db, database.Template{ + OrganizationID: orgID, + CreatedBy: ownerID, + ActiveVersionID: tv.ID, + }) + ws := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: ownerID, + OrganizationID: orgID, + TemplateID: templ.ID, + }) + buildJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + OrganizationID: orgID, + InitiatorID: ownerID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + JobID: buildJob.ID, + BuildNumber: 1, + InitiatorID: ownerID, + TemplateVersionID: tv.ID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + return ws.ID, agent.ID + } + + t.Run("WithParentChat", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + org, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + workspaceID, expectedAgentID := seedWorkspaceAgent(t, db, ps, user.ID, org.ID) + + // Set up the mock OpenAI to return a simple text response + // so the chat finishes cleanly. + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("done")..., + ) + }) + setOpenAIProviderBaseURL(ctx, t, db, openAIURL) + + // Wire up the mock agent connection so we can capture + // the headers passed to SetExtraHeaders. + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + var capturedHeaders http.Header + headersCaptured := make(chan struct{}) + + // SetExtraHeaders is called once when the connection + // is first established. + mockConn.EXPECT().SetExtraHeaders(gomock.Any()).Do(func(h http.Header) { + capturedHeaders = h + close(headersCaptured) + }) + // resolveInstructions calls LS to look for instruction + // files; return an error so it skips gracefully. + mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).Return( + workspacesdk.LSResponse{}, xerrors.New("not found"), + ).AnyTimes() + // The connection is closed when the chat finishes. + mockConn.EXPECT().Close().Return(nil).AnyTimes() + + agentConnFn := func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, expectedAgentID, agentID) + return mockConn, func() {}, nil + } + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: uuid.New(), + Pubsub: ps, + AgentConn: agentConnFn, + PendingChatAcquireInterval: 10 * time.Millisecond, + InFlightChatStaleAfter: testutil.WaitSuperLong, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + + // Create a real parent chat so the FK constraint is + // satisfied. + parentChat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "parent-chat", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{ + fantasy.TextContent{Text: "parent"}, + }, + }) + require.NoError(t, err) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true}, + Title: "header-injection-parent", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{ + fantasy.TextContent{Text: "hello"}, + }, + }) + require.NoError(t, err) + + // Wait for the chat to be processed and headers to be + // captured. + select { + case <-headersCaptured: + case <-ctx.Done(): + require.FailNow(t, "timed out waiting for SetExtraHeaders") + } + + require.Equal(t, + chat.ID.String(), + capturedHeaders.Get(workspacesdk.CoderChatIDHeader), + ) + + ancestorJSON := capturedHeaders.Get(workspacesdk.CoderAncestorChatIDsHeader) + var ancestorIDs []string + err = json.Unmarshal([]byte(ancestorJSON), &ancestorIDs) + require.NoError(t, err) + require.Equal(t, []string{parentChat.ID.String()}, ancestorIDs) + }) + + t.Run("WithoutParentChat", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, model := seedChatDependencies(ctx, t, db) + + org, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + workspaceID, expectedAgentID := seedWorkspaceAgent(t, db, ps, user.ID, org.ID) + + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("done")..., + ) + }) + setOpenAIProviderBaseURL(ctx, t, db, openAIURL) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + var capturedHeaders http.Header + headersCaptured := make(chan struct{}) + + mockConn.EXPECT().SetExtraHeaders(gomock.Any()).Do(func(h http.Header) { + capturedHeaders = h + close(headersCaptured) + }) + mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).Return( + workspacesdk.LSResponse{}, xerrors.New("not found"), + ).AnyTimes() + mockConn.EXPECT().Close().Return(nil).AnyTimes() + + agentConnFn := func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, expectedAgentID, agentID) + return mockConn, func() {}, nil + } + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: uuid.New(), + Pubsub: ps, + AgentConn: agentConnFn, + PendingChatAcquireInterval: 10 * time.Millisecond, + InFlightChatStaleAfter: testutil.WaitSuperLong, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + + // Create a chat without a parent — the ancestor header + // should contain an empty JSON array. + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + Title: "header-injection-no-parent", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{ + fantasy.TextContent{Text: "hello"}, + }, + }) + require.NoError(t, err) + + select { + case <-headersCaptured: + case <-ctx.Done(): + require.FailNow(t, "timed out waiting for SetExtraHeaders") + } + + require.Equal(t, + chat.ID.String(), + capturedHeaders.Get(workspacesdk.CoderChatIDHeader), + ) + + // When there is no parent, the code declares + // var ancestorIDs []string and never appends to it, + // so json.Marshal produces "null". + ancestorJSON := capturedHeaders.Get(workspacesdk.CoderAncestorChatIDsHeader) + var ancestorIDs []string + err = json.Unmarshal([]byte(ancestorJSON), &ancestorIDs) + require.NoError(t, err) + require.Empty(t, ancestorIDs) + }) +} diff --git a/coderd/chats.go b/coderd/chats.go index 7659cf3f00..26c7828d07 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -12,6 +12,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "charm.land/fantasy" @@ -35,6 +36,8 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/wsjson" + "github.com/coder/websocket" ) const ( @@ -413,6 +416,162 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { }) } +// @Summary Watch git changes for a chat. +// @ID watch-chat-git +// @Security CoderSessionToken +// @Tags Chats +// @Param chat path string true "Chat ID" format(uuid) +// @Success 101 +// @Router /chats/{chat}/git/watch [get] +// +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) watchChatGit(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + chat = httpmw.ChatParam(r) + logger = api.Logger.Named("chat_git_watcher").With(slog.F("chat_id", chat.ID)) + ) + + if !chat.WorkspaceID.Valid { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat has no workspace to watch.", + }) + return + } + + agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace agents.", + Detail: err.Error(), + }) + return + } + if len(agents) == 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat workspace has no agents.", + }) + return + } + + apiAgent, err := db2sdk.WorkspaceAgent( + api.DERPMap(), + *api.TailnetCoordinator.Load(), + agents[0], + nil, + nil, + nil, + api.AgentInactiveDisconnectTimeout, + api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second) + defer dialCancel() + + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agents[0].ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + agentStream, err := agentConn.WatchGit(ctx, logger, chat.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error watching agent's git state.", + Detail: err.Error(), + }) + return + } + defer agentStream.Close(websocket.StatusGoingAway) + + clientConn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionNoContextTakeover, + }) + if err != nil { + logger.Error(ctx, "failed to accept websocket", slog.Error(err)) + return + } + + clientStream := wsjson.NewStream[ + codersdk.WorkspaceAgentGitClientMessage, + codersdk.WorkspaceAgentGitServerMessage, + ](clientConn, websocket.MessageText, websocket.MessageText, logger) + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + go httpapi.HeartbeatClose(ctx, logger, cancel, clientConn) + + // Proxy agent → client. + agentCh := agentStream.Chan() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-api.ctx.Done(): + return + case <-ctx.Done(): + return + case msg, ok := <-agentCh: + if !ok { + cancel() + return + } + if err := clientStream.Send(msg); err != nil { + logger.Debug(ctx, "failed to forward agent message to client", slog.Error(err)) + cancel() + return + } + } + } + }() + + // Proxy client → agent. + clientCh := clientStream.Chan() +proxyLoop: + for { + select { + case <-api.ctx.Done(): + break proxyLoop + case <-ctx.Done(): + break proxyLoop + case msg, ok := <-clientCh: + if !ok { + break proxyLoop + } + if err := agentStream.Send(msg); err != nil { + logger.Debug(ctx, "failed to forward client message to agent", slog.Error(err)) + break proxyLoop + } + } + } + + cancel() + wg.Wait() + _ = clientStream.Close(websocket.StatusGoingAway) +} + // @Summary Archive a chat // @ID archive-chat // @Tags Chats diff --git a/coderd/coderd.go b/coderd/coderd.go index e05e174cec..dc9a4bc5f3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1132,6 +1132,7 @@ func New(options *Options) *API { r.Route("/{chat}", func(r chi.Router) { r.Use(httpmw.ExtractChatParam(options.Database)) r.Get("/", api.getChat) + r.Get("/git/watch", api.watchChatGit) r.Post("/archive", api.archiveChat) r.Post("/unarchive", api.unarchiveChat) r.Post("/messages", api.postChatMessages) diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go index 29607731a0..0267f15283 100644 --- a/coderd/workspaceagents_internal_test.go +++ b/coderd/workspaceagents_internal_test.go @@ -11,6 +11,7 @@ import ( "net/http/httputil" "net/url" "strings" + "sync" "testing" "github.com/go-chi/chi/v5" @@ -68,6 +69,526 @@ func (c *channelCloser) Close() error { return nil } +func TestWatchChatGit(t *testing.T) { + t.Parallel() + + t.Run("ChatWithNoWorkspaceReturns400", func(t *testing.T) { + t.Parallel() + + // This test ensures that a chat with no workspace ID + // returns a 400 error. + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + + chatID = uuid.New() + + r = chi.NewMux() + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + }, + } + ) + + // Setup: Return a chat with no workspace ID. + mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{ + ID: chatID, + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{Valid: false}, + }, nil) + + // And: We mount the HTTP handler. + r.With(httpmw.ExtractChatParam(mDB)). + Get("/chats/{chat}/git/watch", api.watchChatGit) + + // Given: We create the HTTP server. + srv := httptest.NewServer(r) + defer srv.Close() + + // When: We make a request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Then: We expect a 400 response. + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("UnauthorizedUsersCannotWatch", func(t *testing.T) { + t.Parallel() + + // This test ensures that if the chat middleware returns + // an error (e.g. unauthorized), the handler is not + // reached. + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + + chatID = uuid.New() + + r = chi.NewMux() + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + }, + } + ) + + // Setup: Return an error from the DB to simulate + // unauthorized access. + mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return( + database.Chat{}, sql.ErrNoRows, + ) + + // And: We mount the HTTP handler. + r.With(httpmw.ExtractChatParam(mDB)). + Get("/chats/{chat}/git/watch", api.watchChatGit) + + // Given: We create the HTTP server. + srv := httptest.NewServer(r) + defer srv.Close() + + // When: We make a request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Then: We expect a 404 (not found) since sql.ErrNoRows + // is treated as a 404 by httpapi.Is404Error. + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("DisconnectedAgentRejected", func(t *testing.T) { + t.Parallel() + + // This test ensures that a chat whose workspace agent is + // not connected returns a 400 error. + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + mCoordinator = tailnettest.NewMockCoordinator(mCtrl) + + chatID = uuid.New() + workspaceID = uuid.New() + agentID = uuid.New() + resourceID = uuid.New() + + r = chi.NewMux() + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + TailnetCoordinator: tailnettest.NewFakeCoordinator(), + }, + } + ) + + var tailnetCoordinator tailnet.Coordinator = mCoordinator + api.TailnetCoordinator.Store(&tailnetCoordinator) + + // Setup: Return a chat with a valid workspace ID. + mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{ + ID: chatID, + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + // And: Return an agent that is disconnected (no + // FirstConnectedAt). + mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{{ + ID: agentID, + ResourceID: resourceID, + LifecycleState: database.WorkspaceAgentLifecycleStateCreated, + }}, nil) + + // And: Allow db2sdk.WorkspaceAgent to complete. + mCoordinator.EXPECT().Node(gomock.Any()).Return(nil) + + // And: We mount the HTTP handler. + r.With(httpmw.ExtractChatParam(mDB)). + Get("/chats/{chat}/git/watch", api.watchChatGit) + + // Given: We create the HTTP server. + srv := httptest.NewServer(r) + defer srv.Close() + + // When: We make a request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Then: We expect a 400 response since the agent is + // not connected. + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BidirectionalProxyWorks", func(t *testing.T) { + t.Parallel() + + // This test ensures that messages flow bidirectionally + // between the client websocket and the agent websocket + // through the coderd proxy. + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + mCoordinator = tailnettest.NewMockCoordinator(mCtrl) + mAgentConn = agentconnmock.NewMockAgentConn(mCtrl) + + chatID = uuid.New() + workspaceID = uuid.New() + agentID = uuid.New() + resourceID = uuid.New() + + r = chi.NewMux() + + fAgentProvider = fakeAgentProvider{ + agentConn: func(ctx context.Context, aID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + return mAgentConn, func() {}, nil + }, + } + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + TailnetCoordinator: tailnettest.NewFakeCoordinator(), + }, + } + ) + + var tailnetCoordinator tailnet.Coordinator = mCoordinator + api.TailnetCoordinator.Store(&tailnetCoordinator) + api.agentProvider = fAgentProvider + + // Setup: Create a fake agent-side websocket server that + // we can interact with. + agentDone := make(chan struct{}) + closeAgentDone := sync.OnceFunc(func() { close(agentDone) }) + t.Cleanup(closeAgentDone) + agentStreamReady := make(chan *wsjson.Stream[ + codersdk.WorkspaceAgentGitClientMessage, + codersdk.WorkspaceAgentGitServerMessage, + ], 1) + agentSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Create stream typed from the agent's perspective: + // reads client messages, writes server messages. + s := wsjson.NewStream[ + codersdk.WorkspaceAgentGitClientMessage, + codersdk.WorkspaceAgentGitServerMessage, + ](ws, websocket.MessageText, websocket.MessageText, logger) + agentStreamReady <- s + // Keep the handler alive until test signals done. + <-agentDone + })) + defer agentSrv.Close() + + // And: Return a chat with a valid workspace ID. + mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{ + ID: chatID, + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + // And: Return a connected agent. + mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{{ + ID: agentID, + ResourceID: resourceID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + FirstConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + LastConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }}, nil) + + // And: Allow db2sdk.WorkspaceAgent to complete. + mCoordinator.EXPECT().Node(gomock.Any()).Return(nil) + + // And: WatchGit dials our fake agent server and returns + // the stream. + mAgentConn.EXPECT().WatchGit(gomock.Any(), gomock.Any(), chatID). + DoAndReturn(func(ctx context.Context, _ slog.Logger, _ uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) { + agentURL := strings.Replace(agentSrv.URL, "http://", "ws://", 1) + conn, resp, err := websocket.Dial(ctx, agentURL, nil) + if err != nil { + return nil, err + } + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + // From coderd's perspective: reads server messages + // from agent, writes client messages to agent. + s := wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn, websocket.MessageText, websocket.MessageText, logger) + return s, nil + }) + // And: We mount the HTTP handler. + r.With(httpmw.ExtractChatParam(mDB)). + Get("/chats/{chat}/git/watch", api.watchChatGit) + + // Given: We create the HTTP server. + coderdSrv := httptest.NewServer(r) + defer coderdSrv.Close() + + // And: Dial the WebSocket as a client. + wsURL := strings.Replace(coderdSrv.URL, "http://", "ws://", 1) + clientConn, resp, err := websocket.Dial(ctx, fmt.Sprintf("%s/chats/%s/git/watch", wsURL, chatID), nil) + require.NoError(t, err) + if resp.Body != nil { + defer resp.Body.Close() + } + + // And: Create a client stream. + clientStream := wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](clientConn, websocket.MessageText, websocket.MessageText, logger) + clientCh := clientStream.Chan() + + // And: Wait for the agent stream to be ready. + agentStream := testutil.RequireReceive(ctx, t, agentStreamReady) + + // Test agent → client: Send a server message from the + // agent and verify the client receives it. + err = agentStream.Send(codersdk.WorkspaceAgentGitServerMessage{ + Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges, + Message: "test-changes", + }) + require.NoError(t, err) + + serverMsg := testutil.RequireReceive(ctx, t, clientCh) + require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, serverMsg.Type) + require.Equal(t, "test-changes", serverMsg.Message) + + // Test client → agent: Send a client message and verify + // the agent receives it. + agentCh := agentStream.Chan() + err = clientStream.Send(codersdk.WorkspaceAgentGitClientMessage{ + Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh, + }) + require.NoError(t, err) + + clientMsg := testutil.RequireReceive(ctx, t, agentCh) + require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, clientMsg.Type) + + // Cleanup: Close the client connection to unwind the + // proxy chain before closing the servers. + _ = clientStream.Close(websocket.StatusNormalClosure) + closeAgentDone() + coderdSrv.Close() + agentSrv.Close() + }) + + t.Run("ClientDisconnectTearsDown", func(t *testing.T) { + t.Parallel() + + // This test ensures that closing the client websocket + // causes the agent stream to be closed. + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + mCoordinator = tailnettest.NewMockCoordinator(mCtrl) + mAgentConn = agentconnmock.NewMockAgentConn(mCtrl) + + chatID = uuid.New() + workspaceID = uuid.New() + agentID = uuid.New() + resourceID = uuid.New() + + r = chi.NewMux() + + fAgentProvider = fakeAgentProvider{ + agentConn: func(ctx context.Context, aID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + return mAgentConn, func() {}, nil + }, + } + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + TailnetCoordinator: tailnettest.NewFakeCoordinator(), + }, + } + ) + + var tailnetCoordinator tailnet.Coordinator = mCoordinator + api.TailnetCoordinator.Store(&tailnetCoordinator) + api.agentProvider = fAgentProvider + + // Setup: Create a fake agent-side websocket server. + agentDone := make(chan struct{}) + closeAgentDone := sync.OnceFunc(func() { close(agentDone) }) + t.Cleanup(closeAgentDone) + agentStreamReady := make(chan *wsjson.Stream[ + codersdk.WorkspaceAgentGitClientMessage, + codersdk.WorkspaceAgentGitServerMessage, + ], 1) + agentSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s := wsjson.NewStream[ + codersdk.WorkspaceAgentGitClientMessage, + codersdk.WorkspaceAgentGitServerMessage, + ](ws, websocket.MessageText, websocket.MessageText, logger) + agentStreamReady <- s + // Keep the handler alive until test signals done. + <-agentDone + })) + defer agentSrv.Close() + + // And: Return a chat with a valid workspace ID. + mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{ + ID: chatID, + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + // And: Return a connected agent. + mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{{ + ID: agentID, + ResourceID: resourceID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + FirstConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + LastConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }}, nil) + + // And: Allow db2sdk.WorkspaceAgent to complete. + mCoordinator.EXPECT().Node(gomock.Any()).Return(nil) + + // And: WatchGit dials our fake agent server. + mAgentConn.EXPECT().WatchGit(gomock.Any(), gomock.Any(), chatID). + DoAndReturn(func(ctx context.Context, _ slog.Logger, _ uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) { + agentURL := strings.Replace(agentSrv.URL, "http://", "ws://", 1) + conn, resp, err := websocket.Dial(ctx, agentURL, nil) + if err != nil { + return nil, err + } + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + s := wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn, websocket.MessageText, websocket.MessageText, logger) + return s, nil + }) + // And: We mount the HTTP handler. + r.With(httpmw.ExtractChatParam(mDB)). + Get("/chats/{chat}/git/watch", api.watchChatGit) + + // Given: We create the HTTP server. + coderdSrv := httptest.NewServer(r) + defer coderdSrv.Close() + + // And: Dial the WebSocket as a client. + wsURL := strings.Replace(coderdSrv.URL, "http://", "ws://", 1) + clientConn, resp, err := websocket.Dial(ctx, fmt.Sprintf("%s/chats/%s/git/watch", wsURL, chatID), nil) + require.NoError(t, err) + if resp.Body != nil { + defer resp.Body.Close() + } + + // And: Wait for the agent stream to be ready. + agentStream := testutil.RequireReceive(ctx, t, agentStreamReady) + agentCh := agentStream.Chan() + + // And: Verify the proxy is working first by sending a + // message from agent to client. + err = agentStream.Send(codersdk.WorkspaceAgentGitServerMessage{ + Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges, + Message: "hello", + }) + require.NoError(t, err) + + clientDecoder := wsjson.NewDecoder[codersdk.WorkspaceAgentGitServerMessage](clientConn, websocket.MessageText, logger) + decodeCh := clientDecoder.Chan() + serverMsg := testutil.RequireReceive(ctx, t, decodeCh) + require.Equal(t, "hello", serverMsg.Message) + + // When: We close the client WebSocket. + clientConn.Close(websocket.StatusNormalClosure, "test closing connection") + + // Then: We expect agentCh to be closed, indicating + // teardown propagated to the agent side. + select { + case <-ctx.Done(): + t.Fatal("timed out waiting for agent channel to close") + + case _, ok := <-agentCh: + require.False(t, ok, "agent channel is expected to be closed") + } + + // Cleanup: Close the servers in the correct order. + closeAgentDone() + coderdSrv.Close() + agentSrv.Close() + }) +} + func TestWatchAgentContainers(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 94a5a0a9d8..7cf7665dca 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -697,3 +697,53 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, d := wsjson.NewDecoder[[]WorkspaceAgentLog](conn, websocket.MessageText, c.logger) return d.Chan(), d, nil } + +// WorkspaceAgentGitClientMessageType represents the type of a client +// message sent to the git watch WebSocket. +type WorkspaceAgentGitClientMessageType string + +const ( + // WorkspaceAgentGitClientMessageTypeRefresh requests an immediate + // re-scan of all subscribed repositories. + WorkspaceAgentGitClientMessageTypeRefresh WorkspaceAgentGitClientMessageType = "refresh" +) + +// WorkspaceAgentGitClientMessage is a message sent from the client to +// the agent over the git watch WebSocket. +type WorkspaceAgentGitClientMessage struct { + Type WorkspaceAgentGitClientMessageType `json:"type"` +} + +// WorkspaceAgentGitServerMessageType represents the type of a server +// message sent from the git watch WebSocket. +type WorkspaceAgentGitServerMessageType string + +const ( + // WorkspaceAgentGitServerMessageTypeChanges contains a delta of + // repository changes since the last emitted update. + WorkspaceAgentGitServerMessageTypeChanges WorkspaceAgentGitServerMessageType = "changes" + // WorkspaceAgentGitServerMessageTypeError signals a server-side + // error. + WorkspaceAgentGitServerMessageTypeError WorkspaceAgentGitServerMessageType = "error" +) + +// WorkspaceAgentGitServerMessage is a message sent from the agent to +// the client over the git watch WebSocket. +type WorkspaceAgentGitServerMessage struct { + Type WorkspaceAgentGitServerMessageType `json:"type"` + ScannedAt *time.Time `json:"scanned_at,omitempty" format:"date-time"` + Repositories []WorkspaceAgentRepoChanges `json:"repositories,omitempty"` + Message string `json:"message,omitempty"` +} + +// WorkspaceAgentRepoChanges describes the current state of a single +// git repository's working tree. When Removed is true the repo root +// directory or its .git subdirectory no longer exists; all other +// fields (Branch, RemoteOrigin, UnifiedDiff) are empty/zero. +type WorkspaceAgentRepoChanges struct { + RepoRoot string `json:"repo_root"` + Branch string `json:"branch"` + RemoteOrigin string `json:"remote_origin,omitempty"` + UnifiedDiff string `json:"unified_diff,omitempty"` + Removed bool `json:"removed,omitempty"` +} diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 55d918e405..9c1dd4f0d7 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -11,6 +11,7 @@ import ( "net/http" "net/netip" "strconv" + "sync" "time" "github.com/google/uuid" @@ -41,10 +42,21 @@ func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) AgentConn { } } +const ( + // CoderChatIDHeader is the HTTP header containing the current + // chat ID. Set by coderd on agentconn requests originating + // from chatd. + CoderChatIDHeader = "Coder-Chat-Id" + // CoderAncestorChatIDsHeader is the HTTP header containing a + // JSON array of ancestor chat UUIDs. + CoderAncestorChatIDsHeader = "Coder-Ancestor-Chat-Ids" +) + // AgentConn represents a connection to a workspace agent. // @typescript-ignore AgentConn type AgentConn interface { TailnetConn() *tailnet.Conn + SetExtraHeaders(h http.Header) AwaitReachable(ctx context.Context) bool Close() error @@ -76,19 +88,28 @@ type AgentConn interface { SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) + WatchGit(ctx context.Context, logger slog.Logger, chatID uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) } // AgentConn represents a connection to a workspace agent. // @typescript-ignore AgentConn type agentConn struct { *tailnet.Conn - opts AgentConnOptions + opts AgentConnOptions + headersMu sync.RWMutex + extraHeaders http.Header } func (c *agentConn) TailnetConn() *tailnet.Conn { return c.Conn } +func (c *agentConn) SetExtraHeaders(h http.Header) { + c.headersMu.Lock() + c.extraHeaders = h + c.headersMu.Unlock() +} + // @typescript-ignore AgentConnOptions type AgentConnOptions struct { AgentID uuid.UUID @@ -466,6 +487,49 @@ func (c *agentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<- return d.Chan(), d, nil } +// WatchGit opens a bidirectional WebSocket to the agent's git watch +// endpoint and returns a stream for sending subscribe/refresh messages +// and receiving change notifications. +func (c *agentConn) WatchGit(ctx context.Context, logger slog.Logger, chatID uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort)) + + dialOpts := &websocket.DialOptions{ + HTTPClient: c.apiClient(), + CompressionMode: websocket.CompressionNoContextTakeover, + } + c.headersMu.RLock() + if len(c.extraHeaders) > 0 { + dialOpts.HTTPHeader = c.extraHeaders.Clone() + } + c.headersMu.RUnlock() + + url := fmt.Sprintf("http://%s%s", host, "/api/v0/git/watch") + if chatID != uuid.Nil { + url += "?chat_id=" + chatID.String() + } + + conn, res, err := websocket.Dial(ctx, url, dialOpts) + if err != nil { + if res == nil { + return nil, err + } + return nil, codersdk.ReadBodyAsError(res) + } + if res != nil && res.Body != nil { + defer res.Body.Close() + } + + conn.SetReadLimit(1 << 22) // 4MiB + + return wsjson.NewStream[ + codersdk.WorkspaceAgentGitServerMessage, + codersdk.WorkspaceAgentGitClientMessage, + ](conn, websocket.MessageText, websocket.MessageText, logger), nil +} + // DeleteDevcontainer deletes the provided devcontainer. // This is a blocking call and will wait for the container to be deleted. func (c *agentConn) DeleteDevcontainer(ctx context.Context, devcontainerID string) error { @@ -861,6 +925,15 @@ func (c *agentConn) apiRequest(ctx context.Context, method, path string, body in return nil, xerrors.Errorf("new http api request to %q: %w", url, err) } + c.headersMu.RLock() + extraHeaders := c.extraHeaders.Clone() + c.headersMu.RUnlock() + for key, values := range extraHeaders { + for _, value := range values { + req.Header.Add(key, value) + } + } + return c.apiClient().Do(req) } diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 17c58782c0..34946851b9 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -13,6 +13,7 @@ import ( context "context" io "io" net "net" + http "net/http" reflect "reflect" time "time" @@ -20,6 +21,7 @@ import ( codersdk "github.com/coder/coder/v2/codersdk" healthsdk "github.com/coder/coder/v2/codersdk/healthsdk" workspacesdk "github.com/coder/coder/v2/codersdk/workspacesdk" + wsjson "github.com/coder/coder/v2/codersdk/wsjson" tailnet "github.com/coder/coder/v2/tailnet" uuid "github.com/google/uuid" gomock "go.uber.org/mock/gomock" @@ -431,6 +433,18 @@ func (mr *MockAgentConnMockRecorder) SSHOnPort(ctx, port any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHOnPort), ctx, port) } +// SetExtraHeaders mocks base method. +func (m *MockAgentConn) SetExtraHeaders(h http.Header) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetExtraHeaders", h) +} + +// SetExtraHeaders indicates an expected call of SetExtraHeaders. +func (mr *MockAgentConnMockRecorder) SetExtraHeaders(h any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetExtraHeaders", reflect.TypeOf((*MockAgentConn)(nil).SetExtraHeaders), h) +} + // SignalProcess mocks base method. func (m *MockAgentConn) SignalProcess(ctx context.Context, id, signal string) error { m.ctrl.T.Helper() @@ -505,6 +519,21 @@ func (mr *MockAgentConnMockRecorder) WatchContainers(ctx, logger any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchContainers", reflect.TypeOf((*MockAgentConn)(nil).WatchContainers), ctx, logger) } +// WatchGit mocks base method. +func (m *MockAgentConn) WatchGit(ctx context.Context, logger slog.Logger, chatID uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WatchGit", ctx, logger, chatID) + ret0, _ := ret[0].(*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WatchGit indicates an expected call of WatchGit. +func (mr *MockAgentConnMockRecorder) WatchGit(ctx, logger, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchGit", reflect.TypeOf((*MockAgentConn)(nil).WatchGit), ctx, logger, chatID) +} + // WriteFile mocks base method. func (m *MockAgentConn) WriteFile(ctx context.Context, path string, reader io.Reader) error { m.ctrl.T.Helper() diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index d75ae7ddf7..655993b962 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -18,6 +18,32 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/archive |--------|-----------------------------------------------------------------|-------------|--------| | 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +## Watch git changes for a chat + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats/{chat}/git/watch \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats/{chat}/git/watch` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------|----------|-------------| +| `chat` | path | string(uuid) | true | Chat ID | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------------------|---------------------|--------| +| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Unarchive a chat ### Code samples diff --git a/go.mod b/go.mod index 81a0ed70ce..32c919d675 100644 --- a/go.mod +++ b/go.mod @@ -490,6 +490,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.16.5 github.com/mark3labs/mcp-go v0.38.0 + github.com/sergi/go-diff v1.4.0 gonum.org/v1/gonum v0.17.0 ) @@ -539,8 +540,10 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cyphar/filepath-securejoin v0.5.1 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/esiqveland/notify v0.13.3 // indirect @@ -561,6 +564,7 @@ require ( github.com/kaptinlin/jsonpointer v0.4.10 // indirect github.com/kaptinlin/jsonschema v0.6.10 // indirect github.com/kaptinlin/messageformat-go v0.4.10 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c // indirect github.com/mattn/go-shellwords v1.0.12 // indirect @@ -572,6 +576,7 @@ require ( github.com/openai/openai-go/v2 v2.7.1 // indirect github.com/openai/openai-go/v3 v3.15.0 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/rhysd/actionlint v1.7.10 // indirect @@ -579,6 +584,7 @@ require ( github.com/samber/lo v1.51.0 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/sony/gobreaker/v2 v2.3.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -588,6 +594,7 @@ require ( github.com/urfave/cli/v2 v2.27.5 // indirect github.com/vektah/gqlparser/v2 v2.5.28 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 4e256ac3e3..a75c71d3b4 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,7 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -156,6 +157,8 @@ github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloD github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696 h1:7hAl/81gNUjmSCqJYKe1aTIVY4myjapaSALdCko19tI= github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -511,6 +514,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= @@ -929,6 +934,8 @@ github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDC github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/open-policy-agent/opa v1.6.0 h1:/S/cnNQJ2MUMNzizHPbisTWBHowmLkPrugY5jjkPlRQ= github.com/open-policy-agent/opa v1.6.0/go.mod h1:zFmw4P+W62+CWGYRDDswfVYSCnPo6oYaktQnfIaRFC4= github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1 h1:lK/3zr73guK9apbXTcnDnYrC0YCQ25V3CIULYz3k2xU= @@ -1322,6 +1329,7 @@ golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= @@ -1350,6 +1358,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -1389,6 +1398,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1399,6 +1409,7 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1432,6 +1443,7 @@ golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -1495,6 +1507,7 @@ gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 h1:wScziU1ff6Bnyr8MEyxATPSLJdnLxKz3p6RsA gopkg.in/DataDog/dd-trace-go.v1 v1.74.0/go.mod h1:ReNBsNfnsjVC7GsCe80zRcykL/n+nxvsNrg3NbjuleM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0d74a0705b..18df4f456a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7065,6 +7065,39 @@ export type WorkspaceAgentDevcontainerStatus = export const WorkspaceAgentDevcontainerStatuses: WorkspaceAgentDevcontainerStatus[] = ["deleting", "error", "running", "starting", "stopped", "stopping"]; +// From codersdk/workspaceagents.go +/** + * WorkspaceAgentGitClientMessage is a message sent from the client to + * the agent over the git watch WebSocket. + */ +export interface WorkspaceAgentGitClientMessage { + readonly type: WorkspaceAgentGitClientMessageType; +} + +// From codersdk/workspaceagents.go +export type WorkspaceAgentGitClientMessageType = "refresh"; + +export const WorkspaceAgentGitClientMessageTypes: WorkspaceAgentGitClientMessageType[] = + ["refresh"]; + +// From codersdk/workspaceagents.go +/** + * WorkspaceAgentGitServerMessage is a message sent from the agent to + * the client over the git watch WebSocket. + */ +export interface WorkspaceAgentGitServerMessage { + readonly type: WorkspaceAgentGitServerMessageType; + readonly scanned_at?: string; + readonly repositories?: readonly WorkspaceAgentRepoChanges[]; + readonly message?: string; +} + +// From codersdk/workspaceagents.go +export type WorkspaceAgentGitServerMessageType = "changes" | "error"; + +export const WorkspaceAgentGitServerMessageTypes: WorkspaceAgentGitServerMessageType[] = + ["changes", "error"]; + // From codersdk/workspaceagents.go export interface WorkspaceAgentHealth { readonly healthy: boolean; // Healthy is true if the agent is healthy. @@ -7218,6 +7251,21 @@ export interface WorkspaceAgentPortShares { readonly shares: readonly WorkspaceAgentPortShare[]; } +// From codersdk/workspaceagents.go +/** + * WorkspaceAgentRepoChanges describes the current state of a single + * git repository's working tree. When Removed is true the repo root + * directory or its .git subdirectory no longer exists; all other + * fields (Branch, RemoteOrigin, UnifiedDiff) are empty/zero. + */ +export interface WorkspaceAgentRepoChanges { + readonly repo_root: string; + readonly branch: string; + readonly remote_origin?: string; + readonly unified_diff?: string; + readonly removed?: boolean; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgentScript { readonly id: string;