Skip to content

Commit 9a7d18a

Browse files
committed
fix(agent/agentcontainers): broadcast devcontainer dirty status over websocket
Previously, when a devcontainer config file was modified, the dirty status was updated internally but not broadcast to websocket listeners. This meant the UI would not show the dirty indicator until some other event triggered a broadcast (e.g., container restart or updater loop). Add broadcastUpdatesLocked() call in markDevcontainerDirty to notify websocket listeners immediately when a config file changes.
1 parent 6aeb144 commit 9a7d18a

File tree

2 files changed

+73
-0
lines changed

2 files changed

+73
-0
lines changed

agent/agentcontainers/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,8 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
14571457

14581458
api.knownDevcontainers[dc.WorkspaceFolder] = dc
14591459
}
1460+
1461+
api.broadcastUpdatesLocked()
14601462
}
14611463

14621464
// cleanupSubAgents removes subagents that are no longer managed by

agent/agentcontainers/api_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,77 @@ func TestAPI(t *testing.T) {
16411641
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
16421642
})
16431643

1644+
// Verify that modifying a config file broadcasts the dirty status
1645+
// over websocket immediately.
1646+
t.Run("FileWatcherDirtyBroadcast", func(t *testing.T) {
1647+
t.Parallel()
1648+
1649+
ctx := testutil.Context(t, testutil.WaitShort)
1650+
configPath := "/workspace/project/.devcontainer/devcontainer.json"
1651+
fWatcher := newFakeWatcher(t)
1652+
fLister := &fakeContainerCLI{
1653+
containers: codersdk.WorkspaceAgentListContainersResponse{
1654+
Containers: []codersdk.WorkspaceAgentContainer{
1655+
{
1656+
ID: "container-id",
1657+
FriendlyName: "container-name",
1658+
Running: true,
1659+
Labels: map[string]string{
1660+
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
1661+
agentcontainers.DevcontainerConfigFileLabel: configPath,
1662+
},
1663+
},
1664+
},
1665+
},
1666+
}
1667+
1668+
mClock := quartz.NewMock(t)
1669+
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
1670+
1671+
api := agentcontainers.NewAPI(
1672+
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
1673+
agentcontainers.WithContainerCLI(fLister),
1674+
agentcontainers.WithWatcher(fWatcher),
1675+
agentcontainers.WithClock(mClock),
1676+
)
1677+
api.Start()
1678+
defer api.Close()
1679+
1680+
srv := httptest.NewServer(api.Routes())
1681+
defer srv.Close()
1682+
1683+
tickerTrap.MustWait(ctx).MustRelease(ctx)
1684+
tickerTrap.Close()
1685+
1686+
wsConn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/watch", nil)
1687+
require.NoError(t, err)
1688+
if resp != nil && resp.Body != nil {
1689+
defer resp.Body.Close()
1690+
}
1691+
defer wsConn.Close(websocket.StatusNormalClosure, "")
1692+
1693+
// Read and discard initial state.
1694+
_, _, err = wsConn.Read(ctx)
1695+
require.NoError(t, err)
1696+
1697+
fWatcher.waitNext(ctx)
1698+
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
1699+
Name: configPath,
1700+
Op: fsnotify.Write,
1701+
})
1702+
1703+
// Verify dirty status is broadcast without advancing the clock.
1704+
_, msg, err := wsConn.Read(ctx)
1705+
require.NoError(t, err)
1706+
1707+
var response codersdk.WorkspaceAgentListContainersResponse
1708+
err = json.Unmarshal(msg, &response)
1709+
require.NoError(t, err)
1710+
require.Len(t, response.Devcontainers, 1)
1711+
assert.True(t, response.Devcontainers[0].Dirty,
1712+
"devcontainer should be marked as dirty after config file modification")
1713+
})
1714+
16441715
t.Run("SubAgentLifecycle", func(t *testing.T) {
16451716
t.Parallel()
16461717

0 commit comments

Comments
 (0)