From 591f5db5f68be5ff218e288ecb71291e2c776c0e Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 18 Jun 2025 18:22:45 +0200 Subject: [PATCH] feat: add has-ai-task filters to the /workspaces and /templates endpoints (#18387) This PR allows filtering templates and workspaces with the `has-ai-task` filter as described in the [Coder Tasks RFC](https://www.notion.so/coderhq/Coder-Tasks-207d579be5928053ab68c8d9a4b59eaa?source=copy_link#20ad579be59280e6a000eb0646d3c2df). --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/dbmem/dbmem.go | 69 ++++++++++++-- coderd/database/modelqueries.go | 3 + coderd/database/queries.sql.go | 78 +++++++++++---- coderd/database/queries/templates.sql | 28 ++++-- coderd/database/queries/workspaces.sql | 28 +++++- coderd/database/sqlc.yaml | 1 + coderd/rbac/regosql/compile_test.go | 4 +- coderd/rbac/regosql/configs.go | 2 +- coderd/searchquery/search.go | 2 + coderd/searchquery/search_test.go | 60 ++++++++++++ coderd/templates_test.go | 65 +++++++++++++ coderd/workspaces.go | 2 +- coderd/workspaces_test.go | 126 +++++++++++++++++++++++++ docs/reference/api/workspaces.md | 10 +- 16 files changed, 431 insertions(+), 51 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 16e72d23e1..d2003470f4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9653,7 +9653,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.", + "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.", "name": "q", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index cf4a96ddcb..8986738ee2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8538,7 +8538,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.", + "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.", "name": "q", "in": "query" }, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ab2dd923da..7ec7c0d761 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1389,6 +1389,17 @@ func isDeprecated(template database.Template) bool { return template.Deprecated != "" } +func (q *FakeQuerier) getWorkspaceBuildParametersNoLock(workspaceBuildID uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + params := make([]database.WorkspaceBuildParameter, 0) + for _, param := range q.workspaceBuildParameters { + if param.WorkspaceBuildID != workspaceBuildID { + continue + } + params = append(params, param) + } + return params, nil +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -7898,14 +7909,7 @@ func (q *FakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu q.mutex.RLock() defer q.mutex.RUnlock() - params := make([]database.WorkspaceBuildParameter, 0) - for _, param := range q.workspaceBuildParameters { - if param.WorkspaceBuildID != workspaceBuildID { - continue - } - params = append(params, param) - } - return params, nil + return q.getWorkspaceBuildParametersNoLock(workspaceBuildID) } func (q *FakeQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { @@ -13233,6 +13237,18 @@ func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.G continue } } + + if arg.HasAITask.Valid { + tv, err := q.getTemplateVersionByIDNoLock(ctx, template.ActiveVersionID) + if err != nil { + return nil, xerrors.Errorf("get template version: %w", err) + } + tvHasAITask := tv.HasAITask.Valid && tv.HasAITask.Bool + if tvHasAITask != arg.HasAITask.Bool { + continue + } + } + templates = append(templates, template) } if len(templates) > 0 { @@ -13562,6 +13578,43 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + if arg.HasAITask.Valid { + hasAITask, err := func() (bool, error) { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return false, xerrors.Errorf("get latest build: %w", err) + } + if build.HasAITask.Valid { + return build.HasAITask.Bool, nil + } + // If the build has a nil AI task, check if the job is in progress + // and if it has a non-empty AI Prompt parameter + job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) + if err != nil { + return false, xerrors.Errorf("get provisioner job: %w", err) + } + if job.CompletedAt.Valid { + return false, nil + } + parameters, err := q.getWorkspaceBuildParametersNoLock(build.ID) + if err != nil { + return false, xerrors.Errorf("get workspace build parameters: %w", err) + } + for _, param := range parameters { + if param.Name == "AI Prompt" && param.Value != "" { + return true, nil + } + } + return false, nil + }() + if err != nil { + return nil, xerrors.Errorf("get hasAITask: %w", err) + } + if hasAITask != arg.HasAITask.Bool { + continue + } + } + // If the filter exists, ensure the object is authorized. if prepared != nil && prepared.Authorize(ctx, workspace.RBACObject()) != nil { continue diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 1e4d249d8a..931412204d 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -80,6 +80,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate arg.FuzzyName, pq.Array(arg.IDs), arg.Deprecated, + arg.HasAITask, ) if err != nil { return nil, err @@ -264,6 +265,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.LastUsedBefore, arg.LastUsedAfter, arg.UsingActive, + arg.HasAITask, arg.RequesterID, arg.Offset, arg.Limit, @@ -311,6 +313,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.LatestBuildError, &i.LatestBuildTransition, &i.LatestBuildStatus, + &i.LatestBuildHasAITask, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3b44aae2d2..80922caec6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10812,34 +10812,36 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon FROM - template_with_names AS templates + template_with_names AS t +LEFT JOIN + template_versions tv ON t.active_version_id = tv.id WHERE -- Optionally include deleted templates - templates.deleted = $1 + t.deleted = $1 -- Filter by organization_id AND CASE WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - organization_id = $2 + t.organization_id = $2 ELSE true END -- Filter by exact name AND CASE WHEN $3 :: text != '' THEN - LOWER("name") = LOWER($3) + LOWER(t.name) = LOWER($3) ELSE true END -- Filter by name, matching on substring AND CASE WHEN $4 :: text != '' THEN - lower(name) ILIKE '%' || lower($4) || '%' + lower(t.name) ILIKE '%' || lower($4) || '%' ELSE true END -- Filter by ids AND CASE WHEN array_length($5 :: uuid[], 1) > 0 THEN - id = ANY($5) + t.id = ANY($5) ELSE true END -- Filter by deprecated @@ -10847,15 +10849,21 @@ WHERE WHEN $6 :: boolean IS NOT NULL THEN CASE WHEN $6 :: boolean THEN - deprecated != '' + t.deprecated != '' ELSE - deprecated = '' + t.deprecated = '' END ELSE true END + -- Filter by has_ai_task in latest version + AND CASE + WHEN $7 :: boolean IS NOT NULL THEN + tv.has_ai_task = $7 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter -ORDER BY (name, id) ASC +ORDER BY (t.name, t.id) ASC ` type GetTemplatesWithFilterParams struct { @@ -10865,6 +10873,7 @@ type GetTemplatesWithFilterParams struct { FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` IDs []uuid.UUID `db:"ids" json:"ids"` Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -10875,6 +10884,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate arg.FuzzyName, pq.Array(arg.IDs), arg.Deprecated, + arg.HasAITask, ) if err != nil { return nil, err @@ -18572,7 +18582,8 @@ SELECT latest_build.canceled_at as latest_build_canceled_at, latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, - latest_build.job_status as latest_build_status + latest_build.job_status as latest_build_status, + latest_build.has_ai_task as latest_build_has_ai_task FROM workspaces_expanded as workspaces JOIN @@ -18584,6 +18595,7 @@ LEFT JOIN LATERAL ( workspace_builds.id, workspace_builds.transition, workspace_builds.template_version_id, + workspace_builds.has_ai_task, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -18801,16 +18813,37 @@ WHERE (latest_build.template_version_id = template.active_version_id) = $18 :: boolean ELSE true END + -- Filter by has_ai_task in latest build + AND CASE + WHEN $19 :: boolean IS NOT NULL THEN + (COALESCE(latest_build.has_ai_task, false) OR ( + -- If the build has no AI task, it means that the provisioner job is in progress + -- and we don't know if it has an AI task yet. In this case, we optimistically + -- assume that it has an AI task if the AI Prompt parameter is not empty. This + -- lets the AI Task frontend spawn a task and see it immediately after instead of + -- having to wait for the build to complete. + latest_build.has_ai_task IS NULL AND + latest_build.completed_at IS NULL AND + EXISTS ( + SELECT 1 + FROM workspace_build_parameters + WHERE workspace_build_parameters.workspace_build_id = latest_build.id + AND workspace_build_parameters.name = 'AI Prompt' + AND workspace_build_parameters.value != '' + ) + )) = ($19 :: boolean) + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @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.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.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.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.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.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_ai_task FROM filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $19 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $20 AND favorite 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 @@ -18819,14 +18852,14 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $21 :: integer > 0 THEN - $21 + WHEN $22 :: integer > 0 THEN + $22 END OFFSET - $20 + $21 ), 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.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.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.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.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.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_ai_task FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -18867,9 +18900,10 @@ WHERE '0001-01-01 00:00:00+00'::timestamptz, -- latest_build_canceled_at, '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition - 'unknown'::provisioner_job_status -- latest_build_status + 'unknown'::provisioner_job_status, -- latest_build_status + false -- latest_build_has_ai_task WHERE - $22 :: boolean = true + $23 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -18877,7 +18911,7 @@ 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.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.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.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.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.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_ai_task, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -18904,6 +18938,7 @@ type GetWorkspacesParams struct { LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` UsingActive sql.NullBool `db:"using_active" json:"using_active"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -18945,6 +18980,7 @@ type GetWorkspacesRow struct { LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` + LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` Count int64 `db:"count" json:"count"` } @@ -18971,6 +19007,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.LastUsedBefore, arg.LastUsedAfter, arg.UsingActive, + arg.HasAITask, arg.RequesterID, arg.Offset, arg.Limit, @@ -19018,6 +19055,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LatestBuildError, &i.LatestBuildTransition, &i.LatestBuildStatus, + &i.LatestBuildHasAITask, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 3a0d34885f..8b399fae87 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -10,34 +10,36 @@ LIMIT -- name: GetTemplatesWithFilter :many SELECT - * + t.* FROM - template_with_names AS templates + template_with_names AS t +LEFT JOIN + template_versions tv ON t.active_version_id = tv.id WHERE -- Optionally include deleted templates - templates.deleted = @deleted + t.deleted = @deleted -- Filter by organization_id AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - organization_id = @organization_id + t.organization_id = @organization_id ELSE true END -- Filter by exact name AND CASE WHEN @exact_name :: text != '' THEN - LOWER("name") = LOWER(@exact_name) + LOWER(t.name) = LOWER(@exact_name) ELSE true END -- Filter by name, matching on substring AND CASE WHEN @fuzzy_name :: text != '' THEN - lower(name) ILIKE '%' || lower(@fuzzy_name) || '%' + lower(t.name) ILIKE '%' || lower(@fuzzy_name) || '%' ELSE true END -- Filter by ids AND CASE WHEN array_length(@ids :: uuid[], 1) > 0 THEN - id = ANY(@ids) + t.id = ANY(@ids) ELSE true END -- Filter by deprecated @@ -45,15 +47,21 @@ WHERE WHEN sqlc.narg('deprecated') :: boolean IS NOT NULL THEN CASE WHEN sqlc.narg('deprecated') :: boolean THEN - deprecated != '' + t.deprecated != '' ELSE - deprecated = '' + t.deprecated = '' END ELSE true END + -- Filter by has_ai_task in latest version + AND CASE + WHEN sqlc.narg('has_ai_task') :: boolean IS NOT NULL THEN + tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter -ORDER BY (name, id) ASC +ORDER BY (t.name, t.id) ASC ; -- name: GetTemplateByOrganizationAndName :one diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index d439ae2aa9..981db4512c 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -116,7 +116,8 @@ SELECT latest_build.canceled_at as latest_build_canceled_at, latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, - latest_build.job_status as latest_build_status + latest_build.job_status as latest_build_status, + latest_build.has_ai_task as latest_build_has_ai_task FROM workspaces_expanded as workspaces JOIN @@ -128,6 +129,7 @@ LEFT JOIN LATERAL ( workspace_builds.id, workspace_builds.transition, workspace_builds.template_version_id, + workspace_builds.has_ai_task, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -345,6 +347,27 @@ WHERE (latest_build.template_version_id = template.active_version_id) = sqlc.narg('using_active') :: boolean ELSE true END + -- Filter by has_ai_task in latest build + AND CASE + WHEN sqlc.narg('has_ai_task') :: boolean IS NOT NULL THEN + (COALESCE(latest_build.has_ai_task, false) OR ( + -- If the build has no AI task, it means that the provisioner job is in progress + -- and we don't know if it has an AI task yet. In this case, we optimistically + -- assume that it has an AI task if the AI Prompt parameter is not empty. This + -- lets the AI Task frontend spawn a task and see it immediately after instead of + -- having to wait for the build to complete. + latest_build.has_ai_task IS NULL AND + latest_build.completed_at IS NULL AND + EXISTS ( + SELECT 1 + FROM workspace_build_parameters + WHERE workspace_build_parameters.workspace_build_id = latest_build.id + AND workspace_build_parameters.name = 'AI Prompt' + AND workspace_build_parameters.value != '' + ) + )) = (sqlc.narg('has_ai_task') :: boolean) + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( @@ -411,7 +434,8 @@ WHERE '0001-01-01 00:00:00+00'::timestamptz, -- latest_build_canceled_at, '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition - 'unknown'::provisioner_job_status -- latest_build_status + 'unknown'::provisioner_job_status, -- latest_build_status + false -- latest_build_has_ai_task WHERE @with_summary :: boolean = true ), total_count AS ( diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 79b4b21f4d..85bb286881 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -149,6 +149,7 @@ sql: stale_interval_ms: StaleIntervalMS has_ai_task: HasAITask ai_tasks_sidebar_app_id: AITasksSidebarAppID + latest_build_has_ai_task: LatestBuildHasAITask rules: - name: do-not-use-public-schema-in-queries message: "do not use public schema in queries" diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index a6b59d1fdd..208cb920ad 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -236,8 +236,8 @@ internal.member_2(input.object.org_owner, {"3bf82434-e40b-44ae-b3d8-d0115bba9bad neq(input.object.owner, ""); "806dd721-775f-4c85-9ce3-63fbbd975954" = input.object.owner`, }, - ExpectedSQL: p(p("organization_id :: text != ''") + " AND " + - p("organization_id :: text = ANY(ARRAY ['3bf82434-e40b-44ae-b3d8-d0115bba9bad','5630fda3-26ab-462c-9014-a88a62d7a415','c304877a-bc0d-4e9b-9623-a38eae412929'])") + " AND " + + ExpectedSQL: p(p("t.organization_id :: text != ''") + " AND " + + p("t.organization_id :: text = ANY(ARRAY ['3bf82434-e40b-44ae-b3d8-d0115bba9bad','5630fda3-26ab-462c-9014-a88a62d7a415','c304877a-bc0d-4e9b-9623-a38eae412929'])") + " AND " + p("false") + " AND " + p("false")), VariableConverter: regosql.TemplateConverter(), diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 4ccd1cb3bb..2cb03b238f 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -25,7 +25,7 @@ func userACLMatcher(m sqltypes.VariableMatcher) sqltypes.VariableMatcher { func TemplateConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), - organizationOwnerMatcher(), + sqltypes.StringVarMatcher("t.organization_id :: text", []string{"input", "object", "org_owner"}), // Templates have no user owner, only owner by an organization. sqltypes.AlwaysFalse(userOwnerMatcher()), ) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 6f4a1c337c..721e593d4d 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -146,6 +146,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder // which will return all workspaces. Valid: values.Has("outdated"), } + filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task") filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") type paramMatch struct { @@ -206,6 +207,7 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), } parser.ErrorExcessParams(values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 065937f389..5739ecab77 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -222,6 +222,36 @@ func TestSearchWorkspace(t *testing.T) { OrganizationID: uuid.MustParse("08eb6715-02f8-45c5-b86d-03786fcfbb4e"), }, }, + { + Name: "HasAITaskTrue", + Query: "has-ai-task:true", + Expected: database.GetWorkspacesParams{ + HasAITask: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasAITaskFalse", + Query: "has-ai-task:false", + Expected: database.GetWorkspacesParams{ + HasAITask: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasAITaskMissing", + Query: "", + Expected: database.GetWorkspacesParams{ + HasAITask: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, // Failures { @@ -559,6 +589,36 @@ func TestSearchTemplates(t *testing.T) { FuzzyName: "foobar", }, }, + { + Name: "HasAITaskTrue", + Query: "has-ai-task:true", + Expected: database.GetTemplatesWithFilterParams{ + HasAITask: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasAITaskFalse", + Query: "has-ai-task:false", + Expected: database.GetTemplatesWithFilterParams{ + HasAITask: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasAITaskMissing", + Query: "", + Expected: database.GetTemplatesWithFilterParams{ + HasAITask: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, } for _, c := range testCases { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index f8f2b13722..f8861da246 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "database/sql" "net/http" "sync/atomic" "testing" @@ -16,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" @@ -1809,3 +1811,66 @@ func TestTemplateNotifications(t *testing.T) { }) }) } + +func TestTemplateFilterHasAITask(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + jobWithAITask := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + jobWithoutAITask := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + versionWithAITask := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + JobID: jobWithAITask.ID, + }) + versionWithoutAITask := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasAITask: sql.NullBool{Bool: false, Valid: true}, + JobID: jobWithoutAITask.ID, + }) + templateWithAITask := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithAITask.ID) + templateWithoutAITask := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithoutAITask.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Test filtering + templates, err := client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has-ai-task:true", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithAITask.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has-ai-task:false", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithoutAITask.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{}) + require.NoError(t, err) + require.Len(t, templates, 2) + require.Contains(t, templates, templateWithAITask) + require.Contains(t, templates, templateWithoutAITask) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d38de99e95..b152077646 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -136,7 +136,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." +// @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." // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 018dd363bd..daabb12c25 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4494,3 +4494,129 @@ func TestOIDCRemoved(t *testing.T) { require.NoError(t, err, "delete the workspace") coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, deleteBuild.ID) } + +func TestWorkspaceFilterHasAITask(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Helper function to create workspace with AI task configuration + createWorkspaceWithAIConfig := func(hasAITask sql.NullBool, jobCompleted bool, aiTaskPrompt *string) database.WorkspaceTable { + // When a provisioner job uses these tags, no provisioner will match it + unpickableTags := database.StringMap{"custom": "true"} + + ws := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.UserID, + OrganizationID: user.OrganizationID, + TemplateID: template.ID, + }) + + jobConfig := database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: unpickableTags, + } + if jobCompleted { + jobConfig.CompletedAt = sql.NullTime{Time: time.Now(), Valid: true} + } + job := dbgen.ProvisionerJob(t, db, pubsub, jobConfig) + + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: version.ID, + InitiatorID: user.UserID, + JobID: job.ID, + BuildNumber: 1, + HasAITask: hasAITask, + }) + + if aiTaskPrompt != nil { + //nolint:gocritic // unit test + err := db.InsertWorkspaceBuildParameters(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceBuildParametersParams{ + WorkspaceBuildID: build.ID, + Name: []string{"AI Prompt"}, + Value: []string{*aiTaskPrompt}, + }) + require.NoError(t, err) + } + + return ws + } + + // Create test workspaces with different AI task configurations + wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, false, nil) + wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil) + + aiTaskPrompt := "Build me a web app" + wsWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &aiTaskPrompt) + + anotherTaskPrompt := "Another task" + wsCompletedWithAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, true, &anotherTaskPrompt) + + emptyPrompt := "" + wsWithEmptyAITaskParam := createWorkspaceWithAIConfig(sql.NullBool{Valid: false}, false, &emptyPrompt) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Debug: Check all workspaces without filter first + allRes, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + t.Logf("Total workspaces created: %d", len(allRes.Workspaces)) + for i, ws := range allRes.Workspaces { + t.Logf("All Workspace %d: ID=%s, Name=%s, Build ID=%s, Job ID=%s", i, ws.ID, ws.Name, ws.LatestBuild.ID, ws.LatestBuild.Job.ID) + } + + // Test filtering for workspaces with AI tasks + // Should include: wsWithAITask (has_ai_task=true) and wsWithAITaskParam (null + incomplete + param) + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "has-ai-task:true", + }) + require.NoError(t, err) + t.Logf("Expected 2 workspaces for has-ai-task:true, got %d", len(res.Workspaces)) + t.Logf("Expected workspaces: %s, %s", wsWithAITask.ID, wsWithAITaskParam.ID) + for i, ws := range res.Workspaces { + t.Logf("AI Task True Workspace %d: ID=%s, Name=%s", i, ws.ID, ws.Name) + } + require.Len(t, res.Workspaces, 2) + workspaceIDs := []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID} + require.Contains(t, workspaceIDs, wsWithAITask.ID) + require.Contains(t, workspaceIDs, wsWithAITaskParam.ID) + + // Test filtering for workspaces without AI tasks + // Should include: wsWithoutAITask, wsCompletedWithAITaskParam, wsWithEmptyAITaskParam + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "has-ai-task:false", + }) + require.NoError(t, err) + + // Debug: print what we got + t.Logf("Expected 3 workspaces for has-ai-task:false, got %d", len(res.Workspaces)) + for i, ws := range res.Workspaces { + t.Logf("Workspace %d: ID=%s, Name=%s", i, ws.ID, ws.Name) + } + t.Logf("Expected IDs: %s, %s, %s", wsWithoutAITask.ID, wsCompletedWithAITaskParam.ID, wsWithEmptyAITaskParam.ID) + + require.Len(t, res.Workspaces, 3) + workspaceIDs = []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID, res.Workspaces[2].ID} + require.Contains(t, workspaceIDs, wsWithoutAITask.ID) + require.Contains(t, workspaceIDs, wsCompletedWithAITaskParam.ID) + require.Contains(t, workspaceIDs, wsWithEmptyAITaskParam.ID) + + // Test no filter returns all + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, res.Workspaces, 5) +} diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index de6fb83310..a43e992fe8 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -920,11 +920,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Parameters -| Name | In | Type | Required | Description | -|----------|-------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `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. | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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. | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses