Skip to content

Commit 3011207

Browse files
authored
feat: add display name field for tasks (#20856)
## Problem Tasks currently only expose a machine-friendly name field (e.g. `task-python-debug-a1b2`), but this value is primarily an identifier rather than a clean, descriptive label. We need a separate display-friendly name for use in the UI. This PR introduces a new `display_name` field and updates the task-name generation flow. The Claude system prompt was updated to return valid JSON with both `name` and `display_name`. The name generation logic follows a fallback chain (Anthropic > prompt sanitization > random fallback). To make task names more closely resemble their display names, the legacy `task-` prefix has been removed. For context, PR #20834 introduced a small Task icon to the workspace list to help identify workspaces associated to tasks. ## Changes - Database migration: Added `display_name` column to tasks table - Updated system prompt to generate both task name and display name as valid JSON - Task name generation now follows a fallback chain: Anthropic > prompt sanitization > random fallback - Removed `task-` prefix from task names to allow more descriptive names - Note: PR #20834 adds a Task icon to workspaces in the workspace list to distinguish task-created workspaces **Note:** UI changes will be addressed in a follow-up PR Related to: #20801
1 parent e8bf074 commit 3011207

File tree

23 files changed

+780
-124
lines changed

23 files changed

+780
-124
lines changed

cli/exp_task_status_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ func Test_TaskStatus(t *testing.T) {
189189
"owner_id": "00000000-0000-0000-0000-000000000000",
190190
"owner_name": "me",
191191
"name": "exists",
192+
"display_name": "Task exists",
192193
"template_id": "00000000-0000-0000-0000-000000000000",
193194
"template_version_id": "00000000-0000-0000-0000-000000000000",
194195
"template_name": "",
@@ -220,9 +221,10 @@ func Test_TaskStatus(t *testing.T) {
220221
switch r.URL.Path {
221222
case "/api/experimental/tasks/me/exists":
222223
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
223-
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
224-
Name: "exists",
225-
OwnerName: "me",
224+
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
225+
Name: "exists",
226+
DisplayName: "Task exists",
227+
OwnerName: "me",
226228
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
227229
Healthy: true,
228230
},

coderd/aitasks.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
"github.com/google/uuid"
1414
"golang.org/x/xerrors"
1515

16-
"cdr.dev/slog"
16+
"github.com/coder/coder/v2/coderd/taskname"
1717

18+
aiagentapi "github.com/coder/agentapi-sdk-go"
1819
"github.com/coder/coder/v2/coderd/audit"
1920
"github.com/coder/coder/v2/coderd/database"
2021
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -24,12 +25,9 @@ import (
2425
"github.com/coder/coder/v2/coderd/rbac"
2526
"github.com/coder/coder/v2/coderd/rbac/policy"
2627
"github.com/coder/coder/v2/coderd/searchquery"
27-
"github.com/coder/coder/v2/coderd/taskname"
2828
"github.com/coder/coder/v2/coderd/util/ptr"
2929
"github.com/coder/coder/v2/coderd/util/slice"
3030
"github.com/coder/coder/v2/codersdk"
31-
32-
aiagentapi "github.com/coder/agentapi-sdk-go"
3331
)
3432

3533
// @Summary Create a new AI task
@@ -111,18 +109,25 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
111109
}
112110
}
113111

114-
if taskName == "" {
115-
taskName = taskname.GenerateFallback()
112+
taskDisplayName := strings.TrimSpace(req.DisplayName)
113+
if taskDisplayName != "" {
114+
if len(taskDisplayName) > 64 {
115+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
116+
Message: "Display name must be 64 characters or less.",
117+
})
118+
return
119+
}
120+
}
116121

117-
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
118-
anthropicModel := taskname.GetAnthropicModelFromEnv()
122+
// Generate task name and display name if either is not provided
123+
if taskName == "" || taskDisplayName == "" {
124+
generatedTaskName := taskname.Generate(ctx, api.Logger, req.Input)
119125

120-
generatedName, err := taskname.Generate(ctx, req.Input, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
121-
if err != nil {
122-
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
123-
} else {
124-
taskName = generatedName
125-
}
126+
if taskName == "" {
127+
taskName = generatedTaskName.Name
128+
}
129+
if taskDisplayName == "" {
130+
taskDisplayName = generatedTaskName.DisplayName
126131
}
127132
}
128133

@@ -215,6 +220,7 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
215220
OrganizationID: templateVersion.OrganizationID,
216221
OwnerID: owner.ID,
217222
Name: taskName,
223+
DisplayName: taskDisplayName,
218224
WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation.
219225
TemplateVersionID: templateVersion.ID,
220226
TemplateParameters: []byte("{}"),
@@ -304,6 +310,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
304310
OwnerName: dbTask.OwnerUsername,
305311
OwnerAvatarURL: dbTask.OwnerAvatarUrl,
306312
Name: dbTask.Name,
313+
DisplayName: dbTask.DisplayName,
307314
TemplateID: ws.TemplateID,
308315
TemplateVersionID: dbTask.TemplateVersionID,
309316
TemplateName: ws.TemplateName,

coderd/aitasks_test.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,14 +1049,17 @@ func TestTasksCreate(t *testing.T) {
10491049
t.Parallel()
10501050

10511051
tests := []struct {
1052-
name string
1053-
taskName string
1054-
expectFallbackName bool
1055-
expectError string
1052+
name string
1053+
taskName string
1054+
taskDisplayName string
1055+
expectFallbackName bool
1056+
expectFallbackDisplayName bool
1057+
expectError string
10561058
}{
10571059
{
1058-
name: "ValidName",
1059-
taskName: "a-valid-task-name",
1060+
name: "ValidName",
1061+
taskName: "a-valid-task-name",
1062+
expectFallbackDisplayName: true,
10601063
},
10611064
{
10621065
name: "NotValidName",
@@ -1066,8 +1069,37 @@ func TestTasksCreate(t *testing.T) {
10661069
{
10671070
name: "NoNameProvided",
10681071
taskName: "",
1072+
taskDisplayName: "A valid task display name",
1073+
expectFallbackName: true,
1074+
},
1075+
{
1076+
name: "ValidDisplayName",
1077+
taskDisplayName: "A valid task display name",
10691078
expectFallbackName: true,
10701079
},
1080+
{
1081+
name: "NotValidDisplayName",
1082+
taskDisplayName: "This is a task display name with a length greater than 64 characters.",
1083+
expectError: "Display name must be 64 characters or less.",
1084+
},
1085+
{
1086+
name: "NoDisplayNameProvided",
1087+
taskName: "a-valid-task-name",
1088+
taskDisplayName: "",
1089+
expectFallbackDisplayName: true,
1090+
},
1091+
{
1092+
name: "ValidNameAndDisplayName",
1093+
taskName: "a-valid-task-name",
1094+
taskDisplayName: "A valid task display name",
1095+
},
1096+
{
1097+
name: "NoNameAndDisplayNameProvided",
1098+
taskName: "",
1099+
taskDisplayName: "",
1100+
expectFallbackName: true,
1101+
expectFallbackDisplayName: true,
1102+
},
10711103
}
10721104

10731105
for _, tt := range tests {
@@ -1098,6 +1130,7 @@ func TestTasksCreate(t *testing.T) {
10981130
TemplateVersionID: template.ActiveVersionID,
10991131
Input: "Some prompt",
11001132
Name: tt.taskName,
1133+
DisplayName: tt.taskDisplayName,
11011134
})
11021135
if tt.expectError == "" {
11031136
require.NoError(t, err)
@@ -1111,8 +1144,17 @@ func TestTasksCreate(t *testing.T) {
11111144
if !tt.expectFallbackName {
11121145
require.Equal(t, tt.taskName, task.Name)
11131146
}
1147+
1148+
// Then: We expect the correct display name to have been picked.
1149+
require.NotEmpty(t, task.DisplayName)
1150+
if !tt.expectFallbackDisplayName {
1151+
require.Equal(t, tt.taskDisplayName, task.DisplayName)
1152+
}
11141153
} else {
1115-
require.ErrorContains(t, err, tt.expectError)
1154+
var apiErr *codersdk.Error
1155+
require.ErrorAs(t, err, &apiErr)
1156+
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
1157+
require.Equal(t, apiErr.Message, tt.expectError)
11161158
}
11171159
})
11181160
}

coderd/apidoc/docs.go

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

coderd/apidoc/swagger.json

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

coderd/database/dbgen/dbgen.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"testing"
1515
"time"
1616

17+
"cdr.dev/slog"
18+
1719
"github.com/google/uuid"
1820
"github.com/sqlc-dev/pqtype"
1921
"github.com/stretchr/testify/require"
@@ -1582,11 +1584,13 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
15821584
parameters = json.RawMessage([]byte("{}"))
15831585
}
15841586

1587+
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
15851588
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
15861589
ID: takeFirst(orig.ID, uuid.New()),
15871590
OrganizationID: orig.OrganizationID,
15881591
OwnerID: orig.OwnerID,
1589-
Name: takeFirst(orig.Name, taskname.GenerateFallback()),
1592+
Name: takeFirst(orig.Name, taskName.Name),
1593+
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
15901594
WorkspaceID: orig.WorkspaceID,
15911595
TemplateVersionID: orig.TemplateVersionID,
15921596
TemplateParameters: parameters,

coderd/database/dump.sql

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
-- Drop view first before removing the display_name column from tasks
2+
DROP VIEW IF EXISTS tasks_with_status;
3+
4+
-- Remove display_name column from tasks
5+
ALTER TABLE tasks DROP COLUMN display_name;
6+
7+
-- Recreate view without the display_name column.
8+
-- This restores the view to its previous state after removing display_name from tasks.
9+
CREATE VIEW
10+
tasks_with_status
11+
AS
12+
SELECT
13+
tasks.*,
14+
CASE
15+
WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status
16+
17+
WHEN latest_build.job_status = 'failed' THEN 'error'::task_status
18+
19+
WHEN latest_build.transition IN ('stop', 'delete')
20+
AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status
21+
22+
WHEN latest_build.transition = 'start'
23+
AND latest_build.job_status = 'pending' THEN 'initializing'::task_status
24+
25+
WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN
26+
CASE
27+
WHEN agent_status.none THEN 'initializing'::task_status
28+
WHEN agent_status.connecting THEN 'initializing'::task_status
29+
WHEN agent_status.connected THEN
30+
CASE
31+
WHEN app_status.any_unhealthy THEN 'error'::task_status
32+
WHEN app_status.any_initializing THEN 'initializing'::task_status
33+
WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status
34+
ELSE 'unknown'::task_status
35+
END
36+
ELSE 'unknown'::task_status
37+
END
38+
39+
ELSE 'unknown'::task_status
40+
END AS status,
41+
task_app.*,
42+
task_owner.*
43+
FROM
44+
tasks
45+
CROSS JOIN LATERAL (
46+
SELECT
47+
vu.username AS owner_username,
48+
vu.name AS owner_name,
49+
vu.avatar_url AS owner_avatar_url
50+
FROM visible_users vu
51+
WHERE vu.id = tasks.owner_id
52+
) task_owner
53+
LEFT JOIN LATERAL (
54+
SELECT workspace_build_number, workspace_agent_id, workspace_app_id
55+
FROM task_workspace_apps task_app
56+
WHERE task_id = tasks.id
57+
ORDER BY workspace_build_number DESC
58+
LIMIT 1
59+
) task_app ON TRUE
60+
LEFT JOIN LATERAL (
61+
SELECT
62+
workspace_build.transition,
63+
provisioner_job.job_status,
64+
workspace_build.job_id
65+
FROM workspace_builds workspace_build
66+
JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id
67+
WHERE workspace_build.workspace_id = tasks.workspace_id
68+
AND workspace_build.build_number = task_app.workspace_build_number
69+
) latest_build ON TRUE
70+
CROSS JOIN LATERAL (
71+
SELECT
72+
COUNT(*) = 0 AS none,
73+
bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting,
74+
bool_and(workspace_agent.lifecycle_state = 'ready') AS connected
75+
FROM workspace_agents workspace_agent
76+
WHERE workspace_agent.id = task_app.workspace_agent_id
77+
) agent_status
78+
CROSS JOIN LATERAL (
79+
SELECT
80+
bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy,
81+
bool_or(workspace_app.health = 'initializing') AS any_initializing,
82+
bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled
83+
FROM workspace_apps workspace_app
84+
WHERE workspace_app.id = task_app.workspace_app_id
85+
) app_status
86+
WHERE
87+
tasks.deleted_at IS NULL;

0 commit comments

Comments
 (0)