From eaf86a753b8aea97ec3088c730248ffdcc7c2021 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:24:25 +0000 Subject: [PATCH] feat: add template abstract metadata --- coderd/apidoc/docs.go | 14 ++ coderd/apidoc/swagger.json | 14 ++ coderd/database/dbgen/dbgen.go | 1 + coderd/database/dump.sql | 6 +- .../000516_templates_abstract.down.sql | 17 +++ .../000516_templates_abstract.up.sql | 20 +++ coderd/database/modelqueries.go | 1 + coderd/database/models.go | 3 + coderd/database/queries.sql.go | 40 +++--- coderd/database/queries/templates.sql | 22 ++-- coderd/templates.go | 4 + coderd/templates_meta_update.go | 2 + coderd/templates_meta_update_internal_test.go | 16 +++ coderd/templates_test.go | 123 +++++++++++++++++- coderd/x/chatd/chattool/listtemplates.go | 5 +- coderd/x/chatd/chattool/listtemplates_test.go | 115 ++++++++++++++++ coderd/x/chatd/chattool/readtemplate.go | 3 + coderd/x/chatd/chattool/readtemplate_test.go | 71 ++++++++++ codersdk/organizations.go | 3 + codersdk/templates.go | 30 +++-- codersdk/toolsdk/chatgpt.go | 9 +- codersdk/toolsdk/chatgpt_test.go | 45 +++++++ codersdk/toolsdk/toolsdk.go | 7 +- codersdk/toolsdk/toolsdk_test.go | 23 ++++ docs/admin/security/audit-logs.md | 68 +++++----- docs/reference/api/schemas.md | 6 + docs/reference/api/templates.md | 10 ++ enterprise/audit/table.go | 1 + site/src/api/typesGenerated.ts | 15 +++ .../TemplateSettingsForm.tsx | 17 +++ .../TemplateSettingsPage.test.tsx | 28 ++++ .../TemplateSettingsPageView.stories.tsx | 10 ++ site/src/testHelpers/entities.ts | 1 + 33 files changed, 667 insertions(+), 83 deletions(-) create mode 100644 coderd/database/migrations/000516_templates_abstract.down.sql create mode 100644 coderd/database/migrations/000516_templates_abstract.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7aca308f9d..db9f75258d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17883,6 +17883,11 @@ const docTemplate = `{ "template_version_id" ], "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string", + "maxLength": 2048 + }, "activity_bump_ms": { "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" @@ -23315,6 +23320,10 @@ const docTemplate = `{ "codersdk.Template": { "type": "object", "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string" + }, "active_user_count": { "description": "ActiveUserCount is set to -1 when loading.", "type": "integer" @@ -24427,6 +24436,11 @@ const docTemplate = `{ "codersdk.UpdateTemplateMeta": { "type": "object", "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string", + "maxLength": 2048 + }, "activity_bump_ms": { "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 842eac0c08..bfe36f7f1c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16174,6 +16174,11 @@ "type": "object", "required": ["name", "template_version_id"], "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string", + "maxLength": 2048 + }, "activity_bump_ms": { "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" @@ -21411,6 +21416,10 @@ "codersdk.Template": { "type": "object", "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string" + }, "active_user_count": { "description": "ActiveUserCount is set to -1 when loading.", "type": "integer" @@ -22464,6 +22473,11 @@ "codersdk.UpdateTemplateMeta": { "type": "object", "properties": { + "abstract": { + "description": "Abstract is a longer-form summary surfaced to agents to help them pick\nthe right template. Up to 2048 characters.", + "type": "string", + "maxLength": 2048 + }, "activity_bump_ms": { "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 7c2bb68a00..8a13bd36fd 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -535,6 +535,7 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. Provisioner: takeFirst(seed.Provisioner, database.ProvisionerTypeEcho), ActiveVersionID: takeFirst(seed.ActiveVersionID, uuid.New()), Description: takeFirst(seed.Description, testutil.GetRandomName(t)), + Abstract: seed.Abstract, CreatedBy: takeFirst(seed.CreatedBy, uuid.New()), Icon: takeFirst(seed.Icon, testutil.GetRandomName(t)), UserACL: seed.UserACL, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ea09f4300f..84c3017dbd 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3095,7 +3095,8 @@ CREATE TABLE templates ( max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL, use_classic_parameter_flow boolean DEFAULT false NOT NULL, cors_behavior cors_behavior DEFAULT 'simple'::cors_behavior NOT NULL, - disable_module_cache boolean DEFAULT false NOT NULL + disable_module_cache boolean DEFAULT false NOT NULL, + abstract character varying(2048) DEFAULT ''::character varying NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -3118,6 +3119,8 @@ COMMENT ON COLUMN templates.deprecated IS 'If set to a non empty string, the tem COMMENT ON COLUMN templates.use_classic_parameter_flow IS 'Determines whether to default to the dynamic parameter creation flow for this template or continue using the legacy classic parameter creation flow.This is a template wide setting, the template admin can revert to the classic flow if there are any issues. An escape hatch is required, as workspace creation is a core workflow and cannot break. This column will be removed when the dynamic parameter creation flow is stable.'; +COMMENT ON COLUMN templates.abstract IS 'Longer-form summary used to help agents pick the right template.'; + CREATE VIEW template_with_names AS SELECT templates.id, templates.created_at, @@ -3150,6 +3153,7 @@ CREATE VIEW template_with_names AS templates.use_classic_parameter_flow, templates.cors_behavior, templates.disable_module_cache, + templates.abstract, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username, COALESCE(visible_users.name, ''::text) AS created_by_name, diff --git a/coderd/database/migrations/000516_templates_abstract.down.sql b/coderd/database/migrations/000516_templates_abstract.down.sql new file mode 100644 index 0000000000..c82913988b --- /dev/null +++ b/coderd/database/migrations/000516_templates_abstract.down.sql @@ -0,0 +1,17 @@ +DROP VIEW template_with_names; + +ALTER TABLE templates DROP COLUMN abstract; + +CREATE VIEW template_with_names AS +SELECT templates.*, + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon +FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/migrations/000516_templates_abstract.up.sql b/coderd/database/migrations/000516_templates_abstract.up.sql new file mode 100644 index 0000000000..2904c84d53 --- /dev/null +++ b/coderd/database/migrations/000516_templates_abstract.up.sql @@ -0,0 +1,20 @@ +DROP VIEW template_with_names; + +ALTER TABLE templates + ADD COLUMN abstract character varying(2048) DEFAULT ''::character varying NOT NULL; + +COMMENT ON COLUMN templates.abstract IS 'Longer-form summary used to help agents pick the right template.'; + +CREATE VIEW template_with_names AS +SELECT templates.*, + COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, + COALESCE(visible_users.username, ''::text) AS created_by_username, + COALESCE(visible_users.name, ''::text) AS created_by_name, + COALESCE(organizations.name, ''::text) AS organization_name, + COALESCE(organizations.display_name, ''::text) AS organization_display_name, + COALESCE(organizations.icon, ''::text) AS organization_icon +FROM ((templates + LEFT JOIN visible_users ON ((templates.created_by = visible_users.id))) + LEFT JOIN organizations ON ((templates.organization_id = organizations.id))); + +COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 972a104201..f04027f4d8 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -129,6 +129,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.UseClassicParameterFlow, &i.CorsBehavior, &i.DisableModuleCache, + &i.Abstract, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, diff --git a/coderd/database/models.go b/coderd/database/models.go index 8a6aa5fd4d..960489cac9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5479,6 +5479,7 @@ type Template struct { UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` + Abstract string `db:"abstract" json:"abstract"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` CreatedByName string `db:"created_by_name" json:"created_by_name"` @@ -5529,6 +5530,8 @@ type TemplateTable struct { UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"` DisableModuleCache bool `db:"disable_module_cache" json:"disable_module_cache"` + // Longer-form summary used to help agents pick the right template. + Abstract string `db:"abstract" json:"abstract"` } // Records aggregated usage statistics for templates/users. All usage is rounded up to the nearest minute. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6b7b39c021..6fb6633490 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -24767,7 +24767,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, templateID const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, abstract, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names WHERE @@ -24811,6 +24811,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.UseClassicParameterFlow, &i.CorsBehavior, &i.DisableModuleCache, + &i.Abstract, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -24823,7 +24824,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, abstract, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates WHERE @@ -24875,6 +24876,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.UseClassicParameterFlow, &i.CorsBehavior, &i.DisableModuleCache, + &i.Abstract, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -24886,7 +24888,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, abstract, created_by_avatar_url, created_by_username, created_by_name, organization_name, organization_display_name, organization_icon FROM template_with_names AS templates ORDER BY (name, id) ASC ` @@ -24931,6 +24933,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.UseClassicParameterFlow, &i.CorsBehavior, &i.DisableModuleCache, + &i.Abstract, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -24953,7 +24956,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.cors_behavior, t.disable_module_cache, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon + t.id, t.created_at, t.updated_at, t.organization_id, t.deleted, t.name, t.provisioner, t.active_version_id, t.description, t.default_ttl, t.created_by, t.icon, t.user_acl, t.group_acl, t.display_name, t.allow_user_cancel_workspace_jobs, t.allow_user_autostart, t.allow_user_autostop, t.failure_ttl, t.time_til_dormant, t.time_til_dormant_autodelete, t.autostop_requirement_days_of_week, t.autostop_requirement_weeks, t.autostart_block_days_of_week, t.require_active_version, t.deprecated, t.activity_bump, t.max_port_sharing_level, t.use_classic_parameter_flow, t.cors_behavior, t.disable_module_cache, t.abstract, t.created_by_avatar_url, t.created_by_username, t.created_by_name, t.organization_name, t.organization_display_name, t.organization_icon FROM template_with_names AS t LEFT JOIN @@ -25113,6 +25116,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.UseClassicParameterFlow, &i.CorsBehavior, &i.DisableModuleCache, + &i.Abstract, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -25144,6 +25148,7 @@ INSERT INTO provisioner, active_version_id, description, + abstract, created_by, icon, user_acl, @@ -25155,7 +25160,7 @@ INSERT INTO cors_behavior ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ` type InsertTemplateParams struct { @@ -25167,6 +25172,7 @@ type InsertTemplateParams struct { Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` Description string `db:"description" json:"description"` + Abstract string `db:"abstract" json:"abstract"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` UserACL TemplateACL `db:"user_acl" json:"user_acl"` @@ -25188,6 +25194,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.Provisioner, arg.ActiveVersionID, arg.Description, + arg.Abstract, arg.CreatedBy, arg.Icon, arg.UserACL, @@ -25291,15 +25298,16 @@ UPDATE SET updated_at = $2, description = $3, - name = $4, - icon = $5, - display_name = $6, - allow_user_cancel_workspace_jobs = $7, - group_acl = $8, - max_port_sharing_level = $9, - use_classic_parameter_flow = $10, - cors_behavior = $11, - disable_module_cache = $12 + abstract = $4, + name = $5, + icon = $6, + display_name = $7, + allow_user_cancel_workspace_jobs = $8, + group_acl = $9, + max_port_sharing_level = $10, + use_classic_parameter_flow = $11, + cors_behavior = $12, + disable_module_cache = $13 WHERE id = $1 ` @@ -25308,6 +25316,7 @@ type UpdateTemplateMetaByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Description string `db:"description" json:"description"` + Abstract string `db:"abstract" json:"abstract"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` DisplayName string `db:"display_name" json:"display_name"` @@ -25324,6 +25333,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.ID, arg.UpdatedAt, arg.Description, + arg.Abstract, arg.Name, arg.Icon, arg.DisplayName, @@ -35319,7 +35329,7 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level, use_classic_parameter_flow, cors_behavior, disable_module_cache, abstract FROM templates WHERE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index eb6ada1972..e6c9adbdcd 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -129,6 +129,7 @@ INSERT INTO provisioner, active_version_id, description, + abstract, created_by, icon, user_acl, @@ -140,7 +141,7 @@ INSERT INTO cors_behavior ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17); + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18); -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -166,15 +167,16 @@ UPDATE SET updated_at = $2, description = $3, - name = $4, - icon = $5, - display_name = $6, - allow_user_cancel_workspace_jobs = $7, - group_acl = $8, - max_port_sharing_level = $9, - use_classic_parameter_flow = $10, - cors_behavior = $11, - disable_module_cache = $12 + abstract = $4, + name = $5, + icon = $6, + display_name = $7, + allow_user_cancel_workspace_jobs = $8, + group_acl = $9, + max_port_sharing_level = $10, + use_classic_parameter_flow = $11, + cors_behavior = $12, + disable_module_cache = $13 WHERE id = $1 ; diff --git a/coderd/templates.go b/coderd/templates.go index 9817382da0..d7a2868999 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -220,6 +220,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque OrganizationID: organization.ID, Name: createTemplate.Name, Description: createTemplate.Description, + Abstract: createTemplate.Abstract, CreatedBy: apiKey.UserID, Icon: createTemplate.Icon, DisplayName: createTemplate.DisplayName, @@ -429,6 +430,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque Provisioner: importJob.Provisioner, ActiveVersionID: templateVersion.ID, Description: createTemplate.Description, + Abstract: createTemplate.Abstract, CreatedBy: apiKey.UserID, UserACL: database.TemplateACL{}, GroupACL: defaultsGroups, @@ -764,6 +766,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { Name: resolved.name, DisplayName: resolved.displayName, Description: resolved.description, + Abstract: resolved.abstract, Icon: resolved.icon, AllowUserCancelWorkspaceJobs: resolved.allowUserCancelWorkspaceJobs, GroupACL: resolved.groupACL, @@ -1017,6 +1020,7 @@ func (api *API) convertTemplate( ActiveUserCount: owners, BuildTimeStats: buildTimeStats, Description: template.Description, + Abstract: template.Abstract, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), ActivityBumpMillis: time.Duration(template.ActivityBump).Milliseconds(), diff --git a/coderd/templates_meta_update.go b/coderd/templates_meta_update.go index 8dfc55eb61..bf47f9d076 100644 --- a/coderd/templates_meta_update.go +++ b/coderd/templates_meta_update.go @@ -19,6 +19,7 @@ type templateMetaUpdate struct { name string displayName string description string + abstract string icon string defaultTTLMillis int64 activityBumpMillis int64 @@ -70,6 +71,7 @@ func resolveTemplateMetaUpdate( name: ptr.NilToDefault(req.Name, template.Name), displayName: ptr.NilToDefault(req.DisplayName, template.DisplayName), description: ptr.NilToDefault(req.Description, template.Description), + abstract: ptr.NilToDefault(req.Abstract, template.Abstract), icon: ptr.NilToDefault(req.Icon, template.Icon), defaultTTLMillis: ptr.NilToDefault(req.DefaultTTLMillis, time.Duration(template.DefaultTTL).Milliseconds()), activityBumpMillis: ptr.NilToDefault(req.ActivityBumpMillis, time.Duration(template.ActivityBump).Milliseconds()), diff --git a/coderd/templates_meta_update_internal_test.go b/coderd/templates_meta_update_internal_test.go index 3ef2a462d3..c3de81b606 100644 --- a/coderd/templates_meta_update_internal_test.go +++ b/coderd/templates_meta_update_internal_test.go @@ -42,6 +42,7 @@ func baselineTemplate() database.Template { UseClassicParameterFlow: true, CorsBehavior: database.CorsBehaviorPassthru, DisableModuleCache: true, + Abstract: "Existing abstract.", GroupACL: database.TemplateACL{ orgID.String(): {"read"}, }, @@ -70,6 +71,7 @@ func baselineResolved() templateMetaUpdate { name: tpl.Name, displayName: tpl.DisplayName, description: tpl.Description, + abstract: tpl.Abstract, icon: tpl.Icon, defaultTTLMillis: tpl.DefaultTTL / 1e6, activityBumpMillis: tpl.ActivityBump / 1e6, @@ -148,6 +150,20 @@ func TestResolveTemplateMetaUpdate(t *testing.T) { r.description = "New description" }}, }, + { + name: "Abstract", + req: codersdk.UpdateTemplateMeta{Abstract: ptr.Ref("New abstract.")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.abstract = "New abstract." + }}, + }, + { + name: "AbstractEmptyStringClears", + req: codersdk.UpdateTemplateMeta{Abstract: ptr.Ref("")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.abstract = "" + }}, + }, { name: "Icon", req: codersdk.UpdateTemplateMeta{Icon: ptr.Ref("/new.svg")}, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index da7f660cf0..35d1350cc3 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "net/http" + "strings" "sync/atomic" "testing" "time" @@ -86,6 +87,50 @@ func TestPostTemplateByOrganization(t *testing.T) { assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[2].Action) }) + t.Run("Abstract", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + abstract string + wantError bool + }{ + {name: "PersistsValid", abstract: strings.Repeat("a", 2048)}, + {name: "RejectsOverLimit", abstract: strings.Repeat("a", 2049), wantError: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + created, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "abstract-test", + VersionID: version.ID, + Abstract: tc.abstract, + }) + if tc.wantError { + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "abstract", apiErr.Validations[0].Field) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.abstract, created.Abstract) + got, err := client.Template(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, tc.abstract, got.Abstract) + }) + } + }) + t.Run("AlreadyExists", func(t *testing.T) { t.Parallel() ownerClient := coderdtest.New(t, nil) @@ -942,6 +987,50 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action) }) + t.Run("Abstract", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + abstract string + wantError bool + }{ + {name: "PersistsValid", abstract: strings.Repeat("a", 2048)}, + {name: "RejectsOverLimit", abstract: strings.Repeat("a", 2049), wantError: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + abstract := tc.abstract + ctx := testutil.Context(t, testutil.WaitLong) + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Abstract: &abstract, + }) + if tc.wantError { + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "abstract", apiErr.Validations[0].Field) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.abstract, updated.Abstract) + got, err := client.Template(ctx, template.ID) + require.NoError(t, err) + assert.Equal(t, tc.abstract, got.Abstract) + }) + } + }) + t.Run("AlreadyExists", func(t *testing.T) { t.Parallel() @@ -1336,6 +1425,7 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Equal(t, template.Name, updated.Name) assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Abstract, updated.Abstract) assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, template.ActivityBumpMillis, updated.ActivityBumpMillis) @@ -1371,6 +1461,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.WithinDuration(t, template.UpdatedAt, updated.UpdatedAt, time.Minute) assert.Equal(t, template.Name, updated.Name) assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Abstract, updated.Abstract) assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) }) @@ -1665,22 +1756,26 @@ func TestPatchTemplateMeta(t *testing.T) { displayName := "Test Display Name" description := "test-description" + abstract := "test abstract for agents" icon := "/icon/icon.png" defaultTTLMillis := 10 * time.Hour.Milliseconds() reference := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DisplayName = displayName ctr.Description = description + ctr.Abstract = abstract ctr.Icon = icon ctr.DefaultTTLMillis = ptr.Ref(defaultTTLMillis) }) require.Equal(t, displayName, reference.DisplayName) require.Equal(t, description, reference.Description) + require.Equal(t, abstract, reference.Abstract) require.Equal(t, icon, reference.Icon) restoreReq := codersdk.UpdateTemplateMeta{ DisplayName: &displayName, Description: &description, + Abstract: &abstract, Icon: &icon, DefaultTTLMillis: ptr.Ref(defaultTTLMillis), } @@ -1688,6 +1783,7 @@ func TestPatchTemplateMeta(t *testing.T) { type expected struct { displayName string description string + abstract string icon string defaultTTLMillis int64 } @@ -1702,22 +1798,27 @@ func TestPatchTemplateMeta(t *testing.T) { { name: "Only update default_ttl_ms", req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: ptr.Ref(99 * time.Hour.Milliseconds())}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 99 * time.Hour.Milliseconds()}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: 99 * time.Hour.Milliseconds()}, }, { name: "Clear display name", req: codersdk.UpdateTemplateMeta{DisplayName: ptr.Ref("")}, - expected: expected{displayName: "", description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: "", description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Clear description", req: codersdk.UpdateTemplateMeta{Description: ptr.Ref("")}, - expected: expected{displayName: reference.DisplayName, description: "", icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: reference.DisplayName, description: "", abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + }, + { + name: "Clear abstract", + req: codersdk.UpdateTemplateMeta{Abstract: ptr.Ref("")}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: "", icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Clear icon", req: codersdk.UpdateTemplateMeta{Icon: ptr.Ref("")}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: "", defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: "", defaultTTLMillis: defaultTTLMillis}, }, // A request whose only field is nil is a true no-op under the new // PATCH semantics; the handler returns 304 Not Modified and the @@ -1725,17 +1826,22 @@ func TestPatchTemplateMeta(t *testing.T) { { name: "Nil display name is a no-op", req: codersdk.UpdateTemplateMeta{DisplayName: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Nil description is a no-op", req: codersdk.UpdateTemplateMeta{Description: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + }, + { + name: "Nil abstract is a no-op", + req: codersdk.UpdateTemplateMeta{Abstract: nil}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Nil icon is a no-op", req: codersdk.UpdateTemplateMeta{Icon: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, abstract: reference.Abstract, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, } @@ -1757,6 +1863,7 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.expected.displayName, updated.DisplayName) assert.Equal(t, tc.expected.description, updated.Description) + assert.Equal(t, tc.expected.abstract, updated.Abstract) assert.Equal(t, tc.expected.icon, updated.Icon) assert.Equal(t, tc.expected.defaultTTLMillis, updated.DefaultTTLMillis) }) @@ -1774,6 +1881,7 @@ func TestPatchTemplateMeta(t *testing.T) { template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DisplayName = "Original Display" ctr.Description = "Original description" + ctr.Abstract = "Original abstract" ctr.Icon = "/icon/original.png" ctr.DefaultTTLMillis = ptr.Ref((24 * time.Hour).Milliseconds()) ctr.AllowUserCancelWorkspaceJobs = ptr.Ref(true) @@ -1789,6 +1897,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, template.Name, updated.Name) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Abstract, updated.Abstract) assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, template.AllowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs) diff --git a/coderd/x/chatd/chattool/listtemplates.go b/coderd/x/chatd/chattool/listtemplates.go index 3c6d31c1b0..f9ca37adc7 100644 --- a/coderd/x/chatd/chattool/listtemplates.go +++ b/coderd/x/chatd/chattool/listtemplates.go @@ -41,7 +41,7 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp "list_templates", "List available workspace templates. Optionally filter by a "+ "search query matching template name or description. "+ - "Use this to find a template before creating a workspace. "+ + "Results include short UI descriptions and agent-facing abstracts when present. "+ "Results are ordered by number of active developers (most popular first). "+ "Returns 10 per page. Use the page parameter to paginate through results.", func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { @@ -128,6 +128,9 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp if desc := strings.TrimSpace(t.Description); desc != "" { item["description"] = truncateRunes(desc, 200) } + if abstract := strings.TrimSpace(t.Abstract); abstract != "" { + item["abstract"] = abstract + } if count, ok := ownerCounts[t.ID]; ok && count > 0 { item["active_developers"] = count } diff --git a/coderd/x/chatd/chattool/listtemplates_test.go b/coderd/x/chatd/chattool/listtemplates_test.go index 0cf25d2c43..52eadca620 100644 --- a/coderd/x/chatd/chattool/listtemplates_test.go +++ b/coderd/x/chatd/chattool/listtemplates_test.go @@ -3,6 +3,7 @@ package chattool_test import ( "context" "encoding/json" + "strings" "testing" "charm.land/fantasy" @@ -121,6 +122,120 @@ func TestListTemplates_OrganizationFilter(t *testing.T) { }) } +func TestListTemplates_Abstract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + template database.Template + want string + wantPresent bool + }{ + { + name: "LongAbstract", + template: database.Template{ + Name: "with-abstract", + Description: "short description", + Abstract: strings.Repeat("a", 1000), + }, + want: strings.Repeat("a", 1000), + wantPresent: true, + }, + { + name: "ShortAbstractUntouched", + template: database.Template{ + Name: "short-abstract", + Description: "short description", + Abstract: "a concise abstract", + }, + want: "a concise abstract", + wantPresent: true, + }, + { + name: "EmptyAbstractOmitted", + template: database.Template{ + Name: "no-abstract", + Description: "short description", + Abstract: "", + }, + }, + { + name: "WhitespaceOnlyAbstractOmitted", + template: database.Template{ + Name: "whitespace-abstract", + Description: "short description", + Abstract: " \t \n ", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + fixture := newListTemplatesFixture(t) + template := tc.template + template.OrganizationID = fixture.org.ID + template.CreatedBy = fixture.user.ID + tpl := dbgen.Template(t, fixture.db, template) + + items := fixture.listTemplates(t, "{}") + require.Len(t, items, 1) + require.Equal(t, tpl.ID.String(), items[0]["id"].(string)) + require.Equal(t, "short description", items[0]["description"].(string)) + + got, ok := items[0]["abstract"] + require.Equal(t, tc.wantPresent, ok) + if tc.wantPresent { + require.Equal(t, tc.want, got.(string)) + } + }) + } +} + +type listTemplatesFixture struct { + db database.Store + user database.User + org database.Organization +} + +func newListTemplatesFixture(t *testing.T) listTemplatesFixture { + t.Helper() + + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + return listTemplatesFixture{db: db, user: user, org: org} +} + +func (f listTemplatesFixture) listTemplates(t *testing.T, input string) []map[string]any { + t.Helper() + + tool := chattool.ListTemplates(f.db, f.org.ID, chattool.ListTemplatesOptions{ + OwnerID: f.user.ID, + }) + resp, err := tool.Run(testutil.Context(t, testutil.WaitShort), fantasy.ToolCall{ + ID: "list-templates", + Name: "list_templates", + Input: input, + }) + require.NoError(t, err) + require.False(t, resp.IsError) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + templates := result["templates"].([]any) + items := make([]map[string]any, 0, len(templates)) + for _, template := range templates { + items = append(items, template.(map[string]any)) + } + return items +} + //nolint:tparallel,paralleltest // Subtests share a single DB and run sequentially. func TestTemplateAllowlistEnforcement(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chattool/readtemplate.go b/coderd/x/chatd/chattool/readtemplate.go index 09179237ba..d7e3f9b4ae 100644 --- a/coderd/x/chatd/chattool/readtemplate.go +++ b/coderd/x/chatd/chattool/readtemplate.go @@ -88,6 +88,9 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl if desc := strings.TrimSpace(template.Description); desc != "" { templateInfo["description"] = desc } + if abstract := strings.TrimSpace(template.Abstract); abstract != "" { + templateInfo["abstract"] = abstract + } paramList := make([]map[string]any, 0, len(params)) for _, p := range params { diff --git a/coderd/x/chatd/chattool/readtemplate_test.go b/coderd/x/chatd/chattool/readtemplate_test.go index e0ea0d6848..880b50f261 100644 --- a/coderd/x/chatd/chattool/readtemplate_test.go +++ b/coderd/x/chatd/chattool/readtemplate_test.go @@ -181,3 +181,74 @@ func TestReadTemplate_NoPresets(t *testing.T) { _, hasPresets := result["presets"] require.False(t, hasPresets, "presets key should be absent when there are none") } + +func TestReadTemplate_Abstract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + abstract string + want string + wantPresent bool + }{ + { + name: "Present", + abstract: "A long-form summary used by agents to pick the right template.", + want: "A long-form summary used by agents to pick the right template.", + wantPresent: true, + }, + {name: "Empty"}, + {name: "WhitespaceOnly", abstract: " \t \n "}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tmplInfo := readTemplateInfo(t, tc.abstract) + got, ok := tmplInfo["abstract"] + require.Equal(t, tc.wantPresent, ok) + if tc.wantPresent { + require.Equal(t, tc.want, got.(string)) + } + }) + } +} + +func readTemplateInfo(t *testing.T, abstract string) map[string]any { + t.Helper() + + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + ActiveVersionID: tv.ID, + Abstract: abstract, + }) + + tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{ + OwnerID: user.ID, + }) + resp, err := tool.Run(testutil.Context(t, testutil.WaitShort), fantasy.ToolCall{ + ID: "abstract", + Name: "read_template", + Input: `{"template_id":"` + tmpl.ID.String() + `"}`, + }) + require.NoError(t, err) + require.False(t, resp.IsError) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + return result["template"].(map[string]any) +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 8c17b50e56..bf88a38a7c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -144,6 +144,9 @@ type CreateTemplateRequest struct { // Description is a description of what the template contains. It must be // less than 128 bytes. Description string `json:"description,omitempty" validate:"lt=128"` + // Abstract is a longer-form summary surfaced to agents to help them pick + // the right template. Up to 2048 characters. + Abstract string `json:"abstract,omitempty" validate:"max=2048"` // Icon is a relative path or external URL that specifies // an icon to be displayed in the dashboard. Icon string `json:"icon,omitempty"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 87fea25fb9..ce71b313e3 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -27,15 +27,18 @@ type Template struct { Provisioner ProvisionerType `json:"provisioner" enums:"terraform"` ActiveVersionID uuid.UUID `json:"active_version_id" format:"uuid"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` - BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` - Description string `json:"description"` - Deprecated bool `json:"deprecated"` - DeprecationMessage string `json:"deprecation_message"` - Deleted bool `json:"deleted"` - Icon string `json:"icon"` - DefaultTTLMillis int64 `json:"default_ttl_ms"` - ActivityBumpMillis int64 `json:"activity_bump_ms"` + ActiveUserCount int `json:"active_user_count"` + BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` + Description string `json:"description"` + // Abstract is a longer-form summary surfaced to agents to help them pick + // the right template. Up to 2048 characters. + Abstract string `json:"abstract"` + Deprecated bool `json:"deprecated"` + DeprecationMessage string `json:"deprecation_message"` + Deleted bool `json:"deleted"` + Icon string `json:"icon"` + DefaultTTLMillis int64 `json:"default_ttl_ms"` + ActivityBumpMillis int64 `json:"activity_bump_ms"` // AutostopRequirement and AutostartRequirement are enterprise features. Its // value is only used if your license is entitled to use the advanced template // scheduling feature. @@ -218,9 +221,12 @@ type ACLAvailable struct { // UpdateTemplateMeta is the request body for the PATCH /templates/{template} // endpoint. All fields are optional. Fields that are nil are not modified. type UpdateTemplateMeta struct { - Name *string `json:"name,omitempty" validate:"omitempty,template_name"` - DisplayName *string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,template_name"` + DisplayName *string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` + Description *string `json:"description,omitempty"` + // Abstract is a longer-form summary surfaced to agents to help them pick + // the right template. Up to 2048 characters. + Abstract *string `json:"abstract,omitempty" validate:"omitempty,max=2048"` Icon *string `json:"icon,omitempty"` DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` // ActivityBumpMillis allows optionally specifying the activity bump diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 4761bb7b1f..7ace50beef 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -57,10 +57,17 @@ func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResu } results := make([]SearchResultItem, len(templates)) for i, template := range templates { + parts := make([]string, 0, 2) + if d := strings.TrimSpace(template.Description); d != "" { + parts = append(parts, d) + } + if a := strings.TrimSpace(template.Abstract); a != "" { + parts = append(parts, a) + } results[i] = SearchResultItem{ ID: createObjectID(ObjectTypeTemplate, template.ID.String()).String(), Title: template.DisplayName, - Text: template.Description, + Text: strings.Join(parts, "\n\n"), URL: fmt.Sprintf("%s/templates/%s/%s", serverURL, template.OrganizationName, template.Name), } } diff --git a/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go index c8a05ba414..3adac92274 100644 --- a/codersdk/toolsdk/chatgpt_test.go +++ b/codersdk/toolsdk/chatgpt_test.go @@ -151,6 +151,51 @@ func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { require.Equal(t, expectedID, result.Results[0].ID, "Should match the docker template in org2") } +func TestChatGPTSearch_TemplateIncludesAbstract(t *testing.T) { + t.Parallel() + + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + const ( + desc = "Short description." + abstract = "Detailed summary that distinguishes this template." + ) + + dbgen.Template(t, store, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + Name: "with-abstract", + DisplayName: "With Abstract", + Description: desc, + Abstract: abstract, + }) + dbgen.Template(t, store, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + Name: "no-abstract", + DisplayName: "No Abstract", + Description: desc, + }) + + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, toolsdk.SearchArgs{Query: "templates"}) + require.NoError(t, err) + require.Len(t, result.Results, 2) + + byTitle := make(map[string]toolsdk.SearchResultItem, len(result.Results)) + for _, item := range result.Results { + byTitle[item.Title] = item + } + require.Contains(t, byTitle, "With Abstract") + require.Contains(t, byTitle, "No Abstract") + require.Contains(t, byTitle["With Abstract"].Text, desc) + require.Contains(t, byTitle["With Abstract"].Text, abstract) + require.Equal(t, desc, byTitle["No Abstract"].Text) +} + func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { t.Parallel() diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 81908820a6..686a8d3afd 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -621,7 +621,7 @@ var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ var ListTemplates = Tool[NoArgs, []MinimalTemplate]{ Tool: aisdk.Tool{ Name: ToolNameListTemplates, - Description: "Lists templates for the authenticated user.", + Description: "Lists templates for the authenticated user. Description is short UI text; abstract is richer selection context.", Schema: aisdk.Schema{ Properties: map[string]any{}, Required: []string{}, @@ -640,6 +640,7 @@ var ListTemplates = Tool[NoArgs, []MinimalTemplate]{ ID: template.ID.String(), Name: template.Name, Description: template.Description, + Abstract: template.Abstract, ActiveVersionID: template.ActiveVersionID, ActiveUserCount: template.ActiveUserCount, } @@ -732,7 +733,7 @@ func toPresetView(p codersdk.Preset) presetView { var GetTemplate = Tool[GetTemplateArgs, TemplateDetail]{ Tool: aisdk.Tool{ Name: ToolNameGetTemplate, - Description: `Get details about a workspace template, including its configurable parameters and available presets for the active version. + Description: `Get details about a workspace template, including its abstract, configurable parameters, and available presets for the active version. Use this after finding a template with coder_list_templates and before creating a workspace with coder_create_workspace. Presets, when present, can be passed to coder_create_workspace as template_version_preset_id. @@ -777,6 +778,7 @@ When selecting a preset: if a preset is marked default and the user has not spec ID: template.ID.String(), Name: template.Name, Description: template.Description, + Abstract: template.Abstract, ActiveVersionID: template.ActiveVersionID, ActiveUserCount: template.ActiveUserCount, }, @@ -1719,6 +1721,7 @@ type MinimalTemplate struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` + Abstract string `json:"abstract,omitempty"` ActiveVersionID uuid.UUID `json:"active_version_id"` ActiveUserCount int `json:"active_user_count"` } diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index bd4949baaa..9d2ec350f1 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "testing" "time" @@ -309,6 +310,7 @@ func TestTools(t *testing.T) { }) for i, template := range result { require.Equal(t, expected[i].ID.String(), template.ID) + require.Equal(t, expected[i].Abstract, template.Abstract) } }) @@ -738,6 +740,27 @@ func TestTools(t *testing.T) { }) require.ErrorContains(t, err, "get template") }) + + t.Run("Abstract", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + absBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Do() + abstract := strings.Repeat("b", 600) + _, err := client.UpdateTemplateMeta(ctx, absBuild.Template.ID, codersdk.UpdateTemplateMeta{ + Abstract: &abstract, + }) + require.NoError(t, err) + + tb, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + result, err := testTool(t, toolsdk.GetTemplate, tb, toolsdk.GetTemplateArgs{ + TemplateID: absBuild.Template.ID.String(), + }) + require.NoError(t, err) + require.Equal(t, abstract, result.Abstract) + }) }) t.Run("GetWorkspaceAgentLogs", func(t *testing.T) { diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 45022ff253..abb1f573f7 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -13,40 +13,40 @@ We track the following resources: -| Resource | | | -|-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| -| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| -| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| -| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| -| AiSeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| AuditableGroupAiBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| -| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
group_acltrue
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| -| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| -| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| -| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| -| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| -| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| -| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| -| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| -| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| 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
disable_module_cachetrue
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
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
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| -| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| Resource | | | +|-----------------------------------------------------------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| +| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| +| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| +| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| +| AiSeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableGroupAiBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| +| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
group_acltrue
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| +| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| +| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| +| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| +| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| +| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| +| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| +| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| +| 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
abstracttrue
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
disable_module_cachetrue
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
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
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| +| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 47268ba974..263ad3781e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4876,6 +4876,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "abstract": "string", "activity_bump_ms": 0, "allow_user_autostart": true, "allow_user_autostop": true, @@ -4912,6 +4913,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | |---------------------------------------|--------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `abstract` | string | false | | Abstract is a longer-form summary surfaced to agents to help them pick the right template. Up to 2048 characters. | | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | | `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | | `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | @@ -11969,6 +11971,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ```json { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -12029,6 +12032,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Name | Type | Required | Restrictions | Description | |------------------------------------|--------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `abstract` | string | false | | Abstract is a longer-form summary surfaced to agents to help them pick the right template. Up to 2048 characters. | | `active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `active_version_id` | string | false | | | | `activity_bump_ms` | integer | false | | | @@ -13212,6 +13216,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { + "abstract": "string", "activity_bump_ms": 0, "allow_user_autostart": true, "allow_user_autostop": true, @@ -13251,6 +13256,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | Name | Type | Required | Restrictions | Description | |------------------------------------|--------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `abstract` | string | false | | Abstract is a longer-form summary surfaced to agents to help them pick the right template. Up to 2048 characters. | | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | | `allow_user_autostart` | boolean | false | | | | `allow_user_autostop` | boolean | false | | | diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index 6deddeb2a5..f0eedee9a2 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -30,6 +30,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. ```json [ { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -100,6 +101,7 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | |--------------------------------------|------------------------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `[array item]` | array | false | | | +| `» abstract` | string | false | | Abstract is a longer-form summary surfaced to agents to help them pick the right template. Up to 2048 characters. | | `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `» active_version_id` | string(uuid) | false | | | | `» activity_bump_ms` | integer | false | | | @@ -171,6 +173,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa ```json { + "abstract": "string", "activity_bump_ms": 0, "allow_user_autostart": true, "allow_user_autostop": true, @@ -216,6 +219,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa ```json { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -368,6 +372,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat ```json { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -790,6 +795,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. ```json [ { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -860,6 +866,7 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | |--------------------------------------|------------------------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `[array item]` | array | false | | | +| `» abstract` | string | false | | Abstract is a longer-form summary surfaced to agents to help them pick the right template. Up to 2048 characters. | | `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `» active_version_id` | string(uuid) | false | | | | `» activity_bump_ms` | integer | false | | | @@ -994,6 +1001,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ ```json { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, @@ -1120,6 +1128,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ ```json { + "abstract": "string", "activity_bump_ms": 0, "allow_user_autostart": true, "allow_user_autostop": true, @@ -1168,6 +1177,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ ```json { + "abstract": "string", "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 6ae2afd92c..0173501dba 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -105,6 +105,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "provisioner": ActionTrack, "active_version_id": ActionTrack, "description": ActionTrack, + "abstract": ActionTrack, "icon": ActionTrack, "default_ttl": ActionTrack, "autostart_block_days_of_week": ActionTrack, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a0aad00206..65419826ba 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3476,6 +3476,11 @@ export interface CreateTemplateRequest { * less than 128 bytes. */ readonly description?: string; + /** + * Abstract is a longer-form summary surfaced to agents to help them pick + * the right template. Up to 2048 characters. + */ + readonly abstract?: string; /** * Icon is a relative path or external URL that specifies * an icon to be displayed in the dashboard. @@ -8025,6 +8030,11 @@ export interface Template { readonly active_user_count: number; readonly build_time_stats: TemplateBuildTimeStats; readonly description: string; + /** + * Abstract is a longer-form summary surfaced to agents to help them pick + * the right template. Up to 2048 characters. + */ + readonly abstract: string; readonly deprecated: boolean; readonly deprecation_message: string; readonly deleted: boolean; @@ -8841,6 +8851,11 @@ export interface UpdateTemplateMeta { readonly name?: string; readonly display_name?: string; readonly description?: string; + /** + * Abstract is a longer-form summary surfaced to agents to help them pick + * the right template. Up to 2048 characters. + */ + readonly abstract?: string; readonly icon?: string; readonly default_ttl_ms?: number; /** diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 0382d72666..8cd8c32423 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -38,6 +38,8 @@ import { const MAX_DESCRIPTION_CHAR_LIMIT = 128; const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`; +const MAX_ABSTRACT_CHAR_LIMIT = 2048; +const MAX_ABSTRACT_MESSAGE = `Please enter an abstract that is no longer than ${MAX_ABSTRACT_CHAR_LIMIT} characters.`; export const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -46,6 +48,7 @@ export const validationSchema = Yup.object({ MAX_DESCRIPTION_CHAR_LIMIT, MAX_DESCRIPTION_MESSAGE, ), + abstract: Yup.string().max(MAX_ABSTRACT_CHAR_LIMIT, MAX_ABSTRACT_MESSAGE), allow_user_cancel_workspace_jobs: Yup.boolean(), icon: iconValidator, require_active_version: Yup.boolean(), @@ -85,6 +88,7 @@ export const TemplateSettingsForm: FC = ({ name: template.name, display_name: template.display_name, description: template.description, + abstract: template.abstract, icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, @@ -148,6 +152,19 @@ export const TemplateSettingsForm: FC = ({ rows={2} /> + + { @@ -89,6 +91,12 @@ const fillAndSubmitForm = async ({ await userEvent.clear(descriptionField); await userEvent.type(descriptionField, description); + const abstractField = await screen.findByLabelText("Abstract"); + await userEvent.clear(abstractField); + if (abstract !== "") { + await userEvent.type(abstractField, abstract); + } + const iconField = await screen.findByLabelText("Icon"); await userEvent.clear(iconField); await userEvent.type(iconField, icon); @@ -118,6 +126,10 @@ describe("TemplateSettingsPage", { timeout: 20_000 }, () => { }); await fillAndSubmitForm(validFormValues); await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)); + expect(API.updateTemplateMeta).toHaveBeenCalledWith( + MockTemplate.id, + expect.objectContaining({ abstract: validFormValues.abstract }), + ); }); it("displays an error if the name is taken", async () => { @@ -165,6 +177,22 @@ describe("TemplateSettingsPage", { timeout: 20_000 }, () => { expect(validate).toThrowError(); }); + it.each<[string, string, boolean]>([ + ["allows the maximum abstract length", "a".repeat(2048), false], + ["rejects an over-limit abstract", "a".repeat(2049), true], + ])("%s", (_, abstract, wantError) => { + const values: UpdateTemplateMeta = { + ...validFormValues, + abstract, + }; + const validate = () => validationSchema.validateSync(values); + if (wantError) { + expect(validate).toThrowError(); + } else { + expect(validate).not.toThrowError(); + } + }); + describe("Deprecate template", () => { it("deprecates a template when has access control", async () => { server.use( diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx index f19dc7c72f..b5c2c829b6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -57,3 +57,13 @@ export const NoEntitlementsExpiredSettings: Story = { advancedSchedulingEnabled: false, }, }; + +export const WithAbstract: Story = { + args: { + template: { + ...MockTemplate, + abstract: + "This template provisions a remote VS Code environment backed by a Kubernetes pod. Agents should prefer it when the user mentions Kubernetes, k8s, or remote development.", + }, + }, +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f85f3d4753..dce845ca23 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -894,6 +894,7 @@ export const MockTemplate: TypesGen.Template = { }, }, description: "This is a test description.", + abstract: "", default_ttl_ms: 24 * 60 * 60 * 1000, activity_bump_ms: 1 * 60 * 60 * 1000, autostop_requirement: {