diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 4e0e13289f..a58543fe6e 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -21,10 +21,12 @@ import ( agentapisdk "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" @@ -1300,6 +1302,23 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) { return } + if _, err := api.NotificationsEnqueuer.Enqueue( + // nolint:gocritic // Need notifier actor to enqueue notifications. + dbauthz.AsNotifier(ctx), + workspace.OwnerID, + notifications.TemplateTaskPaused, + map[string]string{ + "task": task.Name, + "task_id": task.ID.String(), + "workspace": workspace.Name, + "pause_reason": "manual", + }, + "api-task-pause", + workspace.ID, workspace.OwnerID, workspace.OrganizationID, + ); err != nil { + api.Logger.Warn(ctx, "failed to notify of task paused", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID)) + } + httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.PauseTaskResponse{ WorkspaceBuild: &build, }) @@ -1387,6 +1406,22 @@ func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) { httperror.WriteWorkspaceBuildError(ctx, rw, err) return } + if _, err := api.NotificationsEnqueuer.Enqueue( + // nolint:gocritic // Need notifier actor to enqueue notifications. + dbauthz.AsNotifier(ctx), + workspace.OwnerID, + notifications.TemplateTaskResumed, + map[string]string{ + "task": task.Name, + "task_id": task.ID.String(), + "workspace": workspace.Name, + }, + "api-task-resume", + workspace.ID, workspace.OwnerID, workspace.OrganizationID, + ); err != nil { + api.Logger.Warn(ctx, "failed to notify of task resumed", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID)) + } + httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{ WorkspaceBuild: &build, }) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 3eb3189915..7803ae1587 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -45,10 +45,10 @@ import ( ) // 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 ID. +// 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) uuid.UUID { - return func(ctx context.Context, t *testing.T, status database.TaskStatus) uuid.UUID { +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{ @@ -65,6 +65,9 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID, 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, @@ -76,31 +79,32 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID, } resp := builder.Do() - taskID := resp.Task.ID // Post-process by manipulating agent and app state. - if status == database.TaskStatusError { - // First, set agent to ready state so agent_status returns 'active'. - // This ensures the cascade reaches app_status. + 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) - // Then set workspace app health to unhealthy to trigger error state. 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: database.WorkspaceAppHealthUnhealthy, + Health: appHealth, }) require.NoError(t, err) } - return taskID + return resp.Task } } @@ -845,9 +849,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPaused) + task := createTask(ctx, t, database.TaskStatusPaused) - err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{ + err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello", }) @@ -863,9 +867,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusInitializing) + task := createTask(ctx, t, database.TaskStatusInitializing) - err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{ + err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello", }) @@ -881,9 +885,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPending) + task := createTask(ctx, t, database.TaskStatusPending) - err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{ + err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello", }) @@ -899,9 +903,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusError) + task := createTask(ctx, t, database.TaskStatusError) - err := client.TaskSend(ctx, "me", taskID, codersdk.TaskSendRequest{ + err := client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello", }) @@ -1120,16 +1124,16 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPending) + task := createTask(ctx, t, database.TaskStatusPending) err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ - TaskID: taskID, + TaskID: task.ID, LogSnapshot: json.RawMessage(snapshotJSON), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err, "upserting task snapshot") - logsResp, err := client.TaskLogs(ctx, "me", taskID) + logsResp, err := client.TaskLogs(ctx, "me", task.ID) require.NoError(t, err, "fetching task logs") verifySnapshotLogs(t, logsResp) }) @@ -1138,16 +1142,16 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusInitializing) + task := createTask(ctx, t, database.TaskStatusInitializing) err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ - TaskID: taskID, + TaskID: task.ID, LogSnapshot: json.RawMessage(snapshotJSON), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err, "upserting task snapshot") - logsResp, err := client.TaskLogs(ctx, "me", taskID) + logsResp, err := client.TaskLogs(ctx, "me", task.ID) require.NoError(t, err, "fetching task logs") verifySnapshotLogs(t, logsResp) }) @@ -1156,16 +1160,16 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPaused) + task := createTask(ctx, t, database.TaskStatusPaused) err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ - TaskID: taskID, + TaskID: task.ID, LogSnapshot: json.RawMessage(snapshotJSON), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err, "upserting task snapshot") - logsResp, err := client.TaskLogs(ctx, "me", taskID) + logsResp, err := client.TaskLogs(ctx, "me", task.ID) require.NoError(t, err, "fetching task logs") verifySnapshotLogs(t, logsResp) }) @@ -1174,9 +1178,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPending) + task := createTask(ctx, t, database.TaskStatusPending) - logsResp, err := client.TaskLogs(ctx, "me", taskID) + logsResp, err := client.TaskLogs(ctx, "me", task.ID) require.NoError(t, err) assert.True(t, logsResp.Snapshot) @@ -1188,7 +1192,7 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPending) + task := createTask(ctx, t, database.TaskStatusPending) invalidEnvelope := coderd.TaskLogSnapshotEnvelope{ Format: "unknown-format", @@ -1198,13 +1202,13 @@ func TestTasks(t *testing.T) { require.NoError(t, err) err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ - TaskID: taskID, + TaskID: task.ID, LogSnapshot: json.RawMessage(invalidJSON), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err) - _, err = client.TaskLogs(ctx, "me", taskID) + _, err = client.TaskLogs(ctx, "me", task.ID) require.Error(t, err) var sdkErr *codersdk.Error @@ -1217,16 +1221,16 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusPending) + task := createTask(ctx, t, database.TaskStatusPending) err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ - TaskID: taskID, + TaskID: task.ID, LogSnapshot: json.RawMessage(`{"format":"agentapi","data":"not an object"}`), LogSnapshotCreatedAt: snapshotTime, }) require.NoError(t, err) - _, err = client.TaskLogs(ctx, "me", taskID) + _, err = client.TaskLogs(ctx, "me", task.ID) require.Error(t, err) var sdkErr *codersdk.Error @@ -1238,9 +1242,9 @@ func TestTasks(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - taskID := createTask(ctx, t, database.TaskStatusError) + task := createTask(ctx, t, database.TaskStatusError) - _, err := client.TaskLogs(ctx, "me", taskID) + _, err := client.TaskLogs(ctx, "me", task.ID) require.Error(t, err) var sdkErr *codersdk.Error @@ -2787,6 +2791,41 @@ func TestPauseTask(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) }) + + t.Run("Notification", func(t *testing.T) { + t.Parallel() + + var ( + notifyEnq = ¬ificationstest.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) { @@ -3116,4 +3155,38 @@ func TestResumeTask(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode()) }) + + t.Run("Notification", func(t *testing.T) { + t.Parallel() + + var ( + notifyEnq = ¬ificationstest.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"]) + }) } diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index d212a25331..37b9e1d57f 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -231,6 +231,7 @@ func (e *Executor) runOnce(t time.Time) Stats { job *database.ProvisionerJob auditLog *auditParams shouldNotifyDormancy bool + shouldNotifyTaskPause bool nextBuild *database.WorkspaceBuild activeTemplateVersion database.TemplateVersion ws database.Workspace @@ -316,6 +317,10 @@ func (e *Executor) runOnce(t time.Time) Stats { return nil } + if reason == database.BuildReasonTaskAutoPause { + shouldNotifyTaskPause = true + } + // Get the template version job to access tags templateVersionJob, err := tx.GetProvisionerJobByID(e.ctx, activeTemplateVersion.JobID) if err != nil { @@ -482,6 +487,28 @@ func (e *Executor) runOnce(t time.Time) Stats { log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", ws.ID)) } } + if shouldNotifyTaskPause { + task, err := e.db.GetTaskByID(e.ctx, ws.TaskID.UUID) + if err != nil { + log.Warn(e.ctx, "failed to get task for pause notification", slog.Error(err), slog.F("task_id", ws.TaskID.UUID), slog.F("workspace_id", ws.ID)) + } else { + if _, err := e.notificationsEnqueuer.Enqueue( + e.ctx, + ws.OwnerID, + notifications.TemplateTaskPaused, + map[string]string{ + "task": task.Name, + "task_id": task.ID.String(), + "workspace": ws.Name, + "pause_reason": "inactivity exceeded the dormancy threshold", + }, + "lifecycle_executor", + ws.ID, ws.OwnerID, ws.OrganizationID, + ); err != nil { + log.Warn(e.ctx, "failed to notify of task paused", slog.Error(err), slog.F("task_id", ws.TaskID.UUID), slog.F("workspace_id", ws.ID)) + } + } + } return nil }() if err != nil && !xerrors.Is(err, context.Canceled) { diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 37ae651c67..b78c936a38 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -2026,4 +2026,62 @@ func TestExecutorTaskWorkspace(t *testing.T) { workspace = coderdtest.MustWorkspace(t, client, workspace.ID) assert.Equal(t, codersdk.BuildReasonTaskAutoPause, workspace.LatestBuild.Reason, "task workspace should use TaskAutoPause build reason") }) + + t.Run("AutostopNotification", func(t *testing.T) { + t.Parallel() + + var ( + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + notifyEnq = notificationstest.FakeEnqueuer{} + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + NotificationsEnqueuer: ¬ifyEnq, + }) + admin = coderdtest.CreateFirstUser(t, client) + ) + + // Given: A task workspace with an 8 hour deadline + ctx := testutil.Context(t, testutil.WaitShort) + template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 8*time.Hour) + workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostop notification") + + // Given: The workspace is currently running + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + require.NotZero(t, workspace.LatestBuild.Deadline, "workspace should have a deadline for autostop") + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{}) + require.NoError(t, err) + + // When: the autobuild executor ticks after the deadline + go func() { + tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime + close(tickCh) + }() + + // Then: We expect to see a stop transition + stats := <-statsCh + require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace") + assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions") + assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID], "should autostop the workspace") + require.Empty(t, stats.Errors, "should have no errors when managing task workspaces") + + // Then: A task paused notification was sent with "idle timeout" reason + require.True(t, workspace.TaskID.Valid, "workspace should have a task ID") + task, err := db.GetTaskByID(dbauthz.AsSystemRestricted(ctx), workspace.TaskID.UUID) + require.NoError(t, err) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskPaused)) + require.Len(t, sent, 1) + require.Equal(t, workspace.OwnerID, 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, "inactivity exceeded the dormancy threshold", sent[0].Labels["pause_reason"]) + }) } diff --git a/coderd/database/migrations/000419_task_pause_resume_notifications.down.sql b/coderd/database/migrations/000419_task_pause_resume_notifications.down.sql new file mode 100644 index 0000000000..8107fd2d1b --- /dev/null +++ b/coderd/database/migrations/000419_task_pause_resume_notifications.down.sql @@ -0,0 +1,4 @@ +-- Remove Task 'paused' transition template notification +DELETE FROM notification_templates WHERE id = '2a74f3d3-ab09-4123-a4a5-ca238f4f65a1'; +-- Remove Task 'resumed' transition template notification +DELETE FROM notification_templates WHERE id = '843ee9c3-a8fb-4846-afa9-977bec578649'; diff --git a/coderd/database/migrations/000419_task_pause_resume_notifications.up.sql b/coderd/database/migrations/000419_task_pause_resume_notifications.up.sql new file mode 100644 index 0000000000..5f959230b3 --- /dev/null +++ b/coderd/database/migrations/000419_task_pause_resume_notifications.up.sql @@ -0,0 +1,63 @@ +-- Task transition to 'paused' status +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + '2a74f3d3-ab09-4123-a4a5-ca238f4f65a1', + 'Task Paused', + E'Task ''{{.Labels.task}}'' is paused', + E'The task ''{{.Labels.task}}'' was paused ({{.Labels.pause_reason}}).', + '[ + { + "label": "View task", + "url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.task_id}}" + }, + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb, + 'Task Events', + NULL, + 'system'::notification_template_kind, + true + ); + +-- Task transition to 'resumed' status +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + '843ee9c3-a8fb-4846-afa9-977bec578649', + 'Task Resumed', + E'Task ''{{.Labels.task}}'' has resumed', + E'The task ''{{.Labels.task}}'' has resumed.', + '[ + { + "label": "View task", + "url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.task_id}}" + }, + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb, + 'Task Events', + NULL, + 'system'::notification_template_kind, + true + ); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 83e8e990a3..1754b93b0e 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -59,4 +59,6 @@ var ( TemplateTaskIdle = uuid.MustParse("d4a6271c-cced-4ed0-84ad-afd02a9c7799") TemplateTaskCompleted = uuid.MustParse("8c5a4d12-9f7e-4b3a-a1c8-6e4f2d9b5a7c") TemplateTaskFailed = uuid.MustParse("3b7e8f1a-4c2d-49a6-b5e9-7f3a1c8d6b4e") + TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1") + TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649") ) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 96d5c0b923..186bf1d850 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1302,6 +1302,37 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateTaskPaused", + id: notifications.TemplateTaskPaused, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "task": "my-task", + "task_id": "00000000-0000-0000-0000-000000000000", + "workspace": "my-workspace", + "pause_reason": "idle timeout", + }, + Data: map[string]any{}, + }, + }, + { + name: "TemplateTaskResumed", + id: notifications.TemplateTaskResumed, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "task": "my-task", + "task_id": "00000000-0000-0000-0000-000000000001", + "workspace": "my-workspace", + }, + Data: map[string]any{}, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden new file mode 100644 index 0000000000..58a1f098f7 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden @@ -0,0 +1,85 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Task 'my-task' is paused +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The task 'my-task' was paused (idle timeout). + + +View task: http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000 + +View workspace: http://test.com/@bobby/my-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Task 'my-task' is paused + + +
+
+ 3D"Cod= +
+

+ Task 'my-task' is paused +

+
+

Hi Bobby,

+

The task ‘my-task’ was paused (idle timeout).

+
+
+ =20 + + View task + + =20 + + View workspace + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden new file mode 100644 index 0000000000..81d2498b57 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden @@ -0,0 +1,85 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Task 'my-task' has resumed +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The task 'my-task' has resumed. + + +View task: http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000001 + +View workspace: http://test.com/@bobby/my-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Task 'my-task' has resumed + + +
+
+ 3D"Cod= +
+

+ Task 'my-task' has resumed +

+
+

Hi Bobby,

+

The task ‘my-task’ has resumed.

+
+
+ =20 + + View task + + =20 + + View workspace + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskPaused.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskPaused.json.golden new file mode 100644 index 0000000000..2fa793fb1c --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskPaused.json.golden @@ -0,0 +1,35 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Task Paused", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View task", + "url": "http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000" + }, + { + "label": "View workspace", + "url": "http://test.com/@bobby/my-workspace" + } + ], + "labels": { + "pause_reason": "idle timeout", + "task": "my-task", + "task_id": "00000000-0000-0000-0000-000000000000", + "workspace": "my-workspace" + }, + "data": {}, + "targets": null + }, + "title": "Task 'my-task' is paused", + "title_markdown": "Task 'my-task' is paused", + "body": "The task 'my-task' was paused (idle timeout).", + "body_markdown": "The task 'my-task' was paused (idle timeout)." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskResumed.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskResumed.json.golden new file mode 100644 index 0000000000..1fa3a4149d --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskResumed.json.golden @@ -0,0 +1,34 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Task Resumed", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View task", + "url": "http://test.com/tasks/bobby/00000000-0000-0000-0000-000000000000" + }, + { + "label": "View workspace", + "url": "http://test.com/@bobby/my-workspace" + } + ], + "labels": { + "task": "my-task", + "task_id": "00000000-0000-0000-0000-000000000000", + "workspace": "my-workspace" + }, + "data": {}, + "targets": null + }, + "title": "Task 'my-task' has resumed", + "title_markdown": "Task 'my-task' has resumed", + "body": "The task 'my-task' has resumed.", + "body_markdown": "The task 'my-task' has resumed." +} \ No newline at end of file