diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go index c3e19f94f7..41102c8710 100644 --- a/cli/exp_task_status_test.go +++ b/cli/exp_task_status_test.go @@ -189,6 +189,7 @@ func Test_TaskStatus(t *testing.T) { "owner_id": "00000000-0000-0000-0000-000000000000", "owner_name": "me", "name": "exists", + "display_name": "Task exists", "template_id": "00000000-0000-0000-0000-000000000000", "template_version_id": "00000000-0000-0000-0000-000000000000", "template_name": "", @@ -220,9 +221,10 @@ func Test_TaskStatus(t *testing.T) { switch r.URL.Path { case "/api/experimental/tasks/me/exists": httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Name: "exists", - OwnerName: "me", + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + DisplayName: "Task exists", + OwnerName: "me", WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, diff --git a/coderd/aitasks.go b/coderd/aitasks.go index dccdf98bf2..761d4817c2 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -13,8 +13,9 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/taskname" + aiagentapi "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -24,12 +25,9 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" - "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" - - aiagentapi "github.com/coder/agentapi-sdk-go" ) // @Summary Create a new AI task @@ -111,18 +109,25 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { } } - if taskName == "" { - taskName = taskname.GenerateFallback() + taskDisplayName := strings.TrimSpace(req.DisplayName) + if taskDisplayName != "" { + if len(taskDisplayName) > 64 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Display name must be 64 characters or less.", + }) + return + } + } - if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" { - anthropicModel := taskname.GetAnthropicModelFromEnv() + // Generate task name and display name if either is not provided + if taskName == "" || taskDisplayName == "" { + generatedTaskName := taskname.Generate(ctx, api.Logger, req.Input) - generatedName, err := taskname.Generate(ctx, req.Input, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel)) - if err != nil { - api.Logger.Error(ctx, "unable to generate task name", slog.Error(err)) - } else { - taskName = generatedName - } + if taskName == "" { + taskName = generatedTaskName.Name + } + if taskDisplayName == "" { + taskDisplayName = generatedTaskName.DisplayName } } @@ -215,6 +220,7 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { OrganizationID: templateVersion.OrganizationID, OwnerID: owner.ID, Name: taskName, + DisplayName: taskDisplayName, WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation. TemplateVersionID: templateVersion.ID, TemplateParameters: []byte("{}"), @@ -304,6 +310,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod OwnerName: dbTask.OwnerUsername, OwnerAvatarURL: dbTask.OwnerAvatarUrl, Name: dbTask.Name, + DisplayName: dbTask.DisplayName, TemplateID: ws.TemplateID, TemplateVersionID: dbTask.TemplateVersionID, TemplateName: ws.TemplateName, diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index b9f8265688..c3af01e817 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -1049,14 +1049,17 @@ func TestTasksCreate(t *testing.T) { t.Parallel() tests := []struct { - name string - taskName string - expectFallbackName bool - expectError string + name string + taskName string + taskDisplayName string + expectFallbackName bool + expectFallbackDisplayName bool + expectError string }{ { - name: "ValidName", - taskName: "a-valid-task-name", + name: "ValidName", + taskName: "a-valid-task-name", + expectFallbackDisplayName: true, }, { name: "NotValidName", @@ -1066,8 +1069,37 @@ func TestTasksCreate(t *testing.T) { { name: "NoNameProvided", taskName: "", + taskDisplayName: "A valid task display name", expectFallbackName: true, }, + { + name: "ValidDisplayName", + taskDisplayName: "A valid task display name", + expectFallbackName: true, + }, + { + name: "NotValidDisplayName", + taskDisplayName: "This is a task display name with a length greater than 64 characters.", + expectError: "Display name must be 64 characters or less.", + }, + { + name: "NoDisplayNameProvided", + taskName: "a-valid-task-name", + taskDisplayName: "", + expectFallbackDisplayName: true, + }, + { + name: "ValidNameAndDisplayName", + taskName: "a-valid-task-name", + taskDisplayName: "A valid task display name", + }, + { + name: "NoNameAndDisplayNameProvided", + taskName: "", + taskDisplayName: "", + expectFallbackName: true, + expectFallbackDisplayName: true, + }, } for _, tt := range tests { @@ -1098,6 +1130,7 @@ func TestTasksCreate(t *testing.T) { TemplateVersionID: template.ActiveVersionID, Input: "Some prompt", Name: tt.taskName, + DisplayName: tt.taskDisplayName, }) if tt.expectError == "" { require.NoError(t, err) @@ -1111,8 +1144,17 @@ func TestTasksCreate(t *testing.T) { if !tt.expectFallbackName { require.Equal(t, tt.taskName, task.Name) } + + // Then: We expect the correct display name to have been picked. + require.NotEmpty(t, task.DisplayName) + if !tt.expectFallbackDisplayName { + require.Equal(t, tt.taskDisplayName, task.DisplayName) + } } else { - require.ErrorContains(t, err, tt.expectError) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Equal(t, apiErr.Message, tt.expectError) } }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2ca6355524..1b60b41717 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13277,6 +13277,9 @@ const docTemplate = `{ "codersdk.CreateTaskRequest": { "type": "object", "properties": { + "display_name": { + "type": "string" + }, "input": { "type": "string" }, @@ -17876,6 +17879,9 @@ const docTemplate = `{ "current_state": { "$ref": "#/definitions/codersdk.TaskStateEntry" }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 11eb34a1bf..0639cf08a1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11921,6 +11921,9 @@ "codersdk.CreateTaskRequest": { "type": "object", "properties": { + "display_name": { + "type": "string" + }, "input": { "type": "string" }, @@ -16358,6 +16361,9 @@ "current_state": { "$ref": "#/definitions/codersdk.TaskStateEntry" }, + "display_name": { + "type": "string" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 3f28e01ace..de0a3b3845 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "cdr.dev/slog" + "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" @@ -1582,11 +1584,13 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas parameters = json.RawMessage([]byte("{}")) } + taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt) task, err := db.InsertTask(genCtx, database.InsertTaskParams{ ID: takeFirst(orig.ID, uuid.New()), OrganizationID: orig.OrganizationID, OwnerID: orig.OwnerID, - Name: takeFirst(orig.Name, taskname.GenerateFallback()), + Name: takeFirst(orig.Name, taskName.Name), + DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName), WorkspaceID: orig.WorkspaceID, TemplateVersionID: orig.TemplateVersionID, TemplateParameters: parameters, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1a777abf95..735fc5344a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1826,9 +1826,12 @@ CREATE TABLE tasks ( template_parameters jsonb DEFAULT '{}'::jsonb NOT NULL, prompt text NOT NULL, created_at timestamp with time zone NOT NULL, - deleted_at timestamp with time zone + deleted_at timestamp with time zone, + display_name character varying(127) DEFAULT ''::character varying NOT NULL ); +COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.'; + CREATE VIEW visible_users AS SELECT users.id, users.username, @@ -1964,6 +1967,7 @@ CREATE VIEW tasks_with_status AS tasks.prompt, tasks.created_at, tasks.deleted_at, + tasks.display_name, CASE WHEN (tasks.workspace_id IS NULL) THEN 'pending'::task_status WHEN (build_status.status <> 'active'::task_status) THEN build_status.status diff --git a/coderd/database/migrations/000400_add_task_display_name.down.sql b/coderd/database/migrations/000400_add_task_display_name.down.sql new file mode 100644 index 0000000000..b054907de1 --- /dev/null +++ b/coderd/database/migrations/000400_add_task_display_name.down.sql @@ -0,0 +1,87 @@ +-- Drop view first before removing the display_name column from tasks +DROP VIEW IF EXISTS tasks_with_status; + +-- Remove display_name column from tasks +ALTER TABLE tasks DROP COLUMN display_name; + +-- Recreate view without the display_name column. +-- This restores the view to its previous state after removing display_name from tasks. +CREATE VIEW + tasks_with_status +AS +SELECT + tasks.*, + CASE + WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status + + WHEN latest_build.job_status = 'failed' THEN 'error'::task_status + + WHEN latest_build.transition IN ('stop', 'delete') + AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status + + WHEN latest_build.transition = 'start' + AND latest_build.job_status = 'pending' THEN 'initializing'::task_status + + WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN + CASE + WHEN agent_status.none THEN 'initializing'::task_status + WHEN agent_status.connecting THEN 'initializing'::task_status + WHEN agent_status.connected THEN + CASE + WHEN app_status.any_unhealthy THEN 'error'::task_status + WHEN app_status.any_initializing THEN 'initializing'::task_status + WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status + ELSE 'unknown'::task_status + END + ELSE 'unknown'::task_status + END + + ELSE 'unknown'::task_status + END AS status, + task_app.*, + task_owner.* +FROM + tasks + CROSS JOIN LATERAL ( + SELECT + vu.username AS owner_username, + vu.name AS owner_name, + vu.avatar_url AS owner_avatar_url + FROM visible_users vu + WHERE vu.id = tasks.owner_id + ) task_owner + LEFT JOIN LATERAL ( + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps task_app + WHERE task_id = tasks.id + ORDER BY workspace_build_number DESC + LIMIT 1 + ) task_app ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM workspace_builds workspace_build + JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id + WHERE workspace_build.workspace_id = tasks.workspace_id + AND workspace_build.build_number = task_app.workspace_build_number + ) latest_build ON TRUE + CROSS JOIN LATERAL ( + SELECT + COUNT(*) = 0 AS none, + bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting, + bool_and(workspace_agent.lifecycle_state = 'ready') AS connected + FROM workspace_agents workspace_agent + WHERE workspace_agent.id = task_app.workspace_agent_id + ) agent_status + CROSS JOIN LATERAL ( + SELECT + bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy, + bool_or(workspace_app.health = 'initializing') AS any_initializing, + bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled + FROM workspace_apps workspace_app + WHERE workspace_app.id = task_app.workspace_app_id + ) app_status + WHERE + tasks.deleted_at IS NULL; diff --git a/coderd/database/migrations/000400_add_task_display_name.up.sql b/coderd/database/migrations/000400_add_task_display_name.up.sql new file mode 100644 index 0000000000..591802ce1e --- /dev/null +++ b/coderd/database/migrations/000400_add_task_display_name.up.sql @@ -0,0 +1,158 @@ +-- Add display_name column to tasks table +ALTER TABLE tasks ADD COLUMN display_name VARCHAR(127) NOT NULL DEFAULT ''; +COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.'; + +-- Backfill existing tasks with truncated prompt as display name +-- Replace newlines/tabs with spaces, truncate to 64 characters and add ellipsis if truncated +UPDATE tasks +SET display_name = CASE + WHEN LENGTH(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g')) > 64 + THEN LEFT(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g'), 63) || '…' + ELSE REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g') + END +WHERE display_name = ''; + +-- Recreate the tasks_with_status view to pick up the new display_name column. +-- PostgreSQL resolves the tasks.* wildcard when the view is created, not when +-- it's queried, so the view must be recreated after adding columns to tasks. +DROP VIEW IF EXISTS tasks_with_status; + +CREATE VIEW + tasks_with_status +AS +SELECT + tasks.*, + -- Combine component statuses with precedence: build -> agent -> app. + CASE + WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status + WHEN build_status.status != 'active' THEN build_status.status::task_status + WHEN agent_status.status != 'active' THEN agent_status.status::task_status + ELSE app_status.status::task_status + END AS status, + -- Attach debug information for troubleshooting status. + jsonb_build_object( + 'build', jsonb_build_object( + 'transition', latest_build_raw.transition, + 'job_status', latest_build_raw.job_status, + 'computed', build_status.status + ), + 'agent', jsonb_build_object( + 'lifecycle_state', agent_raw.lifecycle_state, + 'computed', agent_status.status + ), + 'app', jsonb_build_object( + 'health', app_raw.health, + 'computed', app_status.status + ) + ) AS status_debug, + task_app.*, + agent_raw.lifecycle_state AS workspace_agent_lifecycle_state, + app_raw.health AS workspace_app_health, + task_owner.* +FROM + tasks + CROSS JOIN LATERAL ( + SELECT + vu.username AS owner_username, + vu.name AS owner_name, + vu.avatar_url AS owner_avatar_url + FROM + visible_users vu + WHERE + vu.id = tasks.owner_id + ) task_owner + LEFT JOIN LATERAL ( + SELECT + task_app.workspace_build_number, + task_app.workspace_agent_id, + task_app.workspace_app_id + FROM + task_workspace_apps task_app + WHERE + task_id = tasks.id + ORDER BY + task_app.workspace_build_number DESC + LIMIT 1 + ) task_app ON TRUE + + -- Join the raw data for computing task status. + LEFT JOIN LATERAL ( + SELECT + workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM + workspace_builds workspace_build + JOIN + provisioner_jobs provisioner_job + ON provisioner_job.id = workspace_build.job_id + WHERE + workspace_build.workspace_id = tasks.workspace_id + AND workspace_build.build_number = task_app.workspace_build_number + ) latest_build_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_agent.lifecycle_state + FROM + workspace_agents workspace_agent + WHERE + workspace_agent.id = task_app.workspace_agent_id + ) agent_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_app.health + FROM + workspace_apps workspace_app + WHERE + workspace_app.id = task_app.workspace_app_id + ) app_raw ON TRUE + + -- Compute the status for each component. + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status + WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status + WHEN + latest_build_raw.transition IN ('stop', 'delete') + AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status = 'pending' THEN 'initializing'::task_status + -- Build is running or done, defer to agent/app status. + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) build_status + CROSS JOIN LATERAL ( + SELECT + CASE + -- No agent or connecting. + WHEN + agent_raw.lifecycle_state IS NULL + OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status + -- Agent is running, defer to app status. + -- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed. + -- This may or may not affect the task status but this has to be caught by app health check. + WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status + -- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop + -- build to be running. + -- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`, + -- but we cannot use them because the values were added in a migration. + WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status + ELSE 'unknown'::task_status + END AS status + ) agent_status + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status + WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status + WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) app_status + WHERE + tasks.deleted_at IS NULL; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index ec89630767..704eeafeca 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -143,6 +143,7 @@ func (t Task) TaskTable() TaskTable { OrganizationID: t.OrganizationID, OwnerID: t.OwnerID, Name: t.Name, + DisplayName: t.DisplayName, WorkspaceID: t.WorkspaceID, TemplateVersionID: t.TemplateVersionID, TemplateParameters: t.TemplateParameters, diff --git a/coderd/database/models.go b/coderd/database/models.go index 10cd5a9c03..e55cd1f24b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4218,6 +4218,7 @@ type Task struct { Prompt string `db:"prompt" json:"prompt"` CreatedAt time.Time `db:"created_at" json:"created_at"` DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` + DisplayName string `db:"display_name" json:"display_name"` Status TaskStatus `db:"status" json:"status"` StatusDebug json.RawMessage `db:"status_debug" json:"status_debug"` WorkspaceBuildNumber sql.NullInt32 `db:"workspace_build_number" json:"workspace_build_number"` @@ -4241,6 +4242,8 @@ type TaskTable struct { Prompt string `db:"prompt" json:"prompt"` CreatedAt time.Time `db:"created_at" json:"created_at"` DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` + // Display name is a custom, human-friendly task name. + DisplayName string `db:"display_name" json:"display_name"` } type TaskWorkspaceApp struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 110f1e8ea6..d592a0f4b5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13187,7 +13187,7 @@ SET WHERE id = $2::uuid AND deleted_at IS NULL -RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name ` type DeleteTaskParams struct { @@ -13209,12 +13209,13 @@ func (q *sqlQuerier) DeleteTask(ctx context.Context, arg DeleteTaskParams) (Task &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, ) return i, err } const getTaskByID = `-- name: GetTaskByID :one -SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid ` func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error) { @@ -13231,6 +13232,7 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, &i.Status, &i.StatusDebug, &i.WorkspaceBuildNumber, @@ -13246,7 +13248,7 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error } const getTaskByOwnerIDAndName = `-- name: GetTaskByOwnerIDAndName :one -SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE owner_id = $1::uuid AND deleted_at IS NULL @@ -13272,6 +13274,7 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, &i.Status, &i.StatusDebug, &i.WorkspaceBuildNumber, @@ -13287,7 +13290,7 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO } const getTaskByWorkspaceID = `-- name: GetTaskByWorkspaceID :one -SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid ` func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error) { @@ -13304,6 +13307,7 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid. &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, &i.Status, &i.StatusDebug, &i.WorkspaceBuildNumber, @@ -13320,10 +13324,10 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid. const insertTask = `-- name: InsertTask :one INSERT INTO tasks - (id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at) + (id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9) -RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name ` type InsertTaskParams struct { @@ -13331,6 +13335,7 @@ type InsertTaskParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"` @@ -13344,6 +13349,7 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task arg.OrganizationID, arg.OwnerID, arg.Name, + arg.DisplayName, arg.WorkspaceID, arg.TemplateVersionID, arg.TemplateParameters, @@ -13362,12 +13368,13 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, ) return i, err } const listTasks = `-- name: ListTasks :many -SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws WHERE tws.deleted_at IS NULL AND CASE WHEN $1::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = $1::UUID ELSE TRUE END AND CASE WHEN $2::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = $2::UUID ELSE TRUE END @@ -13401,6 +13408,7 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, &i.Status, &i.StatusDebug, &i.WorkspaceBuildNumber, @@ -13433,7 +13441,7 @@ SET WHERE id = $2::uuid AND deleted_at IS NULL -RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name ` type UpdateTaskPromptParams struct { @@ -13455,6 +13463,7 @@ func (q *sqlQuerier) UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptP &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, ) return i, err } @@ -13476,7 +13485,7 @@ WHERE AND w.id = $2 AND tv.id = tasks.template_version_id RETURNING - tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at + tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at, tasks.display_name ` type UpdateTaskWorkspaceIDParams struct { @@ -13498,6 +13507,7 @@ func (q *sqlQuerier) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWo &i.Prompt, &i.CreatedAt, &i.DeletedAt, + &i.DisplayName, ) return i, err } diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 32b048f35e..52e259953f 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -1,8 +1,8 @@ -- name: InsertTask :one INSERT INTO tasks - (id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at) + (id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; -- name: UpdateTaskWorkspaceID :one diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go index 734c23eb3d..3aabd8bf33 100644 --- a/coderd/taskname/taskname.go +++ b/coderd/taskname/taskname.go @@ -2,39 +2,82 @@ package taskname import ( "context" + "encoding/json" "fmt" "io" "math/rand/v2" "os" + "regexp" "strings" + "cdr.dev/slog" + "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" "github.com/coder/aisdk-go" + strutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) const ( defaultModel = anthropic.ModelClaude3_5HaikuLatest - systemPrompt = `Generate a short workspace name from this AI task prompt. + systemPrompt = `Generate a short task display name and name from this AI task prompt. +Identify the main task (the core action and subject) and base both names on it. +The task display name and name should be as similar as possible so a human can easily associate them. -Requirements: +Requirements for task display name (generate this first): +- Human-readable description +- Maximum 64 characters total +- Should concisely describe the main task + +Requirements for task name: +- Should be derived from the display name - Only lowercase letters, numbers, and hyphens -- Start with "task-" +- No spaces or underscores - Maximum 27 characters total -- Descriptive of the main task +- Should concisely describe the main task + +Output format (must be valid JSON): +{ + "display_name": "", + "task_name": "" +} Examples: -- "Help me debug a Python script" → "task-python-debug" -- "Create a React dashboard component" → "task-react-dashboard" -- "Analyze sales data from Q3" → "task-analyze-q3-sales" -- "Set up CI/CD pipeline" → "task-setup-cicd" +Prompt: "Help me debug a Python script" → +{ + "display_name": "Debug Python script", + "task_name": "python-debug" +} -If you cannot create a suitable name: -- Respond with "task-unnamed"` +Prompt: "Create a React dashboard component" → +{ + "display_name": "React dashboard component", + "task_name": "react-dashboard" +} + +Prompt: "Analyze sales data from Q3" → +{ + "display_name": "Analyze Q3 sales data", + "task_name": "analyze-q3-sales" +} + +Prompt: "Set up CI/CD pipeline" → +{ + "display_name": "CI/CD pipeline setup", + "task_name": "setup-cicd" +} + +If a suitable name cannot be created, output exactly: +{ + "display_name": "Task Unnamed", + "task_name": "task-unnamed" +} + +Do not include any additional keys, explanations, or text outside the JSON.` ) var ( @@ -42,30 +85,16 @@ var ( ErrNoNameGenerated = xerrors.New("no task name generated") ) -type options struct { - apiKey string - model anthropic.Model +type TaskName struct { + Name string `json:"task_name"` + DisplayName string `json:"display_name"` } -type Option func(o *options) - -func WithAPIKey(apiKey string) Option { - return func(o *options) { - o.apiKey = apiKey - } -} - -func WithModel(model anthropic.Model) Option { - return func(o *options) { - o.model = model - } -} - -func GetAnthropicAPIKeyFromEnv() string { +func getAnthropicAPIKeyFromEnv() string { return os.Getenv("ANTHROPIC_API_KEY") } -func GetAnthropicModelFromEnv() anthropic.Model { +func getAnthropicModelFromEnv() anthropic.Model { return anthropic.Model(os.Getenv("ANTHROPIC_MODEL")) } @@ -79,33 +108,85 @@ func generateSuffix() string { return fmt.Sprintf("%04x", num) } -func GenerateFallback() string { +// generateFallback generates a random task name when other methods fail. +// Uses Docker-style name generation with a collision-resistant suffix. +func generateFallback() TaskName { // We have a 32 character limit for the name. - // We have a 5 character prefix `task-`. // We have a 5 character suffix `-ffff`. - // This leaves us with 22 characters for the middle. + // This leaves us with 27 characters for the name. // - // Unfortunately, `namesgenerator.GetRandomName(0)` will - // generate names that are longer than 22 characters, so - // we just trim these down to length. + // `namesgenerator.GetRandomName(0)` can generate names + // up to 27 characters, but we truncate defensively. name := strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-") - name = name[:min(len(name), 22)] + name = name[:min(len(name), 27)] name = strings.TrimSuffix(name, "-") - return fmt.Sprintf("task-%s-%s", name, generateSuffix()) + taskName := fmt.Sprintf("%s-%s", name, generateSuffix()) + displayName := strings.ReplaceAll(name, "-", " ") + if len(displayName) > 0 { + displayName = strings.ToUpper(displayName[:1]) + displayName[1:] + } + + return TaskName{ + Name: taskName, + DisplayName: displayName, + } } -func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) { - o := options{} - for _, opt := range opts { - opt(&o) +// generateFromPrompt creates a task name directly from the prompt by sanitizing it. +// This is used as a fallback when Claude fails to generate a name. +func generateFromPrompt(prompt string) (TaskName, error) { + // Normalize newlines and tabs to spaces + prompt = regexp.MustCompile(`[\n\r\t]+`).ReplaceAllString(prompt, " ") + + // Truncate prompt to 27 chars with full words for task name generation + truncatedForName := prompt + if len(prompt) > 27 { + truncatedForName = strutil.Truncate(prompt, 27, strutil.TruncateWithFullWords) } - if o.model == "" { - o.model = defaultModel + // Generate task name from truncated prompt + name := strings.ToLower(truncatedForName) + // Replace whitespace (\t \r \n and spaces) sequences with hyphens + name = regexp.MustCompile(`\s+`).ReplaceAllString(name, "-") + // Remove all characters except lowercase letters, numbers, and hyphens + name = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(name, "") + // Collapse multiple consecutive hyphens into a single hyphen + name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-") + // Remove leading and trailing hyphens + name = strings.Trim(name, "-") + + if len(name) == 0 { + return TaskName{}, ErrNoNameGenerated } - if o.apiKey == "" { - return "", ErrNoAPIKey + + taskName := fmt.Sprintf("%s-%s", name, generateSuffix()) + + // Use the initial prompt as display name, truncated to 64 chars with full words + displayName := strutil.Truncate(prompt, 64, strutil.TruncateWithFullWords, strutil.TruncateWithEllipsis) + displayName = strings.TrimSpace(displayName) + if len(displayName) == 0 { + // Ensure display name is never empty + displayName = strings.ReplaceAll(name, "-", " ") + } + displayName = strings.ToUpper(displayName[:1]) + displayName[1:] + + return TaskName{ + Name: taskName, + DisplayName: displayName, + }, nil +} + +// generateFromAnthropic uses Claude (Anthropic) to generate semantic task and display names from a user prompt. +// It sends the prompt to Claude with a structured system prompt requesting JSON output containing both names. +// Returns an error if the API call fails, the response is invalid, or Claude returns an "unnamed" placeholder. +func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, model anthropic.Model) (TaskName, error) { + anthropicModel := model + if anthropicModel == "" { + anthropicModel = defaultModel + } + if apiKey == "" { + return TaskName{}, ErrNoAPIKey } conversation := []aisdk.Message{ @@ -126,42 +207,95 @@ func Generate(ctx context.Context, prompt string, opts ...Option) (string, error } anthropicOptions := anthropic.DefaultClientOptions() - anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey)) + anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(apiKey)) anthropicClient := anthropic.NewClient(anthropicOptions...) - stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation) + stream, err := anthropicDataStream(ctx, anthropicClient, anthropicModel, conversation) if err != nil { - return "", xerrors.Errorf("create anthropic data stream: %w", err) + return TaskName{}, xerrors.Errorf("create anthropic data stream: %w", err) } var acc aisdk.DataStreamAccumulator stream = stream.WithAccumulator(&acc) if err := stream.Pipe(io.Discard); err != nil { - return "", xerrors.Errorf("pipe data stream") + return TaskName{}, xerrors.Errorf("pipe data stream") } if len(acc.Messages()) == 0 { - return "", ErrNoNameGenerated + return TaskName{}, ErrNoNameGenerated } - taskName := acc.Messages()[0].Content - if taskName == "task-unnamed" { - return "", ErrNoNameGenerated + // Parse the JSON response + var taskNameResponse TaskName + if err := json.Unmarshal([]byte(acc.Messages()[0].Content), &taskNameResponse); err != nil { + return TaskName{}, xerrors.Errorf("failed to parse anthropic response: %w", err) + } + + taskNameResponse.Name = strings.TrimSpace(taskNameResponse.Name) + taskNameResponse.DisplayName = strings.TrimSpace(taskNameResponse.DisplayName) + + if taskNameResponse.Name == "" || taskNameResponse.Name == "task-unnamed" { + return TaskName{}, xerrors.Errorf("anthropic returned invalid task name: %q", taskNameResponse.Name) + } + + if taskNameResponse.DisplayName == "" || taskNameResponse.DisplayName == "Task Unnamed" { + return TaskName{}, xerrors.Errorf("anthropic returned invalid task display name: %q", taskNameResponse.DisplayName) } // We append a suffix to the end of the task name to reduce // the chance of collisions. We truncate the task name to - // to a maximum of 27 bytes, so that when we append the + // a maximum of 27 bytes, so that when we append the // 5 byte suffix (`-` and 4 byte hex slug), it should // remain within the 32 byte workspace name limit. - taskName = taskName[:min(len(taskName), 27)] - taskName = fmt.Sprintf("%s-%s", taskName, generateSuffix()) - if err := codersdk.NameValid(taskName); err != nil { - return "", xerrors.Errorf("generated name %v not valid: %w", taskName, err) + name := taskNameResponse.Name[:min(len(taskNameResponse.Name), 27)] + name = strings.TrimSuffix(name, "-") + name = fmt.Sprintf("%s-%s", name, generateSuffix()) + if err := codersdk.NameValid(name); err != nil { + return TaskName{}, xerrors.Errorf("generated name %v not valid: %w", name, err) } - return taskName, nil + displayName := taskNameResponse.DisplayName + displayName = strings.TrimSpace(displayName) + if len(displayName) == 0 { + // Ensure display name is never empty + displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ") + } + displayName = strings.ToUpper(displayName[:1]) + displayName[1:] + + return TaskName{ + Name: name, + DisplayName: displayName, + }, nil +} + +// Generate creates a task name and display name from a user prompt. +// It attempts multiple strategies in order of preference: +// 1. Use Claude (Anthropic) to generate semantic names from the prompt if an API key is available +// 2. Sanitize the prompt directly into a valid task name +// 3. Generate a random name as a final fallback +// +// A suffix is always appended to task names to reduce collision risk. +// This function always succeeds and returns a valid TaskName. +func Generate(ctx context.Context, logger slog.Logger, prompt string) TaskName { + if anthropicAPIKey := getAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" { + taskName, err := generateFromAnthropic(ctx, prompt, anthropicAPIKey, getAnthropicModelFromEnv()) + if err == nil { + return taskName + } + // Anthropic failed, fall through to next fallback + logger.Error(ctx, "unable to generate task name and display name from Anthropic", slog.Error(err)) + } + + // Try generating from prompt + taskName, err := generateFromPrompt(prompt) + if err == nil { + return taskName + } + logger.Warn(ctx, "unable to generate task name and display name from prompt", slog.Error(err)) + + // Final fallback + return generateFallback() } func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) { @@ -171,8 +305,15 @@ func anthropicDataStream(ctx context.Context, client anthropic.Client, model ant } return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ - Model: model, - MaxTokens: 24, + Model: model, + // MaxTokens is set to 100 based on the maximum expected output size. + // The worst-case JSON output is 134 characters: + // - Base structure: 43 chars (including formatting) + // - task_name: 27 chars max + // - display_name: 64 chars max + // Using Anthropic's token counting API, this worst-case output tokenizes to 70 tokens. + // We set MaxTokens to 100 to provide a safety buffer. + MaxTokens: 100, System: system, Messages: messages, })), nil diff --git a/coderd/taskname/taskname_internal_test.go b/coderd/taskname/taskname_internal_test.go new file mode 100644 index 0000000000..4613123250 --- /dev/null +++ b/coderd/taskname/taskname_internal_test.go @@ -0,0 +1,164 @@ +package taskname + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestGenerateFallback(t *testing.T) { + t.Parallel() + + taskName := generateFallback() + err := codersdk.NameValid(taskName.Name) + require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", taskName.Name) + require.NotEmpty(t, taskName.DisplayName) +} + +func TestGenerateFromPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prompt string + expectError bool + expectedName string + expectedDisplayName string + }{ + { + name: "EmptyPrompt", + prompt: "", + expectError: true, + }, + { + name: "OnlySpaces", + prompt: " ", + expectError: true, + }, + { + name: "OnlySpecialCharacters", + prompt: "!@#$%^&*()", + expectError: true, + }, + { + name: "UppercasePrompt", + prompt: "BUILD MY APP", + expectError: false, + expectedName: "build-my-app", + expectedDisplayName: "BUILD MY APP", + }, + { + name: "PromptWithApostrophes", + prompt: "fix user's dashboard", + expectError: false, + expectedName: "fix-users-dashboard", + expectedDisplayName: "Fix user's dashboard", + }, + { + name: "LongPrompt", + prompt: strings.Repeat("a", 100), + expectError: false, + expectedName: strings.Repeat("a", 27), + expectedDisplayName: "A" + strings.Repeat("a", 62) + "…", + }, + { + name: "PromptWithMultipleSpaces", + prompt: "build my app", + expectError: false, + expectedName: "build-my-app", + expectedDisplayName: "Build my app", + }, + { + name: "PromptWithNewlines", + prompt: "build\nmy\napp", + expectError: false, + expectedName: "build-my-app", + expectedDisplayName: "Build my app", + }, + { + name: "TruncatesLongPromptAtWordBoundary", + prompt: "implement real-time notifications dashboard", + expectError: false, + expectedName: "implement-real-time", + expectedDisplayName: "Implement real-time notifications dashboard", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + taskName, err := generateFromPrompt(tc.prompt) + + if tc.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + // Validate task name + require.Contains(t, taskName.Name, fmt.Sprintf("%s-", tc.expectedName)) + require.NoError(t, codersdk.NameValid(taskName.Name)) + + // Validate task display name + require.NotEmpty(t, taskName.DisplayName) + require.Equal(t, tc.expectedDisplayName, taskName.DisplayName) + }) + } +} + +func TestGenerateFromAnthropic(t *testing.T) { + t.Parallel() + + apiKey := getAnthropicAPIKeyFromEnv() + if apiKey == "" { + t.Skip("Skipping test as ANTHROPIC_API_KEY not set") + } + + tests := []struct { + name string + prompt string + }{ + { + name: "SimplePrompt", + prompt: "Create a finance planning app", + }, + { + name: "TechnicalPrompt", + prompt: "Debug authentication middleware for OAuth2", + }, + { + name: "ShortPrompt", + prompt: "Fix bug", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + taskName, err := generateFromAnthropic(ctx, tc.prompt, apiKey, getAnthropicModelFromEnv()) + require.NoError(t, err) + + t.Log("Task name:", taskName.Name) + t.Log("Task display name:", taskName.DisplayName) + + // Validate task name + require.NotEmpty(t, taskName.DisplayName) + require.NoError(t, codersdk.NameValid(taskName.Name)) + + // Validate display name + require.NotEmpty(t, taskName.DisplayName) + require.NotEqual(t, "task-unnamed", taskName.Name) + require.NotEqual(t, "Task Unnamed", taskName.DisplayName) + }) + } +} diff --git a/coderd/taskname/taskname_test.go b/coderd/taskname/taskname_test.go index 3eb26ef1d4..3143337092 100644 --- a/coderd/taskname/taskname_test.go +++ b/coderd/taskname/taskname_test.go @@ -15,42 +15,51 @@ const ( anthropicEnvVar = "ANTHROPIC_API_KEY" ) -func TestGenerateFallback(t *testing.T) { - t.Parallel() - - name := taskname.GenerateFallback() - err := codersdk.NameValid(name) - require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", name) -} - -func TestGenerateTaskName(t *testing.T) { - t.Parallel() - - t.Run("Fallback", func(t *testing.T) { - t.Parallel() +func TestGenerate(t *testing.T) { + t.Run("FromPrompt", func(t *testing.T) { + // Ensure no API key in env for this test + t.Setenv("ANTHROPIC_API_KEY", "") ctx := testutil.Context(t, testutil.WaitShort) - name, err := taskname.Generate(ctx, "Some random prompt") - require.ErrorIs(t, err, taskname.ErrNoAPIKey) - require.Equal(t, "", name) + taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app") + + // Should succeed via prompt sanitization + require.NoError(t, codersdk.NameValid(taskName.Name)) + require.Contains(t, taskName.Name, "create-a-finance-planning-") + require.NotEmpty(t, taskName.DisplayName) + require.Equal(t, "Create a finance planning app", taskName.DisplayName) }) - t.Run("Anthropic", func(t *testing.T) { - t.Parallel() - + t.Run("FromAnthropic", func(t *testing.T) { apiKey := os.Getenv(anthropicEnvVar) if apiKey == "" { t.Skipf("Skipping test as %s not set", anthropicEnvVar) } + // Set API key for this test + t.Setenv("ANTHROPIC_API_KEY", apiKey) + ctx := testutil.Context(t, testutil.WaitShort) - name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey)) - require.NoError(t, err) - require.NotEqual(t, "", name) + taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app") - err = codersdk.NameValid(name) - require.NoError(t, err, "name should be valid") + // Should succeed with Claude-generated names + require.NoError(t, codersdk.NameValid(taskName.Name)) + require.NotEmpty(t, taskName.DisplayName) + }) + + t.Run("Fallback", func(t *testing.T) { + // Ensure no API key + t.Setenv("ANTHROPIC_API_KEY", "") + + ctx := testutil.Context(t, testutil.WaitShort) + + // Use a prompt that can't be sanitized (only special chars) + taskName := taskname.Generate(ctx, testutil.Logger(t), "!@#$%^&*()") + + // Should fall back to random name + require.NoError(t, codersdk.NameValid(taskName.Name)) + require.NotEmpty(t, taskName.DisplayName) }) } diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index ea2297c663..894e2e7bed 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -35,6 +35,7 @@ type CreateTaskRequest struct { TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` Input string `json:"input"` Name string `json:"name,omitempty"` + DisplayName string `json:"display_name,omitempty"` } // CreateTask creates a new task. @@ -128,6 +129,7 @@ type Task struct { OwnerName string `json:"owner_name" table:"owner name"` OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"owner avatar url"` Name string `json:"name" table:"name,default_sort"` + DisplayName string `json:"display_name" table:"display_name"` TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"` TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid" table:"template version id"` TemplateName string `json:"template_name" table:"template name"` diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 381bb61ddb..29e9fe7b6f 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -32,7 +32,7 @@ We track the following resources: | OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| | PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| +| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
use_terraform_workspace_cachetrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index 206cf273b5..5f445a253a 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -49,6 +49,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user} \ ```json { + "display_name": "string", "input": "string", "name": "string", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 5a52ec8a30..f3347ab323 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2085,6 +2085,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "display_name": "string", "input": "string", "name": "string", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", @@ -2096,6 +2097,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | |------------------------------|--------|----------|--------------|-------------| +| `display_name` | string | false | | | | `input` | string | false | | | | `name` | string | false | | | | `template_version_id` | string | false | | | @@ -7805,6 +7807,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "timestamp": "2019-08-24T14:15:22Z", "uri": "string" }, + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initial_prompt": "string", "name": "string", @@ -7848,6 +7851,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| |-----------------------------|----------------------------------------------------------------------|----------|--------------|-------------| | `created_at` | string | false | | | | `current_state` | [codersdk.TaskStateEntry](#codersdktaskstateentry) | false | | | +| `display_name` | string | false | | | | `id` | string | false | | | | `initial_prompt` | string | false | | | | `name` | string | false | | | @@ -8032,6 +8036,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "timestamp": "2019-08-24T14:15:22Z", "uri": "string" }, + "display_name": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initial_prompt": "string", "name": "string", diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e5661687a1..7006c434c6 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -353,6 +353,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "organization_id": ActionIgnore, // Never changes. "owner_id": ActionTrack, "name": ActionTrack, + "display_name": ActionTrack, "workspace_id": ActionTrack, "template_version_id": ActionTrack, "template_parameters": ActionTrack, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1a2873dcc4..8a8eafa945 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1203,6 +1203,7 @@ export interface CreateTaskRequest { readonly template_version_preset_id?: string; readonly input: string; readonly name?: string; + readonly display_name?: string; } // From codersdk/organizations.go @@ -4732,6 +4733,7 @@ export interface Task { readonly owner_name: string; readonly owner_avatar_url?: string; readonly name: string; + readonly display_name: string; readonly template_id: string; readonly template_version_id: string; readonly template_name: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1fd7891ff0..c1516ba3bb 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4997,6 +4997,7 @@ export const MockAIPromptPresets: TypesGen.Preset[] = [ export const MockTask = { id: "test-task", name: "task-wild-test-123", + display_name: "Task wild test 123", organization_id: MockOrganization.id, owner_id: MockUserOwner.id, owner_name: MockUserOwner.username,