From e20c3c8259c17e875271139e7488adbe9735588d Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 16 Jun 2025 10:44:54 +0000 Subject: [PATCH 1/4] query --- coderd/database/queries/templates.sql | 28 +++++++++++++++++--------- coderd/database/queries/workspaces.sql | 28 ++++++++++++++++++++++++-- coderd/database/sqlc.yaml | 1 + 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 3a0d34885f3d9..8b399fae87f3f 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 d439ae2aa9944..981db4512ce8b 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 79b4b21f4d83f..85bb286881f97 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" From 17f6430801601c6ed4628b3a098392d5081a4ab8 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 16 Jun 2025 11:00:40 +0000 Subject: [PATCH 2/4] make gen --- coderd/database/modelqueries.go | 3 ++ coderd/database/queries.sql.go | 78 ++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 1e4d249d8a034..931412204d780 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 3b44aae2d294f..80922caec66bf 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 From 03d4e689514b656b07e94a9d5e6798cb238d3970 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 16 Jun 2025 11:13:30 +0000 Subject: [PATCH 3/4] endpoint --- coderd/database/dbmem/dbmem.go | 69 +++++++++++++-- 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 ++++++++++++++++++++++++++++ 8 files changed, 318 insertions(+), 12 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ab2dd923dab47..7ec7c0d76154d 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/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index a6b59d1fdd4bd..208cb920ad1f7 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 4ccd1cb3bbaef..2cb03b238f471 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 6f4a1c337c535..721e593d4dd8d 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 065937f389e4a..5739ecab77525 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 f8f2b1372263c..f8861da246260 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 d38de99e95eba..b1520776464c0 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 018dd363bdee6..daabb12c25e14 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) +} From 32cc42757d6e73b4bef925635528eb7d725e9901 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 16 Jun 2025 14:01:33 +0000 Subject: [PATCH 4/4] make gen 2 --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- docs/reference/api/workspaces.md | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f2a7dd2dee7a2..cae88af51b11e 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 74b5aad0afed5..ed79fc6c78ac0 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/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index de6fb8331047d..a43e992fe8756 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy