mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add notification for task status (#19965)
## Description Send a notification to the workspace owner when an AI task’s app state becomes `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 the workspace 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 (UTC truncated to the minute) in the enqueue data to allow identical content to resend within the same day (but not more than once per minute). Closes: https://github.com/coder/coder/issues/19776
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user