From 487ee9516e4197e5950e674ac4f91fccd207f413 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 3 Jul 2025 11:44:40 +0000 Subject: [PATCH 01/28] feat(site): use websocket connection for devcontainer updates Instead of polling every 10 seconds, we instead use a WebSocket connection for more timely updates. --- agent/agentcontainers/api.go | 63 +++++++++++++++++++ coderd/coderd.go | 1 + coderd/workspaceagents.go | 84 +++++++++++++++++++++++++ codersdk/workspacesdk/agentconn.go | 24 +++++++ site/src/api/api.ts | 11 ++++ site/src/modules/resources/AgentRow.tsx | 49 ++++++++++++--- 6 files changed, 222 insertions(+), 10 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index d749bf88a522e..9f2e5868fc92a 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -28,8 +28,10 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisioner" "github.com/coder/quartz" + "github.com/coder/websocket" ) const ( @@ -74,6 +76,7 @@ type API struct { mu sync.RWMutex // Protects the following fields. initDone chan struct{} // Closed by Init. + updateChans []chan struct{} closed bool containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. containersErr error // Error from the last list operation. @@ -535,6 +538,7 @@ func (api *API) Routes() http.Handler { r.Use(ensureInitDoneMW) r.Get("/", api.handleList) + r.Get("/watch", api.watchContainers) // TODO(mafredri): Simplify this route as the previous /devcontainers // /-route was dropped. We can drop the /devcontainers prefix here too. r.Route("/devcontainers/{devcontainer}", func(r chi.Router) { @@ -544,6 +548,60 @@ func (api *API) Routes() http.Handler { return r } +func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + ) + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upgrade connection to websocket.", + Detail: err.Error(), + }) + return + } + + go httpapi.Heartbeat(ctx, conn) + defer conn.Close(websocket.StatusNormalClosure, "connection closed") + + encoder := wsjson.NewEncoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText) + defer encoder.Close(websocket.StatusNormalClosure) + + updateCh := make(chan struct{}) + defer close(updateCh) + + api.mu.Lock() + api.updateChans = append(api.updateChans, updateCh) + api.mu.Unlock() + + defer func() { + api.mu.Lock() + api.updateChans = slices.DeleteFunc(api.updateChans, func(ch chan struct{}) bool { + return ch == updateCh + }) + api.mu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return + + case <-updateCh: + ct, err := api.getContainers() + if err != nil { + api.logger.Error(ctx, "get containers", slog.Error(err)) + } else { + if err := encoder.Encode(ct); err != nil { + api.logger.Error(ctx, "encode container list", slog.Error(err)) + } + } + + } + } +} + // handleList handles the HTTP request to list containers. func (api *API) handleList(rw http.ResponseWriter, r *http.Request) { ct, err := api.getContainers() @@ -585,6 +643,11 @@ func (api *API) updateContainers(ctx context.Context) error { api.processUpdatedContainersLocked(ctx, updated) + // Broadcast our updates + for _, ch := range api.updateChans { + ch <- struct{}{} + } + api.logger.Debug(ctx, "containers updated successfully", slog.F("container_count", len(api.containers.Containers)), slog.F("warning_count", len(api.containers.Warnings)), slog.F("devcontainer_count", len(api.knownDevcontainers))) return nil diff --git a/coderd/coderd.go b/coderd/coderd.go index 07c345135a5eb..5b164e9d7540a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1329,6 +1329,7 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/containers", api.workspaceAgentListContainers) + r.Get("/containers/watch", api.watchWorkspaceAgentContainers) r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer) r.Get("/coordinate", api.workspaceAgentClientCoordinate) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0ab28b340a1d1..2ac6a7248c7ae 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -801,6 +801,90 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, portsResponse) } +// @Summary Watch agent for container updates. +func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspaceAgent = httpmw.WorkspaceAgentParam(r) + ) + + // If the agent is unreachable, the request will hang. Assume that if we + // don't get a response after 30s that the agent is unreachable. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + apiAgent, err := db2sdk.WorkspaceAgent( + api.DERPMap(), + *api.TailnetCoordinator.Load(), + workspaceAgent, + 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 + } + + agentConn, release, err := api.agentProvider.AgentConn(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + containersCh, closer, err := agentConn.WatchContainers(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error watching agent's containers.", + Detail: err.Error(), + }) + return + } + defer closer.Close() + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upgrade connection to websocket.", + Detail: err.Error(), + }) + return + } + + go httpapi.Heartbeat(ctx, conn) + defer conn.Close(websocket.StatusNormalClosure, "connection closed") + + encoder := wsjson.NewEncoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText) + defer encoder.Close(websocket.StatusNormalClosure) + + for { + select { + case <-ctx.Done(): + return + + case containers := <-containersCh: + if err := encoder.Encode(containers); err != nil { + api.Logger.Error(ctx, "encode containers", slog.Error(err)) + return + } + } + } +} + // @Summary Get running containers for workspace agent // @ID get-running-containers-for-workspace-agent // @Security CoderSessionToken diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index ee0b36e5a0c23..dac5c57e02e9e 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "cdr.dev/slog" "github.com/google/uuid" "github.com/hashicorp/go-multierror" "golang.org/x/crypto/ssh" @@ -23,7 +24,9 @@ import ( "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/healthsdk" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/tailnet" + "github.com/coder/websocket" ) // NewAgentConn creates a new WorkspaceAgentConn. `conn` may be unique @@ -387,6 +390,27 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } +func (c *AgentConn) WatchContainers(ctx context.Context) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort)) + url := fmt.Sprintf("http://%s%s", host, "/api/v0/containers/watch") + + conn, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + HTTPClient: c.apiClient(), + }) + if err != nil { + if res != nil { + return nil, nil, codersdk.ReadBodyAsError(res) + } + return nil, nil, err + } + + d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, slog.Logger{}) + return d.Chan(), d, nil +} + // RecreateDevcontainer recreates a devcontainer with the given container. // This is a blocking call and will wait for the container to be recreated. func (c *AgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2b13c77faffa1..d09a89752830c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -129,6 +129,17 @@ export const watchWorkspace = ( }); }; +export const watchAgentContainers = ( + agentId: string, + labels?: string[], +): OneWayWebSocket => { + const params = new URLSearchParams(labels?.map((label) => ["label", label])); + + return new OneWayWebSocket({ + apiRoute: `/api/v2/workspaceagents/${agentId}/containers/watch?${params.toString()}`, + }); +}; + type WatchInboxNotificationsParams = Readonly<{ read_status?: "read" | "unread" | "all"; }>; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 3d0888f7872b1..1e727e5b52fa6 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -2,11 +2,12 @@ import type { Interpolation, Theme } from "@emotion/react"; import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; -import { API } from "api/api"; +import { API, watchAgentContainers } from "api/api"; import type { Template, Workspace, WorkspaceAgent, + WorkspaceAgentDevcontainer, WorkspaceAgentMetadata, } from "api/typesGenerated"; import { isAxiosError } from "axios"; @@ -25,7 +26,7 @@ import { useRef, useState, } from "react"; -import { useQuery } from "react-query"; +import { useQuery, useQueryClient } from "react-query"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; @@ -42,6 +43,9 @@ import { AgentSSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useAgentLogs } from "./useAgentLogs"; +import { OneWayWebSocket } from "utils/OneWayWebSocket"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { useEffectEvent } from "hooks/hookPolyfills"; interface AgentRowProps { agent: WorkspaceAgent; @@ -73,6 +77,7 @@ export const AgentRow: FC = ({ const showVSCode = hasVSCodeApp && !browser_only; const hasStartupFeatures = Boolean(agent.logs_length); + const queryClient = useQueryClient(); const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( ["starting", "start_timeout"].includes(agent.lifecycle_state) && @@ -138,16 +143,40 @@ export const AgentRow: FC = ({ queryFn: () => API.getAgentContainers(agent.id), enabled: agent.status === "connected", select: (res) => res.devcontainers, - // TODO: Implement a websocket connection to get updates on containers - // without having to poll. - refetchInterval: ({ state }) => { - const { error } = state; - return isAxiosError(error) && error.response?.status === 403 - ? false - : 10_000; - }, }); + const updateDevcontainersCache = useEffectEvent( + async (devcontainers: WorkspaceAgentDevcontainer[]) => { + const queryKey = ["agents", agent.id, "containers"]; + + queryClient.setQueryData(queryKey, devcontainers); + await queryClient.invalidateQueries({ queryKey }); + }, + ); + + useEffect(() => { + const socket = watchAgentContainers(agent.id); + + socket.addEventListener("message", (event) => { + if (event.parseError) { + displayError( + "Unable to process latest data from the server. Please try refreshing the page.", + ); + return; + } + + updateDevcontainersCache(event.parsedMessage); + }); + + socket.addEventListener("error", () => { + displayError( + "Unable to get workspace containers. Connection has been closed.", + ); + }); + + return () => socket.close(); + }, [agent.id, updateDevcontainersCache]); + // This is used to show the parent apps of the devcontainer. const [showParentApps, setShowParentApps] = useState(false); From cc42018be73785ae110fa145b7bbceec1fc318f4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 7 Jul 2025 11:59:16 +0000 Subject: [PATCH 02/28] fix: some issues --- agent/agentcontainers/api.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 9f2e5868fc92a..b12b70470e722 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -549,9 +549,7 @@ func (api *API) Routes() http.Handler { } func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - ) + ctx := r.Context() conn, err := websocket.Accept(rw, r, nil) if err != nil { @@ -592,12 +590,13 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { ct, err := api.getContainers() if err != nil { api.logger.Error(ctx, "get containers", slog.Error(err)) - } else { - if err := encoder.Encode(ct); err != nil { - api.logger.Error(ctx, "encode container list", slog.Error(err)) - } + continue } + if err := encoder.Encode(ct); err != nil { + api.logger.Error(ctx, "encode container list", slog.Error(err)) + return + } } } } From 975ef8bfcaa763a4257f7c655f63be356202de5d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 8 Jul 2025 15:25:10 +0000 Subject: [PATCH 03/28] chore: fix disconnect bug and add agentcontainers test --- agent/agentcontainers/api.go | 15 +++++-- agent/agentcontainers/api_test.go | 68 +++++++++++++++++++++++++++++++ coderd/workspaceagents.go | 2 + 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index b12b70470e722..4c21684755856 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -560,14 +560,20 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { return } + ctx = api.ctx + go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "connection closed") encoder := wsjson.NewEncoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText) defer encoder.Close(websocket.StatusNormalClosure) - updateCh := make(chan struct{}) - defer close(updateCh) + updateCh := make(chan struct{}, 1) + defer func() { + api.mu.Lock() + close(updateCh) + api.mu.Unlock() + }() api.mu.Lock() api.updateChans = append(api.updateChans, updateCh) @@ -644,7 +650,10 @@ func (api *API) updateContainers(ctx context.Context) error { // Broadcast our updates for _, ch := range api.updateChans { - ch <- struct{}{} + select { + case ch <- struct{}{}: + default: + } } api.logger.Debug(ctx, "containers updated successfully", slog.F("container_count", len(api.containers.Containers)), slog.F("warning_count", len(api.containers.Warnings)), slog.F("devcontainer_count", len(api.knownDevcontainers))) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 37ce66e2c150b..3a0ea106951f7 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -36,6 +36,7 @@ import ( "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" + "github.com/coder/websocket" ) // fakeContainerCLI implements the agentcontainers.ContainerCLI interface for @@ -441,6 +442,73 @@ func TestAPI(t *testing.T) { logbuf.Reset() }) + t.Run("Watch", func(t *testing.T) { + t.Parallel() + + fakeContainer1 := fakeContainer(t) + fakeContainer2 := fakeContainer(t) + fakeContainer3 := fakeContainer(t) + + makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { + return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} + } + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + mClock = quartz.NewMock(t) + updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop") + mCtrl = gomock.NewController(t) + mLister = acmock.NewMockContainerCLI(mCtrl) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + ) + + mLister.EXPECT().List(gomock.Any()).Return(makeResponse(), nil) + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mLister), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + ) + api.Start() + defer api.Close() + + srv := httptest.NewServer(api.Routes()) + defer srv.Close() + + updaterTickerTrap.MustWait(ctx).MustRelease(ctx) + defer updaterTickerTrap.Close() + + client, _, err := websocket.Dial(ctx, srv.URL+"/watch", nil) + require.NoError(t, err) + + for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{ + makeResponse(), + makeResponse(fakeContainer1), + makeResponse(fakeContainer1, fakeContainer2), + makeResponse(fakeContainer1, fakeContainer2, fakeContainer3), + makeResponse(fakeContainer1, fakeContainer2), + makeResponse(fakeContainer1), + makeResponse(), + } { + mLister.EXPECT().List(gomock.Any()).Return(mockResponse, nil) + + // Given: We allow the update loop to progress + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + // When: We attempt to read a message from the socket. + mt, msg, err := client.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, mt) + + // Then: We expect the receieved message matches the mocked response. + var got codersdk.WorkspaceAgentListContainersResponse + err = json.Unmarshal(msg, &got) + require.NoError(t, err) + require.Equal(t, mockResponse, got) + } + }) + // List tests the API.getContainers method using a mock // implementation. It specifically tests caching behavior. t.Run("List", func(t *testing.T) { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 2ac6a7248c7ae..bb8a1c673b111 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -865,6 +865,8 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re return } + ctx = api.ctx + go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "connection closed") From 6da941fbf1b652ff4c4a0516cfde9ba875deeec3 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 11:30:31 +0000 Subject: [PATCH 04/28] test: add coderd/ test --- coderd/workspaceagents_test.go | 85 ++++++++++++++++++++++++++++++++++ codersdk/workspaceagents.go | 49 ++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 899c863cc5fd6..92e877e573707 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1386,6 +1386,91 @@ func TestWorkspaceAgentContainers(t *testing.T) { }) } +func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + mClock = quartz.NewMock(t) + updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop") + mCtrl = gomock.NewController(t) + mCCLI = acmock.NewMockContainerCLI(mCtrl) + + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &logger}) + user = coderdtest.CreateFirstUser(t, client) + r = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + return agents + }).Do() + + devContainer = codersdk.WorkspaceAgentContainer{ + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } + + makeResponse = func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { + return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} + } + ) + + mCCLI.EXPECT().List(gomock.Any()).Return(makeResponse(), nil) + + _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { + o.Logger = logger.Named("agent") + o.Devcontainers = true + o.DevcontainerAPIOptions = []agentcontainers.Option{ + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + } + }) + + resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() + require.Len(t, resources, 1, "expected one resource") + require.Len(t, resources[0].Agents, 1, "expected one agent") + agentID := resources[0].Agents[0].ID + + updaterTickerTrap.MustWait(ctx).MustRelease(ctx) + defer updaterTickerTrap.Close() + + containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID, nil) + require.NoError(t, err) + defer func() { + closer.Close() + }() + + for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{ + makeResponse(), + makeResponse(devContainer), + makeResponse(), + } { + mCCLI.EXPECT().List(gomock.Any()).Return(mockResponse, nil) + + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + var resp codersdk.WorkspaceAgentListContainersResponse + select { + case <-ctx.Done(): + case resp = <-containers: + } + require.NoError(t, ctx.Err()) + require.Equal(t, mockResponse, resp) + } +} + func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 2bfae8aac36cf..9d437877e2b88 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/cookiejar" + "net/url" "strings" "time" @@ -520,6 +521,54 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid. return cr, json.NewDecoder(res.Body).Decode(&cr) } +func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid.UUID, labels map[string]string) (<-chan WorkspaceAgentListContainersResponse, io.Closer, error) { + var labelParams []string + for k, v := range labels { + k = url.QueryEscape(k) + v = url.QueryEscape(v) + + labelParams = append(labelParams, fmt.Sprintf("%s=%s", k, v)) + } + + var query string + if len(labelParams) > 0 { + query = "?" + strings.Join(labelParams, "&") + } + + reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/containers/watch%s", agentID, query)) + if err != nil { + return nil, nil, err + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, nil, xerrors.Errorf("create cookie jar: %w", err) + } + + jar.SetCookies(reqURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + + conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{ + CompressionMode: websocket.CompressionDisabled, + HTTPClient: &http.Client{ + Jar: jar, + Transport: c.HTTPClient.Transport, + }, + }) + if err != nil { + if res == nil { + return nil, nil, err + } + fmt.Println(err) + return nil, nil, ReadBodyAsError(res) + } + + d := wsjson.NewDecoder[WorkspaceAgentListContainersResponse](conn, websocket.MessageText, c.logger) + return d.Chan(), d, nil +} + // WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID. func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) (Response, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s/recreate", agentID, devcontainerID), nil) From ff5725e21bea4673efcb6eef6c517ec3ad71c3f5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 11:35:03 +0000 Subject: [PATCH 05/28] chore: appease formatter --- site/src/modules/resources/AgentRow.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 1e727e5b52fa6..0b8673716ebdd 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -10,11 +10,12 @@ import type { WorkspaceAgentDevcontainer, WorkspaceAgentMetadata, } from "api/typesGenerated"; -import { isAxiosError } from "axios"; import { Button } from "components/Button/Button"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { displayError } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { useProxy } from "contexts/ProxyContext"; +import { useEffectEvent } from "hooks/hookPolyfills"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import { @@ -43,9 +44,6 @@ import { AgentSSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useAgentLogs } from "./useAgentLogs"; -import { OneWayWebSocket } from "utils/OneWayWebSocket"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { useEffectEvent } from "hooks/hookPolyfills"; interface AgentRowProps { agent: WorkspaceAgent; From 178507c0cab9bdfd993ed1bf756bbf7bd5d4f126 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 12:03:30 +0000 Subject: [PATCH 06/28] chore: feedback --- agent/agentcontainers/api.go | 2 +- agent/agentcontainers/api_test.go | 5 ++++- coderd/workspaceagents_test.go | 2 +- codersdk/workspaceagents.go | 19 ++----------------- codersdk/workspacesdk/agentconn.go | 12 ++++++++---- site/src/api/api.ts | 5 +---- 6 files changed, 17 insertions(+), 28 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 4c21684755856..7e28ae28cdd25 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -595,7 +595,7 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { case <-updateCh: ct, err := api.getContainers() if err != nil { - api.logger.Error(ctx, "get containers", slog.Error(err)) + api.logger.Error(ctx, "unable to get containers", slog.Error(err)) continue } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 3a0ea106951f7..2050bee9fc3ce 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -478,8 +478,11 @@ func TestAPI(t *testing.T) { updaterTickerTrap.MustWait(ctx).MustRelease(ctx) defer updaterTickerTrap.Close() - client, _, err := websocket.Dial(ctx, srv.URL+"/watch", nil) + client, res, err := websocket.Dial(ctx, srv.URL+"/watch", nil) require.NoError(t, err) + if res != nil { + defer res.Body.Close() + } for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{ makeResponse(), diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 92e877e573707..7ba03045c8be5 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1445,7 +1445,7 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { updaterTickerTrap.MustWait(ctx).MustRelease(ctx) defer updaterTickerTrap.Close() - containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID, nil) + containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID) require.NoError(t, err) defer func() { closer.Close() diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 9d437877e2b88..0a0a41f0be2b6 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/cookiejar" - "net/url" "strings" "time" @@ -521,21 +520,8 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid. return cr, json.NewDecoder(res.Body).Decode(&cr) } -func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid.UUID, labels map[string]string) (<-chan WorkspaceAgentListContainersResponse, io.Closer, error) { - var labelParams []string - for k, v := range labels { - k = url.QueryEscape(k) - v = url.QueryEscape(v) - - labelParams = append(labelParams, fmt.Sprintf("%s=%s", k, v)) - } - - var query string - if len(labelParams) > 0 { - query = "?" + strings.Join(labelParams, "&") - } - - reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/containers/watch%s", agentID, query)) +func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid.UUID) (<-chan WorkspaceAgentListContainersResponse, io.Closer, error) { + reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/containers/watch", agentID)) if err != nil { return nil, nil, err } @@ -561,7 +547,6 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid if res == nil { return nil, nil, err } - fmt.Println(err) return nil, nil, ReadBodyAsError(res) } diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index dac5c57e02e9e..119c36eb0bc76 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -12,7 +12,6 @@ import ( "strconv" "time" - "cdr.dev/slog" "github.com/google/uuid" "github.com/hashicorp/go-multierror" "golang.org/x/crypto/ssh" @@ -21,6 +20,8 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/speedtest" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/healthsdk" @@ -401,10 +402,13 @@ func (c *AgentConn) WatchContainers(ctx context.Context) (<-chan codersdk.Worksp HTTPClient: c.apiClient(), }) if err != nil { - if res != nil { - return nil, nil, codersdk.ReadBodyAsError(res) + if res == nil { + return nil, nil, err } - return nil, nil, err + return nil, nil, codersdk.ReadBodyAsError(res) + } + if res != nil { + defer res.Body.Close() } d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, slog.Logger{}) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2dac8f3b890f0..d683a733de35c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -131,12 +131,9 @@ export const watchWorkspace = ( export const watchAgentContainers = ( agentId: string, - labels?: string[], ): OneWayWebSocket => { - const params = new URLSearchParams(labels?.map((label) => ["label", label])); - return new OneWayWebSocket({ - apiRoute: `/api/v2/workspaceagents/${agentId}/containers/watch?${params.toString()}`, + apiRoute: `/api/v2/workspaceagents/${agentId}/containers/watch`, }); }; From 367b87d10ecf56edf2171e7e4bbfa4ad48caf8bd Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 12:17:34 +0000 Subject: [PATCH 07/28] chore: fix nil exception --- agent/agentcontainers/api_test.go | 2 +- codersdk/workspacesdk/agentconn.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 2050bee9fc3ce..04a5400d50791 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -480,7 +480,7 @@ func TestAPI(t *testing.T) { client, res, err := websocket.Dial(ctx, srv.URL+"/watch", nil) require.NoError(t, err) - if res != nil { + if res != nil && res.Body != nil { defer res.Body.Close() } diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 119c36eb0bc76..7931ba641073c 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -407,7 +407,7 @@ func (c *AgentConn) WatchContainers(ctx context.Context) (<-chan codersdk.Worksp } return nil, nil, codersdk.ReadBodyAsError(res) } - if res != nil { + if res != nil && res.Body != nil { defer res.Body.Close() } From 34b17c430b94e3be3df3be3a2558b32b8b28be3e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 13:58:10 +0000 Subject: [PATCH 08/28] chore: make gen --- coderd/apidoc/docs.go | 35 ++++++++++++ coderd/apidoc/swagger.json | 31 +++++++++++ coderd/workspaceagents.go | 7 +++ docs/reference/api/agents.md | 105 +++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 79cff80b1fbc5..a05d93001f989 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8778,6 +8778,41 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Watch agent for container updates.", + "operationId": "watch-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5fa1d98030cb5..336433fe51e49 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7751,6 +7751,37 @@ } } }, + "/workspaceagents/{workspaceagent}/containers/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Watch agent for container updates.", + "operationId": "watch-containers-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index bb8a1c673b111..35434fc07c7b3 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -802,6 +802,13 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req } // @Summary Watch agent for container updates. +// @ID watch-containers-for-workspace-agent +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceAgentListContainersResponse +// @Router /workspaceagents/{workspaceagent}/containers/watch [get] func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index cff5fef6f3f8a..614a7fa0584eb 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -899,6 +899,111 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/co To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Watch agent for container updates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/watch \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/containers/watch` + +### Parameters + +| Name | In | Type | Required | Description | +|------------------|------|--------------|----------|--------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | + +### Example responses + +> 200 Response + +```json +{ + "containers": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + } + ], + "devcontainers": [ + { + "agent": { + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "config_path": "string", + "container": { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + }, + "dirty": true, + "error": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": "running", + "workspace_folder": "string" + } + ], + "warnings": [ + "string" + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgentListContainersResponse](schemas.md#codersdkworkspaceagentlistcontainersresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Coordinate workspace agent ### Code samples From 8f12460b5b1121855ed2e7127ed38441761e9af7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 14:19:33 +0000 Subject: [PATCH 09/28] fix: docs --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- coderd/workspaceagents.go | 4 ++-- docs/reference/api/agents.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a05d93001f989..63de31ddcdd42 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8791,8 +8791,8 @@ const docTemplate = `{ "tags": [ "Agents" ], - "summary": "Watch agent for container updates.", - "operationId": "watch-containers-for-workspace-agent", + "summary": "Watch workspace agent for container updates.", + "operationId": "watch-workspace-agent-for-container-updates", "parameters": [ { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 336433fe51e49..fddab50bea546 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7760,8 +7760,8 @@ ], "produces": ["application/json"], "tags": ["Agents"], - "summary": "Watch agent for container updates.", - "operationId": "watch-containers-for-workspace-agent", + "summary": "Watch workspace agent for container updates.", + "operationId": "watch-workspace-agent-for-container-updates", "parameters": [ { "type": "string", diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 35434fc07c7b3..c0fe1b9d77d94 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -801,8 +801,8 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, portsResponse) } -// @Summary Watch agent for container updates. -// @ID watch-containers-for-workspace-agent +// @Summary Watch workspace agent for container updates. +// @ID watch-workspace-agent-for-container-updates // @Security CoderSessionToken // @Produce json // @Tags Agents diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 614a7fa0584eb..54e9b0e6ad628 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -899,7 +899,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/co To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Watch agent for container updates +## Watch workspace agent for container updates ### Code samples From 1768f7b275cc39cb487748efb7b0383e55f2092c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 16:22:52 +0000 Subject: [PATCH 10/28] fix: only send when there are updates --- agent/agentcontainers/api.go | 27 +++- agent/agentcontainers/api_test.go | 119 +++++++++++++++--- coderd/workspaceagents_test.go | 114 ++++++++++++++--- codersdk/workspaceagents.go | 9 ++ site/src/modules/resources/AgentRow.tsx | 40 +----- .../modules/resources/useAgentContainers.ts | 53 ++++++++ 6 files changed, 287 insertions(+), 75 deletions(-) create mode 100644 site/src/modules/resources/useAgentContainers.ts diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 7e28ae28cdd25..c6c1d6fa16759 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "net/http" "os" "path" @@ -646,13 +647,29 @@ func (api *API) updateContainers(ctx context.Context) error { api.mu.Lock() defer api.mu.Unlock() + var knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer + if len(api.updateChans) > 0 { + knownDevcontainers = maps.Clone(api.knownDevcontainers) + } + api.processUpdatedContainersLocked(ctx, updated) - // Broadcast our updates - for _, ch := range api.updateChans { - select { - case ch <- struct{}{}: - default: + if len(api.updateChans) > 0 { + statesAreEqual := maps.EqualFunc( + knownDevcontainers, + api.knownDevcontainers, + func(dc1, dc2 codersdk.WorkspaceAgentDevcontainer) bool { + return dc1.Equals(dc2) + }) + + if !statesAreEqual { + // Broadcast our updates + for _, ch := range api.updateChans { + select { + case ch <- struct{}{}: + default: + } + } } } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 04a5400d50791..3029bf77f60c5 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -445,14 +445,95 @@ func TestAPI(t *testing.T) { t.Run("Watch", func(t *testing.T) { t.Parallel() - fakeContainer1 := fakeContainer(t) - fakeContainer2 := fakeContainer(t) - fakeContainer3 := fakeContainer(t) - makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} } + fakeContainer1 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) { + c.ID = "container1" + c.FriendlyName = "devcontainer1" + c.Image = "busybox:latest" + c.Labels = map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", + } + }) + + fakeContainer2 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) { + c.ID = "container2" + c.FriendlyName = "devcontainer2" + c.Image = "ubuntu:latest" + c.Labels = map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", + } + }) + + stages := []struct { + containers []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse + }{ + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "stopped", + Container: nil, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + } + var ( ctx = testutil.Context(t, testutil.WaitShort) mClock = quartz.NewMock(t) @@ -467,7 +548,7 @@ func TestAPI(t *testing.T) { api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), agentcontainers.WithContainerCLI(mLister), - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), + agentcontainers.WithWatcher(watcher.NewNoop()), ) api.Start() defer api.Close() @@ -484,16 +565,10 @@ func TestAPI(t *testing.T) { defer res.Body.Close() } - for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{ - makeResponse(), - makeResponse(fakeContainer1), - makeResponse(fakeContainer1, fakeContainer2), - makeResponse(fakeContainer1, fakeContainer2, fakeContainer3), - makeResponse(fakeContainer1, fakeContainer2), - makeResponse(fakeContainer1), - makeResponse(), - } { - mLister.EXPECT().List(gomock.Any()).Return(mockResponse, nil) + mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + + for _, stage := range stages { + mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) // Given: We allow the update loop to progress _, aw := mClock.AdvanceNext() @@ -504,11 +579,21 @@ func TestAPI(t *testing.T) { require.NoError(t, err) require.Equal(t, websocket.MessageText, mt) - // Then: We expect the receieved message matches the mocked response. + // Then: We expect the receieved message matches the expected response. var got codersdk.WorkspaceAgentListContainersResponse err = json.Unmarshal(msg, &got) require.NoError(t, err) - require.Equal(t, mockResponse, got) + + require.Equal(t, stage.expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers)) + for j, expectedDev := range stage.expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } } }) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 7ba03045c8be5..794a2b7c10268 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1406,14 +1406,27 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { return agents }).Do() - devContainer = codersdk.WorkspaceAgentContainer{ - ID: uuid.NewString(), + fakeContainer1 = codersdk.WorkspaceAgentContainer{ + ID: "container1", CreatedAt: dbtime.Now(), - FriendlyName: testutil.GetRandomName(t), + FriendlyName: "container1", Image: "busybox:latest", Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project", - agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } + + fakeContainer2 = codersdk.WorkspaceAgentContainer{ + ID: "container1", + CreatedAt: dbtime.Now(), + FriendlyName: "container2", + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", }, Running: true, Status: "running", @@ -1424,6 +1437,71 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { } ) + stages := []struct { + containers []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse + }{ + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "stopped", + Container: nil, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + } + mCCLI.EXPECT().List(gomock.Any()).Return(makeResponse(), nil) _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { @@ -1433,7 +1511,6 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { agentcontainers.WithClock(mClock), agentcontainers.WithContainerCLI(mCCLI), agentcontainers.WithWatcher(watcher.NewNoop()), - agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), } }) @@ -1451,23 +1528,30 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { closer.Close() }() - for _, mockResponse := range []codersdk.WorkspaceAgentListContainersResponse{ - makeResponse(), - makeResponse(devContainer), - makeResponse(), - } { - mCCLI.EXPECT().List(gomock.Any()).Return(mockResponse, nil) + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + for _, stage := range stages { + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) _, aw := mClock.AdvanceNext() aw.MustWait(ctx) - var resp codersdk.WorkspaceAgentListContainersResponse + var got codersdk.WorkspaceAgentListContainersResponse select { case <-ctx.Done(): - case resp = <-containers: + case got = <-containers: } require.NoError(t, ctx.Err()) - require.Equal(t, mockResponse, resp) + + require.Equal(t, stage.expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers)) + for j, expectedDev := range stage.expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } } } diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 0a0a41f0be2b6..9e9d4a3d6448f 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -421,6 +421,15 @@ type WorkspaceAgentDevcontainer struct { Error string `json:"error,omitempty"` } +func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) bool { + return d.ID == other.ID && + d.Name == other.Name && + d.WorkspaceFolder == other.WorkspaceFolder && + d.Status == other.Status && + d.Dirty == other.Dirty && + d.Error == other.Error +} + // WorkspaceAgentDevcontainerAgent represents the sub agent for a // devcontainer. type WorkspaceAgentDevcontainerAgent struct { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 0b8673716ebdd..fe53c93bd8bb1 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -44,6 +44,7 @@ import { AgentSSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useAgentLogs } from "./useAgentLogs"; +import { useAgentContainers } from "./useAgentContainers"; interface AgentRowProps { agent: WorkspaceAgent; @@ -136,44 +137,7 @@ export const AgentRow: FC = ({ setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT); }, []); - const { data: devcontainers } = useQuery({ - queryKey: ["agents", agent.id, "containers"], - queryFn: () => API.getAgentContainers(agent.id), - enabled: agent.status === "connected", - select: (res) => res.devcontainers, - }); - - const updateDevcontainersCache = useEffectEvent( - async (devcontainers: WorkspaceAgentDevcontainer[]) => { - const queryKey = ["agents", agent.id, "containers"]; - - queryClient.setQueryData(queryKey, devcontainers); - await queryClient.invalidateQueries({ queryKey }); - }, - ); - - useEffect(() => { - const socket = watchAgentContainers(agent.id); - - socket.addEventListener("message", (event) => { - if (event.parseError) { - displayError( - "Unable to process latest data from the server. Please try refreshing the page.", - ); - return; - } - - updateDevcontainersCache(event.parsedMessage); - }); - - socket.addEventListener("error", () => { - displayError( - "Unable to get workspace containers. Connection has been closed.", - ); - }); - - return () => socket.close(); - }, [agent.id, updateDevcontainersCache]); + const devcontainers = useAgentContainers(agent); // This is used to show the parent apps of the devcontainer. const [showParentApps, setShowParentApps] = useState(false); diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts new file mode 100644 index 0000000000000..4be3bd670d6f9 --- /dev/null +++ b/site/src/modules/resources/useAgentContainers.ts @@ -0,0 +1,53 @@ +import { API, watchAgentContainers } from "api/api"; +import { WorkspaceAgent, WorkspaceAgentDevcontainer } from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "react-query"; + +export function useAgentContainers( + agent: WorkspaceAgent, +): readonly WorkspaceAgentDevcontainer[] | undefined { + const queryClient = useQueryClient(); + + const { data: devcontainers } = useQuery({ + queryKey: ["agents", agent.id, "containers"], + queryFn: () => API.getAgentContainers(agent.id), + enabled: agent.status === "connected", + select: (res) => res.devcontainers, + }); + + const updateDevcontainersCache = useEffectEvent( + async (devcontainers: WorkspaceAgentDevcontainer[]) => { + const queryKey = ["agents", agent.id, "containers"]; + + queryClient.setQueryData(queryKey, devcontainers); + await queryClient.invalidateQueries({ queryKey }); + }, + ); + + useEffect(() => { + const socket = watchAgentContainers(agent.id); + + socket.addEventListener("message", (event) => { + if (event.parseError) { + displayError( + "Unable to process latest data from the server. Please try refreshing the page.", + ); + return; + } + + updateDevcontainersCache(event.parsedMessage); + }); + + socket.addEventListener("error", () => { + displayError( + "Unable to get workspace containers. Connection has been closed.", + ); + }); + + return () => socket.close(); + }, [agent.id, updateDevcontainersCache]); + + return devcontainers; +} From 8240663459c11d5d9716d513df643b1efa98bfe6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 9 Jul 2025 16:29:49 +0000 Subject: [PATCH 11/28] chore: lint and format --- site/src/modules/resources/AgentRow.tsx | 8 ++------ site/src/modules/resources/useAgentContainers.ts | 5 ++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index fe53c93bd8bb1..9a5935b6451b5 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -2,20 +2,16 @@ import type { Interpolation, Theme } from "@emotion/react"; import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; -import { API, watchAgentContainers } from "api/api"; import type { Template, Workspace, WorkspaceAgent, - WorkspaceAgentDevcontainer, WorkspaceAgentMetadata, } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { useProxy } from "contexts/ProxyContext"; -import { useEffectEvent } from "hooks/hookPolyfills"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import { @@ -27,7 +23,7 @@ import { useRef, useState, } from "react"; -import { useQuery, useQueryClient } from "react-query"; +import { useQueryClient } from "react-query"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; @@ -43,8 +39,8 @@ import { PortForwardButton } from "./PortForwardButton"; import { AgentSSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; -import { useAgentLogs } from "./useAgentLogs"; import { useAgentContainers } from "./useAgentContainers"; +import { useAgentLogs } from "./useAgentLogs"; interface AgentRowProps { agent: WorkspaceAgent; diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index 4be3bd670d6f9..1e6c59b1ee579 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -1,5 +1,8 @@ import { API, watchAgentContainers } from "api/api"; -import { WorkspaceAgent, WorkspaceAgentDevcontainer } from "api/typesGenerated"; +import type { + WorkspaceAgent, + WorkspaceAgentDevcontainer, +} from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; import { useEffectEvent } from "hooks/hookPolyfills"; import { useEffect } from "react"; From 88a611d50131e4fd921e53840b22fe9d386ee584 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 08:57:22 +0000 Subject: [PATCH 12/28] chore: test `useAgentContainers` --- .../resources/useAgentContainers.test.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 site/src/modules/resources/useAgentContainers.test.tsx diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx new file mode 100644 index 0000000000000..6bf398fbd1991 --- /dev/null +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -0,0 +1,85 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { FC, PropsWithChildren } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { + MockWorkspaceAgent, + MockWorkspaceAgentDevcontainer, +} from "testHelpers/entities"; +import { server } from "testHelpers/server"; +import { useAgentContainers } from "./useAgentContainers"; + +const createWrapper = (): FC => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }) => ( + {children} + ); +}; + +describe("useAgentContainers", () => { + it("returns containers when agent is connected", async () => { + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { result } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(result.current).toEqual([MockWorkspaceAgentDevcontainer]); + }); + }); + + it("returns undefined when agent is not connected", () => { + const disconnectedAgent = { + ...MockWorkspaceAgent, + status: "disconnected" as const, + }; + + const { result } = renderHook(() => useAgentContainers(disconnectedAgent), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeUndefined(); + }); + + it("handles API errors gracefully", async () => { + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.error(); + }, + ), + ); + + const { result } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); +}); From 001ccdaf437e02a0f3d48ad45e66afe07d8bbbef Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 12:23:30 +0000 Subject: [PATCH 13/28] chore: check container ids match in `Equals` function --- codersdk/workspaceagents.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 9e9d4a3d6448f..c5d93a8e4ef9b 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -427,6 +427,8 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo d.WorkspaceFolder == other.WorkspaceFolder && d.Status == other.Status && d.Dirty == other.Dirty && + (d.Container == nil && other.Container == nil || + (d.Container != nil && other.Container != nil && d.Container.ID == other.Container.ID)) && d.Error == other.Error } From 3e50965a2174df95ecc3be5944d5b96ebfbf9252 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 12:24:05 +0000 Subject: [PATCH 14/28] chore: add logger to WatchContainers --- coderd/workspaceagents.go | 3 ++- codersdk/workspacesdk/agentconn.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c0fe1b9d77d94..91633de45503d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -853,7 +853,8 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re } defer release() - containersCh, closer, err := agentConn.WatchContainers(ctx) + watcherLogger := api.Logger.Named("agent_container_watcher").With(slog.F("agent_id", workspaceAgent.ID)) + containersCh, closer, err := agentConn.WatchContainers(ctx, watcherLogger) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error watching agent's containers.", diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 7931ba641073c..ce66d5e1b8a70 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -391,7 +391,7 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } -func (c *AgentConn) WatchContainers(ctx context.Context) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { +func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -411,7 +411,7 @@ func (c *AgentConn) WatchContainers(ctx context.Context) (<-chan codersdk.Worksp defer res.Body.Close() } - d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, slog.Logger{}) + d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, logger) return d.Chan(), d, nil } From 6ce5c195a6fab443790893b679cc0e8b0d7c00a1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 12:24:23 +0000 Subject: [PATCH 15/28] chore: reposition close of update channel --- agent/agentcontainers/api.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index c6c1d6fa16759..a2da94931ca56 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -570,11 +570,6 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { defer encoder.Close(websocket.StatusNormalClosure) updateCh := make(chan struct{}, 1) - defer func() { - api.mu.Lock() - close(updateCh) - api.mu.Unlock() - }() api.mu.Lock() api.updateChans = append(api.updateChans, updateCh) @@ -585,6 +580,7 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { api.updateChans = slices.DeleteFunc(api.updateChans, func(ch chan struct{}) bool { return ch == updateCh }) + close(updateCh) api.mu.Unlock() }() From cd0c2d553834c4497d23470a378d608b97be1b5e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 12:25:13 +0000 Subject: [PATCH 16/28] chore: rename `knownDevcontainers` --- agent/agentcontainers/api.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index a2da94931ca56..c8910dc1da46e 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -643,16 +643,16 @@ func (api *API) updateContainers(ctx context.Context) error { api.mu.Lock() defer api.mu.Unlock() - var knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer + var previouslyKnownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer if len(api.updateChans) > 0 { - knownDevcontainers = maps.Clone(api.knownDevcontainers) + previouslyKnownDevcontainers = maps.Clone(api.knownDevcontainers) } api.processUpdatedContainersLocked(ctx, updated) if len(api.updateChans) > 0 { statesAreEqual := maps.EqualFunc( - knownDevcontainers, + previouslyKnownDevcontainers, api.knownDevcontainers, func(dc1, dc2 codersdk.WorkspaceAgentDevcontainer) bool { return dc1.Equals(dc2) From 04a92a48abf8a25a73ce30582e4efec2827c46c6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 12:25:27 +0000 Subject: [PATCH 17/28] chore: use `WebsocketNetConn` --- agent/agentcontainers/api.go | 14 +++++++------- coderd/workspaceagents.go | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index c8910dc1da46e..012a7eba56736 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -2,6 +2,7 @@ package agentcontainers import ( "context" + "encoding/json" "errors" "fmt" "maps" @@ -29,7 +30,6 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" - "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisioner" "github.com/coder/quartz" "github.com/coder/websocket" @@ -561,13 +561,10 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { return } - ctx = api.ctx + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) + defer wsNetConn.Close() go httpapi.Heartbeat(ctx, conn) - defer conn.Close(websocket.StatusNormalClosure, "connection closed") - - encoder := wsjson.NewEncoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText) - defer encoder.Close(websocket.StatusNormalClosure) updateCh := make(chan struct{}, 1) @@ -586,6 +583,9 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { for { select { + case <-api.ctx.Done(): + return + case <-ctx.Done(): return @@ -596,7 +596,7 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { continue } - if err := encoder.Encode(ct); err != nil { + if err := json.NewEncoder(wsNetConn).Encode(ct); err != nil { api.logger.Error(ctx, "encode container list", slog.Error(err)) return } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 91633de45503d..bee7222e35bd1 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -817,7 +817,7 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re // If the agent is unreachable, the request will hang. Assume that if we // don't get a response after 30s that the agent is unreachable. - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() apiAgent, err := db2sdk.WorkspaceAgent( api.DERPMap(), @@ -843,7 +843,7 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re return } - agentConn, release, err := api.agentProvider.AgentConn(ctx, workspaceAgent.ID) + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error dialing workspace agent.", @@ -873,21 +873,21 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re return } - ctx = api.ctx + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) + defer wsNetConn.Close() go httpapi.Heartbeat(ctx, conn) - defer conn.Close(websocket.StatusNormalClosure, "connection closed") - - encoder := wsjson.NewEncoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText) - defer encoder.Close(websocket.StatusNormalClosure) for { select { + case <-api.ctx.Done(): + return + case <-ctx.Done(): return case containers := <-containersCh: - if err := encoder.Encode(containers); err != nil { + if err := json.NewEncoder(wsNetConn).Encode(containers); err != nil { api.Logger.Error(ctx, "encode containers", slog.Error(err)) return } From 096a85e1495855b3749f504d0258b58c0cdcaacc Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 13:25:19 +0000 Subject: [PATCH 18/28] chore: steal CloseRead --- agent/agentcontainers/api.go | 4 ++++ coderd/workspaceagents.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 012a7eba56736..68c3568b01b34 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -561,6 +561,10 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { return } + // Here we close the websocket for reading, so that the websocket library will handle pings and + // close frames. + _ = conn.CloseRead(context.Background()) + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) defer wsNetConn.Close() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index bee7222e35bd1..c120133c103ac 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -873,6 +873,10 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re return } + // Here we close the websocket for reading, so that the websocket library will handle pings and + // close frames. + _ = conn.CloseRead(context.Background()) + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) defer wsNetConn.Close() From 971f9d61b113e7a772eb3846c1aa971c45a3cd05 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 13:28:28 +0000 Subject: [PATCH 19/28] chore: check agents match --- codersdk/workspaceagents.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index c5d93a8e4ef9b..1eb37bb07c989 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -429,6 +429,8 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo d.Dirty == other.Dirty && (d.Container == nil && other.Container == nil || (d.Container != nil && other.Container != nil && d.Container.ID == other.Container.ID)) && + (d.Agent == nil && other.Agent == nil || + (d.Agent != nil && other.Agent != nil && *d.Agent == *other.Agent)) && d.Error == other.Error } From f24401f004945ccb05186106c9aff839002027d7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 14:46:04 +0000 Subject: [PATCH 20/28] test: parsing error and socket error --- site/src/modules/resources/AgentRow.tsx | 1 - .../resources/useAgentContainers.test.tsx | 105 ++++++++++++++++++ .../modules/resources/useAgentContainers.ts | 6 +- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 9a5935b6451b5..1ab425fb34ad6 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -72,7 +72,6 @@ export const AgentRow: FC = ({ const showVSCode = hasVSCodeApp && !browser_only; const hasStartupFeatures = Boolean(agent.logs_length); - const queryClient = useQueryClient(); const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( ["starting", "start_timeout"].includes(agent.lifecycle_state) && diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx index 6bf398fbd1991..9839f8e004790 100644 --- a/site/src/modules/resources/useAgentContainers.test.tsx +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -8,6 +8,8 @@ import { } from "testHelpers/entities"; import { server } from "testHelpers/server"; import { useAgentContainers } from "./useAgentContainers"; +import * as API from "api/api"; +import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; const createWrapper = (): FC => { const queryClient = new QueryClient({ @@ -82,4 +84,107 @@ describe("useAgentContainers", () => { expect(result.current).toBeUndefined(); }); }); + + it("handles parsing errors from WebSocket", async () => { + const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); + + const mockSocket = { + addEventListener: jest.fn(), + close: jest.fn(), + }; + watchAgentContainersSpy.mockReturnValue(mockSocket as any); + + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { unmount } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + // Simulate message event with parsing error + const messageHandler = mockSocket.addEventListener.mock.calls.find( + (call) => call[0] === "message", + )?.[1]; + + if (messageHandler) { + messageHandler({ + parseError: new Error("Parse error"), + parsedMessage: null, + }); + } + + await waitFor(() => { + expect(displayErrorSpy).toHaveBeenCalledWith( + "Failed to update containers", + "Please try refreshing the page", + ); + }); + + unmount(); + displayErrorSpy.mockRestore(); + watchAgentContainersSpy.mockRestore(); + }); + + it("handles WebSocket errors", async () => { + const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); + + const mockSocket = { + addEventListener: jest.fn(), + close: jest.fn(), + }; + watchAgentContainersSpy.mockReturnValue(mockSocket as any); + + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { unmount } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + // Simulate error event + const errorHandler = mockSocket.addEventListener.mock.calls.find( + (call) => call[0] === "error", + )?.[1]; + + if (errorHandler) { + errorHandler(new Error("WebSocket error")); + } + + await waitFor(() => { + expect(displayErrorSpy).toHaveBeenCalledWith( + "Failed to load containers", + "Please try refreshing the page", + ); + }); + + unmount(); + displayErrorSpy.mockRestore(); + watchAgentContainersSpy.mockRestore(); + }); }); diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index 1e6c59b1ee579..efcb835d662f4 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -35,7 +35,8 @@ export function useAgentContainers( socket.addEventListener("message", (event) => { if (event.parseError) { displayError( - "Unable to process latest data from the server. Please try refreshing the page.", + "Failed to update containers", + "Please try refreshing the page", ); return; } @@ -45,7 +46,8 @@ export function useAgentContainers( socket.addEventListener("error", () => { displayError( - "Unable to get workspace containers. Connection has been closed.", + "Failed to load containers", + "Please try refreshing the page", ); }); From 64d925246e69b3940706f4108e678371c4b6ac62 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 10 Jul 2025 14:54:49 +0000 Subject: [PATCH 21/28] chore: lint and format --- site/src/modules/resources/AgentRow.tsx | 1 - .../modules/resources/useAgentContainers.test.tsx | 14 ++++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 1ab425fb34ad6..0b5d8a5dc15c3 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -23,7 +23,6 @@ import { useRef, useState, } from "react"; -import { useQueryClient } from "react-query"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx index 9839f8e004790..c4f7dda46518d 100644 --- a/site/src/modules/resources/useAgentContainers.test.tsx +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -1,4 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; +import * as API from "api/api"; +import type { WorkspaceAgentDevcontainer } from "api/typesGenerated"; +import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; import { http, HttpResponse } from "msw"; import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; @@ -7,9 +10,8 @@ import { MockWorkspaceAgentDevcontainer, } from "testHelpers/entities"; import { server } from "testHelpers/server"; +import type { OneWayWebSocket } from "utils/OneWayWebSocket"; import { useAgentContainers } from "./useAgentContainers"; -import * as API from "api/api"; -import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; const createWrapper = (): FC => { const queryClient = new QueryClient({ @@ -93,7 +95,9 @@ describe("useAgentContainers", () => { addEventListener: jest.fn(), close: jest.fn(), }; - watchAgentContainersSpy.mockReturnValue(mockSocket as any); + watchAgentContainersSpy.mockReturnValue( + mockSocket as unknown as OneWayWebSocket, + ); server.use( http.get( @@ -146,7 +150,9 @@ describe("useAgentContainers", () => { addEventListener: jest.fn(), close: jest.fn(), }; - watchAgentContainersSpy.mockReturnValue(mockSocket as any); + watchAgentContainersSpy.mockReturnValue( + mockSocket as unknown as OneWayWebSocket, + ); server.use( http.get( From 40c3fd9d5180355b19b99498f14dbd7305408b19 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 09:05:01 +0000 Subject: [PATCH 22/28] chore: give comment some love --- agent/agentcontainers/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 68c3568b01b34..3457d16029f58 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -663,7 +663,7 @@ func (api *API) updateContainers(ctx context.Context) error { }) if !statesAreEqual { - // Broadcast our updates + // Broadcast state changes to WebSocket listeners. for _, ch := range api.updateChans { select { case ch <- struct{}{}: From 1cda45557fb8d642f652444418d6f3e5bc9427b7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 09:05:23 +0000 Subject: [PATCH 23/28] chore: re-use json encoder instead of recreating every time --- agent/agentcontainers/api.go | 4 +++- coderd/workspaceagents.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 3457d16029f58..3aa1d8989011f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -585,6 +585,8 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { api.mu.Unlock() }() + encoder := json.NewEncoder(wsNetConn) + for { select { case <-api.ctx.Done(): @@ -600,7 +602,7 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { continue } - if err := json.NewEncoder(wsNetConn).Encode(ct); err != nil { + if err := encoder.Encode(ct); err != nil { api.logger.Error(ctx, "encode container list", slog.Error(err)) return } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c120133c103ac..3ae57d8394d43 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -882,6 +882,8 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re go httpapi.Heartbeat(ctx, conn) + encoder := json.NewEncoder(wsNetConn) + for { select { case <-api.ctx.Done(): @@ -891,7 +893,7 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re return case containers := <-containersCh: - if err := json.NewEncoder(wsNetConn).Encode(containers); err != nil { + if err := encoder.Encode(containers); err != nil { api.Logger.Error(ctx, "encode containers", slog.Error(err)) return } From 2ded15fdd6aaf8defc1b967b8c9058a629366a15 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 10:10:38 +0000 Subject: [PATCH 24/28] fix: push initial dev container state in websocket --- agent/agentcontainers/api.go | 11 +++++++++ agent/agentcontainers/api_test.go | 37 ++++++++++++++++++++++--------- coderd/workspaceagents_test.go | 37 ++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 3aa1d8989011f..321fc97d6b908 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -587,6 +587,17 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { encoder := json.NewEncoder(wsNetConn) + ct, err := api.getContainers() + if err != nil { + api.logger.Error(ctx, "unable to get containers", slog.Error(err)) + return + } + + if err := encoder.Encode(ct); err != nil { + api.logger.Error(ctx, "encode container list", slog.Error(err)) + return + } + for { select { case <-api.ctx.Done(): diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 3029bf77f60c5..75b9342379a35 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -445,10 +445,6 @@ func TestAPI(t *testing.T) { t.Run("Watch", func(t *testing.T) { t.Parallel() - makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { - return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} - } - fakeContainer1 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) { c.ID = "container1" c.FriendlyName = "devcontainer1" @@ -543,7 +539,9 @@ func TestAPI(t *testing.T) { logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) ) - mLister.EXPECT().List(gomock.Any()).Return(makeResponse(), nil) + // Set up initial state for immediate send on connection + mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil) + mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), @@ -565,9 +563,28 @@ func TestAPI(t *testing.T) { defer res.Body.Close() } - mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + // Read initial state sent immediately on connection + mt, msg, err := client.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, mt) + + var got codersdk.WorkspaceAgentListContainersResponse + err = json.Unmarshal(msg, &got) + require.NoError(t, err) + + require.Equal(t, stages[0].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) + for j, expectedDev := range stages[0].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } - for _, stage := range stages { + // Process remaining stages through updater loop + for i, stage := range stages[1:] { mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) // Given: We allow the update loop to progress @@ -584,9 +601,9 @@ func TestAPI(t *testing.T) { err = json.Unmarshal(msg, &got) require.NoError(t, err) - require.Equal(t, stage.expected.Containers, got.Containers) - require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers)) - for j, expectedDev := range stage.expected.Devcontainers { + require.Equal(t, stages[i+1].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) + for j, expectedDev := range stages[i+1].expected.Devcontainers { gotDev := got.Devcontainers[j] require.Equal(t, expectedDev.Name, gotDev.Name) require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 794a2b7c10268..30859cb6391e6 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1431,10 +1431,6 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { Running: true, Status: "running", } - - makeResponse = func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { - return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} - } ) stages := []struct { @@ -1502,7 +1498,9 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { }, } - mCCLI.EXPECT().List(gomock.Any()).Return(makeResponse(), nil) + // Set up initial state for immediate send on connection + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil) + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") @@ -1528,8 +1526,27 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { closer.Close() }() - mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() - for _, stage := range stages { + // Read initial state sent immediately on connection + var got codersdk.WorkspaceAgentListContainersResponse + select { + case <-ctx.Done(): + case got = <-containers: + } + require.NoError(t, ctx.Err()) + + require.Equal(t, stages[0].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) + for j, expectedDev := range stages[0].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + + // Process remaining stages through updater loop + for i, stage := range stages[1:] { mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) _, aw := mClock.AdvanceNext() @@ -1542,9 +1559,9 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { } require.NoError(t, ctx.Err()) - require.Equal(t, stage.expected.Containers, got.Containers) - require.Len(t, got.Devcontainers, len(stage.expected.Devcontainers)) - for j, expectedDev := range stage.expected.Devcontainers { + require.Equal(t, stages[i+1].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) + for j, expectedDev := range stages[i+1].expected.Devcontainers { gotDev := got.Devcontainers[j] require.Equal(t, expectedDev.Name, gotDev.Name) require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) From a87f3882ffb963aa52041436b6e8b45a1e4fe96b Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 10:29:41 +0000 Subject: [PATCH 25/28] fix: do not invalidateQuery + fix bad types --- site/src/api/api.ts | 2 +- site/src/modules/resources/useAgentContainers.test.tsx | 9 ++++++--- site/src/modules/resources/useAgentContainers.ts | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d683a733de35c..7c10188648121 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -131,7 +131,7 @@ export const watchWorkspace = ( export const watchAgentContainers = ( agentId: string, -): OneWayWebSocket => { +): OneWayWebSocket => { return new OneWayWebSocket({ apiRoute: `/api/v2/workspaceagents/${agentId}/containers/watch`, }); diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx index c4f7dda46518d..409465644fe31 100644 --- a/site/src/modules/resources/useAgentContainers.test.tsx +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -1,6 +1,9 @@ import { renderHook, waitFor } from "@testing-library/react"; import * as API from "api/api"; -import type { WorkspaceAgentDevcontainer } from "api/typesGenerated"; +import type { + WorkspaceAgentDevcontainer, + WorkspaceAgentListContainersResponse, +} from "api/typesGenerated"; import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; import { http, HttpResponse } from "msw"; import type { FC, PropsWithChildren } from "react"; @@ -96,7 +99,7 @@ describe("useAgentContainers", () => { close: jest.fn(), }; watchAgentContainersSpy.mockReturnValue( - mockSocket as unknown as OneWayWebSocket, + mockSocket as unknown as OneWayWebSocket, ); server.use( @@ -151,7 +154,7 @@ describe("useAgentContainers", () => { close: jest.fn(), }; watchAgentContainersSpy.mockReturnValue( - mockSocket as unknown as OneWayWebSocket, + mockSocket as unknown as OneWayWebSocket, ); server.use( diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index efcb835d662f4..4c6bd67d8892b 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -2,6 +2,7 @@ import { API, watchAgentContainers } from "api/api"; import type { WorkspaceAgent, WorkspaceAgentDevcontainer, + WorkspaceAgentListContainersResponse, } from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -21,11 +22,10 @@ export function useAgentContainers( }); const updateDevcontainersCache = useEffectEvent( - async (devcontainers: WorkspaceAgentDevcontainer[]) => { + async (data: WorkspaceAgentListContainersResponse) => { const queryKey = ["agents", agent.id, "containers"]; - queryClient.setQueryData(queryKey, devcontainers); - await queryClient.invalidateQueries({ queryKey }); + queryClient.setQueryData(queryKey, data); }, ); From 2de01f5b0be8855d87c598c34494949fee722c65 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 10:36:05 +0000 Subject: [PATCH 26/28] chore: appease linter --- site/src/modules/resources/useAgentContainers.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx index 409465644fe31..922941e04c074 100644 --- a/site/src/modules/resources/useAgentContainers.test.tsx +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -1,9 +1,6 @@ import { renderHook, waitFor } from "@testing-library/react"; import * as API from "api/api"; -import type { - WorkspaceAgentDevcontainer, - WorkspaceAgentListContainersResponse, -} from "api/typesGenerated"; +import type { WorkspaceAgentListContainersResponse } from "api/typesGenerated"; import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; import { http, HttpResponse } from "msw"; import type { FC, PropsWithChildren } from "react"; From 00fdae6b6bf9f68d2a3a82d9f154096632d757cd Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 15:18:54 +0000 Subject: [PATCH 27/28] chore: broadcast updates in more places, add staleTime: Infinity --- agent/agentcontainers/api.go | 21 ++++++++++++------- .../resources/AgentDevcontainerCard.tsx | 6 ------ .../modules/resources/useAgentContainers.ts | 1 + 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 321fc97d6b908..dc92a4d38d9a2 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -549,6 +549,16 @@ func (api *API) Routes() http.Handler { return r } +func (api *API) broadcastUpdatesLocked() { + // Broadcast state changes to WebSocket listeners. + for _, ch := range api.updateChans { + select { + case ch <- struct{}{}: + default: + } + } +} + func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -676,13 +686,7 @@ func (api *API) updateContainers(ctx context.Context) error { }) if !statesAreEqual { - // Broadcast state changes to WebSocket listeners. - for _, ch := range api.updateChans { - select { - case ch <- struct{}{}: - default: - } - } + api.broadcastUpdatesLocked() } } @@ -1056,6 +1060,8 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques dc.Container = nil dc.Error = "" api.knownDevcontainers[dc.WorkspaceFolder] = dc + api.broadcastUpdatesLocked() + go func() { _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath, WithRemoveExistingContainer()) }() @@ -1171,6 +1177,7 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D dc.Error = "" api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes") api.knownDevcontainers[dc.WorkspaceFolder] = dc + api.broadcastUpdatesLocked() api.mu.Unlock() // Ensure an immediate refresh to accurately reflect the diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index c7516dde15c39..bd2f05b123cad 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -130,12 +130,6 @@ export const AgentDevcontainerCard: FC = ({ return { previousData }; }, - onSuccess: async () => { - // Invalidate the containers query to refetch updated data. - await queryClient.invalidateQueries({ - queryKey: ["agents", parentAgent.id, "containers"], - }); - }, onError: (error, _, context) => { // If the mutation fails, use the context returned from // onMutate to roll back. diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index 4c6bd67d8892b..cc3a862d3c3cc 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -19,6 +19,7 @@ export function useAgentContainers( queryFn: () => API.getAgentContainers(agent.id), enabled: agent.status === "connected", select: (res) => res.devcontainers, + staleTime: Infinity, }); const updateDevcontainersCache = useEffectEvent( From a4a4bb288546b3732d55557d05f4ce2c29993bf9 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 14 Jul 2025 20:26:20 +0000 Subject: [PATCH 28/28] chore: appease linter --- site/src/modules/resources/useAgentContainers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index cc3a862d3c3cc..0db4e2fc4b613 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -19,7 +19,7 @@ export function useAgentContainers( queryFn: () => API.getAgentContainers(agent.id), enabled: agent.status === "connected", select: (res) => res.devcontainers, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }); const updateDevcontainersCache = useEffectEvent(