Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitfdb0267

Browse files
authored
feat: add notification for task status (#19965)
## DescriptionSend a notification to the workspace owner when an AI task’s app statebecomes `Working` or `Idle`.An AI task is identified by a workspace build with `HasAITask = true`and `AITaskSidebarAppID` matching the agent app’s ID.## Changes* Add `TemplateTaskWorking` notification template.* Add `TemplateTaskIdle` notification template.* Add `GetLatestWorkspaceAppStatusesByAppID` SQL query to get theworkspace app statuses ordered by latest first.* Update `PATCH /workspaceagents/me/app-status` to enqueue: * `TemplateTaskWorking` when state transitions to `working` * `TemplateTaskIdle` when state transitions to `idle`* Notification labels include: * `task`: task initial prompt * `workspace`: workspace name* Notification dedupe: include a minute-bucketed timestamp (UTCtruncated to the minute) in the enqueue data to allow identical contentto resend within the same day (but not more than once per minute).Closes:#19776
1 parentabdea72 commitfdb0267

18 files changed

+689
-1
lines changed

‎coderd/aitasks_test.go‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd_test
22

33
import (
4+
"database/sql"
45
"fmt"
56
"io"
67
"net/http"
@@ -17,8 +18,12 @@ import (
1718
"github.com/coder/coder/v2/coderd/coderdtest"
1819
"github.com/coder/coder/v2/coderd/database"
1920
"github.com/coder/coder/v2/coderd/database/dbauthz"
21+
"github.com/coder/coder/v2/coderd/database/dbfake"
22+
"github.com/coder/coder/v2/coderd/database/dbgen"
2023
"github.com/coder/coder/v2/coderd/database/dbtestutil"
2124
"github.com/coder/coder/v2/coderd/database/dbtime"
25+
"github.com/coder/coder/v2/coderd/notifications"
26+
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
2227
"github.com/coder/coder/v2/coderd/util/slice"
2328
"github.com/coder/coder/v2/codersdk"
2429
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -961,3 +966,164 @@ func TestTasksCreate(t *testing.T) {
961966
assert.Equal(t,http.StatusNotFound,sdkErr.StatusCode())
962967
})
963968
}
969+
970+
funcTestTasksNotification(t*testing.T) {
971+
t.Parallel()
972+
973+
for_,tc:=range []struct {
974+
namestring
975+
latestAppStatuses []codersdk.WorkspaceAppStatusState
976+
newAppStatus codersdk.WorkspaceAppStatusState
977+
isAITaskbool
978+
isNotificationSentbool
979+
notificationTemplate uuid.UUID
980+
}{
981+
// Should not send a notification when the agent app is not an AI task.
982+
{
983+
name:"NoAITask",
984+
latestAppStatuses:nil,
985+
newAppStatus:codersdk.WorkspaceAppStatusStateWorking,
986+
isAITask:false,
987+
isNotificationSent:false,
988+
},
989+
// Should not send a notification when the new app status is neither 'Working' nor 'Idle'.
990+
{
991+
name:"NonNotifiedState",
992+
latestAppStatuses:nil,
993+
newAppStatus:codersdk.WorkspaceAppStatusStateComplete,
994+
isAITask:true,
995+
isNotificationSent:false,
996+
},
997+
// Should not send a notification when the new app status equals the latest status (Working).
998+
{
999+
name:"NonNotifiedTransition",
1000+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
1001+
newAppStatus:codersdk.WorkspaceAppStatusStateWorking,
1002+
isAITask:true,
1003+
isNotificationSent:false,
1004+
},
1005+
// Should send TemplateTaskWorking when the AI task transitions to 'Working'.
1006+
{
1007+
name:"TemplateTaskWorking",
1008+
latestAppStatuses:nil,
1009+
newAppStatus:codersdk.WorkspaceAppStatusStateWorking,
1010+
isAITask:true,
1011+
isNotificationSent:true,
1012+
notificationTemplate:notifications.TemplateTaskWorking,
1013+
},
1014+
// Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'.
1015+
{
1016+
name:"TemplateTaskWorkingFromIdle",
1017+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{
1018+
codersdk.WorkspaceAppStatusStateWorking,
1019+
codersdk.WorkspaceAppStatusStateIdle,
1020+
},// latest
1021+
newAppStatus:codersdk.WorkspaceAppStatusStateWorking,
1022+
isAITask:true,
1023+
isNotificationSent:true,
1024+
notificationTemplate:notifications.TemplateTaskWorking,
1025+
},
1026+
// Should send TemplateTaskIdle when the AI task transitions to 'Idle'.
1027+
{
1028+
name:"TemplateTaskIdle",
1029+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
1030+
newAppStatus:codersdk.WorkspaceAppStatusStateIdle,
1031+
isAITask:true,
1032+
isNotificationSent:true,
1033+
notificationTemplate:notifications.TemplateTaskIdle,
1034+
},
1035+
} {
1036+
t.Run(tc.name,func(t*testing.T) {
1037+
t.Parallel()
1038+
1039+
ctx:=testutil.Context(t,testutil.WaitShort)
1040+
notifyEnq:=&notificationstest.FakeEnqueuer{}
1041+
client,db:=coderdtest.NewWithDatabase(t,&coderdtest.Options{
1042+
DeploymentValues:coderdtest.DeploymentValues(t),
1043+
NotificationsEnqueuer:notifyEnq,
1044+
})
1045+
1046+
// Given: a member user
1047+
ownerUser:=coderdtest.CreateFirstUser(t,client)
1048+
client,memberUser:=coderdtest.CreateAnotherUser(t,client,ownerUser.OrganizationID)
1049+
1050+
// Given: a workspace build with an agent containing an App
1051+
workspaceAgentAppID:=uuid.New()
1052+
workspaceBuildID:=uuid.New()
1053+
workspaceBuildSeed:= database.WorkspaceBuild{
1054+
ID:workspaceBuildID,
1055+
}
1056+
iftc.isAITask {
1057+
workspaceBuildSeed= database.WorkspaceBuild{
1058+
ID:workspaceBuildID,
1059+
// AI Task configuration
1060+
HasAITask: sql.NullBool{Bool:true,Valid:true},
1061+
AITaskSidebarAppID: uuid.NullUUID{UUID:workspaceAgentAppID,Valid:true},
1062+
}
1063+
}
1064+
workspaceBuild:=dbfake.WorkspaceBuild(t,db, database.WorkspaceTable{
1065+
OrganizationID:ownerUser.OrganizationID,
1066+
OwnerID:memberUser.ID,
1067+
}).Seed(workspaceBuildSeed).Params(database.WorkspaceBuildParameter{
1068+
WorkspaceBuildID:workspaceBuildID,
1069+
Name:codersdk.AITaskPromptParameterName,
1070+
Value:"task prompt",
1071+
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
1072+
agent[0].Apps= []*proto.App{{
1073+
Id:workspaceAgentAppID.String(),
1074+
Slug:"ccw",
1075+
}}
1076+
returnagent
1077+
}).Do()
1078+
1079+
// Given: the workspace agent app has previous statuses
1080+
agentClient:=agentsdk.New(client.URL,agentsdk.WithFixedToken(workspaceBuild.AgentToken))
1081+
iflen(tc.latestAppStatuses)>0 {
1082+
workspace:=coderdtest.MustWorkspace(t,client,workspaceBuild.Workspace.ID)
1083+
for_,appStatus:=rangetc.latestAppStatuses {
1084+
dbgen.WorkspaceAppStatus(t,db, database.WorkspaceAppStatus{
1085+
WorkspaceID:workspaceBuild.Workspace.ID,
1086+
AgentID:workspace.LatestBuild.Resources[0].Agents[0].ID,
1087+
AppID:workspaceAgentAppID,
1088+
State:database.WorkspaceAppStatusState(appStatus),
1089+
})
1090+
}
1091+
}
1092+
1093+
// When: the agent updates the app status
1094+
err:=agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
1095+
AppSlug:"ccw",
1096+
Message:"testing",
1097+
URI:"https://example.com",
1098+
State:tc.newAppStatus,
1099+
})
1100+
require.NoError(t,err)
1101+
1102+
// Then: The workspace app status transitions successfully
1103+
workspace,err:=client.Workspace(ctx,workspaceBuild.Workspace.ID)
1104+
require.NoError(t,err)
1105+
workspaceAgent,err:=client.WorkspaceAgent(ctx,workspace.LatestBuild.Resources[0].Agents[0].ID)
1106+
require.NoError(t,err)
1107+
require.Len(t,workspaceAgent.Apps,1)
1108+
require.GreaterOrEqual(t,len(workspaceAgent.Apps[0].Statuses),1)
1109+
latestStatusIndex:=len(workspaceAgent.Apps[0].Statuses)-1
1110+
require.Equal(t,tc.newAppStatus,workspaceAgent.Apps[0].Statuses[latestStatusIndex].State)
1111+
1112+
iftc.isNotificationSent {
1113+
// Then: A notification is sent to the workspace owner (memberUser)
1114+
sent:=notifyEnq.Sent(notificationstest.WithTemplateID(tc.notificationTemplate))
1115+
require.Len(t,sent,1)
1116+
require.Equal(t,memberUser.ID,sent[0].UserID)
1117+
require.Len(t,sent[0].Labels,2)
1118+
require.Equal(t,"task prompt",sent[0].Labels["task"])
1119+
require.Equal(t,workspace.Name,sent[0].Labels["workspace"])
1120+
}else {
1121+
// Then: No notification is sent
1122+
sentWorking:=notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskWorking))
1123+
sentIdle:=notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateTaskIdle))
1124+
require.Len(t,sentWorking,0)
1125+
require.Len(t,sentIdle,0)
1126+
}
1127+
})
1128+
}
1129+
}

‎coderd/database/dbauthz/dbauthz.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2313,6 +2313,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab
23132313
returnq.db.GetLatestCryptoKeyByFeature(ctx,feature)
23142314
}
23152315

2316+
func (q*querier)GetLatestWorkspaceAppStatusesByAppID(ctx context.Context,appID uuid.UUID) ([]database.WorkspaceAppStatus,error) {
2317+
iferr:=q.authorizeContext(ctx,policy.ActionRead,rbac.ResourceSystem);err!=nil {
2318+
returnnil,err
2319+
}
2320+
returnq.db.GetLatestWorkspaceAppStatusesByAppID(ctx,appID)
2321+
}
2322+
23162323
func (q*querier)GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context,ids []uuid.UUID) ([]database.WorkspaceAppStatus,error) {
23172324
iferr:=q.authorizeContext(ctx,policy.ActionRead,rbac.ResourceSystem);err!=nil {
23182325
returnnil,err

‎coderd/database/dbauthz/dbauthz_test.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2683,6 +2683,11 @@ func (s *MethodTestSuite) TestSystemFunctions() {
26832683
dbm.EXPECT().UpdateUserLinkedID(gomock.Any(),arg).Return(l,nil).AnyTimes()
26842684
check.Args(arg).Asserts(rbac.ResourceSystem,policy.ActionUpdate).Returns(l)
26852685
}))
2686+
s.Run("GetLatestWorkspaceAppStatusesByAppID",s.Mocked(func(dbm*dbmock.MockStore,_*gofakeit.Faker,check*expects) {
2687+
appID:=uuid.New()
2688+
dbm.EXPECT().GetLatestWorkspaceAppStatusesByAppID(gomock.Any(),appID).Return([]database.WorkspaceAppStatus{},nil).AnyTimes()
2689+
check.Args(appID).Asserts(rbac.ResourceSystem,policy.ActionRead)
2690+
}))
26862691
s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs",s.Mocked(func(dbm*dbmock.MockStore,_*gofakeit.Faker,check*expects) {
26872692
ids:= []uuid.UUID{uuid.New()}
26882693
dbm.EXPECT().GetLatestWorkspaceAppStatusesByWorkspaceIDs(gomock.Any(),ids).Return([]database.WorkspaceAppStatus{},nil).AnyTimes()

‎coderd/database/dbgen/dbgen.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,21 @@ func WorkspaceAppStat(t testing.TB, db database.Store, orig database.WorkspaceAp
905905
returnscheme
906906
}
907907

908+
funcWorkspaceAppStatus(t testing.TB,db database.Store,orig database.WorkspaceAppStatus) database.WorkspaceAppStatus {
909+
appStatus,err:=db.InsertWorkspaceAppStatus(genCtx, database.InsertWorkspaceAppStatusParams{
910+
ID:takeFirst(orig.ID,uuid.New()),
911+
CreatedAt:takeFirst(orig.CreatedAt,dbtime.Now()),
912+
WorkspaceID:takeFirst(orig.WorkspaceID,uuid.New()),
913+
AgentID:takeFirst(orig.AgentID,uuid.New()),
914+
AppID:takeFirst(orig.AppID,uuid.New()),
915+
State:takeFirst(orig.State,database.WorkspaceAppStatusStateWorking),
916+
Message:takeFirst(orig.Message,""),
917+
Uri:takeFirst(orig.Uri, sql.NullString{}),
918+
})
919+
require.NoError(t,err,"insert workspace agent status")
920+
returnappStatus
921+
}
922+
908923
funcWorkspaceResource(t testing.TB,db database.Store,orig database.WorkspaceResource) database.WorkspaceResource {
909924
resource,err:=db.InsertWorkspaceResource(genCtx, database.InsertWorkspaceResourceParams{
910925
ID:takeFirst(orig.ID,uuid.New()),

‎coderd/database/dbmetrics/querymetrics.go‎

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more aboutcustomizing how changed files appear on GitHub.

‎coderd/database/dbmock/dbmock.go‎

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more aboutcustomizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Remove Task 'working' transition template notification
2+
DELETEFROM notification_templatesWHERE id='bd4b7168-d05e-4e19-ad0f-3593b77aa90f';
3+
-- Remove Task 'idle' transition template notification
4+
DELETEFROM notification_templatesWHERE id='d4a6271c-cced-4ed0-84ad-afd02a9c7799';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
-- Task transition to 'working' status
2+
INSERT INTO notification_templates (
3+
id,
4+
name,
5+
title_template,
6+
body_template,
7+
actions,
8+
"group",
9+
method,
10+
kind,
11+
enabled_by_default
12+
)VALUES (
13+
'bd4b7168-d05e-4e19-ad0f-3593b77aa90f',
14+
'Task Working',
15+
E'Task''{{.Labels.workspace}}'' is working',
16+
E'The task''{{.Labels.task}}'' transitioned to a working state.',
17+
'[
18+
{
19+
"label": "View task",
20+
"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}"
21+
},
22+
{
23+
"label": "View workspace",
24+
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
25+
}
26+
]'::jsonb,
27+
'Task Events',
28+
NULL,
29+
'system'::notification_template_kind,
30+
true
31+
);
32+
33+
-- Task transition to 'idle' status
34+
INSERT INTO notification_templates (
35+
id,
36+
name,
37+
title_template,
38+
body_template,
39+
actions,
40+
"group",
41+
method,
42+
kind,
43+
enabled_by_default
44+
)VALUES (
45+
'd4a6271c-cced-4ed0-84ad-afd02a9c7799',
46+
'Task Idle',
47+
E'Task''{{.Labels.workspace}}'' is idle',
48+
E'The task''{{.Labels.task}}'' is idle and ready for input.',
49+
'[
50+
{
51+
"label": "View task",
52+
"url": "{{base_url}}/tasks/{{.UserUsername}}/{{.Labels.workspace}}"
53+
},
54+
{
55+
"label": "View workspace",
56+
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
57+
}
58+
]'::jsonb,
59+
'Task Events',
60+
NULL,
61+
'system'::notification_template_kind,
62+
true
63+
);

‎coderd/database/querier.go‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more aboutcustomizing how changed files appear on GitHub.

‎coderd/database/queries.sql.go‎

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more aboutcustomizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp