diff --git a/cli/exp_task_list_test.go b/cli/exp_task_list_test.go index d297310dc4..f9255da9b3 100644 --- a/cli/exp_task_list_test.go +++ b/cli/exp_task_list_test.go @@ -2,7 +2,6 @@ package cli_test import ( "bytes" - "context" "database/sql" "encoding/json" "io" @@ -19,10 +18,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "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/dbfake" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" @@ -43,76 +39,22 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU }, }).Do() - ws := database.WorkspaceTable{ + build := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: orgID, OwnerID: ownerID, TemplateID: tv.Template.ID, - } - build := dbfake.WorkspaceBuild(t, db, ws). + }). Seed(database.WorkspaceBuild{ TemplateVersionID: tv.TemplateVersion.ID, Transition: transition, - }).WithAgent().Do() - dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ - { - WorkspaceBuildID: build.Build.ID, - Name: codersdk.AITaskPromptParameterName, - Value: prompt, - }, - }) - agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber( - dbauthz.AsSystemRestricted(context.Background()), - database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ - WorkspaceID: build.Workspace.ID, - BuildNumber: build.Build.BuildNumber, - }, - ) - require.NoError(t, err) - require.NotEmpty(t, agents) - agentID := agents[0].ID + }). + WithAgent(). + WithTask(database.TaskTable{ + Prompt: prompt, + }, nil). + Do() - // Create a workspace app and set it as the sidebar app. - app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ - AgentID: agentID, - Slug: "task-sidebar", - DisplayName: "Task Sidebar", - External: false, - }) - - // Update build flags to reference the sidebar app and HasAITask=true. - err = db.UpdateWorkspaceBuildFlagsByID( - dbauthz.AsSystemRestricted(context.Background()), - database.UpdateWorkspaceBuildFlagsByIDParams{ - ID: build.Build.ID, - HasAITask: sql.NullBool{Bool: true, Valid: true}, - HasExternalAgent: sql.NullBool{Bool: false, Valid: false}, - SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, - UpdatedAt: build.Build.UpdatedAt, - }, - ) - require.NoError(t, err) - - // Create a task record in the tasks table for the new data model. - task := dbgen.Task(t, db, database.TaskTable{ - OrganizationID: orgID, - OwnerID: ownerID, - Name: build.Workspace.Name, - WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true}, - TemplateVersionID: tv.TemplateVersion.ID, - TemplateParameters: []byte("{}"), - Prompt: prompt, - CreatedAt: dbtime.Now(), - }) - - // Link the task to the workspace app. - dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{ - TaskID: task.ID, - WorkspaceBuildNumber: build.Build.BuildNumber, - WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, - }) - - return task + return build.Task } func TestExpTaskList(t *testing.T) { diff --git a/cli/exp_task_test.go b/cli/exp_task_test.go index d2d3728aeb..7173290249 100644 --- a/cli/exp_task_test.go +++ b/cli/exp_task_test.go @@ -293,7 +293,6 @@ func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID { Type: &proto.Response_Plan{ Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }, }, @@ -328,9 +327,7 @@ func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID }, AiTasks: []*proto.AITask{ { - SidebarApp: &proto.AITaskSidebarApp{ - Id: taskAppID.String(), - }, + AppId: taskAppID.String(), }, }, }, diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 8415a01454..9611785b85 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" "slices" - "strings" "time" "github.com/google/uuid" @@ -24,62 +23,12 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" aiagentapi "github.com/coder/agentapi-sdk-go" ) -// This endpoint is experimental and not guaranteed to be stable, so we're not -// generating public-facing documentation for it. -func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - buildIDsParam := r.URL.Query().Get("build_ids") - if buildIDsParam == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "build_ids query parameter is required", - }) - return - } - - // Parse build IDs - buildIDStrings := strings.Split(buildIDsParam, ",") - buildIDs := make([]uuid.UUID, 0, len(buildIDStrings)) - for _, idStr := range buildIDStrings { - id, err := uuid.Parse(strings.TrimSpace(idStr)) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid build ID format: %s", idStr), - Detail: err.Error(), - }) - return - } - buildIDs = append(buildIDs, id) - } - - parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace build parameters.", - Detail: err.Error(), - }) - return - } - - promptsByBuildID := make(map[string]string, len(parameters)) - for _, param := range parameters { - if param.Name != codersdk.AITaskPromptParameterName { - continue - } - buildID := param.WorkspaceBuildID.String() - promptsByBuildID[buildID] = param.Value - } - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{ - Prompts: promptsByBuildID, - }) -} - // @Summary Create a new AI task // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID create-task @@ -174,13 +123,31 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { } } + // Check if the template defines the AI Prompt parameter. + templateParams, err := api.Database.GetTemplateVersionParameters(ctx, req.TemplateVersionID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template parameters.", + Detail: err.Error(), + }) + return + } + + var richParams []codersdk.WorkspaceBuildParameter + if _, hasAIPromptParam := slice.Find(templateParams, func(param database.TemplateVersionParameter) bool { + return param.Name == codersdk.AITaskPromptParameterName + }); hasAIPromptParam { + // Only add the AI Prompt parameter if the template defines it. + richParams = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: req.Input}, + } + } + createReq := codersdk.CreateWorkspaceRequest{ Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, - RichParameterValues: []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: req.Input}, - }, + RichParameterValues: richParams, } var owner workspaceOwner diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index d3b5e240d8..9fa8e168a4 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -35,128 +35,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestAITasksPrompts(t *testing.T) { - t.Parallel() - - t.Run("EmptyBuildIDs", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{}) - _ = coderdtest.CreateFirstUser(t, client) - experimentalClient := codersdk.NewExperimentalClient(client) - - ctx := testutil.Context(t, testutil.WaitShort) - - // Test with empty build IDs - prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{}) - require.NoError(t, err) - require.Empty(t, prompts.Prompts) - }) - - t.Run("MultipleBuilds", func(t *testing.T) { - t.Parallel() - - adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - first := coderdtest.CreateFirstUser(t, adminClient) - memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID) - - ctx := testutil.Context(t, testutil.WaitLong) - - // Create a template with parameters - version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Response{{ - Type: &proto.Response_Plan{ - Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{ - { - Name: "param1", - Type: "string", - DefaultValue: "default1", - }, - { - Name: codersdk.AITaskPromptParameterName, - Type: "string", - DefaultValue: "default2", - }, - }, - }, - }, - }}, - ProvisionApply: echo.ApplyComplete, - }) - template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) - - // Create two workspaces with different parameters - workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { - request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: "param1", Value: "value1a"}, - {Name: codersdk.AITaskPromptParameterName, Value: "value2a"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID) - - workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { - request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: "param1", Value: "value1b"}, - {Name: codersdk.AITaskPromptParameterName, Value: "value2b"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID) - - workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { - request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: "param1", Value: "value1c"}, - {Name: codersdk.AITaskPromptParameterName, Value: "value2c"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID) - allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID} - - experimentalMemberClient := codersdk.NewExperimentalClient(memberClient) - // Test parameters endpoint as member - prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs) - require.NoError(t, err) - // we expect 2 prompts because the member client does not have access to workspace3 - // since it was created by the admin client - require.Len(t, prompts.Prompts, 2) - - // Check workspace1 parameters - build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()] - require.Equal(t, "value2a", build1Prompt) - - // Check workspace2 parameters - build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()] - require.Equal(t, "value2b", build2Prompt) - - experimentalAdminClient := codersdk.NewExperimentalClient(adminClient) - // Test parameters endpoint as admin - // we expect 3 prompts because the admin client has access to all workspaces - prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs) - require.NoError(t, err) - require.Len(t, prompts.Prompts, 3) - - // Check workspace3 parameters - build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()] - require.Equal(t, "value2c", build3Prompt) - }) - - t.Run("NonExistentBuildIDs", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{}) - _ = coderdtest.CreateFirstUser(t, client) - - ctx := testutil.Context(t, testutil.WaitShort) - - // Test with non-existent build IDs - nonExistentID := uuid.New() - experimentalClient := codersdk.NewExperimentalClient(client) - prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID}) - require.NoError(t, err) - require.Empty(t, prompts.Prompts) - }) -} - func TestTasks(t *testing.T) { t.Parallel() @@ -188,7 +66,6 @@ func TestTasks(t *testing.T) { { Type: &proto.Response_Plan{ Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }, }, @@ -817,6 +694,51 @@ func TestTasksCreate(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: taskPrompt, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + assert.NotEmpty(t, task.Name) + assert.Equal(t, template.ID, task.TemplateID) + + parameters, err := client.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID) + require.NoError(t, err) + require.Len(t, parameters, 0) + }) + + t.Run("OK AIPromptBackCompat", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + + taskPrompt = "Some task prompt" + ) + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + // Given: A template with an "AI Prompt" parameter version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -896,7 +818,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -1012,7 +933,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -1072,7 +992,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -1109,7 +1028,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -1162,7 +1080,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -1175,7 +1092,6 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, diff --git a/coderd/coderd.go b/coderd/coderd.go index a1f94bfa6f..fd361c1e0e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1021,10 +1021,7 @@ func New(options *Options) *API { apiRateLimiter, httpmw.ReportCLITelemetry(api.Logger, options.Telemetry), ) - r.Route("/aitasks", func(r chi.Router) { - r.Use(apiKeyMiddleware) - r.Get("/prompts", api.aiTasksPrompts) - }) + r.Route("/tasks", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index f9b058a409..46f9fb0f68 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -329,7 +329,6 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.LatestBuildError, &i.LatestBuildTransition, &i.LatestBuildStatus, - &i.LatestBuildHasAITask, &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index eafe73279b..e9108751cf 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -22289,7 +22289,6 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task, latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces @@ -22523,25 +22522,19 @@ WHERE (latest_build.template_version_id = template.active_version_id) = $18 :: boolean ELSE true END - -- Filter by has_ai_task in latest build + -- Filter by has_ai_task, checks if this is a task workspace. 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) + WHEN $19::boolean IS NOT NULL + THEN $19::boolean = EXISTS ( + SELECT + 1 + FROM + tasks + WHERE + -- Consider all tasks, deleting a task does not turn the + -- workspace into a non-task workspace. + tasks.workspace_id = workspaces.id + ) ELSE true END -- Filter by has_external_agent in latest build @@ -22572,7 +22565,7 @@ WHERE -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.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, fw.latest_build_has_external_agent + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.task_id, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_external_agent FROM filtered_workspaces fw ORDER BY @@ -22593,7 +22586,7 @@ WHERE $25 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.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, fwo.latest_build_has_external_agent + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.task_id, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_external_agent FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -22638,7 +22631,6 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false, -- latest_build_has_ai_task false -- latest_build_has_external_agent WHERE $27 :: boolean = true @@ -22649,7 +22641,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.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.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, fwos.latest_build_has_external_agent, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.task_id, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_external_agent, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -22725,7 +22717,6 @@ 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"` LatestBuildHasExternalAgent sql.NullBool `db:"latest_build_has_external_agent" json:"latest_build_has_external_agent"` Count int64 `db:"count" json:"count"` } @@ -22808,7 +22799,6 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LatestBuildError, &i.LatestBuildTransition, &i.LatestBuildStatus, - &i.LatestBuildHasAITask, &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index d48285bb7d..9e6e0d8b24 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -117,7 +117,6 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task, latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces @@ -351,25 +350,19 @@ 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 + -- Filter by has_ai_task, checks if this is a task workspace. 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) + WHEN sqlc.narg('has_ai_task')::boolean IS NOT NULL + THEN sqlc.narg('has_ai_task')::boolean = EXISTS ( + SELECT + 1 + FROM + tasks + WHERE + -- Consider all tasks, deleting a task does not turn the + -- workspace into a non-task workspace. + tasks.workspace_id = workspaces.id + ) ELSE true END -- Filter by has_external_agent in latest build @@ -466,7 +459,6 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false, -- latest_build_has_ai_task false -- latest_build_has_external_agent WHERE @with_summary :: boolean = true diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 51134dce27..6515367bfe 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4700,11 +4700,16 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { 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 { + // Helper function to create workspace with optional task. + createWorkspace := func(jobCompleted, createTask bool, prompt string) uuid.UUID { + // TODO(mafredri): The bellow comment is based on deprecated logic and + // kept only present to test that the old observable behavior works as + // intended. + // // When a provisioner job uses these tags, no provisioner will match it. - // We do this so jobs will always be stuck in "pending", allowing us to exercise the intermediary state when - // has_ai_task is nil and we compensate by looking at pending provisioning jobs. + // We do this so jobs will always be stuck in "pending", allowing us to + // exercise the intermediary state when has_ai_task is nil and we + // compensate by looking at pending provisioning jobs. // See GetWorkspaces clauses. unpickableTags := database.StringMap{"custom": "true"} @@ -4723,102 +4728,71 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { jobConfig.CompletedAt = sql.NullTime{Time: time.Now(), Valid: true} } job := dbgen.ProvisionerJob(t, db, pubsub, jobConfig) - res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID}) agnt := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID}) - - var sidebarAppID uuid.UUID - if hasAITask.Bool { - sidebarApp := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agnt.ID}) - sidebarAppID = sidebarApp.ID - } - + taskApp := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agnt.ID}) build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ WorkspaceID: ws.ID, TemplateVersionID: version.ID, InitiatorID: user.UserID, JobID: job.ID, BuildNumber: 1, - HasAITask: hasAITask, - AITaskSidebarAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: sidebarAppID != uuid.Nil}, + AITaskSidebarAppID: uuid.NullUUID{UUID: taskApp.ID, Valid: createTask}, }) - if aiTaskPrompt != nil { - err := db.InsertWorkspaceBuildParameters(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceBuildParametersParams{ - WorkspaceBuildID: build.ID, - Name: []string{provider.TaskPromptParameterName}, - Value: []string{*aiTaskPrompt}, + if createTask { + task := dbgen.Task(t, db, database.TaskTable{ + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + TemplateVersionID: version.ID, + Prompt: prompt, + }) + dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{ + TaskID: task.ID, + WorkspaceBuildNumber: build.BuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: agnt.ID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: taskApp.ID, Valid: true}, }) - require.NoError(t, err) } - return ws + return ws.ID } - // Create test workspaces with different AI task configurations - wsWithAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: true, Valid: true}, true, nil) - wsWithoutAITask := createWorkspaceWithAIConfig(sql.NullBool{Bool: false, Valid: true}, false, nil) + // Create workspaces with tasks. + wsWithTask1 := createWorkspace(true, true, "Build me a web app") + wsWithTask2 := createWorkspace(false, true, "Another task") - 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) - } + // Create workspaces without tasks + wsWithoutTask1 := createWorkspace(true, false, "") + wsWithoutTask2 := createWorkspace(false, false, "") // Test filtering for workspaces with AI tasks - // Should include: wsWithAITask (has_ai_task=true) and wsWithAITaskParam (null + incomplete + param) + // Should include: wsWithTask1 and wsWithTask2 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) + require.Contains(t, workspaceIDs, wsWithTask1) + require.Contains(t, workspaceIDs, wsWithTask2) // Test filtering for workspaces without AI tasks - // Should include: wsWithoutAITask, wsCompletedWithAITaskParam, wsWithEmptyAITaskParam + // Should include: wsWithoutTask1, wsWithoutTask2, wsWithoutTask3 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) + require.Len(t, res.Workspaces, 2) + workspaceIDs = []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID} + require.Contains(t, workspaceIDs, wsWithoutTask1) + require.Contains(t, workspaceIDs, wsWithoutTask2) // Test no filter returns all res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) - require.Len(t, res.Workspaces, 5) + require.Len(t, res.Workspaces, 4) } func TestWorkspaceAppUpsertRestart(t *testing.T) { diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 9f390202e4..1f1e9758e9 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -17,46 +17,16 @@ import ( // AITaskPromptParameterName is the name of the parameter used to pass prompts // to AI tasks. // -// Experimental: This value is experimental and may change in the future. +// Deprecated: This constant is deprecated and maintained only for backwards +// compatibility with older templates. Task prompts are now stored directly +// in the tasks.prompt database column. New code should access prompts via +// the Task.InitialPrompt field returned from task endpoints. +// +// This constant will be removed in a future major version. Templates should +// not rely on this parameter name, as the backend will continue to create it +// automatically for compatibility but reads from tasks.prompt. const AITaskPromptParameterName = provider.TaskPromptParameterName -// AITasksPromptsResponse represents the response from the AITaskPrompts method. -// -// Experimental: This method is experimental and may change in the future. -type AITasksPromptsResponse struct { - // Prompts is a map of workspace build IDs to prompts. - Prompts map[string]string `json:"prompts"` -} - -// AITaskPrompts returns prompts for multiple workspace builds by their IDs. -// -// Experimental: This method is experimental and may change in the future. -func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) { - if len(buildIDs) == 0 { - return AITasksPromptsResponse{ - Prompts: make(map[string]string), - }, nil - } - - // Convert UUIDs to strings and join them - buildIDStrings := make([]string, len(buildIDs)) - for i, id := range buildIDs { - buildIDStrings[i] = id.String() - } - buildIDsParam := strings.Join(buildIDStrings, ",") - - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/aitasks/prompts", nil, WithQueryParam("build_ids", buildIDsParam)) - if err != nil { - return AITasksPromptsResponse{}, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return AITasksPromptsResponse{}, ReadBodyAsError(res) - } - var prompts AITasksPromptsResponse - return prompts, json.NewDecoder(res.Body).Decode(&prompts) -} - // CreateTaskRequest represents the request to create a new task. // // Experimental: This type is experimental and may change in the future. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4c02a96fe2..baf56e5a27 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2661,27 +2661,6 @@ export type CreateTaskFeedbackRequest = { class ExperimentalApiMethods { constructor(protected readonly axios: AxiosInstance) {} - getAITasksPrompts = async ( - buildIds: TypesGen.WorkspaceBuild["id"][], - ): Promise => { - if (buildIds.length === 0) { - return { - prompts: {}, - }; - } - - const response = await this.axios.get( - "/api/experimental/aitasks/prompts", - { - params: { - build_ids: buildIds.join(","), - }, - }, - ); - - return response.data; - }; - createTask = async ( user: string, req: TypesGen.CreateTaskRequest, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6d703cbcfe..90913ea842 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -108,23 +108,17 @@ export interface AIConfig { * AITaskPromptParameterName is the name of the parameter used to pass prompts * to AI tasks. * - * Experimental: This value is experimental and may change in the future. + * Deprecated: This constant is deprecated and maintained only for backwards + * compatibility with older templates. Task prompts are now stored directly + * in the tasks.prompt database column. New code should access prompts via + * the Task.InitialPrompt field returned from task endpoints. + * + * This constant will be removed in a future major version. Templates should + * not rely on this parameter name, as the backend will continue to create it + * automatically for compatibility but reads from tasks.prompt. */ export const AITaskPromptParameterName = "AI Prompt"; -// From codersdk/aitasks.go -/** - * AITasksPromptsResponse represents the response from the AITaskPrompts method. - * - * Experimental: This method is experimental and may change in the future. - */ -export interface AITasksPromptsResponse { - /** - * Prompts is a map of workspace build IDs to prompts. - */ - readonly prompts: Record; -} - // From codersdk/allowlist.go /** * APIAllowListTarget represents a single allow-list entry using the canonical diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index b40f3253a8..e0afb9cf7d 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -7,6 +7,7 @@ import type { Template, TemplateVersionExternalAuth, } from "api/typesGenerated"; +import { AITaskPromptParameterName } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; @@ -38,8 +39,6 @@ import { docs } from "utils/docs"; import { PromptSelectTrigger } from "./PromptSelectTrigger"; import { TemplateVersionSelect } from "./TemplateVersionSelect"; -const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; - type TaskPromptProps = { templates: Template[] | undefined; error: unknown; @@ -168,7 +167,7 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { // Read-only prompt if defined in preset const presetPrompt = selectedPreset?.Parameters?.find( - (param) => param.Name === AI_PROMPT_PARAMETER_NAME, + (param) => param.Name === AITaskPromptParameterName, )?.Value; const isPromptReadOnly = !!presetPrompt; useEffect(() => {