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
+117 -60
View File
@@ -38510,8 +38510,8 @@ const getWorkspaces = `-- name: GetWorkspaces :many
WITH
build_params AS (
SELECT
LOWER(unnest($1 :: text[])) AS name,
LOWER(unnest($2 :: text[])) AS value
LOWER(unnest($2 :: text[])) AS name,
LOWER(unnest($3 :: text[])) AS value
),
filtered_workspaces AS (
SELECT
@@ -38523,7 +38523,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
@@ -38572,32 +38573,32 @@ LEFT JOIN LATERAL (
) template ON true
WHERE
-- Optionally include deleted workspaces
workspaces.deleted = $3
workspaces.deleted = $4
AND CASE
WHEN $4 :: text != '' THEN
WHEN $5 :: text != '' THEN
CASE
-- Some workspace specific status refer to the transition
-- type. By default, the standard provisioner job status
-- search strings are supported.
-- 'running' states
WHEN $4 = 'starting' THEN
WHEN $5 = 'starting' THEN
latest_build.job_status = 'running'::provisioner_job_status AND
latest_build.transition = 'start'::workspace_transition
WHEN $4 = 'stopping' THEN
WHEN $5 = 'stopping' THEN
latest_build.job_status = 'running'::provisioner_job_status AND
latest_build.transition = 'stop'::workspace_transition
WHEN $4 = 'deleting' THEN
WHEN $5 = 'deleting' THEN
latest_build.job_status = 'running' AND
latest_build.transition = 'delete'::workspace_transition
-- 'succeeded' states
WHEN $4 = 'deleted' THEN
WHEN $5 = 'deleted' THEN
latest_build.job_status = 'succeeded'::provisioner_job_status AND
latest_build.transition = 'delete'::workspace_transition
WHEN $4 = 'stopped' THEN
WHEN $5 = 'stopped' THEN
latest_build.job_status = 'succeeded'::provisioner_job_status AND
latest_build.transition = 'stop'::workspace_transition
WHEN $4 = 'started' THEN
WHEN $5 = 'started' THEN
latest_build.job_status = 'succeeded'::provisioner_job_status AND
latest_build.transition = 'start'::workspace_transition
@@ -38605,13 +38606,13 @@ WHERE
-- differ. A workspace is "running" if the job is "succeeded" and
-- the transition is "start". This is because a workspace starts
-- running when a job is complete.
WHEN $4 = 'running' THEN
WHEN $5 = 'running' THEN
latest_build.job_status = 'succeeded'::provisioner_job_status AND
latest_build.transition = 'start'::workspace_transition
WHEN $4 != '' THEN
WHEN $5 != '' THEN
-- By default just match the job status exactly
latest_build.job_status = $4::provisioner_job_status
latest_build.job_status = $5::provisioner_job_status
ELSE
true
END
@@ -38619,19 +38620,19 @@ WHERE
END
-- Filter by owner_id
AND CASE
WHEN $5 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.owner_id = $5
WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.owner_id = $6
ELSE true
END
-- Filter by organization_id
AND CASE
WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.organization_id = $6
WHEN $7 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.organization_id = $7
ELSE true
END
-- Filter by build parameter
-- @has_param will match any build that includes the parameter.
AND CASE WHEN array_length($7 :: text[], 1) > 0 THEN
AND CASE WHEN array_length($8 :: text[], 1) > 0 THEN
EXISTS (
SELECT
1
@@ -38640,14 +38641,14 @@ WHERE
WHERE
workspace_build_parameters.workspace_build_id = latest_build.id AND
-- ILIKE is case insensitive
workspace_build_parameters.name ILIKE ANY($7)
workspace_build_parameters.name ILIKE ANY($8)
)
ELSE true
END
-- @param_value will match param name an value.
-- requires 2 arrays, @param_names and @param_values to be passed in.
-- Array index must match between the 2 arrays for name=value
AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN
AND CASE WHEN array_length($2 :: text[], 1) > 0 THEN
EXISTS (
SELECT
1
@@ -38665,40 +38666,40 @@ WHERE
-- Filter by owner_name
AND CASE
WHEN $8 :: text != '' THEN
workspaces.owner_id = (SELECT id FROM users WHERE lower(users.username) = lower($8) AND deleted = false)
WHEN $9 :: text != '' THEN
workspaces.owner_id = (SELECT id FROM users WHERE lower(users.username) = lower($9) AND deleted = false)
ELSE true
END
-- Filter by template_name
-- There can be more than 1 template with the same name across organizations.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN $9 :: text != '' THEN
workspaces.template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($9) AND deleted = false)
WHEN $10 :: text != '' THEN
workspaces.template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($10) AND deleted = false)
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length($10 :: uuid[], 1) > 0 THEN
workspaces.template_id = ANY($10)
WHEN array_length($11 :: uuid[], 1) > 0 THEN
workspaces.template_id = ANY($11)
ELSE true
END
-- Filter by workspace_ids
AND CASE
WHEN array_length($11 :: uuid[], 1) > 0 THEN
workspaces.id = ANY($11)
WHEN array_length($12 :: uuid[], 1) > 0 THEN
workspaces.id = ANY($12)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $12 :: text != '' THEN
workspaces.name ILIKE '%' || $12 || '%'
WHEN $13 :: text != '' THEN
workspaces.name ILIKE '%' || $13 || '%'
ELSE true
END
-- Filter by agent status
-- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents.
AND CASE
WHEN array_length($13 :: text[], 1) > 0 THEN
WHEN array_length($14 :: text[], 1) > 0 THEN
(
SELECT COUNT(*)
FROM
@@ -38723,43 +38724,43 @@ WHERE
END
WHEN workspace_agents.disconnected_at > workspace_agents.last_connected_at THEN
'disconnected'
WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $14 :: bigint THEN
WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $15 :: bigint THEN
'disconnected'
WHEN workspace_agents.last_connected_at IS NOT NULL THEN
'connected'
ELSE
NULL
END
) = ANY($13 :: text[])
) = ANY($14 :: text[])
) > 0
ELSE true
END
-- Filter by dormant workspaces.
AND CASE
WHEN $15 :: boolean != 'false' THEN
WHEN $16 :: boolean != 'false' THEN
dormant_at IS NOT NULL
ELSE true
END
-- Filter by last_used
AND CASE
WHEN $16 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at <= $16
WHEN $17 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at <= $17
ELSE true
END
AND CASE
WHEN $17 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at >= $17
WHEN $18 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at >= $18
ELSE true
END
AND CASE
WHEN $18 :: boolean IS NOT NULL THEN
(latest_build.template_version_id = template.active_version_id) = $18 :: boolean
WHEN $19 :: boolean IS NOT NULL THEN
(latest_build.template_version_id = template.active_version_id) = $19 :: boolean
ELSE true
END
-- Filter by has_ai_task, checks if this is a task workspace.
AND CASE
WHEN $19::boolean IS NOT NULL
THEN $19::boolean = EXISTS (
WHEN $20::boolean IS NOT NULL
THEN $20::boolean = EXISTS (
SELECT
1
FROM
@@ -38773,26 +38774,26 @@ WHERE
END
-- Filter by has_external_agent in latest build
AND CASE
WHEN $20 :: boolean IS NOT NULL THEN
latest_build.has_external_agent = $20 :: boolean
WHEN $21 :: boolean IS NOT NULL THEN
latest_build.has_external_agent = $21 :: boolean
ELSE true
END
-- Filter by shared status
AND CASE
WHEN $21 :: boolean IS NOT NULL THEN
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = $21 :: boolean
WHEN $22 :: boolean IS NOT NULL THEN
(workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = $22 :: boolean
ELSE true
END
-- Filter by shared_with_user_id
AND CASE
WHEN $22 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.user_acl ? ($22 :: uuid) :: text
WHEN $23 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.user_acl ? ($23 :: uuid) :: text
ELSE true
END
-- Filter by shared_with_group_id
AND CASE
WHEN $23 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.group_acl ? ($23 :: uuid) :: text
WHEN $24 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspaces.group_acl ? ($24 :: uuid) :: text
ELSE true
END
@@ -38800,12 +38801,12 @@ WHERE
-- @authorize_filter
), filtered_workspaces_order AS (
SELECT
fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.group_acl_display_info, fw.user_acl_display_info, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent
fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.group_acl_display_info, fw.user_acl_display_info, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent, fw.latest_build_provisioner_job_id
FROM
filtered_workspaces fw
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $24) THEN 0 ELSE 1 END ASC,
CASE WHEN favorite AND owner_username = (SELECT users.username FROM users WHERE users.id = $25) THEN 0 ELSE 1 END ASC,
(latest_build_completed_at IS NOT NULL AND
latest_build_canceled_at IS NULL AND
latest_build_error IS NULL AND
@@ -38814,14 +38815,14 @@ WHERE
LOWER(name) ASC
LIMIT
CASE
WHEN $26 :: integer > 0 THEN
$26
WHEN $27 :: integer > 0 THEN
$27
END
OFFSET
$25
$26
), filtered_workspaces_order_with_summary AS (
SELECT
fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent
fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.group_acl_display_info, fwo.user_acl_display_info, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent, fwo.latest_build_provisioner_job_id
FROM
filtered_workspaces_order fwo
-- Return a technical summary row with total count of workspaces.
@@ -38868,9 +38869,10 @@ 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
$27 :: boolean = true
$28 :: boolean = true
), total_count AS (
SELECT
count(*) AS count
@@ -38878,7 +38880,56 @@ WHERE
filtered_workspaces
)
SELECT
fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.group_acl_display_info, fwos.user_acl_display_info, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent,
fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.group_acl_display_info, fwos.user_acl_display_info, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, fwos.latest_build_provisioner_job_id,
-- 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($1 :: 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($1 :: 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
@@ -38887,6 +38938,7 @@ CROSS JOIN
`
type GetWorkspacesParams struct {
IncludeAgentMetadata []string `db:"include_agent_metadata" json:"include_agent_metadata"`
ParamNames []string `db:"param_names" json:"param_names"`
ParamValues []string `db:"param_values" json:"param_values"`
Deleted bool `db:"deleted" json:"deleted"`
@@ -38957,6 +39009,8 @@ type GetWorkspacesRow struct {
LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"`
LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"`
LatestBuildHasExternalAgent sql.NullBool `db:"latest_build_has_external_agent" json:"latest_build_has_external_agent"`
LatestBuildProvisionerJobID uuid.UUID `db:"latest_build_provisioner_job_id" json:"latest_build_provisioner_job_id"`
AgentMetadata json.RawMessage `db:"agent_metadata" json:"agent_metadata"`
Count int64 `db:"count" json:"count"`
}
@@ -38965,6 +39019,7 @@ type GetWorkspacesRow struct {
// be used in a WHERE clause.
func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaces,
pq.Array(arg.IncludeAgentMetadata),
pq.Array(arg.ParamNames),
pq.Array(arg.ParamValues),
arg.Deleted,
@@ -39041,6 +39096,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
&i.LatestBuildTransition,
&i.LatestBuildStatus,
&i.LatestBuildHasExternalAgent,
&i.LatestBuildProvisionerJobID,
&i.AgentMetadata,
&i.Count,
); err != nil {
return nil, err