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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user