diff --git a/coderd/aitasks.go b/coderd/aitasks.go index ccd8b2dfac5b7..dccdf98bf2164 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "slices" + "strings" "time" "github.com/google/uuid" @@ -500,7 +501,7 @@ func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.Task // @Router /api/experimental/tasks/{user}/{task} [get] // @@ -578,7 +579,7 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 202 "Task deletion initiated" // @Router /api/experimental/tasks/{user}/{task} [delete] // @@ -646,13 +647,96 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusAccepted) } +// @Summary Update AI task input +// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. +// @ID update-task-input +// @Security CoderSessionToken +// @Tags Experimental +// @Param user path string true "Username, user ID, or 'me' for the authenticated user" +// @Param task path string true "Task ID, or task name" +// @Param request body codersdk.UpdateTaskInputRequest true "Update task input request" +// @Success 204 +// @Router /api/experimental/tasks/{user}/{task}/input [patch] +// +// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. +// taskUpdateInput allows modifying a task's prompt before the agent executes it. +func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + task = httpmw.TaskParam(r) + auditor = api.Auditor.Load() + taskResourceInfo = audit.AdditionalFields{} + ) + + aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + AdditionalFields: taskResourceInfo, + }) + defer commitAudit() + aReq.Old = task.TaskTable() + aReq.UpdateOrganizationID(task.OrganizationID) + + var req codersdk.UpdateTaskInputRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if strings.TrimSpace(req.Input) == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Task input is required.", + }) + return + } + + var updatedTask database.TaskTable + if err := api.Database.InTx(func(tx database.Store) error { + task, err := tx.GetTaskByID(ctx, task.ID) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch task.", + Detail: err.Error(), + }) + } + + if task.Status != database.TaskStatusPaused { + return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ + Message: "Unable to update task input, task must be paused.", + Detail: "Please stop the task's workspace before updating the input.", + }) + } + + updatedTask, err = tx.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ + ID: task.ID, + Prompt: req.Input, + }) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update task input.", + Detail: err.Error(), + }) + } + + return nil + }, nil); err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + aReq.New = updatedTask + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // @Summary Send input to AI task // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID send-task-input // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Param request body codersdk.TaskSendRequest true "Task input request" // @Success 204 "Input sent successfully" // @Router /api/experimental/tasks/{user}/{task}/send [post] @@ -726,7 +810,7 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param task path string true "Task ID" format(uuid) +// @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.TaskLogsResponse // @Router /api/experimental/tasks/{user}/{task}/logs [get] // diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 8582765c01174..b9f82656884f2 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -23,6 +23,7 @@ import ( "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/httpapi" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/util/slice" @@ -738,6 +739,210 @@ func TestTasks(t *testing.T) { require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode()) }) }) + + t.Run("UpdateInput", func(t *testing.T) { + tests := []struct { + name string + disableProvisioner bool + transition database.WorkspaceTransition + cancelTransition bool + deleteTask bool + taskInput string + wantStatus codersdk.TaskStatus + wantErr string + wantErrStatusCode int + }{ + { + name: "TaskStatusInitializing", + // We want to disable the provisioner so that the task + // never gets provisioned (ensuring it stays in Initializing). + disableProvisioner: true, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusInitializing, + wantErr: "Unable to update", + wantErrStatusCode: http.StatusConflict, + }, + { + name: "TaskStatusPaused", + transition: database.WorkspaceTransitionStop, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusPaused, + }, + { + name: "TaskStatusError", + transition: database.WorkspaceTransitionStart, + cancelTransition: true, + taskInput: "Valid prompt", + wantStatus: codersdk.TaskStatusError, + wantErr: "Unable to update", + wantErrStatusCode: http.StatusConflict, + }, + { + name: "EmptyPrompt", + transition: database.WorkspaceTransitionStop, + // We want to ensure an empty prompt is rejected. + taskInput: "", + wantStatus: codersdk.TaskStatusPaused, + wantErr: "Task input is required.", + wantErrStatusCode: http.StatusBadRequest, + }, + { + name: "TaskDeleted", + transition: database.WorkspaceTransitionStop, + deleteTask: true, + taskInput: "Valid prompt", + wantErr: httpapi.ResourceNotFoundResponse.Message, + wantErrStatusCode: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, provisioner := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + if tt.disableProvisioner { + provisioner.Close() + } + + // Given: We create a task + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + + if !tt.disableProvisioner { + // Given: The Task is running + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Given: We transition the task's workspace + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, tt.transition) + if tt.cancelTransition { + // Given: We cancel the workspace build + err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{}) + require.NoError(t, err) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Then: We expect it to be canceled + build, err = client.WorkspaceBuild(ctx, build.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusCanceled, build.Status) + } else { + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + } + } + + if tt.deleteTask { + err = exp.DeleteTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + } else { + // Given: Task has expected status + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, tt.wantStatus, task.Status) + } + + // When: We attempt to update the task input + err = exp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: tt.taskInput, + }) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + + if tt.wantErrStatusCode != 0 { + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, tt.wantErrStatusCode, apiErr.StatusCode()) + } + + if !tt.deleteTask { + // Then: We expect the input to **not** be updated + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotEqual(t, tt.taskInput, task.InitialPrompt) + } + } else { + require.NoError(t, err) + + if !tt.deleteTask { + // Then: We expect the input to be updated + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.Equal(t, tt.taskInput, task.InitialPrompt) + } + } + }) + } + + t.Run("NonExistentTask", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + exp := codersdk.NewExperimentalClient(client) + + // Attempt to update prompt for non-existent task + err := exp.UpdateTaskInput(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskInputRequest{ + Input: "Should fail", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("UnauthorizedUser", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + anotherUser, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a task as the first user + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "initial prompt", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Wait for workspace to complete + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Attempt to update prompt as another user should fail with 404 Not Found + otherExp := codersdk.NewExperimentalClient(anotherUser) + err = otherExp.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{ + Input: "Should fail - unauthorized", + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + }) } func TestTasksCreate(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index de7cd416f287d..449127ee5577b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -228,8 +228,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -265,8 +264,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -279,6 +277,50 @@ const docTemplate = `{ } } }, + "/api/experimental/tasks/{user}/{task}/input": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Experimental" + ], + "summary": "Update AI task input", + "operationId": "update-task-input", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Task ID, or task name", + "name": "task", + "in": "path", + "required": true + }, + { + "description": "Update task input request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskInputRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ @@ -301,8 +343,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -340,8 +381,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -18962,6 +19002,14 @@ const docTemplate = `{ } } }, + "codersdk.UpdateTaskInputRequest": { + "type": "object", + "properties": { + "input": { + "type": "string" + } + } + }, "codersdk.UpdateTemplateACL": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 80d705f335f13..9a7503c6b60fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -198,8 +198,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -233,8 +232,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -247,6 +245,48 @@ } } }, + "/api/experimental/tasks/{user}/{task}/input": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Experimental"], + "summary": "Update AI task input", + "operationId": "update-task-input", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Task ID, or task name", + "name": "task", + "in": "path", + "required": true + }, + { + "description": "Update task input request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTaskInputRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ @@ -267,8 +307,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -304,8 +343,7 @@ }, { "type": "string", - "format": "uuid", - "description": "Task ID", + "description": "Task ID, or task name", "name": "task", "in": "path", "required": true @@ -17393,6 +17431,14 @@ } } }, + "codersdk.UpdateTaskInputRequest": { + "type": "object", + "properties": { + "input": { + "type": "string" + } + } + }, "codersdk.UpdateTemplateACL": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 872fc5359e383..684781d844bb1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1036,6 +1036,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractTaskParam(options.Database)) r.Get("/", api.taskGet) r.Delete("/", api.taskDelete) + r.Patch("/input", api.taskUpdateInput) r.Post("/send", api.taskSend) r.Get("/logs", api.taskLogs) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 87b5de36009bf..c761811e33c99 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5100,6 +5100,21 @@ func (q *querier) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg return q.db.UpdateTailnetPeerStatusByCoordinator(ctx, arg) } +func (q *querier) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + // An actor is allowed to update the prompt of a task if they have + // permission to update the task (same as UpdateTaskWorkspaceID). + task, err := q.db.GetTaskByID(ctx, arg.ID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, task.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + return q.db.UpdateTaskPrompt(ctx, arg) +} + func (q *querier) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { // An actor is allowed to update the workspace ID of a task if they are the // owner of the task and workspace or have the appropriate permissions. diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index fc98700c548f6..640b532d1cf6a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2442,6 +2442,22 @@ func (s *MethodTestSuite) TestTasks() { check.Args(arg).Asserts(task, policy.ActionUpdate, ws, policy.ActionUpdate).Returns(database.TaskTable{}) })) + s.Run("UpdateTaskPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + arg := database.UpdateTaskPromptParams{ + ID: task.ID, + Prompt: "Updated prompt text", + } + + // Create a copy of the task with the updated prompt + updatedTask := task + updatedTask.Prompt = arg.Prompt + + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().UpdateTaskPrompt(gomock.Any(), arg).Return(updatedTask.TaskTable(), nil).AnyTimes() + + check.Args(arg).Asserts(task, policy.ActionUpdate).Returns(updatedTask.TaskTable()) + })) s.Run("GetTaskByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { task := testutil.Fake(s.T(), faker, database.Task{}) task.WorkspaceID = uuid.NullUUID{UUID: uuid.New(), Valid: true} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d841315924a15..26553e77c20e9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3133,6 +3133,13 @@ func (m queryMetricsStore) UpdateTailnetPeerStatusByCoordinator(ctx context.Cont return r0 } +func (m queryMetricsStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.UpdateTaskPrompt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTaskPrompt").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { start := time.Now() r0, r1 := m.s.UpdateTaskWorkspaceID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 313bb988979a1..20dcf97bcbc07 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6725,6 +6725,21 @@ func (mr *MockStoreMockRecorder) UpdateTailnetPeerStatusByCoordinator(ctx, arg a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTailnetPeerStatusByCoordinator", reflect.TypeOf((*MockStore)(nil).UpdateTailnetPeerStatusByCoordinator), ctx, arg) } +// UpdateTaskPrompt mocks base method. +func (m *MockStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTaskPrompt", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateTaskPrompt indicates an expected call of UpdateTaskPrompt. +func (mr *MockStoreMockRecorder) UpdateTaskPrompt(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskPrompt", reflect.TypeOf((*MockStore)(nil).UpdateTaskPrompt), ctx, arg) +} + // UpdateTaskWorkspaceID mocks base method. func (m *MockStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index b3202342e3ffa..c0c0e2b40aeb9 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -132,11 +132,28 @@ func (w ConnectionLog) RBACObject() rbac.Object { return obj } +// TaskTable converts a Task to it's reduced version. +// A more generalized solution is to use json marshaling to +// consistently keep these two structs in sync. +// That would be a lot of overhead, and a more costly unit test is +// written to make sure these match up. +func (t Task) TaskTable() TaskTable { + return TaskTable{ + ID: t.ID, + OrganizationID: t.OrganizationID, + OwnerID: t.OwnerID, + Name: t.Name, + WorkspaceID: t.WorkspaceID, + TemplateVersionID: t.TemplateVersionID, + TemplateParameters: t.TemplateParameters, + Prompt: t.Prompt, + CreatedAt: t.CreatedAt, + DeletedAt: t.DeletedAt, + } +} + func (t Task) RBACObject() rbac.Object { - return rbac.ResourceTask. - WithID(t.ID). - WithOwner(t.OwnerID.String()). - InOrg(t.OrganizationID) + return t.TaskTable().RBACObject() } func (t TaskTable) RBACObject() rbac.Object { diff --git a/coderd/database/modelqueries_internal_test.go b/coderd/database/modelqueries_internal_test.go index 275ed947a3e4c..9e84324b72ee8 100644 --- a/coderd/database/modelqueries_internal_test.go +++ b/coderd/database/modelqueries_internal_test.go @@ -58,6 +58,45 @@ func TestWorkspaceTableConvert(t *testing.T) { "To resolve this, go to the 'func (w Workspace) WorkspaceTable()' and ensure all fields are converted.") } +// TestTaskTableConvert verifies all task fields are converted +// when reducing a `Task` to a `TaskTable`. +// This test is a guard rail to prevent developer oversight mistakes. +func TestTaskTableConvert(t *testing.T) { + t.Parallel() + + staticRandoms := &testutil.Random{ + String: func() string { return "foo" }, + Bool: func() bool { return true }, + Int: func() int64 { return 500 }, + Uint: func() uint64 { return 126 }, + Float: func() float64 { return 3.14 }, + Complex: func() complex128 { return 6.24 }, + Time: func() time.Time { + return time.Date(2020, 5, 2, 5, 19, 21, 30, time.UTC) + }, + } + + // Copies the approach taken by TestWorkspaceTableConvert. + // + // If you use 'PopulateStruct' to create 2 tasks, using the same + // "random" values for each type. Then they should be identical. + // + // So if 'task.TaskTable()' was missing any fields in its + // conversion, the comparison would fail. + + var task Task + err := testutil.PopulateStruct(&task, staticRandoms) + require.NoError(t, err) + + var subset TaskTable + err = testutil.PopulateStruct(&subset, staticRandoms) + require.NoError(t, err) + + require.Equal(t, task.TaskTable(), subset, + "'task.TaskTable()' is not missing at least 1 field when converting to 'TaskTable'. "+ + "To resolve this, go to the 'func (t Task) TaskTable()' and ensure all fields are converted.") +} + // TestAuditLogsQueryConsistency ensures that GetAuditLogsOffset and CountAuditLogs // have identical WHERE clauses to prevent filtering inconsistencies. // This test is a guard rail to prevent developer oversight mistakes. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3e5771f96de04..f06e858fdd94d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -682,6 +682,7 @@ type sqlcQuerier interface { UpdateProvisionerJobWithCompleteWithStartedAtByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg UpdateTailnetPeerStatusByCoordinatorParams) error + UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWorkspaceIDParams) (TaskTable, error) UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 21cb7b1874b5e..2f65019760902 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13283,6 +13283,40 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task return items, nil } +const updateTaskPrompt = `-- name: UpdateTaskPrompt :one +UPDATE + tasks +SET + prompt = $1::text +WHERE + id = $2::uuid + AND deleted_at IS NULL +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +` + +type UpdateTaskPromptParams struct { + Prompt string `db:"prompt" json:"prompt"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, updateTaskPrompt, arg.Prompt, arg.ID) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const updateTaskWorkspaceID = `-- name: UpdateTaskWorkspaceID :one UPDATE tasks diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 5cbbefd458881..32b048f35e455 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -64,3 +64,14 @@ WHERE id = @id::uuid AND deleted_at IS NULL RETURNING *; + + +-- name: UpdateTaskPrompt :one +UPDATE + tasks +SET + prompt = @prompt::text +WHERE + id = @id::uuid + AND deleted_at IS NULL +RETURNING *; diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index db8db8abca119..ea2297c663ca0 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -352,6 +352,28 @@ func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid. return nil } +// UpdateTaskInputRequest is used to update a task's input. +// +// Experimental: This type is experimental and may change in the future. +type UpdateTaskInputRequest struct { + Input string `json:"input"` +} + +// UpdateTaskInput updates the task's input. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID, req UpdateTaskInputRequest) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/tasks/%s/%s/input", user, id.String()), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // TaskLogType indicates the source of a task log entry. // // Experimental: This type is experimental and may change in the future. diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index 34ad224bd3538..206cf273b532a 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -90,10 +90,10 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} ### Parameters -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | ### Example responses @@ -121,10 +121,10 @@ curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{ta ### Parameters -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | ### Responses @@ -134,6 +134,43 @@ curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{ta To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update AI task input + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/input \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /api/experimental/tasks/{user}/{task}/input` + +> Body parameter + +```json +{ + "input": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | +| `body` | body | [codersdk.UpdateTaskInputRequest](schemas.md#codersdkupdatetaskinputrequest) | true | Update task input request | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get AI task logs ### Code samples @@ -149,10 +186,10 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} ### Parameters -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------------------------------------------------| -| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------------------------------------------------| +| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | +| `task` | path | string | true | Task ID, or task name | ### Example responses @@ -192,7 +229,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task | Name | In | Type | Required | Description | |--------|------|----------------------------------------------------------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `task` | path | string(uuid) | true | Task ID | +| `task` | path | string | true | Task ID, or task name | | `body` | body | [codersdk.TaskSendRequest](schemas.md#codersdktasksendrequest) | true | Task input request | ### Responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0f43255ad60c7..d564725577d2b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -9155,6 +9155,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |---------|-----------------|----------|--------------|-------------| | `roles` | array of string | false | | | +## codersdk.UpdateTaskInputRequest + +```json +{ + "input": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|--------|----------|--------------|-------------| +| `input` | string | false | | | + ## codersdk.UpdateTemplateACL ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c2c94aa314b3d..07ffa2d679ead 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5358,6 +5358,16 @@ export interface UpdateRoles { readonly roles: readonly string[]; } +// From codersdk/aitasks.go +/** + * UpdateTaskInputRequest is used to update a task's input. + * + * Experimental: This type is experimental and may change in the future. + */ +export interface UpdateTaskInputRequest { + readonly input: string; +} + // From codersdk/templates.go export interface UpdateTemplateACL { /**