mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user