Files
coder/coderd/aitasks_test.go
T
Mathias Fredriksson 75f5b60eb6 fix: return 409 Conflict instead of 502 when task agent is busy (#23424)
The "Task app is not ready to accept input" error occurs when the
agent responds successfully but its status is not "stable" (e.g.
"running"). This is a state conflict, not a gateway error. 502 was
semantically wrong because the gateway communication succeeded.

409 Conflict is correct because the request conflicts with the
agent's current state. This is consistent with how
authAndDoWithTaskAppClient already returns 409 for pending,
initializing, and paused agent states.
2026-03-23 09:52:34 +00:00

3197 lines
113 KiB
Go

package coderd_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
agentapisdk "github.com/coder/agentapi-sdk-go"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd"
"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/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"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/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// createTaskInState is a helper to create a task in the desired state.
// It returns a function that takes context, test, and status, and returns the task.
// The caller is responsible for setting up the database, owner, and user.
func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID, userID uuid.UUID) func(context.Context, *testing.T, database.TaskStatus) database.Task {
return func(ctx context.Context, t *testing.T, status database.TaskStatus) database.Task {
ctx = dbauthz.As(ctx, ownerSubject)
builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: ownerOrgID,
OwnerID: userID,
}).
WithTask(database.TaskTable{
OrganizationID: ownerOrgID,
OwnerID: userID,
}, nil)
switch status {
case database.TaskStatusPending:
builder = builder.Pending()
case database.TaskStatusInitializing:
builder = builder.Starting()
case database.TaskStatusActive:
// Default builder produces a succeeded start build.
// Post-processing below sets agent and app to active.
case database.TaskStatusPaused:
builder = builder.Seed(database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStop,
})
case database.TaskStatusError:
// For error state, create a completed build then manipulate app health.
default:
require.Fail(t, "unsupported task status in test helper", "status: %s", status)
}
resp := builder.Do()
// Post-process by manipulating agent and app state.
if status == database.TaskStatusActive || status == database.TaskStatusError {
// Set agent to ready state so agent_status returns 'active'.
err := db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: resp.Agents[0].ID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
})
require.NoError(t, err)
apps, err := db.GetWorkspaceAppsByAgentID(ctx, resp.Agents[0].ID)
require.NoError(t, err)
require.Len(t, apps, 1, "expected exactly one app for task")
appHealth := database.WorkspaceAppHealthHealthy
if status == database.TaskStatusError {
appHealth = database.WorkspaceAppHealthUnhealthy
}
err = db.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{
ID: apps[0].ID,
Health: appHealth,
})
require.NoError(t, err)
}
return resp.Task
}
}
type aiTaskStoreWrapper struct {
database.Store
getWorkspaceByID func(ctx context.Context, id uuid.UUID) (database.Workspace, error)
insertWorkspaceBuild func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error
}
func (s aiTaskStoreWrapper) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
if s.getWorkspaceByID != nil {
return s.getWorkspaceByID(ctx, id)
}
return s.Store.GetWorkspaceByID(ctx, id)
}
func (s aiTaskStoreWrapper) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
if s.insertWorkspaceBuild != nil {
return s.insertWorkspaceBuild(ctx, arg)
}
return s.Store.InsertWorkspaceBuild(ctx, arg)
}
func (s aiTaskStoreWrapper) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return fn(aiTaskStoreWrapper{
Store: tx,
getWorkspaceByID: s.getWorkspaceByID,
insertWorkspaceBuild: s.insertWorkspaceBuild,
})
}, opts)
}
func TestTasks(t *testing.T) {
t.Parallel()
type aiTemplateOpts struct {
appURL string
authToken string
}
type aiTemplateOpt func(*aiTemplateOpts)
withSidebarURL := func(url string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.appURL = url } }
withAgentToken := func(token string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.authToken = token } }
createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, opts ...aiTemplateOpt) codersdk.Template {
t.Helper()
opt := aiTemplateOpts{
authToken: uuid.New().String(),
}
for _, o := range opts {
o(&opt)
}
// Create a template version that supports AI tasks.
taskAppID := uuid.New()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionGraph: []*proto.Response{
{
Type: &proto.Response_Graph{
Graph: &proto.GraphComplete{
HasAiTasks: true,
Resources: []*proto.Resource{
{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: opt.authToken,
},
Apps: []*proto.App{
{
Id: taskAppID.String(),
Slug: "task-app",
DisplayName: "Task App",
Url: opt.appURL,
},
},
},
},
},
},
AiTasks: []*proto.AITask{
{
AppId: taskAppID.String(),
},
},
},
},
},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
return template
}
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
template := createAITemplate(t, client, user)
// Create a task with a specific prompt using the new data model.
wantPrompt := "build me a web app"
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: wantPrompt,
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
// Wait for the workspace to be built.
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
if assert.True(t, workspace.TaskID.Valid, "task id should be set on workspace") {
assert.Equal(t, task.ID, workspace.TaskID.UUID, "workspace task id should match")
}
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// List tasks via experimental API and verify the prompt and status mapping.
tasks, err := client.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me})
require.NoError(t, err)
got, ok := slice.Find(tasks, func(t codersdk.Task) bool { return t.ID == task.ID })
require.True(t, ok, "task should be found in the list")
assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the input")
assert.Equal(t, task.WorkspaceID.UUID, got.WorkspaceID.UUID, "workspace id should match")
assert.Equal(t, task.WorkspaceName, got.WorkspaceName, "workspace name should match")
// Status should be populated via the tasks_with_status view.
assert.NotEmpty(t, got.Status, "task status should not be empty")
assert.NotEmpty(t, got.WorkspaceStatus, "workspace status should not be empty")
})
t.Run("Get", func(t *testing.T) {
t.Parallel()
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
ctx = testutil.Context(t, testutil.WaitLong)
user = coderdtest.CreateFirstUser(t, client)
anotherUser, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
template = createAITemplate(t, client, user)
wantPrompt = "review my code"
)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: wantPrompt,
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
// Get the workspace and wait for it to be ready.
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
}
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
ws = coderdtest.MustWorkspace(t, client, task.WorkspaceID.UUID)
// Assert invariant: the workspace has exactly one resource with one agent with one app.
require.Len(t, ws.LatestBuild.Resources, 1)
require.Len(t, ws.LatestBuild.Resources[0].Agents, 1)
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
taskAppID := ws.LatestBuild.Resources[0].Agents[0].Apps[0].ID
// Insert an app status for the workspace
_, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
ID: uuid.New(),
WorkspaceID: task.WorkspaceID.UUID,
CreatedAt: dbtime.Now(),
AgentID: agentID,
AppID: taskAppID,
State: database.WorkspaceAppStatusStateComplete,
Message: "all done",
})
require.NoError(t, err)
// Fetch the task by ID via experimental API and verify fields.
updated, err := client.TaskByID(ctx, task.ID)
require.NoError(t, err)
assert.Equal(t, task.ID, updated.ID, "task ID should match")
assert.Equal(t, task.Name, updated.Name, "task name should match")
assert.Equal(t, wantPrompt, updated.InitialPrompt, "task prompt should match the input")
assert.Equal(t, task.WorkspaceID.UUID, updated.WorkspaceID.UUID, "workspace id should match")
assert.Equal(t, task.WorkspaceName, updated.WorkspaceName, "workspace name should match")
assert.Equal(t, ws.LatestBuild.BuildNumber, updated.WorkspaceBuildNumber, "workspace build number should match")
assert.Equal(t, agentID, updated.WorkspaceAgentID.UUID, "workspace agent id should match")
assert.Equal(t, taskAppID, updated.WorkspaceAppID.UUID, "workspace app id should match")
assert.NotEmpty(t, updated.WorkspaceStatus, "task status should not be empty")
// Fetch the task by name and verify the same result
byName, err := client.TaskByOwnerAndName(ctx, codersdk.Me, task.Name)
require.NoError(t, err)
require.Equal(t, byName, updated)
// Another member user should not be able to fetch the task
_, err = anotherUser.TaskByID(ctx, task.ID)
require.Error(t, err, "fetching task should fail by ID for another member user")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
// Also test by name
_, err = anotherUser.TaskByOwnerAndName(ctx, task.OwnerName, task.Name)
require.Error(t, err, "fetching task should fail by name for another member user")
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
// Stop the workspace
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
// Verify that the previous status still remains
updated, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
assert.NotNil(t, updated.CurrentState, "current state should not be nil")
assert.Equal(t, "all done", updated.CurrentState.Message)
assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State)
previousCurrentState := updated.CurrentState
// Start the workspace again
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart)
// Verify that the status from the previous build has been cleared
// and replaced by the agent initialization status.
updated, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
assert.NotEqual(t, previousCurrentState, updated.CurrentState)
assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State)
assert.NotEqual(t, "all done", updated.CurrentState.Message)
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
}
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
err = client.DeleteTask(ctx, "me", task.ID)
require.NoError(t, err, "delete task request should be accepted")
// Poll until the workspace is deleted.
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
dws, derr := client.DeletedWorkspace(ctx, task.WorkspaceID.UUID)
if !assert.NoError(t, derr, "expected to fetch deleted workspace before deadline") {
return false
}
t.Logf("workspace latest_build status: %q", dws.LatestBuild.Status)
return dws.LatestBuild.Status == codersdk.WorkspaceStatusDeleted
}, testutil.IntervalMedium, "workspace should be deleted before deadline")
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
err := client.DeleteTask(ctx, "me", uuid.New())
var sdkErr *codersdk.Error
require.Error(t, err, "expected an error for non-existent task")
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, 404, sdkErr.StatusCode())
})
t.Run("NotTaskWorkspace", 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)
// Create a template without AI tasks support and a workspace from it.
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ws := coderdtest.CreateWorkspace(t, client, template.ID)
if assert.False(t, ws.TaskID.Valid, "task id should not be set on non-task workspace") {
assert.Zero(t, ws.TaskID, "non-task workspace task id should be empty")
}
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
err := client.DeleteTask(ctx, "me", ws.ID)
var sdkErr *codersdk.Error
require.Error(t, err, "expected an error for non-task workspace delete via tasks endpoint")
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, 404, sdkErr.StatusCode())
})
t.Run("UnauthorizedUserCannotDeleteOthersTask", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
// Owner's AI-capable template and workspace (task).
template := createAITemplate(t, client, owner)
ctx := testutil.Context(t, testutil.WaitShort)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me not",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Another regular org member without elevated permissions.
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Attempt to delete the owner's task as a non-owner without permissions.
err = otherClient.DeleteTask(ctx, "me", task.ID)
var authErr *codersdk.Error
require.Error(t, err, "expected an authorization error when deleting another user's task")
require.ErrorAs(t, err, &authErr)
// Accept either 403 or 404 depending on authz behavior.
if authErr.StatusCode() != 403 && authErr.StatusCode() != 404 {
t.Fatalf("unexpected status code: %d (expected 403 or 404)", authErr.StatusCode())
}
})
t.Run("DeletedWorkspace", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Mark the workspace as deleted directly in the database, bypassing provisionerd.
require.NoError(t, db.UpdateWorkspaceDeletedByID(dbauthz.AsProvisionerd(ctx), database.UpdateWorkspaceDeletedByIDParams{
ID: ws.ID,
Deleted: true,
}))
// We should still be able to fetch the task if its workspace was deleted.
// Provisionerdserver will attempt delete the related task when deleting a workspace.
// This test ensures that we can still handle the case where, for some reason, the
// task has not been marked as deleted, but the workspace has.
task, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err, "fetching a task should still work if its related workspace is deleted")
err = client.DeleteTask(ctx, task.OwnerID.String(), task.ID)
require.NoError(t, err, "should be possible to delete a task with no workspace")
})
t.Run("SnapshotCleanupOnDeletion", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
userObj, err := client.User(ctx, user.UserID.String())
require.NoError(t, err)
userSubject := coderdtest.AuthzUserSubject(userObj)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me with snapshot",
})
require.NoError(t, err)
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Create a snapshot for the task.
snapshotJSON := `{"format":"agentapi","data":{"messages":[{"role":"user","content":"test"}]}}`
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, userSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: dbtime.Now(),
})
require.NoError(t, err)
// Verify snapshot exists.
_, err = db.GetTaskSnapshot(dbauthz.As(ctx, userSubject), task.ID)
require.NoError(t, err)
// Delete the task.
err = client.DeleteTask(ctx, "me", task.ID)
require.NoError(t, err, "delete task request should be accepted")
// Verify snapshot no longer exists.
_, err = db.GetTaskSnapshot(dbauthz.As(ctx, userSubject), task.ID)
require.ErrorIs(t, err, sql.ErrNoRows, "snapshot should be deleted with task")
})
t.Run("DeletionWithoutSnapshot", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
userObj, err := client.User(ctx, user.UserID.String())
require.NoError(t, err)
userSubject := coderdtest.AuthzUserSubject(userObj)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me without snapshot",
})
require.NoError(t, err)
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Verify no snapshot exists.
_, err = db.GetTaskSnapshot(dbauthz.As(ctx, userSubject), task.ID)
require.ErrorIs(t, err, sql.ErrNoRows, "snapshot should not exist initially")
// Delete the task (should succeed even without snapshot).
err = client.DeleteTask(ctx, "me", task.ID)
require.NoError(t, err, "delete task should succeed even without snapshot")
})
t.Run("PreservesOtherTaskSnapshots", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
userObj, err := client.User(ctx, user.UserID.String())
require.NoError(t, err)
userSubject := coderdtest.AuthzUserSubject(userObj)
// Create task A.
taskA, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "task A",
})
require.NoError(t, err)
wsA, err := client.Workspace(ctx, taskA.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wsA.LatestBuild.ID)
// Create task B.
taskB, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "task B",
})
require.NoError(t, err)
wsB, err := client.Workspace(ctx, taskB.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wsB.LatestBuild.ID)
// Create snapshots for both tasks.
snapshotJSONA := `{"format":"agentapi","data":{"messages":[{"role":"user","content":"task A"}]}}`
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, userSubject), database.UpsertTaskSnapshotParams{
TaskID: taskA.ID,
LogSnapshot: json.RawMessage(snapshotJSONA),
LogSnapshotCreatedAt: dbtime.Now(),
})
require.NoError(t, err)
snapshotJSONB := `{"format":"agentapi","data":{"messages":[{"role":"user","content":"task B"}]}}`
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, userSubject), database.UpsertTaskSnapshotParams{
TaskID: taskB.ID,
LogSnapshot: json.RawMessage(snapshotJSONB),
LogSnapshotCreatedAt: dbtime.Now(),
})
require.NoError(t, err)
// Delete task A.
err = client.DeleteTask(ctx, "me", taskA.ID)
require.NoError(t, err, "delete task A should succeed")
// Verify task A's snapshot is removed.
_, err = db.GetTaskSnapshot(dbauthz.As(ctx, userSubject), taskA.ID)
require.ErrorIs(t, err, sql.ErrNoRows, "task A snapshot should be deleted")
// Verify task B's snapshot still exists.
_, err = db.GetTaskSnapshot(dbauthz.As(ctx, userSubject), taskB.ID)
require.NoError(t, err, "task B snapshot should still exist")
})
t.Run("DeletingTaskWorkspaceDeletesTask", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "delete me",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
}
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// When; the task workspace is deleted
coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete)
// Then: the task associated with the workspace is also deleted
_, err = client.TaskByID(ctx, task.ID)
require.Error(t, err, "expected an error fetching the task")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr, "expected a codersdk.Error")
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
})
t.Run("Send", func(t *testing.T) {
t.Parallel()
t.Run("IntegrationOK", func(t *testing.T) {
t.Parallel()
statusResponse := agentapisdk.StatusStable
// Start a fake AgentAPI that accepts GET /status and POST /message.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/status" {
w.Header().Set("Content-Type", "application/json")
resp := agentapisdk.GetStatusResponse{
Status: statusResponse,
}
respBytes, err := json.Marshal(resp)
assert.NoError(t, err)
w.WriteHeader(http.StatusOK)
w.Write(respBytes)
return
}
if r.Method == http.MethodPost && r.URL.Path == "/message" {
w.Header().Set("Content-Type", "application/json")
b, _ := io.ReadAll(r.Body)
expectedReq := agentapisdk.PostMessageParams{
Content: "Hello, Agent!",
Type: agentapisdk.MessageTypeUser,
}
expectedBytes, _ := json.Marshal(expectedReq)
assert.Equal(t, string(expectedBytes), string(b), "expected message content")
resp := agentapisdk.PostMessageResponse{Ok: true}
respBytes, err := json.Marshal(resp)
assert.NoError(t, err)
w.WriteHeader(http.StatusOK)
w.Write(respBytes)
return
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
// Create an AI-capable template whose sidebar app points to our fake AgentAPI.
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
ctx = testutil.Context(t, testutil.WaitLong)
owner = coderdtest.CreateFirstUser(t, client)
userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
agentAuthToken = uuid.NewString()
template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL))
)
task, err := userClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "send me food",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
// Get the workspace and wait for it to be ready.
ws, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID)
// Fetch the task by ID via experimental API and verify fields.
task, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
require.NotZero(t, task.WorkspaceBuildNumber)
require.True(t, task.WorkspaceAgentID.Valid)
require.True(t, task.WorkspaceAppID.Valid)
// Insert an app status for the workspace
_, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
ID: uuid.New(),
WorkspaceID: task.WorkspaceID.UUID,
CreatedAt: dbtime.Now(),
AgentID: task.WorkspaceAgentID.UUID,
AppID: task.WorkspaceAppID.UUID,
State: database.WorkspaceAppStatusStateComplete,
Message: "all done",
})
require.NoError(t, err)
// Start a fake agent so the workspace agent is connected before sending the message.
agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(agentAuthToken))
_ = agenttest.New(t, userClient.URL, agentAuthToken, func(o *agent.Options) {
o.Client = agentClient
})
coderdtest.NewWorkspaceAgentWaiter(t, userClient, ws.ID).WaitFor(coderdtest.AgentsReady)
// Fetch the task by ID via experimental API and verify fields.
task, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
// Make the sidebar app unhealthy initially.
err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
ID: task.WorkspaceAppID.UUID,
Health: database.WorkspaceAppHealthUnhealthy,
})
require.NoError(t, err)
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello, Agent!",
})
require.Error(t, err, "wanted error due to unhealthy sidebar app")
// Make the sidebar app healthy.
err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
ID: task.WorkspaceAppID.UUID,
Health: database.WorkspaceAppHealthHealthy,
})
require.NoError(t, err)
statusResponse = agentapisdk.AgentStatus("bad")
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello, Agent!",
})
require.Error(t, err, "wanted error due to bad status")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "not ready to accept input")
statusResponse = agentapisdk.StatusStable
//nolint:tparallel // Not intended to run in parallel.
t.Run("SendOK", func(t *testing.T) {
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello, Agent!",
})
require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status")
})
//nolint:tparallel // Not intended to run in parallel.
t.Run("MissingContent", func(t *testing.T) {
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "",
})
require.Error(t, err, "wanted error due to missing content")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
})
t.Run("TaskNotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
err := client.TaskSend(ctx, "me", uuid.New(), codersdk.TaskSendRequest{
Input: "hi",
})
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("SendToNonActiveStates", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
ownerUser, err := client.User(ctx, owner.UserID.String())
require.NoError(t, err)
ownerSubject := coderdtest.AuthzUserSubject(ownerUser)
// Create a regular user for task ownership.
_, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
createTask := createTaskInState(db, ownerSubject, owner.OrganizationID, user.ID)
t.Run("Paused", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPaused)
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello",
})
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "paused")
require.Contains(t, sdkErr.Detail, "Resume")
})
t.Run("Initializing", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusInitializing)
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello",
})
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "initializing")
require.Contains(t, sdkErr.Detail, "resuming")
})
t.Run("Pending", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPending)
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello",
})
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "pending")
require.Contains(t, sdkErr.Detail, "resuming")
})
t.Run("Error", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusError)
err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
Input: "Hello",
})
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "must be active")
})
})
})
t.Run("Logs", func(t *testing.T) {
t.Parallel()
messageResponseData := agentapisdk.GetMessagesResponse{
Messages: []agentapisdk.Message{
{
Id: 0,
Content: "Welcome, user!",
Role: agentapisdk.RoleAgent,
Time: time.Date(2025, 9, 25, 10, 42, 48, 0, time.UTC),
},
{
Id: 1,
Content: "Hello, agent!",
Role: agentapisdk.RoleUser,
Time: time.Date(2025, 9, 25, 10, 46, 42, 0, time.UTC),
},
{
Id: 2,
Content: "What would you like to work on today?",
Role: agentapisdk.RoleAgent,
Time: time.Date(2025, 9, 25, 10, 46, 50, 0, time.UTC),
},
},
}
messageResponseBytes, err := json.Marshal(messageResponseData)
require.NoError(t, err)
messageResponse := string(messageResponseBytes)
var shouldReturnError bool
// Fake AgentAPI that returns a couple of messages or an error.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if shouldReturnError {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "boom")
return
}
if r.Method == http.MethodGet && r.URL.Path == "/messages" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, messageResponse)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// Create an AI-capable template whose sidebar app points to our fake AgentAPI.
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
ctx = testutil.Context(t, testutil.WaitLong)
owner = coderdtest.CreateFirstUser(t, client)
agentAuthToken = uuid.NewString()
template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL))
)
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "show logs",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
// Get the workspace and wait for it to be ready.
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Fetch the task by ID via experimental API and verify fields.
task, err = client.TaskByIdentifier(ctx, task.ID.String())
require.NoError(t, err)
require.NotZero(t, task.WorkspaceBuildNumber)
require.True(t, task.WorkspaceAgentID.Valid)
require.True(t, task.WorkspaceAppID.Valid)
// Insert an app status for the workspace
_, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
ID: uuid.New(),
WorkspaceID: task.WorkspaceID.UUID,
CreatedAt: dbtime.Now(),
AgentID: task.WorkspaceAgentID.UUID,
AppID: task.WorkspaceAppID.UUID,
State: database.WorkspaceAppStatusStateComplete,
Message: "all done",
})
require.NoError(t, err)
// Start a fake agent so the workspace agent is connected before fetching logs.
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentAuthToken))
_ = agenttest.New(t, client.URL, agentAuthToken, func(o *agent.Options) {
o.Client = agentClient
})
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady)
// Fetch the task by ID via experimental API and verify fields.
task, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
//nolint:tparallel // Not intended to run in parallel.
t.Run("OK", func(t *testing.T) {
// Fetch logs.
resp, err := client.TaskLogs(ctx, "me", task.ID)
require.NoError(t, err)
require.Len(t, resp.Logs, 3)
assert.Equal(t, 0, resp.Logs[0].ID)
assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[0].Type)
assert.Equal(t, "Welcome, user!", resp.Logs[0].Content)
assert.Equal(t, 1, resp.Logs[1].ID)
assert.Equal(t, codersdk.TaskLogTypeInput, resp.Logs[1].Type)
assert.Equal(t, "Hello, agent!", resp.Logs[1].Content)
assert.Equal(t, 2, resp.Logs[2].ID)
assert.Equal(t, codersdk.TaskLogTypeOutput, resp.Logs[2].Type)
assert.Equal(t, "What would you like to work on today?", resp.Logs[2].Content)
})
//nolint:tparallel // Not intended to run in parallel.
t.Run("UpstreamError", func(t *testing.T) {
shouldReturnError = true
t.Cleanup(func() { shouldReturnError = false })
_, err := client.TaskLogs(ctx, "me", task.ID)
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode())
})
})
t.Run("LogsWithSnapshot", func(t *testing.T) {
t.Parallel()
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, ownerClient)
ownerUser, err := ownerClient.User(testutil.Context(t, testutil.WaitMedium), owner.UserID.String())
require.NoError(t, err)
ownerSubject := coderdtest.AuthzUserSubject(ownerUser)
// Create a regular user to test snapshot access.
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
createTask := createTaskInState(db, ownerSubject, owner.OrganizationID, user.ID)
// Prepare snapshot data used across tests.
snapshotMessages := []agentapisdk.Message{
{
Id: 0,
Content: "First message",
Role: agentapisdk.RoleAgent,
Time: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC),
},
{
Id: 1,
Content: "Second message",
Role: agentapisdk.RoleUser,
Time: time.Date(2025, 1, 1, 10, 1, 0, 0, time.UTC),
},
}
snapshotData := agentapisdk.GetMessagesResponse{
Messages: snapshotMessages,
}
envelope := coderd.TaskLogSnapshotEnvelope{
Format: "agentapi",
Data: snapshotData,
}
snapshotJSON, err := json.Marshal(envelope)
require.NoError(t, err)
snapshotTime := time.Date(2025, 1, 1, 10, 5, 0, 0, time.UTC)
// Helper to verify snapshot logs content.
verifySnapshotLogs := func(t *testing.T, got codersdk.TaskLogsResponse) {
t.Helper()
want := codersdk.TaskLogsResponse{
Snapshot: true,
SnapshotAt: &snapshotTime,
Logs: []codersdk.TaskLogEntry{
{
ID: 0,
Type: codersdk.TaskLogTypeOutput,
Content: "First message",
Time: snapshotMessages[0].Time,
},
{
ID: 1,
Type: codersdk.TaskLogTypeInput,
Content: "Second message",
Time: snapshotMessages[1].Time,
},
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("got bad response (-want +got):\n%s", diff)
}
}
t.Run("PendingTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPending)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("InitializingTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusInitializing)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("PausedTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPaused)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("NoSnapshotReturnsEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPending)
logsResp, err := client.TaskLogs(ctx, "me", task.ID)
require.NoError(t, err)
assert.True(t, logsResp.Snapshot)
assert.Nil(t, logsResp.SnapshotAt)
assert.Len(t, logsResp.Logs, 0)
})
t.Run("InvalidSnapshotFormat", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPending)
invalidEnvelope := coderd.TaskLogSnapshotEnvelope{
Format: "unknown-format",
Data: map[string]any{},
}
invalidJSON, err := json.Marshal(invalidEnvelope)
require.NoError(t, err)
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(invalidJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err)
_, err = client.TaskLogs(ctx, "me", task.ID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Unsupported task snapshot format")
})
t.Run("MalformedSnapshotData", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusPending)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: task.ID,
LogSnapshot: json.RawMessage(`{"format":"agentapi","data":"not an object"}`),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err)
_, err = client.TaskLogs(ctx, "me", task.ID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
})
t.Run("ErrorStateReturnsError", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
task := createTask(ctx, t, database.TaskStatusError)
_, err := client.TaskLogs(ctx, "me", task.ID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Cannot fetch logs for task in current state")
assert.Contains(t, sdkErr.Detail, "error")
})
})
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: "TaskStatusPending",
// We want to disable the provisioner so that the task
// never gets picked up (ensuring it stays in Pending).
disableProvisioner: true,
taskInput: "Valid prompt",
wantStatus: codersdk.TaskStatusPending,
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
task, err := client.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)
// If we're going to cancel the transition, we want to close the provisioner
// to stop the job completing before we can cancel it.
if tt.cancelTransition {
provisioner.Close()
}
// 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 = client.DeleteTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
} else {
// Given: Task has expected status
task, err = client.TaskByID(ctx, task.ID)
require.NoError(t, err)
require.Equal(t, tt.wantStatus, task.Status)
}
// When: We attempt to update the task input
err = client.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 = client.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 = client.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)
// Attempt to update prompt for non-existent task
err := client.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
task, err := client.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
err = anotherUser.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) {
t.Parallel()
t.Run("OK", 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)
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, "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("CustomNames", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
taskName string
taskDisplayName string
expectFallbackName bool
expectFallbackDisplayName bool
expectError string
}{
{
name: "ValidName",
taskName: "a-valid-task-name",
expectFallbackDisplayName: true,
},
{
name: "NotValidName",
taskName: "this is not a valid task name",
expectError: "Unable to create a Task with the provided name.",
},
{
name: "NoNameProvided",
taskName: "",
taskDisplayName: "A valid task display name",
expectFallbackName: true,
},
{
name: "ValidDisplayName",
taskDisplayName: "A valid task display name",
expectFallbackName: true,
},
{
name: "NotValidDisplayName",
taskDisplayName: "This is a task display name with a length greater than 64 characters.",
expectError: "Display name must be 64 characters or less.",
},
{
name: "NoDisplayNameProvided",
taskName: "a-valid-task-name",
taskDisplayName: "",
expectFallbackDisplayName: true,
},
{
name: "ValidNameAndDisplayName",
taskName: "a-valid-task-name",
taskDisplayName: "A valid task display name",
},
{
name: "NoNameAndDisplayNameProvided",
taskName: "",
taskDisplayName: "",
expectFallbackName: true,
expectFallbackDisplayName: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
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,
}}},
},
})
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// When: We attempt to create a Task.
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "Some prompt",
Name: tt.taskName,
DisplayName: tt.taskDisplayName,
})
if tt.expectError == "" {
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
// Then: We expect the correct name to have been picked.
err = codersdk.NameValid(task.Name)
require.NoError(t, err, "Generated task name should be valid")
require.NotEmpty(t, task.Name)
if !tt.expectFallbackName {
require.Equal(t, tt.taskName, task.Name)
}
// Then: We expect the correct display name to have been picked.
require.NotEmpty(t, task.DisplayName)
if !tt.expectFallbackDisplayName {
require.Equal(t, tt.taskDisplayName, task.DisplayName)
}
} else {
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Equal(t, apiErr.Message, tt.expectError)
}
})
}
})
t.Run("FailsOnNonTaskTemplate", 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 without AI task support (no coder_ai_task resource)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
// When: We attempt to create a Task.
_, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: taskPrompt,
})
// Then: We expect it to fail.
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("FailsOnInvalidTemplate", 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
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
// When: We attempt to create a Task with an invalid template version ID.
_, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: uuid.New(),
Input: taskPrompt,
})
// Then: We expect it to fail.
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("TaskTableCreatedAndLinked", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
taskPrompt = "Create a REST API"
)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
// Create a template with AI task support to test the new task data model.
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, "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)
// Verify that the task was created in the tasks table with the correct
// fields. This ensures the data model properly separates task records
// from workspace records.
dbCtx := dbauthz.AsSystemRestricted(ctx)
dbTask, err := db.GetTaskByID(dbCtx, task.ID)
require.NoError(t, err)
assert.Equal(t, user.OrganizationID, dbTask.OrganizationID)
assert.Equal(t, user.UserID, dbTask.OwnerID)
assert.Equal(t, task.Name, dbTask.Name)
assert.True(t, dbTask.WorkspaceID.Valid)
assert.Equal(t, ws.ID, dbTask.WorkspaceID.UUID)
assert.Equal(t, version.ID, dbTask.TemplateVersionID)
assert.Equal(t, taskPrompt, dbTask.Prompt)
assert.False(t, dbTask.DeletedAt.Valid)
// Verify the bidirectional relationship works by looking up the task
// via workspace ID.
dbTaskByWs, err := db.GetTaskByWorkspaceID(dbCtx, ws.ID)
require.NoError(t, err)
assert.Equal(t, dbTask.ID, dbTaskByWs.ID)
})
t.Run("TaskWithCustomName", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
taskPrompt = "Build a dashboard"
taskName = "my-custom-task"
)
client, db := coderdtest.NewWithDatabase(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, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: taskPrompt,
Name: taskName,
})
require.NoError(t, err)
require.Equal(t, taskName, task.Name)
// Verify the custom name is preserved in the database record.
dbCtx := dbauthz.AsSystemRestricted(ctx)
dbTask, err := db.GetTaskByID(dbCtx, task.ID)
require.NoError(t, err)
assert.Equal(t, taskName, dbTask.Name)
})
t.Run("MultipleTasksForSameUser", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
client, db := coderdtest.NewWithDatabase(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)
task1, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "First task",
Name: "task-1",
})
require.NoError(t, err)
task2, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "Second task",
Name: "task-2",
})
require.NoError(t, err)
// Verify both tasks are stored independently and can be listed together.
dbCtx := dbauthz.AsSystemRestricted(ctx)
tasks, err := db.ListTasks(dbCtx, database.ListTasksParams{
OwnerID: user.UserID,
OrganizationID: uuid.Nil,
})
require.NoError(t, err)
require.GreaterOrEqual(t, len(tasks), 2)
taskIDs := make(map[uuid.UUID]bool)
for _, task := range tasks {
taskIDs[task.ID] = true
}
assert.True(t, taskIDs[task1.ID], "task1 should be in the list")
assert.True(t, taskIDs[task2.ID], "task2 should be in the list")
})
t.Run("TaskLinkedToCorrectTemplateVersion", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version1 := 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, version1.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID)
version2 := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: []*proto.Response{
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
HasAiTasks: true,
}}},
},
}, template.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
// Create a task using version 2 to verify the template_version_id is
// stored correctly.
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: version2.ID,
Input: "Use version 2",
})
require.NoError(t, err)
// Verify the task references the correct template version, not just the
// active one.
dbCtx := dbauthz.AsSystemRestricted(ctx)
dbTask, err := db.GetTaskByID(dbCtx, task.ID)
require.NoError(t, err)
assert.Equal(t, version2.ID, dbTask.TemplateVersionID, "task should be linked to version 2")
})
}
func TestTasksNotification(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
latestAppStatuses []codersdk.WorkspaceAppStatusState
newAppStatus codersdk.WorkspaceAppStatusState
isAITask bool
isNotificationSent bool
notificationTemplate uuid.UUID
taskPrompt string
agentLifecycle database.WorkspaceAgentLifecycleState
}{
// Should not send a notification when the agent app is not an AI task.
{
name: "NoAITask",
latestAppStatuses: nil,
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
isAITask: false,
isNotificationSent: false,
taskPrompt: "NoAITask",
},
// Should not send a notification when the new app status is neither 'Working' nor 'Idle'.
{
name: "NonNotifiedState",
latestAppStatuses: nil,
newAppStatus: codersdk.WorkspaceAppStatusStateComplete,
isAITask: true,
isNotificationSent: false,
taskPrompt: "NonNotifiedState",
},
// Should not send a notification when the new app status equals the latest status (Working).
{
name: "NonNotifiedTransition",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
isAITask: true,
isNotificationSent: false,
taskPrompt: "NonNotifiedTransition",
},
// Should NOT send TemplateTaskWorking when the AI task's FIRST status is 'Working' (obvious state).
{
name: "TemplateTaskWorking",
latestAppStatuses: nil,
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
isAITask: true,
isNotificationSent: false,
notificationTemplate: notifications.TemplateTaskWorking,
taskPrompt: "TemplateTaskWorking",
},
// Should send TemplateTaskIdle when the AI task's FIRST status is 'Idle' (task completed immediately).
{
name: "InitialTemplateTaskIdle",
latestAppStatuses: nil,
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskIdle,
taskPrompt: "InitialTemplateTaskIdle",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'.
{
name: "TemplateTaskWorkingFromIdle",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{
codersdk.WorkspaceAppStatusStateWorking,
codersdk.WorkspaceAppStatusStateIdle,
}, // latest
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskWorking,
taskPrompt: "TemplateTaskWorkingFromIdle",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskIdle when the AI task transitions to 'Idle'.
{
name: "TemplateTaskIdle",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskIdle,
taskPrompt: "TemplateTaskIdle",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Long task prompts should be truncated to 160 characters.
{
name: "LongTaskPrompt",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskIdle,
taskPrompt: "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskCompleted when the AI task transitions to 'Complete'.
{
name: "TemplateTaskCompleted",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateComplete,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskCompleted,
taskPrompt: "TemplateTaskCompleted",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskFailed when the AI task transitions to 'Failure'.
{
name: "TemplateTaskFailed",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateFailure,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskFailed,
taskPrompt: "TemplateTaskFailed",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskCompleted when the AI task transitions from 'Idle' to 'Complete'.
{
name: "TemplateTaskCompletedFromIdle",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateIdle},
newAppStatus: codersdk.WorkspaceAppStatusStateComplete,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskCompleted,
taskPrompt: "TemplateTaskCompletedFromIdle",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should send TemplateTaskFailed when the AI task transitions from 'Idle' to 'Failure'.
{
name: "TemplateTaskFailedFromIdle",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateIdle},
newAppStatus: codersdk.WorkspaceAppStatusStateFailure,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskFailed,
taskPrompt: "TemplateTaskFailedFromIdle",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
// Should NOT send notification when transitioning from 'Complete' to 'Complete' (no change).
{
name: "NoNotificationCompleteToComplete",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateComplete},
newAppStatus: codersdk.WorkspaceAppStatusStateComplete,
isAITask: true,
isNotificationSent: false,
taskPrompt: "NoNotificationCompleteToComplete",
},
// Should NOT send notification when transitioning from 'Failure' to 'Failure' (no change).
{
name: "NoNotificationFailureToFailure",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateFailure},
newAppStatus: codersdk.WorkspaceAppStatusStateFailure,
isAITask: true,
isNotificationSent: false,
taskPrompt: "NoNotificationFailureToFailure",
},
// Should NOT send notification when agent is in 'starting' lifecycle state (agent startup).
{
name: "AgentStarting_NoNotification",
latestAppStatuses: nil,
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: false,
taskPrompt: "AgentStarting_NoNotification",
agentLifecycle: database.WorkspaceAgentLifecycleStateStarting,
},
// Should NOT send notification when agent is in 'created' lifecycle state (agent not started).
{
name: "AgentCreated_NoNotification",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: false,
taskPrompt: "AgentCreated_NoNotification",
agentLifecycle: database.WorkspaceAgentLifecycleStateCreated,
},
// Should send notification when agent is in 'ready' lifecycle state (agent fully started).
{
name: "AgentReady_SendNotification",
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
isAITask: true,
isNotificationSent: true,
notificationTemplate: notifications.TemplateTaskIdle,
taskPrompt: "AgentReady_SendNotification",
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
clock := quartz.NewMock(t)
notifyEnq := &notificationstest.FakeEnqueuer{}
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t),
NotificationsEnqueuer: notifyEnq,
})
// Given: a member user
ownerUser := coderdtest.CreateFirstUser(t, client)
client, memberUser := coderdtest.CreateAnotherUser(t, client, ownerUser.OrganizationID)
// Given: a workspace build with an agent containing an App
workspaceAgentAppID := uuid.New()
workspaceBuildID := uuid.New()
workspaceBuilder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: ownerUser.OrganizationID,
OwnerID: memberUser.ID,
}).Seed(database.WorkspaceBuild{
ID: workspaceBuildID,
})
if tc.isAITask {
workspaceBuilder = workspaceBuilder.
WithTask(database.TaskTable{
Prompt: tc.taskPrompt,
}, &proto.App{
Id: workspaceAgentAppID.String(),
Slug: "ccw",
})
} else {
workspaceBuilder = workspaceBuilder.
WithAgent(func(agent []*proto.Agent) []*proto.Agent {
agent[0].Apps = []*proto.App{{
Id: workspaceAgentAppID.String(),
Slug: "ccw",
}}
return agent
})
}
workspaceBuild := workspaceBuilder.Do()
ctx := testutil.Context(t, testutil.WaitShort)
// Given: set the agent lifecycle state if specified
if tc.agentLifecycle != "" {
workspace := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
agentID := workspace.LatestBuild.Resources[0].Agents[0].ID
var (
startedAt sql.NullTime
readyAt sql.NullTime
)
if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateReady {
startedAt = sql.NullTime{Time: clock.Now(), Valid: true}
readyAt = sql.NullTime{Time: clock.Now(), Valid: true}
} else if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateStarting {
startedAt = sql.NullTime{Time: clock.Now(), Valid: true}
}
// nolint:gocritic // This is a system restricted operation for test setup.
err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: agentID,
LifecycleState: tc.agentLifecycle,
StartedAt: startedAt,
ReadyAt: readyAt,
})
require.NoError(t, err)
}
// Given: the workspace agent app has previous statuses
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspaceBuild.AgentToken))
if len(tc.latestAppStatuses) > 0 {
workspace := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
for _, appStatus := range tc.latestAppStatuses {
clock.Advance(time.Second)
dbgen.WorkspaceAppStatus(t, db, database.WorkspaceAppStatus{
CreatedAt: clock.Now(),
WorkspaceID: workspaceBuild.Workspace.ID,
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
AppID: workspaceAgentAppID,
State: database.WorkspaceAppStatusState(appStatus),
})
}
}
// When: the agent updates the app status
err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "ccw",
Message: "testing",
URI: "https://example.com",
State: tc.newAppStatus,
})
require.NoError(t, err)
// Then: The workspace app status transitions successfully
workspace, err := client.Workspace(ctx, workspaceBuild.Workspace.ID)
require.NoError(t, err)
workspaceAgent, err := client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID)
require.NoError(t, err)
require.Len(t, workspaceAgent.Apps, 1)
require.GreaterOrEqual(t, len(workspaceAgent.Apps[0].Statuses), 1)
// Statuses are ordered by created_at DESC, so the first element is the latest.
require.Equal(t, tc.newAppStatus, workspaceAgent.Apps[0].Statuses[0].State)
if tc.isNotificationSent {
// Then: A notification is sent to the workspace owner (memberUser)
sent := notifyEnq.Sent(notificationstest.WithTemplateID(tc.notificationTemplate))
require.Len(t, sent, 1)
require.Equal(t, memberUser.ID, sent[0].UserID)
require.Len(t, sent[0].Labels, 2)
require.Equal(t, workspaceBuild.Task.Name, sent[0].Labels["task"])
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
} else {
// Then: No notification is sent
sentWorking := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskWorking))
sentIdle := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskIdle))
require.Len(t, sentWorking, 0)
require.Len(t, sentIdle, 0)
}
})
}
}
func TestPostWorkspaceAgentTaskSnapshot(t *testing.T) {
t.Parallel()
// Shared coderd with mock clock for all tests.
clock := quartz.NewMock(t)
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
Clock: clock,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
createTaskWorkspace := func(t *testing.T, agentToken string) (taskID uuid.UUID, workspaceID uuid.UUID) {
t.Helper()
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: owner.UserID,
}).WithTask(database.TaskTable{
Prompt: "test prompt",
}, &proto.App{
Slug: "task-app",
Url: "http://localhost:8080",
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
agents[0].Auth = &proto.Agent_Token{Token: agentToken}
return agents
}).Do()
return workspaceBuild.Task.ID, workspaceBuild.Workspace.ID
}
makePayload := func(t *testing.T, content string) []byte {
t.Helper()
data := agentapisdk.GetMessagesResponse{
Messages: []agentapisdk.Message{
{Id: 0, Role: "agent", Content: content, Time: time.Now()},
},
}
b, err := json.Marshal(data)
require.NoError(t, err)
return b
}
makeRequest := func(t *testing.T, taskID uuid.UUID, agentToken string, payload []byte, format string) *http.Response {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)
url := ownerClient.URL.JoinPath("/api/v2/workspaceagents/me/tasks", taskID.String(), "log-snapshot").String()
if format != "" {
url += "?format=" + format
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, agentToken)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return res
}
unmarshalSnapshot := func(t *testing.T, snapshotJSON json.RawMessage) agentapisdk.GetMessagesResponse {
t.Helper()
// Pre-populate Data with the correct type so json.Unmarshal decodes
// directly into it instead of creating a map[string]any.
envelope := coderd.TaskLogSnapshotEnvelope{
Data: &agentapisdk.GetMessagesResponse{},
}
err := json.Unmarshal(snapshotJSON, &envelope)
require.NoError(t, err)
require.Equal(t, "agentapi", envelope.Format)
return *envelope.Data.(*agentapisdk.GetMessagesResponse)
}
t.Run("Success", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
ctx := testutil.Context(t, testutil.WaitShort)
res := makeRequest(t, taskID, agentToken, makePayload(t, "test"), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusNoContent, res.StatusCode)
snapshot, err := db.GetTaskSnapshot(dbauthz.AsSystemRestricted(ctx), taskID)
require.NoError(t, err)
data := unmarshalSnapshot(t, snapshot.LogSnapshot)
require.Len(t, data.Messages, 1)
require.Equal(t, "test", data.Messages[0].Content)
})
//nolint:paralleltest // Not parallel, advances shared clock.
t.Run("Overwrite", func(t *testing.T) {
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
ctx := testutil.Context(t, testutil.WaitShort)
// First snapshot.
res1 := makeRequest(t, taskID, agentToken, makePayload(t, "first"), "agentapi")
res1.Body.Close()
require.Equal(t, http.StatusNoContent, res1.StatusCode)
snapshot1, err := db.GetTaskSnapshot(dbauthz.AsSystemRestricted(ctx), taskID)
require.NoError(t, err)
firstTime := snapshot1.LogSnapshotCreatedAt
// Advance clock to ensure timestamp differs.
clock.Advance(time.Second)
// Second snapshot.
res2 := makeRequest(t, taskID, agentToken, makePayload(t, "second"), "agentapi")
res2.Body.Close()
require.Equal(t, http.StatusNoContent, res2.StatusCode)
snapshot2, err := db.GetTaskSnapshot(dbauthz.AsSystemRestricted(ctx), taskID)
require.NoError(t, err)
require.True(t, snapshot2.LogSnapshotCreatedAt.After(firstTime))
// Verify data was overwritten.
data := unmarshalSnapshot(t, snapshot2.LogSnapshot)
require.Len(t, data.Messages, 1)
require.Equal(t, "second", data.Messages[0].Content)
})
t.Run("MissingFormat", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
res := makeRequest(t, taskID, agentToken, makePayload(t, "test"), "")
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var errResp codersdk.Response
json.NewDecoder(res.Body).Decode(&errResp)
require.Contains(t, errResp.Message, "Invalid query parameters")
require.Len(t, errResp.Validations, 1)
require.Equal(t, "format", errResp.Validations[0].Field)
require.Contains(t, errResp.Validations[0].Detail, "required and cannot be empty")
})
t.Run("InvalidFormat", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
res := makeRequest(t, taskID, agentToken, makePayload(t, "test"), "unknown")
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var errResp codersdk.Response
json.NewDecoder(res.Body).Decode(&errResp)
require.Contains(t, errResp.Message, "Invalid format parameter")
})
t.Run("PayloadTooLarge", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
largeContent := strings.Repeat("x", 65*1024)
payload := makePayload(t, largeContent)
res := makeRequest(t, taskID, agentToken, payload, "agentapi")
require.Equal(t, http.StatusBadRequest, res.StatusCode)
res.Body.Close()
})
t.Run("InvalidTaskID", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
createTaskWorkspace(t, agentToken)
ctx := testutil.Context(t, testutil.WaitShort)
url := ownerClient.URL.JoinPath("/api/v2/workspaceagents/me/tasks", "not-a-uuid", "log-snapshot").String() + "?format=agentapi"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(makePayload(t, "test")))
req.Header.Set(codersdk.SessionTokenHeader, agentToken)
res, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var errResp codersdk.Response
json.NewDecoder(res.Body).Decode(&errResp)
require.Contains(t, errResp.Message, "Invalid task ID format")
})
t.Run("TaskNotFound", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
createTaskWorkspace(t, agentToken)
res := makeRequest(t, uuid.New(), agentToken, makePayload(t, "test"), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("WrongWorkspace", func(t *testing.T) {
t.Parallel()
agent1Token := uuid.NewString()
agent2Token := uuid.NewString()
taskID1, _ := createTaskWorkspace(t, agent1Token)
taskID2, _ := createTaskWorkspace(t, agent2Token)
// Try to POST snapshot for task2 using agent1's token.
res := makeRequest(t, taskID2, agent1Token, makePayload(t, "test"), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
// Verify we CAN post for our own task.
res2 := makeRequest(t, taskID1, agent1Token, makePayload(t, "test"), "agentapi")
defer res2.Body.Close()
require.Equal(t, http.StatusNoContent, res2.StatusCode)
})
t.Run("Unauthorized", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
res := makeRequest(t, taskID, "", makePayload(t, "test"), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("MalformedJSON", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
res := makeRequest(t, taskID, agentToken, []byte("{invalid json"), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var errResp codersdk.Response
json.NewDecoder(res.Body).Decode(&errResp)
require.Contains(t, errResp.Message, "Failed to decode request payload")
})
t.Run("InvalidAgentAPIPayload", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
// Missing required "messages" field.
res := makeRequest(t, taskID, agentToken, []byte(`{"truncated":false,"total_count":0}`), "agentapi")
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var errResp codersdk.Response
json.NewDecoder(res.Body).Decode(&errResp)
require.Contains(t, errResp.Message, "Invalid agentapi payload structure")
})
t.Run("DeletedTask", func(t *testing.T) {
t.Parallel()
agentToken := uuid.NewString()
taskID, _ := createTaskWorkspace(t, agentToken)
ctx := testutil.Context(t, testutil.WaitShort)
// Delete the task.
err := ownerClient.DeleteTask(ctx, owner.UserID.String(), taskID)
require.NoError(t, err)
res := makeRequest(t, taskID, agentToken, makePayload(t, "test"), "agentapi")
defer res.Body.Close()
// Agent token becomes invalid after task deletion.
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
}
func TestPauseTask(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,
})
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: "pause 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: "pause me",
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid)
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
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.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) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, ps := dbtestutil.NewDB(t)
client := setupClient(t, db, ps, nil)
owner := coderdtest.CreateFirstUser(t, client)
cases := []struct {
name string
roles []rbac.RoleIdentifier
expectedStatus int
}{
{
name: "org_member",
expectedStatus: http.StatusNotFound,
},
{
name: "org_admin",
roles: []rbac.RoleIdentifier{rbac.ScopedRoleOrgAdmin(owner.OrganizationID)},
expectedStatus: http.StatusAccepted,
},
{
name: "sitewide_member",
roles: []rbac.RoleIdentifier{rbac.RoleMember()},
expectedStatus: http.StatusNotFound,
},
{
name: "sitewide_admin",
roles: []rbac.RoleIdentifier{rbac.RoleOwner()},
expectedStatus: http.StatusAccepted,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
task, _ := setupWorkspaceTask(t, db, owner)
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, tc.roles...)
resp, err := userClient.PauseTask(ctx, codersdk.Me, task.ID)
if tc.expectedStatus == http.StatusAccepted {
require.NoError(t, err)
require.NotNil(t, resp.WorkspaceBuild)
require.NotEqual(t, uuid.Nil, resp.WorkspaceBuild.ID)
return
}
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, tc.expectedStatus, 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.PauseTask(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.PauseTask(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.PauseTask(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.PauseTask(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.PauseTask(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.PauseTask(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.ActionWorkspaceStop && 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.PauseTask(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: "pause me",
}, nil).
Starting().
Do()
_, err := client.PauseTask(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,
insertWorkspaceBuild: func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
return xerrors.New("insert failed")
},
}
client := setupClient(t, wrapped, ps, nil)
user := coderdtest.CreateFirstUser(t, client)
task, _ := setupWorkspaceTask(t, db, user)
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
})
t.Run("Notification", func(t *testing.T) {
t.Parallel()
var (
notifyEnq = &notificationstest.FakeEnqueuer{}
ownerClient, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{NotificationsEnqueuer: notifyEnq})
owner = coderdtest.CreateFirstUser(t, ownerClient)
)
ctx := testutil.Context(t, testutil.WaitMedium)
ownerUser, err := ownerClient.User(ctx, owner.UserID.String())
require.NoError(t, err)
createTask := createTaskInState(db, coderdtest.AuthzUserSubject(ownerUser), owner.OrganizationID, owner.UserID)
// Given: A task in an active state
task := createTask(ctx, t, database.TaskStatusActive)
workspace, err := ownerClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
// When: We pause the task
_, err = ownerClient.PauseTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
// Then: A notification should be sent
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskPaused))
require.Len(t, sent, 1)
require.Equal(t, owner.UserID, sent[0].UserID)
require.Equal(t, task.Name, sent[0].Labels["task"])
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
require.Equal(t, "manual", sent[0].Labels["pause_reason"])
})
}
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())
})
t.Run("Notification", func(t *testing.T) {
t.Parallel()
var (
notifyEnq = &notificationstest.FakeEnqueuer{}
ownerClient, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{NotificationsEnqueuer: notifyEnq})
owner = coderdtest.CreateFirstUser(t, ownerClient)
)
ctx := testutil.Context(t, testutil.WaitMedium)
ownerUser, err := ownerClient.User(ctx, owner.UserID.String())
require.NoError(t, err)
createTask := createTaskInState(db, coderdtest.AuthzUserSubject(ownerUser), owner.OrganizationID, owner.UserID)
// Given: A task in a paused state
task := createTask(ctx, t, database.TaskStatusPaused)
workspace, err := ownerClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
// When: We resume the task
_, err = ownerClient.ResumeTask(ctx, codersdk.Me, task.ID)
require.NoError(t, err)
// Then: A notification should be sent
sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskResumed))
require.Len(t, sent, 1)
require.Equal(t, owner.UserID, sent[0].UserID)
require.Equal(t, task.Name, sent[0].Labels["task"])
require.Equal(t, task.ID.String(), sent[0].Labels["task_id"])
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
})
}