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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user