From 4de7661c0be9a4e11434b75e7a976ab9f097ba92 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 30 Apr 2025 23:09:00 +0100 Subject: [PATCH 001/158] fix: remove unused import (#17626) --- .../CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 2a5b70f5f882c..c725a8cbb73f6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -31,7 +31,7 @@ import { useRef, useState, } from "react"; -import { getFormHelpers, nameValidator } from "utils/formUtils"; +import { nameValidator } from "utils/formUtils"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; import type { From c7fc7b91ec5cd7f108022ad3c511fa77c94f427e Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Thu, 1 May 2025 16:53:13 +1000 Subject: [PATCH 002/158] fix: create directory before writing coder connect network info file (#17628) The regular network info file creation code also calls `Mkdirall`. Wasn't picked up in manual testing as I already had the `/net` folder in my VSCode. Wasn't picked up in automated testing because we use an in-memory FS, which for some reason does this implicitly. --- cli/ssh.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/ssh.go b/cli/ssh.go index f9cc1be14c3b8..7c5bda073f973 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -1542,6 +1542,10 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error if !ok { fs = afero.NewOsFs() } + if err := fs.MkdirAll(networkInfoDir, 0o700); err != nil { + return xerrors.Errorf("mkdir: %w", err) + } + // The VS Code extension obtains the PID of the SSH process to // find the log file associated with a SSH session. // From 98e5611e164ca889e99fab0aa4c5870c07d181f8 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 1 May 2025 04:52:23 -0400 Subject: [PATCH 003/158] fix: fix for prebuilds claiming and deletion (#17624) PR contains: - fix for claiming & deleting prebuilds with immutable params - unit test for claiming scenario - unit test for deletion scenario The parameter resolver was failing when deleting/claiming prebuilds because a value for a previously-used parameter was provided to the resolver, but since the value was unchanged (it's coming from the preset) it failed in the resolver. The resolver was missing a check to see if the old value != new value; if the values match then there's no mutation of an immutable parameter. --------- Signed-off-by: Danny Kopping --- codersdk/richparameters.go | 2 +- codersdk/richparameters_test.go | 57 ++++++++++++++++--- enterprise/coderd/prebuilds/claim_test.go | 16 +++++- enterprise/coderd/prebuilds/reconcile_test.go | 19 ++++++- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/codersdk/richparameters.go b/codersdk/richparameters.go index 2ddd5d00f6c41..6fc6b8e0c343f 100644 --- a/codersdk/richparameters.go +++ b/codersdk/richparameters.go @@ -164,7 +164,7 @@ type ParameterResolver struct { // resolves the correct value. It returns the value of the parameter, if valid, and an error if invalid. func (r *ParameterResolver) ValidateResolve(p TemplateVersionParameter, v *WorkspaceBuildParameter) (value string, err error) { prevV := r.findLastValue(p) - if !p.Mutable && v != nil && prevV != nil { + if !p.Mutable && v != nil && prevV != nil && v.Value != prevV.Value { return "", xerrors.Errorf("Parameter %q is not mutable, so it can't be updated after creating a workspace.", p.Name) } if p.Required && v == nil && prevV == nil { diff --git a/codersdk/richparameters_test.go b/codersdk/richparameters_test.go index 16365f7c2f416..5635a82beb6c6 100644 --- a/codersdk/richparameters_test.go +++ b/codersdk/richparameters_test.go @@ -1,6 +1,7 @@ package codersdk_test import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -121,20 +122,60 @@ func TestParameterResolver_ValidateResolve_NewOverridesOld(t *testing.T) { func TestParameterResolver_ValidateResolve_Immutable(t *testing.T) { t.Parallel() uut := codersdk.ParameterResolver{ - Rich: []codersdk.WorkspaceBuildParameter{{Name: "n", Value: "5"}}, + Rich: []codersdk.WorkspaceBuildParameter{{Name: "n", Value: "old"}}, } p := codersdk.TemplateVersionParameter{ Name: "n", - Type: "number", + Type: "string", Required: true, Mutable: false, } - v, err := uut.ValidateResolve(p, &codersdk.WorkspaceBuildParameter{ - Name: "n", - Value: "6", - }) - require.Error(t, err) - require.Equal(t, "", v) + + cases := []struct { + name string + newValue string + expectedErr string + }{ + { + name: "mutation", + newValue: "new", // "new" != "old" + expectedErr: fmt.Sprintf("Parameter %q is not mutable", p.Name), + }, + { + // Values are case-sensitive. + name: "case change", + newValue: "Old", // "Old" != "old" + expectedErr: fmt.Sprintf("Parameter %q is not mutable", p.Name), + }, + { + name: "default", + newValue: "", // "" != "old" + expectedErr: fmt.Sprintf("Parameter %q is not mutable", p.Name), + }, + { + name: "no change", + newValue: "old", // "old" == "old" + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + v, err := uut.ValidateResolve(p, &codersdk.WorkspaceBuildParameter{ + Name: "n", + Value: tc.newValue, + }) + + if tc.expectedErr == "" { + require.NoError(t, err) + require.Equal(t, tc.newValue, v) + } else { + require.ErrorContains(t, err, tc.expectedErr) + require.Equal(t, "", v) + } + }) + } } func TestRichParameterValidation(t *testing.T) { diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index 145095e6533e7..ad31d2b4eff1b 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -329,9 +329,8 @@ func TestClaimPrebuild(t *testing.T) { require.NoError(t, err) stopBuild, err := userClient.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: version.ID, - Transition: codersdk.WorkspaceTransitionStop, - RichParameterValues: wp, + TemplateVersionID: version.ID, + Transition: codersdk.WorkspaceTransitionStop, }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, stopBuild.ID) @@ -369,6 +368,17 @@ func templateWithAgentAndPresetsWithPrebuilds(desiredInstances int32) *echo.Resp }, }, }, + // Make sure immutable params don't break claiming logic + Parameters: []*proto.RichParameter{ + { + Name: "k1", + Description: "immutable param", + Type: "string", + DefaultValue: "", + Required: false, + Mutable: false, + }, + }, Presets: []*proto.Preset{ { Name: "preset-a", diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 9783b215f185b..bc886fc0a8231 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -901,6 +901,16 @@ func setupTestDBTemplateVersion( ID: templateID, ActiveVersionID: templateVersion.ID, })) + // Make sure immutable params don't break prebuilt workspace deletion logic + dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{ + TemplateVersionID: templateVersion.ID, + Name: "test", + Description: "required & immutable param", + Type: "string", + DefaultValue: "", + Required: true, + Mutable: false, + }) return templateVersion.ID } @@ -999,7 +1009,7 @@ func setupTestDBWorkspace( OrganizationID: orgID, Error: buildError, }) - dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ WorkspaceID: workspace.ID, InitiatorID: initiatorID, TemplateVersionID: templateVersionID, @@ -1008,6 +1018,13 @@ func setupTestDBWorkspace( Transition: transition, CreatedAt: clock.Now(), }) + dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ + { + WorkspaceBuildID: workspaceBuild.ID, + Name: "test", + Value: "test", + }, + }) return workspace } From 35d686caef003e41e1624ec25c9aeffa15a27bc7 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 1 May 2025 14:24:51 +0400 Subject: [PATCH 004/158] chore: add Spike & Cian as CODEOWNERS for provisionerd proto (#17629) Adds @spikecurtis and @johnstcn as CODEOWNERS of the provisioner protocol files. These need to be versioned, so we need some human review over changes. --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index a24dfad099030..327c43dd3bb81 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,3 +4,5 @@ agent/proto/ @spikecurtis @johnstcn tailnet/proto/ @spikecurtis @johnstcn vpn/vpn.proto @spikecurtis @johnstcn vpn/version.go @spikecurtis @johnstcn +provisionerd/proto/ @spikecurtis @johnstcn +provisionersdk/proto/ @spikecurtis @johnstcn From ef00ae54f4e32267f2a81a669ca7fc9464950c03 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 1 May 2025 14:25:02 +0400 Subject: [PATCH 005/158] fix: fix data race in agentscripts.Runner (#17630) Fixes https://github.com/coder/internal/issues/604 Fixes a data race in `agentscripts.Runner` where a concurrent `Execute()` call races with `Init()`. We hit this race during shut down, which is not synchronized against starting up. In this PR I've chosen to add synchronization to the `Runner` rather than try to synchronize the calls in the agent. When we close down the agent, it's OK to just throw an error if we were never initialized with a startup script---we don't want to wait for it since that requires an active connection to the control plane. --- agent/agentscripts/agentscripts.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/agent/agentscripts/agentscripts.go b/agent/agentscripts/agentscripts.go index 4e4921b87ee5b..79606a80233b9 100644 --- a/agent/agentscripts/agentscripts.go +++ b/agent/agentscripts/agentscripts.go @@ -10,7 +10,6 @@ import ( "os/user" "path/filepath" "sync" - "sync/atomic" "time" "github.com/google/uuid" @@ -104,7 +103,6 @@ type Runner struct { closed chan struct{} closeMutex sync.Mutex cron *cron.Cron - initialized atomic.Bool scripts []runnerScript dataDir string scriptCompleted ScriptCompletedFunc @@ -113,6 +111,9 @@ type Runner struct { // execute startup scripts, and scripts on a cron schedule. Both will increment // this counter. scriptsExecuted *prometheus.CounterVec + + initMutex sync.Mutex + initialized bool } // DataDir returns the directory where scripts data is stored. @@ -154,10 +155,12 @@ func WithPostStartScripts(scripts ...codersdk.WorkspaceAgentScript) InitOption { // It also schedules any scripts that have a schedule. // This function must be called before Execute. func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc, opts ...InitOption) error { - if r.initialized.Load() { + r.initMutex.Lock() + defer r.initMutex.Unlock() + if r.initialized { return xerrors.New("init: already initialized") } - r.initialized.Store(true) + r.initialized = true r.scripts = toRunnerScript(scripts...) r.scriptCompleted = scriptCompleted for _, opt := range opts { @@ -227,6 +230,18 @@ const ( // Execute runs a set of scripts according to a filter. func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error { + initErr := func() error { + r.initMutex.Lock() + defer r.initMutex.Unlock() + if !r.initialized { + return xerrors.New("execute: not initialized") + } + return nil + }() + if initErr != nil { + return initErr + } + var eg errgroup.Group for _, script := range r.scripts { runScript := (option == ExecuteStartScripts && script.RunOnStart) || From 4ac71e9fd9b52740ea2b3e13e09150a665b976c9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 1 May 2025 13:19:35 +0100 Subject: [PATCH 006/158] fix(codersdk/toolsdk): ensure all tools include required fields of aisdk.Schema (#17632) --- codersdk/toolsdk/toolsdk.go | 2 ++ codersdk/toolsdk/toolsdk_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 024e3bad6efdc..166bde730efc5 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -327,6 +327,7 @@ var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", }, }, + Required: []string{}, }, }, Handler: func(ctx context.Context, deps Deps, args ListWorkspacesArgs) ([]MinimalWorkspace, error) { @@ -1256,6 +1257,7 @@ var DeleteTemplate = Tool[DeleteTemplateArgs, codersdk.Response]{ "type": "string", }, }, + Required: []string{"template_id"}, }, }, Handler: func(ctx context.Context, deps Deps, args DeleteTemplateArgs) (codersdk.Response, error) { diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index fae4e85e52a66..f9c35dba5951d 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -539,6 +539,33 @@ func TestWithCleanContext(t *testing.T) { }) } +func TestToolSchemaFields(t *testing.T) { + t.Parallel() + + // Test that all tools have the required Schema fields (Properties and Required) + for _, tool := range toolsdk.All { + t.Run(tool.Tool.Name, func(t *testing.T) { + t.Parallel() + + // Check that Properties is not nil + require.NotNil(t, tool.Tool.Schema.Properties, + "Tool %q missing Schema.Properties", tool.Tool.Name) + + // Check that Required is not nil + require.NotNil(t, tool.Tool.Schema.Required, + "Tool %q missing Schema.Required", tool.Tool.Name) + + // Ensure Properties has entries for all required fields + for _, requiredField := range tool.Tool.Schema.Required { + _, exists := tool.Tool.Schema.Properties[requiredField] + require.True(t, exists, + "Tool %q requires field %q but it is not defined in Properties", + tool.Tool.Name, requiredField) + } + }) + } +} + // TestMain runs after all tests to ensure that all tools in this package have // been tested once. func TestMain(m *testing.M) { From cae4fa8b45f68483bd3e89530dd7a570b85c0c58 Mon Sep 17 00:00:00 2001 From: Phorcys <57866459+phorcys420@users.noreply.github.com> Date: Thu, 1 May 2025 18:14:27 +0200 Subject: [PATCH 007/158] chore: correct typo in "Logs" page (#17633) I saw this typo when looking at the docs, quick fix. https://coder.com/docs/admin/monitoring/logs --- docs/admin/monitoring/logs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/monitoring/logs.md b/docs/admin/monitoring/logs.md index f1a5b499075f3..02e175795ae1f 100644 --- a/docs/admin/monitoring/logs.md +++ b/docs/admin/monitoring/logs.md @@ -13,7 +13,7 @@ machine/VM. - To change the log format/location, you can set [`CODER_LOGGING_HUMAN`](../../reference/cli/server.md#--log-human) and - [`CODER_LOGGING_JSON](../../reference/cli/server.md#--log-json) server config. + [`CODER_LOGGING_JSON`](../../reference/cli/server.md#--log-json) server config. options. - To only display certain types of logs, use the[`CODER_LOG_FILTER`](../../reference/cli/server.md#-l---log-filter) server From b7e08ba7c9336b3ecf95675a661f420623d3eaaf Mon Sep 17 00:00:00 2001 From: brettkolodny Date: Thu, 1 May 2025 12:26:01 -0400 Subject: [PATCH 008/158] fix: filter out deleted users when attempting to delete an organization (#17621) Closes [coder/internal#601](https://github.com/coder/internal/issues/601) --- coderd/database/dump.sql | 7 +- ...ting_orgs_to_filter_deleted_users.down.sql | 96 +++++++++++++++++ ...leting_orgs_to_filter_deleted_users.up.sql | 101 ++++++++++++++++++ coderd/database/querier_test.go | 37 +++++++ coderd/database/queries.sql.go | 44 +++++++- coderd/database/queries/organizations.sql | 45 +++++++- 6 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.down.sql create mode 100644 coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 83d998b2b9a3e..968b6a24d4bf8 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -482,9 +482,14 @@ BEGIN ); member_count := ( - SELECT count(*) as count FROM organization_members + SELECT + count(*) AS count + FROM + organization_members + LEFT JOIN users ON users.id = organization_members.user_id WHERE organization_members.organization_id = OLD.id + AND users.deleted = FALSE ); provisioner_keys_count := ( diff --git a/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.down.sql b/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.down.sql new file mode 100644 index 0000000000000..cacafc029222c --- /dev/null +++ b/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.down.sql @@ -0,0 +1,96 @@ +DROP TRIGGER IF EXISTS protect_deleting_organizations ON organizations; + +-- Replace the function with the new implementation +CREATE OR REPLACE FUNCTION protect_deleting_organizations() + RETURNS TRIGGER AS +$$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT count(*) as count FROM organization_members + WHERE + organization_members.organization_id = OLD.id + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + -- Only create error message for resources that actually exist + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + DECLARE + error_message text := 'cannot delete organization: organization has '; + error_parts text[] := '{}'; + BEGIN + IF workspace_count > 0 THEN + error_parts := array_append(error_parts, workspace_count || ' workspaces'); + END IF; + + IF template_count > 0 THEN + error_parts := array_append(error_parts, template_count || ' templates'); + END IF; + + IF provisioner_keys_count > 0 THEN + error_parts := array_append(error_parts, provisioner_keys_count || ' provisioner keys'); + END IF; + + error_message := error_message || array_to_string(error_parts, ', ') || ' that must be deleted first'; + RAISE EXCEPTION '%', error_message; + END; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to protect organizations from being soft deleted with existing resources +CREATE TRIGGER protect_deleting_organizations + BEFORE UPDATE ON organizations + FOR EACH ROW + WHEN (NEW.deleted = true AND OLD.deleted = false) + EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.up.sql b/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.up.sql new file mode 100644 index 0000000000000..8db15223d92f1 --- /dev/null +++ b/coderd/database/migrations/000318_update_protect_deleting_orgs_to_filter_deleted_users.up.sql @@ -0,0 +1,101 @@ +DROP TRIGGER IF EXISTS protect_deleting_organizations ON organizations; + +-- Replace the function with the new implementation +CREATE OR REPLACE FUNCTION protect_deleting_organizations() + RETURNS TRIGGER AS +$$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT + count(*) AS count + FROM + organization_members + LEFT JOIN users ON users.id = organization_members.user_id + WHERE + organization_members.organization_id = OLD.id + AND users.deleted = FALSE + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + -- Only create error message for resources that actually exist + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + DECLARE + error_message text := 'cannot delete organization: organization has '; + error_parts text[] := '{}'; + BEGIN + IF workspace_count > 0 THEN + error_parts := array_append(error_parts, workspace_count || ' workspaces'); + END IF; + + IF template_count > 0 THEN + error_parts := array_append(error_parts, template_count || ' templates'); + END IF; + + IF provisioner_keys_count > 0 THEN + error_parts := array_append(error_parts, provisioner_keys_count || ' provisioner keys'); + END IF; + + error_message := error_message || array_to_string(error_parts, ', ') || ' that must be deleted first'; + RAISE EXCEPTION '%', error_message; + END; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to protect organizations from being soft deleted with existing resources +CREATE TRIGGER protect_deleting_organizations + BEFORE UPDATE ON organizations + FOR EACH ROW + WHEN (NEW.deleted = true AND OLD.deleted = false) + EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 4a2edb4451c34..b2cc20c4894d5 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3586,6 +3586,43 @@ func TestOrganizationDeleteTrigger(t *testing.T) { require.ErrorContains(t, err, "cannot delete organization") require.ErrorContains(t, err, "has 1 members") }) + + t.Run("UserDeletedButNotRemovedFromOrg", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + userA := dbgen.User(t, db, database.User{}) + userB := dbgen.User(t, db, database.User{}) + userC := dbgen.User(t, db, database.User{}) + + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgA.Org.ID, + UserID: userA.ID, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgA.Org.ID, + UserID: userB.ID, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgA.Org.ID, + UserID: userC.ID, + }) + + // Delete one of the users but don't remove them from the org + ctx := testutil.Context(t, testutil.WaitShort) + db.UpdateUserDeletedByID(ctx, userB.ID) + + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 1 members that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "has 1 members") + }) } type templateVersionWithPreset struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 60416b1a35730..3908dab715e31 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5586,11 +5586,45 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat const getOrganizationResourceCountByID = `-- name: GetOrganizationResourceCountByID :one SELECT - (SELECT COUNT(*) FROM workspaces WHERE workspaces.organization_id = $1 AND workspaces.deleted = false) AS workspace_count, - (SELECT COUNT(*) FROM groups WHERE groups.organization_id = $1) AS group_count, - (SELECT COUNT(*) FROM templates WHERE templates.organization_id = $1 AND templates.deleted = false) AS template_count, - (SELECT COUNT(*) FROM organization_members WHERE organization_members.organization_id = $1) AS member_count, - (SELECT COUNT(*) FROM provisioner_keys WHERE provisioner_keys.organization_id = $1) AS provisioner_key_count + ( + SELECT + count(*) + FROM + workspaces + WHERE + workspaces.organization_id = $1 + AND workspaces.deleted = FALSE) AS workspace_count, + ( + SELECT + count(*) + FROM + GROUPS + WHERE + groups.organization_id = $1) AS group_count, + ( + SELECT + count(*) + FROM + templates + WHERE + templates.organization_id = $1 + AND templates.deleted = FALSE) AS template_count, + ( + SELECT + count(*) + FROM + organization_members + LEFT JOIN users ON organization_members.user_id = users.id + WHERE + organization_members.organization_id = $1 + AND users.deleted = FALSE) AS member_count, +( + SELECT + count(*) + FROM + provisioner_keys + WHERE + provisioner_keys.organization_id = $1) AS provisioner_key_count ` type GetOrganizationResourceCountByIDRow struct { diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index d940fb1ad4dc6..89a4a7bcfcef4 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -73,11 +73,46 @@ WHERE -- name: GetOrganizationResourceCountByID :one SELECT - (SELECT COUNT(*) FROM workspaces WHERE workspaces.organization_id = $1 AND workspaces.deleted = false) AS workspace_count, - (SELECT COUNT(*) FROM groups WHERE groups.organization_id = $1) AS group_count, - (SELECT COUNT(*) FROM templates WHERE templates.organization_id = $1 AND templates.deleted = false) AS template_count, - (SELECT COUNT(*) FROM organization_members WHERE organization_members.organization_id = $1) AS member_count, - (SELECT COUNT(*) FROM provisioner_keys WHERE provisioner_keys.organization_id = $1) AS provisioner_key_count; + ( + SELECT + count(*) + FROM + workspaces + WHERE + workspaces.organization_id = $1 + AND workspaces.deleted = FALSE) AS workspace_count, + ( + SELECT + count(*) + FROM + GROUPS + WHERE + groups.organization_id = $1) AS group_count, + ( + SELECT + count(*) + FROM + templates + WHERE + templates.organization_id = $1 + AND templates.deleted = FALSE) AS template_count, + ( + SELECT + count(*) + FROM + organization_members + LEFT JOIN users ON organization_members.user_id = users.id + WHERE + organization_members.organization_id = $1 + AND users.deleted = FALSE) AS member_count, +( + SELECT + count(*) + FROM + provisioner_keys + WHERE + provisioner_keys.organization_id = $1) AS provisioner_key_count; + -- name: InsertOrganization :one INSERT INTO From d9ef6ed8aec422e7ccd799a285b79dc34ab73804 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 1 May 2025 18:14:11 +0100 Subject: [PATCH 009/158] chore: replace MoreMenu with DropdownMenu (#17615) Replace MoreMenu with DropDownMenu component to match update design patterns. Note: This was the result of experimentation using Cursor to make the changes and Claude Code for fixing tests. One key takeaway is that verbose e2e logging, especially benign warnings/errors can confuse Claude Code in running playwright and confirming its work. Screenshot 2025-05-01 at 00 00 52 Screenshot 2025-05-01 at 00 01 07 Screenshot 2025-05-01 at 00 01 20 Screenshot 2025-05-01 at 00 01 30 --- site/e2e/tests/groups/removeMember.spec.ts | 5 +- site/e2e/tests/organizationGroups.spec.ts | 6 +- site/e2e/tests/organizationMembers.spec.ts | 5 +- .../customRoles/customRoles.spec.ts | 10 +- site/e2e/tests/updateTemplate.spec.ts | 6 +- site/e2e/tests/users/removeUser.spec.ts | 6 +- .../components/DropdownMenu/DropdownMenu.tsx | 2 +- .../components/MoreMenu/MoreMenu.stories.tsx | 59 -------- site/src/components/MoreMenu/MoreMenu.tsx | 135 ------------------ .../AnnouncementBannerItem.tsx | 41 +++--- site/src/pages/GroupsPage/GroupPage.tsx | 42 +++--- .../CustomRolesPage/CustomRolesPageView.tsx | 51 ++++--- .../OrganizationMembersPage.test.tsx | 18 ++- .../OrganizationMembersPageView.tsx | 43 +++--- .../pages/TemplatePage/TemplatePageHeader.tsx | 71 ++++----- .../TemplatePermissionsPageView.tsx | 69 +++++---- .../ExternalAuthPage/ExternalAuthPageView.tsx | 44 +++--- .../src/pages/UsersPage/UsersPage.stories.tsx | 50 +++---- .../UsersPage/UsersTable/UsersTableBody.tsx | 98 +++++++------ .../WorkspaceActions.stories.tsx | 13 +- .../WorkspaceActions/WorkspaceActions.tsx | 63 ++++---- .../WorkspacesPage/WorkspacesPageView.tsx | 51 +++---- 22 files changed, 384 insertions(+), 504 deletions(-) delete mode 100644 site/src/components/MoreMenu/MoreMenu.stories.tsx delete mode 100644 site/src/components/MoreMenu/MoreMenu.tsx diff --git a/site/e2e/tests/groups/removeMember.spec.ts b/site/e2e/tests/groups/removeMember.spec.ts index 856ece95c0b02..c69925589221a 100644 --- a/site/e2e/tests/groups/removeMember.spec.ts +++ b/site/e2e/tests/groups/removeMember.spec.ts @@ -33,9 +33,8 @@ test("remove member", async ({ page, baseURL }) => { await expect(page).toHaveTitle(`${group.display_name} - Coder`); const userRow = page.getByRole("row", { name: member.username }); - await userRow.getByRole("button", { name: "More options" }).click(); - - const menu = page.locator("#more-options"); + await userRow.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); await menu.getByText("Remove").click({ timeout: 1_000 }); await expect(page.getByText("Member removed successfully.")).toBeVisible(); diff --git a/site/e2e/tests/organizationGroups.spec.ts b/site/e2e/tests/organizationGroups.spec.ts index 08768d4bbae11..14741bdf38e00 100644 --- a/site/e2e/tests/organizationGroups.spec.ts +++ b/site/e2e/tests/organizationGroups.spec.ts @@ -79,8 +79,10 @@ test("create group", async ({ page }) => { await expect(page.getByText("No users found")).toBeVisible(); // Remove someone from the group - await addedRow.getByLabel("More options").click(); - await page.getByText("Remove").click(); + await addedRow.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); + await menu.getByText("Remove").click(); + await expect(addedRow).not.toBeVisible(); // Delete the group diff --git a/site/e2e/tests/organizationMembers.spec.ts b/site/e2e/tests/organizationMembers.spec.ts index 51c3491ae3d62..639e6428edfb5 100644 --- a/site/e2e/tests/organizationMembers.spec.ts +++ b/site/e2e/tests/organizationMembers.spec.ts @@ -39,8 +39,9 @@ test("add and remove organization member", async ({ page }) => { await expect(addedRow.getByText("+1 more")).toBeVisible(); // Remove them from the org - await addedRow.getByLabel("More options").click(); - await page.getByText("Remove").click(); // Click the "Remove" option + await addedRow.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); + await menu.getByText("Remove").click(); await page.getByRole("button", { name: "Remove" }).click(); // Click "Remove" in the confirmation dialog await expect(addedRow).not.toBeVisible(); }); diff --git a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts index 1e1e518e96399..1f55e87de8bab 100644 --- a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts +++ b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts @@ -37,8 +37,8 @@ test.describe("CustomRolesPage", () => { await expect(roleRow.getByText(customRole.display_name)).toBeVisible(); await expect(roleRow.getByText("organization_member")).toBeVisible(); - await roleRow.getByRole("button", { name: "More options" }).click(); - const menu = page.locator("#more-options"); + await roleRow.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); await menu.getByText("Edit").click(); await expect(page).toHaveURL( @@ -118,7 +118,7 @@ test.describe("CustomRolesPage", () => { // Verify that the more menu (three dots) is not present for built-in roles await expect( - roleRow.getByRole("button", { name: "More options" }), + roleRow.getByRole("button", { name: "Open menu" }), ).not.toBeVisible(); await deleteOrganization(org.name); @@ -175,9 +175,9 @@ test.describe("CustomRolesPage", () => { await page.goto(`/organizations/${org.name}/roles`); const roleRow = page.getByTestId(`role-${customRole.name}`); - await roleRow.getByRole("button", { name: "More options" }).click(); + await roleRow.getByRole("button", { name: "Open menu" }).click(); - const menu = page.locator("#more-options"); + const menu = page.getByRole("menu"); await menu.getByText("Delete…").click(); const input = page.getByRole("textbox"); diff --git a/site/e2e/tests/updateTemplate.spec.ts b/site/e2e/tests/updateTemplate.spec.ts index e0bfac03cf036..43dd392443ea2 100644 --- a/site/e2e/tests/updateTemplate.spec.ts +++ b/site/e2e/tests/updateTemplate.spec.ts @@ -53,8 +53,10 @@ test("add and remove a group", async ({ page }) => { await expect(row).toBeVisible(); // Now remove the group - await row.getByLabel("More options").click(); - await page.getByText("Remove").click(); + await row.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); + await menu.getByText("Remove").click(); + await expect(page.getByText("Group removed successfully!")).toBeVisible(); await expect(row).not.toBeVisible(); }); diff --git a/site/e2e/tests/users/removeUser.spec.ts b/site/e2e/tests/users/removeUser.spec.ts index c44d64b39c13c..92aa3efaa803a 100644 --- a/site/e2e/tests/users/removeUser.spec.ts +++ b/site/e2e/tests/users/removeUser.spec.ts @@ -17,9 +17,9 @@ test("remove user", async ({ page, baseURL }) => { await expect(page).toHaveTitle("Users - Coder"); const userRow = page.getByRole("row", { name: user.email }); - await userRow.getByRole("button", { name: "More options" }).click(); - const menu = page.locator("#more-options"); - await menu.getByText("Delete").click(); + await userRow.getByRole("button", { name: "Open menu" }).click(); + const menu = page.getByRole("menu"); + await menu.getByText("Delete…").click(); const dialog = page.getByTestId("dialog"); await dialog.getByLabel("Name of the user to delete").fill(user.username); diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index c37f9f0146047..e56fd7cbe4343 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -196,7 +196,7 @@ export const DropdownMenuSeparator = forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/site/src/components/MoreMenu/MoreMenu.stories.tsx b/site/src/components/MoreMenu/MoreMenu.stories.tsx deleted file mode 100644 index e7a9968b01414..0000000000000 --- a/site/src/components/MoreMenu/MoreMenu.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import GrassIcon from "@mui/icons-material/Grass"; -import KitesurfingIcon from "@mui/icons-material/Kitesurfing"; -import { action } from "@storybook/addon-actions"; -import type { Meta, StoryObj } from "@storybook/react"; -import { expect, screen, userEvent, waitFor, within } from "@storybook/test"; -import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "./MoreMenu"; - -const meta: Meta = { - title: "components/MoreMenu", - component: MoreMenu, -}; - -export default meta; -type Story = StoryObj; - -const Example: Story = { - args: { - children: ( - <> - - - - - - - Touch grass - - - - Touch water - - - - ), - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Open menu", async () => { - await userEvent.click( - canvas.getByRole("button", { name: "More options" }), - ); - await waitFor(() => - Promise.all([ - expect(screen.getByText(/touch grass/i)).toBeInTheDocument(), - expect(screen.getByText(/touch water/i)).toBeInTheDocument(), - ]), - ); - }); - }, -}; - -export { Example as MoreMenu }; diff --git a/site/src/components/MoreMenu/MoreMenu.tsx b/site/src/components/MoreMenu/MoreMenu.tsx deleted file mode 100644 index 8ba7864fc5e5d..0000000000000 --- a/site/src/components/MoreMenu/MoreMenu.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; -import IconButton, { type IconButtonProps } from "@mui/material/IconButton"; -import Menu, { type MenuProps } from "@mui/material/Menu"; -import MenuItem, { type MenuItemProps } from "@mui/material/MenuItem"; -import { - type FC, - type HTMLProps, - type PropsWithChildren, - type ReactElement, - cloneElement, - createContext, - forwardRef, - useContext, - useRef, - useState, -} from "react"; - -type MoreMenuContextValue = { - triggerRef: React.RefObject; - close: () => void; - open: () => void; - isOpen: boolean; -}; - -const MoreMenuContext = createContext( - undefined, -); - -export const MoreMenu: FC = ({ children }) => { - const triggerRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - - const close = () => { - setIsOpen(false); - }; - - const open = () => { - setIsOpen(true); - }; - - return ( - - {children} - - ); -}; - -const useMoreMenuContext = () => { - const ctx = useContext(MoreMenuContext); - - if (!ctx) { - throw new Error("useMoreMenuContext must be used inside of MoreMenu"); - } - - return ctx; -}; - -export const MoreMenuTrigger: FC> = ({ - children, - ...props -}) => { - const menu = useMoreMenuContext(); - - return cloneElement(children as ReactElement, { - "aria-haspopup": "true", - ...props, - ref: menu.triggerRef, - onClick: menu.open, - }); -}; - -export const ThreeDotsButton = forwardRef( - (props, ref) => { - return ( - - - - ); - }, -); - -export const MoreMenuContent: FC> = ( - props, -) => { - const menu = useMoreMenuContext(); - - return ( - - ); -}; - -interface MoreMenuItemProps extends MenuItemProps { - closeOnClick?: boolean; - danger?: boolean; -} - -export const MoreMenuItem: FC = ({ - closeOnClick = true, - danger = false, - ...menuItemProps -}) => { - const menu = useMoreMenuContext(); - - return ( - ({ - fontSize: 14, - color: danger ? theme.palette.warning.light : undefined, - "& .MuiSvgIcon-root": { - width: 16, - height: 16, - }, - })} - onClick={(e) => { - menuItemProps.onClick?.(e); - if (closeOnClick) { - menu.close(); - } - }} - /> - ); -}; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerItem.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerItem.tsx index 9df726681a555..9f72601ea9607 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerItem.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AnnouncementBannerItem.tsx @@ -3,13 +3,14 @@ import Checkbox from "@mui/material/Checkbox"; import TableCell from "@mui/material/TableCell"; import TableRow from "@mui/material/TableRow"; import type { BannerConfig } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EllipsisVertical } from "lucide-react"; import type { FC } from "react"; interface AnnouncementBannerItemProps { @@ -48,17 +49,25 @@ export const AnnouncementBannerItem: FC = ({ - - - - - - onEdit()}>Edit… - onDelete()} danger> + + + + + + onEdit()}> + Edit… + + onDelete()} + > Delete… - - - + + + ); diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index db439550f2f81..2d1469ed696dd 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -20,18 +20,18 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; +import { Button as ShadcnButton } from "components/Button/Button"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { LastSeen } from "components/LastSeen/LastSeen"; import { Loader } from "components/Loader/Loader"; -import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; import { SettingsHeader, SettingsHeaderDescription, @@ -51,6 +51,7 @@ import { TableToolbar, } from "components/TableToolbar/TableToolbar"; import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { EllipsisVertical } from "lucide-react"; import { type FC, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -330,20 +331,27 @@ const GroupMemberRow: FC = ({ {canUpdate && ( - - - - - - + + + + + + Remove - - - + + + )} diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 3fb04fe483271..2c360a8dd4e45 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -4,15 +4,15 @@ import AddOutlined from "@mui/icons-material/AddOutlined"; import Button from "@mui/material/Button"; import Skeleton from "@mui/material/Skeleton"; import type { AssignableRoles, Role } from "api/typesGenerated"; +import { Button as ShadcnButton } from "components/Button/Button"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; -import { EmptyState } from "components/EmptyState/EmptyState"; import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EmptyState } from "components/EmptyState/EmptyState"; import { Paywall } from "components/Paywall/Paywall"; import { Stack } from "components/Stack/Stack"; import { @@ -27,6 +27,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; +import { EllipsisVertical } from "lucide-react"; import type { FC } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import { docs } from "utils/docs"; @@ -213,27 +214,33 @@ const RoleRow: FC = ({ {!role.built_in && (canUpdateOrgRole || canDeleteOrgRole) && ( - - - - - + + + + + + {canUpdateOrgRole && ( - { - navigate(role.name); - }} - > + navigate(role.name)}> Edit - + )} {canDeleteOrgRole && ( - + Delete… - + )} - - + + )} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx index f828969238cec..660e66ca0ccb2 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx @@ -46,15 +46,19 @@ const renderPage = async () => { const removeMember = async () => { const user = userEvent.setup(); - // Click on the "More options" button to display the "Remove" option - const moreButtons = await screen.findAllByLabelText("More options"); - // get MockUser2 - const selectedMoreButton = moreButtons[0]; - await user.click(selectedMoreButton); + const users = await screen.findAllByText(/.*@coder.com/); + const userRow = users[1].closest("tr"); + if (!userRow) { + throw new Error("Error on get the first user row"); + } + const menuButton = await within(userRow).findByRole("button", { + name: "Open menu", + }); + await user.click(menuButton); - const removeButton = screen.getByText(/Remove/); - await user.click(removeButton); + const removeOption = await screen.findByRole("menuitem", { name: "Remove" }); + await user.click(removeOption); const dialog = await within(document.body).findByRole("dialog"); await user.click(within(dialog).getByRole("button", { name: "Remove" })); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 296ea9a8c658a..686842b196b0b 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -10,15 +10,15 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; +import { Button } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; -import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; import { PaginationContainer } from "components/PaginationWidget/PaginationContainer"; import { SettingsHeader, @@ -35,7 +35,7 @@ import { } from "components/Table/Table"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import type { PaginationResultInfo } from "hooks/usePaginatedQuery"; -import { TriangleAlert } from "lucide-react"; +import { EllipsisVertical, TriangleAlert } from "lucide-react"; import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; import { type FC, useState } from "react"; import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; @@ -163,19 +163,26 @@ export const OrganizationMembersPageView: FC< {member.user_id !== me.id && canEditMembers && ( - - - - - - + + + + + removeMember(member)} > Remove - - - + + + )} diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index e9970df30c174..83c5b55019715 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -4,7 +4,6 @@ import EditIcon from "@mui/icons-material/EditOutlined"; import CopyIcon from "@mui/icons-material/FileCopyOutlined"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; import { workspaces } from "api/queries/workspaces"; import type { AuthorizationResponse, @@ -12,17 +11,18 @@ import type { TemplateVersion, } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; +import { Button as ShadcnButton } from "components/Button/Button"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { Margins } from "components/Margins/Margins"; import { MemoizedInlineMarkdown } from "components/Markdown/Markdown"; -import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; import { PageHeader, PageHeaderSubtitle, @@ -30,6 +30,7 @@ import { } from "components/PageHeader/PageHeader"; import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; +import { EllipsisVertical } from "lucide-react"; import { linkToTemplate, useLinks } from "modules/navigation"; import type { WorkspacePermissions } from "modules/permissions/workspaces"; import type { FC } from "react"; @@ -67,44 +68,48 @@ const TemplateMenu: FC = ({ return ( <> - - - - - - { - navigate(`${templateLink}/settings`); - }} + + + + + + + navigate(`${templateLink}/settings`)} > Settings - + - { - navigate(`${templateLink}/versions/${templateVersion}/edit`); - }} + + navigate(`${templateLink}/versions/${templateVersion}/edit`) + } > Edit files - + - { - navigate(`/templates/new?fromTemplate=${templateId}`); - }} + + navigate(`/templates/new?fromTemplate=${templateId}`) + } > Duplicate… - - - + + + Delete… - - - + + + {safeToDeleteTemplate ? ( {canUpdatePermissions && ( - - - - - - + + + + + onRemoveGroup(group)} > Remove - - - + + + )} @@ -338,19 +346,26 @@ export const TemplatePermissionsPageView: FC< {canUpdatePermissions && ( - - - - - - + + + + + onRemoveUser(user)} > Remove - - - + + + )} diff --git a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx index 6cf9204b70540..e44e26fa5aeeb 100644 --- a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx +++ b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx @@ -1,7 +1,6 @@ import { useTheme } from "@emotion/react"; import AutorenewIcon from "@mui/icons-material/Autorenew"; import LoadingButton from "@mui/lab/LoadingButton"; -import Divider from "@mui/material/Divider"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -18,16 +17,17 @@ import type { } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; -import { Loader } from "components/Loader/Loader"; +import { Button } from "components/Button/Button"; import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; +import { EllipsisVertical } from "lucide-react"; import type { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage"; import { type FC, useCallback, useEffect, useState } from "react"; import { useQuery } from "react-query"; @@ -178,12 +178,15 @@ const ExternalAuthRow: FC = ({ - - - - - - + + + + + { onValidateExternalAuth(); // This is kinda jank. It does a refetch of the thing @@ -194,19 +197,18 @@ const ExternalAuthRow: FC = ({ }} > Test Validate… - - - + { onUnlinkExternalAuth(); await refetch(); }} > Unlink… - - - + + + ); diff --git a/site/src/pages/UsersPage/UsersPage.stories.tsx b/site/src/pages/UsersPage/UsersPage.stories.tsx index 8a3c9bea5d013..88059c35e3096 100644 --- a/site/src/pages/UsersPage/UsersPage.stories.tsx +++ b/site/src/pages/UsersPage/UsersPage.stories.tsx @@ -99,10 +99,8 @@ export const SuspendUserSuccess: Story = { count: 60, }); - await user.click(within(userRow).getByLabelText("More options")); - const suspendButton = await within(userRow).findByText("Suspend", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const suspendButton = await within(document.body).findByText("Suspend…"); await user.click(suspendButton); const dialog = await within(document.body).findByRole("dialog"); @@ -120,10 +118,8 @@ export const SuspendUserError: Story = { } spyOn(API, "suspendUser").mockRejectedValue(undefined); - await user.click(within(userRow).getByLabelText("More options")); - const suspendButton = await within(userRow).findByText("Suspend", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const suspendButton = await within(document.body).findByText("Suspend…"); await user.click(suspendButton); const dialog = await within(document.body).findByRole("dialog"); @@ -149,10 +145,8 @@ export const DeleteUserSuccess: Story = { count: 59, }); - await user.click(within(userRow).getByLabelText("More options")); - const deleteButton = await within(userRow).findByText("Delete", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const deleteButton = await within(document.body).findByText("Delete…"); await user.click(deleteButton); const dialog = await within(document.body).findByRole("dialog"); @@ -172,10 +166,8 @@ export const DeleteUserError: Story = { } spyOn(API, "deleteUser").mockRejectedValue({}); - await user.click(within(userRow).getByLabelText("More options")); - const deleteButton = await within(userRow).findByText("Delete", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const deleteButton = await within(document.body).findByText("Delete…"); await user.click(deleteButton); const dialog = await within(document.body).findByRole("dialog"); @@ -220,10 +212,8 @@ export const ActivateUserSuccess: Story = { count: 60, }); - await user.click(within(userRow).getByLabelText("More options")); - const activateButton = await within(userRow).findByText("Activate", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const activateButton = await within(document.body).findByText("Activate…"); await user.click(activateButton); const dialog = await within(document.body).findByRole("dialog"); @@ -242,10 +232,8 @@ export const ActivateUserError: Story = { } spyOn(API, "activateUser").mockRejectedValue({}); - await user.click(within(userRow).getByLabelText("More options")); - const activateButton = await within(userRow).findByText("Activate", { - exact: false, - }); + await user.click(within(userRow).getByLabelText("Open menu")); + const activateButton = await within(document.body).findByText("Activate…"); await user.click(activateButton); const dialog = await within(document.body).findByRole("dialog"); @@ -279,10 +267,9 @@ export const ResetUserPasswordSuccess: Story = { } spyOn(API, "updateUserPassword").mockResolvedValue(); - await user.click(within(userRow).getByLabelText("More options")); - const resetPasswordButton = await within(userRow).findByText( - "Reset password", - { exact: false }, + await user.click(within(userRow).getByLabelText("Open menu")); + const resetPasswordButton = await within(document.body).findByText( + "Reset password…", ); await user.click(resetPasswordButton); @@ -306,10 +293,9 @@ export const ResetUserPasswordError: Story = { } spyOn(API, "updateUserPassword").mockRejectedValue({}); - await user.click(within(userRow).getByLabelText("More options")); - const resetPasswordButton = await within(userRow).findByText( - "Reset password", - { exact: false }, + await user.click(within(userRow).getByLabelText("Open menu")); + const resetPasswordButton = await within(document.body).findByText( + "Reset password…", ); await user.click(resetPasswordButton); diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index f746b35aba75f..d473e2be95fe6 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -1,26 +1,27 @@ import type { Interpolation, Theme } from "@emotion/react"; +import DeleteIcon from "@mui/icons-material/Delete"; import GitHub from "@mui/icons-material/GitHub"; import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined"; import KeyOutlined from "@mui/icons-material/KeyOutlined"; import PasswordOutlined from "@mui/icons-material/PasswordOutlined"; import ShieldOutlined from "@mui/icons-material/ShieldOutlined"; -import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; import type { GroupsByUserId } from "api/queries/groups"; import type * as TypesGen from "api/typesGenerated"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; import { PremiumBadge } from "components/Badges/Badges"; +import { Button } from "components/Button/Button"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { EmptyState } from "components/EmptyState/EmptyState"; import { LastSeen } from "components/LastSeen/LastSeen"; -import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, - ThreeDotsButton, -} from "components/MoreMenu/MoreMenu"; import { TableCell, TableRow } from "components/Table/Table"; import { TableLoaderSkeleton, @@ -28,6 +29,7 @@ import { } from "components/TableLoader/TableLoader"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; +import { EllipsisVertical } from "lucide-react"; import type { FC } from "react"; import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell"; import { UserGroupsCell } from "./UserGroupsCell"; @@ -180,51 +182,65 @@ export const UsersTableBody: FC = ({ {canEditUsers && ( - - - - - + + + + + {user.status === "active" || user.status === "dormant" ? ( - { - onSuspendUser(user); - }} + onClick={() => onSuspendUser(user)} > Suspend… - + ) : ( - onActivateUser(user)}> + onActivateUser(user)}> Activate… - + )} - onListWorkspaces(user)}> + + onListWorkspaces(user)}> View workspaces - - onViewActivity(user)} - disabled={!canViewActivity} - > - View activity - {!canViewActivity && } - - onResetUserPassword(user)} - disabled={user.login_type !== "password"} - > - Reset password… - - - + + {canViewActivity && ( + onViewActivity(user)} + disabled={!canViewActivity} + > + View activity {!canViewActivity && } + + )} + + {user.login_type === "password" && ( + onResetUserPassword(user)} + disabled={user.login_type !== "password"} + > + Reset password… + + )} + + + + onDeleteUser(user)} disabled={user.id === actorID} - danger > + Delete… - - - + + + )} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index 700797c886030..d726a047b7c57 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -202,9 +202,11 @@ export const OpenDownloadLogs: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "More options" })); - await userEvent.click(canvas.getByText("Download logs", { exact: false })); + await userEvent.click( + canvas.getByRole("button", { name: "Workspace actions" }), + ); const screen = within(document.body); + await userEvent.click(screen.getByText("Download logs…")); await expect(screen.getByTestId("dialog")).toBeInTheDocument(); }, }; @@ -215,8 +217,11 @@ export const CanDeleteDormantWorkspace: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "More options" })); - const deleteButton = canvas.getByText("Delete…"); + await userEvent.click( + canvas.getByRole("button", { name: "Workspace actions" }), + ); + const screen = within(document.body); + const deleteButton = screen.getByText("Delete…"); await expect(deleteButton).toBeEnabled(); }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index b187167bb4631..71f890cff3a5b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -2,17 +2,17 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; import DuplicateIcon from "@mui/icons-material/FileCopyOutlined"; import HistoryIcon from "@mui/icons-material/HistoryOutlined"; -import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; -import Divider from "@mui/material/Divider"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; -import { TopbarIconButton } from "components/FullPageLayout/Topbar"; +import { Button } from "components/Button/Button"; import { - MoreMenu, - MoreMenuContent, - MoreMenuItem, - MoreMenuTrigger, -} from "components/MoreMenu/MoreMenu"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EllipsisVertical } from "lucide-react"; import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication"; import { type FC, Fragment, type ReactNode, useState } from "react"; import { mustUpdateWorkspace } from "utils/workspace"; @@ -177,56 +177,59 @@ export const WorkspaceActions: FC = ({ onToggle={handleToggleFavorite} /> - - - + + + - - + + Settings - + {canChangeVersions && ( - + Change version… - + )} - Duplicate… - + - setIsDownloadDialogOpen(true)}> + setIsDownloadDialogOpen(true)}> Download logs… - + - + - Delete… - - - + + + = ({ {workspaces?.length === 1 ? "workspace" : "workspaces"} - - + + = ({ > Actions - - - + + @@ -157,28 +156,32 @@ export const WorkspacesPageView: FC = ({ !mustUpdateWorkspace(w, canChangeVersions), ) } + onClick={onStartAll} > Start - - + w.latest_build.status === "running", ) } + onClick={onStopAll} > Stop - - - + + + Update… - - + + Delete… - - - + + + ) : ( !invalidPageNumber && ( From ef11d4f769abf48c9b55624ade7cb07152374df9 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 1 May 2025 17:26:30 -0400 Subject: [PATCH 010/158] fix: fix bug with deletion of prebuilt workspaces (#17652) Don't specify the template version for a delete transition, because the prebuilt workspace may have been created using an older template version. If the template version isn't explicitly set, the builder will automatically use the version from the last workspace build - which is the desired behavior. --- enterprise/coderd/prebuilds/reconcile.go | 15 ++-- enterprise/coderd/prebuilds/reconcile_test.go | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index 1b99e46a56680..5639678c1b9db 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -549,13 +549,18 @@ func (c *StoreReconciler) provision( builder := wsbuilder.New(workspace, transition). Reason(database.BuildReasonInitiator). Initiator(prebuilds.SystemUserID). - VersionID(template.ActiveVersionID). - MarkPrebuild(). - TemplateVersionPresetID(presetID) + MarkPrebuild() - // We only inject the required params when the prebuild is being created. - // This mirrors the behavior of regular workspace deletion (see cli/delete.go). if transition != database.WorkspaceTransitionDelete { + // We don't specify the version for a delete transition, + // because the prebuilt workspace may have been created using an older template version. + // If the version isn't explicitly set, the builder will automatically use the version + // from the last workspace build — which is the desired behavior. + builder = builder.VersionID(template.ActiveVersionID) + + // We only inject the required params when the prebuild is being created. + // This mirrors the behavior of regular workspace deletion (see cli/delete.go). + builder = builder.TemplateVersionPresetID(presetID) builder = builder.RichParameterValues(params) } diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index bc886fc0a8231..a1732c8391d11 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -554,6 +554,75 @@ func TestInvalidPreset(t *testing.T) { } } +func TestDeletionOfPrebuiltWorkspaceWithInvalidPreset(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + + templateDeleted := false + + clock := quartz.NewMock(t) + ctx := testutil.Context(t, testutil.WaitShort) + cfg := codersdk.PrebuildsConfig{} + logger := slogtest.Make( + t, &slogtest.Options{IgnoreErrors: true}, + ).Leveled(slog.LevelDebug) + db, pubSub := dbtestutil.NewDB(t) + controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry()) + + ownerID := uuid.New() + dbgen.User(t, db, database.User{ + ID: ownerID, + }) + org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted) + templateVersionID := setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) + preset := setupTestDBPreset(t, db, templateVersionID, 1, uuid.New().String()) + prebuiltWorkspace := setupTestDBPrebuild( + t, + clock, + db, + pubSub, + database.WorkspaceTransitionStart, + database.ProvisionerJobStatusSucceeded, + org.ID, + preset, + template.ID, + templateVersionID, + ) + + workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) + require.NoError(t, err) + // make sure we have only one workspace + require.Equal(t, 1, len(workspaces)) + + // Create a new template version and mark it as active. + // This marks the previous template version as inactive. + templateVersionID = setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) + // Add required param, which is not set in preset. + // It means that creating of new prebuilt workspace will fail, but we should be able to clean up old prebuilt workspaces. + dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{ + TemplateVersionID: templateVersionID, + Name: "required-param", + Description: "required param which isn't set in preset", + Type: "bool", + DefaultValue: "", + Required: true, + }) + + // Old prebuilt workspace should be deleted. + require.NoError(t, controller.ReconcileAll(ctx)) + + builds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ + WorkspaceID: prebuiltWorkspace.ID, + }) + require.NoError(t, err) + // Make sure old prebuild workspace was deleted, despite it contains required parameter which isn't set in preset. + require.Equal(t, 2, len(builds)) + require.Equal(t, database.WorkspaceTransitionDelete, builds[0].Transition) +} + func TestRunLoop(t *testing.T) { t.Parallel() From a226a75b3276a2291a7cbf4091dfaa23ac03c742 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 2 May 2025 01:45:02 +0300 Subject: [PATCH 011/158] docs: add early access dev container docs (#17613) This change documents the early access dev containers integration and how to enable it, what features are available and what limitations exist at the time of writing. --------- Co-authored-by: EdwardAngert <17991901+EdwardAngert@users.noreply.github.com> --- .../extending-templates/devcontainers.md | 124 ++++++++++++++++++ docs/admin/templates/index.md | 3 + .../devcontainer-agent-ports.png | Bin 0 -> 136171 bytes .../devcontainer-web-terminal.png | Bin 0 -> 51032 bytes docs/manifest.json | 21 +++ docs/user-guides/devcontainers/index.md | 99 ++++++++++++++ .../troubleshooting-dev-containers.md | 16 +++ .../working-with-dev-containers.md | 97 ++++++++++++++ docs/user-guides/index.md | 3 + 9 files changed, 363 insertions(+) create mode 100644 docs/admin/templates/extending-templates/devcontainers.md create mode 100644 docs/images/user-guides/devcontainers/devcontainer-agent-ports.png create mode 100644 docs/images/user-guides/devcontainers/devcontainer-web-terminal.png create mode 100644 docs/user-guides/devcontainers/index.md create mode 100644 docs/user-guides/devcontainers/troubleshooting-dev-containers.md create mode 100644 docs/user-guides/devcontainers/working-with-dev-containers.md diff --git a/docs/admin/templates/extending-templates/devcontainers.md b/docs/admin/templates/extending-templates/devcontainers.md new file mode 100644 index 0000000000000..4894a012476a1 --- /dev/null +++ b/docs/admin/templates/extending-templates/devcontainers.md @@ -0,0 +1,124 @@ +# Configure a template for dev containers + +To enable dev containers in workspaces, configure your template with the dev containers +modules and configurations outlined in this doc. + +## Install the Dev Containers CLI + +Use the +[devcontainers-cli](https://registry.coder.com/modules/devcontainers-cli) module +to ensure the `@devcontainers/cli` is installed in your workspace: + +```terraform +module "devcontainers-cli" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/devcontainers-cli/coder" + agent_id = coder_agent.dev.id +} +``` + +Alternatively, install the devcontainer CLI manually in your base image. + +## Configure Automatic Dev Container Startup + +The +[`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer) +resource automatically starts a dev container in your workspace, ensuring it's +ready when you access the workspace: + +```terraform +resource "coder_devcontainer" "my-repository" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.dev.id + workspace_folder = "/home/coder/my-repository" +} +``` + +> [!NOTE] +> +> The `workspace_folder` attribute must specify the location of the dev +> container's workspace and should point to a valid project folder containing a +> `devcontainer.json` file. + + + +> [!TIP] +> +> Consider using the [`git-clone`](https://registry.coder.com/modules/git-clone) +> module to ensure your repository is cloned into the workspace folder and ready +> for automatic startup. + +## Enable Dev Containers Integration + +To enable the dev containers integration in your workspace, you must set the +`CODER_AGENT_DEVCONTAINERS_ENABLE` environment variable to `true` in your +workspace container: + +```terraform +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = "codercom/oss-dogfood:latest" + env = [ + "CODER_AGENT_DEVCONTAINERS_ENABLE=true", + # ... Other environment variables. + ] + # ... Other container configuration. +} +``` + +This environment variable is required for the Coder agent to detect and manage +dev containers. Without it, the agent will not attempt to start or connect to +dev containers even if the `coder_devcontainer` resource is defined. + +## Complete Template Example + +Here's a simplified template example that enables the dev containers +integration: + +```terraform +terraform { + required_providers { + coder = { source = "coder/coder" } + docker = { source = "kreuzwerker/docker" } + } +} + +provider "coder" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "dev" { + arch = "amd64" + os = "linux" + startup_script_behavior = "blocking" + startup_script = "sudo service docker start" + shutdown_script = "sudo service docker stop" + # ... +} + +module "devcontainers-cli" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/modules/devcontainers-cli/coder" + agent_id = coder_agent.dev.id +} + +resource "coder_devcontainer" "my-repository" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.dev.id + workspace_folder = "/home/coder/my-repository" +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = "codercom/oss-dogfood:latest" + env = [ + "CODER_AGENT_DEVCONTAINERS_ENABLE=true", + # ... Other environment variables. + ] + # ... Other container configuration. +} +``` + +## Next Steps + +- [Dev Containers Integration](../../../user-guides/devcontainers/index.md) diff --git a/docs/admin/templates/index.md b/docs/admin/templates/index.md index 85f2769e880bd..cc9a08cf26a25 100644 --- a/docs/admin/templates/index.md +++ b/docs/admin/templates/index.md @@ -50,6 +50,9 @@ needs of different teams. create and publish images for use within Coder workspaces & templates. - [Dev Container support](./managing-templates/devcontainers/index.md): Enable dev containers to allow teams to bring their own tools into Coder workspaces. +- [Early Access Dev Containers](../../user-guides/devcontainers/index.md): Try our + new direct devcontainers integration (distinct from Envbuilder-based + approach). - [Template hardening](./extending-templates/resource-persistence.md#-bulletproofing): Configure your template to prevent certain resources from being destroyed (e.g. user disks). diff --git a/docs/images/user-guides/devcontainers/devcontainer-agent-ports.png b/docs/images/user-guides/devcontainers/devcontainer-agent-ports.png new file mode 100644 index 0000000000000000000000000000000000000000..1979fcd67706421929f121818207122a8019a2a1 GIT binary patch literal 136171 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

f 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%0AGn)M)HfmOyf9UXj{4nYz5- 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 G>c0Udv;Bhr literal 0 HcmV?d00001 diff --git a/docs/images/user-guides/devcontainers/devcontainer-web-terminal.png b/docs/images/user-guides/devcontainers/devcontainer-web-terminal.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf570cd73f99d139940343347f6f7906d37ee0f GIT binary patch literal 51032 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 literal 0 HcmV?d00001 diff --git a/docs/manifest.json b/docs/manifest.json index 8692336d089ea..23629ccc3b725 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -213,6 +213,27 @@ "path": "./user-guides/workspace-lifecycle.md", "icon_path": "./images/icons/circle-dot.svg" }, + { + "title": "Dev Containers Integration", + "description": "Run containerized development environments in your Coder workspace using the dev containers specification.", + "path": "./user-guides/devcontainers/index.md", + "icon_path": "./images/icons/container.svg", + "state": ["early access"], + "children": [ + { + "title": "Working with dev containers", + "description": "Access dev containers via SSH, your IDE, or web terminal.", + "path": "./user-guides/devcontainers/working-with-dev-containers.md", + "state": ["early access"] + }, + { + "title": "Troubleshooting dev containers", + "description": "Diagnose and resolve common issues with dev containers in your Coder workspace.", + "path": "./user-guides/devcontainers/troubleshooting-dev-containers.md", + "state": ["early access"] + } + ] + }, { "title": "Dotfiles", "description": "Personalize your environment with dotfiles", diff --git a/docs/user-guides/devcontainers/index.md b/docs/user-guides/devcontainers/index.md new file mode 100644 index 0000000000000..ed817fe853416 --- /dev/null +++ b/docs/user-guides/devcontainers/index.md @@ -0,0 +1,99 @@ +# Dev Containers Integration + +> [!NOTE] +> +> The Coder dev containers integration is an [early access](../../install/releases/feature-stages.md) feature. +> +> While functional for testing and feedback, it may change significantly before general availability. + +The dev containers integration is an early access feature that enables seamless +creation and management of dev containers in Coder workspaces. This feature +leverages the [`@devcontainers/cli`](https://github.com/devcontainers/cli) and +[Docker](https://www.docker.com) to provide a streamlined development +experience. + +This implementation is different from the existing +[Envbuilder-based dev containers](../../admin/templates/managing-templates/devcontainers/index.md) +offering. + +## Prerequisites + +- Coder version 2.22.0 or later +- Coder CLI version 2.22.0 or later +- A template with: + - Dev containers integration enabled + - A Docker-compatible workspace image +- Appropriate permissions to execute Docker commands inside your workspace + +## How It Works + +The dev containers integration utilizes the `devcontainer` command from +[`@devcontainers/cli`](https://github.com/devcontainers/cli) to manage dev +containers within your Coder workspace. +This command provides comprehensive functionality for creating, starting, and managing dev containers. + +Dev environments are configured through a standard `devcontainer.json` file, +which allows for extensive customization of your development setup. + +When a workspace with the dev containers integration starts: + +1. The workspace initializes the Docker environment. +1. The integration detects repositories with a `.devcontainer` directory or a + `devcontainer.json` file. +1. The integration builds and starts the dev container based on the + configuration. +1. Your workspace automatically detects the running dev container. + +## Features + +### Available Now + +- Automatic dev container detection from repositories +- Seamless dev container startup during workspace initialization +- Integrated IDE experience in dev containers with VS Code +- Direct service access in dev containers +- Limited SSH access to dev containers + +### Coming Soon + +- Dev container change detection +- On-demand dev container recreation +- Support for automatic port forwarding inside the container +- Full native SSH support to dev containers + +## Limitations during Early Access + +During the early access phase, the dev containers integration has the following +limitations: + +- Changes to the `devcontainer.json` file require manual container recreation +- Automatic port forwarding only works for ports specified in `appPort` +- SSH access requires using the `--container` flag +- Some devcontainer features may not work as expected + +These limitations will be addressed in future updates as the feature matures. + +## Comparison with Envbuilder-based Dev Containers + +| Feature | Dev Containers (Early Access) | Envbuilder Dev Containers | +|----------------|----------------------------------------|----------------------------------------------| +| Implementation | Direct `@devcontainers/cli` and Docker | Coder's Envbuilder | +| Target users | Individual developers | Platform teams and administrators | +| Configuration | Standard `devcontainer.json` | Terraform templates with Envbuilder | +| Management | User-controlled | Admin-controlled | +| Requirements | Docker access in workspace | Compatible with more restricted environments | + +Choose the appropriate solution based on your team's needs and infrastructure +constraints. For additional details on Envbuilder's dev container support, see +the +[Envbuilder devcontainer spec support documentation](https://github.com/coder/envbuilder/blob/main/docs/devcontainer-spec-support.md). + +## Next Steps + +- Explore the [dev container specification](https://containers.dev/) to learn + more about advanced configuration options +- Read about [dev container features](https://containers.dev/features) to + enhance your development environment +- Check the + [VS Code dev containers documentation](https://code.visualstudio.com/docs/devcontainers/containers) + for IDE-specific features diff --git a/docs/user-guides/devcontainers/troubleshooting-dev-containers.md b/docs/user-guides/devcontainers/troubleshooting-dev-containers.md new file mode 100644 index 0000000000000..ca27516a81cc0 --- /dev/null +++ b/docs/user-guides/devcontainers/troubleshooting-dev-containers.md @@ -0,0 +1,16 @@ +# Troubleshooting dev containers + +## Dev Container Not Starting + +If your dev container fails to start: + +1. Check the agent logs for error messages: + + - `/tmp/coder-agent.log` + - `/tmp/coder-startup-script.log` + - `/tmp/coder-script-[script_id].log` + +1. Verify that Docker is running in your workspace. +1. Ensure the `devcontainer.json` file is valid. +1. Check that the repository has been cloned correctly. +1. Verify the resource limits in your workspace are sufficient. diff --git a/docs/user-guides/devcontainers/working-with-dev-containers.md b/docs/user-guides/devcontainers/working-with-dev-containers.md new file mode 100644 index 0000000000000..a4257f91d420e --- /dev/null +++ b/docs/user-guides/devcontainers/working-with-dev-containers.md @@ -0,0 +1,97 @@ +# Working with Dev Containers + +The dev container integration appears in your Coder dashboard, providing a +visual representation of the running environment: + +![Dev container integration in Coder dashboard](../../images/user-guides/devcontainers/devcontainer-agent-ports.png) + +## SSH Access + +You can SSH into your dev container directly using the Coder CLI: + +```console +coder ssh --container keen_dijkstra my-workspace +``` + +> [!NOTE] +> +> SSH access is not yet compatible with the `coder config-ssh` command for use +> with OpenSSH. You would need to manually modify your SSH config to include the +> `--container` flag in the `ProxyCommand`. + +## Web Terminal Access + +Once your workspace and dev container are running, you can use the web terminal +in the Coder interface to execute commands directly inside the dev container. + +![Coder web terminal with dev container](../../images/user-guides/devcontainers/devcontainer-web-terminal.png) + +## IDE Integration (VS Code) + +You can open your dev container directly in VS Code by: + +1. Selecting "Open in VS Code Desktop" from the Coder web interface +2. Using the Coder CLI with the container flag: + +```console +coder open vscode --container keen_dijkstra my-workspace +``` + +While optimized for VS Code, other IDEs with dev containers support may also +work. + +## Port Forwarding + +During the early access phase, port forwarding is limited to ports defined via +[`appPort`](https://containers.dev/implementors/json_reference/#image-specific) +in your `devcontainer.json` file. + +> [!NOTE] +> +> Support for automatic port forwarding via the `forwardPorts` property in +> `devcontainer.json` is planned for a future release. + +For example, with this `devcontainer.json` configuration: + +```json +{ + "appPort": ["8080:8080", "4000:3000"] +} +``` + +You can forward these ports to your local machine using: + +```console +coder port-forward my-workspace --tcp 8080,4000 +``` + +This forwards port 8080 (local) -> 8080 (agent) -> 8080 (dev container) and port +4000 (local) -> 4000 (agent) -> 3000 (dev container). + +## Dev Container Features + +You can use standard dev container features in your `devcontainer.json` file. +Coder also maintains a +[repository of features](https://github.com/coder/devcontainer-features) to +enhance your development experience. + +Currently available features include [code-server](https://github.com/coder/devcontainer-features/blob/main/src/code-server). + +To use the code-server feature, add the following to your `devcontainer.json`: + +```json +{ + "features": { + "ghcr.io/coder/devcontainer-features/code-server:1": { + "port": 13337, + "host": "0.0.0.0" + } + }, + "appPort": ["13337:13337"] +} +``` + +> [!NOTE] +> +> Remember to include the port in the `appPort` section to ensure proper port +> forwarding. diff --git a/docs/user-guides/index.md b/docs/user-guides/index.md index b756c7b0e1202..92040b4bebd1a 100644 --- a/docs/user-guides/index.md +++ b/docs/user-guides/index.md @@ -7,4 +7,7 @@ These are intended for end-user flows only. If you are an administrator, please refer to our docs on configuring [templates](../admin/index.md) or the [control plane](../admin/index.md). +Check out our [early access features](../install/releases/feature-stages.md) for upcoming +functionality, including [Dev Containers integration](../user-guides/devcontainers/index.md). + From e718c3ab2f22575dedbdf018078e9c64b6c3a71d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 May 2025 00:02:34 +0100 Subject: [PATCH 012/158] fix: improve WebSocket error handling in CreateWorkspacePageExperimental (#17647) Refactor WebSocket error handling to ensure that errors are only set when the current socket ref matches the active one. This prevents unnecessary error messages when the WebSocket connection closes unexpectedly This solves the problem of showing error messages because of React Strict mode rendering the page twice and opening 2 websocket connections. --- .../CreateWorkspacePageExperimental.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index e52a50dda072e..ae31ab2503930 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -95,9 +95,7 @@ const CreateWorkspacePageExperimental: FC = () => { // Initialize the WebSocket connection when there is a valid template version ID useEffect(() => { - if (!realizedVersionId) { - return; - } + if (!realizedVersionId) return; const socket = API.templateVersionDynamicParameters( owner.id, @@ -105,16 +103,19 @@ const CreateWorkspacePageExperimental: FC = () => { { onMessage, onError: (error) => { - setWsError(error); + if (ws.current === socket) { + setWsError(error); + } }, onClose: () => { - // There is no reason for the websocket to close while a user is on the page - setWsError( - new DetailedError( - "Websocket connection for dynamic parameters unexpectedly closed.", - "Refresh the page to reset the form.", - ), - ); + if (ws.current === socket) { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + } }, }, ); From c27866221822e4112bddb43f8ad102a5589c98ab Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 2 May 2025 12:17:01 +0200 Subject: [PATCH 013/158] feat: collect database metrics (#17635) Currently we don't have a way to get insight into Postgres connections being exhausted. By using the prometheus' [`DBStats` collector](https://github.com/prometheus/client_golang/blob/main/prometheus/collectors/dbstats_collector.go), we get some insight out-of-the-box. ``` # HELP go_sql_idle_connections The number of idle connections. # TYPE go_sql_idle_connections gauge go_sql_idle_connections{db_name="coder"} 1 # HELP go_sql_in_use_connections The number of connections currently in use. # TYPE go_sql_in_use_connections gauge go_sql_in_use_connections{db_name="coder"} 2 # HELP go_sql_max_idle_closed_total The total number of connections closed due to SetMaxIdleConns. # TYPE go_sql_max_idle_closed_total counter go_sql_max_idle_closed_total{db_name="coder"} 112 # HELP go_sql_max_idle_time_closed_total The total number of connections closed due to SetConnMaxIdleTime. # TYPE go_sql_max_idle_time_closed_total counter go_sql_max_idle_time_closed_total{db_name="coder"} 0 # HELP go_sql_max_lifetime_closed_total The total number of connections closed due to SetConnMaxLifetime. # TYPE go_sql_max_lifetime_closed_total counter go_sql_max_lifetime_closed_total{db_name="coder"} 0 # HELP go_sql_max_open_connections Maximum number of open connections to the database. # TYPE go_sql_max_open_connections gauge go_sql_max_open_connections{db_name="coder"} 10 # HELP go_sql_open_connections The number of established connections both in use and idle. # TYPE go_sql_open_connections gauge go_sql_open_connections{db_name="coder"} 3 # HELP go_sql_wait_count_total The total number of connections waited for. # TYPE go_sql_wait_count_total counter go_sql_wait_count_total{db_name="coder"} 28 # HELP go_sql_wait_duration_seconds_total The total time blocked waiting for a new connection. # TYPE go_sql_wait_duration_seconds_total counter go_sql_wait_duration_seconds_total{db_name="coder"} 0.086936235 ``` `go_sql_wait_count_total` is the metric I'm most interested in gaining, but the others are also very useful. Changing the prefix is easy (`prometheus.WrapRegistererWithPrefix`), but getting rid of the `go_` segment is not quite so easy. I've kept the changeset small for now. **NOTE:** I imported a library to determine the database name from the given conn string. It's [not as simple](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) as one might hope. The database name is used for the `db_name` label. --------- Signed-off-by: Danny Kopping --- cli/server.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli/server.go b/cli/server.go index 39cfa52571595..580dae369446c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -739,6 +739,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. _ = sqlDB.Close() }() + if options.DeploymentValues.Prometheus.Enable { + // At this stage we don't think the database name serves much purpose in these metrics. + // It requires parsing the DSN to determine it, which requires pulling in another dependency + // (i.e. https://github.com/jackc/pgx), but it's rather heavy. + // The conn string (https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) can + // take different forms, which make parsing non-trivial. + options.PrometheusRegistry.MustRegister(collectors.NewDBStatsCollector(sqlDB, "")) + } + options.Database = database.New(sqlDB) ps, err := pubsub.New(ctx, logger.Named("pubsub"), sqlDB, dbURL) if err != nil { From 50695b7d7678867750aa53ad14593169f9a53e75 Mon Sep 17 00:00:00 2001 From: Edward Angert Date: Fri, 2 May 2025 09:44:30 -0400 Subject: [PATCH 014/158] docs: fix link in tutorials faq to new docker-code-server link (#17655) Co-authored-by: EdwardAngert <17991901+EdwardAngert@users.noreply.github.com> --- docs/tutorials/faqs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/faqs.md b/docs/tutorials/faqs.md index 1c2f5b1fb854e..bd386f81288a8 100644 --- a/docs/tutorials/faqs.md +++ b/docs/tutorials/faqs.md @@ -426,7 +426,7 @@ colima start --arch x86_64 --cpu 4 --memory 8 --disk 10 ``` Colima will show the path to the docker socket so we have a -[community template](https://github.com/sharkymark/v2-templates/tree/main/src/docker-code-server) +[community template](https://github.com/sharkymark/v2-templates/tree/main/src/templates/docker/docker-code-server) that prompts the Coder admin to enter the Docker socket as a Terraform variable. ## How to make a `coder_app` optional? From 912b6aba82d9a83662352f887c79935bfeb7a0f9 Mon Sep 17 00:00:00 2001 From: Edward Angert Date: Fri, 2 May 2025 11:13:42 -0400 Subject: [PATCH 015/158] docs: link to eks steps from aws section (#17646) closes #17634 --------- Co-authored-by: Claude Co-authored-by: EdwardAngert <17991901+EdwardAngert@users.noreply.github.com> --- docs/install/cloud/index.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/install/cloud/index.md b/docs/install/cloud/index.md index 4574b00de08c9..9155b4b0ead40 100644 --- a/docs/install/cloud/index.md +++ b/docs/install/cloud/index.md @@ -10,10 +10,13 @@ cloud of choice. We publish an EC2 image with Coder pre-installed. Follow the tutorial here: - [Install Coder on AWS EC2](./ec2.md) +- [Install Coder on AWS EKS](../kubernetes.md#aws) Alternatively, install the [CLI binary](../cli.md) on any Linux machine or follow our [Kubernetes](../kubernetes.md) documentation to install Coder on an -existing EKS cluster. +existing Kubernetes cluster. + +For EKS-specific installation guidance, see the [AWS section in Kubernetes installation docs](../kubernetes.md#aws). ## GCP From e37ddd44d25be76dec42965a9e969c6e62f64224 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 May 2025 16:14:32 +0100 Subject: [PATCH 016/158] chore: improve the design of the create workspace page for dynamic parameters (#17654) contributes to coder/preview#59 1. Improves the design and layout of the presets dropdown and switch 2. Improves the design for the immutable badge Screenshot 2025-05-01 at 23 28 11 Screenshot 2025-05-01 at 23 28 34 --- site/src/components/Badge/Badge.tsx | 9 ++- .../DynamicParameter/DynamicParameter.tsx | 2 +- .../CreateWorkspacePageViewExperimental.tsx | 58 ++++++++++++------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index 6311dff38b18d..8995222027ed0 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -26,10 +26,15 @@ const badgeVariants = cva( sm: "text-2xs font-regular h-5.5 [&_svg]:size-icon-xs", md: "text-xs font-medium [&_svg]:size-icon-sm", }, + border: { + none: "border-transparent", + solid: "border border-solid", + }, }, defaultVariants: { variant: "default", size: "md", + border: "solid", }, }, ); @@ -41,14 +46,14 @@ export interface BadgeProps } export const Badge = forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, border, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "div"; return ( ); }, diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index d93933228be92..d023bbcf4446b 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -106,7 +106,7 @@ const ParameterLabel: FC = ({ parameter, isPreset }) => { - + Immutable diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index c725a8cbb73f6..1a07596854f8d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -5,12 +5,17 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; -import { SelectFilter } from "components/Filter/SelectFilter"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { Pill } from "components/Pill/Pill"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; import { Spinner } from "components/Spinner/Spinner"; -import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; @@ -153,11 +158,11 @@ export const CreateWorkspacePageViewExperimental: FC< }, [form.submitCount, form.errors]); const [presetOptions, setPresetOptions] = useState([ - { label: "None", value: "" }, + { label: "None", value: "None" }, ]); useEffect(() => { setPresetOptions([ - { label: "None", value: "" }, + { label: "None", value: "None" }, ...presets.map((preset) => ({ label: preset.Name, value: preset.ID, @@ -421,7 +426,7 @@ export const CreateWorkspacePageViewExperimental: FC< )} {parameters.length > 0 && ( -

+

Parameters

@@ -429,30 +434,39 @@ export const CreateWorkspacePageViewExperimental: FC< parameters cannot be modified once the workspace is created.

- + {diagnostics.length > 0 && ( + + )} {presets.length > 0 && ( - -
-
- - -
-
- { +
+
+ + +
+
+
+
- +
)}
From 64b9bc1ca49eb5d8a50dc6892b4827122af772c9 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Fri, 2 May 2025 21:07:10 +0500 Subject: [PATCH 017/158] fix: update licensing info URL on sign up page (#17657) --- site/src/pages/SetupPage/SetupPageView.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index b47a6e9b78f8c..42c8faedea348 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -18,7 +18,6 @@ import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { Stack } from "components/Stack/Stack"; import { type FormikContextType, useFormik } from "formik"; import type { ChangeEvent, FC } from "react"; -import { docs } from "utils/docs"; import { getFormHelpers, nameValidator, @@ -247,7 +246,7 @@ export const SetupPageView: FC = ({ quotas, and more. From 544259b8093f0c3351f3ae86c6a0440b6479f395 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 2 May 2025 17:29:57 +0100 Subject: [PATCH 018/158] feat: add database tables and API routes for agentic chat feature (#17570) Backend portion of experimental `AgenticChat` feature: - Adds database tables for chats and chat messages - Adds functionality to stream messages from LLM providers using `kylecarbs/aisdk-go` - Adds API routes with relevant functionality (list, create, update chats, insert chat message) - Adds experiment `codersdk.AgenticChat` --------- Co-authored-by: Kyle Carberry --- cli/server.go | 89 +++ cli/testdata/server-config.yaml.golden | 3 + coderd/ai/ai.go | 167 +++++ coderd/apidoc/docs.go | 592 +++++++++++++++++- coderd/apidoc/swagger.json | 552 +++++++++++++++- coderd/chat.go | 366 +++++++++++ coderd/chat_test.go | 125 ++++ coderd/coderd.go | 20 +- coderd/database/db2sdk/db2sdk.go | 13 + coderd/database/dbauthz/dbauthz.go | 42 ++ coderd/database/dbauthz/dbauthz_test.go | 74 +++ coderd/database/dbgen/dbgen.go | 24 + coderd/database/dbmem/dbmem.go | 137 ++++ coderd/database/dbmetrics/querymetrics.go | 49 ++ coderd/database/dbmock/dbmock.go | 103 +++ coderd/database/dump.sql | 40 ++ coderd/database/foreign_key_constraint.go | 2 + .../database/migrations/000319_chat.down.sql | 3 + coderd/database/migrations/000319_chat.up.sql | 17 + .../testdata/fixtures/000319_chat.up.sql | 6 + coderd/database/modelmethods.go | 5 + coderd/database/models.go | 17 + coderd/database/querier.go | 7 + coderd/database/queries.sql.go | 201 ++++++ coderd/database/queries/chat.sql | 36 ++ coderd/database/unique_constraint.go | 2 + coderd/deployment.go | 25 + coderd/httpmw/chat.go | 59 ++ coderd/httpmw/chat_test.go | 150 +++++ coderd/rbac/object_gen.go | 11 + coderd/rbac/policy/policy.go | 8 + coderd/rbac/roles.go | 2 + coderd/rbac/roles_test.go | 31 + codersdk/chat.go | 153 +++++ codersdk/deployment.go | 52 ++ codersdk/rbacresources_gen.go | 2 + codersdk/toolsdk/toolsdk.go | 9 +- docs/reference/api/chat.md | 372 +++++++++++ docs/reference/api/general.md | 50 ++ docs/reference/api/members.md | 5 + docs/reference/api/schemas.md | 583 ++++++++++++++++- go.mod | 8 +- go.sum | 4 +- site/src/api/rbacresourcesGenerated.ts | 6 + site/src/api/typesGenerated.ts | 58 ++ 45 files changed, 4264 insertions(+), 16 deletions(-) create mode 100644 coderd/ai/ai.go create mode 100644 coderd/chat.go create mode 100644 coderd/chat_test.go create mode 100644 coderd/database/migrations/000319_chat.down.sql create mode 100644 coderd/database/migrations/000319_chat.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000319_chat.up.sql create mode 100644 coderd/database/queries/chat.sql create mode 100644 coderd/httpmw/chat.go create mode 100644 coderd/httpmw/chat_test.go create mode 100644 codersdk/chat.go create mode 100644 docs/reference/api/chat.md diff --git a/cli/server.go b/cli/server.go index 580dae369446c..48ec8492f0a55 100644 --- a/cli/server.go +++ b/cli/server.go @@ -61,6 +61,7 @@ import ( "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -610,6 +611,22 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } + aiProviders, err := ReadAIProvidersFromEnv(os.Environ()) + if err != nil { + return xerrors.Errorf("read ai providers from env: %w", err) + } + vals.AI.Value.Providers = append(vals.AI.Value.Providers, aiProviders...) + for _, provider := range aiProviders { + logger.Debug( + ctx, "loaded ai provider", + slog.F("type", provider.Type), + ) + } + languageModels, err := ai.ModelsFromConfig(ctx, vals.AI.Value.Providers) + if err != nil { + return xerrors.Errorf("create language models: %w", err) + } + realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) @@ -640,6 +657,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. CacheDir: cacheDir, GoogleTokenValidator: googleTokenValidator, ExternalAuthConfigs: externalAuthConfigs, + LanguageModels: languageModels, RealIPConfig: realIPConfig, SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, @@ -2621,6 +2639,77 @@ func redirectHTTPToHTTPSDeprecation(ctx context.Context, logger slog.Logger, inv } } +func ReadAIProvidersFromEnv(environ []string) ([]codersdk.AIProviderConfig, error) { + // The index numbers must be in-order. + sort.Strings(environ) + + var providers []codersdk.AIProviderConfig + for _, v := range serpent.ParseEnviron(environ, "CODER_AI_PROVIDER_") { + tokens := strings.SplitN(v.Name, "_", 2) + if len(tokens) != 2 { + return nil, xerrors.Errorf("invalid env var: %s", v.Name) + } + + providerNum, err := strconv.Atoi(tokens[0]) + if err != nil { + return nil, xerrors.Errorf("parse number: %s", v.Name) + } + + var provider codersdk.AIProviderConfig + switch { + case len(providers) < providerNum: + return nil, xerrors.Errorf( + "provider num %v skipped: %s", + len(providers), + v.Name, + ) + case len(providers) == providerNum: + // At the next next provider. + providers = append(providers, provider) + case len(providers) == providerNum+1: + // At the current provider. + provider = providers[providerNum] + } + + key := tokens[1] + switch key { + case "TYPE": + provider.Type = v.Value + case "API_KEY": + provider.APIKey = v.Value + case "BASE_URL": + provider.BaseURL = v.Value + case "MODELS": + provider.Models = strings.Split(v.Value, ",") + } + providers[providerNum] = provider + } + for _, envVar := range environ { + tokens := strings.SplitN(envVar, "=", 2) + if len(tokens) != 2 { + continue + } + switch tokens[0] { + case "OPENAI_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "openai", + APIKey: tokens[1], + }) + case "ANTHROPIC_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "anthropic", + APIKey: tokens[1], + }) + case "GOOGLE_API_KEY": + providers = append(providers, codersdk.AIProviderConfig{ + Type: "google", + APIKey: tokens[1], + }) + } + } + return providers, nil +} + // ReadExternalAuthProvidersFromEnv is provided for compatibility purposes with // the viper CLI. func ReadExternalAuthProvidersFromEnv(environ []string) ([]codersdk.ExternalAuthConfig, error) { diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 8f34ee8cbe7be..fc76a6c2ec8a0 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -519,6 +519,9 @@ client: # Support links to display in the top right drop down menu. # (default: , type: struct[[]codersdk.LinkConfig]) supportLinks: [] +# Configure AI providers. +# (default: , type: struct[codersdk.AIConfig]) +ai: {} # External Authentication providers. # (default: , type: struct[[]codersdk.ExternalAuthConfig]) externalAuthProviders: [] diff --git a/coderd/ai/ai.go b/coderd/ai/ai.go new file mode 100644 index 0000000000000..97c825ae44c06 --- /dev/null +++ b/coderd/ai/ai.go @@ -0,0 +1,167 @@ +package ai + +import ( + "context" + + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "github.com/kylecarbs/aisdk-go" + "github.com/openai/openai-go" + openaioption "github.com/openai/openai-go/option" + "golang.org/x/xerrors" + "google.golang.org/genai" + + "github.com/coder/coder/v2/codersdk" +) + +type LanguageModel struct { + codersdk.LanguageModel + StreamFunc StreamFunc +} + +type StreamOptions struct { + SystemPrompt string + Model string + Messages []aisdk.Message + Thinking bool + Tools []aisdk.Tool +} + +type StreamFunc func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) + +// LanguageModels is a map of language model ID to language model. +type LanguageModels map[string]LanguageModel + +func ModelsFromConfig(ctx context.Context, configs []codersdk.AIProviderConfig) (LanguageModels, error) { + models := make(LanguageModels) + + for _, config := range configs { + var streamFunc StreamFunc + + switch config.Type { + case "openai": + opts := []openaioption.RequestOption{ + openaioption.WithAPIKey(config.APIKey), + } + if config.BaseURL != "" { + opts = append(opts, openaioption.WithBaseURL(config.BaseURL)) + } + client := openai.NewClient(opts...) + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + openaiMessages, err := aisdk.MessagesToOpenAI(options.Messages) + if err != nil { + return nil, err + } + tools := aisdk.ToolsToOpenAI(options.Tools) + if options.SystemPrompt != "" { + openaiMessages = append([]openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(options.SystemPrompt), + }, openaiMessages...) + } + + return aisdk.OpenAIToDataStream(client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openaiMessages, + Model: options.Model, + Tools: tools, + MaxTokens: openai.Int(8192), + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Data)) + for i, model := range models.Data { + config.Models[i] = model.ID + } + } + case "anthropic": + client := anthropic.NewClient(anthropicoption.WithAPIKey(config.APIKey)) + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + anthropicMessages, systemMessage, err := aisdk.MessagesToAnthropic(options.Messages) + if err != nil { + return nil, err + } + if options.SystemPrompt != "" { + systemMessage = []anthropic.TextBlockParam{ + *anthropic.NewTextBlock(options.SystemPrompt).OfRequestTextBlock, + } + } + return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Messages: anthropicMessages, + Model: options.Model, + System: systemMessage, + Tools: aisdk.ToolsToAnthropic(options.Tools), + MaxTokens: 8192, + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx, anthropic.ModelListParams{}) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Data)) + for i, model := range models.Data { + config.Models[i] = model.ID + } + } + case "google": + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: config.APIKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return nil, err + } + streamFunc = func(ctx context.Context, options StreamOptions) (aisdk.DataStream, error) { + googleMessages, err := aisdk.MessagesToGoogle(options.Messages) + if err != nil { + return nil, err + } + tools, err := aisdk.ToolsToGoogle(options.Tools) + if err != nil { + return nil, err + } + var systemInstruction *genai.Content + if options.SystemPrompt != "" { + systemInstruction = &genai.Content{ + Parts: []*genai.Part{ + genai.NewPartFromText(options.SystemPrompt), + }, + Role: "model", + } + } + return aisdk.GoogleToDataStream(client.Models.GenerateContentStream(ctx, options.Model, googleMessages, &genai.GenerateContentConfig{ + SystemInstruction: systemInstruction, + Tools: tools, + })), nil + } + if config.Models == nil { + models, err := client.Models.List(ctx, &genai.ListModelsConfig{}) + if err != nil { + return nil, err + } + config.Models = make([]string, len(models.Items)) + for i, model := range models.Items { + config.Models[i] = model.Name + } + } + default: + return nil, xerrors.Errorf("unsupported model type: %s", config.Type) + } + + for _, model := range config.Models { + models[model] = LanguageModel{ + LanguageModel: codersdk.LanguageModel{ + ID: model, + DisplayName: model, + Provider: config.Type, + }, + StreamFunc: streamFunc, + } + } + } + + return models, nil +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index daef10a90d422..fb5ae20e448c8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -343,6 +343,173 @@ const docTemplate = `{ } } }, + "/chats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "List chats", + "operationId": "list-chats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Create a chat", + "operationId": "create-a-chat", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Get a chat", + "operationId": "get-a-chat", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}/messages": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Get chat messages", + "operationId": "get-chat-messages", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Message" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "Create a chat message", + "operationId": "create-a-chat-message", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": {} + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -659,6 +826,31 @@ const docTemplate = `{ } } }, + "/deployment/llms": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get language models", + "operationId": "get-language-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.LanguageModelConfig" + } + } + } + } + }, "/deployment/ssh": { "get": { "security": [ @@ -10297,6 +10489,190 @@ const docTemplate = `{ } } }, + "aisdk.Attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "aisdk.Message": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, + "aisdk.Part": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "details": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.ReasoningDetail" + } + }, + "mimeType": { + "description": "Type: \"file\"", + "type": "string" + }, + "reasoning": { + "description": "Type: \"reasoning\"", + "type": "string" + }, + "source": { + "description": "Type: \"source\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.SourceInfo" + } + ] + }, + "text": { + "description": "Type: \"text\"", + "type": "string" + }, + "toolInvocation": { + "description": "Type: \"tool-invocation\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.ToolInvocation" + } + ] + }, + "type": { + "$ref": "#/definitions/aisdk.PartType" + } + } + }, + "aisdk.PartType": { + "type": "string", + "enum": [ + "text", + "reasoning", + "tool-invocation", + "source", + "file", + "step-start" + ], + "x-enum-varnames": [ + "PartTypeText", + "PartTypeReasoning", + "PartTypeToolInvocation", + "PartTypeSource", + "PartTypeFile", + "PartTypeStepStart" + ] + }, + "aisdk.ReasoningDetail": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "aisdk.SourceInfo": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "data": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "uri": { + "type": "string" + } + } + }, + "aisdk.ToolInvocation": { + "type": "object", + "properties": { + "args": {}, + "result": {}, + "state": { + "$ref": "#/definitions/aisdk.ToolInvocationState" + }, + "step": { + "type": "integer" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + } + } + }, + "aisdk.ToolInvocationState": { + "type": "string", + "enum": [ + "call", + "partial-call", + "result" + ], + "x-enum-varnames": [ + "ToolInvocationStateCall", + "ToolInvocationStatePartialCall", + "ToolInvocationStateResult" + ] + }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -10388,6 +10764,37 @@ const docTemplate = `{ } } }, + "codersdk.AIConfig": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProviderConfig" + } + } + } + }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL to use for the API provider.", + "type": "string" + }, + "models": { + "description": "Models is the list of models to use for the API provider.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "Type is the type of the API provider.", + "type": "string" + } + } + }, "codersdk.APIKey": { "type": "object", "required": [ @@ -10973,6 +11380,62 @@ const docTemplate = `{ } } }, + "codersdk.Chat": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatMessage": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -11006,6 +11469,20 @@ const docTemplate = `{ } } }, + "codersdk.CreateChatMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/codersdk.ChatMessage" + }, + "model": { + "type": "string" + }, + "thinking": { + "type": "boolean" + } + } + }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": [ @@ -11293,7 +11770,73 @@ const docTemplate = `{ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": [ + "create", + "write", + "delete", + "start", + "stop" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": [ + "autostart", + "autostop", + "initiator" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", @@ -11742,6 +12285,9 @@ const docTemplate = `{ "agent_stat_refresh_interval": { "type": "integer" }, + "ai": { + "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" + }, "allow_workspace_renames": { "type": "boolean" }, @@ -12009,9 +12555,11 @@ const docTemplate = `{ "workspace-usage", "web-push", "dynamic-parameters", - "workspace-prebuilds" + "workspace-prebuilds", + "agentic-chat" ], "x-enum-comments": { + "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", @@ -12027,7 +12575,8 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentDynamicParameters", - "ExperimentWorkspacePrebuilds" + "ExperimentWorkspacePrebuilds", + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { @@ -12538,6 +13087,33 @@ const docTemplate = `{ "RequiredTemplateVariables" ] }, + "codersdk.LanguageModel": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "description": "ID is used by the provider to identify the LLM.", + "type": "string" + }, + "provider": { + "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", + "type": "string" + } + } + }, + "codersdk.LanguageModelConfig": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LanguageModel" + } + } + } + }, "codersdk.License": { "type": "object", "properties": { @@ -14272,6 +14848,7 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", @@ -14310,6 +14887,7 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -18250,6 +18828,14 @@ const docTemplate = `{ } } }, + "serpent.Struct-codersdk_AIConfig": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/codersdk.AIConfig" + } + } + }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3a7bc4c2c71ed..8420c9ea0f812 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -291,6 +291,151 @@ } } }, + "/chats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "List chats", + "operationId": "list-chats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Create a chat", + "operationId": "create-a-chat", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Get a chat", + "operationId": "get-a-chat", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + } + } + }, + "/chats/{chat}/messages": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Get chat messages", + "operationId": "get-chat-messages", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Message" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Chat"], + "summary": "Create a chat message", + "operationId": "create-a-chat-message", + "parameters": [ + { + "type": "string", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateChatMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": {} + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -563,6 +708,27 @@ } } }, + "/deployment/llms": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get language models", + "operationId": "get-language-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.LanguageModelConfig" + } + } + } + } + }, "/deployment/ssh": { "get": { "security": [ @@ -9134,6 +9300,186 @@ } } }, + "aisdk.Attachment": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "aisdk.Message": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, + "aisdk.Part": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "details": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.ReasoningDetail" + } + }, + "mimeType": { + "description": "Type: \"file\"", + "type": "string" + }, + "reasoning": { + "description": "Type: \"reasoning\"", + "type": "string" + }, + "source": { + "description": "Type: \"source\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.SourceInfo" + } + ] + }, + "text": { + "description": "Type: \"text\"", + "type": "string" + }, + "toolInvocation": { + "description": "Type: \"tool-invocation\"", + "allOf": [ + { + "$ref": "#/definitions/aisdk.ToolInvocation" + } + ] + }, + "type": { + "$ref": "#/definitions/aisdk.PartType" + } + } + }, + "aisdk.PartType": { + "type": "string", + "enum": [ + "text", + "reasoning", + "tool-invocation", + "source", + "file", + "step-start" + ], + "x-enum-varnames": [ + "PartTypeText", + "PartTypeReasoning", + "PartTypeToolInvocation", + "PartTypeSource", + "PartTypeFile", + "PartTypeStepStart" + ] + }, + "aisdk.ReasoningDetail": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "aisdk.SourceInfo": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "data": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "uri": { + "type": "string" + } + } + }, + "aisdk.ToolInvocation": { + "type": "object", + "properties": { + "args": {}, + "result": {}, + "state": { + "$ref": "#/definitions/aisdk.ToolInvocationState" + }, + "step": { + "type": "integer" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + } + } + }, + "aisdk.ToolInvocationState": { + "type": "string", + "enum": ["call", "partial-call", "result"], + "x-enum-varnames": [ + "ToolInvocationStateCall", + "ToolInvocationStatePartialCall", + "ToolInvocationStateResult" + ] + }, "coderd.SCIMUser": { "type": "object", "properties": { @@ -9225,6 +9571,37 @@ } } }, + "codersdk.AIConfig": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIProviderConfig" + } + } + } + }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL to use for the API provider.", + "type": "string" + }, + "models": { + "description": "Models is the list of models to use for the API provider.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "Type is the type of the API provider.", + "type": "string" + } + } + }, "codersdk.APIKey": { "type": "object", "required": [ @@ -9771,6 +10148,62 @@ } } }, + "codersdk.Chat": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "codersdk.ChatMessage": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": {} + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "array", + "items": { + "type": "integer" + } + }, + "experimental_attachments": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Attachment" + } + }, + "id": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/definitions/aisdk.Part" + } + }, + "role": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -9801,6 +10234,20 @@ } } }, + "codersdk.CreateChatMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/codersdk.ChatMessage" + }, + "model": { + "type": "string" + }, + "thinking": { + "type": "boolean" + } + } + }, "codersdk.CreateFirstUserRequest": { "type": "object", "required": ["email", "password", "username"], @@ -10069,7 +10516,63 @@ } }, "codersdk.CreateTestAuditLogRequest": { - "type": "object" + "type": "object", + "properties": { + "action": { + "enum": ["create", "write", "delete", "start", "stop"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AuditAction" + } + ] + }, + "additional_fields": { + "type": "array", + "items": { + "type": "integer" + } + }, + "build_reason": { + "enum": ["autostart", "autostop", "initiator"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.BuildReason" + } + ] + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "request_id": { + "type": "string", + "format": "uuid" + }, + "resource_id": { + "type": "string", + "format": "uuid" + }, + "resource_type": { + "enum": [ + "template", + "template_version", + "user", + "workspace", + "workspace_build", + "git_ssh_key", + "auditable_group" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ResourceType" + } + ] + }, + "time": { + "type": "string", + "format": "date-time" + } + } }, "codersdk.CreateTokenRequest": { "type": "object", @@ -10500,6 +11003,9 @@ "agent_stat_refresh_interval": { "type": "integer" }, + "ai": { + "$ref": "#/definitions/serpent.Struct-codersdk_AIConfig" + }, "allow_workspace_renames": { "type": "boolean" }, @@ -10763,9 +11269,11 @@ "workspace-usage", "web-push", "dynamic-parameters", - "workspace-prebuilds" + "workspace-prebuilds", + "agentic-chat" ], "x-enum-comments": { + "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", @@ -10781,7 +11289,8 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentDynamicParameters", - "ExperimentWorkspacePrebuilds" + "ExperimentWorkspacePrebuilds", + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { @@ -11276,6 +11785,33 @@ "enum": ["REQUIRED_TEMPLATE_VARIABLES"], "x-enum-varnames": ["RequiredTemplateVariables"] }, + "codersdk.LanguageModel": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "description": "ID is used by the provider to identify the LLM.", + "type": "string" + }, + "provider": { + "description": "Provider is the provider of the LLM. e.g. openai, anthropic, etc.", + "type": "string" + } + } + }, + "codersdk.LanguageModelConfig": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.LanguageModel" + } + } + } + }, "codersdk.License": { "type": "object", "properties": { @@ -12930,6 +13466,7 @@ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", @@ -12968,6 +13505,7 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceChat", "ResourceCryptoKey", "ResourceDebugInfo", "ResourceDeploymentConfig", @@ -16705,6 +17243,14 @@ } } }, + "serpent.Struct-codersdk_AIConfig": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/codersdk.AIConfig" + } + } + }, "serpent.URL": { "type": "object", "properties": { diff --git a/coderd/chat.go b/coderd/chat.go new file mode 100644 index 0000000000000..b10211075cfe6 --- /dev/null +++ b/coderd/chat.go @@ -0,0 +1,366 @@ +package coderd + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "github.com/kylecarbs/aisdk-go" + + "github.com/coder/coder/v2/coderd/ai" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/util/strings" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" +) + +// postChats creates a new chat. +// +// @Summary Create a chat +// @ID create-a-chat +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 201 {object} codersdk.Chat +// @Router /chats [post] +func (api *API) postChats(w http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + chat, err := api.Database.InsertChat(ctx, database.InsertChatParams{ + OwnerID: apiKey.UserID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Title: "New Chat", + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create chat", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, w, http.StatusCreated, db2sdk.Chat(chat)) +} + +// listChats lists all chats for a user. +// +// @Summary List chats +// @ID list-chats +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Success 200 {array} codersdk.Chat +// @Router /chats [get] +func (api *API) listChats(w http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + chats, err := api.Database.GetChatsByOwnerID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chats(chats)) +} + +// chat returns a chat by ID. +// +// @Summary Get a chat +// @ID get-a-chat +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Param chat path string true "Chat ID" +// @Success 200 {object} codersdk.Chat +// @Router /chats/{chat} [get] +func (*API) chat(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + httpapi.Write(ctx, w, http.StatusOK, db2sdk.Chat(chat)) +} + +// chatMessages returns the messages of a chat. +// +// @Summary Get chat messages +// @ID get-chat-messages +// @Security CoderSessionToken +// @Produce json +// @Tags Chat +// @Param chat path string true "Chat ID" +// @Success 200 {array} aisdk.Message +// @Router /chats/{chat}/messages [get] +func (api *API) chatMessages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + rawMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat messages", + Detail: err.Error(), + }) + return + } + messages := make([]aisdk.Message, len(rawMessages)) + for i, message := range rawMessages { + var msg aisdk.Message + err = json.Unmarshal(message.Content, &msg) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal chat message", + Detail: err.Error(), + }) + return + } + messages[i] = msg + } + + httpapi.Write(ctx, w, http.StatusOK, messages) +} + +// postChatMessages creates a new chat message and streams the response. +// +// @Summary Create a chat message +// @ID create-a-chat-message +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Chat +// @Param chat path string true "Chat ID" +// @Param request body codersdk.CreateChatMessageRequest true "Request body" +// @Success 200 {array} aisdk.DataStreamPart +// @Router /chats/{chat}/messages [post] +func (api *API) postChatMessages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + var req codersdk.CreateChatMessageRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to decode chat message", + Detail: err.Error(), + }) + return + } + + dbMessages, err := api.Database.GetChatMessagesByChatID(ctx, chat.ID) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat messages", + Detail: err.Error(), + }) + return + } + + messages := make([]codersdk.ChatMessage, 0) + for _, dbMsg := range dbMessages { + var msg codersdk.ChatMessage + err = json.Unmarshal(dbMsg.Content, &msg) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal chat message", + Detail: err.Error(), + }) + return + } + messages = append(messages, msg) + } + messages = append(messages, req.Message) + + client := codersdk.New(api.AccessURL) + client.SetSessionToken(httpmw.APITokenFromRequest(r)) + + tools := make([]aisdk.Tool, 0) + handlers := map[string]toolsdk.GenericHandlerFunc{} + for _, tool := range toolsdk.All { + if tool.Name == "coder_report_task" { + continue // This tool requires an agent to run. + } + tools = append(tools, tool.Tool) + handlers[tool.Tool.Name] = tool.Handler + } + + provider, ok := api.LanguageModels[req.Model] + if !ok { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Model not found", + }) + return + } + + // If it's the user's first message, generate a title for the chat. + if len(messages) == 1 { + var acc aisdk.DataStreamAccumulator + stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ + Model: req.Model, + SystemPrompt: `- You will generate a short title based on the user's message. +- It should be maximum of 40 characters. +- Do not use quotes, colons, special characters, or emojis.`, + Messages: messages, + Tools: []aisdk.Tool{}, // This initial stream doesn't use tools. + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create stream", + Detail: err.Error(), + }) + return + } + stream = stream.WithAccumulator(&acc) + err = stream.Pipe(io.Discard) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to pipe stream", + Detail: err.Error(), + }) + return + } + var newTitle string + accMessages := acc.Messages() + // If for some reason the stream didn't return any messages, use the + // original message as the title. + if len(accMessages) == 0 { + newTitle = strings.Truncate(messages[0].Content, 40) + } else { + newTitle = strings.Truncate(accMessages[0].Content, 40) + } + err = api.Database.UpdateChatByID(ctx, database.UpdateChatByIDParams{ + ID: chat.ID, + Title: newTitle, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat title", + Detail: err.Error(), + }) + return + } + } + + // Write headers for the data stream! + aisdk.WriteDataStreamHeaders(w) + + // Insert the user-requested message into the database! + raw, err := json.Marshal([]aisdk.Message{req.Message}) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal chat message", + Detail: err.Error(), + }) + return + } + _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: req.Model, + Provider: provider.Provider, + Content: raw, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert chat messages", + Detail: err.Error(), + }) + return + } + + deps, err := toolsdk.NewDeps(client) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create tool dependencies", + Detail: err.Error(), + }) + return + } + + for { + var acc aisdk.DataStreamAccumulator + stream, err := provider.StreamFunc(ctx, ai.StreamOptions{ + Model: req.Model, + Messages: messages, + Tools: tools, + SystemPrompt: `You are a chat assistant for Coder - an open-source platform for creating and managing cloud development environments on any infrastructure. You are expected to be precise, concise, and helpful. + +You are running as an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Do NOT guess or make up an answer.`, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create stream", + Detail: err.Error(), + }) + return + } + stream = stream.WithToolCalling(func(toolCall aisdk.ToolCall) aisdk.ToolCallResult { + tool, ok := handlers[toolCall.Name] + if !ok { + return nil + } + toolArgs, err := json.Marshal(toolCall.Args) + if err != nil { + return nil + } + result, err := tool(ctx, deps, toolArgs) + if err != nil { + return map[string]any{ + "error": err.Error(), + } + } + return result + }).WithAccumulator(&acc) + + err = stream.Pipe(w) + if err != nil { + // The client disppeared! + api.Logger.Error(ctx, "stream pipe error", "error", err) + return + } + + // acc.Messages() may sometimes return nil. Serializing this + // will cause a pq error: "cannot extract elements from a scalar". + newMessages := append([]aisdk.Message{}, acc.Messages()...) + if len(newMessages) > 0 { + raw, err := json.Marshal(newMessages) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal chat message", + Detail: err.Error(), + }) + return + } + messages = append(messages, newMessages...) + + // Insert these messages into the database! + _, err = api.Database.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: req.Model, + Provider: provider.Provider, + Content: raw, + }) + if err != nil { + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert chat messages", + Detail: err.Error(), + }) + return + } + } + + if acc.FinishReason() == aisdk.FinishReasonToolCalls { + continue + } + + break + } +} diff --git a/coderd/chat_test.go b/coderd/chat_test.go new file mode 100644 index 0000000000000..71e7b99ab3720 --- /dev/null +++ b/coderd/chat_test.go @@ -0,0 +1,125 @@ +package coderd_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestChat(t *testing.T) { + t.Parallel() + + t.Run("ExperimentAgenticChatDisabled", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Hit the endpoint to get the chat. It should return a 404. + ctx := testutil.Context(t, testutil.WaitShort) + _, err := memberClient.ListChats(ctx) + require.Error(t, err, "list chats should fail") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "request should fail with an SDK error") + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("ChatCRUD", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentAgenticChat)} + dv.AI.Value = codersdk.AIConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "fake", + APIKey: "", + BaseURL: "http://localhost", + Models: []string{"fake-model"}, + }, + }, + } + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Seed the database with some data. + dbChat := dbgen.Chat(t, db, database.Chat{ + OwnerID: memberUser.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + UpdatedAt: dbtime.Now().Add(-time.Hour), + Title: "This is a test chat", + }) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: dbChat.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + Content: []byte(`[{"content": "Hello world"}]`), + Model: "fake model", + Provider: "fake", + }) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Listing chats should return the chat we just inserted. + chats, err := memberClient.ListChats(ctx) + require.NoError(t, err, "list chats should succeed") + require.Len(t, chats, 1, "response should have one chat") + require.Equal(t, dbChat.ID, chats[0].ID, "unexpected chat ID") + require.Equal(t, dbChat.Title, chats[0].Title, "unexpected chat title") + require.Equal(t, dbChat.CreatedAt.UTC(), chats[0].CreatedAt.UTC(), "unexpected chat created at") + require.Equal(t, dbChat.UpdatedAt.UTC(), chats[0].UpdatedAt.UTC(), "unexpected chat updated at") + + // Fetching a single chat by ID should return the same chat. + chat, err := memberClient.Chat(ctx, dbChat.ID) + require.NoError(t, err, "get chat should succeed") + require.Equal(t, chats[0], chat, "get chat should return the same chat") + + // Listing chat messages should return the message we just inserted. + messages, err := memberClient.ChatMessages(ctx, dbChat.ID) + require.NoError(t, err, "list chat messages should succeed") + require.Len(t, messages, 1, "response should have one message") + require.Equal(t, "Hello world", messages[0].Content, "response should have the correct message content") + + // Creating a new chat will fail because the model does not exist. + // TODO: Test the message streaming functionality with a mock model. + // Inserting a chat message will fail due to the model not existing. + _, err = memberClient.CreateChatMessage(ctx, dbChat.ID, codersdk.CreateChatMessageRequest{ + Model: "echo", + Message: codersdk.ChatMessage{ + Role: "user", + Content: "Hello world", + }, + Thinking: false, + }) + require.Error(t, err, "create chat message should fail") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "create chat should fail with an SDK error") + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), "create chat should fail with a 400 when model does not exist") + + // Creating a new chat message with malformed content should fail. + res, err := memberClient.Request(ctx, http.MethodPost, "/api/v2/chats/"+dbChat.ID.String()+"/messages", strings.NewReader(`{malformed json}`)) + require.NoError(t, err) + defer res.Body.Close() + apiErr := codersdk.ReadBodyAsError(res) + require.Contains(t, apiErr.Error(), "Failed to decode chat message") + + _, err = memberClient.CreateChat(ctx) + require.NoError(t, err, "create chat should succeed") + chats, err = memberClient.ListChats(ctx) + require.NoError(t, err, "list chats should succeed") + require.Len(t, chats, 2, "response should have two chats") + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 288671c6cb6e9..123e58feb642a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -41,6 +41,7 @@ import ( "github.com/coder/quartz" "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/ai" "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/files" @@ -155,6 +156,7 @@ type Options struct { Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GoogleTokenValidator *idtoken.Validator + LanguageModels ai.LanguageModels GithubOAuth2Config *GithubOAuth2Config OIDCConfig *OIDCConfig PrometheusRegistry *prometheus.Registry @@ -851,7 +853,7 @@ func New(options *Options) *API { next.ServeHTTP(w, r) }) }, - httpmw.CSRF(options.DeploymentValues.HTTPCookies), + // httpmw.CSRF(options.DeploymentValues.HTTPCookies), ) // This incurs a performance hit from the middleware, but is required to make sure @@ -956,6 +958,7 @@ func New(options *Options) *API { r.Get("/config", api.deploymentValues) r.Get("/stats", api.deploymentStats) r.Get("/ssh", api.sshConfig) + r.Get("/llms", api.deploymentLLMs) }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) @@ -998,6 +1001,21 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) + // Chats are an experimental feature + r.Route("/chats", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentAgenticChat), + ) + r.Get("/", api.listChats) + r.Post("/", api.postChats) + r.Route("/{chat}", func(r chi.Router) { + r.Use(httpmw.ExtractChatParam(options.Database)) + r.Get("/", api.chat) + r.Get("/messages", api.chatMessages) + r.Post("/messages", api.postChatMessages) + }) + }) r.Route("/external-auth", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7efcd009c6ef9..18d1d8a6ac788 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -751,3 +751,16 @@ func AgentProtoConnectionActionToAuditAction(action database.AuditAction) (agent return agentproto.Connection_ACTION_UNSPECIFIED, xerrors.Errorf("unknown agent connection action %q", action) } } + +func Chat(chat database.Chat) codersdk.Chat { + return codersdk.Chat{ + ID: chat.ID, + Title: chat.Title, + CreatedAt: chat.CreatedAt, + UpdatedAt: chat.UpdatedAt, + } +} + +func Chats(chats []database.Chat) []codersdk.Chat { + return List(chats, Chat) +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ceb5ba7f2a15a..2ed230dd7a8f3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1269,6 +1269,10 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteChat(ctx context.Context, id uuid.UUID) error { + return deleteQ(q.log, q.auth, q.db.GetChatByID, q.db.DeleteChat)(ctx, id) +} + func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1686,6 +1690,22 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + return fetch(q.log, q.auth, q.db.GetChatByID)(ctx, id) +} + +func (q *querier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + c, err := q.GetChatByID(ctx, chatID) + if err != nil { + return nil, err + } + return q.db.GetChatMessagesByChatID(ctx, c.ID) +} + +func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID) +} + func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -3315,6 +3335,21 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } +func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg) +} + +func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + c, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, c); err != nil { + return nil, err + } + return q.db.InsertChatMessages(ctx, arg) +} + func (q *querier) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -3963,6 +3998,13 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } +func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) { + return q.db.GetChatByID(ctx, arg.ID) + } + return update(q.log, q.auth, fetch, q.db.UpdateChatByID)(ctx, arg) +} + func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e562bbd1f7160..6dc9a32f03943 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5307,3 +5307,77 @@ func (s *MethodTestSuite) TestResourcesProvisionerdserver() { }).Asserts(rbac.ResourceWorkspaceAgentDevcontainers, policy.ActionCreate) })) } + +func (s *MethodTestSuite) TestChat() { + createChat := func(t *testing.T, db database.Store) (database.User, database.Chat, database.ChatMessage) { + t.Helper() + + usr := dbgen.User(t, db, database.User{}) + chat := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: usr.ID, + }) + msg := dbgen.ChatMessage(s.T(), db, database.ChatMessage{ + ChatID: chat.ID, + }) + + return usr, chat, msg + } + + s.Run("DeleteChat", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionDelete) + })) + + s.Run("GetChatByID", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionRead).Returns(c) + })) + + s.Run("GetChatMessagesByChatID", s.Subtest(func(db database.Store, check *expects) { + _, c, m := createChat(s.T(), db) + check.Args(c.ID).Asserts(c, policy.ActionRead).Returns([]database.ChatMessage{m}) + })) + + s.Run("GetChatsByOwnerID", s.Subtest(func(db database.Store, check *expects) { + u1, u1c1, _ := createChat(s.T(), db) + u1c2 := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: u1.ID, + CreatedAt: u1c1.CreatedAt.Add(time.Hour), + }) + _, _, _ = createChat(s.T(), db) // other user's chat + check.Args(u1.ID).Asserts(u1c2, policy.ActionRead, u1c1, policy.ActionRead).Returns([]database.Chat{u1c2, u1c1}) + })) + + s.Run("InsertChat", s.Subtest(func(db database.Store, check *expects) { + usr := dbgen.User(s.T(), db, database.User{}) + check.Args(database.InsertChatParams{ + OwnerID: usr.ID, + Title: "test chat", + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + }).Asserts(rbac.ResourceChat.WithOwner(usr.ID.String()), policy.ActionCreate) + })) + + s.Run("InsertChatMessages", s.Subtest(func(db database.Store, check *expects) { + usr := dbgen.User(s.T(), db, database.User{}) + chat := dbgen.Chat(s.T(), db, database.Chat{ + OwnerID: usr.ID, + }) + check.Args(database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedAt: dbtime.Now(), + Model: "test-model", + Provider: "test-provider", + Content: []byte(`[]`), + }).Asserts(chat, policy.ActionUpdate) + })) + + s.Run("UpdateChatByID", s.Subtest(func(db database.Store, check *expects) { + _, c, _ := createChat(s.T(), db) + check.Args(database.UpdateChatByIDParams{ + ID: c.ID, + Title: "new title", + UpdatedAt: dbtime.Now(), + }).Asserts(c, policy.ActionUpdate) + })) +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 854c7c2974fe6..55c2fe4cf6965 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -142,6 +142,30 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database return key, fmt.Sprintf("%s-%s", key.ID, secret) } +func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { + chat, err := db.InsertChat(genCtx, database.InsertChatParams{ + OwnerID: takeFirst(seed.OwnerID, uuid.New()), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), + Title: takeFirst(seed.Title, "Test Chat"), + }) + require.NoError(t, err, "insert chat") + return chat +} + +func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) database.ChatMessage { + msg, err := db.InsertChatMessages(genCtx, database.InsertChatMessagesParams{ + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + ChatID: takeFirst(seed.ChatID, uuid.New()), + Model: takeFirst(seed.Model, "train"), + Provider: takeFirst(seed.Provider, "thomas"), + Content: takeFirstSlice(seed.Content, []byte(`[{"text": "Choo choo!"}]`)), + }) + require.NoError(t, err, "insert chat message") + require.Len(t, msg, 1, "insert one chat message did not return exactly one message") + return msg[0] +} + func WorkspaceAgentPortShare(t testing.TB, db database.Store, orig database.WorkspaceAgentPortShare) database.WorkspaceAgentPortShare { ps, err := db.UpsertWorkspaceAgentPortShare(genCtx, database.UpsertWorkspaceAgentPortShareParams{ WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 1359d2e63484d..6bae4455a89ef 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -215,6 +215,8 @@ type data struct { // New tables auditLogs []database.AuditLog + chats []database.Chat + chatMessages []database.ChatMessage cryptoKeys []database.CryptoKey dbcryptKeys []database.DBCryptKey files []database.File @@ -1885,6 +1887,19 @@ func (q *FakeQuerier) DeleteApplicationConnectAPIKeysByUserID(_ context.Context, return nil } +func (q *FakeQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, chat := range q.chats { + if chat.ID == id { + q.chats = append(q.chats[:i], q.chats[i+1:]...) + return nil + } + } + return sql.ErrNoRows +} + func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } @@ -2866,6 +2881,47 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U }, nil } +func (q *FakeQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, chat := range q.chats { + if chat.ID == id { + return chat, nil + } + } + return database.Chat{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + messages := []database.ChatMessage{} + for _, chatMessage := range q.chatMessages { + if chatMessage.ChatID == chatID { + messages = append(messages, chatMessage) + } + } + return messages, nil +} + +func (q *FakeQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + chats := []database.Chat{} + for _, chat := range q.chats { + if chat.OwnerID == ownerID { + chats = append(chats, chat) + } + } + sort.Slice(chats, func(i, j int) bool { + return chats[i].CreatedAt.After(chats[j].CreatedAt) + }) + return chats, nil +} + func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -8385,6 +8441,66 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit return alog, nil } +func (q *FakeQuerier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.Chat{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + chat := database.Chat{ + ID: uuid.New(), + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + Title: arg.Title, + } + q.chats = append(q.chats, chat) + + return chat, nil +} + +func (q *FakeQuerier) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + id := int64(0) + if len(q.chatMessages) > 0 { + id = q.chatMessages[len(q.chatMessages)-1].ID + } + + messages := make([]database.ChatMessage, 0) + + rawMessages := make([]json.RawMessage, 0) + err = json.Unmarshal(arg.Content, &rawMessages) + if err != nil { + return nil, err + } + + for _, content := range rawMessages { + id++ + _ = content + messages = append(messages, database.ChatMessage{ + ID: id, + ChatID: arg.ChatID, + CreatedAt: arg.CreatedAt, + Model: arg.Model, + Provider: arg.Provider, + Content: content, + }) + } + + q.chatMessages = append(q.chatMessages, messages...) + return messages, nil +} + func (q *FakeQuerier) InsertCryptoKey(_ context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { @@ -10342,6 +10458,27 @@ func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI return sql.ErrNoRows } +func (q *FakeQuerier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, chat := range q.chats { + if chat.ID == arg.ID { + q.chats[i].Title = arg.Title + q.chats[i].UpdatedAt = arg.UpdatedAt + q.chats[i] = chat + return nil + } + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateCryptoKeyDeletesAt(_ context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index b76d70c764cf6..128e741da1d76 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -249,6 +249,13 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return err } +func (m queryMetricsStore) DeleteChat(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteChat(ctx, id) + m.queryLatencies.WithLabelValues("DeleteChat").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteCoordinator(ctx, id) @@ -627,6 +634,27 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return row, err } +func (m queryMetricsStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatByID(ctx, id) + m.queryLatencies.WithLabelValues("GetChatByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.GetChatMessagesByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatMessagesByChatID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + start := time.Now() + r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID) + m.queryLatencies.WithLabelValues("GetChatsByOwnerID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetCoordinatorResumeTokenSigningKey(ctx) @@ -1992,6 +2020,20 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return log, err } +func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + start := time.Now() + r0, r1 := m.s.InsertChat(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChat").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.InsertChatMessages(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatMessages").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.InsertCryptoKey(ctx, arg) @@ -2517,6 +2559,13 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up return err } +func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + start := time.Now() + r0 := m.s.UpdateChatByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 10adfd7c5a408..17b263dfb2e07 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -376,6 +376,20 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteChat mocks base method. +func (m *MockStore) DeleteChat(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChat", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChat indicates an expected call of DeleteChat. +func (mr *MockStoreMockRecorder) DeleteChat(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChat", reflect.TypeOf((*MockStore)(nil).DeleteChat), ctx, id) +} + // DeleteCoordinator mocks base method. func (m *MockStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -1234,6 +1248,51 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } +// GetChatByID mocks base method. +func (m *MockStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatByID", ctx, id) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatByID indicates an expected call of GetChatByID. +func (mr *MockStoreMockRecorder) GetChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByID", reflect.TypeOf((*MockStore)(nil).GetChatByID), ctx, id) +} + +// GetChatMessagesByChatID mocks base method. +func (m *MockStore) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessagesByChatID", ctx, chatID) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessagesByChatID indicates an expected call of GetChatMessagesByChatID. +func (mr *MockStoreMockRecorder) GetChatMessagesByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatID), ctx, chatID) +} + +// GetChatsByOwnerID mocks base method. +func (m *MockStore) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatsByOwnerID", ctx, ownerID) + ret0, _ := ret[0].([]database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatsByOwnerID indicates an expected call of GetChatsByOwnerID. +func (mr *MockStoreMockRecorder) GetChatsByOwnerID(ctx, ownerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetChatsByOwnerID), ctx, ownerID) +} + // GetCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -4203,6 +4262,36 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } +// InsertChat mocks base method. +func (m *MockStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChat", ctx, arg) + ret0, _ := ret[0].(database.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChat indicates an expected call of InsertChat. +func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) +} + +// InsertChatMessages mocks base method. +func (m *MockStore) InsertChatMessages(ctx context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatMessages", ctx, arg) + ret0, _ := ret[0].([]database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatMessages indicates an expected call of InsertChatMessages. +func (mr *MockStoreMockRecorder) InsertChatMessages(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessages", reflect.TypeOf((*MockStore)(nil).InsertChatMessages), ctx, arg) +} + // InsertCryptoKey mocks base method. func (m *MockStore) InsertCryptoKey(ctx context.Context, arg database.InsertCryptoKeyParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -5337,6 +5426,20 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg) } +// UpdateChatByID mocks base method. +func (m *MockStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChatByID indicates an expected call of UpdateChatByID. +func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg) +} + // UpdateCryptoKeyDeletesAt mocks base method. func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 968b6a24d4bf8..9ce3b0171d2d4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -755,6 +755,32 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE chat_messages ( + id bigint NOT NULL, + chat_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + model text NOT NULL, + provider text NOT NULL, + content jsonb NOT NULL +); + +CREATE SEQUENCE chat_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE chat_messages_id_seq OWNED BY chat_messages.id; + +CREATE TABLE chats ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + owner_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + title text NOT NULL +); + CREATE TABLE crypto_keys ( feature crypto_key_feature NOT NULL, sequence integer NOT NULL, @@ -2195,6 +2221,8 @@ CREATE VIEW workspaces_expanded AS COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.'; +ALTER TABLE ONLY chat_messages ALTER COLUMN id SET DEFAULT nextval('chat_messages_id_seq'::regclass); + ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); @@ -2216,6 +2244,12 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_pkey PRIMARY KEY (id); + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); @@ -2699,6 +2733,12 @@ CREATE TRIGGER user_status_change_trigger AFTER INSERT OR UPDATE ON users FOR EA ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_messages + ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chats + ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 3f5ce963e6fdb..0db3e9522547e 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,8 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000319_chat.down.sql b/coderd/database/migrations/000319_chat.down.sql new file mode 100644 index 0000000000000..9bab993f500f5 --- /dev/null +++ b/coderd/database/migrations/000319_chat.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS chat_messages; + +DROP TABLE IF EXISTS chats; diff --git a/coderd/database/migrations/000319_chat.up.sql b/coderd/database/migrations/000319_chat.up.sql new file mode 100644 index 0000000000000..a53942239c9e2 --- /dev/null +++ b/coderd/database/migrations/000319_chat.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS chats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + title TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + -- BIGSERIAL is auto-incrementing so we know the exact order of messages. + id BIGSERIAL PRIMARY KEY, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + model TEXT NOT NULL, + provider TEXT NOT NULL, + content JSONB NOT NULL +); diff --git a/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql b/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql new file mode 100644 index 0000000000000..123a62c4eb722 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000319_chat.up.sql @@ -0,0 +1,6 @@ +INSERT INTO chats (id, owner_id, created_at, updated_at, title) VALUES +('00000000-0000-0000-0000-000000000001', '0ed9befc-4911-4ccf-a8e2-559bf72daa94', '2023-10-01 12:00:00+00', '2023-10-01 12:00:00+00', 'Test Chat 1'); + +INSERT INTO chat_messages (id, chat_id, created_at, model, provider, content) VALUES +(1, '00000000-0000-0000-0000-000000000001', '2023-10-01 12:00:00+00', 'annie-oakley', 'cowboy-coder', '{"role":"user","content":"Hello"}'), +(2, '00000000-0000-0000-0000-000000000001', '2023-10-01 12:01:00+00', 'annie-oakley', 'cowboy-coder', '{"role":"assistant","content":"Howdy pardner! What can I do ya for?"}'); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 896fdd4af17e9..b3f6deed9eff0 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -568,3 +568,8 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( return m.DebouncedUntil, false } + +func (c Chat) RBACObject() rbac.Object { + return rbac.ResourceChat.WithID(c.ID). + WithOwner(c.OwnerID.String()) +} diff --git a/coderd/database/models.go b/coderd/database/models.go index f817ff2712d54..c8ac71e8b9398 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2570,6 +2570,23 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +type Chat struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Title string `db:"title" json:"title"` +} + +type ChatMessage struct { + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Model string `db:"model" json:"model"` + Provider string `db:"provider" json:"provider"` + Content json.RawMessage `db:"content" json:"content"` +} + type CryptoKey struct { Feature CryptoKeyFeature `db:"feature" json:"feature"` Sequence int32 `db:"sequence" json:"sequence"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9fbfbde410d40..d0f74ee609724 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -79,6 +79,7 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteChat(ctx context.Context, id uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error DeleteCryptoKey(ctx context.Context, arg DeleteCryptoKeyParams) (CryptoKey, error) DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error @@ -151,6 +152,9 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) + GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) + GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -447,6 +451,8 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) InsertCryptoKey(ctx context.Context, arg InsertCryptoKeyParams) (CryptoKey, error) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) InsertDBCryptKey(ctx context.Context, arg InsertDBCryptKeyParams) error @@ -540,6 +546,7 @@ type sqlcQuerier interface { UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3908dab715e31..cd5b297c85e07 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -766,6 +766,207 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } +const deleteChat = `-- name: DeleteChat :exec +DELETE FROM chats WHERE id = $1 +` + +func (q *sqlQuerier) DeleteChat(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChat, id) + return err +} + +const getChatByID = `-- name: GetChatByID :one +SELECT id, owner_id, created_at, updated_at, title FROM chats +WHERE id = $1 +` + +func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) { + row := q.db.QueryRowContext(ctx, getChatByID, id) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ) + return i, err +} + +const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many +SELECT id, chat_id, created_at, model, provider, content FROM chat_messages +WHERE chat_id = $1 +ORDER BY created_at ASC +` + +func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, getChatMessagesByChatID, chatID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.CreatedAt, + &i.Model, + &i.Provider, + &i.Content, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatsByOwnerID = `-- name: GetChatsByOwnerID :many +SELECT id, owner_id, created_at, updated_at, title FROM chats +WHERE owner_id = $1 +ORDER BY created_at DESC +` + +func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]Chat, error) { + rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chat + for rows.Next() { + var i Chat + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertChat = `-- name: InsertChat :one +INSERT INTO chats (owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4) +RETURNING id, owner_id, created_at, updated_at, title +` + +type InsertChatParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Title string `db:"title" json:"title"` +} + +func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { + row := q.db.QueryRowContext(ctx, insertChat, + arg.OwnerID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Title, + ) + var i Chat + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Title, + ) + return i, err +} + +const insertChatMessages = `-- name: InsertChatMessages :many +INSERT INTO chat_messages (chat_id, created_at, model, provider, content) +SELECT + $1 :: uuid AS chat_id, + $2 :: timestamptz AS created_at, + $3 :: VARCHAR(127) AS model, + $4 :: VARCHAR(127) AS provider, + jsonb_array_elements($5 :: jsonb) AS content +RETURNING chat_messages.id, chat_messages.chat_id, chat_messages.created_at, chat_messages.model, chat_messages.provider, chat_messages.content +` + +type InsertChatMessagesParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Model string `db:"model" json:"model"` + Provider string `db:"provider" json:"provider"` + Content json.RawMessage `db:"content" json:"content"` +} + +func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error) { + rows, err := q.db.QueryContext(ctx, insertChatMessages, + arg.ChatID, + arg.CreatedAt, + arg.Model, + arg.Provider, + arg.Content, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatMessage + for rows.Next() { + var i ChatMessage + if err := rows.Scan( + &i.ID, + &i.ChatID, + &i.CreatedAt, + &i.Model, + &i.Provider, + &i.Content, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateChatByID = `-- name: UpdateChatByID :exec +UPDATE chats +SET title = $2, updated_at = $3 +WHERE id = $1 +` + +type UpdateChatByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + Title string `db:"title" json:"title"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) error { + _, err := q.db.ExecContext(ctx, updateChatByID, arg.ID, arg.Title, arg.UpdatedAt) + return err +} + const deleteCryptoKey = `-- name: DeleteCryptoKey :one UPDATE crypto_keys SET secret = NULL, secret_key_id = NULL diff --git a/coderd/database/queries/chat.sql b/coderd/database/queries/chat.sql new file mode 100644 index 0000000000000..68f662d8a886b --- /dev/null +++ b/coderd/database/queries/chat.sql @@ -0,0 +1,36 @@ +-- name: InsertChat :one +INSERT INTO chats (owner_id, created_at, updated_at, title) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: UpdateChatByID :exec +UPDATE chats +SET title = $2, updated_at = $3 +WHERE id = $1; + +-- name: GetChatsByOwnerID :many +SELECT * FROM chats +WHERE owner_id = $1 +ORDER BY created_at DESC; + +-- name: GetChatByID :one +SELECT * FROM chats +WHERE id = $1; + +-- name: InsertChatMessages :many +INSERT INTO chat_messages (chat_id, created_at, model, provider, content) +SELECT + @chat_id :: uuid AS chat_id, + @created_at :: timestamptz AS created_at, + @model :: VARCHAR(127) AS model, + @provider :: VARCHAR(127) AS provider, + jsonb_array_elements(@content :: jsonb) AS content +RETURNING chat_messages.*; + +-- name: GetChatMessagesByChatID :many +SELECT * FROM chat_messages +WHERE chat_id = $1 +ORDER BY created_at ASC; + +-- name: DeleteChat :exec +DELETE FROM chats WHERE id = $1; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 2b91f38c88d42..4c9c8cedcba23 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,6 +9,8 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); diff --git a/coderd/deployment.go b/coderd/deployment.go index 4c78563a80456..60988aeb2ce5a 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -1,8 +1,11 @@ package coderd import ( + "context" "net/http" + "github.com/kylecarbs/aisdk-go" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -84,3 +87,25 @@ func buildInfoHandler(resp codersdk.BuildInfoResponse) http.HandlerFunc { func (api *API) sshConfig(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, api.SSHConfig) } + +type LanguageModel struct { + codersdk.LanguageModel + Provider func(ctx context.Context, messages []aisdk.Message, thinking bool) (aisdk.DataStream, error) +} + +// @Summary Get language models +// @ID get-language-models +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.LanguageModelConfig +// @Router /deployment/llms [get] +func (api *API) deploymentLLMs(rw http.ResponseWriter, r *http.Request) { + models := make([]codersdk.LanguageModel, 0, len(api.LanguageModels)) + for _, model := range api.LanguageModels { + models = append(models, model.LanguageModel) + } + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.LanguageModelConfig{ + Models: models, + }) +} diff --git a/coderd/httpmw/chat.go b/coderd/httpmw/chat.go new file mode 100644 index 0000000000000..c92fa5038ab22 --- /dev/null +++ b/coderd/httpmw/chat.go @@ -0,0 +1,59 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type chatContextKey struct{} + +func ChatParam(r *http.Request) database.Chat { + chat, ok := r.Context().Value(chatContextKey{}).(database.Chat) + if !ok { + panic("developer error: chat param middleware not provided") + } + return chat +} + +func ExtractChatParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + arg := chi.URLParam(r, "chat") + if arg == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "\"chat\" must be provided.", + }) + return + } + chatID, err := uuid.Parse(arg) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat ID.", + }) + return + } + chat, err := db.GetChatByID(ctx, chatID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat.", + Detail: err.Error(), + }) + return + } + ctx = context.WithValue(ctx, chatContextKey{}, chat) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/chat_test.go b/coderd/httpmw/chat_test.go new file mode 100644 index 0000000000000..a8bad05f33797 --- /dev/null +++ b/coderd/httpmw/chat_test.go @@ -0,0 +1,150 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +func TestExtractChat(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.User) { + r := httptest.NewRequest("GET", "/", nil) + + user := dbgen.User(t, db, database.User{ + ID: uuid.New(), + }) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + r.Header.Set(codersdk.SessionTokenHeader, token) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext())) + return r, user + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("InvalidUUID", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + chi.RouteContext(r.Context()).URLParams.Add("chat", "not-a-uuid") + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) // Changed from NotFound in org test to BadRequest as per chat.go + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, _ = setupAuthentication(db) + rtr = chi.NewRouter() + ) + chi.RouteContext(r.Context()).URLParams.Add("chat", uuid.NewString()) + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", nil) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + rw = httptest.NewRecorder() + r, user = setupAuthentication(db) + rtr = chi.NewRouter() + ) + + // Create a test chat + testChat := dbgen.Chat(t, db, database.Chat{ + ID: uuid.New(), + OwnerID: user.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Title: "Test Chat", + }) + + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractChatParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + chat := httpmw.ChatParam(r) + require.NotZero(t, chat) + assert.Equal(t, testChat.ID, chat.ID) + assert.WithinDuration(t, testChat.CreatedAt, chat.CreatedAt, time.Second) + assert.WithinDuration(t, testChat.UpdatedAt, chat.UpdatedAt, time.Second) + assert.Equal(t, testChat.Title, chat.Title) + rw.WriteHeader(http.StatusOK) + }) + + // Try by ID + chi.RouteContext(r.Context()).URLParams.Add("chat", testChat.ID.String()) + rtr.ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode, "by id") + }) +} diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 7c0933c4241b0..40b7dc87a56f8 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -54,6 +54,16 @@ var ( Type: "audit_log", } + // ResourceChat + // Valid Actions + // - "ActionCreate" :: create a chat + // - "ActionDelete" :: delete a chat + // - "ActionRead" :: read a chat + // - "ActionUpdate" :: update a chat + ResourceChat = Object{ + Type: "chat", + } + // ResourceCryptoKey // Valid Actions // - "ActionCreate" :: create crypto keys @@ -354,6 +364,7 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, + ResourceChat, ResourceCryptoKey, ResourceDebugInfo, ResourceDeploymentConfig, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 5b661243dc127..35da0892abfdb 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -104,6 +104,14 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: actDef("read and use a workspace proxy"), }, }, + "chat": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef("create a chat"), + ActionRead: actDef("read a chat"), + ActionDelete: actDef("delete a chat"), + ActionUpdate: actDef("update a chat"), + }, + }, "license": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create a license"), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 6b99cb4e871a2..56124faee44e2 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -299,6 +299,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceOrganizationMember.Type: {policy.ActionRead}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + // Users can create, read, update, and delete their own agentic chat messages. + ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })..., ), }.withCachedRegoValue() diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 1080903637ac5..e90c89914fdec 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -831,6 +831,37 @@ func TestRolePermissions(t *testing.T) { }, }, }, + // Members may read their own chats. + { + Name: "CreateReadUpdateDeleteMyChats", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceChat.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, orgMemberMe, owner}, + false: { + userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, + // Only owners can create, read, update, and delete other users' chats. + { + Name: "CreateReadUpdateDeleteOtherUserChats", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceChat.WithOwner(uuid.NewString()), // some other user + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + memberMe, orgMemberMe, + userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/chat.go b/codersdk/chat.go new file mode 100644 index 0000000000000..2093adaff95e8 --- /dev/null +++ b/codersdk/chat.go @@ -0,0 +1,153 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/kylecarbs/aisdk-go" + "golang.org/x/xerrors" +) + +// CreateChat creates a new chat. +func (c *Client) CreateChat(ctx context.Context) (Chat, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/chats", nil) + if err != nil { + return Chat{}, xerrors.Errorf("execute request: %w", err) + } + if res.StatusCode != http.StatusCreated { + return Chat{}, ReadBodyAsError(res) + } + defer res.Body.Close() + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +type Chat struct { + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + Title string `json:"title"` +} + +// ListChats lists all chats. +func (c *Client) ListChats(ctx context.Context) ([]Chat, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/chats", nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var chats []Chat + return chats, json.NewDecoder(res.Body).Decode(&chats) +} + +// Chat returns a chat by ID. +func (c *Client) Chat(ctx context.Context, id uuid.UUID) (Chat, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s", id), nil) + if err != nil { + return Chat{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Chat{}, ReadBodyAsError(res) + } + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + +// ChatMessages returns the messages of a chat. +func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]ChatMessage, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/messages", id), nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var messages []ChatMessage + return messages, json.NewDecoder(res.Body).Decode(&messages) +} + +type ChatMessage = aisdk.Message + +type CreateChatMessageRequest struct { + Model string `json:"model"` + Message ChatMessage `json:"message"` + Thinking bool `json:"thinking"` +} + +// CreateChatMessage creates a new chat message and streams the response. +// If the provided message has a conflicting ID with an existing message, +// it will be overwritten. +func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, req CreateChatMessageRequest) (<-chan aisdk.DataStreamPart, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), req) + defer func() { + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + }() + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + nextEvent := ServerSentEventReader(ctx, res.Body) + + wc := make(chan aisdk.DataStreamPart, 256) + go func() { + defer close(wc) + defer res.Body.Close() + + for { + select { + case <-ctx.Done(): + return + default: + sse, err := nextEvent() + if err != nil { + return + } + if sse.Type != ServerSentEventTypeData { + continue + } + var part aisdk.DataStreamPart + b, ok := sse.Data.([]byte) + if !ok { + return + } + err = json.Unmarshal(b, &part) + if err != nil { + return + } + select { + case <-ctx.Done(): + return + case wc <- part: + } + } + } + }() + + return wc, nil +} + +func (c *Client) DeleteChat(ctx context.Context, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/chats/%s", id), nil) + if err != nil { + return xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 154d7f6cb92e4..0741bf9e3844a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -383,6 +383,7 @@ type DeploymentValues struct { DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` Support SupportConfig `json:"support,omitempty" typescript:",notnull"` ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"` + AI serpent.Struct[AIConfig] `json:"ai,omitempty" typescript:",notnull"` SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` @@ -2660,6 +2661,15 @@ Write out the current server config as YAML to stdout.`, Value: &c.Support.Links, Hidden: false, }, + { + // Env handling is done in cli.ReadAIProvidersFromEnv + Name: "AI", + Description: "Configure AI providers.", + YAML: "ai", + Value: &c.AI, + // Hidden because this is experimental. + Hidden: true, + }, { // Env handling is done in cli.ReadGitAuthFromEnvironment Name: "External Auth Providers", @@ -3081,6 +3091,21 @@ Write out the current server config as YAML to stdout.`, return opts } +type AIProviderConfig struct { + // Type is the type of the API provider. + Type string `json:"type" yaml:"type"` + // APIKey is the API key to use for the API provider. + APIKey string `json:"-" yaml:"api_key"` + // Models is the list of models to use for the API provider. + Models []string `json:"models" yaml:"models"` + // BaseURL is the base URL to use for the API provider. + BaseURL string `json:"base_url" yaml:"base_url"` +} + +type AIConfig struct { + Providers []AIProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` +} + type SupportConfig struct { Links serpent.Struct[[]LinkConfig] `json:"links" typescript:",notnull"` } @@ -3303,6 +3328,7 @@ const ( ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace. ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature. + ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature. ) // ExperimentsSafe should include all experiments that are safe for @@ -3517,6 +3543,32 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig) } +type LanguageModelConfig struct { + Models []LanguageModel `json:"models"` +} + +// LanguageModel is a language model that can be used for chat. +type LanguageModel struct { + // ID is used by the provider to identify the LLM. + ID string `json:"id"` + DisplayName string `json:"display_name"` + // Provider is the provider of the LLM. e.g. openai, anthropic, etc. + Provider string `json:"provider"` +} + +func (c *Client) LanguageModelConfig(ctx context.Context) (LanguageModelConfig, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/llms", nil) + if err != nil { + return LanguageModelConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return LanguageModelConfig{}, ReadBodyAsError(res) + } + var llms LanguageModelConfig + return llms, json.NewDecoder(res.Body).Decode(&llms) +} + type CryptoKeyFeature string const ( diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 7f1bd5da4eb3c..54f65767928d6 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -9,6 +9,7 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" + ResourceChat RBACResource = "chat" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" ResourceDeploymentConfig RBACResource = "deployment_config" @@ -69,6 +70,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, ResourceDeploymentConfig: {ActionRead, ActionUpdate}, diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 166bde730efc5..985475d211fa3 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -591,7 +591,7 @@ This resource provides the following fields: - init_script: The script to run on provisioned infrastructure to fetch and start the agent. - token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent. -The agent MUST be installed and started using the init_script. +The agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure. Expose terminal or HTTP applications running in a workspace with: @@ -711,13 +711,20 @@ resource "google_compute_instance" "dev" { auto_delete = false source = google_compute_disk.root.name } + // In order to use google-instance-identity, a service account *must* be provided. service_account { email = data.google_compute_default_service_account.default.email scopes = ["cloud-platform"] } + # ONLY FOR WINDOWS: + # metadata = { + # windows-startup-script-ps1 = coder_agent.main.init_script + # } # The startup script runs as root with no $HOME environment set up, so instead of directly # running the agent init script, create a user (with a homedir, default shell and sudo # permissions) and execute the init script as that user. + # + # The agent MUST be started in here. metadata_startup_script = < 200 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Chat](schemas.md#codersdkchat) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|----------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» id` | string(uuid) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create a chat + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /chats` + +### Example responses + +> 201 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Chat](schemas.md#codersdkchat) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get a chat + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats/{chat} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats/{chat}` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------| +| `chat` | path | string | true | Chat ID | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get chat messages + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/chats/{chat}/messages \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /chats/{chat}/messages` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|-------------| +| `chat` | path | string | true | Chat ID | + +### Example responses + +> 200 Response + +```json +[ + { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [aisdk.Message](schemas.md#aisdkmessage) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|------------------------------|------------------------------------------------------------------|----------|--------------|-------------------------| +| `[array item]` | array | false | | | +| `» annotations` | array | false | | | +| `» content` | string | false | | | +| `» createdAt` | array | false | | | +| `» experimental_attachments` | array | false | | | +| `»» contentType` | string | false | | | +| `»» name` | string | false | | | +| `»» url` | string | false | | | +| `» id` | string | false | | | +| `» parts` | array | false | | | +| `»» data` | array | false | | | +| `»» details` | array | false | | | +| `»»» data` | string | false | | | +| `»»» signature` | string | false | | | +| `»»» text` | string | false | | | +| `»»» type` | string | false | | | +| `»» mimeType` | string | false | | Type: "file" | +| `»» reasoning` | string | false | | Type: "reasoning" | +| `»» source` | [aisdk.SourceInfo](schemas.md#aisdksourceinfo) | false | | Type: "source" | +| `»»» contentType` | string | false | | | +| `»»» data` | string | false | | | +| `»»» metadata` | object | false | | | +| `»»»» [any property]` | any | false | | | +| `»»» uri` | string | false | | | +| `»» text` | string | false | | Type: "text" | +| `»» toolInvocation` | [aisdk.ToolInvocation](schemas.md#aisdktoolinvocation) | false | | Type: "tool-invocation" | +| `»»» args` | any | false | | | +| `»»» result` | any | false | | | +| `»»» state` | [aisdk.ToolInvocationState](schemas.md#aisdktoolinvocationstate) | false | | | +| `»»» step` | integer | false | | | +| `»»» toolCallId` | string | false | | | +| `»»» toolName` | string | false | | | +| `»» type` | [aisdk.PartType](schemas.md#aisdkparttype) | false | | | +| `» role` | string | false | | | + +#### Enumerated Values + +| Property | Value | +|----------|-------------------| +| `state` | `call` | +| `state` | `partial-call` | +| `state` | `result` | +| `type` | `text` | +| `type` | `reasoning` | +| `type` | `tool-invocation` | +| `type` | `source` | +| `type` | `file` | +| `type` | `step-start` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create a chat message + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats/{chat}/messages \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /chats/{chat}/messages` + +> Body parameter + +```json +{ + "message": { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + }, + "model": "string", + "thinking": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------|----------|--------------| +| `chat` | path | string | true | Chat ID | +| `body` | body | [codersdk.CreateChatMessageRequest](schemas.md#codersdkcreatechatmessagerequest) | true | Request body | + +### Example responses + +> 200 Response + +```json +[ + null +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of undefined | + +

Response Schema

+ +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 3c27ddb6dea1d..c14c317066a39 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -161,6 +161,19 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -570,6 +583,43 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get language models + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/deployment/llms \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /deployment/llms` + +### Example responses + +> 200 Response + +```json +{ + "models": [ + { + "display_name": "string", + "id": "string", + "provider": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.LanguageModelConfig](schemas.md#codersdklanguagemodelconfig) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## SSH Config ### Code samples diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 972313001f3ea..a58a597d1ea2a 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -185,6 +185,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -351,6 +352,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -517,6 +519,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -652,6 +655,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | @@ -1009,6 +1013,7 @@ Status Code **200** | `resource_type` | `assign_org_role` | | `resource_type` | `assign_role` | | `resource_type` | `audit_log` | +| `resource_type` | `chat` | | `resource_type` | `crypto_key` | | `resource_type` | `debug_info` | | `resource_type` | `deployment_config` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e2ba1373aa613..6ca005b4ec69c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -182,6 +182,250 @@ | `icon` | string | false | | | | `id` | string | false | | ID is a unique identifier for the log source. It is scoped to a workspace agent, and can be statically defined inside code to prevent duplicate sources from being created for the same agent. | +## aisdk.Attachment + +```json +{ + "contentType": "string", + "name": "string", + "url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `contentType` | string | false | | | +| `name` | string | false | | | +| `url` | string | false | | | + +## aisdk.Message + +```json +{ + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|-----------------------------------------------|----------|--------------|-------------| +| `annotations` | array of undefined | false | | | +| `content` | string | false | | | +| `createdAt` | array of integer | false | | | +| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | +| `id` | string | false | | | +| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | +| `role` | string | false | | | + +## aisdk.Part + +```json +{ + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------|---------------------------------------------------------|----------|--------------|-------------------------| +| `data` | array of integer | false | | | +| `details` | array of [aisdk.ReasoningDetail](#aisdkreasoningdetail) | false | | | +| `mimeType` | string | false | | Type: "file" | +| `reasoning` | string | false | | Type: "reasoning" | +| `source` | [aisdk.SourceInfo](#aisdksourceinfo) | false | | Type: "source" | +| `text` | string | false | | Type: "text" | +| `toolInvocation` | [aisdk.ToolInvocation](#aisdktoolinvocation) | false | | Type: "tool-invocation" | +| `type` | [aisdk.PartType](#aisdkparttype) | false | | | + +## aisdk.PartType + +```json +"text" +``` + +### Properties + +#### Enumerated Values + +| Value | +|-------------------| +| `text` | +| `reasoning` | +| `tool-invocation` | +| `source` | +| `file` | +| `step-start` | + +## aisdk.ReasoningDetail + +```json +{ + "data": "string", + "signature": "string", + "text": "string", + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `data` | string | false | | | +| `signature` | string | false | | | +| `text` | string | false | | | +| `type` | string | false | | | + +## aisdk.SourceInfo + +```json +{ + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------|----------|--------------|-------------| +| `contentType` | string | false | | | +| `data` | string | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `uri` | string | false | | | + +## aisdk.ToolInvocation + +```json +{ + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------------------------------------------------------|----------|--------------|-------------| +| `args` | any | false | | | +| `result` | any | false | | | +| `state` | [aisdk.ToolInvocationState](#aisdktoolinvocationstate) | false | | | +| `step` | integer | false | | | +| `toolCallId` | string | false | | | +| `toolName` | string | false | | | + +## aisdk.ToolInvocationState + +```json +"call" +``` + +### Properties + +#### Enumerated Values + +| Value | +|----------------| +| `call` | +| `partial-call` | +| `result` | + ## coderd.SCIMUser ```json @@ -305,6 +549,48 @@ | `groups` | array of [codersdk.Group](#codersdkgroup) | false | | | | `users` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | +## codersdk.AIConfig + +```json +{ + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|-----------------------------------------------------------------|----------|--------------|-------------| +| `providers` | array of [codersdk.AIProviderConfig](#codersdkaiproviderconfig) | false | | | + +## codersdk.AIProviderConfig + +```json +{ + "base_url": "string", + "models": [ + "string" + ], + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|-----------------|----------|--------------|-----------------------------------------------------------| +| `base_url` | string | false | | Base URL is the base URL to use for the API provider. | +| `models` | array of string | false | | Models is the list of models to use for the API provider. | +| `type` | string | false | | Type is the type of the API provider. | + ## codersdk.APIKey ```json @@ -1038,6 +1324,97 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `one_time_passcode` | string | true | | | | `password` | string | true | | | +## codersdk.Chat + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `id` | string | false | | | +| `title` | string | false | | | +| `updated_at` | string | false | | | + +## codersdk.ChatMessage + +```json +{ + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|-----------------------------------------------|----------|--------------|-------------| +| `annotations` | array of undefined | false | | | +| `content` | string | false | | | +| `createdAt` | array of integer | false | | | +| `experimental_attachments` | array of [aisdk.Attachment](#aisdkattachment) | false | | | +| `id` | string | false | | | +| `parts` | array of [aisdk.Part](#aisdkpart) | false | | | +| `role` | string | false | | | + ## codersdk.ConnectionLatency ```json @@ -1070,6 +1447,77 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `password` | string | true | | | | `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. | +## codersdk.CreateChatMessageRequest + +```json +{ + "message": { + "annotations": [ + null + ], + "content": "string", + "createdAt": [ + 0 + ], + "experimental_attachments": [ + { + "contentType": "string", + "name": "string", + "url": "string" + } + ], + "id": "string", + "parts": [ + { + "data": [ + 0 + ], + "details": [ + { + "data": "string", + "signature": "string", + "text": "string", + "type": "string" + } + ], + "mimeType": "string", + "reasoning": "string", + "source": { + "contentType": "string", + "data": "string", + "metadata": { + "property1": null, + "property2": null + }, + "uri": "string" + }, + "text": "string", + "toolInvocation": { + "args": null, + "result": null, + "state": "call", + "step": 0, + "toolCallId": "string", + "toolName": "string" + }, + "type": "text" + } + ], + "role": "string" + }, + "model": "string", + "thinking": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|----------------------------------------------|----------|--------------|-------------| +| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | +| `model` | string | false | | | +| `thinking` | boolean | false | | | + ## codersdk.CreateFirstUserRequest ```json @@ -1334,12 +1782,52 @@ This is required on creation to enable a user-flow of validating a template work ## codersdk.CreateTestAuditLogRequest ```json -{} +{ + "action": "create", + "additional_fields": [ + 0 + ], + "build_reason": "autostart", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "request_id": "266ea41d-adf5-480b-af50-15b940c2b846", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "resource_type": "template", + "time": "2019-08-24T14:15:22Z" +} ``` ### Properties -None +| Name | Type | Required | Restrictions | Description | +|---------------------|------------------------------------------------|----------|--------------|-------------| +| `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | | +| `additional_fields` | array of integer | false | | | +| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | +| `organization_id` | string | false | | | +| `request_id` | string | false | | | +| `resource_id` | string | false | | | +| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | | +| `time` | string | false | | | + +#### Enumerated Values + +| Property | Value | +|-----------------|--------------------| +| `action` | `create` | +| `action` | `write` | +| `action` | `delete` | +| `action` | `start` | +| `action` | `stop` | +| `build_reason` | `autostart` | +| `build_reason` | `autostop` | +| `build_reason` | `initiator` | +| `resource_type` | `template` | +| `resource_type` | `template_version` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_build` | +| `resource_type` | `git_ssh_key` | +| `resource_type` | `auditable_group` | ## codersdk.CreateTokenRequest @@ -1812,6 +2300,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -2297,6 +2798,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "user": {} }, "agent_stat_refresh_interval": 0, + "ai": { + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } + }, "allow_workspace_renames": true, "autobuild_poll_interval": 0, "browser_only": true, @@ -2673,6 +3187,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `address` | [serpent.HostPort](#serpenthostport) | false | | Deprecated: Use HTTPAddress or TLS.Address instead. | | `agent_fallback_troubleshooting_url` | [serpent.URL](#serpenturl) | false | | | | `agent_stat_refresh_interval` | integer | false | | | +| `ai` | [serpent.Struct-codersdk_AIConfig](#serpentstruct-codersdk_aiconfig) | false | | | | `allow_workspace_renames` | boolean | false | | | | `autobuild_poll_interval` | integer | false | | | | `browser_only` | boolean | false | | | @@ -2829,6 +3344,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `web-push` | | `dynamic-parameters` | | `workspace-prebuilds` | +| `agentic-chat` | ## codersdk.ExternalAuth @@ -3446,6 +3962,44 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------------------------------| | `REQUIRED_TEMPLATE_VARIABLES` | +## codersdk.LanguageModel + +```json +{ + "display_name": "string", + "id": "string", + "provider": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|-------------------------------------------------------------------| +| `display_name` | string | false | | | +| `id` | string | false | | ID is used by the provider to identify the LLM. | +| `provider` | string | false | | Provider is the provider of the LLM. e.g. openai, anthropic, etc. | + +## codersdk.LanguageModelConfig + +```json +{ + "models": [ + { + "display_name": "string", + "id": "string", + "provider": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|-----------------------------------------------------------|----------|--------------|-------------| +| `models` | array of [codersdk.LanguageModel](#codersdklanguagemodel) | false | | | + ## codersdk.License ```json @@ -5354,6 +5908,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `assign_org_role` | | `assign_role` | | `audit_log` | +| `chat` | | `crypto_key` | | `debug_info` | | `deployment_config` | @@ -11118,6 +11673,30 @@ None |---------|-----------------------------------------------------|----------|--------------|-------------| | `value` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | | +## serpent.Struct-codersdk_AIConfig + +```json +{ + "value": { + "providers": [ + { + "base_url": "string", + "models": [ + "string" + ], + "type": "string" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|----------------------------------------|----------|--------------|-------------| +| `value` | [codersdk.AIConfig](#codersdkaiconfig) | false | | | + ## serpent.URL ```json diff --git a/go.mod b/go.mod index 8ff0ba1fa2376..ce41f23e02e05 100644 --- a/go.mod +++ b/go.mod @@ -487,10 +487,13 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 github.com/coder/preview v0.0.1 github.com/fsnotify/fsnotify v1.9.0 - github.com/kylecarbs/aisdk-go v0.0.5 + github.com/kylecarbs/aisdk-go v0.0.8 github.com/mark3labs/mcp-go v0.23.1 + github.com/openai/openai-go v0.1.0-beta.6 + google.golang.org/genai v0.7.0 ) require ( @@ -502,7 +505,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect - github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect @@ -516,7 +518,6 @@ require ( github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/moby/sys/user v0.3.0 // indirect - github.com/openai/openai-go v0.1.0-beta.6 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/samber/lo v1.49.1 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect @@ -527,6 +528,5 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - google.golang.org/genai v0.7.0 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect ) diff --git a/go.sum b/go.sum index fc05152d34122..09bd945ec4898 100644 --- a/go.sum +++ b/go.sum @@ -1467,8 +1467,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylecarbs/aisdk-go v0.0.5 h1:e4HE/SMBUUZn7AS/luiIYbEtHbbtUBzJS95R6qHDYVE= -github.com/kylecarbs/aisdk-go v0.0.5/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= +github.com/kylecarbs/aisdk-go v0.0.8 h1:hnKVbLM6U8XqX3t5I26J8k5saXdra595bGt1HP0PvKA= +github.com/kylecarbs/aisdk-go v0.0.8/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index ffb5b541e3a4a..079dcb4a87a61 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -31,6 +31,12 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, + chat: { + create: "create a chat", + delete: "delete a chat", + read: "read a chat", + update: "update a chat", + }, crypto_key: { create: "create crypto keys", delete: "delete crypto keys", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d879c09d119b2..b1fcb296de4e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6,6 +6,18 @@ export interface ACLAvailable { readonly groups: readonly Group[]; } +// From codersdk/deployment.go +export interface AIConfig { + readonly providers?: readonly AIProviderConfig[]; +} + +// From codersdk/deployment.go +export interface AIProviderConfig { + readonly type: string; + readonly models: readonly string[]; + readonly base_url: string; +} + // From codersdk/apikey.go export interface APIKey { readonly id: string; @@ -291,6 +303,28 @@ export interface ChangePasswordWithOneTimePasscodeRequest { readonly one_time_passcode: string; } +// From codersdk/chat.go +export interface Chat { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly title: string; +} + +// From codersdk/chat.go +export interface ChatMessage { + readonly id: string; + readonly createdAt?: Record; + readonly content: string; + readonly role: string; + // external type "github.com/kylecarbs/aisdk-go.Part", to include this type the package must be explicitly included in the parsing + readonly parts?: readonly unknown[]; + // empty interface{} type, falling back to unknown + readonly annotations?: readonly unknown[]; + // external type "github.com/kylecarbs/aisdk-go.Attachment", to include this type the package must be explicitly included in the parsing + readonly experimental_attachments?: readonly unknown[]; +} + // From codersdk/client.go export const CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"; @@ -312,6 +346,14 @@ export interface ConvertLoginRequest { readonly password: string; } +// From codersdk/chat.go +export interface CreateChatMessageRequest { + readonly model: string; + // embedded anonymous struct, please fix by naming it + readonly message: unknown; + readonly thinking: boolean; +} + // From codersdk/users.go export interface CreateFirstUserRequest { readonly email: string; @@ -677,6 +719,7 @@ export interface DeploymentValues { readonly disable_password_auth?: boolean; readonly support?: SupportConfig; readonly external_auth?: SerpentStruct; + readonly ai?: SerpentStruct; readonly config_ssh?: SSHConfig; readonly wgtunnel_host?: string; readonly disable_owner_workspace_exec?: boolean; @@ -769,6 +812,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = + | "agentic-chat" | "auto-fill-parameters" | "dynamic-parameters" | "example" @@ -1186,6 +1230,18 @@ export type JobErrorCode = "REQUIRED_TEMPLATE_VARIABLES"; export const JobErrorCodes: JobErrorCode[] = ["REQUIRED_TEMPLATE_VARIABLES"]; +// From codersdk/deployment.go +export interface LanguageModel { + readonly id: string; + readonly display_name: string; + readonly provider: string; +} + +// From codersdk/deployment.go +export interface LanguageModelConfig { + readonly models: readonly LanguageModel[]; +} + // From codersdk/licenses.go export interface License { readonly id: number; @@ -2061,6 +2117,7 @@ export type RBACResource = | "assign_org_role" | "assign_role" | "audit_log" + | "chat" | "crypto_key" | "debug_info" | "deployment_config" @@ -2099,6 +2156,7 @@ export const RBACResources: RBACResource[] = [ "assign_org_role", "assign_role", "audit_log", + "chat", "crypto_key", "debug_info", "deployment_config", From 3be6487f02db25d9ec213a9bf43b7f32c386b3eb Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 2 May 2025 14:44:01 -0300 Subject: [PATCH 019/158] feat: support GFM alerts in markdown (#17662) Closes https://github.com/coder/coder/issues/17660 Add support to [GFM Alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). Screenshot 2025-05-02 at 14 26 36 PS: This was heavily copied from https://github.com/coder/coder-registry/blob/dev/cmd/main/site/src/components/MarkdownView/MarkdownView.tsx --- .../components/Markdown/Markdown.stories.tsx | 21 +++ site/src/components/Markdown/Markdown.tsx | 177 +++++++++++++++++- site/src/index.css | 4 + site/tailwind.config.js | 2 + 4 files changed, 203 insertions(+), 1 deletion(-) diff --git a/site/src/components/Markdown/Markdown.stories.tsx b/site/src/components/Markdown/Markdown.stories.tsx index d4adce530efdf..37a0670c73fdb 100644 --- a/site/src/components/Markdown/Markdown.stories.tsx +++ b/site/src/components/Markdown/Markdown.stories.tsx @@ -74,3 +74,24 @@ export const WithTable: Story = { | cell 1 | cell 2 | 3 | 4 | `, }, }; + +export const GFMAlerts: Story = { + args: { + children: ` +> [!NOTE] +> Useful information that users should know, even when skimming content. + +> [!TIP] +> Helpful advice for doing things better or more easily. + +> [!IMPORTANT] +> Key information users need to know to achieve their goal. + +> [!WARNING] +> Urgent info that needs immediate user attention to avoid problems. + +> [!CAUTION] +> Advises about risks or negative outcomes of certain actions. + `, + }, +}; diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index a9bac7c6ad43a..b68919dce51f8 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -8,12 +8,20 @@ import { TableRow, } from "components/Table/Table"; import isEqual from "lodash/isEqual"; -import { type FC, memo } from "react"; +import { + type FC, + type HTMLProps, + type ReactElement, + type ReactNode, + isValidElement, + memo, +} from "react"; import ReactMarkdown, { type Options } from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; import gfm from "remark-gfm"; import colors from "theme/tailwindColors"; +import { cn } from "utils/cn"; interface MarkdownProps { /** @@ -114,6 +122,30 @@ export const Markdown: FC = (props) => { return {children}; }, + /** + * 2025-02-10 - The RemarkGFM plugin that we use currently doesn't have + * support for special alert messages like this: + * ``` + * > [!IMPORTANT] + * > This module will only work with Git versions >=2.34, and... + * ``` + * Have to intercept all blockquotes and see if their content is + * formatted like an alert. + */ + blockquote: (parseProps) => { + const { node: _node, children, ...renderProps } = parseProps; + const alertContent = parseChildrenAsAlertContent(children); + if (alertContent === null) { + return
{children}
; + } + + return ( + + {alertContent.children} + + ); + }, + ...components, }} > @@ -197,6 +229,149 @@ export const InlineMarkdown: FC = (props) => { export const MemoizedMarkdown = memo(Markdown, isEqual); export const MemoizedInlineMarkdown = memo(InlineMarkdown, isEqual); +const githubFlavoredMarkdownAlertTypes = [ + "tip", + "note", + "important", + "warning", + "caution", +]; + +type AlertContent = Readonly<{ + type: string; + children: readonly ReactNode[]; +}>; + +function parseChildrenAsAlertContent( + jsxChildren: ReactNode, +): AlertContent | null { + // Have no idea why the plugin parses the data by mixing node types + // like this. Have to do a good bit of nested filtering. + if (!Array.isArray(jsxChildren)) { + return null; + } + + const mainParentNode = jsxChildren.find((node): node is ReactElement => + isValidElement(node), + ); + let parentChildren = mainParentNode?.props.children; + if (typeof parentChildren === "string") { + // Children will only be an array if the parsed text contains other + // content that can be turned into HTML. If there aren't any, you + // just get one big string + parentChildren = parentChildren.split("\n"); + } + if (!Array.isArray(parentChildren)) { + return null; + } + + const outputContent = parentChildren + .filter((el) => { + if (isValidElement(el)) { + return true; + } + return typeof el === "string" && el !== "\n"; + }) + .map((el) => { + if (!isValidElement(el)) { + return el; + } + if (el.type !== "a") { + return el; + } + + const recastProps = el.props as Record & { + children?: ReactNode; + }; + if (recastProps.target === "_blank") { + return el; + } + + return { + ...el, + props: { + ...recastProps, + target: "_blank", + children: ( + <> + {recastProps.children} + (link opens in new tab) + + ), + }, + }; + }); + const [firstEl, ...remainingChildren] = outputContent; + if (typeof firstEl !== "string") { + return null; + } + + const alertType = firstEl + .trim() + .toLowerCase() + .replace("!", "") + .replace("[", "") + .replace("]", ""); + if (!githubFlavoredMarkdownAlertTypes.includes(alertType)) { + return null; + } + + const hasLeadingLinebreak = + isValidElement(remainingChildren[0]) && remainingChildren[0].type === "br"; + if (hasLeadingLinebreak) { + remainingChildren.shift(); + } + + return { + type: alertType, + children: remainingChildren, + }; +} + +type MarkdownGfmAlertProps = Readonly< + HTMLProps & { + alertType: string; + } +>; + +const MarkdownGfmAlert: FC = ({ + alertType, + children, + ...delegatedProps +}) => { + return ( +
+ +
+ ); +}; + const markdownStyles: Interpolation = (theme: Theme) => ({ fontSize: 16, lineHeight: "24px", diff --git a/site/src/index.css b/site/src/index.css index e2b71d7be6516..f3bf0918ddb3a 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -29,6 +29,7 @@ --surface-orange: 34 100% 92%; --surface-sky: 201 94% 86%; --surface-red: 0 93% 94%; + --surface-purple: 251 91% 95%; --border-default: 240 6% 90%; --border-success: 142 76% 36%; --border-warning: 30.66, 97.16%, 72.35%; @@ -41,6 +42,7 @@ --highlight-green: 143 64% 24%; --highlight-grey: 240 5% 65%; --highlight-sky: 201 90% 27%; + --highlight-red: 0 74% 42%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; @@ -69,6 +71,7 @@ --surface-orange: 13 81% 15%; --surface-sky: 204 80% 16%; --surface-red: 0 75% 15%; + --surface-purple: 261 73% 23%; --border-default: 240 4% 16%; --border-success: 142 76% 36%; --border-warning: 30.66, 97.16%, 72.35%; @@ -80,6 +83,7 @@ --highlight-green: 141 79% 85%; --highlight-grey: 240 4% 46%; --highlight-sky: 198 93% 60%; + --highlight-red: 0 91% 71%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; diff --git a/site/tailwind.config.js b/site/tailwind.config.js index d2935698e5d9e..e4b40aa1773f9 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -53,6 +53,7 @@ module.exports = { orange: "hsl(var(--surface-orange))", sky: "hsl(var(--surface-sky))", red: "hsl(var(--surface-red))", + purple: "hsl(var(--surface-purple))", }, border: { DEFAULT: "hsl(var(--border-default))", @@ -69,6 +70,7 @@ module.exports = { green: "hsl(var(--highlight-green))", grey: "hsl(var(--highlight-grey))", sky: "hsl(var(--highlight-sky))", + red: "hsl(var(--highlight-red))", }, }, keyframes: { From 82fdb6a6ae5af47aa931cc5d29efde5217ccd619 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 2 May 2025 14:44:13 -0300 Subject: [PATCH 020/158] fix: fix size for non-squared app icons (#17663) **Before:** ![image](https://github.com/user-attachments/assets/e7544b00-24b0-405c-b763-49a9a009c1d2) **After:** Screenshot 2025-05-02 at 14 36 19 --- site/src/modules/resources/AgentButton.tsx | 3 ++- .../modules/resources/AppLink/AppLink.stories.tsx | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/site/src/modules/resources/AgentButton.tsx b/site/src/modules/resources/AgentButton.tsx index 580358abdd73d..2f772e4f8e0ca 100644 --- a/site/src/modules/resources/AgentButton.tsx +++ b/site/src/modules/resources/AgentButton.tsx @@ -19,7 +19,8 @@ export const AgentButton = forwardRef( "& .MuiButton-startIcon, & .MuiButton-endIcon": { width: 16, height: 16, - "& svg": { width: "100%", height: "100%" }, + + "& svg, & img": { width: "100%", height: "100%" }, }, })} > diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index db6fbf02c69da..94cb0e2010b66 100644 --- a/site/src/modules/resources/AppLink/AppLink.stories.tsx +++ b/site/src/modules/resources/AppLink/AppLink.stories.tsx @@ -62,6 +62,19 @@ export const WithIcon: Story = { }, }; +export const WithNonSquaredIcon: Story = { + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + icon: "/icon/windsurf.svg", + sharing_level: "owner", + health: "healthy", + }, + agent: MockWorkspaceAgent, + }, +}; + export const ExternalApp: Story = { args: { workspace: MockWorkspace, From a646478aed63996d5257e425ffd56318eda91457 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 5 May 2025 11:54:18 +0200 Subject: [PATCH 021/158] fix: move pubsub publishing out of database transactions to avoid conn exhaustion (#17648) Database transactions hold onto connections, and `pubsub.Publish` tries to acquire a connection of its own. If the latter is called within a transaction, this can lead to connection exhaustion. I plan two follow-ups to this PR: 1. Make connection counts tuneable https://github.com/coder/coder/blob/main/cli/server.go#L2360-L2376 We will then be able to write tests showing how connection exhaustion occurs. 2. Write a linter/ruleguard to prevent `pubsub.Publish` from being called within a transaction. --------- Signed-off-by: Danny Kopping --- enterprise/coderd/prebuilds/reconcile.go | 61 ++++-- enterprise/coderd/prebuilds/reconcile_test.go | 198 ++++++++++-------- 2 files changed, 156 insertions(+), 103 deletions(-) diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index 5639678c1b9db..c31da695637ba 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -40,10 +40,11 @@ type StoreReconciler struct { registerer prometheus.Registerer metrics *MetricsCollector - cancelFn context.CancelCauseFunc - running atomic.Bool - stopped atomic.Bool - done chan struct{} + cancelFn context.CancelCauseFunc + running atomic.Bool + stopped atomic.Bool + done chan struct{} + provisionNotifyCh chan database.ProvisionerJob } var _ prebuilds.ReconciliationOrchestrator = &StoreReconciler{} @@ -56,13 +57,14 @@ func NewStoreReconciler(store database.Store, registerer prometheus.Registerer, ) *StoreReconciler { reconciler := &StoreReconciler{ - store: store, - pubsub: ps, - logger: logger, - cfg: cfg, - clock: clock, - registerer: registerer, - done: make(chan struct{}, 1), + store: store, + pubsub: ps, + logger: logger, + cfg: cfg, + clock: clock, + registerer: registerer, + done: make(chan struct{}, 1), + provisionNotifyCh: make(chan database.ProvisionerJob, 10), } reconciler.metrics = NewMetricsCollector(store, logger, reconciler) @@ -100,6 +102,29 @@ func (c *StoreReconciler) Run(ctx context.Context) { // NOTE: without this atomic bool, Stop might race with Run for the c.cancelFn above. c.running.Store(true) + // Publish provisioning jobs outside of database transactions. + // A connection is held while a database transaction is active; PGPubsub also tries to acquire a new connection on + // Publish, so we can exhaust available connections. + // + // A single worker dequeues from the channel, which should be sufficient. + // If any messages are missed due to congestion or errors, provisionerdserver has a backup polling mechanism which + // will periodically pick up any queued jobs (see poll(time.Duration) in coderd/provisionerdserver/acquirer.go). + go func() { + for { + select { + case <-c.done: + return + case <-ctx.Done(): + return + case job := <-c.provisionNotifyCh: + err := provisionerjobs.PostJob(c.pubsub, job) + if err != nil { + c.logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + } + } + } + }() + for { select { // TODO: implement pubsub listener to allow reconciling a specific template imperatively once it has been changed, @@ -576,10 +601,16 @@ func (c *StoreReconciler) provision( return xerrors.Errorf("provision workspace: %w", err) } - err = provisionerjobs.PostJob(c.pubsub, *provisionerJob) - if err != nil { - // Client probably doesn't care about this error, so just log it. - c.logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + if provisionerJob == nil { + return nil + } + + // Publish provisioner job event outside of transaction. + select { + case c.provisionNotifyCh <- *provisionerJob: + default: // channel full, drop the message; provisioner will pick this job up later with its periodic check, though. + c.logger.Warn(ctx, "provisioner job notification queue full, dropping", + slog.F("job_id", provisionerJob.ID), slog.F("prebuild_id", prebuildID.String())) } c.logger.Info(ctx, "prebuild job scheduled", slog.F("transition", transition), diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index a1732c8391d11..a1666134a7965 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" @@ -303,100 +304,106 @@ func TestPrebuildReconciliation(t *testing.T) { for _, prebuildLatestTransition := range tc.prebuildLatestTransitions { for _, prebuildJobStatus := range tc.prebuildJobStatuses { for _, templateDeleted := range tc.templateDeleted { - t.Run(fmt.Sprintf("%s - %s - %s", tc.name, prebuildLatestTransition, prebuildJobStatus), func(t *testing.T) { - t.Parallel() - t.Cleanup(func() { - if t.Failed() { - t.Logf("failed to run test: %s", tc.name) - t.Logf("templateVersionActive: %t", templateVersionActive) - t.Logf("prebuildLatestTransition: %s", prebuildLatestTransition) - t.Logf("prebuildJobStatus: %s", prebuildJobStatus) + for _, useBrokenPubsub := range []bool{true, false} { + t.Run(fmt.Sprintf("%s - %s - %s - pubsub_broken=%v", tc.name, prebuildLatestTransition, prebuildJobStatus, useBrokenPubsub), func(t *testing.T) { + t.Parallel() + t.Cleanup(func() { + if t.Failed() { + t.Logf("failed to run test: %s", tc.name) + t.Logf("templateVersionActive: %t", templateVersionActive) + t.Logf("prebuildLatestTransition: %s", prebuildLatestTransition) + t.Logf("prebuildJobStatus: %s", prebuildJobStatus) + } + }) + clock := quartz.NewMock(t) + ctx := testutil.Context(t, testutil.WaitShort) + cfg := codersdk.PrebuildsConfig{} + logger := slogtest.Make( + t, &slogtest.Options{IgnoreErrors: true}, + ).Leveled(slog.LevelDebug) + db, pubSub := dbtestutil.NewDB(t) + + ownerID := uuid.New() + dbgen.User(t, db, database.User{ + ID: ownerID, + }) + org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted) + templateVersionID := setupTestDBTemplateVersion( + ctx, + t, + clock, + db, + pubSub, + org.ID, + ownerID, + template.ID, + ) + preset := setupTestDBPreset( + t, + db, + templateVersionID, + 1, + uuid.New().String(), + ) + prebuild := setupTestDBPrebuild( + t, + clock, + db, + pubSub, + prebuildLatestTransition, + prebuildJobStatus, + org.ID, + preset, + template.ID, + templateVersionID, + ) + + if !templateVersionActive { + // Create a new template version and mark it as active + // This marks the template version that we care about as inactive + setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) } - }) - clock := quartz.NewMock(t) - ctx := testutil.Context(t, testutil.WaitShort) - cfg := codersdk.PrebuildsConfig{} - logger := slogtest.Make( - t, &slogtest.Options{IgnoreErrors: true}, - ).Leveled(slog.LevelDebug) - db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry()) - - ownerID := uuid.New() - dbgen.User(t, db, database.User{ - ID: ownerID, - }) - org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted) - templateVersionID := setupTestDBTemplateVersion( - ctx, - t, - clock, - db, - pubSub, - org.ID, - ownerID, - template.ID, - ) - preset := setupTestDBPreset( - t, - db, - templateVersionID, - 1, - uuid.New().String(), - ) - prebuild := setupTestDBPrebuild( - t, - clock, - db, - pubSub, - prebuildLatestTransition, - prebuildJobStatus, - org.ID, - preset, - template.ID, - templateVersionID, - ) - - if !templateVersionActive { - // Create a new template version and mark it as active - // This marks the template version that we care about as inactive - setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) - } - - // Run the reconciliation multiple times to ensure idempotency - // 8 was arbitrary, but large enough to reasonably trust the result - for i := 1; i <= 8; i++ { - require.NoErrorf(t, controller.ReconcileAll(ctx), "failed on iteration %d", i) - - if tc.shouldCreateNewPrebuild != nil { - newPrebuildCount := 0 - workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) - require.NoError(t, err) - for _, workspace := range workspaces { - if workspace.ID != prebuild.ID { - newPrebuildCount++ + + if useBrokenPubsub { + pubSub = &brokenPublisher{Pubsub: pubSub} + } + controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry()) + + // Run the reconciliation multiple times to ensure idempotency + // 8 was arbitrary, but large enough to reasonably trust the result + for i := 1; i <= 8; i++ { + require.NoErrorf(t, controller.ReconcileAll(ctx), "failed on iteration %d", i) + + if tc.shouldCreateNewPrebuild != nil { + newPrebuildCount := 0 + workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) + require.NoError(t, err) + for _, workspace := range workspaces { + if workspace.ID != prebuild.ID { + newPrebuildCount++ + } } + // This test configures a preset that desires one prebuild. + // In cases where new prebuilds should be created, there should be exactly one. + require.Equal(t, *tc.shouldCreateNewPrebuild, newPrebuildCount == 1) } - // This test configures a preset that desires one prebuild. - // In cases where new prebuilds should be created, there should be exactly one. - require.Equal(t, *tc.shouldCreateNewPrebuild, newPrebuildCount == 1) - } - if tc.shouldDeleteOldPrebuild != nil { - builds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ - WorkspaceID: prebuild.ID, - }) - require.NoError(t, err) - if *tc.shouldDeleteOldPrebuild { - require.Equal(t, 2, len(builds)) - require.Equal(t, database.WorkspaceTransitionDelete, builds[0].Transition) - } else { - require.Equal(t, 1, len(builds)) - require.Equal(t, prebuildLatestTransition, builds[0].Transition) + if tc.shouldDeleteOldPrebuild != nil { + builds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ + WorkspaceID: prebuild.ID, + }) + require.NoError(t, err) + if *tc.shouldDeleteOldPrebuild { + require.Equal(t, 2, len(builds)) + require.Equal(t, database.WorkspaceTransitionDelete, builds[0].Transition) + } else { + require.Equal(t, 1, len(builds)) + require.Equal(t, prebuildLatestTransition, builds[0].Transition) + } } } - } - }) + }) + } } } } @@ -404,6 +411,21 @@ func TestPrebuildReconciliation(t *testing.T) { } } +// brokenPublisher is used to validate that Publish() calls which always fail do not affect the reconciler's behavior, +// since the messages published are not essential but merely advisory. +type brokenPublisher struct { + pubsub.Pubsub +} + +// Publish deliberately fails. +// I'm explicitly _not_ checking for EventJobPosted (coderd/database/provisionerjobs/provisionerjobs.go) since that +// requires too much knowledge of the underlying implementation. +func (*brokenPublisher) Publish(event string, _ []byte) error { + // Mimick some work being done. + <-time.After(testutil.IntervalFast) + return xerrors.Errorf("failed to publish %q", event) +} + func TestMultiplePresetsPerTemplateVersion(t *testing.T) { t.Parallel() From 87f453535758bd505722b6954f31ccdc844deb89 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 5 May 2025 14:26:30 +0200 Subject: [PATCH 022/158] chore: optimize CI setup time on Windows (#17666) This PR focuses on optimizing go-test CI times on Windows. It: - backs the `$RUNNER_TEMP` directory with a RAM disk. This directory is used by actions like cache, setup-go, and setup-terraform as a staging area - backs `GOCACHE`, `GOMODCACHE`, and `GOPATH` with a RAM disk - backs `$GITHUB_WORKSPACE` with a RAM disk - that's where the repository is checked out - uses preinstalled Go on Windows runners - starts using the depot Windows runner From what I've seen, these changes bring test times down to be on par with Linux and macOS. The biggest improvement comes from backing frequently accessed paths with RAM disks. The C drive is surprisingly slow - I ran some performance tests with [fio](https://fio.readthedocs.io/en/latest/fio_doc.html#) where I tested IOPS on many small files, and the RAM disk was 100x faster. Additionally, the depot runners seem to have more consistent performance than the ones provided by GitHub. --- .github/actions/setup-go/action.yaml | 29 ++++++++++++++++++++++++++-- .github/workflows/ci.yaml | 16 ++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 76b7c5d87d206..e13e019554a39 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -5,17 +5,42 @@ inputs: version: description: "The Go version to use." default: "1.24.2" + use-preinstalled-go: + description: "Whether to use preinstalled Go." + default: "false" + use-temp-cache-dirs: + description: "Whether to use temporary GOCACHE and GOMODCACHE directories." + default: "false" runs: using: "composite" steps: + - name: Override GOCACHE and GOMODCACHE + shell: bash + if: inputs.use-temp-cache-dirs == 'true' + run: | + # cd to another directory to ensure we're not inside a Go project. + # That'd trigger Go to download the toolchain for that project. + cd "$RUNNER_TEMP" + # RUNNER_TEMP should be backed by a RAM disk on Windows if + # coder/setup-ramdisk-action was used + export GOCACHE_DIR="$RUNNER_TEMP""\go-cache" + export GOMODCACHE_DIR="$RUNNER_TEMP""\go-mod-cache" + export GOPATH_DIR="$RUNNER_TEMP""\go-path" + mkdir -p "$GOCACHE_DIR" + mkdir -p "$GOMODCACHE_DIR" + mkdir -p "$GOPATH_DIR" + go env -w GOCACHE="$GOCACHE_DIR" + go env -w GOMODCACHE="$GOMODCACHE_DIR" + go env -w GOPATH="$GOPATH_DIR" + - name: Setup Go uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: ${{ inputs.version }} + go-version: ${{ inputs.use-preinstalled-go == 'false' && inputs.version || '' }} - name: Install gotestsum shell: bash - run: go install gotest.tools/gotestsum@latest + run: go install gotest.tools/gotestsum@3f7ff0ec4aeb6f95f5d67c998b71f272aa8a8b41 # v1.12.1 # It isn't necessary that we ever do this, but it helps # separate the "setup" from the "run" times. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb1260f2ee767..625e6a82673e1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -313,7 +313,7 @@ jobs: run: ./scripts/check_unstaged.sh test-go: - runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }} + runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'depot-windows-2022-16' || matrix.os }} needs: changes if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main' timeout-minutes: 20 @@ -326,10 +326,18 @@ jobs: - windows-2022 steps: - name: Harden Runner + # Harden Runner is only supported on Ubuntu runners. + if: runner.os == 'Linux' uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit + # Set up RAM disks to speed up the rest of the job. This action is in + # a separate repository to allow its use before actions/checkout. + - name: Setup RAM Disks + if: runner.os == 'Windows' + uses: coder/setup-ramdisk-action@79dacfe70c47ad6d6c0dd7f45412368802641439 + - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -337,6 +345,12 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go + with: + # Runners have Go baked-in and Go will automatically + # download the toolchain configured in go.mod, so we don't + # need to reinstall it. It's faster on Windows runners. + use-preinstalled-go: ${{ runner.os == 'Windows' }} + use-temp-cache-dirs: ${{ runner.os == 'Windows' }} - name: Setup Terraform uses: ./.github/actions/setup-tf From dc66dafc7cb0e7c4aab90f6396793543093d1d88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 12:37:32 +0000 Subject: [PATCH 023/158] chore: bump github.com/mark3labs/mcp-go from 0.23.1 to 0.25.0 (#17672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) from 0.23.1 to 0.25.0.
Release notes

Sourced from github.com/mark3labs/mcp-go's releases.

Release v0.25.0

What's Changed

New Contributors

Full Changelog: https://github.com/mark3labs/mcp-go/compare/v0.24.1...v0.25.0

Release v0.24.1

What's Changed

Full Changelog: https://github.com/mark3labs/mcp-go/compare/v0.24.0...v0.24.1

Release v0.24.0

What's Changed

New Contributors

... (truncated)

Commits
  • eadd702 Format
  • 4a1010e feat(server/sse): Add support for dynamic base paths (#214)
  • cfeb0ee feat: quick return tool-call request, send response via SSE in goroutine (#163)
  • d352118 feat(SSEServer): add WithAppendQueryToMessageEndpoint() (#136)
  • df5f67e [chore][client] Add ability to override the http.Client (#109)
  • ddb59dd fix: handle nil rawMessage in response parsing functions (#218)
  • f0a648b fix(SSE): only initialize http.Server when not set (#229)
  • ffc63d9 Add Accept header (#230)
  • ae96a68 fix: update doc comments to match Go conventions (#226)
  • df73667 fix(client/test): verify mock server binary exists after compilation (#215)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/mark3labs/mcp-go&package-manager=go_modules&previous-version=0.23.1&new-version=0.25.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ce41f23e02e05..7cc6f7c96f1fc 100644 --- a/go.mod +++ b/go.mod @@ -491,7 +491,7 @@ require ( github.com/coder/preview v0.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.8 - github.com/mark3labs/mcp-go v0.23.1 + github.com/mark3labs/mcp-go v0.25.0 github.com/openai/openai-go v0.1.0-beta.6 google.golang.org/genai v0.7.0 ) diff --git a/go.sum b/go.sum index 09bd945ec4898..2ce394881aa40 100644 --- a/go.sum +++ b/go.sum @@ -1501,8 +1501,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= -github.com/mark3labs/mcp-go v0.23.1 h1:RzTzZ5kJ+HxwnutKA4rll8N/pKV6Wh5dhCmiJUu5S9I= -github.com/mark3labs/mcp-go v0.23.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mark3labs/mcp-go v0.25.0 h1:UUpcMT3L5hIhuDy7aifj4Bphw4Pfx1Rf8mzMXDe8RQw= +github.com/mark3labs/mcp-go v0.25.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= From 1f569f71f8673812f1daadbcd6ffc263390bd68d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 12:37:45 +0000 Subject: [PATCH 024/158] chore: bump google.golang.org/api from 0.230.0 to 0.231.0 (#17671) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.230.0 to 0.231.0.
Release notes

Sourced from google.golang.org/api's releases.

v0.231.0

0.231.0 (2025-04-29)

Features

Changelog

Sourced from google.golang.org/api's changelog.

0.231.0 (2025-04-29)

Features

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.230.0&new-version=0.231.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 7cc6f7c96f1fc..2e67bf024cbd7 100644 --- a/go.mod +++ b/go.mod @@ -206,7 +206,7 @@ require ( golang.org/x/text v0.24.0 // indirect golang.org/x/tools v0.32.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da - google.golang.org/api v0.230.0 + google.golang.org/api v0.231.0 google.golang.org/grpc v1.72.0 google.golang.org/protobuf v1.36.6 gopkg.in/DataDog/dd-trace-go.v1 v1.72.1 @@ -219,7 +219,7 @@ require ( ) require ( - cloud.google.com/go/auth v0.16.0 // indirect + cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/logging v1.13.0 // indirect cloud.google.com/go/longrunning v0.6.4 // indirect @@ -466,7 +466,7 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2ce394881aa40..4f1481930c552 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= -cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= -cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= +cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= @@ -2478,8 +2478,8 @@ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/ google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= -google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= -google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= +google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= +google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2624,8 +2624,8 @@ google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2Z google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 h1:29cjnHVylHwTzH66WfFZqgSQgnxzvWE+jvBwpZCLRxY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= From b8137e7ca40a5638b7a79cdd6a6b3312f10924e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 12:54:22 +0000 Subject: [PATCH 025/158] chore: bump github.com/openai/openai-go from 0.1.0-beta.6 to 0.1.0-beta.10 (#17677) Bumps [github.com/openai/openai-go](https://github.com/openai/openai-go) from 0.1.0-beta.6 to 0.1.0-beta.10.
Release notes

Sourced from github.com/openai/openai-go's releases.

v0.1.0-beta.10

0.1.0-beta.10 (2025-04-14)

Full Changelog: v0.1.0-beta.9...v0.1.0-beta.10

Chores

  • internal: expand CI branch coverage (#369) (258dda8)
  • internal: reduce CI branch coverage (a2f7c03)

v0.1.0-beta.9

0.1.0-beta.9 (2025-04-09)

Full Changelog: v0.1.0-beta.8...v0.1.0-beta.9

Chores

v0.1.0-beta.8

0.1.0-beta.8 (2025-04-09)

Full Changelog: v0.1.0-beta.7...v0.1.0-beta.8

Features

Chores

v0.1.0-beta.7

0.1.0-beta.7 (2025-04-07)

Full Changelog: v0.1.0-beta.6...v0.1.0-beta.7

Features

  • client: make response union's AsAny method type safe (#352) (1252f56)

Chores

... (truncated)

Changelog

Sourced from github.com/openai/openai-go's changelog.

0.1.0-beta.10 (2025-04-14)

Full Changelog: v0.1.0-beta.9...v0.1.0-beta.10

Chores

  • internal: expand CI branch coverage (#369) (258dda8)
  • internal: reduce CI branch coverage (a2f7c03)

0.1.0-beta.9 (2025-04-09)

Full Changelog: v0.1.0-beta.8...v0.1.0-beta.9

Chores

0.1.0-beta.8 (2025-04-09)

Full Changelog: v0.1.0-beta.7...v0.1.0-beta.8

Features

Chores

0.1.0-beta.7 (2025-04-07)

Full Changelog: v0.1.0-beta.6...v0.1.0-beta.7

Features

  • client: make response union's AsAny method type safe (#352) (1252f56)

Chores

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/openai/openai-go&package-manager=go_modules&previous-version=0.1.0-beta.6&new-version=0.1.0-beta.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2e67bf024cbd7..cffcd99d06db8 100644 --- a/go.mod +++ b/go.mod @@ -492,7 +492,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/kylecarbs/aisdk-go v0.0.8 github.com/mark3labs/mcp-go v0.25.0 - github.com/openai/openai-go v0.1.0-beta.6 + github.com/openai/openai-go v0.1.0-beta.10 google.golang.org/genai v0.7.0 ) diff --git a/go.sum b/go.sum index 4f1481930c552..4c418e5fd2a02 100644 --- a/go.sum +++ b/go.sum @@ -1613,8 +1613,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/open-policy-agent/opa v1.3.0 h1:zVvQvQg+9+FuSRBt4LgKNzJwsWl/c85kD5jPozJTydY= github.com/open-policy-agent/opa v1.3.0/go.mod h1:t9iPNhaplD2qpiBqeudzJtEX3fKHK8zdA29oFvofAHo= -github.com/openai/openai-go v0.1.0-beta.6 h1:JquYDpprfrGnlKvQQg+apy9dQ8R9mIrm+wNvAPp6jCQ= -github.com/openai/openai-go v0.1.0-beta.6/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU= +github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= From 93a584b7c24cbf223ecef301c6edb0ace367c000 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 5 May 2025 11:10:50 -0300 Subject: [PATCH 026/158] fix: fix windsurf icon on light theme (#17679) --- site/src/modules/resources/AppLink/BaseIcon.tsx | 3 ++- site/src/theme/externalImages.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/modules/resources/AppLink/BaseIcon.tsx b/site/src/modules/resources/AppLink/BaseIcon.tsx index 1f2885a49a02f..b768facbdd482 100644 --- a/site/src/modules/resources/AppLink/BaseIcon.tsx +++ b/site/src/modules/resources/AppLink/BaseIcon.tsx @@ -1,5 +1,6 @@ import ComputerIcon from "@mui/icons-material/Computer"; import type { WorkspaceApp } from "api/typesGenerated"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; import type { FC } from "react"; interface BaseIconProps { @@ -9,7 +10,7 @@ interface BaseIconProps { export const BaseIcon: FC = ({ app, onIconPathError }) => { return app.icon ? ( - {`${app.display_name}([ ["/icon/rust.svg", "monochrome"], ["/icon/terminal.svg", "monochrome"], ["/icon/widgets.svg", "monochrome"], + ["/icon/windsurf.svg", "monochrome"], ]); From 4369765996bb39468d8df146b2b6825932353d86 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 6 May 2025 00:15:24 +1000 Subject: [PATCH 027/158] test: fix `TestWorkspaceAgentReportStats` flake (#17678) Closes https://github.com/coder/internal/issues/609. As seen in the below logs, the `last_used_at` time was updating, but just to the same value that it was on creation; `dbtime.Now` was called in quick succession. ``` t.go:106: 2025-05-05 12:11:54.166 [info] coderd.workspace_usage_tracker: updated workspaces last_used_at count=1 now="2025-05-05T12:11:54.161329Z" t.go:106: 2025-05-05 12:11:54.172 [debu] coderd: GET host=localhost:50422 path=/api/v2/workspaces/745b7ff3-47f2-4e1a-9452-85ea48ba5c46 proto=HTTP/1.1 remote_addr=127.0.0.1 start="2025-05-05T12:11:54.1669073Z" workspace_name=peaceful_faraday34 requestor_id=b2cf02ae-2181-480b-bb1f-95dc6acb6497 requestor_name=testuser requestor_email="" took=5.2105ms status_code=200 latency_ms=5 params_workspace=745b7ff3-47f2-4e1a-9452-85ea48ba5c46 request_id=7fd5ea90-af7b-4104-91c5-9ca64bc2d5e6 workspaceagentsrpc_test.go:70: Error Trace: C:/actions-runner/coder/coder/coderd/workspaceagentsrpc_test.go:70 Error: Should be true Test: TestWorkspaceAgentReportStats Messages: 2025-05-05 12:11:54.161329 +0000 UTC is not after 2025-05-05 12:11:54.161329 +0000 UTC ``` If we change the initial `LastUsedAt` time to be a time in the past, ticking with a `dbtime.Now` will always update it to a later value. If it never updates, the condition will still fail. --- coderd/workspaceagentsrpc_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 3f1f1a2b8a764..caea9b39c2f54 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -32,6 +32,7 @@ func TestWorkspaceAgentReportStats(t *testing.T) { r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + LastUsedAt: dbtime.Now().Add(-time.Minute), }).WithAgent().Do() ac := agentsdk.New(client.URL) From 6b4d3f83bc37a53778762c5e2f2a248d894ca004 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 5 May 2025 18:49:58 +0200 Subject: [PATCH 028/158] chore: reduce "Upload tests to datadog" times in CI (#17668) This PR speeds up the "Upload tests to datadog" step by downloading the `datadog-ci` binary directly from GitHub releases. Most of the time used to be spent in `npm install`, which consistently timed out on Windows after a minute. [Now it takes 3 seconds](https://github.com/coder/coder/actions/runs/14834976784/job/41644230049?pr=17668#step:10:1). I updated it to version v2.48.0 because v2.21.0 didn't have the artifacts for arm64 macOS. --- .github/actions/upload-datadog/action.yaml | 43 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/actions/upload-datadog/action.yaml b/.github/actions/upload-datadog/action.yaml index 11eecac636636..a2df93ab14b28 100644 --- a/.github/actions/upload-datadog/action.yaml +++ b/.github/actions/upload-datadog/action.yaml @@ -10,6 +10,8 @@ runs: steps: - shell: bash run: | + set -e + owner=${{ github.repository_owner }} echo "owner: $owner" if [[ $owner != "coder" ]]; then @@ -21,8 +23,45 @@ runs: echo "No API key provided, skipping..." exit 0 fi - npm install -g @datadog/datadog-ci@2.21.0 - datadog-ci junit upload --service coder ./gotests.xml \ + + BINARY_VERSION="v2.48.0" + BINARY_HASH_WINDOWS="b7bebb8212403fddb1563bae84ce5e69a70dac11e35eb07a00c9ef7ac9ed65ea" + BINARY_HASH_MACOS="e87c808638fddb21a87a5c4584b68ba802965eb0a593d43959c81f67246bd9eb" + BINARY_HASH_LINUX="5e700c465728fff8313e77c2d5ba1ce19a736168735137e1ddc7c6346ed48208" + + TMP_DIR=$(mktemp -d) + + if [[ "${{ runner.os }}" == "Windows" ]]; then + BINARY_PATH="${TMP_DIR}/datadog-ci.exe" + BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_win-x64" + elif [[ "${{ runner.os }}" == "macOS" ]]; then + BINARY_PATH="${TMP_DIR}/datadog-ci" + BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_darwin-arm64" + elif [[ "${{ runner.os }}" == "Linux" ]]; then + BINARY_PATH="${TMP_DIR}/datadog-ci" + BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_linux-x64" + else + echo "Unsupported OS: ${{ runner.os }}" + exit 1 + fi + + echo "Downloading DataDog CI binary version ${BINARY_VERSION} for ${{ runner.os }}..." + curl -sSL "$BINARY_URL" -o "$BINARY_PATH" + + if [[ "${{ runner.os }}" == "Windows" ]]; then + echo "$BINARY_HASH_WINDOWS $BINARY_PATH" | sha256sum --check + elif [[ "${{ runner.os }}" == "macOS" ]]; then + echo "$BINARY_HASH_MACOS $BINARY_PATH" | shasum -a 256 --check + elif [[ "${{ runner.os }}" == "Linux" ]]; then + echo "$BINARY_HASH_LINUX $BINARY_PATH" | sha256sum --check + fi + + # Make binary executable (not needed for Windows) + if [[ "${{ runner.os }}" != "Windows" ]]; then + chmod +x "$BINARY_PATH" + fi + + "$BINARY_PATH" junit upload --service coder ./gotests.xml \ --tags os:${{runner.os}} --tags runner_name:${{runner.name}} env: DATADOG_API_KEY: ${{ inputs.api-key }} From 4587082fcf84a92bda6413733371373acae2392f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 5 May 2025 22:13:39 +0100 Subject: [PATCH 029/158] chore: update design of External auth section of CreateWorkspacePage (#17683) contributes to coder/preview#59 Figma: https://www.figma.com/design/SMg6H8VKXnPSkE6h9KPoAD/UX-Presets?node-id=2180-2995&t=RL6ICIf6KUL5YUpB-1 This updates the design of the External authentication section of the create workspace page form for both the existing and the new experimental create workspace pages. Screenshot 2025-05-05 at 18 15 28 --- .../CreateWorkspacePage.test.tsx | 2 +- .../CreateWorkspacePageViewExperimental.tsx | 6 +- .../ExternalAuthButton.tsx | 130 ++++++++++-------- 3 files changed, 73 insertions(+), 65 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index b24542b34021d..64deba2116fb1 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -209,7 +209,7 @@ describe("CreateWorkspacePage", () => { .mockResolvedValue([MockTemplateVersionExternalAuthGithubAuthenticated]); await screen.findByText( - "Authenticated with GitHub", + "Authenticated", {}, { interval: 500, timeout: 5000 }, ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 1a07596854f8d..6751961e3cb2e 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -304,7 +304,7 @@ export const CreateWorkspacePageViewExperimental: FC<
{Boolean(error) && } @@ -397,14 +397,14 @@ export const CreateWorkspacePageViewExperimental: FC< {externalAuth && externalAuth.length > 0 && (
-

+

External Authentication

This template uses external services for authentication.

-
+
{Boolean(error) && !hasAllRequiredExternalAuth && ( To create a workspace using this template, please connect to diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx index 427c62b7bdf93..9a647b507947e 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx @@ -1,11 +1,15 @@ -import ReplayIcon from "@mui/icons-material/Replay"; -import LoadingButton from "@mui/lab/LoadingButton"; -import Button from "@mui/material/Button"; -import Tooltip from "@mui/material/Tooltip"; -import { visuallyHidden } from "@mui/utils"; import type { TemplateVersionExternalAuth } from "api/typesGenerated"; +import { Badge } from "components/Badge/Badge"; +import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; -import { Pill } from "components/Pill/Pill"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { Check, Redo } from "lucide-react"; import type { FC } from "react"; export interface ExternalAuthButtonProps { @@ -24,62 +28,66 @@ export const ExternalAuthButton: FC = ({ error, }) => { return ( - <> -
- - ) - } - disabled={auth.authenticated} - onClick={() => { - window.open( - auth.authenticate_url, - "_blank", - "width=900,height=600", - ); - onStartPolling(); - }} - > - {auth.authenticated ? ( - `Authenticated with ${auth.display_name}` - ) : ( - <> - Login with {auth.display_name} - {!auth.optional && ( - - Required - - )} - - )} - +
+ + {auth.display_icon && ( + + )} +

{auth.display_name}

+ {!auth.optional && ( + + Required + + )} +
+ + + {auth.authenticated ? ( + <> + +

+ Authenticated +

+ + ) : ( + + )} - {displayRetry && ( - - - + {displayRetry && !auth.authenticated && ( + + + + + + + Retry login with {auth.display_name} + + + )} -
- + +
); }; From ec003b7cf9c2c041fd401dc7d662084d280b9be5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 May 2025 11:40:31 +0100 Subject: [PATCH 030/158] fix: update default value handling for dynamic defaults (#17609) resolves coder/preview#102 --- .../DynamicParameter/DynamicParameter.tsx | 89 +++++++++++-------- .../CreateWorkspacePageViewExperimental.tsx | 20 +++-- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index d023bbcf4446b..9ec69158c4e84 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -32,7 +32,7 @@ import { TooltipTrigger, } from "components/Tooltip/Tooltip"; import { Info, Settings, TriangleAlert } from "lucide-react"; -import { type FC, useId } from "react"; +import { type FC, useEffect, useId, useState } from "react"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; @@ -164,14 +164,18 @@ const ParameterField: FC = ({ id, }) => { const value = validValue(parameter.value); - const defaultValue = validValue(parameter.default_value); + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); switch (parameter.form_type) { case "dropdown": return (