From 8ea956fc115c221f198dd2c54538c93fc03c91cf Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 31 Mar 2025 10:55:44 -0400 Subject: [PATCH 001/457] feat: add app status tracking to the backend (#17163) This does ~95% of the backend work required to integrate the AI work. Most left to integrate from the tasks branch is just frontend, which will be a lot smaller I believe. The real difference between this branch and that one is the abstraction -- this now attaches statuses to apps, and returns the latest status reported as part of a workspace. This change enables us to have a similar UX to in the tasks branch, but for agents other than Claude Code as well. Any app can report status now. --- cli/testdata/coder_list_--output_json.golden | 1 + coderd/apidoc/docs.go | 127 ++++++++++ coderd/apidoc/swagger.json | 117 +++++++++ coderd/coderd.go | 1 + coderd/database/db2sdk/db2sdk.go | 33 ++- coderd/database/dbauthz/dbauthz.go | 21 ++ coderd/database/dbauthz/dbauthz_test.go | 13 + coderd/database/dbmem/dbmem.go | 69 ++++++ coderd/database/dbmetrics/querymetrics.go | 21 ++ coderd/database/dbmock/dbmock.go | 45 ++++ coderd/database/dump.sql | 33 +++ coderd/database/foreign_key_constraint.go | 3 + .../000313_workspace_app_statuses.down.sql | 3 + .../000313_workspace_app_statuses.up.sql | 28 +++ .../000313_workspace_app_statuses.up.sql | 19 ++ coderd/database/models.go | 74 ++++++ coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 128 ++++++++++ coderd/database/queries/workspaceapps.sql | 15 ++ coderd/database/unique_constraint.go | 1 + coderd/workspaceagents.go | 93 ++++++- coderd/workspaceagents_test.go | 40 +++ coderd/workspacebuilds.go | 27 ++- coderd/workspaces.go | 87 ++++++- coderd/wspubsub/wspubsub.go | 1 + codersdk/agentsdk/agentsdk.go | 22 ++ codersdk/workspaceapps.go | 30 +++ codersdk/workspaces.go | 43 ++-- docs/reference/api/agents.md | 72 ++++++ docs/reference/api/builds.md | 112 +++++++++ docs/reference/api/schemas.md | 229 +++++++++++++++--- docs/reference/api/templates.md | 56 +++++ docs/reference/api/workspaces.md | 143 +++++++++++ site/src/api/typesGenerated.ts | 25 ++ site/src/testHelpers/entities.ts | 2 + 35 files changed, 1668 insertions(+), 69 deletions(-) create mode 100644 coderd/database/migrations/000313_workspace_app_statuses.down.sql create mode 100644 coderd/database/migrations/000313_workspace_app_statuses.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 4b308a9468b6f..ac9bcc2153668 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -69,6 +69,7 @@ "most_recently_seen": null } }, + "latest_app_status": null, "outdated": false, "name": "test-workspace", "autostart_schedule": "CRON_TZ=US/Central 30 9 * * 1-5", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e553f66d7a9a5..134031a2fa5f0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8057,6 +8057,45 @@ const docTemplate = `{ } } }, + "/workspaceagents/me/app-status": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Patch workspace agent app status", + "operationId": "patch-workspace-agent-app-status", + "parameters": [ + { + "description": "app status", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchAppStatus" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -10245,6 +10284,29 @@ const docTemplate = `{ } } }, + "agentsdk.PatchAppStatus": { + "type": "object", + "properties": { + "app_slug": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "type": "string" + } + } + }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -16273,6 +16335,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "latest_app_status": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + }, "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, @@ -16872,6 +16937,13 @@ const docTemplate = `{ "description": "Slug is a unique identifier within the agent.", "type": "string" }, + "statuses": { + "description": "Statuses is a list of statuses for the app.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + } + }, "subdomain": { "description": "Subdomain denotes whether the app should be accessed via a path on the\n` + "`" + `coder server` + "`" + ` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" @@ -16925,6 +16997,61 @@ const docTemplate = `{ "WorkspaceAppSharingLevelPublic" ] }, + "codersdk.WorkspaceAppStatus": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "description": "Icon is an external URL to an icon that will be rendered in the UI.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "description": "URI is the URI of the resource that the status is for.\ne.g. https://github.com/org/repo/pull/123\ne.g. file:///path/to/file", + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAppStatusState": { + "type": "string", + "enum": [ + "working", + "complete", + "failure" + ], + "x-enum-varnames": [ + "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateComplete", + "WorkspaceAppStatusStateFailure" + ] + }, "codersdk.WorkspaceBuild": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9765d79218c5e..66821355e7387 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7122,6 +7122,39 @@ } } }, + "/workspaceagents/me/app-status": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Patch workspace agent app status", + "operationId": "patch-workspace-agent-app-status", + "parameters": [ + { + "description": "app status", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchAppStatus" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -9080,6 +9113,29 @@ } } }, + "agentsdk.PatchAppStatus": { + "type": "object", + "properties": { + "app_slug": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "type": "string" + } + } + }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -14819,6 +14875,9 @@ "type": "string", "format": "date-time" }, + "latest_app_status": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + }, "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, @@ -15392,6 +15451,13 @@ "description": "Slug is a unique identifier within the agent.", "type": "string" }, + "statuses": { + "description": "Statuses is a list of statuses for the app.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + } + }, "subdomain": { "description": "Subdomain denotes whether the app should be accessed via a path on the\n`coder server` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" @@ -15433,6 +15499,57 @@ "WorkspaceAppSharingLevelPublic" ] }, + "codersdk.WorkspaceAppStatus": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "description": "Icon is an external URL to an icon that will be rendered in the UI.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "description": "URI is the URI of the resource that the status is for.\ne.g. https://github.com/org/repo/pull/123\ne.g. file:///path/to/file", + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAppStatusState": { + "type": "string", + "enum": ["working", "complete", "failure"], + "x-enum-varnames": [ + "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateComplete", + "WorkspaceAppStatusStateFailure" + ] + }, "codersdk.WorkspaceBuild": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 20982de70a741..1eefd15a8d655 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1228,6 +1228,7 @@ func New(options *Options) *API { })) r.Get("/rpc", api.workspaceAgentRPC) r.Patch("/logs", api.patchWorkspaceAgentLogs) + r.Patch("/app-status", api.patchWorkspaceAgentAppStatus) // Deprecated: Required to support legacy agents r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/external-auth", api.workspaceAgentsExternalAuth) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 41691c5a1d3f1..89676b1d94d46 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -487,7 +487,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa }.String() } -func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp { +func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp { sort.Slice(dbApps, func(i, j int) bool { if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder { return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder @@ -498,8 +498,14 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa return dbApps[i].Slug < dbApps[j].Slug }) + statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{} + for _, status := range statuses { + statusesByAppID[status.AppID] = append(statusesByAppID[status.AppID], status) + } + apps := make([]codersdk.WorkspaceApp, 0) for _, dbApp := range dbApps { + statuses := statusesByAppID[dbApp.ID] apps = append(apps, codersdk.WorkspaceApp{ ID: dbApp.ID, URL: dbApp.Url.String, @@ -516,14 +522,33 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa Interval: dbApp.HealthcheckInterval, Threshold: dbApp.HealthcheckThreshold, }, - Health: codersdk.WorkspaceAppHealth(dbApp.Health), - Hidden: dbApp.Hidden, - OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn), + Health: codersdk.WorkspaceAppHealth(dbApp.Health), + Hidden: dbApp.Hidden, + OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn), + Statuses: WorkspaceAppStatuses(statuses), }) } return apps } +func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus { + return List(statuses, WorkspaceAppStatus) +} + +func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus { + return codersdk.WorkspaceAppStatus{ + ID: status.ID, + CreatedAt: status.CreatedAt, + AgentID: status.AgentID, + AppID: status.AppID, + NeedsUserAttention: status.NeedsUserAttention, + URI: status.Uri.String, + Icon: status.Icon.String, + Message: status.Message, + State: codersdk.WorkspaceAppStatusState(status.State), + } +} + func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon { result := codersdk.ProvisionerDaemon{ ID: dbDaemon.ID, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 87778c66d3dab..7ab078d32ad4f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1840,6 +1840,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab return q.db.GetLatestCryptoKeyByFeature(ctx, feature) } +func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) +} + func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { if _, err := q.GetWorkspaceByID(ctx, workspaceID); err != nil { return database.WorkspaceBuild{}, err @@ -2854,6 +2861,13 @@ func (q *querier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg datab return q.db.GetWorkspaceAppByAgentIDAndSlug(ctx, arg) } +func (q *querier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetWorkspaceAppStatusesByAppIDs(ctx, ids) +} + func (q *querier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { if _, err := q.GetWorkspaceByAgentID(ctx, agentID); err != nil { return nil, err @@ -3547,6 +3561,13 @@ func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.Inse return q.db.InsertWorkspaceAppStats(ctx, arg) } +func (q *querier) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { + return database.WorkspaceAppStatus{}, err + } + return q.db.InsertWorkspaceAppStatus(ctx, arg) +} + func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 70c2a33443a16..cdc1c8e9ca197 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3706,6 +3706,12 @@ func (s *MethodTestSuite) TestSystemFunctions() { LoginType: database.LoginTypeGithub, }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) })) + s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { + check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAppStatusesByAppIDs", s.Subtest(func(db database.Store, check *expects) { + check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) @@ -4135,6 +4141,13 @@ func (s *MethodTestSuite) TestSystemFunctions() { Options: json.RawMessage("{}"), }).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) + s.Run("InsertWorkspaceAppStatus", s.Subtest(func(db database.Store, check *expects) { + dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + check.Args(database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + State: "working", + }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) s.Run("InsertWorkspaceResource", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) check.Args(database.InsertWorkspaceResourceParams{ diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 46f2de5a5820e..87275b1051efe 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -259,6 +259,7 @@ type data struct { workspaceAgentVolumeResourceMonitors []database.WorkspaceAgentVolumeResourceMonitor workspaceAgentDevcontainers []database.WorkspaceAgentDevcontainer workspaceApps []database.WorkspaceApp + workspaceAppStatuses []database.WorkspaceAppStatus workspaceAppAuditSessions []database.WorkspaceAppAuditSession workspaceAppStatsLastInsertID int64 workspaceAppStats []database.WorkspaceAppStat @@ -3697,6 +3698,34 @@ func (q *FakeQuerier) GetLatestCryptoKeyByFeature(_ context.Context, feature dat return latestKey, nil } +func (q *FakeQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + // Map to track latest status per workspace ID + latestByWorkspace := make(map[uuid.UUID]database.WorkspaceAppStatus) + + // Find latest status for each workspace ID + for _, appStatus := range q.workspaceAppStatuses { + if !slices.Contains(ids, appStatus.WorkspaceID) { + continue + } + + current, exists := latestByWorkspace[appStatus.WorkspaceID] + if !exists || appStatus.CreatedAt.After(current.CreatedAt) { + latestByWorkspace[appStatus.WorkspaceID] = appStatus + } + } + + // Convert map to slice + appStatuses := make([]database.WorkspaceAppStatus, 0, len(latestByWorkspace)) + for _, status := range latestByWorkspace { + appStatuses = append(appStatuses, status) + } + + return appStatuses, nil +} + func (q *FakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -7488,6 +7517,21 @@ func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg d return q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, arg) } +func (q *FakeQuerier) GetWorkspaceAppStatusesByAppIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + statuses := make([]database.WorkspaceAppStatus, 0) + for _, status := range q.workspaceAppStatuses { + for _, id := range ids { + if status.AppID == id { + statuses = append(statuses, status) + } + } + } + return statuses, nil +} + func (q *FakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) ([]database.WorkspaceApp, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -9584,6 +9628,31 @@ InsertWorkspaceAppStatsLoop: return nil } +func (q *FakeQuerier) InsertWorkspaceAppStatus(_ context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceAppStatus{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + status := database.WorkspaceAppStatus{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + WorkspaceID: arg.WorkspaceID, + AgentID: arg.AgentID, + AppID: arg.AppID, + NeedsUserAttention: arg.NeedsUserAttention, + State: arg.State, + Message: arg.Message, + Uri: arg.Uri, + Icon: arg.Icon, + } + q.workspaceAppStatuses = append(q.workspaceAppStatuses, status) + return status, nil +} + func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.InsertWorkspaceBuildParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 05c0418c77acd..91cdf641c3446 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -858,6 +858,13 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat return r0, r1 } +func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) + m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusesByWorkspaceIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { start := time.Now() build, err := m.s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) @@ -1670,6 +1677,13 @@ func (m queryMetricsStore) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, return app, err } +func (m queryMetricsStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceAppStatusesByAppIDs(ctx, ids) + m.queryLatencies.WithLabelValues("GetWorkspaceAppStatusesByAppIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { start := time.Now() apps, err := m.s.GetWorkspaceAppsByAgentID(ctx, agentID) @@ -2265,6 +2279,13 @@ func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg data return r0 } +func (m queryMetricsStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.InsertWorkspaceAppStatus(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAppStatus").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { start := time.Now() err := m.s.InsertWorkspaceBuild(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c5a5db20a9e90..109462e5f1996 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1729,6 +1729,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature) } +// GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method. +func (m *MockStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", ctx, ids) + ret0, _ := ret[0].([]database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestWorkspaceAppStatusesByWorkspaceIDs indicates an expected call of GetLatestWorkspaceAppStatusesByWorkspaceIDs. +func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusesByWorkspaceIDs), ctx, ids) +} + // GetLatestWorkspaceBuildByWorkspaceID mocks base method. func (m *MockStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { m.ctrl.T.Helper() @@ -3499,6 +3514,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAppByAgentIDAndSlug(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppByAgentIDAndSlug", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppByAgentIDAndSlug), ctx, arg) } +// GetWorkspaceAppStatusesByAppIDs mocks base method. +func (m *MockStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceAppStatusesByAppIDs", ctx, ids) + ret0, _ := ret[0].([]database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceAppStatusesByAppIDs indicates an expected call of GetWorkspaceAppStatusesByAppIDs. +func (mr *MockStoreMockRecorder) GetWorkspaceAppStatusesByAppIDs(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppStatusesByAppIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppStatusesByAppIDs), ctx, ids) +} + // GetWorkspaceAppsByAgentID mocks base method. func (m *MockStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { m.ctrl.T.Helper() @@ -4776,6 +4806,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAppStats(ctx, arg any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStats), ctx, arg) } +// InsertWorkspaceAppStatus mocks base method. +func (m *MockStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAppStatus", ctx, arg) + ret0, _ := ret[0].(database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertWorkspaceAppStatus indicates an expected call of InsertWorkspaceAppStatus. +func (mr *MockStoreMockRecorder) InsertWorkspaceAppStatus(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStatus", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStatus), ctx, arg) +} + // InsertWorkspaceBuild mocks base method. func (m *MockStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b7908a8880107..b4207c41deff2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -293,6 +293,12 @@ CREATE TYPE workspace_app_open_in AS ENUM ( 'slim-window' ); +CREATE TYPE workspace_app_status_state AS ENUM ( + 'working', + 'complete', + 'failure' +); + CREATE TYPE workspace_transition AS ENUM ( 'start', 'stop', @@ -1896,6 +1902,19 @@ CREATE SEQUENCE workspace_app_stats_id_seq ALTER SEQUENCE workspace_app_stats_id_seq OWNED BY workspace_app_stats.id; +CREATE TABLE workspace_app_statuses ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + agent_id uuid NOT NULL, + app_id uuid NOT NULL, + workspace_id uuid NOT NULL, + state workspace_app_status_state NOT NULL, + needs_user_attention boolean NOT NULL, + message text NOT NULL, + uri text, + icon text +); + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -2359,6 +2378,9 @@ ALTER TABLE ONLY workspace_app_stats ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); @@ -2451,6 +2473,8 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); +CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses USING btree (workspace_id, created_at DESC); + CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); @@ -2802,6 +2826,15 @@ ALTER TABLE ONLY workspace_app_stats ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); + +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 7dab8519a897c..3f5ce963e6fdb 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -73,6 +73,9 @@ const ( ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ForeignKeyWorkspaceAppStatusesAgentID ForeignKeyConstraint = "workspace_app_statuses_agent_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + ForeignKeyWorkspaceAppStatusesAppID ForeignKeyConstraint = "workspace_app_statuses_app_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); + ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000313_workspace_app_statuses.down.sql b/coderd/database/migrations/000313_workspace_app_statuses.down.sql new file mode 100644 index 0000000000000..59d38cc8bc21c --- /dev/null +++ b/coderd/database/migrations/000313_workspace_app_statuses.down.sql @@ -0,0 +1,3 @@ +DROP TABLE workspace_app_statuses; + +DROP TYPE workspace_app_status_state; diff --git a/coderd/database/migrations/000313_workspace_app_statuses.up.sql b/coderd/database/migrations/000313_workspace_app_statuses.up.sql new file mode 100644 index 0000000000000..4bbeb64efc231 --- /dev/null +++ b/coderd/database/migrations/000313_workspace_app_statuses.up.sql @@ -0,0 +1,28 @@ +CREATE TYPE workspace_app_status_state AS ENUM ('working', 'complete', 'failure'); + +-- Workspace app statuses allow agents to report statuses per-app in the UI. +CREATE TABLE workspace_app_statuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- The agent that the status is for. + agent_id UUID NOT NULL REFERENCES workspace_agents(id), + -- The slug of the app that the status is for. This will be used + -- to reference the app in the UI - with an icon. + app_id UUID NOT NULL REFERENCES workspace_apps(id), + -- workspace_id is the workspace that the status is for. + workspace_id UUID NOT NULL REFERENCES workspaces(id), + -- The status determines how the status is displayed in the UI. + state workspace_app_status_state NOT NULL, + -- Whether the status needs user attention. + needs_user_attention BOOLEAN NOT NULL, + -- The message is the main text that will be displayed in the UI. + message TEXT NOT NULL, + -- The URI of the resource that the status is for. + -- e.g. https://github.com/org/repo/pull/123 + -- e.g. file:///path/to/file + uri TEXT, + -- Icon is an external URL to an icon that will be rendered in the UI. + icon TEXT +); + +CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses(workspace_id, created_at DESC); diff --git a/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql b/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql new file mode 100644 index 0000000000000..c36f5c66c3dd0 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql @@ -0,0 +1,19 @@ +INSERT INTO workspace_app_statuses ( + id, + created_at, + agent_id, + app_id, + workspace_id, + state, + needs_user_attention, + message +) VALUES ( + gen_random_uuid(), + NOW(), + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + '36b65d0c-042b-4653-863a-655ee739861c', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + 'working', + false, + 'Creating SQL queries for test data!' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 634cb6b59a41a..4339191f7afa2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2414,6 +2414,67 @@ func AllWorkspaceAppOpenInValues() []WorkspaceAppOpenIn { } } +type WorkspaceAppStatusState string + +const ( + WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" + WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" +) + +func (e *WorkspaceAppStatusState) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WorkspaceAppStatusState(s) + case string: + *e = WorkspaceAppStatusState(s) + default: + return fmt.Errorf("unsupported scan type for WorkspaceAppStatusState: %T", src) + } + return nil +} + +type NullWorkspaceAppStatusState struct { + WorkspaceAppStatusState WorkspaceAppStatusState `json:"workspace_app_status_state"` + Valid bool `json:"valid"` // Valid is true if WorkspaceAppStatusState is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWorkspaceAppStatusState) Scan(value interface{}) error { + if value == nil { + ns.WorkspaceAppStatusState, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WorkspaceAppStatusState.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWorkspaceAppStatusState) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WorkspaceAppStatusState), nil +} + +func (e WorkspaceAppStatusState) Valid() bool { + switch e { + case WorkspaceAppStatusStateWorking, + WorkspaceAppStatusStateComplete, + WorkspaceAppStatusStateFailure: + return true + } + return false +} + +func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState { + return []WorkspaceAppStatusState{ + WorkspaceAppStatusStateWorking, + WorkspaceAppStatusStateComplete, + WorkspaceAppStatusStateFailure, + } +} + type WorkspaceTransition string const ( @@ -3515,6 +3576,19 @@ type WorkspaceAppStat struct { Requests int32 `db:"requests" json:"requests"` } +type WorkspaceAppStatus struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"` + Message string `db:"message" json:"message"` + Uri sql.NullString `db:"uri" json:"uri"` + Icon sql.NullString `db:"icon" json:"icon"` +} + // Joins in the username + avatar url of the initiated by user. type WorkspaceBuild struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 892582c1201e5..59b53ac5950d8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -198,6 +198,7 @@ type sqlcQuerier interface { GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) GetLastUpdateCheck(ctx context.Context) (string, error) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) + GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) @@ -369,6 +370,7 @@ type sqlcQuerier interface { GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) + GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) @@ -474,6 +476,7 @@ type sqlcQuerier interface { InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error + InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error InsertWorkspaceModule(ctx context.Context, arg InsertWorkspaceModuleParams) (WorkspaceModule, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 221c9f2c51df6..59d717531324a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15108,6 +15108,48 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups return new_or_stale, err } +const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many +SELECT DISTINCT ON (workspace_id) + id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon +FROM workspace_app_statuses +WHERE workspace_id = ANY($1 :: uuid[]) +ORDER BY workspace_id, created_at DESC +` + +func (q *sqlQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) { + rows, err := q.db.QueryContext(ctx, getLatestWorkspaceAppStatusesByWorkspaceIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAppStatus + for rows.Next() { + var i WorkspaceAppStatus + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ); 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 getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 AND slug = $2 ` @@ -15143,6 +15185,44 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg Ge return i, err } +const getWorkspaceAppStatusesByAppIDs = `-- name: GetWorkspaceAppStatusesByAppIDs :many +SELECT id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon FROM workspace_app_statuses WHERE app_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAppStatusesByAppIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAppStatus + for rows.Next() { + var i WorkspaceAppStatus + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ); 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 getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 ORDER BY slug ASC ` @@ -15373,6 +15453,54 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace return i, err } +const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon +` + +type InsertWorkspaceAppStatusParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + Message string `db:"message" json:"message"` + NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"` + Uri sql.NullString `db:"uri" json:"uri"` + Icon sql.NullString `db:"icon" json:"icon"` +} + +func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, + arg.ID, + arg.CreatedAt, + arg.WorkspaceID, + arg.AgentID, + arg.AppID, + arg.State, + arg.Message, + arg.NeedsUserAttention, + arg.Uri, + arg.Icon, + ) + var i WorkspaceAppStatus + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ) + return i, err +} + const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec UPDATE workspace_apps diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 2f431268a4c41..e402ee1402922 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -42,3 +42,18 @@ SET health = $2 WHERE id = $1; + +-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING *; + +-- name: GetWorkspaceAppStatusesByAppIDs :many +SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]); + +-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many +SELECT DISTINCT ON (workspace_id) + * +FROM workspace_app_statuses +WHERE workspace_id = ANY(@ids :: uuid[]) +ORDER BY workspace_id, created_at DESC; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9318e1af1678b..d9f8ce275bfdf 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -86,6 +86,7 @@ const ( UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); + UniqueWorkspaceAppStatusesPkey UniqueConstraint = "workspace_app_statuses_pkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c76d029f43d7c..1573ef70eb443 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -93,6 +93,20 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { return } + appIDs := []uuid.UUID{} + for _, app := range dbApps { + appIDs = append(appIDs, app.ID) + } + // nolint:gocritic // This is a system restricted operation. + statuses, err := api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace app statuses.", + Detail: err.Error(), + }) + return + } + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -127,7 +141,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { } apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, db2sdk.Apps(dbApps, workspaceAgent, owner.Username, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, db2sdk.Apps(dbApps, statuses, workspaceAgent, owner.Username, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { @@ -300,6 +314,81 @@ func (api *API) patchWorkspaceAgentLogs(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, nil) } +// @Summary Patch workspace agent app status +// @ID patch-workspace-agent-app-status +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Agents +// @Param request body agentsdk.PatchAppStatus true "app status" +// @Success 200 {object} codersdk.Response +// @Router /workspaceagents/me/app-status [patch] +func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgent(r) + + var req agentsdk.PatchAppStatus + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + app, err := api.Database.GetWorkspaceAppByAgentIDAndSlug(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{ + AgentID: workspaceAgent.ID, + Slug: req.AppSlug, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace app.", + Detail: err.Error(), + }) + return + } + + workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + }) + return + } + + // nolint:gocritic // This is a system restricted operation. + _, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + WorkspaceID: workspace.ID, + AgentID: workspaceAgent.ID, + AppID: app.ID, + State: database.WorkspaceAppStatusState(req.State), + Message: req.Message, + Uri: sql.NullString{ + String: req.URI, + Valid: req.URI != "", + }, + Icon: sql.NullString{ + String: req.Icon, + Valid: req.Icon != "", + }, + NeedsUserAttention: req.NeedsUserAttention, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert workspace app status.", + Detail: err.Error(), + }) + return + } + + api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindAgentAppStatusUpdate, + WorkspaceID: workspace.ID, + AgentID: &workspaceAgent.ID, + }) + + httpapi.Write(ctx, rw, http.StatusOK, nil) +} + // workspaceAgentLogs returns the logs associated with a workspace agent // // @Summary Get logs by workspace agent @@ -1054,7 +1143,7 @@ func (api *API) workspaceAgentPostLogSource(rw http.ResponseWriter, r *http.Requ // convertProvisionedApps converts applications that are in the middle of provisioning process. // It means that they may not have an agent or workspace assigned (dry-run job). func convertProvisionedApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { - return db2sdk.Apps(dbApps, database.WorkspaceAgent{}, "", database.Workspace{}) + return db2sdk.Apps(dbApps, []database.WorkspaceAppStatus{}, database.WorkspaceAgent{}, "", database.Workspace{}) } func convertLogSources(dbLogSources []database.WorkspaceAgentLogSource) []codersdk.WorkspaceAgentLogSource { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c45cc8c2a6c2f..186c66bfd6f8e 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -339,6 +339,46 @@ func TestWorkspaceAgentLogs(t *testing.T) { }) } +func TestWorkspaceAgentAppStatus(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user2.ID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Apps = []*proto.App{ + { + Slug: "vscode", + }, + } + return a + }).Do() + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(r.AgentToken) + err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + Message: "testing", + URI: "https://example.com", + Icon: "https://example.com/icon.png", + State: codersdk.WorkspaceAppStatusStateComplete, + }) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + agent, err := client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID) + require.NoError(t, err) + require.Len(t, agent.Apps[0].Statuses, 1) + }) +} + func TestWorkspaceAgentConnectRPC(t *testing.T) { t.Parallel() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index f159d4a4e8bf1..7bd32e00cd830 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -84,6 +84,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions[0], @@ -202,6 +203,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions, @@ -292,6 +294,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions[0], @@ -432,6 +435,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, []database.WorkspaceApp{}, + []database.WorkspaceAppStatus{}, []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, @@ -764,6 +768,7 @@ type workspaceBuildsData struct { metadata []database.WorkspaceResourceMetadatum agents []database.WorkspaceAgent apps []database.WorkspaceApp + appStatuses []database.WorkspaceAppStatus scripts []database.WorkspaceAgentScript logSources []database.WorkspaceAgentLogSource provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow @@ -874,6 +879,17 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab return workspaceBuildsData{}, err } + appIDs := make([]uuid.UUID, 0) + for _, app := range apps { + appIDs = append(appIDs, app.ID) + } + + // nolint:gocritic // Getting workspace app statuses by app IDs is a system function. + statuses, err := api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get workspace app statuses: %w", err) + } + return workspaceBuildsData{ jobs: jobs, templateVersions: templateVersions, @@ -881,6 +897,7 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab metadata: metadata, agents: agents, apps: apps, + appStatuses: statuses, scripts: scripts, logSources: logSources, provisionerDaemons: pendingJobProvisioners, @@ -895,6 +912,7 @@ func (api *API) convertWorkspaceBuilds( resourceMetadata []database.WorkspaceResourceMetadatum, resourceAgents []database.WorkspaceAgent, agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersions []database.TemplateVersion, @@ -937,6 +955,7 @@ func (api *API) convertWorkspaceBuilds( resourceMetadata, resourceAgents, agentApps, + agentAppStatuses, agentScripts, agentLogSources, templateVersion, @@ -960,6 +979,7 @@ func (api *API) convertWorkspaceBuild( resourceMetadata []database.WorkspaceResourceMetadatum, resourceAgents []database.WorkspaceAgent, agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, @@ -997,6 +1017,10 @@ func (api *API) convertWorkspaceBuild( provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) } matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) + statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{} + for _, status := range agentAppStatuses { + statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status) + } resources := resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) @@ -1018,9 +1042,10 @@ func (api *API) convertWorkspaceBuild( apps := appsByAgentID[agent.ID] scripts := scriptsByAgentID[agent.ID] + statuses := statusesByAgentID[agent.ID] logSources := logSourcesByAgentID[agent.ID] apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, agent, workspace.OwnerUsername, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d57481aa12f90..6b010b53020a3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -14,6 +14,7 @@ import ( "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/slog" @@ -102,12 +103,18 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -300,12 +307,18 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -731,6 +744,7 @@ func createWorkspace( []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, []database.WorkspaceApp{}, + []database.WorkspaceAppStatus{}, []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, @@ -750,6 +764,7 @@ func createWorkspace( apiBuild, template, api.Options.AllowWorkspaceRenames, + codersdk.WorkspaceAppStatus{}, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1234,12 +1249,18 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { aReq.New = newWorkspace + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1792,12 +1813,17 @@ func (api *API) watchWorkspace( return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { _ = sendEvent(codersdk.ServerSentEvent{ @@ -1908,6 +1934,7 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { type workspaceData struct { templates []database.Template builds []codersdk.WorkspaceBuild + appStatuses []codersdk.WorkspaceAppStatus allowRenames bool } @@ -1923,18 +1950,42 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa templateIDs = append(templateIDs, workspace.TemplateID) } - templates, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ - IDs: templateIDs, + var ( + templates []database.Template + builds []database.WorkspaceBuild + appStatuses []database.WorkspaceAppStatus + eg errgroup.Group + ) + eg.Go(func() (err error) { + templates, err = api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + IDs: templateIDs, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get templates: %w", err) + } + return nil }) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return workspaceData{}, xerrors.Errorf("get templates: %w", err) - } - - // This query must be run as system restricted to be efficient. - // nolint:gocritic - builds, err := api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return workspaceData{}, xerrors.Errorf("get workspace builds: %w", err) + eg.Go(func() (err error) { + // This query must be run as system restricted to be efficient. + // nolint:gocritic + builds, err = api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get workspace builds: %w", err) + } + return nil + }) + eg.Go(func() (err error) { + // This query must be run as system restricted to be efficient. + // nolint:gocritic + appStatuses, err = api.Database.GetLatestWorkspaceAppStatusesByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get workspace app statuses: %w", err) + } + return nil + }) + err := eg.Wait() + if err != nil { + return workspaceData{}, err } data, err := api.workspaceBuildsData(ctx, builds) @@ -1950,6 +2001,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions, @@ -1961,6 +2013,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa return workspaceData{ templates: templates, + appStatuses: db2sdk.WorkspaceAppStatuses(appStatuses), builds: apiBuilds, allowRenames: api.Options.AllowWorkspaceRenames, }, nil @@ -1975,6 +2028,10 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d for _, template := range data.templates { templateByID[template.ID] = template } + appStatusesByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceAppStatus{} + for _, appStatus := range data.appStatuses { + appStatusesByWorkspaceID[appStatus.WorkspaceID] = appStatus + } apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { @@ -1991,6 +2048,7 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d if !exists { continue } + appStatus := appStatusesByWorkspaceID[workspace.ID] w, err := convertWorkspace( requesterID, @@ -1998,6 +2056,7 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d build, template, data.allowRenames, + appStatus, ) if err != nil { return nil, xerrors.Errorf("convert workspace: %w", err) @@ -2014,6 +2073,7 @@ func convertWorkspace( workspaceBuild codersdk.WorkspaceBuild, template database.Template, allowRenames bool, + latestAppStatus codersdk.WorkspaceAppStatus, ) (codersdk.Workspace, error) { if requesterID == uuid.Nil { return codersdk.Workspace{}, xerrors.Errorf("developer error: requesterID cannot be uuid.Nil!") @@ -2057,6 +2117,10 @@ func convertWorkspace( // Only show favorite status if you own the workspace. requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite + appStatus := &latestAppStatus + if latestAppStatus.ID == uuid.Nil { + appStatus = nil + } return codersdk.Workspace{ ID: workspace.ID, CreatedAt: workspace.CreatedAt, @@ -2068,6 +2132,7 @@ func convertWorkspace( OrganizationName: workspace.OrganizationName, TemplateID: workspace.TemplateID, LatestBuild: workspaceBuild, + LatestAppStatus: appStatus, TemplateName: workspace.TemplateName, TemplateIcon: workspace.TemplateIcon, TemplateDisplayName: workspace.TemplateDisplayName, diff --git a/coderd/wspubsub/wspubsub.go b/coderd/wspubsub/wspubsub.go index 0326efa695304..1175ce5830292 100644 --- a/coderd/wspubsub/wspubsub.go +++ b/coderd/wspubsub/wspubsub.go @@ -55,6 +55,7 @@ const ( WorkspaceEventKindAgentFirstLogs WorkspaceEventKind = "agt_first_logs" WorkspaceEventKindAgentLogsOverflow WorkspaceEventKind = "agt_logs_overflow" WorkspaceEventKindAgentTimeout WorkspaceEventKind = "agt_timeout" + WorkspaceEventKindAgentAppStatusUpdate WorkspaceEventKind = "agt_app_status_update" ) func (w *WorkspaceEvent) Validate() error { diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index a6207f238fcac..4f7d0a8baef31 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -581,6 +581,28 @@ func (c *Client) PatchLogs(ctx context.Context, req PatchLogs) error { return nil } +// PatchAppStatus updates the status of a workspace app. +type PatchAppStatus struct { + AppSlug string `json:"app_slug"` + NeedsUserAttention bool `json:"needs_user_attention"` + State codersdk.WorkspaceAppStatusState `json:"state"` + Message string `json:"message"` + URI string `json:"uri"` + Icon string `json:"icon"` +} + +func (c *Client) PatchAppStatus(ctx context.Context, req PatchAppStatus) error { + res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/app-status", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return codersdk.ReadBodyAsError(res) + } + return nil +} + type PostLogSourceRequest struct { // ID is a unique identifier for the log source. // It is scoped to a workspace agent, and can be statically diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 25e45ac5eb305..ec5a7c4414f76 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -1,6 +1,8 @@ package codersdk import ( + "time" + "github.com/google/uuid" ) @@ -13,6 +15,14 @@ const ( WorkspaceAppHealthUnhealthy WorkspaceAppHealth = "unhealthy" ) +type WorkspaceAppStatusState string + +const ( + WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" + WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" +) + var MapWorkspaceAppHealths = map[WorkspaceAppHealth]struct{}{ WorkspaceAppHealthDisabled: {}, WorkspaceAppHealthInitializing: {}, @@ -75,6 +85,9 @@ type WorkspaceApp struct { Health WorkspaceAppHealth `json:"health"` Hidden bool `json:"hidden"` OpenIn WorkspaceAppOpenIn `json:"open_in"` + + // Statuses is a list of statuses for the app. + Statuses []WorkspaceAppStatus `json:"statuses"` } type Healthcheck struct { @@ -85,3 +98,20 @@ type Healthcheck struct { // Threshold specifies the number of consecutive failed health checks before returning "unhealthy". Threshold int32 `json:"threshold"` } + +type WorkspaceAppStatus struct { + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + AgentID uuid.UUID `json:"agent_id" format:"uuid"` + AppID uuid.UUID `json:"app_id" format:"uuid"` + State WorkspaceAppStatusState `json:"state"` + NeedsUserAttention bool `json:"needs_user_attention"` + Message string `json:"message"` + // URI is the URI of the resource that the status is for. + // e.g. https://github.com/org/repo/pull/123 + // e.g. file:///path/to/file + URI string `json:"uri"` + // Icon is an external URL to an icon that will be rendered in the UI. + Icon string `json:"icon"` +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index da3df12eb9364..f9377c1767451 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -26,27 +26,28 @@ const ( // Workspace is a deployment of a template. It references a specific // version and can be updated. type Workspace 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"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - OwnerName string `json:"owner_name"` - OwnerAvatarURL string `json:"owner_avatar_url"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OrganizationName string `json:"organization_name"` - TemplateID uuid.UUID `json:"template_id" format:"uuid"` - TemplateName string `json:"template_name"` - TemplateDisplayName string `json:"template_display_name"` - TemplateIcon string `json:"template_icon"` - TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` - TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` - TemplateRequireActiveVersion bool `json:"template_require_active_version"` - LatestBuild WorkspaceBuild `json:"latest_build"` - Outdated bool `json:"outdated"` - Name string `json:"name"` - AutostartSchedule *string `json:"autostart_schedule,omitempty"` - TTLMillis *int64 `json:"ttl_ms,omitempty"` - LastUsedAt time.Time `json:"last_used_at" format:"date-time"` + 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"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + OwnerName string `json:"owner_name"` + OwnerAvatarURL string `json:"owner_avatar_url"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OrganizationName string `json:"organization_name"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + TemplateName string `json:"template_name"` + TemplateDisplayName string `json:"template_display_name"` + TemplateIcon string `json:"template_icon"` + TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` + TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` + TemplateRequireActiveVersion bool `json:"template_require_active_version"` + LatestBuild WorkspaceBuild `json:"latest_build"` + LatestAppStatus *WorkspaceAppStatus `json:"latest_app_status"` + Outdated bool `json:"outdated"` + Name string `json:"name"` + AutostartSchedule *string `json:"autostart_schedule,omitempty"` + TTLMillis *int64 `json:"ttl_ms,omitempty"` + LastUsedAt time.Time `json:"last_used_at" format:"date-time"` // DeletingAt indicates the time at which the workspace will be permanently deleted. // A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index ec996e9f57d7d..8faba29cf7ba5 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -180,6 +180,64 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/google-instance-ide To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Patch workspace agent app status + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/workspaceagents/me/app-status \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /workspaceagents/me/app-status` + +> Body parameter + +```json +{ + "app_slug": "string", + "icon": "string", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------|----------|-------------| +| `body` | body | [agentsdk.PatchAppStatus](schemas.md#agentsdkpatchappstatus) | true | app status | + +### Example responses + +> 200 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace agent external auth ### Code samples @@ -455,6 +513,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 26f6df4a55b73..0bb4b2e5b0ef3 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -100,6 +100,20 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -314,6 +328,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -643,6 +671,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -770,6 +812,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -851,6 +904,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -970,6 +1026,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1257,6 +1327,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1440,6 +1524,17 @@ Status Code **200** | `»»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»»» agent_id` | string(uuid) | false | | | +| `»»»»» app_id` | string(uuid) | false | | | +| `»»»»» created_at` | string(date-time) | false | | | +| `»»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»»» id` | string(uuid) | false | | | +| `»»»»» message` | string | false | | | +| `»»»»» needs_user_attention` | boolean | false | | | +| `»»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»»» workspace_id` | string(uuid) | false | | | | `»»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -1544,6 +1639,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -1699,6 +1797,20 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 652c1274751e9..f8af45a5e6787 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -118,6 +118,30 @@ | `level` | [codersdk.LogLevel](#codersdkloglevel) | false | | | | `output` | string | false | | | +## agentsdk.PatchAppStatus + +```json +{ + "app_slug": "string", + "icon": "string", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------|----------------------------------------------------------------------|----------|--------------|-------------| +| `app_slug` | string | false | | | +| `icon` | string | false | | | +| `message` | string | false | | | +| `needs_user_attention` | boolean | false | | | +| `state` | [codersdk.WorkspaceAppStatusState](#codersdkworkspaceappstatusstate) | false | | | +| `uri` | string | false | | | + ## agentsdk.PatchLogs ```json @@ -7557,6 +7581,18 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -7631,6 +7667,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -7759,36 +7809,37 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------------------------------|--------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allow_renames` | boolean | false | | | -| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | -| `autostart_schedule` | string | false | | | -| `created_at` | string | false | | | -| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | -| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | -| `favorite` | boolean | false | | | -| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | -| `id` | string | false | | | -| `last_used_at` | string | false | | | -| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | -| `name` | string | false | | | -| `next_start_at` | string | false | | | -| `organization_id` | string | false | | | -| `organization_name` | string | false | | | -| `outdated` | boolean | false | | | -| `owner_avatar_url` | string | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `template_active_version_id` | string | false | | | -| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | -| `template_display_name` | string | false | | | -| `template_icon` | string | false | | | -| `template_id` | string | false | | | -| `template_name` | string | false | | | -| `template_require_active_version` | boolean | false | | | -| `ttl_ms` | integer | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------------------------|------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_renames` | boolean | false | | | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | +| `autostart_schedule` | string | false | | | +| `created_at` | string | false | | | +| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | +| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | +| `favorite` | boolean | false | | | +| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | +| `id` | string | false | | | +| `last_used_at` | string | false | | | +| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | | +| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | +| `name` | string | false | | | +| `next_start_at` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `outdated` | boolean | false | | | +| `owner_avatar_url` | string | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `template_active_version_id` | string | false | | | +| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | +| `template_display_name` | string | false | | | +| `template_icon` | string | false | | | +| `template_id` | string | false | | | +| `template_name` | string | false | | | +| `template_require_active_version` | boolean | false | | | +| `ttl_ms` | integer | false | | | +| `updated_at` | string | false | | | #### Enumerated Values @@ -7819,6 +7870,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8332,6 +8397,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8353,6 +8432,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `open_in` | [codersdk.WorkspaceAppOpenIn](#codersdkworkspaceappopenin) | false | | | | `sharing_level` | [codersdk.WorkspaceAppSharingLevel](#codersdkworkspaceappsharinglevel) | false | | | | `slug` | string | false | | Slug is a unique identifier within the agent. | +| `statuses` | array of [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | Statuses is a list of statuses for the app. | | `subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -8413,6 +8493,54 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `authenticated` | | `public` | +## codersdk.WorkspaceAppStatus + +```json +{ + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------|----------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------| +| `agent_id` | string | false | | | +| `app_id` | string | false | | | +| `created_at` | string | false | | | +| `icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `id` | string | false | | | +| `message` | string | false | | | +| `needs_user_attention` | boolean | false | | | +| `state` | [codersdk.WorkspaceAppStatusState](#codersdkworkspaceappstatusstate) | false | | | +| `uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `workspace_id` | string | false | | | + +## codersdk.WorkspaceAppStatusState + +```json +"working" +``` + +### Properties + +#### Enumerated Values + +| Value | +|------------| +| `working` | +| `complete` | +| `failure` | + ## codersdk.WorkspaceBuild ```json @@ -8490,6 +8618,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8890,6 +9032,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -9089,6 +9245,18 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -9159,6 +9327,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index ab8b4f1b7c131..b644affbbfc88 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2284,6 +2284,20 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -2411,6 +2425,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -2492,6 +2517,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -2777,6 +2805,20 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -2904,6 +2946,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -2985,6 +3038,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 18500158567ae..00400942d34db 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -67,6 +67,18 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -141,6 +153,20 @@ of the template will be used. "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -317,6 +343,18 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -391,6 +429,20 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -591,6 +643,18 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -665,6 +729,20 @@ of the template will be used. "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -844,6 +922,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -914,6 +1004,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1091,6 +1182,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -1165,6 +1268,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1457,6 +1574,18 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -1531,6 +1660,20 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b812bcb46bc03..ab8e58d4574f4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3060,6 +3060,7 @@ export interface Workspace { readonly template_active_version_id: string; readonly template_require_active_version: boolean; readonly latest_build: WorkspaceBuild; + readonly latest_app_status: WorkspaceAppStatus | null; readonly outdated: boolean; readonly name: string; readonly autostart_schedule?: string; @@ -3307,6 +3308,7 @@ export interface WorkspaceApp { readonly health: WorkspaceAppHealth; readonly hidden: boolean; readonly open_in: WorkspaceAppOpenIn; + readonly statuses: readonly WorkspaceAppStatus[]; } // From codersdk/workspaceapps.go @@ -3337,6 +3339,29 @@ export const WorkspaceAppSharingLevels: WorkspaceAppSharingLevel[] = [ "public", ]; +// From codersdk/workspaceapps.go +export interface WorkspaceAppStatus { + readonly id: string; + readonly created_at: string; + readonly workspace_id: string; + readonly agent_id: string; + readonly app_id: string; + readonly state: WorkspaceAppStatusState; + readonly needs_user_attention: boolean; + readonly message: string; + readonly uri: string; + readonly icon: string; +} + +// From codersdk/workspaceapps.go +export type WorkspaceAppStatusState = "complete" | "failure" | "working"; + +export const WorkspaceAppStatusStates: WorkspaceAppStatusState[] = [ + "complete", + "failure", + "working", +]; + // From codersdk/workspacebuilds.go export interface WorkspaceBuild { readonly id: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2efd3580c1f94..2efcccb941e45 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -913,6 +913,7 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = { }, hidden: false, open_in: "slim-window", + statuses: [], }; export const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { @@ -1371,6 +1372,7 @@ export const MockWorkspace: TypesGen.Workspace = { healthy: true, failing_agents: [], }, + latest_app_status: null, automatic_updates: "never", allow_renames: true, favorite: false, From 057cbd4d80d2f99023a722ccc503d7f1d66145e6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 18:52:09 +0100 Subject: [PATCH 002/457] feat(cli): add `coder exp mcp` command (#17066) Adds a `coder exp mcp` command which will start a local MCP server listening on stdio with the following capabilities: * Show logged in user (`coder whoami`) * List workspaces (`coder list`) * List templates (`coder templates list`) * Start a workspace (`coder start`) * Stop a workspace (`coder stop`) * Fetch a single workspace (no direct CLI analogue) * Execute a command inside a workspace (`coder exp rpty`) * Report the status of a task (currently a no-op, pending task support) This can be tested as follows: ``` # Start a local Coder server. ./scripts/develop.sh # Start a workspace. Currently, creating workspaces is not supported. ./scripts/coder-dev.sh create -t docker --yes # Add the MCP to your Claude config. claude mcp add coder ./scripts/coder-dev.sh exp mcp # Tell Claude to do something Coder-related. You may need to nudge it to use the tools. claude 'start a docker workspace and tell me what version of python is installed' ``` --- cli/clitest/golden.go | 8 +- cli/exp.go | 1 + cli/exp_mcp.go | 284 +++++++++++++++++++ cli/exp_mcp_test.go | 142 ++++++++++ go.mod | 4 + go.sum | 4 + mcp/mcp.go | 643 ++++++++++++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 361 ++++++++++++++++++++++++ testutil/json.go | 27 ++ 9 files changed, 1469 insertions(+), 5 deletions(-) create mode 100644 cli/exp_mcp.go create mode 100644 cli/exp_mcp_test.go create mode 100644 mcp/mcp.go create mode 100644 mcp/mcp_test.go create mode 100644 testutil/json.go diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index e79006ebb58e3..d4401d6c5d5f9 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -11,7 +11,9 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/config" @@ -117,11 +119,7 @@ func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements m require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes") expected = normalizeGoldenFile(t, expected) - require.Equal( - t, string(expected), string(actual), - "golden file mismatch: %s, run \"make gen/golden-files\", verify and commit the changes", - goldenPath, - ) + assert.Empty(t, cmp.Diff(string(expected), string(actual)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenPath) } // normalizeGoldenFile replaces any strings that are system or timing dependent diff --git a/cli/exp.go b/cli/exp.go index 2339da86313a6..dafd85402663e 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -13,6 +13,7 @@ func (r *RootCmd) expCmd() *serpent.Command { Children: []*serpent.Command{ r.scaletestCmd(), r.errorExample(), + r.mcpCommand(), r.promptExample(), r.rptyCommand(), }, diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go new file mode 100644 index 0000000000000..a5af41d9103a6 --- /dev/null +++ b/cli/exp_mcp.go @@ -0,0 +1,284 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + codermcp "github.com/coder/coder/v2/mcp" + "github.com/coder/serpent" +) + +func (r *RootCmd) mcpCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "mcp", + Short: "Run the Coder MCP server and configure it to work with AI tools.", + Long: "The Coder MCP server allows you to automatically create workspaces with parameters.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.mcpConfigure(), + r.mcpServer(), + }, + } + return cmd +} + +func (r *RootCmd) mcpConfigure() *serpent.Command { + cmd := &serpent.Command{ + Use: "configure", + Short: "Automatically configure the MCP server.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.mcpConfigureClaudeDesktop(), + r.mcpConfigureClaudeCode(), + r.mcpConfigureCursor(), + }, + } + return cmd +} + +func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { + cmd := &serpent.Command{ + Use: "claude-desktop", + Short: "Configure the Claude Desktop server.", + Handler: func(_ *serpent.Invocation) error { + configPath, err := os.UserConfigDir() + if err != nil { + return err + } + configPath = filepath.Join(configPath, "Claude") + err = os.MkdirAll(configPath, 0o755) + if err != nil { + return err + } + configPath = filepath.Join(configPath, "claude_desktop_config.json") + _, err = os.Stat(configPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } + contents := map[string]any{} + data, err := os.ReadFile(configPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + err = json.Unmarshal(data, &contents) + if err != nil { + return err + } + } + binPath, err := os.Executable() + if err != nil { + return err + } + contents["mcpServers"] = map[string]any{ + "coder": map[string]any{"command": binPath, "args": []string{"exp", "mcp", "server"}}, + } + data, err = json.MarshalIndent(contents, "", " ") + if err != nil { + return err + } + err = os.WriteFile(configPath, data, 0o600) + if err != nil { + return err + } + return nil + }, + } + return cmd +} + +func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command { + cmd := &serpent.Command{ + Use: "claude-code", + Short: "Configure the Claude Code server.", + Handler: func(_ *serpent.Invocation) error { + return nil + }, + } + return cmd +} + +func (*RootCmd) mcpConfigureCursor() *serpent.Command { + var project bool + cmd := &serpent.Command{ + Use: "cursor", + Short: "Configure Cursor to use Coder MCP.", + Options: serpent.OptionSet{ + serpent.Option{ + Flag: "project", + Env: "CODER_MCP_CURSOR_PROJECT", + Description: "Use to configure a local project to use the Cursor MCP.", + Value: serpent.BoolOf(&project), + }, + }, + Handler: func(_ *serpent.Invocation) error { + dir, err := os.Getwd() + if err != nil { + return err + } + if !project { + dir, err = os.UserHomeDir() + if err != nil { + return err + } + } + cursorDir := filepath.Join(dir, ".cursor") + err = os.MkdirAll(cursorDir, 0o755) + if err != nil { + return err + } + mcpConfig := filepath.Join(cursorDir, "mcp.json") + _, err = os.Stat(mcpConfig) + contents := map[string]any{} + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + data, err := os.ReadFile(mcpConfig) + if err != nil { + return err + } + // The config can be empty, so we don't want to return an error if it is. + if len(data) > 0 { + err = json.Unmarshal(data, &contents) + if err != nil { + return err + } + } + } + mcpServers, ok := contents["mcpServers"].(map[string]any) + if !ok { + mcpServers = map[string]any{} + } + binPath, err := os.Executable() + if err != nil { + return err + } + mcpServers["coder"] = map[string]any{ + "command": binPath, + "args": []string{"exp", "mcp", "server"}, + } + contents["mcpServers"] = mcpServers + data, err := json.MarshalIndent(contents, "", " ") + if err != nil { + return err + } + err = os.WriteFile(mcpConfig, data, 0o600) + if err != nil { + return err + } + return nil + }, + } + return cmd +} + +func (r *RootCmd) mcpServer() *serpent.Command { + var ( + client = new(codersdk.Client) + instructions string + allowedTools []string + ) + return &serpent.Command{ + Use: "server", + Handler: func(inv *serpent.Invocation) error { + return mcpServerHandler(inv, client, instructions, allowedTools) + }, + Short: "Start the Coder MCP server.", + Middleware: serpent.Chain( + r.InitClient(client), + ), + Options: []serpent.Option{ + { + Name: "instructions", + Description: "The instructions to pass to the MCP server.", + Flag: "instructions", + Value: serpent.StringOf(&instructions), + }, + { + Name: "allowed-tools", + Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.", + Flag: "allowed-tools", + Value: serpent.StringArrayOf(&allowedTools), + }, + }, + } +} + +func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + + me, err := client.User(ctx, codersdk.Me) + if err != nil { + cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.") + cliui.Errorf(inv.Stderr, "Please check your URL and credentials.") + cliui.Errorf(inv.Stderr, "Tip: Run `coder whoami` to check your credentials.") + return err + } + cliui.Infof(inv.Stderr, "Starting MCP server") + cliui.Infof(inv.Stderr, "User : %s", me.Username) + cliui.Infof(inv.Stderr, "URL : %s", client.URL) + cliui.Infof(inv.Stderr, "Instructions : %q", instructions) + if len(allowedTools) > 0 { + cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) + } + cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server") + + // Capture the original stdin, stdout, and stderr. + invStdin := inv.Stdin + invStdout := inv.Stdout + invStderr := inv.Stderr + defer func() { + inv.Stdin = invStdin + inv.Stdout = invStdout + inv.Stderr = invStderr + }() + + options := []codermcp.Option{ + codermcp.WithInstructions(instructions), + codermcp.WithLogger(&logger), + } + + // Add allowed tools option if specified + if len(allowedTools) > 0 { + options = append(options, codermcp.WithAllowedTools(allowedTools)) + } + + srv := codermcp.NewStdio(client, options...) + srv.SetErrorLogger(log.New(invStderr, "", log.LstdFlags)) + + done := make(chan error) + go func() { + defer close(done) + srvErr := srv.Listen(ctx, invStdin, invStdout) + done <- srvErr + }() + + if err := <-done; err != nil { + if !errors.Is(err, context.Canceled) { + cliui.Errorf(inv.Stderr, "Failed to start the MCP server: %s", err) + return err + } + } + + return nil +} diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go new file mode 100644 index 0000000000000..06d7693c86f7d --- /dev/null +++ b/cli/exp_mcp_test.go @@ -0,0 +1,142 @@ +package cli_test + +import ( + "context" + "encoding/json" + "runtime" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpMcp(t *testing.T) { + t.Parallel() + + // Reading to / writing from the PTY is flaky on non-linux systems. + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux") + } + + t.Run("AllowedTools", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + // Given: a running coder deployment + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // Given: we run the exp mcp command with allowed tools set + inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_whoami,coder_list_templates") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + // When: we send a tools/list request + toolsPayload := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + pty.WriteLine(toolsPayload) + _ = pty.ReadLine(ctx) // ignore echoed output + output := pty.ReadLine(ctx) + + cancel() + <-cmdDone + + // Then: we should only see the allowed tools in the response + var toolsResponse struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + err := json.Unmarshal([]byte(output), &toolsResponse) + require.NoError(t, err) + require.Len(t, toolsResponse.Result.Tools, 2, "should have exactly 2 tools") + foundTools := make([]string, 0, 2) + for _, tool := range toolsResponse.Result.Tools { + foundTools = append(foundTools, tool.Name) + } + slices.Sort(foundTools) + require.Equal(t, []string{"coder_list_templates", "coder_whoami"}, foundTools) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "exp", "mcp", "server") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + pty.WriteLine(payload) + _ = pty.ReadLine(ctx) // ignore echoed output + output := pty.ReadLine(ctx) + cancel() + <-cmdDone + + // Ensure the initialize output is valid JSON + t.Logf("/initialize output: %s", output) + var initializeResponse map[string]interface{} + err := json.Unmarshal([]byte(output), &initializeResponse) + require.NoError(t, err) + require.Equal(t, "2.0", initializeResponse["jsonrpc"]) + require.Equal(t, 1.0, initializeResponse["id"]) + require.NotNil(t, initializeResponse["result"]) + }) + + t.Run("NoCredentials", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + inv, root := clitest.New(t, "exp", "mcp", "server") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + err := inv.Run() + assert.ErrorContains(t, err, "your session has expired") + }) +} diff --git a/go.mod b/go.mod index 56c52a82b6721..3ecb96a3e14f6 100644 --- a/go.mod +++ b/go.mod @@ -480,3 +480,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) + +require github.com/mark3labs/mcp-go v0.17.0 + +require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index efa6ade52ffb6..70c46ff5266da 100644 --- a/go.sum +++ b/go.sum @@ -658,6 +658,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.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930= +github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= 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= @@ -972,6 +974,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= diff --git a/mcp/mcp.go b/mcp/mcp.go new file mode 100644 index 0000000000000..80e0f341e16e6 --- /dev/null +++ b/mcp/mcp.go @@ -0,0 +1,643 @@ +package codermcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "slices" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +type mcpOptions struct { + instructions string + logger *slog.Logger + allowedTools []string +} + +// Option is a function that configures the MCP server. +type Option func(*mcpOptions) + +// WithInstructions sets the instructions for the MCP server. +func WithInstructions(instructions string) Option { + return func(o *mcpOptions) { + o.instructions = instructions + } +} + +// WithLogger sets the logger for the MCP server. +func WithLogger(logger *slog.Logger) Option { + return func(o *mcpOptions) { + o.logger = logger + } +} + +// WithAllowedTools sets the allowed tools for the MCP server. +func WithAllowedTools(tools []string) Option { + return func(o *mcpOptions) { + o.allowedTools = tools + } +} + +// NewStdio creates a new MCP stdio server with the given client and options. +// It is the responsibility of the caller to start and stop the server. +func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer { + options := &mcpOptions{ + instructions: ``, + logger: ptr.Ref(slog.Make(sloghuman.Sink(os.Stdout))), + } + for _, opt := range opts { + opt(options) + } + + mcpSrv := server.NewMCPServer( + "Coder Agent", + buildinfo.Version(), + server.WithInstructions(options.instructions), + ) + + logger := slog.Make(sloghuman.Sink(os.Stdout)) + + // Register tools based on the allowed list (if specified) + reg := AllTools() + if len(options.allowedTools) > 0 { + reg = reg.WithOnlyAllowed(options.allowedTools...) + } + reg.Register(mcpSrv, ToolDeps{ + Client: client, + Logger: &logger, + }) + + srv := server.NewStdioServer(mcpSrv) + return srv +} + +// allTools is the list of all available tools. When adding a new tool, +// make sure to update this list. +var allTools = ToolRegistry{ + { + Tool: mcp.NewTool("coder_report_task", + mcp.WithDescription(`Report progress on a user task in Coder. +Use this tool to keep the user informed about your progress with their request. +For long-running operations, call this periodically to provide status updates. +This is especially useful when performing multi-step operations like workspace creation or deployment.`), + mcp.WithString("summary", mcp.Description(`A concise summary of your current progress on the task. + +Good Summaries: +- "Taking a look at the login page..." +- "Found a bug! Fixing it now..." +- "Investigating the GitHub Issue..." +- "Waiting for workspace to start (1/3 resources ready)" +- "Downloading template files from repository"`), mcp.Required()), + mcp.WithString("link", mcp.Description(`A relevant URL related to your work, such as: +- GitHub issue link +- Pull request URL +- Documentation reference +- Workspace URL +Use complete URLs (including https://) when possible.`), mcp.Required()), + mcp.WithString("emoji", mcp.Description(`A relevant emoji that visually represents the current status: +- 🔍 for investigating/searching +- 🚀 for deploying/starting +- 🐛 for debugging +- ✅ for completion +- ⏳ for waiting +Choose an emoji that helps the user understand the current phase at a glance.`), mcp.Required()), + mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete. +Set to true only when the entire requested operation is finished successfully. +For multi-step processes, use false until all steps are complete.`), mcp.Required()), + ), + MakeHandler: handleCoderReportTask, + }, + { + Tool: mcp.NewTool("coder_whoami", + mcp.WithDescription(`Get information about the currently logged-in Coder user. +Returns JSON with the user's profile including fields: id, username, email, created_at, status, roles, etc. +Use this to identify the current user context before performing workspace operations. +This tool is useful for verifying permissions and checking the user's identity. + +Common errors: +- Authentication failure: The session may have expired +- Server unavailable: The Coder deployment may be unreachable`), + ), + MakeHandler: handleCoderWhoami, + }, + { + Tool: mcp.NewTool("coder_list_templates", + mcp.WithDescription(`List all templates available on the Coder deployment. +Returns JSON with detailed information about each template, including: +- Template name, ID, and description +- Creation/modification timestamps +- Version information +- Associated organization + +Use this tool to discover available templates before creating workspaces. +Templates define the infrastructure and configuration for workspaces. + +Common errors: +- Authentication failure: Check user permissions +- No templates available: The deployment may not have any templates configured`), + ), + MakeHandler: handleCoderListTemplates, + }, + { + Tool: mcp.NewTool("coder_list_workspaces", + mcp.WithDescription(`List workspaces available on the Coder deployment. +Returns JSON with workspace metadata including status, resources, and configurations. +Use this before other workspace operations to find valid workspace names/IDs. +Results are paginated - use offset and limit parameters for large deployments. + +Common errors: +- Authentication failure: Check user permissions +- Invalid owner parameter: Ensure the owner exists`), + mcp.WithString(`owner`, mcp.Description(`The username of the workspace owner to filter by. +Defaults to "me" which represents the currently authenticated user. +Use this to view workspaces belonging to other users (requires appropriate permissions). +Special value: "me" - List workspaces owned by the authenticated user.`), mcp.DefaultString(codersdk.Me)), + mcp.WithNumber(`offset`, mcp.Description(`Pagination offset - the starting index for listing workspaces. +Used with the 'limit' parameter to implement pagination. +For example, to get the second page of results with 10 items per page, use offset=10. +Defaults to 0 (first page).`), mcp.DefaultNumber(0)), + mcp.WithNumber(`limit`, mcp.Description(`Maximum number of workspaces to return in a single request. +Used with the 'offset' parameter to implement pagination. +Higher values return more results but may increase response time. +Valid range: 1-100. Defaults to 10.`), mcp.DefaultNumber(10)), + ), + MakeHandler: handleCoderListWorkspaces, + }, + { + Tool: mcp.NewTool("coder_get_workspace", + mcp.WithDescription(`Get detailed information about a specific Coder workspace. +Returns comprehensive JSON with the workspace's configuration, status, and resources. +Use this to check workspace status before performing operations like exec or start/stop. +The response includes the latest build status, agent connectivity, and resource details. + +Common errors: +- Workspace not found: Check the workspace name or ID +- Permission denied: The user may not have access to this workspace`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to retrieve. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +Use coder_list_workspaces first if you're not sure about available workspace names.`), mcp.Required()), + ), + MakeHandler: handleCoderGetWorkspace, + }, + { + Tool: mcp.NewTool("coder_workspace_exec", + mcp.WithDescription(`Execute a shell command in a remote Coder workspace. +Runs the specified command and returns the complete output (stdout/stderr). +Use this for file operations, running build commands, or checking workspace state. +The workspace must be running with a connected agent for this to succeed. + +Before using this tool: +1. Verify the workspace is running using coder_get_workspace +2. Start the workspace if needed using coder_start_workspace + +Common errors: +- Workspace not running: Start the workspace first +- Command not allowed: Check security restrictions +- Agent not connected: The workspace may still be starting up`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name where the command will execute. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be running with a connected agent. +Use coder_get_workspace first to check the workspace status.`), mcp.Required()), + mcp.WithString("command", mcp.Description(`The shell command to execute in the workspace. +Commands are executed in the default shell of the workspace. + +Examples: +- "ls -la" - List files with details +- "cd /path/to/directory && command" - Execute in specific directory +- "cat ~/.bashrc" - View a file's contents +- "python -m pip list" - List installed Python packages + +Note: Very long-running commands may time out.`), mcp.Required()), + ), + MakeHandler: handleCoderWorkspaceExec, + }, + { + Tool: mcp.NewTool("coder_workspace_transition", + mcp.WithDescription(`Start or stop a running Coder workspace. +If stopping, initiates the workspace stop transition. +Only works on workspaces that are currently running or failed. + +If starting, initiates the workspace start transition. +Only works on workspaces that are currently stopped or failed. + +Stopping or starting a workspace is an asynchronous operation - it may take several minutes to complete. + +After calling this tool: +1. Use coder_report_task to inform the user that the workspace is stopping or starting +2. Use coder_get_workspace periodically to check for completion + +Common errors: +- Workspace already started/starting/stopped/stopping: No action needed +- Cancellation failed: There may be issues with the underlying infrastructure +- User doesn't own workspace: Permission issues`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start or stop. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be in a running state to be stopped, or in a stopped or failed state to be started. +Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), + mcp.WithString("transition", mcp.Description(`The transition to apply to the workspace. +Can be either "start" or "stop".`)), + ), + MakeHandler: handleCoderWorkspaceTransition, + }, +} + +// ToolDeps contains all dependencies needed by tool handlers +type ToolDeps struct { + Client *codersdk.Client + Logger *slog.Logger +} + +// ToolHandler associates a tool with its handler creation function +type ToolHandler struct { + Tool mcp.Tool + MakeHandler func(ToolDeps) server.ToolHandlerFunc +} + +// ToolRegistry is a map of available tools with their handler creation +// functions +type ToolRegistry []ToolHandler + +// WithOnlyAllowed returns a new ToolRegistry containing only the tools +// specified in the allowed list. +func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry { + if len(allowed) == 0 { + return []ToolHandler{} + } + + filtered := make(ToolRegistry, 0, len(r)) + + // The overhead of a map lookup is likely higher than a linear scan + // for a small number of tools. + for _, entry := range r { + if slices.Contains(allowed, entry.Tool.Name) { + filtered = append(filtered, entry) + } + } + return filtered +} + +// Register registers all tools in the registry with the given tool adder +// and dependencies. +func (r ToolRegistry) Register(srv *server.MCPServer, deps ToolDeps) { + for _, entry := range r { + srv.AddTool(entry.Tool, entry.MakeHandler(deps)) + } +} + +// AllTools returns all available tools. +func AllTools() ToolRegistry { + // return a copy of allTools to avoid mutating the original + return slices.Clone(allTools) +} + +type handleCoderReportTaskArgs struct { + Summary string `json:"summary"` + Link string `json:"link"` + Emoji string `json:"emoji"` + Done bool `json:"done"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} +func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + + // Convert the request parameters to a json.RawMessage so we can unmarshal + // them into the correct struct. + args, err := unmarshalArgs[handleCoderReportTaskArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + // TODO: Waiting on support for tasks. + deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji)) + /* + err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ + Reporter: "claude", + Summary: summary, + URL: link, + Completion: done, + Icon: emoji, + }) + if err != nil { + return nil, err + } + */ + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent("Thanks for reporting!"), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_whoami", "arguments": {}}} +func handleCoderWhoami(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + me, err := deps.Client.User(ctx, codersdk.Me) + if err != nil { + return nil, xerrors.Errorf("Failed to fetch the current user: %s", err.Error()) + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(me); err != nil { + return nil, xerrors.Errorf("Failed to encode the current user: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(strings.TrimSpace(buf.String())), + }, + }, nil + } +} + +type handleCoderListWorkspacesArgs struct { + Owner string `json:"owner"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}} +func handleCoderListWorkspaces(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderListWorkspacesArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: args.Owner, + Offset: args.Offset, + Limit: args.Limit, + }) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspaces: %w", err) + } + + // Encode it as JSON. TODO: It might be nicer for the agent to have a tabulated response. + data, err := json.Marshal(workspaces) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspaces: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(data)), + }, + }, nil + } +} + +type handleCoderGetWorkspaceArgs struct { + Workspace string `json:"workspace"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}} +func handleCoderGetWorkspace(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderGetWorkspaceArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + workspaceJSON, err := json.Marshal(workspace) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(workspaceJSON)), + }, + }, nil + } +} + +type handleCoderWorkspaceExecArgs struct { + Workspace string `json:"workspace"` + Command string `json:"command"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}} +func handleCoderWorkspaceExec(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderWorkspaceExecArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + // Attempt to fetch the workspace. We may get a UUID or a name, so try to + // handle both. + ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + // Ensure the workspace is started. + // Select the first agent of the workspace. + var agt *codersdk.WorkspaceAgent + for _, r := range ws.LatestBuild.Resources { + for _, a := range r.Agents { + if a.Status != codersdk.WorkspaceAgentConnected { + continue + } + agt = ptr.Ref(a) + break + } + } + if agt == nil { + return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID) + } + + startedAt := time.Now() + conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{ + AgentID: agt.ID, + Reconnect: uuid.New(), + Width: 80, + Height: 24, + Command: args.Command, + BackendType: "buffered", // the screen backend is annoying to use here. + }) + if err != nil { + return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err) + } + defer conn.Close() + connectedAt := time.Now() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, conn); err != nil { + // EOF is expected when the connection is closed. + // We can ignore this error. + if !errors.Is(err, io.EOF) { + return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err) + } + } + completedAt := time.Now() + connectionTime := connectedAt.Sub(startedAt) + executionTime := completedAt.Sub(connectedAt) + + resp := map[string]string{ + "connection_time": connectionTime.String(), + "execution_time": executionTime.String(), + "output": buf.String(), + } + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_templates", "arguments": {}}} +func handleCoderListTemplates(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + templates, err := deps.Client.Templates(ctx, codersdk.TemplateFilter{}) + if err != nil { + return nil, xerrors.Errorf("failed to fetch templates: %w", err) + } + + templateJSON, err := json.Marshal(templates) + if err != nil { + return nil, xerrors.Errorf("failed to encode templates: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(templateJSON)), + }, + }, nil + } +} + +type handleCoderWorkspaceTransitionArgs struct { + Workspace string `json:"workspace"` + Transition string `json:"transition"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": +// "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}} +func handleCoderWorkspaceTransition(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderWorkspaceTransitionArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + wsTransition := codersdk.WorkspaceTransition(args.Transition) + switch wsTransition { + case codersdk.WorkspaceTransitionStart: + case codersdk.WorkspaceTransitionStop: + default: + return nil, xerrors.New("invalid transition") + } + + // We're not going to check the workspace status here as it is checked on the + // server side. + wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: wsTransition, + }) + if err != nil { + return nil, xerrors.Errorf("failed to stop workspace: %w", err) + } + + resp := map[string]any{"status": wb.Status, "transition": wb.Transition} + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { + if wsid, err := uuid.Parse(identifier); err == nil { + return client.Workspace(ctx, wsid) + } + return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{}) +} + +// unmarshalArgs is a helper function to convert the map[string]any we get from +// the MCP server into a typed struct. It does this by marshaling and unmarshalling +// the arguments. +func unmarshalArgs[T any](args map[string]interface{}) (t T, err error) { + argsJSON, err := json.Marshal(args) + if err != nil { + return t, xerrors.Errorf("failed to marshal arguments: %w", err) + } + if err := json.Unmarshal(argsJSON, &t); err != nil { + return t, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + return t, nil +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go new file mode 100644 index 0000000000000..f2573f44a1be6 --- /dev/null +++ b/mcp/mcp_test.go @@ -0,0 +1,361 @@ +package codermcp_test + +import ( + "context" + "encoding/json" + "io" + "runtime" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/codersdk" + codermcp "github.com/coder/coder/v2/mcp" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// These tests are dependent on the state of the coder server. +// Running them in parallel is prone to racy behavior. +// nolint:tparallel,paralleltest +func TestCoderTools(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux due to pty issues") + } + ctx := testutil.Context(t, testutil.WaitLong) + // Given: a coder server, workspace, and agent. + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + // Given: a member user with which to test the tools. + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + // Given: a workspace with an agent. + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).WithAgent().Do() + + // Note: we want to test the list_workspaces tool before starting the + // workspace agent. Starting the workspace agent will modify the workspace + // state, which will affect the results of the list_workspaces tool. + listWorkspacesDone := make(chan struct{}) + agentStarted := make(chan struct{}) + go func() { + defer close(agentStarted) + <-listWorkspacesDone + agt := agenttest.New(t, client.URL, r.AgentToken) + t.Cleanup(func() { + _ = agt.Close() + }) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() + }() + + // Given: a MCP server listening on a pty. + pty := ptytest.New(t) + mcpSrv, closeSrv := startTestMCPServer(ctx, t, pty.Input(), pty.Output()) + t.Cleanup(func() { + _ = closeSrv() + }) + + // Register tools using our registry + logger := slogtest.Make(t, nil) + codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{ + Client: memberClient, + Logger: &logger, + }) + + t.Run("coder_list_templates", func(t *testing.T) { + // When: the coder_list_templates tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_templates", map[string]any{}) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + templates, err := memberClient.Templates(ctx, codersdk.TemplateFilter{}) + require.NoError(t, err) + templatesJSON, err := json.Marshal(templates) + require.NoError(t, err) + + // Then: the response is a list of templates visible to the user. + expected := makeJSONRPCTextResponse(t, string(templatesJSON)) + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_report_task", func(t *testing.T) { + // When: the coder_report_task tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_report_task", map[string]any{ + "summary": "Test summary", + "link": "https://example.com", + "emoji": "🔍", + "done": false, + "coder_url": client.URL.String(), + "coder_session_token": client.SessionToken(), + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + // Then: the response is a success message. + // TODO: check the task was created. This functionality is not yet implemented. + expected := makeJSONRPCTextResponse(t, "Thanks for reporting!") + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_whoami", func(t *testing.T) { + // When: the coder_whoami tool is called + me, err := memberClient.User(ctx, codersdk.Me) + require.NoError(t, err) + meJSON, err := json.Marshal(me) + require.NoError(t, err) + + ctr := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{}) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + // Then: the response is a valid JSON respresentation of the calling user. + expected := makeJSONRPCTextResponse(t, string(meJSON)) + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_list_workspaces", func(t *testing.T) { + defer close(listWorkspacesDone) + // When: the coder_list_workspaces tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_workspaces", map[string]any{ + "coder_url": client.URL.String(), + "coder_session_token": client.SessionToken(), + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + ws, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + wsJSON, err := json.Marshal(ws) + require.NoError(t, err) + + // Then: the response is a valid JSON respresentation of the calling user's workspaces. + expected := makeJSONRPCTextResponse(t, string(wsJSON)) + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_get_workspace", func(t *testing.T) { + // Given: the workspace agent is connected. + // The act of starting the agent will modify the workspace state. + <-agentStarted + // When: the coder_get_workspace tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_get_workspace", map[string]any{ + "workspace": r.Workspace.ID.String(), + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + ws, err := memberClient.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + wsJSON, err := json.Marshal(ws) + require.NoError(t, err) + + // Then: the response is a valid JSON respresentation of the workspace. + expected := makeJSONRPCTextResponse(t, string(wsJSON)) + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + // NOTE: this test runs after the list_workspaces tool is called. + t.Run("coder_workspace_exec", func(t *testing.T) { + // Given: the workspace agent is connected + <-agentStarted + + // When: the coder_workspace_exec tools is called with a command + randString := testutil.GetRandomName(t) + ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ + "workspace": r.Workspace.ID.String(), + "command": "echo " + randString, + "coder_url": client.URL.String(), + "coder_session_token": client.SessionToken(), + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + // Then: the response is the output of the command. + actual := pty.ReadLine(ctx) + require.Contains(t, actual, randString) + }) + + // NOTE: this test runs after the list_workspaces tool is called. + t.Run("tool_restrictions", func(t *testing.T) { + // Given: the workspace agent is connected + <-agentStarted + + // Given: a restricted MCP server with only allowed tools and commands + restrictedPty := ptytest.New(t) + allowedTools := []string{"coder_workspace_exec"} + restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output()) + t.Cleanup(func() { + _ = closeRestrictedSrv() + }) + codermcp.AllTools(). + WithOnlyAllowed(allowedTools...). + Register(restrictedMCPSrv, codermcp.ToolDeps{ + Client: memberClient, + Logger: &logger, + }) + + // When: the tools/list command is called + toolsListCmd := makeJSONRPCRequest(t, "tools/list", "", nil) + restrictedPty.WriteLine(toolsListCmd) + _ = restrictedPty.ReadLine(ctx) // skip the echo + + // Then: the response is a list of only the allowed tools. + toolsListResponse := restrictedPty.ReadLine(ctx) + require.Contains(t, toolsListResponse, "coder_workspace_exec") + require.NotContains(t, toolsListResponse, "coder_whoami") + + // When: a disallowed tool is called + disallowedToolCmd := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{}) + restrictedPty.WriteLine(disallowedToolCmd) + _ = restrictedPty.ReadLine(ctx) // skip the echo + + // Then: the response is an error indicating the tool is not available. + disallowedToolResponse := restrictedPty.ReadLine(ctx) + require.Contains(t, disallowedToolResponse, "error") + require.Contains(t, disallowedToolResponse, "not found") + }) + + t.Run("coder_workspace_transition_stop", func(t *testing.T) { + // Given: a separate workspace in the running state + stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).WithAgent().Do() + + // When: the coder_workspace_transition tool is called with a stop transition + ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{ + "workspace": stopWs.Workspace.ID.String(), + "transition": "stop", + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + // Then: the response is as expected. + expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"stop"}`) // no provisionerd yet + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_workspace_transition_start", func(t *testing.T) { + // Given: a separate workspace in the stopped state + stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + }).Do() + + // When: the coder_workspace_transition tool is called with a start transition + ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{ + "workspace": stopWs.Workspace.ID.String(), + "transition": "start", + }) + + pty.WriteLine(ctr) + _ = pty.ReadLine(ctx) // skip the echo + + // Then: the response is as expected + expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"start"}`) // no provisionerd yet + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) +} + +// makeJSONRPCRequest is a helper function that makes a JSON RPC request. +func makeJSONRPCRequest(t *testing.T, method, name string, args map[string]any) string { + t.Helper() + req := mcp.JSONRPCRequest{ + ID: "1", + JSONRPC: "2.0", + Request: mcp.Request{Method: method}, + Params: struct { // Unfortunately, there is no type for this yet. + Name string "json:\"name\"" + Arguments map[string]any "json:\"arguments,omitempty\"" + Meta *struct { + ProgressToken mcp.ProgressToken "json:\"progressToken,omitempty\"" + } "json:\"_meta,omitempty\"" + }{ + Name: name, + Arguments: args, + }, + } + bs, err := json.Marshal(req) + require.NoError(t, err, "failed to marshal JSON RPC request") + return string(bs) +} + +// makeJSONRPCTextResponse is a helper function that makes a JSON RPC text response +func makeJSONRPCTextResponse(t *testing.T, text string) string { + t.Helper() + + resp := mcp.JSONRPCResponse{ + ID: "1", + JSONRPC: "2.0", + Result: mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(text), + }, + }, + } + bs, err := json.Marshal(resp) + require.NoError(t, err, "failed to marshal JSON RPC response") + return string(bs) +} + +// startTestMCPServer is a helper function that starts a MCP server listening on +// a pty. It is the responsibility of the caller to close the server. +func startTestMCPServer(ctx context.Context, t testing.TB, stdin io.Reader, stdout io.Writer) (*server.MCPServer, func() error) { + t.Helper() + + mcpSrv := server.NewMCPServer( + "Test Server", + "0.0.0", + server.WithInstructions(""), + server.WithLogging(), + ) + + stdioSrv := server.NewStdioServer(mcpSrv) + + cancelCtx, cancel := context.WithCancel(ctx) + closeCh := make(chan struct{}) + done := make(chan error) + go func() { + defer close(done) + srvErr := stdioSrv.Listen(cancelCtx, stdin, stdout) + done <- srvErr + }() + + go func() { + select { + case <-closeCh: + cancel() + case <-done: + cancel() + } + }() + + return mcpSrv, func() error { + close(closeCh) + return <-done + } +} diff --git a/testutil/json.go b/testutil/json.go new file mode 100644 index 0000000000000..006617d1ca030 --- /dev/null +++ b/testutil/json.go @@ -0,0 +1,27 @@ +package testutil + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// RequireJSONEq is like assert.RequireJSONEq, but it's actually readable. +// Note that this calls t.Fatalf under the hood, so it should never +// be called in a goroutine. +func RequireJSONEq(t *testing.T, expected, actual string) { + t.Helper() + + var expectedJSON, actualJSON any + if err := json.Unmarshal([]byte(expected), &expectedJSON); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %s", err) + } + if err := json.Unmarshal([]byte(actual), &actualJSON); err != nil { + t.Fatalf("failed to unmarshal actual JSON: %s", err) + } + + if diff := cmp.Diff(expectedJSON, actualJSON); diff != "" { + t.Fatalf("JSON diff (-want +got):\n%s", diff) + } +} From 40de51b1886bdd32fbc2659de618201359577383 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 18:02:30 +0000 Subject: [PATCH 003/457] chore: bump vite from 5.4.15 to 5.4.16 in /site (#17176) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.15 to 5.4.16.
Release notes

Sourced from vite's releases.

v5.4.16

Please refer to CHANGELOG.md for details.

Changelog

Sourced from vite's changelog.

5.4.16 (2025-03-31)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.15&new-version=5.4.16)](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) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/coder/coder/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 223 ++++++++++++++++++++++---------------------- 2 files changed, 113 insertions(+), 112 deletions(-) diff --git a/site/package.json b/site/package.json index 51ec024ae2fa1..ba115cbff00f8 100644 --- a/site/package.json +++ b/site/package.json @@ -186,7 +186,7 @@ "ts-proto": "1.164.0", "ts-prune": "0.10.3", "typescript": "5.6.3", - "vite": "5.4.15", + "vite": "5.4.16", "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index fc5dbb43876f6..1eed3227d4de2 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -245,7 +245,7 @@ importers: version: 1.5.1 rollup-plugin-visualizer: specifier: 5.14.0 - version: 5.14.0(rollup@4.37.0) + version: 5.14.0(rollup@4.38.0) semver: specifier: 7.6.2 version: 7.6.2 @@ -315,7 +315,7 @@ importers: version: 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3) '@storybook/react-vite': specifier: 8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.37.0)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16)) + version: 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.38.0)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16)) '@storybook/test': specifier: 8.4.6 version: 8.4.6(storybook@8.5.3(prettier@3.4.1)) @@ -396,7 +396,7 @@ importers: version: 9.0.2 '@vitejs/plugin-react': specifier: 4.3.4 - version: 4.3.4(vite@5.4.15(@types/node@20.17.16)) + version: 4.3.4(vite@5.4.16(@types/node@20.17.16)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.5.1) @@ -464,11 +464,11 @@ importers: specifier: 5.6.3 version: 5.6.3 vite: - specifier: 5.4.15 - version: 5.4.15(@types/node@20.17.16) + specifier: 5.4.16 + version: 5.4.16(@types/node@20.17.16) vite-plugin-checker: specifier: 0.8.0 - version: 0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16)) + version: 0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16)) vite-plugin-turbosnap: specifier: 1.0.3 version: 1.0.3 @@ -2076,103 +2076,103 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.37.0': - resolution: {integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz} + '@rollup/rollup-android-arm-eabi@4.38.0': + resolution: {integrity: sha512-ldomqc4/jDZu/xpYU+aRxo3V4mGCV9HeTgUBANI3oIQMOL+SsxB+S2lxMpkFp5UamSS3XuTMQVbsS24R4J4Qjg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.38.0.tgz} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.37.0': - resolution: {integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz} + '@rollup/rollup-android-arm64@4.38.0': + resolution: {integrity: sha512-VUsgcy4GhhT7rokwzYQP+aV9XnSLkkhlEJ0St8pbasuWO/vwphhZQxYEKUP3ayeCYLhk6gEtacRpYP/cj3GjyQ==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.38.0.tgz} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.37.0': - resolution: {integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.37.0.tgz} + '@rollup/rollup-darwin-arm64@4.38.0': + resolution: {integrity: sha512-buA17AYXlW9Rn091sWMq1xGUvWQFOH4N1rqUxGJtEQzhChxWjldGCCup7r/wUnaI6Au8sKXpoh0xg58a7cgcpg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.38.0.tgz} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.37.0': - resolution: {integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.37.0.tgz} + '@rollup/rollup-darwin-x64@4.38.0': + resolution: {integrity: sha512-Mgcmc78AjunP1SKXl624vVBOF2bzwNWFPMP4fpOu05vS0amnLcX8gHIge7q/lDAHy3T2HeR0TqrriZDQS2Woeg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.38.0.tgz} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.37.0': - resolution: {integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.37.0.tgz} + '@rollup/rollup-freebsd-arm64@4.38.0': + resolution: {integrity: sha512-zzJACgjLbQTsscxWqvrEQAEh28hqhebpRz5q/uUd1T7VTwUNZ4VIXQt5hE7ncs0GrF+s7d3S4on4TiXUY8KoQA==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.38.0.tgz} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.37.0': - resolution: {integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.37.0.tgz} + '@rollup/rollup-freebsd-x64@4.38.0': + resolution: {integrity: sha512-hCY/KAeYMCyDpEE4pTETam0XZS4/5GXzlLgpi5f0IaPExw9kuB+PDTOTLuPtM10TlRG0U9OSmXJ+Wq9J39LvAg==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.38.0.tgz} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.37.0': - resolution: {integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.37.0.tgz} + '@rollup/rollup-linux-arm-gnueabihf@4.38.0': + resolution: {integrity: sha512-mimPH43mHl4JdOTD7bUMFhBdrg6f9HzMTOEnzRmXbOZqjijCw8LA5z8uL6LCjxSa67H2xiLFvvO67PT05PRKGg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.38.0.tgz} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.37.0': - resolution: {integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.37.0.tgz} + '@rollup/rollup-linux-arm-musleabihf@4.38.0': + resolution: {integrity: sha512-tPiJtiOoNuIH8XGG8sWoMMkAMm98PUwlriOFCCbZGc9WCax+GLeVRhmaxjJtz6WxrPKACgrwoZ5ia/uapq3ZVg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.38.0.tgz} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.37.0': - resolution: {integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.37.0.tgz} + '@rollup/rollup-linux-arm64-gnu@4.38.0': + resolution: {integrity: sha512-wZco59rIVuB0tjQS0CSHTTUcEde+pXQWugZVxWaQFdQQ1VYub/sTrNdY76D1MKdN2NB48JDuGABP6o6fqos8mA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.38.0.tgz} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.37.0': - resolution: {integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.37.0.tgz} + '@rollup/rollup-linux-arm64-musl@4.38.0': + resolution: {integrity: sha512-fQgqwKmW0REM4LomQ+87PP8w8xvU9LZfeLBKybeli+0yHT7VKILINzFEuggvnV9M3x1Ed4gUBmGUzCo/ikmFbQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.38.0.tgz} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.37.0': - resolution: {integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.37.0.tgz} + '@rollup/rollup-linux-loongarch64-gnu@4.38.0': + resolution: {integrity: sha512-hz5oqQLXTB3SbXpfkKHKXLdIp02/w3M+ajp8p4yWOWwQRtHWiEOCKtc9U+YXahrwdk+3qHdFMDWR5k+4dIlddg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.38.0.tgz} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.37.0': - resolution: {integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.37.0.tgz} + '@rollup/rollup-linux-powerpc64le-gnu@4.38.0': + resolution: {integrity: sha512-NXqygK/dTSibQ+0pzxsL3r4Xl8oPqVoWbZV9niqOnIHV/J92fe65pOir0xjkUZDRSPyFRvu+4YOpJF9BZHQImw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.38.0.tgz} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.37.0': - resolution: {integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.37.0.tgz} + '@rollup/rollup-linux-riscv64-gnu@4.38.0': + resolution: {integrity: sha512-GEAIabR1uFyvf/jW/5jfu8gjM06/4kZ1W+j1nWTSSB3w6moZEBm7iBtzwQ3a1Pxos2F7Gz+58aVEnZHU295QTg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.38.0.tgz} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.37.0': - resolution: {integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.37.0.tgz} + '@rollup/rollup-linux-riscv64-musl@4.38.0': + resolution: {integrity: sha512-9EYTX+Gus2EGPbfs+fh7l95wVADtSQyYw4DfSBcYdUEAmP2lqSZY0Y17yX/3m5VKGGJ4UmIH5LHLkMJft3bYoA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.38.0.tgz} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.37.0': - resolution: {integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.37.0.tgz} + '@rollup/rollup-linux-s390x-gnu@4.38.0': + resolution: {integrity: sha512-Mpp6+Z5VhB9VDk7RwZXoG2qMdERm3Jw07RNlXHE0bOnEeX+l7Fy4bg+NxfyN15ruuY3/7Vrbpm75J9QHFqj5+Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.38.0.tgz} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.37.0': - resolution: {integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.37.0.tgz} + '@rollup/rollup-linux-x64-gnu@4.38.0': + resolution: {integrity: sha512-vPvNgFlZRAgO7rwncMeE0+8c4Hmc+qixnp00/Uv3ht2x7KYrJ6ERVd3/R0nUtlE6/hu7/HiiNHJ/rP6knRFt1w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.38.0.tgz} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.37.0': - resolution: {integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.37.0.tgz} + '@rollup/rollup-linux-x64-musl@4.38.0': + resolution: {integrity: sha512-q5Zv+goWvQUGCaL7fU8NuTw8aydIL/C9abAVGCzRReuj5h30TPx4LumBtAidrVOtXnlB+RZkBtExMsfqkMfb8g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.38.0.tgz} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.37.0': - resolution: {integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.37.0.tgz} + '@rollup/rollup-win32-arm64-msvc@4.38.0': + resolution: {integrity: sha512-u/Jbm1BU89Vftqyqbmxdq14nBaQjQX1HhmsdBWqSdGClNaKwhjsg5TpW+5Ibs1mb8Es9wJiMdl86BcmtUVXNZg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.38.0.tgz} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.37.0': - resolution: {integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.37.0.tgz} + '@rollup/rollup-win32-ia32-msvc@4.38.0': + resolution: {integrity: sha512-mqu4PzTrlpNHHbu5qleGvXJoGgHpChBlrBx/mEhTPpnAL1ZAYFlvHD7rLK839LLKQzqEQMFJfGrrOHItN4ZQqA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.38.0.tgz} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.37.0': - resolution: {integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.37.0.tgz} + '@rollup/rollup-win32-x64-msvc@4.38.0': + resolution: {integrity: sha512-jjqy3uWlecfB98Psxb5cD6Fny9Fupv9LrDSPTQZUROqjvZmcCqNu4UMl7qqhlUUGpwiAkotj6GYu4SZdcr/nLw==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.38.0.tgz} cpu: [x64] os: [win32] @@ -3750,6 +3750,7 @@ packages: eslint@8.52.0: resolution: {integrity: sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==, tarball: https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -5681,8 +5682,8 @@ packages: rollup: optional: true - rollup@4.37.0: - resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.37.0.tgz} + rollup@4.38.0: + resolution: {integrity: sha512-5SsIRtJy9bf1ErAOiFMFzl64Ex9X5V7bnJ+WlFMb+zmP459OSWCEG7b0ERZ+PEU7xPt4OG3RHbrp1LJlXxYTrw==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.38.0.tgz} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6337,8 +6338,8 @@ packages: vite-plugin-turbosnap@1.0.3: resolution: {integrity: sha512-p4D8CFVhZS412SyQX125qxyzOgIFouwOcvjZWk6bQbNPR1wtaEzFT6jZxAjf1dejlGqa6fqHcuCvQea6EWUkUA==, tarball: https://registry.npmjs.org/vite-plugin-turbosnap/-/vite-plugin-turbosnap-1.0.3.tgz} - vite@5.4.15: - resolution: {integrity: sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.15.tgz} + vite@5.4.16: + resolution: {integrity: sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.16.tgz} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -7403,11 +7404,11 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16))': dependencies: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.6.3) - vite: 5.4.15(@types/node@20.17.16) + vite: 5.4.16(@types/node@20.17.16) optionalDependencies: typescript: 5.6.3 @@ -8163,72 +8164,72 @@ snapshots: '@remix-run/router@1.19.2': {} - '@rollup/pluginutils@5.0.5(rollup@4.37.0)': + '@rollup/pluginutils@5.0.5(rollup@4.38.0)': dependencies: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 4.37.0 + rollup: 4.38.0 - '@rollup/rollup-android-arm-eabi@4.37.0': + '@rollup/rollup-android-arm-eabi@4.38.0': optional: true - '@rollup/rollup-android-arm64@4.37.0': + '@rollup/rollup-android-arm64@4.38.0': optional: true - '@rollup/rollup-darwin-arm64@4.37.0': + '@rollup/rollup-darwin-arm64@4.38.0': optional: true - '@rollup/rollup-darwin-x64@4.37.0': + '@rollup/rollup-darwin-x64@4.38.0': optional: true - '@rollup/rollup-freebsd-arm64@4.37.0': + '@rollup/rollup-freebsd-arm64@4.38.0': optional: true - '@rollup/rollup-freebsd-x64@4.37.0': + '@rollup/rollup-freebsd-x64@4.38.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.37.0': + '@rollup/rollup-linux-arm-gnueabihf@4.38.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.37.0': + '@rollup/rollup-linux-arm-musleabihf@4.38.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.37.0': + '@rollup/rollup-linux-arm64-gnu@4.38.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.37.0': + '@rollup/rollup-linux-arm64-musl@4.38.0': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.37.0': + '@rollup/rollup-linux-loongarch64-gnu@4.38.0': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.37.0': + '@rollup/rollup-linux-powerpc64le-gnu@4.38.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.37.0': + '@rollup/rollup-linux-riscv64-gnu@4.38.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.37.0': + '@rollup/rollup-linux-riscv64-musl@4.38.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.37.0': + '@rollup/rollup-linux-s390x-gnu@4.38.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.37.0': + '@rollup/rollup-linux-x64-gnu@4.38.0': optional: true - '@rollup/rollup-linux-x64-musl@4.37.0': + '@rollup/rollup-linux-x64-musl@4.38.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.37.0': + '@rollup/rollup-win32-arm64-msvc@4.38.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.37.0': + '@rollup/rollup-win32-ia32-msvc@4.38.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.37.0': + '@rollup/rollup-win32-x64-msvc@4.38.0': optional: true '@sinclair/typebox@0.27.8': {} @@ -8369,13 +8370,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@5.4.15(@types/node@20.17.16))': + '@storybook/builder-vite@8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@5.4.16(@types/node@20.17.16))': dependencies: '@storybook/csf-plugin': 8.4.6(storybook@8.5.3(prettier@3.4.1)) browser-assert: 1.2.1 storybook: 8.5.3(prettier@3.4.1) ts-dedent: 2.2.0 - vite: 5.4.15(@types/node@20.17.16) + vite: 5.4.16(@types/node@20.17.16) '@storybook/channels@8.1.11': dependencies: @@ -8472,11 +8473,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.5.3(prettier@3.4.1) - '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.37.0)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16))': + '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.38.0)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16)) - '@rollup/pluginutils': 5.0.5(rollup@4.37.0) - '@storybook/builder-vite': 8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@5.4.15(@types/node@20.17.16)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16)) + '@rollup/pluginutils': 5.0.5(rollup@4.38.0) + '@storybook/builder-vite': 8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@5.4.16(@types/node@20.17.16)) '@storybook/react': 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3) find-up: 5.0.0 magic-string: 0.30.5 @@ -8486,7 +8487,7 @@ snapshots: resolve: 1.22.8 storybook: 8.5.3(prettier@3.4.1) tsconfig-paths: 4.2.0 - vite: 5.4.15(@types/node@20.17.16) + vite: 5.4.16(@types/node@20.17.16) transitivePeerDependencies: - '@storybook/test' - rollup @@ -8983,14 +8984,14 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.3.4(vite@5.4.15(@types/node@20.17.16))': + '@vitejs/plugin-react@4.3.4(vite@5.4.16(@types/node@20.17.16))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.15(@types/node@20.17.16) + vite: 5.4.16(@types/node@20.17.16) transitivePeerDependencies: - supports-color @@ -12513,39 +12514,39 @@ snapshots: glob: 7.2.3 optional: true - rollup-plugin-visualizer@5.14.0(rollup@4.37.0): + rollup-plugin-visualizer@5.14.0(rollup@4.38.0): dependencies: open: 8.4.2 picomatch: 4.0.2 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.37.0 + rollup: 4.38.0 - rollup@4.37.0: + rollup@4.38.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.37.0 - '@rollup/rollup-android-arm64': 4.37.0 - '@rollup/rollup-darwin-arm64': 4.37.0 - '@rollup/rollup-darwin-x64': 4.37.0 - '@rollup/rollup-freebsd-arm64': 4.37.0 - '@rollup/rollup-freebsd-x64': 4.37.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.37.0 - '@rollup/rollup-linux-arm-musleabihf': 4.37.0 - '@rollup/rollup-linux-arm64-gnu': 4.37.0 - '@rollup/rollup-linux-arm64-musl': 4.37.0 - '@rollup/rollup-linux-loongarch64-gnu': 4.37.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.37.0 - '@rollup/rollup-linux-riscv64-gnu': 4.37.0 - '@rollup/rollup-linux-riscv64-musl': 4.37.0 - '@rollup/rollup-linux-s390x-gnu': 4.37.0 - '@rollup/rollup-linux-x64-gnu': 4.37.0 - '@rollup/rollup-linux-x64-musl': 4.37.0 - '@rollup/rollup-win32-arm64-msvc': 4.37.0 - '@rollup/rollup-win32-ia32-msvc': 4.37.0 - '@rollup/rollup-win32-x64-msvc': 4.37.0 + '@rollup/rollup-android-arm-eabi': 4.38.0 + '@rollup/rollup-android-arm64': 4.38.0 + '@rollup/rollup-darwin-arm64': 4.38.0 + '@rollup/rollup-darwin-x64': 4.38.0 + '@rollup/rollup-freebsd-arm64': 4.38.0 + '@rollup/rollup-freebsd-x64': 4.38.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.38.0 + '@rollup/rollup-linux-arm-musleabihf': 4.38.0 + '@rollup/rollup-linux-arm64-gnu': 4.38.0 + '@rollup/rollup-linux-arm64-musl': 4.38.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.38.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.38.0 + '@rollup/rollup-linux-riscv64-gnu': 4.38.0 + '@rollup/rollup-linux-riscv64-musl': 4.38.0 + '@rollup/rollup-linux-s390x-gnu': 4.38.0 + '@rollup/rollup-linux-x64-gnu': 4.38.0 + '@rollup/rollup-linux-x64-musl': 4.38.0 + '@rollup/rollup-win32-arm64-msvc': 4.38.0 + '@rollup/rollup-win32-ia32-msvc': 4.38.0 + '@rollup/rollup-win32-x64-msvc': 4.38.0 fsevents: 2.3.3 run-async@3.0.0: {} @@ -13233,7 +13234,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-checker@0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.15(@types/node@20.17.16)): + vite-plugin-checker@0.8.0(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@5.4.16(@types/node@20.17.16)): dependencies: '@babel/code-frame': 7.25.7 ansi-escapes: 4.3.2 @@ -13245,7 +13246,7 @@ snapshots: npm-run-path: 4.0.1 strip-ansi: 6.0.1 tiny-invariant: 1.3.3 - vite: 5.4.15(@types/node@20.17.16) + vite: 5.4.16(@types/node@20.17.16) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.12 @@ -13258,11 +13259,11 @@ snapshots: vite-plugin-turbosnap@1.0.3: {} - vite@5.4.15(@types/node@20.17.16): + vite@5.4.16(@types/node@20.17.16): dependencies: esbuild: 0.21.5 postcss: 8.5.1 - rollup: 4.37.0 + rollup: 4.38.0 optionalDependencies: '@types/node': 20.17.16 fsevents: 2.3.3 From 7b14b4f5e1f23fd43e2dc9509d238fec73b46acf Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 31 Mar 2025 15:10:00 -0400 Subject: [PATCH 004/457] chore: update msw to 2.4.8 (#17167) - Fixes a transitive vuln in path-to-regexp --- site/jest.config.ts | 2 +- site/package.json | 5 +- site/pnpm-lock.yaml | 204 +++++++++++++++++++++++++------------------- 3 files changed, 122 insertions(+), 89 deletions(-) diff --git a/site/jest.config.ts b/site/jest.config.ts index 3131500df0131..a07fa22246242 100644 --- a/site/jest.config.ts +++ b/site/jest.config.ts @@ -27,7 +27,7 @@ module.exports = { }, ], }, - testEnvironment: "jsdom", + testEnvironment: "jest-fixed-jsdom", testEnvironmentOptions: { customExportConditions: [""], }, diff --git a/site/package.json b/site/package.json index ba115cbff00f8..ad1d449226ae1 100644 --- a/site/package.json +++ b/site/package.json @@ -24,7 +24,7 @@ "storybook": "STORYBOOK=true storybook dev -p 6006", "storybook:build": "storybook build", "storybook:ci": "storybook build --test", - "test": "jest --selectProjects test", + "test": "jest", "test:ci": "jest --selectProjects test --silent", "test:coverage": "jest --selectProjects test --collectCoverage", "test:watch": "jest --selectProjects test --watch", @@ -170,10 +170,11 @@ "jest": "29.7.0", "jest-canvas-mock": "2.5.2", "jest-environment-jsdom": "29.5.0", + "jest-fixed-jsdom": "0.0.9", "jest-location-mock": "2.0.0", "jest-websocket-mock": "2.5.0", "jest_workaround": "0.1.14", - "msw": "2.4.3", + "msw": "2.4.8", "postcss": "8.5.1", "protobufjs": "7.4.0", "rxjs": "7.8.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 1eed3227d4de2..a2f87b0e91ea4 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: jest-environment-jsdom: specifier: 29.5.0 version: 29.5.0(canvas@3.1.0) + jest-fixed-jsdom: + specifier: 0.0.9 + version: 0.0.9(jest-environment-jsdom@29.5.0(canvas@3.1.0)) jest-location-mock: specifier: 2.0.0 version: 2.0.0 @@ -425,8 +428,8 @@ importers: specifier: 0.1.14 version: 0.1.14(@swc/core@1.3.38)(@swc/jest@0.2.37(@swc/core@1.3.38)) msw: - specifier: 2.4.3 - version: 2.4.3(typescript@5.6.3) + specifier: 2.4.8 + version: 2.4.8(typescript@5.6.3) postcss: specifier: 8.5.1 version: 8.5.1 @@ -752,8 +755,8 @@ packages: cpu: [x64] os: [win32] - '@bundled-es-modules/cookie@2.0.0': - resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==, tarball: https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz} + '@bundled-es-modules/cookie@2.0.1': + resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==, tarball: https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz} '@bundled-es-modules/statuses@1.0.1': resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==, tarball: https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz} @@ -1185,16 +1188,24 @@ packages: peerDependencies: react: '*' - '@inquirer/confirm@3.0.0': - resolution: {integrity: sha512-LHeuYP1D8NmQra1eR4UqvZMXwxEdDXyElJmmZfU44xdNLL6+GcQBS0uE16vyfZVjH8c22p9e+DStROfE/hyHrg==, tarball: https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.0.0.tgz} + '@inquirer/confirm@3.2.0': + resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==, tarball: https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz} engines: {node: '>=18'} - '@inquirer/core@7.0.0': - resolution: {integrity: sha512-g13W5yEt9r1sEVVriffJqQ8GWy94OnfxLCreNSOTw0HPVcszmc/If1KIf7YBmlwtX4klmvwpZHnQpl3N7VX2xA==, tarball: https://registry.npmjs.org/@inquirer/core/-/core-7.0.0.tgz} + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==, tarball: https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz} engines: {node: '>=18'} - '@inquirer/type@1.2.0': - resolution: {integrity: sha512-/vvkUkYhrjbm+RolU7V1aUFDydZVKNKqKHR5TsE+j5DXgXFwrsOPcoGUJ02K0O7q7O53CU2DOTMYCHeGZ25WHA==, tarball: https://registry.npmjs.org/@inquirer/type/-/type-1.2.0.tgz} + '@inquirer/figures@1.0.11': + resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==, tarball: https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz} + engines: {node: '>=18'} + + '@inquirer/type@1.5.5': + resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==, tarball: https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz} + engines: {node: '>=18'} + + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==, tarball: https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz} engines: {node: '>=18'} '@isaacs/cliui@8.0.2': @@ -1348,8 +1359,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@mswjs/interceptors@0.29.1': - resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==, tarball: https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz} + '@mswjs/interceptors@0.35.9': + resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==, tarball: https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.35.9.tgz} engines: {node: '>=18'} '@mui/base@5.0.0-beta.40-0': @@ -2726,6 +2737,9 @@ packages: '@types/node@20.17.16': resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==, tarball: https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz} + '@types/node@22.13.14': + resolution: {integrity: sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==, tarball: https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz} + '@types/parse-json@4.0.0': resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==, tarball: https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz} @@ -2795,8 +2809,8 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==, tarball: https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz} - '@types/statuses@2.0.4': - resolution: {integrity: sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==, tarball: https://registry.npmjs.org/@types/statuses/-/statuses-2.0.4.tgz} + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==, tarball: https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz} '@types/tough-cookie@4.0.2': resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==, tarball: https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz} @@ -3266,10 +3280,6 @@ packages: classnames@2.3.2: resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==, tarball: https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz} - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==, tarball: https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz} - engines: {node: '>=6'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==, tarball: https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz} engines: {node: '>= 12'} @@ -3356,14 +3366,14 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==, tarball: https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz} - cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz} - engines: {node: '>= 0.6'} - cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz} + engines: {node: '>= 0.6'} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==, tarball: https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz} engines: {node: '>=12.13'} @@ -3847,10 +3857,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, tarball: https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz} - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==, tarball: https://registry.npmjs.org/figures/-/figures-3.2.0.tgz} - engines: {node: '>=8'} - file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==, tarball: https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz} engines: {node: ^10.12.0 || >=12.0.0} @@ -4023,8 +4029,8 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, tarball: https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz} - graphql@16.8.1: - resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==, tarball: https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz} + graphql@16.10.0: + resolution: {integrity: sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==, tarball: https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-bigints@1.0.2: @@ -4072,8 +4078,8 @@ packages: hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz} - headers-polyfill@4.0.2: - resolution: {integrity: sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.2.tgz} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz} highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz} @@ -4429,6 +4435,12 @@ packages: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==, tarball: https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-fixed-jsdom@0.0.9: + resolution: {integrity: sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==, tarball: https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz} + engines: {node: '>=18.0.0'} + peerDependencies: + jest-environment-jsdom: '>=28.0.0' + jest-get-type@29.4.3: resolution: {integrity: sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==, tarball: https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5003,8 +5015,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz} - msw@2.4.3: - resolution: {integrity: sha512-PXK3wOQHwDtz6JYVyAVlQtzrLr6bOAJxggw5UHm3CId79+W7238aNBD1zJVkFY53o/DMacuIfgesW2nv9yCO3Q==, tarball: https://registry.npmjs.org/msw/-/msw-2.4.3.tgz} + msw@2.4.8: + resolution: {integrity: sha512-a+FUW1m5yT8cV9GBy0L/cbNg0EA4//SKEzgu3qFrpITrWYeZmqfo7dqtM74T2lAl69jjUjjCaEhZKaxG2Ns8DA==, tarball: https://registry.npmjs.org/msw/-/msw-2.4.8.tgz} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -5112,8 +5124,8 @@ packages: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==, tarball: https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz} engines: {node: '>= 0.8.0'} - outvariant@1.4.2: - resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==, tarball: https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==, tarball: https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz} p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz} @@ -5187,8 +5199,8 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz} - path-to-regexp@6.2.1: - resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, tarball: https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz} @@ -5352,6 +5364,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, tarball: https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==, tarball: https://registry.npmjs.org/psl/-/psl-1.15.0.tgz} + psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==, tarball: https://registry.npmjs.org/psl/-/psl-1.9.0.tgz} @@ -5687,10 +5702,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==, tarball: https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, tarball: https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz} @@ -6145,8 +6156,8 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==, tarball: https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz} engines: {node: '>=12.20'} - type-fest@4.11.1: - resolution: {integrity: sha512-MFMf6VkEVZAETidGGSYW2B1MjXbGX+sWIywn2QPEaJ3j08V+MwVRHMXtf2noB8ENJaD0LIun9wh5Z6OPNf1QzQ==, tarball: https://registry.npmjs.org/type-fest/-/type-fest-4.11.1.tgz} + type-fest@4.38.0: + resolution: {integrity: sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==, tarball: https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz} engines: {node: '>=16'} type-is@1.6.18: @@ -6171,6 +6182,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz} + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz} + undici@6.21.1: resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==, tarball: https://registry.npmjs.org/undici/-/undici-6.21.1.tgz} engines: {node: '>=18.17'} @@ -6524,6 +6538,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz} engines: {node: '>=10'} + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==, tarball: https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz} + engines: {node: '>=18'} + yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz} @@ -6823,9 +6841,9 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true - '@bundled-es-modules/cookie@2.0.0': + '@bundled-es-modules/cookie@2.0.1': dependencies: - cookie: 0.5.0 + cookie: 0.7.2 '@bundled-es-modules/statuses@1.0.1': dependencies: @@ -7168,29 +7186,35 @@ snapshots: dependencies: react: 18.3.1 - '@inquirer/confirm@3.0.0': + '@inquirer/confirm@3.2.0': dependencies: - '@inquirer/core': 7.0.0 - '@inquirer/type': 1.2.0 + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 - '@inquirer/core@7.0.0': + '@inquirer/core@9.2.1': dependencies: - '@inquirer/type': 1.2.0 + '@inquirer/figures': 1.0.11 + '@inquirer/type': 2.0.0 '@types/mute-stream': 0.0.4 - '@types/node': 20.17.16 + '@types/node': 22.13.14 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-spinners: 2.9.2 cli-width: 4.1.0 - figures: 3.2.0 mute-stream: 1.0.0 - run-async: 3.0.0 signal-exit: 4.1.0 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 - '@inquirer/type@1.2.0': {} + '@inquirer/figures@1.0.11': {} + + '@inquirer/type@1.5.5': + dependencies: + mute-stream: 1.0.0 + + '@inquirer/type@2.0.0': + dependencies: + mute-stream: 1.0.0 '@isaacs/cliui@8.0.2': dependencies: @@ -7456,13 +7480,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@mswjs/interceptors@0.29.1': + '@mswjs/interceptors@0.35.9': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 '@open-draft/until': 2.1.0 is-node-process: 1.2.0 - outvariant: 1.4.2 + outvariant: 1.4.3 strict-event-emitter: 0.5.1 '@mui/base@5.0.0-beta.40-0(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -7633,7 +7657,7 @@ snapshots: '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 - outvariant: 1.4.2 + outvariant: 1.4.3 '@open-draft/until@2.1.0': {} @@ -8870,7 +8894,7 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 20.17.16 + '@types/node': 22.13.14 '@types/node@18.19.74': dependencies: @@ -8880,6 +8904,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.13.14': + dependencies: + undici-types: 6.20.0 + '@types/parse-json@4.0.0': {} '@types/prop-types@15.7.13': {} @@ -8952,7 +8980,7 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/statuses@2.0.4': {} + '@types/statuses@2.0.5': {} '@types/tough-cookie@4.0.2': {} @@ -9460,8 +9488,6 @@ snapshots: classnames@2.3.2: {} - cli-spinners@2.9.2: {} - cli-width@4.1.0: {} cliui@8.0.1: @@ -9532,10 +9558,10 @@ snapshots: cookie-signature@1.0.6: {} - cookie@0.5.0: {} - cookie@0.7.1: {} + cookie@0.7.2: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 @@ -10112,10 +10138,6 @@ snapshots: dependencies: bser: 2.1.1 - figures@3.2.0: - dependencies: - escape-string-regexp: 1.0.5 - file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -10295,7 +10317,7 @@ snapshots: graphemer@1.4.0: optional: true - graphql@16.8.1: {} + graphql@16.10.0: {} has-bigints@1.0.2: {} @@ -10359,7 +10381,7 @@ snapshots: property-information: 5.6.0 space-separated-tokens: 1.1.5 - headers-polyfill@4.0.2: {} + headers-polyfill@4.0.3: {} highlight.js@10.7.3: {} @@ -10797,6 +10819,10 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-fixed-jsdom@0.0.9(jest-environment-jsdom@29.5.0(canvas@3.1.0)): + dependencies: + jest-environment-jsdom: 29.5.0(canvas@3.1.0) + jest-get-type@29.4.3: {} jest-get-type@29.6.3: {} @@ -11784,24 +11810,24 @@ snapshots: ms@2.1.3: {} - msw@2.4.3(typescript@5.6.3): + msw@2.4.8(typescript@5.6.3): dependencies: - '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 3.0.0 - '@mswjs/interceptors': 0.29.1 + '@inquirer/confirm': 3.2.0 + '@mswjs/interceptors': 0.35.9 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 - '@types/statuses': 2.0.4 + '@types/statuses': 2.0.5 chalk: 4.1.2 - graphql: 16.8.1 - headers-polyfill: 4.0.2 + graphql: 16.10.0 + headers-polyfill: 4.0.3 is-node-process: 1.2.0 - outvariant: 1.4.2 - path-to-regexp: 6.2.1 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 strict-event-emitter: 0.5.1 - type-fest: 4.11.1 + type-fest: 4.38.0 yargs: 17.7.2 optionalDependencies: typescript: 5.6.3 @@ -11895,7 +11921,7 @@ snapshots: type-check: 0.4.0 optional: true - outvariant@1.4.2: {} + outvariant@1.4.3: {} p-limit@2.3.0: dependencies: @@ -11972,7 +11998,7 @@ snapshots: path-to-regexp@0.1.12: {} - path-to-regexp@6.2.1: {} + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -12133,6 +12159,10 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + psl@1.9.0: {} pump@3.0.2: @@ -12549,8 +12579,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.38.0 fsevents: 2.3.3 - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -12948,7 +12976,7 @@ snapshots: tough-cookie@4.1.4: dependencies: - psl: 1.9.0 + psl: 1.15.0 punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 @@ -13053,7 +13081,7 @@ snapshots: type-fest@2.19.0: {} - type-fest@4.11.1: {} + type-fest@4.38.0: {} type-is@1.6.18: dependencies: @@ -13070,6 +13098,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.20.0: {} + undici@6.21.1: {} unified@11.0.4: @@ -13403,6 +13433,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.2: {} + yup@1.6.1: dependencies: property-expr: 2.0.6 From 83405677bf75a7ab69847e40dae1c33b410002d9 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 31 Mar 2025 22:56:21 -0400 Subject: [PATCH 005/457] chore: pin goimports to 0.31.0 (#17177) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ff0978e5d807..1e23aa991bb1f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -252,7 +252,7 @@ jobs: run: | go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 - go install golang.org/x/tools/cmd/goimports@latest + go install golang.org/x/tools/cmd/goimports@v0.31.0 go install github.com/mikefarah/yq/v4@v4.44.3 go install go.uber.org/mock/mockgen@v0.5.0 From 989c3ec62e7f5c3d55b04ac3dbb650cfb6de6278 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 1 Apr 2025 00:15:15 -0400 Subject: [PATCH 006/457] chore: pin various dependencies in CI files (#17180) --- .github/workflows/ci.yaml | 2 +- dogfood/coder/Dockerfile | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e23aa991bb1f..2b4f69fb2e72f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -860,7 +860,7 @@ jobs: run: | go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 - go install golang.org/x/tools/cmd/goimports@latest + go install golang.org/x/tools/cmd/goimports@v0.31.0 go install github.com/mikefarah/yq/v4@v4.44.3 go install go.uber.org/mock/mockgen@v0.5.0 diff --git a/dogfood/coder/Dockerfile b/dogfood/coder/Dockerfile index d23156caf94f8..a5eb1c411883e 100644 --- a/dogfood/coder/Dockerfile +++ b/dogfood/coder/Dockerfile @@ -34,7 +34,7 @@ RUN apt-get update && \ # go-swagger tool to generate the go coder api client go install github.com/go-swagger/go-swagger/cmd/swagger@v0.28.0 && \ # goimports for updating imports - go install golang.org/x/tools/cmd/goimports@v0.1.7 && \ + go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ # protoc-gen-go is needed to build sysbox from source go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ # drpc support for v2 @@ -45,7 +45,7 @@ RUN apt-get update && \ go install github.com/goreleaser/goreleaser@v1.6.1 && \ # Install the latest version of gopls for editors that support # the language server protocol - go install golang.org/x/tools/gopls@latest && \ + go install golang.org/x/tools/gopls@v0.18.1 && \ # gotestsum makes test output more readable go install gotest.tools/gotestsum@v1.9.0 && \ # goveralls collects code coverage metrics from tests @@ -84,7 +84,8 @@ RUN apt-get update && \ rm -rf /tmp/go/pkg && \ rm -rf /tmp/go/src -FROM gcr.io/coder-dev-1/alpine:3.18 as proto +# alpine:3.18 +FROM gcr.io/coder-dev-1/alpine@sha256:25fad2a32ad1f6f510e528448ae1ec69a28ef81916a004d3629874104f8a7f70 AS proto WORKDIR /tmp RUN apk add curl unzip RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip && \ @@ -232,18 +233,22 @@ RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/rel tar xf doctl.tar.gz -C /usr/local/bin doctl && \ rm doctl.tar.gz +ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d # Install frontend utilities ENV NVM_DIR=/usr/local/nvm ENV NODE_VERSION=20.16.0 RUN mkdir -p $NVM_DIR -RUN curl https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash +RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ + echo "${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ + bash nvm_install.sh && \ + rm nvm_install.sh RUN source $NVM_DIR/nvm.sh && \ nvm install $NODE_VERSION && \ nvm use $NODE_VERSION ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH # Allow patch updates for npm and pnpm -RUN npm install -g npm@^10.8 -RUN npm install -g pnpm@^9.6 +RUN npm install -g npm@10.8.1 +RUN npm install -g pnpm@9.15.1 RUN pnpx playwright@1.47.0 install --with-deps chromium From cc733aba71e3c6be7146ea837bf3f84b502fcfd2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 1 Apr 2025 09:03:54 +0100 Subject: [PATCH 007/457] ci: check go versions are consistent (#17149) Fixes https://github.com/coder/coder/issues/17063 I'm ignoring flake.nix for now. ``` $ IGNORE_NIX=true ./scripts/check_go_versions.sh INFO : go.mod : 1.24.1 INFO : dogfood/coder/Dockerfile : 1.24.1 INFO : setup-go/action.yaml : 1.24.1 INFO : flake.nix : 1.22 INFO : Ignoring flake.nix, as IGNORE_NIX=true Go version check passed, all versions are 1.24.1 $ ./scripts/check_go_versions.sh INFO : go.mod : 1.24.1 INFO : dogfood/coder/Dockerfile : 1.24.1 INFO : setup-go/action.yaml : 1.24.1 INFO : flake.nix : 1.22 ERROR: Go version mismatch between go.mod and flake.nix ``` --- .github/workflows/ci.yaml | 3 +++ scripts/check_go_versions.sh | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100755 scripts/check_go_versions.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b4f69fb2e72f..64d274d1b46d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -299,6 +299,9 @@ jobs: - name: Setup Node uses: ./.github/actions/setup-node + - name: Check Go version + run: IGNORE_NIX=true ./scripts/check_go_versions.sh + # Use default Go version - name: Setup Go uses: ./.github/actions/setup-go diff --git a/scripts/check_go_versions.sh b/scripts/check_go_versions.sh new file mode 100755 index 0000000000000..8349960bd580a --- /dev/null +++ b/scripts/check_go_versions.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# This script ensures that the same version of Go is referenced in all of the +# following files: +# - go.mod +# - dogfood/coder/Dockerfile +# - flake.nix +# - .github/actions/setup-go/action.yml +# The version of Go in go.mod is considered the source of truth. + +set -euo pipefail +# shellcheck source=scripts/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +cdroot + +# At the time of writing, Nix only has go 1.22.x. +# We don't want to fail the build for this reason. +IGNORE_NIX=${IGNORE_NIX:-false} + +GO_VERSION_GO_MOD=$(grep -Eo 'go [0-9]+\.[0-9]+\.[0-9]+' ./go.mod | cut -d' ' -f2) +GO_VERSION_DOCKERFILE=$(grep -Eo 'ARG GO_VERSION=[0-9]+\.[0-9]+\.[0-9]+' ./dogfood/coder/Dockerfile | cut -d'=' -f2) +GO_VERSION_SETUP_GO=$(yq '.inputs.version.default' .github/actions/setup-go/action.yaml) +GO_VERSION_FLAKE_NIX=$(grep -Eo '\bgo_[0-9]+_[0-9]+\b' ./flake.nix) +# Convert to major.minor format. +GO_VERSION_FLAKE_NIX_MAJOR_MINOR=$(echo "$GO_VERSION_FLAKE_NIX" | cut -d '_' -f 2-3 | tr '_' '.') +log "INFO : go.mod : $GO_VERSION_GO_MOD" +log "INFO : dogfood/coder/Dockerfile : $GO_VERSION_DOCKERFILE" +log "INFO : setup-go/action.yaml : $GO_VERSION_SETUP_GO" +log "INFO : flake.nix : $GO_VERSION_FLAKE_NIX_MAJOR_MINOR" + +if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_DOCKERFILE" ]; then + error "Go version mismatch between go.mod and dogfood/coder/Dockerfile:" +fi + +if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_SETUP_GO" ]; then + error "Go version mismatch between go.mod and .github/actions/setup-go/action.yaml" +fi + +# At the time of writing, Nix only constrains the major.minor version. +# We need to check that specifically. +if [ "$IGNORE_NIX" = "false" ]; then + GO_VERSION_GO_MOD_MAJOR_MINOR=$(echo "$GO_VERSION_GO_MOD" | cut -d '.' -f 1-2) + if [ "$GO_VERSION_FLAKE_NIX_MAJOR_MINOR" != "$GO_VERSION_GO_MOD_MAJOR_MINOR" ]; then + error "Go version mismatch between go.mod and flake.nix" + fi +else + log "INFO : Ignoring flake.nix, as IGNORE_NIX=${IGNORE_NIX}" +fi + +log "Go version check passed, all versions are $GO_VERSION_GO_MOD" From e4cf18989c0db6560e785c042f64617af4d88b66 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 1 Apr 2025 11:28:47 +0100 Subject: [PATCH 008/457] chore(mcp): fix test flakes (#17183) Closes https://github.com/coder/internal/issues/547 --- mcp/mcp_test.go | 64 ++++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index f2573f44a1be6..1144d9265aa15 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -77,15 +77,12 @@ func TestCoderTools(t *testing.T) { pty.WriteLine(ctr) _ = pty.ReadLine(ctx) // skip the echo - templates, err := memberClient.Templates(ctx, codersdk.TemplateFilter{}) + // Then: the response is a list of expected visible to the user. + expected, err := memberClient.Templates(ctx, codersdk.TemplateFilter{}) require.NoError(t, err) - templatesJSON, err := json.Marshal(templates) - require.NoError(t, err) - - // Then: the response is a list of templates visible to the user. - expected := makeJSONRPCTextResponse(t, string(templatesJSON)) - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + actual := unmarshalFromCallToolResult[[]codersdk.Template](t, pty.ReadLine(ctx)) + require.Len(t, actual, 1) + require.Equal(t, expected[0].ID, actual[0].ID) }) t.Run("coder_report_task", func(t *testing.T) { @@ -111,20 +108,16 @@ func TestCoderTools(t *testing.T) { t.Run("coder_whoami", func(t *testing.T) { // When: the coder_whoami tool is called - me, err := memberClient.User(ctx, codersdk.Me) - require.NoError(t, err) - meJSON, err := json.Marshal(me) - require.NoError(t, err) - ctr := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{}) pty.WriteLine(ctr) _ = pty.ReadLine(ctx) // skip the echo // Then: the response is a valid JSON respresentation of the calling user. - expected := makeJSONRPCTextResponse(t, string(meJSON)) - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + expected, err := memberClient.User(ctx, codersdk.Me) + require.NoError(t, err) + actual := unmarshalFromCallToolResult[codersdk.User](t, pty.ReadLine(ctx)) + require.Equal(t, expected.ID, actual.ID) }) t.Run("coder_list_workspaces", func(t *testing.T) { @@ -138,15 +131,10 @@ func TestCoderTools(t *testing.T) { pty.WriteLine(ctr) _ = pty.ReadLine(ctx) // skip the echo - ws, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{}) - require.NoError(t, err) - wsJSON, err := json.Marshal(ws) - require.NoError(t, err) - // Then: the response is a valid JSON respresentation of the calling user's workspaces. - expected := makeJSONRPCTextResponse(t, string(wsJSON)) - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + actual := unmarshalFromCallToolResult[codersdk.WorkspacesResponse](t, pty.ReadLine(ctx)) + require.Len(t, actual.Workspaces, 1, "expected 1 workspace") + require.Equal(t, r.Workspace.ID, actual.Workspaces[0].ID, "expected the workspace to be the one we created in setup") }) t.Run("coder_get_workspace", func(t *testing.T) { @@ -161,15 +149,12 @@ func TestCoderTools(t *testing.T) { pty.WriteLine(ctr) _ = pty.ReadLine(ctx) // skip the echo - ws, err := memberClient.Workspace(ctx, r.Workspace.ID) - require.NoError(t, err) - wsJSON, err := json.Marshal(ws) + expected, err := memberClient.Workspace(ctx, r.Workspace.ID) require.NoError(t, err) // Then: the response is a valid JSON respresentation of the workspace. - expected := makeJSONRPCTextResponse(t, string(wsJSON)) - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + actual := unmarshalFromCallToolResult[codersdk.Workspace](t, pty.ReadLine(ctx)) + require.Equal(t, expected.ID, actual.ID) }) // NOTE: this test runs after the list_workspaces tool is called. @@ -322,6 +307,25 @@ func makeJSONRPCTextResponse(t *testing.T, text string) string { return string(bs) } +func unmarshalFromCallToolResult[T any](t *testing.T, raw string) T { + t.Helper() + + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &resp), "failed to unmarshal JSON RPC response") + res, ok := resp["result"].(map[string]any) + require.True(t, ok, "expected a result field in the response") + ct, ok := res["content"].([]any) + require.True(t, ok, "expected a content field in the result") + require.Len(t, ct, 1, "expected a single content item in the result") + ct0, ok := ct[0].(map[string]any) + require.True(t, ok, "expected a content item in the result") + txt, ok := ct0["text"].(string) + require.True(t, ok, "expected a text field in the content item") + var actual T + require.NoError(t, json.Unmarshal([]byte(txt), &actual), "failed to unmarshal content") + return actual +} + // startTestMCPServer is a helper function that starts a MCP server listening on // a pty. It is the responsibility of the caller to close the server. func startTestMCPServer(ctx context.Context, t testing.TB, stdin io.Reader, stdout io.Writer) (*server.MCPServer, func() error) { From 7d08bf0afe79e75961aeda9407dae05ead3f8942 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 1 Apr 2025 13:23:06 +0200 Subject: [PATCH 009/457] chore: improve error logging in TestServer/EphemeralDeployment (#17184) There's a flake reported in https://github.com/coder/internal/issues/549 that was caused by the built-in Postgres failing to start. However, the test was written in a way that didn't log the actual error which caused Postgres to fail. This PR improves error logging in the affected test so that the next time the error happens, we know what it is. --- cli/server_test.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/cli/server_test.go b/cli/server_test.go index f224fcb43fe63..715cbe5c7584c 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -201,7 +201,16 @@ func TestServer(t *testing.T) { go func() { errCh <- inv.WithContext(ctx).Run() }() - pty.ExpectMatch("Using an ephemeral deployment directory") + matchCh1 := make(chan string, 1) + go func() { + matchCh1 <- pty.ExpectMatchContext(ctx, "Using an ephemeral deployment directory") + }() + select { + case err := <-errCh: + require.NoError(t, err) + case <-matchCh1: + // OK! + } rootDirLine := pty.ReadLine(ctx) rootDir := strings.TrimPrefix(rootDirLine, "Using an ephemeral deployment directory") rootDir = strings.TrimSpace(rootDir) @@ -210,7 +219,17 @@ func TestServer(t *testing.T) { require.NotEmpty(t, rootDir) require.DirExists(t, rootDir) - pty.ExpectMatchContext(ctx, "View the Web UI") + matchCh2 := make(chan string, 1) + go func() { + // The "View the Web UI" log is a decent indicator that the server was successfully started. + matchCh2 <- pty.ExpectMatchContext(ctx, "View the Web UI") + }() + select { + case err := <-errCh: + require.NoError(t, err) + case <-matchCh2: + // OK! + } cancelFunc() <-errCh From 3a243c111b9abb5c38328169ff70064025bbe2fe Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 1 Apr 2025 22:28:05 +1100 Subject: [PATCH 010/457] fix: remove shared mutable state between oidc tests (#17179) Spotted on main: https://github.com/coder/coder/actions/runs/14179449567/job/39721999486 ``` === FAIL: coderd TestOIDCDomainErrorMessage/MalformedEmailErrorOmitsDomains (0.01s) ================== WARNING: DATA RACE Read at 0x00c060b54e68 by goroutine 296485: golang.org/x/oauth2.(*Config).Exchange() /home/runner/go/pkg/mod/golang.org/x/oauth2@v0.28.0/oauth2.go:228 +0x1d8 github.com/coder/coder/v2/coderd.(*OIDCConfig).Exchange() :1 +0xb7 github.com/coder/coder/v2/coderd.New.func11.12.1.2.ExtractOAuth2.1.1() /home/runner/work/coder/coder/coderd/httpmw/oauth2.go:168 +0x7b5 net/http.HandlerFunc.ServeHTTP() /opt/hostedtoolcache/go/1.24.1/x64/src/net/http/server.go:2294 +0x47 [...] Previous write at 0x00c060b54e68 by goroutine 55730: github.com/coder/coder/v2/coderd/coderdtest/oidctest.(*FakeIDP).SetRedirect() /home/runner/work/coder/coder/coderd/coderdtest/oidctest/idp.go:1280 +0x1e6 github.com/coder/coder/v2/coderd/coderdtest/oidctest.(*FakeIDP).LoginWithClient() /home/runner/work/coder/coder/coderd/coderdtest/oidctest/idp.go:494 +0x170 github.com/coder/coder/v2/coderd/coderdtest/oidctest.(*FakeIDP).AttemptLogin() /home/runner/work/coder/coder/coderd/coderdtest/oidctest/idp.go:479 +0x624 github.com/coder/coder/v2/coderd_test.TestOIDCDomainErrorMessage.func3() /home/runner/work/coder/coder/coderd/userauth_test.go:2041 +0x1f2 ``` As seen, this race was caused by sharing a `*oidctest.FakeIDP` between test cases. The fix is to simply do the setup twice. ``` $ go test -race -run "TestOIDCDomainErrorMessage" github.com/coder/coder/v2/coderd -count=100 ok github.com/coder/coder/v2/coderd 7.551s ```` --- coderd/userauth_test.go | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index ad8e126706dd1..ddf3dceba236f 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1988,22 +1988,28 @@ func TestUserLogout(t *testing.T) { func TestOIDCDomainErrorMessage(t *testing.T) { t.Parallel() - fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) - allowedDomains := []string{"allowed1.com", "allowed2.org", "company.internal"} - cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { - cfg.EmailDomain = allowedDomains - cfg.AllowSignups = true - }) - server := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: cfg, - }) + setup := func() (*oidctest.FakeIDP, *codersdk.Client) { + fake := oidctest.NewFakeIDP(t, oidctest.WithServing()) + + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.EmailDomain = allowedDomains + cfg.AllowSignups = true + }) + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + return fake, client + } // Test case 1: Email domain not in allowed list t.Run("ErrorMessageOmitsDomains", func(t *testing.T) { t.Parallel() + fake, client := setup() + // Prepare claims with email from unauthorized domain claims := jwt.MapClaims{ "email": "user@unauthorized.com", @@ -2011,7 +2017,7 @@ func TestOIDCDomainErrorMessage(t *testing.T) { "sub": uuid.NewString(), } - _, resp := fake.AttemptLogin(t, server, claims) + _, resp := fake.AttemptLogin(t, client, claims) defer resp.Body.Close() require.Equal(t, http.StatusForbidden, resp.StatusCode) @@ -2031,6 +2037,8 @@ func TestOIDCDomainErrorMessage(t *testing.T) { t.Run("MalformedEmailErrorOmitsDomains", func(t *testing.T) { t.Parallel() + fake, client := setup() + // Prepare claims with an invalid email format (no @ symbol) claims := jwt.MapClaims{ "email": "invalid-email-without-domain", @@ -2038,7 +2046,7 @@ func TestOIDCDomainErrorMessage(t *testing.T) { "sub": uuid.NewString(), } - _, resp := fake.AttemptLogin(t, server, claims) + _, resp := fake.AttemptLogin(t, client, claims) defer resp.Body.Close() require.Equal(t, http.StatusForbidden, resp.StatusCode) From 1e11e823c9420713991a7838ad94528969d97d56 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 1 Apr 2025 15:02:08 +0100 Subject: [PATCH 011/457] fix(mcp): report task status correctly (#17187) --- cli/exp_mcp.go | 72 ++++++++++++++++++++------ mcp/mcp.go | 135 +++++++++++++++++------------------------------- mcp/mcp_test.go | 50 ++++++++++++++---- 3 files changed, 144 insertions(+), 113 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index a5af41d9103a6..b46a8b4d7f03a 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -4,14 +4,18 @@ import ( "context" "encoding/json" "errors" - "log" "os" "path/filepath" + "github.com/mark3labs/mcp-go/server" + "golang.org/x/xerrors" + "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" codermcp "github.com/coder/coder/v2/mcp" "github.com/coder/serpent" ) @@ -191,14 +195,16 @@ func (*RootCmd) mcpConfigureCursor() *serpent.Command { func (r *RootCmd) mcpServer() *serpent.Command { var ( - client = new(codersdk.Client) - instructions string - allowedTools []string + client = new(codersdk.Client) + instructions string + allowedTools []string + appStatusSlug string + mcpServerAgent bool ) return &serpent.Command{ Use: "server", Handler: func(inv *serpent.Invocation) error { - return mcpServerHandler(inv, client, instructions, allowedTools) + return mcpServerHandler(inv, client, instructions, allowedTools, appStatusSlug, mcpServerAgent) }, Short: "Start the Coder MCP server.", Middleware: serpent.Chain( @@ -209,24 +215,39 @@ func (r *RootCmd) mcpServer() *serpent.Command { Name: "instructions", Description: "The instructions to pass to the MCP server.", Flag: "instructions", + Env: "CODER_MCP_INSTRUCTIONS", Value: serpent.StringOf(&instructions), }, { Name: "allowed-tools", Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.", Flag: "allowed-tools", + Env: "CODER_MCP_ALLOWED_TOOLS", Value: serpent.StringArrayOf(&allowedTools), }, + { + Name: "app-status-slug", + Description: "When reporting a task, the coder_app slug under which to report the task.", + Flag: "app-status-slug", + Env: "CODER_MCP_APP_STATUS_SLUG", + Value: serpent.StringOf(&appStatusSlug), + Default: "", + }, + { + Flag: "agent", + Env: "CODER_MCP_SERVER_AGENT", + Description: "Start the MCP server in agent mode, with a different set of tools.", + Value: serpent.BoolOf(&mcpServerAgent), + }, }, } } -func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error { +//nolint:revive // control coupling +func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, appStatusSlug string, mcpServerAgent bool) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - logger := slog.Make(sloghuman.Sink(inv.Stdout)) - me, err := client.User(ctx, codersdk.Me) if err != nil { cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.") @@ -253,19 +274,40 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct inv.Stderr = invStderr }() - options := []codermcp.Option{ - codermcp.WithInstructions(instructions), - codermcp.WithLogger(&logger), + mcpSrv := server.NewMCPServer( + "Coder Agent", + buildinfo.Version(), + server.WithInstructions(instructions), + ) + + // Create a separate logger for the tools. + toolLogger := slog.Make(sloghuman.Sink(invStderr)) + + toolDeps := codermcp.ToolDeps{ + Client: client, + Logger: &toolLogger, + AppStatusSlug: appStatusSlug, + AgentClient: agentsdk.New(client.URL), + } + + if mcpServerAgent { + // Get the workspace agent token from the environment. + agentToken, ok := os.LookupEnv("CODER_AGENT_TOKEN") + if !ok || agentToken == "" { + return xerrors.New("CODER_AGENT_TOKEN is not set") + } + toolDeps.AgentClient.SetSessionToken(agentToken) } - // Add allowed tools option if specified + // Register tools based on the allowlist (if specified) + reg := codermcp.AllTools() if len(allowedTools) > 0 { - options = append(options, codermcp.WithAllowedTools(allowedTools)) + reg = reg.WithOnlyAllowed(allowedTools...) } - srv := codermcp.NewStdio(client, options...) - srv.SetErrorLogger(log.New(invStderr, "", log.LstdFlags)) + reg.Register(mcpSrv, toolDeps) + srv := server.NewStdioServer(mcpSrv) done := make(chan error) go func() { defer close(done) diff --git a/mcp/mcp.go b/mcp/mcp.go index 80e0f341e16e6..0dd01ccdc5fdd 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "io" - "os" "slices" "strings" "time" @@ -17,76 +16,12 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) -type mcpOptions struct { - instructions string - logger *slog.Logger - allowedTools []string -} - -// Option is a function that configures the MCP server. -type Option func(*mcpOptions) - -// WithInstructions sets the instructions for the MCP server. -func WithInstructions(instructions string) Option { - return func(o *mcpOptions) { - o.instructions = instructions - } -} - -// WithLogger sets the logger for the MCP server. -func WithLogger(logger *slog.Logger) Option { - return func(o *mcpOptions) { - o.logger = logger - } -} - -// WithAllowedTools sets the allowed tools for the MCP server. -func WithAllowedTools(tools []string) Option { - return func(o *mcpOptions) { - o.allowedTools = tools - } -} - -// NewStdio creates a new MCP stdio server with the given client and options. -// It is the responsibility of the caller to start and stop the server. -func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer { - options := &mcpOptions{ - instructions: ``, - logger: ptr.Ref(slog.Make(sloghuman.Sink(os.Stdout))), - } - for _, opt := range opts { - opt(options) - } - - mcpSrv := server.NewMCPServer( - "Coder Agent", - buildinfo.Version(), - server.WithInstructions(options.instructions), - ) - - logger := slog.Make(sloghuman.Sink(os.Stdout)) - - // Register tools based on the allowed list (if specified) - reg := AllTools() - if len(options.allowedTools) > 0 { - reg = reg.WithOnlyAllowed(options.allowedTools...) - } - reg.Register(mcpSrv, ToolDeps{ - Client: client, - Logger: &logger, - }) - - srv := server.NewStdioServer(mcpSrv) - return srv -} - // allTools is the list of all available tools. When adding a new tool, // make sure to update this list. var allTools = ToolRegistry{ @@ -120,6 +55,8 @@ Choose an emoji that helps the user understand the current phase at a glance.`), mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete. Set to true only when the entire requested operation is finished successfully. For multi-step processes, use false until all steps are complete.`), mcp.Required()), + mcp.WithBoolean("need_user_attention", mcp.Description(`Whether the user needs to take action on the task. +Set to true if the task is in a failed state or if the user needs to take action to continue.`), mcp.Required()), ), MakeHandler: handleCoderReportTask, }, @@ -265,8 +202,10 @@ Can be either "start" or "stop".`)), // ToolDeps contains all dependencies needed by tool handlers type ToolDeps struct { - Client *codersdk.Client - Logger *slog.Logger + Client *codersdk.Client + AgentClient *agentsdk.Client + Logger *slog.Logger + AppStatusSlug string } // ToolHandler associates a tool with its handler creation function @@ -313,18 +252,23 @@ func AllTools() ToolRegistry { } type handleCoderReportTaskArgs struct { - Summary string `json:"summary"` - Link string `json:"link"` - Emoji string `json:"emoji"` - Done bool `json:"done"` + Summary string `json:"summary"` + Link string `json:"link"` + Emoji string `json:"emoji"` + Done bool `json:"done"` + NeedUserAttention bool `json:"need_user_attention"` } // Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I need help with the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false, "need_user_attention": true}}} func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") + if deps.AgentClient == nil { + return nil, xerrors.New("developer error: agent client is required") + } + + if deps.AppStatusSlug == "" { + return nil, xerrors.New("No app status slug provided, set CODER_MCP_APP_STATUS_SLUG when running the MCP server to report tasks.") } // Convert the request parameters to a json.RawMessage so we can unmarshal @@ -334,20 +278,33 @@ func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc { return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } - // TODO: Waiting on support for tasks. - deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji)) - /* - err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ - Reporter: "claude", - Summary: summary, - URL: link, - Completion: done, - Icon: emoji, - }) - if err != nil { - return nil, err - } - */ + deps.Logger.Info(ctx, "report task tool called", + slog.F("summary", args.Summary), + slog.F("link", args.Link), + slog.F("emoji", args.Emoji), + slog.F("done", args.Done), + slog.F("need_user_attention", args.NeedUserAttention), + ) + + newStatus := agentsdk.PatchAppStatus{ + AppSlug: deps.AppStatusSlug, + Message: args.Summary, + URI: args.Link, + Icon: args.Emoji, + NeedsUserAttention: args.NeedUserAttention, + State: codersdk.WorkspaceAppStatusStateWorking, + } + + if args.Done { + newStatus.State = codersdk.WorkspaceAppStatusStateComplete + } + if args.NeedUserAttention { + newStatus.State = codersdk.WorkspaceAppStatusStateFailure + } + + if err := deps.AgentClient.PatchAppStatus(ctx, newStatus); err != nil { + return nil, xerrors.Errorf("failed to patch app status: %w", err) + } return &mcp.CallToolResult{ Content: []mcp.Content{ diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 1144d9265aa15..c5cf000efcfa3 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -17,7 +17,9 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" codermcp "github.com/coder/coder/v2/mcp" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -39,7 +41,14 @@ func TestCoderTools(t *testing.T) { r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ OrganizationID: owner.OrganizationID, OwnerID: member.ID, - }).WithAgent().Do() + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "some-agent-app", + }, + } + return agents + }).Do() // Note: we want to test the list_workspaces tool before starting the // workspace agent. Starting the workspace agent will modify the workspace @@ -65,9 +74,12 @@ func TestCoderTools(t *testing.T) { // Register tools using our registry logger := slogtest.Make(t, nil) + agentClient := agentsdk.New(memberClient.URL) codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{ - Client: memberClient, - Logger: &logger, + Client: memberClient, + Logger: &logger, + AppStatusSlug: "some-agent-app", + AgentClient: agentClient, }) t.Run("coder_list_templates", func(t *testing.T) { @@ -86,24 +98,44 @@ func TestCoderTools(t *testing.T) { }) t.Run("coder_report_task", func(t *testing.T) { + // Given: the MCP server has an agent token. + oldAgentToken := agentClient.SDK.SessionToken() + agentClient.SetSessionToken(r.AgentToken) + t.Cleanup(func() { + agentClient.SDK.SetSessionToken(oldAgentToken) + }) // When: the coder_report_task tool is called ctr := makeJSONRPCRequest(t, "tools/call", "coder_report_task", map[string]any{ "summary": "Test summary", "link": "https://example.com", "emoji": "🔍", "done": false, - "coder_url": client.URL.String(), - "coder_session_token": client.SessionToken(), + "need_user_attention": true, }) pty.WriteLine(ctr) _ = pty.ReadLine(ctx) // skip the echo - // Then: the response is a success message. - // TODO: check the task was created. This functionality is not yet implemented. - expected := makeJSONRPCTextResponse(t, "Thanks for reporting!") + // Then: positive feedback is given to the reporting agent. actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + require.Contains(t, actual, "Thanks for reporting!") + + // Then: the response is a success message. + ws, err := memberClient.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err, "failed to get workspace") + agt, err := memberClient.WorkspaceAgent(ctx, ws.LatestBuild.Resources[0].Agents[0].ID) + require.NoError(t, err, "failed to get workspace agent") + require.NotEmpty(t, agt.Apps, "workspace agent should have an app") + require.NotEmpty(t, agt.Apps[0].Statuses, "workspace agent app should have a status") + st := agt.Apps[0].Statuses[0] + // require.Equal(t, ws.ID, st.WorkspaceID, "workspace app status should have the correct workspace id") + require.Equal(t, agt.ID, st.AgentID, "workspace app status should have the correct agent id") + require.Equal(t, agt.Apps[0].ID, st.AppID, "workspace app status should have the correct app id") + require.Equal(t, codersdk.WorkspaceAppStatusStateFailure, st.State, "workspace app status should be in the failure state") + require.Equal(t, "Test summary", st.Message, "workspace app status should have the correct message") + require.Equal(t, "https://example.com", st.URI, "workspace app status should have the correct uri") + require.Equal(t, "🔍", st.Icon, "workspace app status should have the correct icon") + require.True(t, st.NeedsUserAttention, "workspace app status should need user attention") }) t.Run("coder_whoami", func(t *testing.T) { From fcac4abcca51d27df3fcd7379e6333da16690c51 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 1 Apr 2025 10:13:56 -0400 Subject: [PATCH 012/457] fix(site): standardize headers for Admin Settings page (#16911) ## Changes made - Switched almost all headers to use the `SettingHeader` component - Redesigned component to be more composition-based, to stay in line with the patterns we're starting to use more throughout the codebase - Refactored `SettingHeader` to be based on Radix and Tailwind, rather than Emotion/MUI - Added additional props to `SettingHeader` to help resolve issues with the component creating invalid HTML - Beefed up `SettingHeader` to have better out-of-the-box accessibility - Addressed some typographic problems in `SettingHeader` - Addressed some responsive layout problems for `SettingsHeader` - Added first-ever stories for `SettingsHeader` ## Notes - There are still a few headers that aren't using `SettingHeader` yet. There were some UI edge cases that meant I couldn't reliably bring it in without consulting the Design team first. I'm a little less worried about them, because they at least *look* like the other headers, but it'd be nice if we could centralize everything in a followup PR --- .../SettingsHeader/SettingsHeader.stories.tsx | 83 +++++++++ .../SettingsHeader/SettingsHeader.tsx | 161 +++++++++++------- .../AppearanceSettingsPageView.tsx | 16 +- .../ExternalAuthSettingsPageView.tsx | 19 ++- .../AddNewLicensePageView.tsx | 17 +- .../LicensesSettingsPageView.tsx | 16 +- .../NetworkSettingsPageView.tsx | 38 +++-- .../NotificationsPage/NotificationsPage.tsx | 37 ++-- .../CreateOAuth2AppPageView.tsx | 17 +- .../EditOAuth2AppPageView.tsx | 17 +- .../OAuth2AppsSettingsPageView.tsx | 16 +- .../ObservabilitySettingsPageView.tsx | 43 +++-- .../OverviewPage/OverviewPageView.tsx | 19 ++- .../SecuritySettingsPageView.tsx | 49 ++++-- .../UserAuthSettingsPageView.tsx | 45 +++-- .../pages/GroupsPage/CreateGroupPageView.tsx | 16 +- site/src/pages/GroupsPage/GroupPage.tsx | 19 ++- site/src/pages/GroupsPage/GroupsPage.tsx | 18 +- .../pages/HealthPage/WorkspaceProxyPage.tsx | 4 +- .../CreateEditRolePageView.tsx | 19 ++- .../CustomRolesPage/CustomRolesPage.tsx | 16 +- .../OrganizationMembersPageView.tsx | 10 +- .../OrganizationProvisionersPageView.tsx | 10 +- .../OrganizationSettingsPageView.tsx | 10 +- .../WorkspaceProxyPage/WorkspaceProxyPage.tsx | 35 +--- .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 13 ++ site/src/pages/UsersPage/UsersPageView.tsx | 39 +++-- 27 files changed, 556 insertions(+), 246 deletions(-) create mode 100644 site/src/components/SettingsHeader/SettingsHeader.stories.tsx diff --git a/site/src/components/SettingsHeader/SettingsHeader.stories.tsx b/site/src/components/SettingsHeader/SettingsHeader.stories.tsx new file mode 100644 index 0000000000000..75381d419c4dc --- /dev/null +++ b/site/src/components/SettingsHeader/SettingsHeader.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { docs } from "utils/docs"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderDocsLink, + SettingsHeaderTitle, +} from "./SettingsHeader"; + +const meta: Meta = { + title: "components/SettingsHeader", + component: SettingsHeader, +}; + +export default meta; +type Story = StoryObj; + +export const PrimaryHeaderOnly: Story = { + args: { + children: This is a header, + }, +}; + +export const PrimaryHeaderWithDescription: Story = { + args: { + children: ( + <> + Another primary header + + This description can be any ReactNode. This provides more options for + composition. + + + ), + }, +}; + +export const PrimaryHeaderWithDescriptionAndDocsLink: Story = { + args: { + children: ( + <> + Another primary header + + This description can be any ReactNode. This provides more options for + composition. + + + ), + actions: , + }, +}; + +export const SecondaryHeaderWithDescription: Story = { + args: { + children: ( + <> + + This is a secondary header. + + + The header's styling is completely independent of its semantics. Both + can be adjusted independently to help avoid invalid HTML. + + + ), + }, +}; + +export const SecondaryHeaderWithDescriptionAndDocsLink: Story = { + args: { + children: ( + <> + + Another secondary header + + + Nothing to add, really. + + + ), + actions: , + }, +}; diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index edd06a6957815..b5128bcc28224 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.tsx @@ -1,74 +1,107 @@ -import { useTheme } from "@emotion/react"; +import { type VariantProps, cva } from "class-variance-authority"; import { Button } from "components/Button/Button"; -import { Stack } from "components/Stack/Stack"; import { SquareArrowOutUpRightIcon } from "lucide-react"; -import type { FC, ReactNode } from "react"; +import type { FC, PropsWithChildren, ReactNode } from "react"; +import { cn } from "utils/cn"; -interface HeaderProps { - title: ReactNode; - description?: ReactNode; - secondary?: boolean; - docsHref?: string; - tooltip?: ReactNode; -} - -export const SettingsHeader: FC = ({ - title, - description, - docsHref, - secondary, - tooltip, +type SettingsHeaderProps = Readonly< + PropsWithChildren<{ + actions?: ReactNode; + className?: string; + }> +>; +export const SettingsHeader: FC = ({ + children, + actions, + className, }) => { - const theme = useTheme(); + return ( +
+ {/* + * The text-sm class is only meant to adjust the font size of + * SettingsDescription, but we need to apply it here. That way, + * text-sm combines with the max-w-prose class and makes sure + * we have a predictable max width for the header + description by + * default. + */} +
{children}
+ {actions} +
+ ); +}; +type SettingsHeaderDocsLinkProps = Readonly< + PropsWithChildren<{ href: string }> +>; +export const SettingsHeaderDocsLink: FC = ({ + href, + children = "Read the docs", +}) => { return ( - -
- -

- {title} -

- {tooltip} -
+ + ); +}; - {description && ( - - {description} - - )} -
+const titleVariants = cva("m-0 pb-1 flex items-center gap-2 leading-tight", { + variants: { + hierarchy: { + primary: "text-3xl font-bold", + secondary: "text-2xl font-medium", + }, + }, + defaultVariants: { + hierarchy: "primary", + }, +}); +type SettingsHeaderTitleProps = Readonly< + PropsWithChildren< + VariantProps & { + level?: `h${1 | 2 | 3 | 4 | 5 | 6}`; + tooltip?: ReactNode; + className?: string; + } + > +>; +export const SettingsHeaderTitle: FC = ({ + children, + tooltip, + className, + level = "h1", + hierarchy = "primary", +}) => { + // Explicitly not using Radix's Slot component, because we don't want to + // allow any arbitrary element to be composed into this. We specifically + // only want to allow the six HTML headers. Anything else will likely result + // in invalid markup + const Title = level; + return ( +
+ + {children} + + {tooltip} +
+ ); +}; - {docsHref && ( - - )} -
+type SettingsHeaderDescriptionProps = Readonly< + PropsWithChildren<{ + className?: string; + }> +>; +export const SettingsHeaderDescription: FC = ({ + children, + className, +}) => { + return ( +

+ {children} +

); }; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 6509153694f6d..4f72c67d02fb3 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -8,7 +8,11 @@ import { } from "components/Badges/Badges"; import { Button } from "components/Button/Button"; import { PopoverPaywall } from "components/Paywall/PopoverPaywall"; -import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "components/SettingsHeader/SettingsHeader"; import { Popover, PopoverContent, @@ -54,10 +58,12 @@ export const AppearanceSettingsPageView: FC< return ( <> - + + Appearance + + Customize the look and feel of your Coder deployment. + + diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index e64986c5788fc..d2a916823f56e 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -8,7 +8,12 @@ import TableRow from "@mui/material/TableRow"; import type { DeploymentValues, ExternalAuthConfig } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { PremiumBadge } from "components/Badges/Badges"; -import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderDocsLink, + SettingsHeaderTitle, +} from "components/SettingsHeader/SettingsHeader"; import type { FC } from "react"; import { docs } from "utils/docs"; @@ -22,10 +27,14 @@ export const ExternalAuthSettingsPageView: FC< return ( <> + actions={} + > + External Authentication + + Coder integrates with GitHub, GitLab, BitBucket, Azure Repos, and + OpenID Connect to authenticate developers with external services. + +