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
+44 -1
View File
@@ -143,7 +143,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy."
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)."
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.WorkspacesResponse
@@ -248,6 +248,17 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
return
}
if len(filter.IncludeAgentMetadata) > 0 {
err = attachAgentMetadata(wss, workspaceRows)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting agent metadata.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
Workspaces: wss,
Count: int(workspaceRows[0].Count),
@@ -2758,6 +2769,38 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa
}, nil
}
// attachAgentMetadata maps the agent metadata the workspaces query
// aggregated per workspace onto the agents in the converted response.
// Each aggregated datum carries its workspace_agent_id.
func attachAgentMetadata(workspaces []codersdk.Workspace, rows []database.GetWorkspacesRow) error {
byAgent := map[uuid.UUID][]database.WorkspaceAgentMetadatum{}
for _, row := range rows {
if len(row.AgentMetadata) == 0 {
continue
}
var metadata database.AgentMetadataAggregate
err := metadata.Scan(row.AgentMetadata)
if err != nil {
return xerrors.Errorf("scan agent metadata for workspace %q: %w", row.ID, err)
}
for _, datum := range metadata {
byAgent[datum.WorkspaceAgentID] = append(byAgent[datum.WorkspaceAgentID], datum)
}
}
for wi := range workspaces {
resources := workspaces[wi].LatestBuild.Resources
for ri := range resources {
for ai := range resources[ri].Agents {
agent := &resources[ri].Agents[ai]
if metadata, ok := byAgent[agent.ID]; ok {
agent.Metadata = convertWorkspaceAgentMetadata(metadata)
}
}
}
}
return nil
}
func convertWorkspaces(
ctx context.Context,
logger slog.Logger,