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
@@ -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()