feat: add healthy filter for workspace queries (#21743)

Adds support for filtering workspaces by health status using
healthy:true or healthy:false in the search query.

This is done by changing `has-agent` to accept a list of statuses and
aliasing `health:true` to `has-agent:connected` and `healthy:false` to
`has-agent:timeout,disconnected`.

Fixes #21623
This commit is contained in:
Jon Ayers
2026-02-04 20:48:27 -06:00
committed by GitHub
parent 984e363180
commit 22ece10a4a
11 changed files with 209 additions and 19 deletions
+1 -1
View File
@@ -10927,7 +10927,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.",
"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.",
"name": "q",
"in": "query"
},
+1 -1
View File
@@ -9665,7 +9665,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.",
"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.",
"name": "q",
"in": "query"
},
+1 -1
View File
@@ -268,7 +268,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
pq.Array(arg.TemplateIDs),
pq.Array(arg.WorkspaceIds),
arg.Name,
arg.HasAgent,
pq.Array(arg.HasAgentStatuses),
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.Dormant,
arg.LastUsedBefore,
+6 -5
View File
@@ -22977,7 +22977,7 @@ WHERE
-- 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 $13 :: text != '' THEN
WHEN array_length($13 :: text[], 1) > 0 THEN
(
SELECT COUNT(*)
FROM
@@ -22991,7 +22991,7 @@ WHERE
latest_build.transition = 'start'::workspace_transition AND
-- Filter out deleted sub agents.
workspace_agents.deleted = FALSE AND
$13 = (
(
CASE
WHEN workspace_agents.first_connected_at IS NULL THEN
CASE
@@ -23009,7 +23009,7 @@ WHERE
ELSE
NULL
END
)
) = ANY($13 :: text[])
) > 0
ELSE true
END
@@ -23074,6 +23074,7 @@ WHERE
workspaces.group_acl ? ($23 :: uuid) :: text
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
@@ -23177,7 +23178,7 @@ type GetWorkspacesParams struct {
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
WorkspaceIds []uuid.UUID `db:"workspace_ids" json:"workspace_ids"`
Name string `db:"name" json:"name"`
HasAgent string `db:"has_agent" json:"has_agent"`
HasAgentStatuses []string `db:"has_agent_statuses" json:"has_agent_statuses"`
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
Dormant bool `db:"dormant" json:"dormant"`
LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"`
@@ -23255,7 +23256,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
pq.Array(arg.TemplateIDs),
pq.Array(arg.WorkspaceIds),
arg.Name,
arg.HasAgent,
pq.Array(arg.HasAgentStatuses),
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.Dormant,
arg.LastUsedBefore,
+4 -3
View File
@@ -292,7 +292,7 @@ WHERE
-- 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 @has_agent :: text != '' THEN
WHEN array_length(@has_agent_statuses :: text[], 1) > 0 THEN
(
SELECT COUNT(*)
FROM
@@ -306,7 +306,7 @@ WHERE
latest_build.transition = 'start'::workspace_transition AND
-- Filter out deleted sub agents.
workspace_agents.deleted = FALSE AND
@has_agent = (
(
CASE
WHEN workspace_agents.first_connected_at IS NULL THEN
CASE
@@ -324,7 +324,7 @@ WHERE
ELSE
NULL
END
)
) = ANY(@has_agent_statuses :: text[])
) > 0
ELSE true
END
@@ -389,6 +389,7 @@ WHERE
workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
), filtered_workspaces_order AS (
+10 -1
View File
@@ -254,7 +254,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
filter.TemplateName = parser.String(values, "", "template")
filter.Name = parser.String(values, "", "name")
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
filter.HasAgent = parser.String(values, "", "has-agent")
filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent")
filter.Dormant = parser.Boolean(values, false, "dormant")
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
@@ -273,6 +273,15 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
// TODO: support "me" by passing in the actorID
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil)
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
// Translate healthy filter to has-agent statuses
// healthy:true = connected, healthy:false = disconnected or timeout
if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
if healthy.Bool {
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
} else {
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
}
}
type paramMatch struct {
name string
+32
View File
@@ -312,6 +312,34 @@ func TestSearchWorkspace(t *testing.T) {
},
},
},
{
Name: "HealthyTrue",
Query: "healthy:true",
Expected: database.GetWorkspacesParams{
HasAgentStatuses: []string{"connected"},
},
},
{
Name: "HealthyFalse",
Query: "healthy:false",
Expected: database.GetWorkspacesParams{
HasAgentStatuses: []string{"disconnected", "timeout"},
},
},
{
Name: "HealthyMissing",
Query: "",
Expected: database.GetWorkspacesParams{
HasAgentStatuses: []string{},
},
},
{
Name: "HealthyAndHasAgent",
Query: "has-agent:connecting healthy:true",
Expected: database.GetWorkspacesParams{
HasAgentStatuses: []string{"connecting", "connected"},
},
},
{
Name: "SharedWithUser",
Query: `shared_with_user:3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf`,
@@ -474,6 +502,10 @@ func TestSearchWorkspace(t *testing.T) {
// nil slice vs 0 len slice is equivalent for our purposes.
c.Expected.HasParam = values.HasParam
}
if len(c.Expected.HasAgentStatuses) == len(values.HasAgentStatuses) {
// nil slice vs 0 len slice is equivalent for our purposes.
c.Expected.HasAgentStatuses = values.HasAgentStatuses
}
assert.Len(t, errs, 0, "expected no error")
assert.Equal(t, c.Expected, values, "expected values")
}
+1 -1
View File
@@ -141,7 +141,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."
// @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 limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.WorkspacesResponse
+146
View File
@@ -2519,6 +2519,152 @@ func TestWorkspaceFilterManual(t *testing.T) {
require.Len(t, res.Workspaces, 1)
require.Equal(t, workspace.ID, res.Workspaces[0].ID)
})
t.Run("HealthyFilter", func(t *testing.T) {
t.Parallel()
t.Run("Healthy", func(t *testing.T) {
t.Parallel()
// healthy:true should return workspaces with connected agents
// and exclude workspaces with disconnected agents
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
// Create a workspace with a connected agent
connectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: "connected-workspace",
}).WithAgent().Do()
// Mark the agent as connected
now := time.Now()
require.Len(t, connectedBuild.Agents, 1)
//nolint:gocritic // This is a test, we need system context to update agent connection
ctx := dbauthz.AsSystemRestricted(context.Background())
err := db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
ID: connectedBuild.Agents[0].ID,
FirstConnectedAt: sql.NullTime{Time: now, Valid: true},
LastConnectedAt: sql.NullTime{Time: now, Valid: true},
DisconnectedAt: sql.NullTime{},
UpdatedAt: now,
LastConnectedReplicaID: uuid.NullUUID{},
})
require.NoError(t, err)
// Create a workspace with a disconnected agent
disconnectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: "disconnected-workspace",
}).WithAgent().Do()
// Mark the agent as disconnected
require.Len(t, disconnectedBuild.Agents, 1)
disconnectedTime := now.Add(-time.Hour)
err = db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
ID: disconnectedBuild.Agents[0].ID,
FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
DisconnectedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: now,
LastConnectedReplicaID: uuid.NullUUID{},
})
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// healthy:true should only return the connected workspace
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: "healthy:true",
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 1)
require.Equal(t, connectedBuild.Workspace.ID, res.Workspaces[0].ID)
})
t.Run("Unhealthy", func(t *testing.T) {
t.Parallel()
// healthy:false should return workspaces with disconnected or timed out agents
// and exclude workspaces with connected agents
store, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: store,
Pubsub: ps,
})
user := coderdtest.CreateFirstUser(t, client)
now := time.Now()
//nolint:gocritic // This is a test, we need system context to update agent connection
ctx := dbauthz.AsSystemRestricted(context.Background())
// Create a workspace with a connected agent (should be excluded)
connectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: "connected-workspace",
}).WithAgent().Do()
require.Len(t, connectedBuild.Agents, 1)
err := store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
ID: connectedBuild.Agents[0].ID,
FirstConnectedAt: sql.NullTime{Time: now, Valid: true},
LastConnectedAt: sql.NullTime{Time: now, Valid: true},
DisconnectedAt: sql.NullTime{},
UpdatedAt: now,
LastConnectedReplicaID: uuid.NullUUID{},
})
require.NoError(t, err)
// Create a workspace with a disconnected agent
disconnectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: "disconnected-workspace",
}).WithAgent().Do()
require.Len(t, disconnectedBuild.Agents, 1)
disconnectedTime := now.Add(-time.Hour)
err = store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
ID: disconnectedBuild.Agents[0].ID,
FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
DisconnectedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: now,
LastConnectedReplicaID: uuid.NullUUID{},
})
require.NoError(t, err)
// Create a workspace with a timed out agent (never connected, timeout exceeded)
timedOutBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: "timeout-workspace",
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
agents[0].ConnectionTimeoutSeconds = 1
return agents
}).Do()
require.Len(t, timedOutBuild.Agents, 1)
// Set created_at to the past so the timeout is exceeded
_, err = sqlDB.ExecContext(ctx, "UPDATE workspace_agents SET created_at = $1 WHERE id = $2",
now.Add(-time.Hour), timedOutBuild.Agents[0].ID)
require.NoError(t, err)
testCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// healthy:false should return both disconnected and timed out workspaces
res, err := client.Workspaces(testCtx, codersdk.WorkspaceFilter{
FilterQuery: "healthy:false",
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 2)
workspaceIDs := []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID}
require.Contains(t, workspaceIDs, disconnectedBuild.Workspace.ID)
require.Contains(t, workspaceIDs, timedOutBuild.Workspace.ID)
})
})
t.Run("Params", func(t *testing.T) {
t.Parallel()