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( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy