mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+58
-1
@@ -13551,7 +13551,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "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.",
|
||||
"description": "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).",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -27254,6 +27254,13 @@ const docTemplate = `{
|
||||
"logs_overflowed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Metadata is only populated on the workspaces list endpoint when the\nrequest opts in with the include_agent_metadata search key, and it\nonly carries the requested keys. The description's script is always\nempty here: it can be long, and list consumers want values.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadata"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -27638,6 +27645,56 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription"
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataDescription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.",
|
||||
"type": "integer"
|
||||
},
|
||||
"collected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentPortShare": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+58
-1
@@ -12021,7 +12021,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "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.",
|
||||
"description": "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).",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -25096,6 +25096,13 @@
|
||||
"logs_overflowed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Metadata is only populated on the workspaces list endpoint when the\nrequest opts in with the include_agent_metadata search key, and it\nonly carries the requested keys. The description's script is always\nempty here: it can be long, and list consumers want values.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadata"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -25477,6 +25484,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription"
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataDescription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {
|
||||
"description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.",
|
||||
"type": "integer"
|
||||
},
|
||||
"collected_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentPortShare": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -260,6 +260,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
// The name comment is for metric tracking
|
||||
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filtered)
|
||||
rows, err := q.db.QueryContext(ctx, query,
|
||||
pq.Array(arg.IncludeAgentMetadata),
|
||||
pq.Array(arg.ParamNames),
|
||||
pq.Array(arg.ParamValues),
|
||||
arg.Deleted,
|
||||
@@ -336,6 +337,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
&i.LatestBuildTransition,
|
||||
&i.LatestBuildStatus,
|
||||
&i.LatestBuildHasExternalAgent,
|
||||
&i.LatestBuildProvisionerJobID,
|
||||
&i.AgentMetadata,
|
||||
&i.Count,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
Generated
+117
-60
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,6 +120,34 @@ type ChatACLEntry struct {
|
||||
Permissions []policy.Action `json:"permissions"`
|
||||
}
|
||||
|
||||
// AgentMetadataAggregate is the agent_metadata jsonb array the
|
||||
// GetWorkspaces query aggregates for the include_agent_metadata
|
||||
// expansion. Elements have WorkspaceAgentMetadatum's JSON shape; each
|
||||
// carries its workspace_agent_id so multi-agent workspaces can map
|
||||
// values onto the right agent. The generated row keeps
|
||||
// json.RawMessage because sqlc overrides cannot target expression
|
||||
// columns; callers Scan the raw value into this type.
|
||||
type AgentMetadataAggregate []WorkspaceAgentMetadatum
|
||||
|
||||
func (a *AgentMetadataAggregate) Scan(src interface{}) error {
|
||||
switch v := src.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
return json.Unmarshal([]byte(v), &a)
|
||||
case []byte:
|
||||
return json.Unmarshal(v, &a)
|
||||
case json.RawMessage:
|
||||
return json.Unmarshal(v, &a)
|
||||
}
|
||||
|
||||
return xerrors.Errorf("unexpected type %T", src)
|
||||
}
|
||||
|
||||
func (a AgentMetadataAggregate) Value() (driver.Value, error) {
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type WorkspaceACL map[string]WorkspaceACLEntry
|
||||
|
||||
func (t *WorkspaceACL) Scan(src interface{}) error {
|
||||
|
||||
@@ -272,6 +272,9 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
||||
}
|
||||
filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task")
|
||||
filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent")
|
||||
// include_agent_metadata expands the response with the named agent
|
||||
// metadata keys; it does not filter the returned workspaces.
|
||||
filter.IncludeAgentMetadata = parser.Strings(values, []string{}, "include_agent_metadata")
|
||||
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
||||
filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
|
||||
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", actorID)
|
||||
|
||||
@@ -341,6 +341,13 @@ func TestSearchWorkspace(t *testing.T) {
|
||||
HasAgentStatuses: []string{"connecting", "connected"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "IncludeAgentMetadata",
|
||||
Query: `include_agent_metadata:"task_status" include_agent_metadata:"cpu"`,
|
||||
Expected: database.GetWorkspacesParams{
|
||||
IncludeAgentMetadata: []string{"task_status", "cpu"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SharedWithMe",
|
||||
Query: `shared_with_user:me`,
|
||||
@@ -541,6 +548,10 @@ func TestSearchWorkspace(t *testing.T) {
|
||||
// nil slice vs 0 len slice is equivalent for our purposes.
|
||||
c.Expected.HasAgentStatuses = values.HasAgentStatuses
|
||||
}
|
||||
if len(c.Expected.IncludeAgentMetadata) == len(values.IncludeAgentMetadata) {
|
||||
// nil slice vs 0 len slice is equivalent for our purposes.
|
||||
c.Expected.IncludeAgentMetadata = values.IncludeAgentMetadata
|
||||
}
|
||||
assert.Len(t, errs, 0, "expected no error")
|
||||
assert.Equal(t, c.Expected, values, "expected values")
|
||||
}
|
||||
|
||||
+44
-1
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user