From 670fa4a3cc722e7e94d3a44622b24d1c7ebe31ad Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 24 Jun 2025 13:06:02 +0200 Subject: [PATCH] feat: add the /aitasks/prompts endpoint (#18464) Add an endpoint to fetch AI task prompts for multiple workspace builds at the same time. A prompt is the value of the "AI Prompt" workspace build parameter. On main, the only way our API allows fetching workspace build parameters is by using the `/workspacebuilds/$build_id/parameters` endpoint, requiring a separate API call for every build. The Tasks dashboard fetches Task workspaces in order to show them in a list, and then needs to fetch the value of the `AI Prompt` parameter for every task workspace (using its latest build id), requiring an additional API call for each list item. This endpoint will allow the dashboard to make just 2 calls to render the list: one to fetch task workspaces, the other to fetch prompts. Screenshot 2025-06-20 at 11 33 11 Related to https://github.com/coder/internal/issues/660. --- coderd/aitasks.go | 63 ++++++++ coderd/aitasks_test.go | 141 ++++++++++++++++++ coderd/coderd.go | 8 + coderd/database/dbauthz/dbauthz.go | 13 ++ coderd/database/dbauthz/dbauthz_test.go | 8 + coderd/database/dbmem/dbmem.go | 29 ++++ coderd/database/dbmetrics/querymetrics.go | 14 ++ coderd/database/dbmock/dbmock.go | 30 ++++ coderd/database/modelqueries.go | 30 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 38 +++++ .../queries/workspacebuildparameters.sql | 15 ++ codersdk/aitasks.go | 46 ++++++ codersdk/client_experimental.go | 14 ++ site/src/api/api.ts | 36 ++++- site/src/api/typesGenerated.ts | 8 + 16 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 coderd/aitasks.go create mode 100644 coderd/aitasks_test.go create mode 100644 codersdk/aitasks.go create mode 100644 codersdk/client_experimental.go diff --git a/coderd/aitasks.go b/coderd/aitasks.go new file mode 100644 index 0000000000..a982ccc39b --- /dev/null +++ b/coderd/aitasks.go @@ -0,0 +1,63 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// This endpoint is experimental and not guaranteed to be stable, so we're not +// generating public-facing documentation for it. +func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + buildIDsParam := r.URL.Query().Get("build_ids") + if buildIDsParam == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "build_ids query parameter is required", + }) + return + } + + // Parse build IDs + buildIDStrings := strings.Split(buildIDsParam, ",") + buildIDs := make([]uuid.UUID, 0, len(buildIDStrings)) + for _, idStr := range buildIDStrings { + id, err := uuid.Parse(strings.TrimSpace(idStr)) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid build ID format: %s", idStr), + Detail: err.Error(), + }) + return + } + buildIDs = append(buildIDs, id) + } + + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace build parameters.", + Detail: err.Error(), + }) + return + } + + promptsByBuildID := make(map[string]string, len(parameters)) + for _, param := range parameters { + if param.Name != codersdk.AITaskPromptParameterName { + continue + } + buildID := param.WorkspaceBuildID.String() + promptsByBuildID[buildID] = param.Value + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{ + Prompts: promptsByBuildID, + }) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go new file mode 100644 index 0000000000..53f0174d6f --- /dev/null +++ b/coderd/aitasks_test.go @@ -0,0 +1,141 @@ +package coderd_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestAITasksPrompts(t *testing.T) { + t.Parallel() + + t.Run("EmptyBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + experimentalClient := codersdk.NewExperimentalClient(client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with empty build IDs + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) + + t.Run("MultipleBuilds", func(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test checks RBAC, which is not supported in the in-memory database") + } + + adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + first := coderdtest.CreateFirstUser(t, adminClient) + memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a template with parameters + version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "param1", + Type: "string", + DefaultValue: "default1", + }, + { + Name: codersdk.AITaskPromptParameterName, + Type: "string", + DefaultValue: "default2", + }, + }, + }, + }, + }}, + ProvisionApply: echo.ApplyComplete, + }) + template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) + + // Create two workspaces with different parameters + workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1a"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2a"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID) + + workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1b"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2b"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID) + + workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1c"}, + {Name: codersdk.AITaskPromptParameterName, Value: "value2c"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID) + allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID} + + experimentalMemberClient := codersdk.NewExperimentalClient(memberClient) + // Test parameters endpoint as member + prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + // we expect 2 prompts because the member client does not have access to workspace3 + // since it was created by the admin client + require.Len(t, prompts.Prompts, 2) + + // Check workspace1 parameters + build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()] + require.Equal(t, "value2a", build1Prompt) + + // Check workspace2 parameters + build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()] + require.Equal(t, "value2b", build2Prompt) + + experimentalAdminClient := codersdk.NewExperimentalClient(adminClient) + // Test parameters endpoint as admin + // we expect 3 prompts because the admin client has access to all workspaces + prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs) + require.NoError(t, err) + require.Len(t, prompts.Prompts, 3) + + // Check workspace3 parameters + build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()] + require.Equal(t, "value2c", build3Prompt) + }) + + t.Run("NonExistentBuildIDs", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Test with non-existent build IDs + nonExistentID := uuid.New() + experimentalClient := codersdk.NewExperimentalClient(client) + prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID}) + require.NoError(t, err) + require.Empty(t, prompts.Prompts) + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index a4c4d335d7..bf10573b28 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -939,6 +939,14 @@ func New(options *Options) *API { }) }) + // Experimental routes are not guaranteed to be stable and may change at any time. + r.Route("/api/experimental", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/aitasks", func(r chi.Router) { + r.Get("/prompts", api.aiTasksPrompts) + }) + }) + r.Route("/api/v2", func(r chi.Router) { api.APIHandler = r diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 86279bcb41..50f175a694 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3281,6 +3281,15 @@ func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuil return q.db.GetWorkspaceBuildParameters(ctx, workspaceBuildID) } +func (q *querier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + + return q.db.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prep) +} + func (q *querier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -5266,6 +5275,10 @@ func (q *querier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, return q.GetWorkspacesAndAgentsByOwnerID(ctx, ownerID) } +func (q *querier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, _ rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + return q.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs) +} + // GetAuthorizedUsers is not required for dbauthz since GetUsers is already // authenticated. func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, _ rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a07682a971..a790bcabb3 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2012,6 +2012,14 @@ func (s *MethodTestSuite) TestWorkspace() { // No asserts here because SQLFilter. check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}).Asserts() + })) + s.Run("GetAuthorizedWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + // no asserts here because SQLFilter + check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8d14b2504b..cfcbc060e9 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7960,6 +7960,11 @@ func (q *FakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu return q.getWorkspaceBuildParametersNoLock(workspaceBuildID) } +func (q *FakeQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + // No auth filter. + return q.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, nil) +} + func (q *FakeQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -13901,6 +13906,30 @@ func (q *FakeQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Cont return out, nil } +func (q *FakeQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if prepared != nil { + // Call this to match the same function calls as the SQL implementation. + _, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL()) + if err != nil { + return nil, err + } + } + + filteredParameters := make([]database.WorkspaceBuildParameter, 0) + for _, buildID := range workspaceBuildIDs { + parameters, err := q.GetWorkspaceBuildParameters(ctx, buildID) + if err != nil { + return nil, err + } + filteredParameters = append(filteredParameters, parameters...) + } + + return filteredParameters, nil +} + func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fb68f9c7ab..38e662ce44 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1873,6 +1873,13 @@ func (m queryMetricsStore) GetWorkspaceBuildParameters(ctx context.Context, work return params, err } +func (m queryMetricsStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds) + m.queryLatencies.WithLabelValues("GetWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceBuildStatsByTemplates(ctx, since) @@ -3343,6 +3350,13 @@ func (m queryMetricsStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context return r0, r1 } +func (m queryMetricsStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedWorkspaceBuildParametersByBuildIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedUsers(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 717dc8ef19..586ebf9f60 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1247,6 +1247,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedUsers(ctx, arg, prepared any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedUsers", reflect.TypeOf((*MockStore)(nil).GetAuthorizedUsers), ctx, arg, prepared) } +// GetAuthorizedWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIDs, prepared) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedWorkspaceBuildParametersByBuildIDs indicates an expected call of GetAuthorizedWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIDs, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIDs, prepared) +} + // GetAuthorizedWorkspaces mocks base method. func (m *MockStore) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { m.ctrl.T.Helper() @@ -3932,6 +3947,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceBuildParameters(ctx, workspaceBuild return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParameters), ctx, workspaceBuildID) } +// GetWorkspaceBuildParametersByBuildIDs mocks base method. +func (m *MockStore) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]database.WorkspaceBuildParameter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceBuildParametersByBuildIDs", ctx, workspaceBuildIds) + ret0, _ := ret[0].([]database.WorkspaceBuildParameter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceBuildParametersByBuildIDs indicates an expected call of GetWorkspaceBuildParametersByBuildIDs. +func (mr *MockStoreMockRecorder) GetWorkspaceBuildParametersByBuildIDs(ctx, workspaceBuildIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParametersByBuildIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParametersByBuildIDs), ctx, workspaceBuildIds) +} + // GetWorkspaceBuildStatsByTemplates mocks base method. func (m *MockStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 931412204d..eaf73af07f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -226,6 +226,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) + GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -372,6 +373,35 @@ func (q *sqlQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Conte return items, nil } +func (q *sqlQuerier) GetAuthorizedWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIDs []uuid.UUID, prepared rbac.PreparedAuthorized) ([]WorkspaceBuildParameter, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getWorkspaceBuildParametersByBuildIDs, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceBuildParametersByBuildIDs :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, pq.Array(workspaceBuildIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + type userQuerier interface { GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error) } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6845f95e18..2300402c27 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -428,6 +428,7 @@ type sqlcQuerier interface { GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) + GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]GetWorkspaceBuildStatsByTemplatesRow, error) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e44c35d2d2..ab111ad6f7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17035,6 +17035,44 @@ func (q *sqlQuerier) GetWorkspaceBuildParameters(ctx context.Context, workspaceB return items, nil } +const getWorkspaceBuildParametersByBuildIDs = `-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.workspace_build_id, workspace_build_parameters.name, workspace_build_parameters.value +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY($1 :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +` + +func (q *sqlQuerier) GetWorkspaceBuildParametersByBuildIDs(ctx context.Context, workspaceBuildIds []uuid.UUID) ([]WorkspaceBuildParameter, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildParametersByBuildIDs, pq.Array(workspaceBuildIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceBuildParameter + for rows.Next() { + var i WorkspaceBuildParameter + if err := rows.Scan(&i.WorkspaceBuildID, &i.Name, &i.Value); 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 insertWorkspaceBuildParameters = `-- name: InsertWorkspaceBuildParameters :exec INSERT INTO workspace_build_parameters (workspace_build_id, name, value) diff --git a/coderd/database/queries/workspacebuildparameters.sql b/coderd/database/queries/workspacebuildparameters.sql index 5cda9c0206..b639a553ef 100644 --- a/coderd/database/queries/workspacebuildparameters.sql +++ b/coderd/database/queries/workspacebuildparameters.sql @@ -41,3 +41,18 @@ FROM ( ) q1 ORDER BY created_at DESC, name LIMIT 100; + +-- name: GetWorkspaceBuildParametersByBuildIDs :many +SELECT + workspace_build_parameters.* +FROM + workspace_build_parameters +JOIN + workspace_builds ON workspace_builds.id = workspace_build_parameters.workspace_build_id +JOIN + workspaces ON workspaces.id = workspace_builds.workspace_id +WHERE + workspace_build_parameters.workspace_build_id = ANY(@workspace_build_ids :: uuid[]) + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceBuildParametersByBuildIDs + -- @authorize_filter +; diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go new file mode 100644 index 0000000000..89ca9c948f --- /dev/null +++ b/codersdk/aitasks.go @@ -0,0 +1,46 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/coder/terraform-provider-coder/v2/provider" +) + +const AITaskPromptParameterName = provider.TaskPromptParameterName + +type AITasksPromptsResponse struct { + // Prompts is a map of workspace build IDs to prompts. + Prompts map[string]string `json:"prompts"` +} + +// AITaskPrompts returns prompts for multiple workspace builds by their IDs. +func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.UUID) (AITasksPromptsResponse, error) { + if len(buildIDs) == 0 { + return AITasksPromptsResponse{ + Prompts: make(map[string]string), + }, nil + } + + // Convert UUIDs to strings and join them + buildIDStrings := make([]string, len(buildIDs)) + for i, id := range buildIDs { + buildIDStrings[i] = id.String() + } + buildIDsParam := strings.Join(buildIDStrings, ",") + + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/aitasks/prompts", nil, WithQueryParam("build_ids", buildIDsParam)) + if err != nil { + return AITasksPromptsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AITasksPromptsResponse{}, ReadBodyAsError(res) + } + var prompts AITasksPromptsResponse + return prompts, json.NewDecoder(res.Body).Decode(&prompts) +} diff --git a/codersdk/client_experimental.go b/codersdk/client_experimental.go new file mode 100644 index 0000000000..e37b4d0c86 --- /dev/null +++ b/codersdk/client_experimental.go @@ -0,0 +1,14 @@ +package codersdk + +// ExperimentalClient is a client for the experimental API. +// Its interface is not guaranteed to be stable and may change at any time. +// @typescript-ignore ExperimentalClient +type ExperimentalClient struct { + *Client +} + +func NewExperimentalClient(client *Client) *ExperimentalClient { + return &ExperimentalClient{ + Client: client, + } +} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0945029398..35e60de6ae 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -411,7 +411,11 @@ export type GetProvisionerDaemonsParams = { * lexical scope. */ class ApiMethods { - constructor(protected readonly axios: AxiosInstance) {} + experimental: ExperimentalApiMethods; + + constructor(protected readonly axios: AxiosInstance) { + this.experimental = new ExperimentalApiMethods(this.axios); + } login = async ( email: string, @@ -2599,6 +2603,36 @@ class ApiMethods { }; } +// Experimental API methods call endpoints under the /api/experimental/ prefix. +// These endpoints are not stable and may change or be removed at any time. +// +// All methods must be defined with arrow function syntax. See the docstring +// above the ApiMethods class for a full explanation. +class ExperimentalApiMethods { + constructor(protected readonly axios: AxiosInstance) {} + + getAITasksPrompts = async ( + buildIds: TypesGen.WorkspaceBuild["id"][], + ): Promise => { + if (buildIds.length === 0) { + return { + prompts: {}, + }; + } + + const response = await this.axios.get( + "/api/experimental/aitasks/prompts", + { + params: { + build_ids: buildIds.join(","), + }, + }, + ); + + return response.data; + }; +} + // This is a hard coded CSRF token/cookie pair for local development. In prod, // the GoLang webserver generates a random cookie with a new token for each // document request. For local development, we don't use the Go webserver for diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5d0a46f7ec..b2c5c562a4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -18,6 +18,14 @@ export interface AIProviderConfig { readonly base_url: string; } +// From codersdk/aitasks.go +export const AITaskPromptParameterName = "AI Prompt"; + +// From codersdk/aitasks.go +export interface AITasksPromptsResponse { + readonly prompts: Record; +} + // From codersdk/apikey.go export interface APIKey { readonly id: string;