From 47b8ca940c1633927e63fbb5a52256a42a7db115 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 12 Feb 2026 09:59:53 +0200 Subject: [PATCH] feat: add an endpoint to manually resume a coder task (#21948) Closes https://github.com/coder/internal/issues/1262. This PR adds: * the `POST /api/experimental/tasks/{user}/{task}/resume` endpoint * follows conventions from https://github.com/coder/internal/issues/1261 * sets the build reason to `task_resume` * a task that is not paused (ie. is already running), cannot be resumed. --- coderd/aitasks.go | 87 +++++++++ coderd/aitasks_test.go | 338 ++++++++++++++++++++++++++++++++- coderd/apidoc/docs.go | 56 +++++- coderd/apidoc/swagger.json | 52 ++++- coderd/coderd.go | 1 + codersdk/aitasks.go | 23 +++ codersdk/workspaces.go | 1 + docs/reference/api/schemas.md | 225 +++++++++++++++++++++- docs/reference/api/tasks.md | 32 ++++ site/src/api/typesGenerated.ts | 10 + 10 files changed, 817 insertions(+), 8 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e71df52935..4e0e13289f 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1304,3 +1304,90 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) { WorkspaceBuild: &build, }) } + +// @Summary Resume task +// @ID resume-task +// @Security CoderSessionToken +// @Accept json +// @Tags Tasks +// @Param user path string true "Username, user ID, or 'me' for the authenticated user" +// @Param task path string true "Task ID" format(uuid) +// @Success 202 {object} codersdk.ResumeTaskResponse +// @Router /tasks/{user}/{task}/resume [post] +func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + task = httpmw.TaskParam(r) + ) + + if !task.WorkspaceID.Valid { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Task does not have a workspace.", + }) + return + } + + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task workspace.", + Detail: err.Error(), + }) + return + } + + latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task workspace build.", + Detail: err.Error(), + }) + return + } + job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task workspace build job.", + Detail: err.Error(), + }) + return + } + workspaceStatus := codersdk.ConvertWorkspaceStatus( + codersdk.ProvisionerJobStatus(job.JobStatus), + codersdk.WorkspaceTransition(latestBuild.Transition), + ) + if workspaceStatus == codersdk.WorkspaceStatusRunning { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Task workspace is already running.", + Detail: fmt.Sprintf("Workspace status is %q.", workspaceStatus), + }) + return + } + + buildReq := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + Reason: codersdk.CreateWorkspaceBuildReasonTaskResume, + } + build, err := api.postWorkspaceBuildsInternal( + ctx, + apiKey, + workspace, + buildReq, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + if err != nil { + httperror.WriteWorkspaceBuildError(ctx, rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{ + WorkspaceBuild: &build, + }) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 2bd684de21..3eb3189915 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -2512,13 +2512,20 @@ func TestPauseTask(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) resp, err := client.PauseTask(ctx, codersdk.Me, task.ID) + + // Verify that the request was accepted correctly: require.NoError(t, err) build := *resp.WorkspaceBuild - require.NotNil(t, build) require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition) require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID) require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskManualPause), string(build.Reason)) + + // Verify that the accepted request was processed correctly: + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status) }) t.Run("Non-owner role access", func(t *testing.T) { @@ -2781,3 +2788,332 @@ func TestPauseTask(t *testing.T) { require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) }) } + +func TestResumeTask(t *testing.T) { + t.Parallel() + + setupClient := func(t *testing.T, db database.Store, ps pubsub.Pubsub, authorizer rbac.Authorizer) *codersdk.Client { + t.Helper() + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, + Authorizer: authorizer, + IncludeProvisionerDaemon: true, + }) + return client + } + + setupWorkspaceTask := func(t *testing.T, db database.Store, user codersdk.CreateFirstUserResponse) (database.Task, uuid.UUID) { + t.Helper() + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithTask(database.TaskTable{ + Prompt: "resume me", + }, nil).Do() + return workspaceBuild.Task, workspaceBuild.Workspace.ID + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + 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, + ProvisionGraph: []*proto.Response{ + {Type: &proto.Response_Graph{Graph: &proto.GraphComplete{ + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "resume me", + }) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) + + resumeResp, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + build := *resumeResp.WorkspaceBuild + require.Equal(t, codersdk.WorkspaceTransitionStart, build.Transition) + require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID) + require.Equal(t, workspace.LatestBuild.BuildNumber+2, build.BuildNumber) + require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskResume), string(build.Reason)) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + }) + + t.Run("Resume a task that is not paused", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + client := setupClient(t, db, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }). + WithTask(database.TaskTable{ + Prompt: "pause me", + }, nil). + Succeeded(). + Do() + + _, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("Task not found", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.ResumeTask(ctx, codersdk.Me, uuid.New()) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("Task lookup forbidden", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + auth := &coderdtest.FakeAuthorizer{ + ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error { + if action == policy.ActionRead && object.Type == rbac.ResourceTask.Type { + return rbac.UnauthorizedError{} + } + return nil + }, + } + client := setupClient(t, db, ps, auth) + user := coderdtest.CreateFirstUser(t, client) + task, _ := setupWorkspaceTask(t, db, user) + + _, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("Workspace lookup forbidden", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + auth := &coderdtest.FakeAuthorizer{ + ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error { + if action == policy.ActionRead && object.Type == rbac.ResourceWorkspace.Type { + return rbac.UnauthorizedError{} + } + return nil + }, + } + client := setupClient(t, db, ps, auth) + user := coderdtest.CreateFirstUser(t, client) + task, _ := setupWorkspaceTask(t, db, user) + + _, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("No Workspace for Task", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + client := setupClient(t, db, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Do() + task := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + TemplateVersionID: workspaceBuild.Build.TemplateVersionID, + Prompt: "no workspace", + }) + + _, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) + require.Equal(t, "Task does not have a workspace.", apiErr.Message) + }) + + t.Run("Workspace not found", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + var workspaceID uuid.UUID + wrapped := aiTaskStoreWrapper{ + Store: db, + getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) { + if id == workspaceID && id != uuid.Nil { + return database.Workspace{}, sql.ErrNoRows + } + return db.GetWorkspaceByID(ctx, id) + }, + } + client := setupClient(t, wrapped, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + task, workspaceIDValue := setupWorkspaceTask(t, db, user) + workspaceID = workspaceIDValue + + _, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("Workspace lookup internal error", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + var workspaceID uuid.UUID + wrapped := aiTaskStoreWrapper{ + Store: db, + getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) { + if id == workspaceID && id != uuid.Nil { + return database.Workspace{}, xerrors.New("boom") + } + return db.GetWorkspaceByID(ctx, id) + }, + } + client := setupClient(t, wrapped, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + task, workspaceIDValue := setupWorkspaceTask(t, db, user) + workspaceID = workspaceIDValue + + _, err := client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) + require.Equal(t, "Internal error fetching task workspace.", apiErr.Message) + }) + + t.Run("Build Forbidden", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + auth := &coderdtest.FakeAuthorizer{ + ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error { + if action == policy.ActionWorkspaceStart && object.Type == rbac.ResourceWorkspace.Type { + return rbac.UnauthorizedError{} + } + return nil + }, + } + client := setupClient(t, db, ps, auth) + user := coderdtest.CreateFirstUser(t, client) + task, _ := setupWorkspaceTask(t, db, user) + + pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) + + _, err = client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) + + t.Run("Job already in progress", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + client := setupClient(t, db, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }). + WithTask(database.TaskTable{ + Prompt: "resume me", + }, nil). + Starting(). + Do() + + _, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("Build Internal Error", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + wrapped := aiTaskStoreWrapper{ + Store: db, + } + + client := setupClient(t, &wrapped, ps, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: []*proto.Response{ + {Type: &proto.Response_Graph{Graph: &proto.GraphComplete{ + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "resume me", + }) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) + + // Induce a transient failure in the database after the task has been paused. + wrapped.insertWorkspaceBuild = func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { + return xerrors.New("insert failed") + } + _, err = client.ResumeTask(ctx, codersdk.Me, task.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 00f5078153..c8c09b06b0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5866,6 +5866,48 @@ const docTemplate = `{ } } }, + "/tasks/{user}/{task}/resume": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Tasks" + ], + "summary": "Resume task", + "operationId": "resume-task", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Task ID", + "name": "task", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/codersdk.ResumeTaskResponse" + } + } + } + } + }, "/tasks/{user}/{task}/send": { "post": { "security": [ @@ -14145,7 +14187,8 @@ const docTemplate = `{ "ssh_connection", "vscode_connection", "jetbrains_connection", - "task_manual_pause" + "task_manual_pause", + "task_resume" ], "x-enum-varnames": [ "CreateWorkspaceBuildReasonDashboard", @@ -14153,7 +14196,8 @@ const docTemplate = `{ "CreateWorkspaceBuildReasonSSHConnection", "CreateWorkspaceBuildReasonVSCodeConnection", "CreateWorkspaceBuildReasonJetbrainsConnection", - "CreateWorkspaceBuildReasonTaskManualPause" + "CreateWorkspaceBuildReasonTaskManualPause", + "CreateWorkspaceBuildReasonTaskResume" ] }, "codersdk.CreateWorkspaceBuildRequest": { @@ -18235,6 +18279,14 @@ const docTemplate = `{ } } }, + "codersdk.ResumeTaskResponse": { + "type": "object", + "properties": { + "workspace_build": { + "$ref": "#/definitions/codersdk.WorkspaceBuild" + } + } + }, "codersdk.RetentionConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index acabf1a22b..eb70c06c0b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5185,6 +5185,44 @@ } } }, + "/tasks/{user}/{task}/resume": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Tasks"], + "summary": "Resume task", + "operationId": "resume-task", + "parameters": [ + { + "type": "string", + "description": "Username, user ID, or 'me' for the authenticated user", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Task ID", + "name": "task", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/codersdk.ResumeTaskResponse" + } + } + } + } + }, "/tasks/{user}/{task}/send": { "post": { "security": [ @@ -12701,7 +12739,8 @@ "ssh_connection", "vscode_connection", "jetbrains_connection", - "task_manual_pause" + "task_manual_pause", + "task_resume" ], "x-enum-varnames": [ "CreateWorkspaceBuildReasonDashboard", @@ -12709,7 +12748,8 @@ "CreateWorkspaceBuildReasonSSHConnection", "CreateWorkspaceBuildReasonVSCodeConnection", "CreateWorkspaceBuildReasonJetbrainsConnection", - "CreateWorkspaceBuildReasonTaskManualPause" + "CreateWorkspaceBuildReasonTaskManualPause", + "CreateWorkspaceBuildReasonTaskResume" ] }, "codersdk.CreateWorkspaceBuildRequest": { @@ -16647,6 +16687,14 @@ } } }, + "codersdk.ResumeTaskResponse": { + "type": "object", + "properties": { + "workspace_build": { + "$ref": "#/definitions/codersdk.WorkspaceBuild" + } + } + }, "codersdk.RetentionConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index ffd5d6699f..6a85df1c3d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1079,6 +1079,7 @@ func New(options *Options) *API { r.Post("/send", api.taskSend) r.Get("/logs", api.taskLogs) r.Post("/pause", api.pauseTask) + r.Post("/resume", api.resumeTask) }) }) }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 9dda558fcf..e600e1c1c8 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -354,6 +354,29 @@ func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (Paus return resp, nil } +// ResumeTaskResponse represents the response from resuming a task. +type ResumeTaskResponse struct { + WorkspaceBuild *WorkspaceBuild `json:"workspace_build"` +} + +func (c *Client) ResumeTask(ctx context.Context, user string, id uuid.UUID) (ResumeTaskResponse, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/resume", user, id.String()), nil) + if err != nil { + return ResumeTaskResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusAccepted { + return ResumeTaskResponse{}, ReadBodyAsError(res) + } + + var resp ResumeTaskResponse + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return ResumeTaskResponse{}, err + } + + return resp, nil +} + // TaskLogType indicates the source of a task log entry. type TaskLogType string diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 27a04dbfdf..4a93014c79 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -110,6 +110,7 @@ const ( CreateWorkspaceBuildReasonVSCodeConnection CreateWorkspaceBuildReason = "vscode_connection" CreateWorkspaceBuildReasonJetbrainsConnection CreateWorkspaceBuildReason = "jetbrains_connection" CreateWorkspaceBuildReasonTaskManualPause CreateWorkspaceBuildReason = "task_manual_pause" + CreateWorkspaceBuildReasonTaskResume CreateWorkspaceBuildReason = "task_resume" ) // CreateWorkspaceBuildRequest provides options to update the latest workspace build. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 99af6ddb1a..16f769d069 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2184,9 +2184,9 @@ This is required on creation to enable a user-flow of validating a template work #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------| -| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `vscode_connection` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------| +| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `task_resume`, `vscode_connection` | ## codersdk.CreateWorkspaceBuildRequest @@ -7522,6 +7522,225 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `message` | string | false | | Message is an actionable message that depicts actions the request took. These messages should be fully formed sentences with proper punctuation. Examples: - "A user has been created." - "Failed to create a user." | | `validations` | array of [codersdk.ValidationError](#codersdkvalidationerror) | false | | Validations are form field-specific friendly error messages. They will be shown on a form field in the UI. These can also be used to add additional context if there is a set of errors in the primary 'Message'. | +## codersdk.ResumeTaskResponse + +```json +{ + "workspace_build": { + "build_number": 0, + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "deadline": "2019-08-24T14:15:22Z", + "has_ai_task": true, + "has_external_agent": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", + "initiator_name": "string", + "job": { + "available_workers": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "canceled_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "error": "string", + "error_code": "REQUIRED_TEMPLATE_VARIABLES", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", + "input": { + "error": "string", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478" + }, + "logs_overflowed": true, + "metadata": { + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "template_version_name": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string" + }, + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "queue_position": 0, + "queue_size": 0, + "started_at": "2019-08-24T14:15:22Z", + "status": "pending", + "tags": { + "property1": "string", + "property2": "string" + }, + "type": "template_version_import", + "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b", + "worker_name": "string" + }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, + "max_deadline": "2019-08-24T14:15:22Z", + "reason": "initiator", + "resources": [ + { + "agents": [ + { + "api_version": "string", + "apps": [ + { + "command": "string", + "display_name": "string", + "external": true, + "group": "string", + "health": "disabled", + "healthcheck": { + "interval": 0, + "threshold": 0, + "url": "string" + }, + "hidden": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "open_in": "slim-window", + "sharing_level": "owner", + "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], + "subdomain": true, + "subdomain_name": "string", + "tooltip": "string", + "url": "string" + } + ], + "architecture": "string", + "connection_timeout_seconds": 0, + "created_at": "2019-08-24T14:15:22Z", + "directory": "string", + "disconnected_at": "2019-08-24T14:15:22Z", + "display_apps": [ + "vscode" + ], + "environment_variables": { + "property1": "string", + "property2": "string" + }, + "expanded_directory": "string", + "first_connected_at": "2019-08-24T14:15:22Z", + "health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "instance_id": "string", + "last_connected_at": "2019-08-24T14:15:22Z", + "latency": { + "property1": { + "latency_ms": 0, + "preferred": true + }, + "property2": { + "latency_ms": 0, + "preferred": true + } + }, + "lifecycle_state": "created", + "log_sources": [ + { + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1" + } + ], + "logs_length": 0, + "logs_overflowed": true, + "name": "string", + "operating_system": "string", + "parent_id": { + "uuid": "string", + "valid": true + }, + "ready_at": "2019-08-24T14:15:22Z", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "scripts": [ + { + "cron": "string", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "log_path": "string", + "log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a", + "run_on_start": true, + "run_on_stop": true, + "script": "string", + "start_blocks_login": true, + "timeout": 0 + } + ], + "started_at": "2019-08-24T14:15:22Z", + "startup_script_behavior": "blocking", + "status": "connecting", + "subsystems": [ + "envbox" + ], + "troubleshooting_url": "string", + "updated_at": "2019-08-24T14:15:22Z", + "version": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "hide": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "metadata": [ + { + "key": "string", + "sensitive": true, + "value": "string" + } + ], + "name": "string", + "type": "string", + "workspace_transition": "start" + } + ], + "status": "pending", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "template_version_name": "string", + "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", + "transition": "start", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_avatar_url": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_name": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|----------------------------------------------------|----------|--------------|-------------| +| `workspace_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | + ## codersdk.RetentionConfig ```json diff --git a/docs/reference/api/tasks.md b/docs/reference/api/tasks.md index f2fb89df06..60875e5a0f 100644 --- a/docs/reference/api/tasks.md +++ b/docs/reference/api/tasks.md @@ -397,6 +397,38 @@ curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/pause \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Resume task + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/resume \ + -H 'Accept: */*' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /tasks/{user}/{task}/resume` + +### 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 | + +### Example responses + +> 202 Response + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------------|-------------|----------------------------------------------------------------------| +| 202 | [Accepted](https://tools.ietf.org/html/rfc7231#section-6.3.3) | Accepted | [codersdk.ResumeTaskResponse](schemas.md#codersdkresumetaskresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Send input to AI task ### Code samples diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e204b7eb5c..273254428f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1426,6 +1426,7 @@ export type CreateWorkspaceBuildReason = | "jetbrains_connection" | "ssh_connection" | "task_manual_pause" + | "task_resume" | "vscode_connection"; export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [ @@ -1434,6 +1435,7 @@ export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [ "jetbrains_connection", "ssh_connection", "task_manual_pause", + "task_resume", "vscode_connection", ]; @@ -4355,6 +4357,14 @@ export interface Response { readonly validations?: readonly ValidationError[]; } +// From codersdk/aitasks.go +/** + * ResumeTaskResponse represents the response from resuming a task. + */ +export interface ResumeTaskResponse { + readonly workspace_build: WorkspaceBuild | null; +} + // From codersdk/deployment.go /** * RetentionConfig contains configuration for data retention policies.