mirror of
https://github.com/coder/coder.git
synced 2025-07-10 23:53:15 +00:00
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).
This commit is contained in:
2
coderd/apidoc/docs.go
generated
2
coderd/apidoc/docs.go
generated
@ -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"
|
||||
},
|
||||
|
2
coderd/apidoc/swagger.json
generated
2
coderd/apidoc/swagger.json
generated
@ -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"
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 (
|
||||
|
@ -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"
|
||||
|
@ -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(),
|
||||
|
@ -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()),
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
Reference in New Issue
Block a user