Skip to content

refactor: create tasks in coderd instead of frontend #19280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions coderd/aitasks.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package coderd

import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"

"github.com/google/uuid"

"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
)

Expand Down Expand Up @@ -61,3 +67,79 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) {
Prompts: promptsByBuildID,
})
}

// This endpoint is experimental and not guaranteed to be stable, so we're not
// generating public-facing documentation for it.
func (api *API) aiTasksCreate(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load()
)

var req codersdk.CreateAITasksRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}

user, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
Detail: err.Error(),
})
return
}

hasAITask, err := api.Database.GetTemplateVersionHasAITask(ctx, req.TemplateVersionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || rbac.IsUnauthorizedError(err) {
httpapi.ResourceNotFound(rw)
return
}

httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching whether the template version has an AI task.",
Detail: err.Error(),
})
return
}
if !hasAITask {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: `Template does not have required parameter "` + codersdk.AITaskPromptParameterName + `"`,
})
return
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This might be better moved inside wsbuilder as it runs inside a tx and has access to the parameters.


createReq := codersdk.CreateWorkspaceRequest{
Name: req.Name,
TemplateVersionID: req.TemplateVersionID,
TemplateVersionPresetID: req.TemplateVersionPresetID,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
{Name: codersdk.AITaskPromptParameterName, Value: req.Prompt},
},
}

owner := workspaceOwner{
ID: user.ID,
Username: user.Username,
AvatarURL: user.AvatarURL,
}

aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
AdditionalFields: audit.AdditionalFields{
WorkspaceOwner: owner.Username,
},
})

defer commitAudit()
createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r)
}
124 changes: 124 additions & 0 deletions coderd/aitasks_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package coderd_test

import (
"net/http"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/coderdtest"
Expand Down Expand Up @@ -139,3 +141,125 @@ func TestAITasksPrompts(t *testing.T) {
require.Empty(t, prompts.Prompts)
})
}

func TestAITasksCreate(t *testing.T) {
t.Parallel()

t.Run("OK", func(t *testing.T) {
t.Parallel()

var (
ctx = testutil.Context(t, testutil.WaitShort)

taskName = "task-foo-bar-baz"
taskPrompt = "Some task prompt"
)

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)

// Given: A template with an "AI Prompt" parameter
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionPlan: []*proto.Response{
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}},
HasAiTasks: true,
}}},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)

expClient := codersdk.NewExperimentalClient(client)

// When: We attempt to create a Task.
workspace, err := expClient.AITasksCreate(ctx, codersdk.CreateAITasksRequest{
Name: taskName,
TemplateVersionID: template.ActiveVersionID,
Prompt: taskPrompt,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)

// Then: We expect a workspace to have been created.
assert.Equal(t, taskName, workspace.Name)
assert.Equal(t, template.ID, workspace.TemplateID)

// And: We expect it to have the "AI Prompt" parameter correctly set.
parameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, parameters, 1)
assert.Equal(t, codersdk.AITaskPromptParameterName, parameters[0].Name)
assert.Equal(t, taskPrompt, parameters[0].Value)
})

t.Run("FailsOnNonTaskTemplate", func(t *testing.T) {
t.Parallel()

var (
ctx = testutil.Context(t, testutil.WaitShort)

taskName = "task-foo-bar-baz"
taskPrompt = "Some task prompt"
)

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)

// Given: A template without an "AI Prompt" parameter
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)

expClient := codersdk.NewExperimentalClient(client)

// When: We attempt to create a Task.
_, err := expClient.AITasksCreate(ctx, codersdk.CreateAITasksRequest{
Name: taskName,
TemplateVersionID: template.ActiveVersionID,
Prompt: taskPrompt,
})

// Then: We expect it to fail.
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})

t.Run("FailsOnInvalidTemplate", func(t *testing.T) {
t.Parallel()

var (
ctx = testutil.Context(t, testutil.WaitShort)

taskName = "task-foo-bar-baz"
taskPrompt = "Some task prompt"
)

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)

// Given: A template
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)

expClient := codersdk.NewExperimentalClient(client)

// When: We attempt to create a Task with an invalid template version ID.
_, err := expClient.AITasksCreate(ctx, codersdk.CreateAITasksRequest{
Name: taskName,
TemplateVersionID: uuid.New(),
Prompt: taskPrompt,
})

// Then: We expect it to fail.
var sdkErr *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
2 changes: 2 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,8 @@ func New(options *Options) *API {
r.Use(apiKeyMiddleware)
r.Route("/aitasks", func(r chi.Router) {
r.Get("/prompts", api.aiTasksPrompts)

r.With(apiRateLimiter).Post("/", api.aiTasksCreate)
})
r.Route("/mcp", func(r chi.Router) {
r.Use(
Expand Down
11 changes: 11 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -2874,6 +2874,17 @@ func (q *querier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg
return tv, nil
}

func (q *querier) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) {
// If we can successfully call `GetTemplateVersionByID`, then
// we know the actor has sufficient permissions to know if the
// template has an AI task.
if _, err := q.GetTemplateVersionByID(ctx, id); err != nil {
return false, err
}

return q.db.GetTemplateVersionHasAITask(ctx, id)
}

func (q *querier) GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionParameter, error) {
// An actor can read template version parameters if they can read the related template.
tv, err := q.db.GetTemplateVersionByID(ctx, templateVersionID)
Expand Down
14 changes: 14 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,20 @@ func (s *MethodTestSuite) TestTemplate() {
})
check.Args(now.Add(-time.Hour)).Asserts(rbac.ResourceTemplate.All(), policy.ActionRead)
}))
s.Run("GetTemplateVersionHasAITask", s.Subtest(func(db database.Store, check *expects) {
o := dbgen.Organization(s.T(), db, database.Organization{})
u := dbgen.User(s.T(), db, database.User{})
t := dbgen.Template(s.T(), db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
OrganizationID: o.ID,
TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true},
CreatedBy: u.ID,
})
check.Args(tv.ID).Asserts(t, policy.ActionRead)
}))
s.Run("GetTemplatesWithFilter", s.Subtest(func(db database.Store, check *expects) {
o := dbgen.Organization(s.T(), db, database.Organization{})
u := dbgen.User(s.T(), db, database.User{})
Expand Down
7 changes: 7 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions coderd/database/queries/templateversions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,10 @@ FROM
WHERE
template_versions.id IN (archived_versions.id)
RETURNING template_versions.id;

-- name: GetTemplateVersionHasAITask :one
SELECT EXISTS (
SELECT 1
FROM template_versions
WHERE id = $1 AND has_ai_task = TRUE
);
26 changes: 26 additions & 0 deletions codersdk/aitasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,29 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid.
var prompts AITasksPromptsResponse
return prompts, json.NewDecoder(res.Body).Decode(&prompts)
}

type CreateAITasksRequest struct {
Name string `json:"name"`
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
Prompt string `json:"prompt"`
}

func (c *ExperimentalClient) AITasksCreate(ctx context.Context, request CreateAITasksRequest) (Workspace, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/aitasks", request)
if err != nil {
return Workspace{}, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusCreated {
return Workspace{}, ReadBodyAsError(res)
}

var workspace Workspace
if err := json.NewDecoder(res.Body).Decode(&workspace); err != nil {
return Workspace{}, err
}

return workspace, nil
}
Loading