diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 3de4930af1..20b33e9314 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "database/sql" "fmt" "io" "net/http" @@ -17,8 +18,12 @@ import ( "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/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -961,3 +966,164 @@ func TestTasksCreate(t *testing.T) { assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +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 + }{ + // 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, + }, + // 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, + }, + // 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, + }, + // Should send TemplateTaskWorking when the AI task transitions to 'Working'. + { + name: "TemplateTaskWorking", + latestAppStatuses: nil, + newAppStatus: codersdk.WorkspaceAppStatusStateWorking, + isAITask: true, + isNotificationSent: true, + notificationTemplate: notifications.TemplateTaskWorking, + }, + // 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, + }, + // 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, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + notifyEnq := ¬ificationstest.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() + workspaceBuildSeed := database.WorkspaceBuild{ + ID: workspaceBuildID, + } + if tc.isAITask { + workspaceBuildSeed = database.WorkspaceBuild{ + ID: workspaceBuildID, + // AI Task configuration + HasAITask: sql.NullBool{Bool: true, Valid: true}, + AITaskSidebarAppID: uuid.NullUUID{UUID: workspaceAgentAppID, Valid: true}, + } + } + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: ownerUser.OrganizationID, + OwnerID: memberUser.ID, + }).Seed(workspaceBuildSeed).Params(database.WorkspaceBuildParameter{ + WorkspaceBuildID: workspaceBuildID, + Name: codersdk.AITaskPromptParameterName, + Value: "task prompt", + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + agent[0].Apps = []*proto.App{{ + Id: workspaceAgentAppID.String(), + Slug: "ccw", + }} + return agent + }).Do() + + // 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 { + dbgen.WorkspaceAppStatus(t, db, database.WorkspaceAppStatus{ + 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) + latestStatusIndex := len(workspaceAgent.Apps[0].Statuses) - 1 + require.Equal(t, tc.newAppStatus, workspaceAgent.Apps[0].Statuses[latestStatusIndex].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, "task prompt", 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) + } + }) + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2490ea2323..b0cfedb119 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2313,6 +2313,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab return q.db.GetLatestCryptoKeyByFeature(ctx, feature) } +func (q *querier) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetLatestWorkspaceAppStatusesByAppID(ctx, appID) +} + func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4b96c4e3eb..730d5f3198 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2683,6 +2683,11 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(l, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) })) + s.Run("GetLatestWorkspaceAppStatusesByAppID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + appID := uuid.New() + dbm.EXPECT().GetLatestWorkspaceAppStatusesByAppID(gomock.Any(), appID).Return([]database.WorkspaceAppStatus{}, nil).AnyTimes() + check.Args(appID).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { ids := []uuid.UUID{uuid.New()} dbm.EXPECT().GetLatestWorkspaceAppStatusesByWorkspaceIDs(gomock.Any(), ids).Return([]database.WorkspaceAppStatus{}, nil).AnyTimes() diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9b5a3818fd..c2042e687f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -905,6 +905,21 @@ func WorkspaceAppStat(t testing.TB, db database.Store, orig database.WorkspaceAp return scheme } +func WorkspaceAppStatus(t testing.TB, db database.Store, orig database.WorkspaceAppStatus) database.WorkspaceAppStatus { + appStatus, err := db.InsertWorkspaceAppStatus(genCtx, database.InsertWorkspaceAppStatusParams{ + ID: takeFirst(orig.ID, uuid.New()), + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), + AgentID: takeFirst(orig.AgentID, uuid.New()), + AppID: takeFirst(orig.AppID, uuid.New()), + State: takeFirst(orig.State, database.WorkspaceAppStatusStateWorking), + Message: takeFirst(orig.Message, ""), + Uri: takeFirst(orig.Uri, sql.NullString{}), + }) + require.NoError(t, err, "insert workspace agent status") + return appStatus +} + func WorkspaceResource(t testing.TB, db database.Store, orig database.WorkspaceResource) database.WorkspaceResource { resource, err := db.InsertWorkspaceResource(genCtx, database.InsertWorkspaceResourceParams{ ID: takeFirst(orig.ID, uuid.New()), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f90c59cb5a..d2f504964d 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -985,6 +985,13 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat return r0, r1 } +func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetLatestWorkspaceAppStatusesByAppID(ctx, appID) + m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusesByAppID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { start := time.Now() r0, r1 := m.s.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 080f0390a6..09edffc9de 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2054,6 +2054,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature) } +// GetLatestWorkspaceAppStatusesByAppID mocks base method. +func (m *MockStore) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusesByAppID", ctx, appID) + ret0, _ := ret[0].([]database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestWorkspaceAppStatusesByAppID indicates an expected call of GetLatestWorkspaceAppStatusesByAppID. +func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusesByAppID(ctx, appID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusesByAppID", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusesByAppID), ctx, appID) +} + // GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method. func (m *MockStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { m.ctrl.T.Helper() diff --git a/coderd/database/migrations/000376_task_status_notifications.down.sql b/coderd/database/migrations/000376_task_status_notifications.down.sql new file mode 100644 index 0000000000..ef1c77d88f --- /dev/null +++ b/coderd/database/migrations/000376_task_status_notifications.down.sql @@ -0,0 +1,4 @@ +-- Remove Task 'working' transition template notification +DELETE FROM notification_templates WHERE id = 'bd4b7168-d05e-4e19-ad0f-3593b77aa90f'; +-- Remove Task 'idle' transition template notification +DELETE FROM notification_templates WHERE id = 'd4a6271c-cced-4ed0-84ad-afd02a9c7799'; diff --git a/coderd/database/migrations/000376_task_status_notifications.up.sql b/coderd/database/migrations/000376_task_status_notifications.up.sql new file mode 100644 index 0000000000..0506593149 --- /dev/null +++ b/coderd/database/migrations/000376_task_status_notifications.up.sql @@ -0,0 +1,63 @@ +-- Task transition to 'working' status +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + 'bd4b7168-d05e-4e19-ad0f-3593b77aa90f', + 'Task Working', + E'Task ''{{.Labels.workspace}}'' is working', + E'The task ''{{.Labels.task}}'' transitioned to a working state.', + '[ + { + "label": "View task", + "url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}" + }, + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb, + 'Task Events', + NULL, + 'system'::notification_template_kind, + true + ); + +-- Task transition to 'idle' status +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) VALUES ( + 'd4a6271c-cced-4ed0-84ad-afd02a9c7799', + 'Task Idle', + E'Task ''{{.Labels.workspace}}'' is idle', + E'The task ''{{.Labels.task}}'' is idle and ready for input.', + '[ + { + "label": "View task", + "url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}" + }, + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb, + 'Task Events', + NULL, + 'system'::notification_template_kind, + true + ); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a43042114c..27e828e45a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -227,6 +227,7 @@ type sqlcQuerier interface { GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error) GetLastUpdateCheck(ctx context.Context) (string, error) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) + GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e4c0300e82..9d84cc9675 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -18841,6 +18841,45 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups return new_or_stale, err } +const getLatestWorkspaceAppStatusesByAppID = `-- name: GetLatestWorkspaceAppStatusesByAppID :many +SELECT id, created_at, agent_id, app_id, workspace_id, state, message, uri +FROM workspace_app_statuses +WHERE app_id = $1::uuid +ORDER BY created_at DESC, id DESC +` + +func (q *sqlQuerier) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]WorkspaceAppStatus, error) { + rows, err := q.db.QueryContext(ctx, getLatestWorkspaceAppStatusesByAppID, appID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAppStatus + for rows.Next() { + var i WorkspaceAppStatus + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.Message, + &i.Uri, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many SELECT DISTINCT ON (workspace_id) id, created_at, agent_id, app_id, workspace_id, state, message, uri diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 9f514b5203..d76e789f19 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -73,6 +73,12 @@ RETURNING *; -- name: GetWorkspaceAppStatusesByAppIDs :many SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]); +-- name: GetLatestWorkspaceAppStatusesByAppID :many +SELECT * +FROM workspace_app_statuses +WHERE app_id = @app_id::uuid +ORDER BY created_at DESC, id DESC; + -- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many SELECT DISTINCT ON (workspace_id) * diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 9c92a1a622..12adcfbb08 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -42,7 +42,7 @@ var ( TemplateWorkspaceResourceReplaced = uuid.MustParse("89d9745a-816e-4695-a17f-3d0a229e2b8d") ) -// Prebuilds-related events +// Prebuilds-related events. var ( PrebuildFailureLimitReached = uuid.MustParse("414d9331-c1fc-4761-b40c-d1f4702279eb") ) @@ -52,3 +52,9 @@ var ( TemplateTestNotification = uuid.MustParse("c425f63e-716a-4bf4-ae24-78348f706c3f") TemplateCustomNotification = uuid.MustParse("39b1e189-c857-4b0c-877a-511144c18516") ) + +// Task-related events. +var ( + TemplateTaskWorking = uuid.MustParse("bd4b7168-d05e-4e19-ad0f-3593b77aa90f") + TemplateTaskIdle = uuid.MustParse("d4a6271c-cced-4ed0-84ad-afd02a9c7799") +) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 22dd78591c..9689e6467d 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1273,6 +1273,34 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + name: "TemplateTaskWorking", + id: notifications.TemplateTaskWorking, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "task": "my-task", + "workspace": "my-workspace", + }, + Data: map[string]any{}, + }, + }, + { + name: "TemplateTaskIdle", + id: notifications.TemplateTaskIdle, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "task": "my-task", + "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/TemplateTaskIdle.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden new file mode 100644 index 0000000000..578e39e91a --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden @@ -0,0 +1,84 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Task 'my-workspace' is idle +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' is idle and ready for input. + + +View task: http://test.com/tasks/bobby/my-workspace + +View workspace: http://test.com/@bobby/my-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Task 'my-workspace' is idle + + +
+
+ 3D"Cod= +
+

+ Task 'my-workspace' is idle +

+
+

Hi Bobby,

+

The task ‘my-task’ is idle and ready for input.

+
+
+ =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/TemplateTaskWorking.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden new file mode 100644 index 0000000000..21356601f6 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden @@ -0,0 +1,85 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Task 'my-workspace' is working +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' transitioned to a working state. + + +View task: http://test.com/tasks/bobby/my-workspace + +View workspace: http://test.com/@bobby/my-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Task 'my-workspace' is working + + +
+
+ 3D"Cod= +
+

+ Task 'my-workspace' is working +

+
+

Hi Bobby,

+

The task ‘my-task’ transitioned to a working state.<= +/p> +

+
+ =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/TemplateTaskIdle.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskIdle.json.golden new file mode 100644 index 0000000000..44736053b8 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskIdle.json.golden @@ -0,0 +1,33 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Task Idle", + "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/my-workspace" + }, + { + "label": "View workspace", + "url": "http://test.com/@bobby/my-workspace" + } + ], + "labels": { + "task": "my-task", + "workspace": "my-workspace" + }, + "data": {}, + "targets": null + }, + "title": "Task 'my-workspace' is idle", + "title_markdown": "Task 'my-workspace' is idle", + "body": "The task 'my-task' is idle and ready for input.", + "body_markdown": "The task 'my-task' is idle and ready for input." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskWorking.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskWorking.json.golden new file mode 100644 index 0000000000..aba837ca77 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTaskWorking.json.golden @@ -0,0 +1,33 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Task Working", + "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/my-workspace" + }, + { + "label": "View workspace", + "url": "http://test.com/@bobby/my-workspace" + } + ], + "labels": { + "task": "my-task", + "workspace": "my-workspace" + }, + "data": {}, + "targets": null + }, + "title": "Task 'my-workspace' is working", + "title_markdown": "Task 'my-workspace' is working", + "body": "The task 'my-task' transitioned to a working state.", + "body_markdown": "The task 'my-task' transitioned to a working state." +} \ No newline at end of file diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index ddab39ed8a..4a411749a7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -36,6 +36,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/httpmw/loggermw" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -387,6 +388,17 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req // Treat the message as untrusted input. cleaned := strutil.UISanitize(req.Message) + // Get the latest statuses for the workspace app to detect no-op updates + // nolint:gocritic // This is a system restricted operation. + latestAppStatus, err := api.Database.GetLatestWorkspaceAppStatusesByAppID(dbauthz.AsSystemRestricted(ctx), app.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get latest workspace app statuses.", + Detail: err.Error(), + }) + return + } + // nolint:gocritic // This is a system restricted operation. _, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ ID: uuid.New(), @@ -415,9 +427,88 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req AgentID: &workspaceAgent.ID, }) + // Notify on state change to Working/Idle for AI tasks + api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace) + httpapi.Write(ctx, rw, http.StatusOK, nil) } +// enqueueAITaskStateNotification enqueues a notification when an AI task's app +// transitions to Working or Idle. +// No-op if: +// - the workspace agent app isn't configured as an AI task, +// - the new state equals the latest persisted state. +func (api *API) enqueueAITaskStateNotification( + ctx context.Context, + appID uuid.UUID, + latestAppStatus []database.WorkspaceAppStatus, + newAppStatus codersdk.WorkspaceAppStatusState, + workspace database.Workspace, +) { + // Select notification template based on the new state + var notificationTemplate uuid.UUID + switch newAppStatus { + case codersdk.WorkspaceAppStatusStateWorking: + notificationTemplate = notifications.TemplateTaskWorking + case codersdk.WorkspaceAppStatusStateIdle: + notificationTemplate = notifications.TemplateTaskIdle + default: + // Not a notifiable state, do nothing + return + } + + workspaceBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + api.Logger.Warn(ctx, "failed to get workspace build", slog.Error(err)) + return + } + + // Confirm Workspace Agent App is an AI Task + if workspaceBuild.HasAITask.Valid && workspaceBuild.HasAITask.Bool && + workspaceBuild.AITaskSidebarAppID.Valid && workspaceBuild.AITaskSidebarAppID.UUID == appID { + // Skip if the latest persisted state equals the new state (no new transition) + if len(latestAppStatus) > 0 && latestAppStatus[0].State == database.WorkspaceAppStatusState(newAppStatus) { + return + } + + // Use the task prompt as the "task" label, fallback to workspace name + parameters, err := api.Database.GetWorkspaceBuildParameters(ctx, workspaceBuild.ID) + if err != nil { + api.Logger.Warn(ctx, "failed to get workspace build parameters", slog.Error(err)) + return + } + taskName := workspace.Name + for _, param := range parameters { + if param.Name == codersdk.AITaskPromptParameterName { + taskName = param.Value + } + } + + if _, err := api.NotificationsEnqueuer.EnqueueWithData( + // nolint:gocritic // Need notifier actor to enqueue notifications + dbauthz.AsNotifier(ctx), + workspace.OwnerID, + notificationTemplate, + map[string]string{ + "task": taskName, + "workspace": workspace.Name, + }, + map[string]any{ + // Use a 10-second bucketed timestamp to bypass per-day dedupe, + // allowing identical content to resend within the same day + // (but not more than once every 10s). + "dedupe_bypass_ts": api.Clock.Now().UTC().Truncate(10 * time.Second), + }, + "api-workspace-agent-app-status", + // Associate this notification with related entities + workspace.ID, workspace.OwnerID, workspace.OrganizationID, appID, + ); err != nil { + api.Logger.Warn(ctx, "failed to notify of task state", slog.Error(err)) + return + } + } +} + // workspaceAgentLogs returns the logs associated with a workspace agent // // @Summary Get logs by workspace agent