feat: agents git watch backend (#22565)

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

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

This PR was implemented with AI with me closely controlling what it's
doing. The code follows a plan file that was updated continuously during
implementation. Here's the file if you'd like to see it:
[project.md](https://gist.github.com/hugodutka/8722cf80c92f8a56555f7bc595b770e2).
It reflects the current state of the PR.
This commit is contained in:
Hugo Dutka
2026-03-06 10:47:55 +01:00
committed by GitHub
parent 81468323e0
commit 48ab492f49
28 changed files with 4421 additions and 14 deletions
+74 -1
View File
@@ -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)
}
@@ -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()