feat: include agent metadata in workspace list responses (#27934)

Closes #27933. Related: #27897 (single-agent GET).

Agent metadata is only readable via a per-agent watch stream, so reading
it across N workspaces costs N+1 requests. This adds a batch read to the
list endpoint:

```text
GET /api/v2/workspaces?q=param:"pool=demo" include_agent_metadata:task_status
```

- New `include_agent_metadata` search key, repeatable and key-scoped. It
expands the response, it does not filter workspaces.
- `GetWorkspaces` aggregates the requested keys as JSON behind a `CASE`:
without opt-in the response is unchanged and the subquery never runs.
Runs only for the returned page, inside the same authorized query.
- Agents in the response gain `metadata`
(`[]codersdk.WorkspaceAgentMetadata`, `omitempty`), mapped by the
`workspace_agent_id` each element carries. The collection script is
omitted; it can be long.
- `codersdk.WorkspaceFilter` gains `IncludeAgentMetadata []string`.
- No wildcard, no schema change, no migration.

---

Authored by Coder Agents on behalf of @Emyrk.
This commit is contained in:
Steven Masley
2026-08-10 08:13:32 -05:00
committed by GitHub
parent a3a51228ee
commit 9a57dfa642
21 changed files with 1507 additions and 588 deletions
+82
View File
@@ -2869,6 +2869,88 @@ func TestWorkspaceFilterManual(t *testing.T) {
require.Equal(t, workspace.ID, res.Workspaces[0].ID)
})
t.Run("IncludeAgentMetadata", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
build := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent().Do()
require.Len(t, build.Agents, 1)
agentID := build.Agents[0].ID
//nolint:gocritic // This is a test; only the agent API writes metadata.
ctx := dbauthz.AsSystemRestricted(context.Background())
collectedAt := dbtime.Now()
// Task_Status is mixed-case on purpose: requested keys are
// lowercased by the search parser, and the query matches stored
// keys case-insensitively.
for i, key := range []string{"Task_Status", "cpu", "unrequested"} {
err := db.InsertWorkspaceAgentMetadata(ctx, database.InsertWorkspaceAgentMetadataParams{
WorkspaceAgentID: agentID,
DisplayName: key,
Key: key,
Script: "echo",
Timeout: int64(time.Second),
Interval: int64(time.Second),
// Reversed so the response order proves display_order
// sorting rather than insertion order.
DisplayOrder: int32(3 - i), //nolint:gosec // Tiny test constant.
})
require.NoError(t, err)
err = db.UpdateWorkspaceAgentMetadata(ctx, database.UpdateWorkspaceAgentMetadataParams{
WorkspaceAgentID: agentID,
Key: []string{key},
Value: []string{"value-" + key},
Error: []string{""},
CollectedAt: []time.Time{collectedAt},
})
require.NoError(t, err)
}
reqCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
findAgent := func(res codersdk.WorkspacesResponse) codersdk.WorkspaceAgent {
require.Len(t, res.Workspaces, 1)
require.Len(t, res.Workspaces[0].LatestBuild.Resources, 1)
require.Len(t, res.Workspaces[0].LatestBuild.Resources[0].Agents, 1)
return res.Workspaces[0].LatestBuild.Resources[0].Agents[0]
}
// Without the opt-in the response carries no metadata.
res, err := client.Workspaces(reqCtx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
require.Empty(t, findAgent(res).Metadata)
// Opting in returns exactly the requested keys, ordered by
// display_order, with their collected values.
res, err = client.Workspaces(reqCtx, codersdk.WorkspaceFilter{
IncludeAgentMetadata: []string{"task_status", "cpu"},
})
require.NoError(t, err)
metadata := findAgent(res).Metadata
require.Len(t, metadata, 2)
require.Equal(t, "cpu", metadata[0].Description.Key)
require.Equal(t, "value-cpu", metadata[0].Result.Value)
// The collection script is deliberately not exposed on the list
// endpoint; it can be long.
require.Empty(t, metadata[0].Description.Script)
require.Equal(t, "Task_Status", metadata[1].Description.Key)
require.Equal(t, "value-Task_Status", metadata[1].Result.Value)
require.WithinDuration(t, collectedAt, metadata[1].Result.CollectedAt, time.Second)
// Unknown keys are not an error; the metadata is just absent.
res, err = client.Workspaces(reqCtx, codersdk.WorkspaceFilter{
IncludeAgentMetadata: []string{"no_such_key"},
})
require.NoError(t, err)
require.Empty(t, findAgent(res).Metadata)
})
t.Run("HealthyFilter", func(t *testing.T) {
t.Parallel()