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
+53 -2
View File
@@ -117,7 +117,8 @@ SELECT
latest_build.error as latest_build_error,
latest_build.transition as latest_build_transition,
latest_build.job_status as latest_build_status,
latest_build.has_external_agent as latest_build_has_external_agent
latest_build.has_external_agent as latest_build_has_external_agent,
latest_build.provisioner_job_id as latest_build_provisioner_job_id
FROM
workspaces_expanded as workspaces
JOIN
@@ -462,7 +463,8 @@ WHERE
'', -- latest_build_error
'start'::workspace_transition, -- latest_build_transition
'unknown'::provisioner_job_status, -- latest_build_status
false -- latest_build_has_external_agent
false, -- latest_build_has_external_agent
'00000000-0000-0000-0000-000000000000'::uuid -- latest_build_provisioner_job_id
WHERE
@with_summary :: boolean = true
), total_count AS (
@@ -473,6 +475,55 @@ WHERE
)
SELECT
fwos.*,
-- agent_metadata expands the response with the requested agent
-- metadata keys for the latest build's agents. The CASE keeps the
-- subquery unevaluated for every caller that does not opt in, and
-- it only runs for the returned page. Each element carries the
-- workspace_agent_id so multi-agent workspaces can map values onto
-- the right agent.
CASE WHEN cardinality(@include_agent_metadata :: text[]) > 0 THEN
COALESCE((
SELECT
jsonb_agg(jsonb_build_object(
'workspace_agent_id', workspace_agents.id,
'display_name', workspace_agent_metadata.display_name,
'key', workspace_agent_metadata.key,
-- script is deliberately omitted: it can be long and
-- list consumers want values, not collection commands.
'value', workspace_agent_metadata.value,
'error', workspace_agent_metadata.error,
'timeout', workspace_agent_metadata.timeout,
'interval', workspace_agent_metadata.interval,
'collected_at', workspace_agent_metadata.collected_at,
'display_order', workspace_agent_metadata.display_order
))
FROM
workspace_agents
JOIN
workspace_resources
ON
workspace_resources.id = workspace_agents.resource_id
JOIN
workspace_agent_metadata
ON
workspace_agent_metadata.workspace_agent_id = workspace_agents.id
WHERE
-- The latest build's job was already resolved by the
-- latest_build lateral; resources hang off its job.
workspace_resources.job_id = fwos.latest_build_provisioner_job_id
-- Filter out deleted sub agents.
AND workspace_agents.deleted = FALSE
-- Both sides are lowercased so matching is
-- case-insensitive regardless of how the caller cased
-- the requested keys.
AND LOWER(workspace_agent_metadata.key) = ANY(ARRAY(
SELECT LOWER(key) FROM unnest(@include_agent_metadata :: text[]) AS k(key)
))
), '[]'::jsonb)
ELSE
-- Never NULL: lib/pq cannot scan NULL into json.RawMessage.
'[]'::jsonb
END :: jsonb AS agent_metadata,
tc.count
FROM
filtered_workspaces_order_with_summary fwos