mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(coderd): use task data model for list (#20394)
Updates coder/internal#976
This commit is contained in:
+63
-177
@@ -2,7 +2,6 @@ package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
@@ -360,107 +360,6 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
|
||||
}
|
||||
}
|
||||
|
||||
func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Task {
|
||||
// TODO(DanielleMaywood):
|
||||
// This just picks up the first agent it discovers.
|
||||
// This approach _might_ break when a task has multiple agents,
|
||||
// depending on which agent was found first.
|
||||
//
|
||||
// We explicitly do not have support for running tasks
|
||||
// inside of a sub agent at the moment, so we can be sure
|
||||
// that any sub agents are not the agent we're looking for.
|
||||
var taskAgentID uuid.NullUUID
|
||||
var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle
|
||||
var taskAgentHealth *codersdk.WorkspaceAgentHealth
|
||||
for _, resource := range ws.LatestBuild.Resources {
|
||||
for _, agent := range resource.Agents {
|
||||
if agent.ParentID.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
taskAgentID = uuid.NullUUID{Valid: true, UUID: agent.ID}
|
||||
taskAgentLifecycle = &agent.LifecycleState
|
||||
taskAgentHealth = &agent.Health
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore 'latest app status' if it is older than the latest build and the
|
||||
// latest build is a 'start' transition. This ensures that you don't show a
|
||||
// stale app status from a previous build. For stop transitions, there is
|
||||
// still value in showing the latest app status.
|
||||
var currentState *codersdk.TaskStateEntry
|
||||
if ws.LatestAppStatus != nil {
|
||||
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
|
||||
currentState = &codersdk.TaskStateEntry{
|
||||
Timestamp: ws.LatestAppStatus.CreatedAt,
|
||||
State: codersdk.TaskState(ws.LatestAppStatus.State),
|
||||
Message: ws.LatestAppStatus.Message,
|
||||
URI: ws.LatestAppStatus.URI,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var appID uuid.NullUUID
|
||||
if ws.LatestBuild.AITaskSidebarAppID != nil {
|
||||
appID = uuid.NullUUID{
|
||||
Valid: true,
|
||||
UUID: *ws.LatestBuild.AITaskSidebarAppID,
|
||||
}
|
||||
}
|
||||
|
||||
return codersdk.Task{
|
||||
ID: ws.ID,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
OwnerID: ws.OwnerID,
|
||||
OwnerName: ws.OwnerName,
|
||||
Name: ws.Name,
|
||||
TemplateID: ws.TemplateID,
|
||||
TemplateName: ws.TemplateName,
|
||||
TemplateDisplayName: ws.TemplateDisplayName,
|
||||
TemplateIcon: ws.TemplateIcon,
|
||||
WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID},
|
||||
WorkspaceBuildNumber: ws.LatestBuild.BuildNumber,
|
||||
WorkspaceAgentID: taskAgentID,
|
||||
WorkspaceAgentLifecycle: taskAgentLifecycle,
|
||||
WorkspaceAgentHealth: taskAgentHealth,
|
||||
WorkspaceAppID: appID,
|
||||
CreatedAt: ws.CreatedAt,
|
||||
UpdatedAt: ws.UpdatedAt,
|
||||
InitialPrompt: initialPrompt,
|
||||
WorkspaceStatus: ws.LatestBuild.Status,
|
||||
CurrentState: currentState,
|
||||
}
|
||||
}
|
||||
|
||||
// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching
|
||||
// prompts and mapping status/state. This method enforces that only AI task
|
||||
// workspaces are given.
|
||||
func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) {
|
||||
// Fetch prompts for each workspace build and map by build ID.
|
||||
buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces))
|
||||
for _, ws := range apiWorkspaces {
|
||||
buildIDs = append(buildIDs, ws.LatestBuild.ID)
|
||||
}
|
||||
parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
promptsByBuildID := make(map[uuid.UUID]string, len(parameters))
|
||||
for _, p := range parameters {
|
||||
if p.Name == codersdk.AITaskPromptParameterName {
|
||||
promptsByBuildID[p.WorkspaceBuildID] = p.Value
|
||||
}
|
||||
}
|
||||
|
||||
tasks := make([]codersdk.Task, 0, len(apiWorkspaces))
|
||||
for _, ws := range apiWorkspaces {
|
||||
tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID]))
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// tasksListResponse wraps a list of experimental tasks.
|
||||
//
|
||||
// Experimental: Response shape is experimental and may change.
|
||||
@@ -474,106 +373,41 @@ type tasksListResponse struct {
|
||||
// @ID list-tasks
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Experimental
|
||||
// @Param q query string false "Search query for filtering tasks"
|
||||
// @Param after_id query string false "Return tasks after this ID for pagination"
|
||||
// @Param limit query int false "Maximum number of tasks to return" minimum(1) maximum(100) default(25)
|
||||
// @Param offset query int false "Offset for pagination" minimum(0) default(0)
|
||||
// @Param q query string false "Search query for filtering tasks. Supports: owner:<username/uuid/me>, organization:<org-name/uuid>, status:<status>"
|
||||
// @Success 200 {object} coderd.tasksListResponse
|
||||
// @Router /api/experimental/tasks [get]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// tasksList is an experimental endpoint to list AI tasks by mapping
|
||||
// workspaces to a task-shaped response.
|
||||
// tasksList is an experimental endpoint to list tasks.
|
||||
func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
|
||||
// Support standard pagination/filters for workspaces.
|
||||
page, ok := ParsePagination(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Parse query parameters for filtering tasks.
|
||||
queryStr := r.URL.Query().Get("q")
|
||||
filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout)
|
||||
filter, errs := searchquery.Tasks(ctx, api.Database, queryStr, apiKey.UserID)
|
||||
if len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid workspace search query.",
|
||||
Message: "Invalid task search query.",
|
||||
Validations: errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure that we only include AI task workspaces in the results.
|
||||
filter.HasAITask = sql.NullBool{Valid: true, Bool: true}
|
||||
|
||||
if filter.OwnerUsername == "me" {
|
||||
filter.OwnerID = apiKey.UserID
|
||||
filter.OwnerUsername = ""
|
||||
}
|
||||
|
||||
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type)
|
||||
// Fetch all tasks matching the filters from the database.
|
||||
dbTasks, err := api.Database.ListTasks(ctx, filter)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error preparing sql filter.",
|
||||
Message: "Internal error fetching tasks.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Order with requester's favorites first, include summary row.
|
||||
filter.RequesterID = apiKey.UserID
|
||||
filter.WithSummary = true
|
||||
|
||||
workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared)
|
||||
tasks, err := api.convertTasks(ctx, apiKey.UserID, dbTasks)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspaces.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(workspaceRows) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspaces.",
|
||||
Detail: "Workspace summary row is missing.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(workspaceRows) == 1 {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
|
||||
Tasks: []codersdk.Task{},
|
||||
Count: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Skip summary row.
|
||||
workspaceRows = workspaceRows[:len(workspaceRows)-1]
|
||||
|
||||
workspaces := database.ConvertWorkspaceRows(workspaceRows)
|
||||
|
||||
// Gather associated data and convert to API workspaces.
|
||||
data, err := api.workspaceData(ctx, workspaces)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace resources.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error converting workspaces.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching task prompts and states.",
|
||||
Message: "Internal error converting tasks.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
@@ -585,6 +419,58 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// convertTasks converts database tasks to API tasks, enriching them with
|
||||
// workspace information.
|
||||
func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks []database.Task) ([]codersdk.Task, error) {
|
||||
if len(dbTasks) == 0 {
|
||||
return []codersdk.Task{}, nil
|
||||
}
|
||||
|
||||
// Prepare to batch fetch workspaces.
|
||||
workspaceIDs := make([]uuid.UUID, 0, len(dbTasks))
|
||||
for _, task := range dbTasks {
|
||||
if !task.WorkspaceID.Valid {
|
||||
return nil, xerrors.New("task has no workspace ID")
|
||||
}
|
||||
workspaceIDs = append(workspaceIDs, task.WorkspaceID.UUID)
|
||||
}
|
||||
|
||||
// Fetch workspaces for tasks that have workspaces.
|
||||
workspaceRows, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{
|
||||
WorkspaceIds: workspaceIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch workspaces: %w", err)
|
||||
}
|
||||
|
||||
workspaces := database.ConvertWorkspaceRows(workspaceRows)
|
||||
|
||||
// Gather associated data and convert to API workspaces.
|
||||
data, err := api.workspaceData(ctx, workspaces)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch workspace data: %w", err)
|
||||
}
|
||||
|
||||
apiWorkspaces, err := convertWorkspaces(requesterID, workspaces, data)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert workspaces: %w", err)
|
||||
}
|
||||
|
||||
workspacesByID := make(map[uuid.UUID]codersdk.Workspace)
|
||||
for _, ws := range apiWorkspaces {
|
||||
workspacesByID[ws.ID] = ws
|
||||
}
|
||||
|
||||
// Convert tasks to SDK format.
|
||||
result := make([]codersdk.Task, 0, len(dbTasks))
|
||||
for _, dbTask := range dbTasks {
|
||||
task := taskFromDBTaskAndWorkspace(dbTask, workspacesByID[dbTask.WorkspaceID.UUID])
|
||||
result = append(result, task)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// @Summary Get AI task by ID
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID get-task
|
||||
|
||||
+16
-13
@@ -239,35 +239,38 @@ func TestTasks(t *testing.T) {
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
template := createAITemplate(t, client, user)
|
||||
|
||||
// Create a workspace (task) with a specific prompt.
|
||||
// Create a task with a specific prompt using the new data model.
|
||||
wantPrompt := "build me a web app"
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: wantPrompt},
|
||||
}
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: wantPrompt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
|
||||
// Wait for the workspace to be built.
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// List tasks via experimental API and verify the prompt and status mapping.
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := slice.Find(tasks, func(task codersdk.Task) bool { return task.ID == workspace.ID })
|
||||
got, ok := slice.Find(tasks, func(t codersdk.Task) bool { return t.ID == task.ID })
|
||||
require.True(t, ok, "task should be found in the list")
|
||||
assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter")
|
||||
assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name")
|
||||
assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match")
|
||||
// Status should be populated via app status or workspace status mapping.
|
||||
assert.NotEmpty(t, got.WorkspaceStatus, "task status should not be empty")
|
||||
assert.Equal(t, task.WorkspaceID.UUID, got.WorkspaceID.UUID, "workspace id should match")
|
||||
// Status should be populated via the tasks_with_status view.
|
||||
assert.NotEmpty(t, got.Status, "task status should not be empty")
|
||||
assert.NotEmpty(t, got.WorkspaceStatus, "workspace status should not be empty")
|
||||
})
|
||||
|
||||
t.Run("Get", func(t *testing.T) {
|
||||
|
||||
Generated
+1
-24
@@ -151,32 +151,9 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Return tasks after this ID for pagination",
|
||||
"name": "after_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
"default": 25,
|
||||
"description": "Maximum number of tasks to return",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"minimum": 0,
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Offset for pagination",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
Generated
+1
-24
@@ -125,32 +125,9 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Return tasks after this ID for pagination",
|
||||
"name": "after_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
"default": 25,
|
||||
"description": "Maximum number of tasks to return",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"minimum": 0,
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"description": "Offset for pagination",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -120,19 +120,23 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) []
|
||||
}
|
||||
|
||||
func (b WorkspaceBuildBuilder) WithTask(seed *sdkproto.App) WorkspaceBuildBuilder {
|
||||
//nolint: revive // returns modified struct
|
||||
b.taskAppID = uuid.New()
|
||||
if seed == nil {
|
||||
seed = &sdkproto.App{}
|
||||
}
|
||||
|
||||
var err error
|
||||
//nolint: revive // returns modified struct
|
||||
b.taskAppID, err = uuid.Parse(takeFirst(seed.Id, uuid.NewString()))
|
||||
require.NoError(b.t, err)
|
||||
|
||||
return b.Params(database.WorkspaceBuildParameter{
|
||||
Name: codersdk.AITaskPromptParameterName,
|
||||
Value: "list me",
|
||||
}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
|
||||
a[0].Apps = []*sdkproto.App{
|
||||
{
|
||||
Id: takeFirst(seed.Id, b.taskAppID.String()),
|
||||
Slug: takeFirst(seed.Slug, "vcode"),
|
||||
Id: b.taskAppID.String(),
|
||||
Slug: takeFirst(seed.Slug, "task-app"),
|
||||
Url: takeFirst(seed.Url, ""),
|
||||
},
|
||||
}
|
||||
@@ -195,11 +199,11 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
|
||||
if b.ws.ID == uuid.Nil {
|
||||
// nolint: revive
|
||||
b.ws = dbgen.Workspace(b.t, b.db, b.ws)
|
||||
resp.Workspace = b.ws
|
||||
b.logger.Debug(context.Background(), "created workspace",
|
||||
slog.F("name", resp.Workspace.Name),
|
||||
slog.F("workspace_id", resp.Workspace.ID))
|
||||
slog.F("name", b.ws.Name),
|
||||
slog.F("workspace_id", b.ws.ID))
|
||||
}
|
||||
resp.Workspace = b.ws
|
||||
b.seed.WorkspaceID = b.ws.ID
|
||||
b.seed.InitiatorID = takeFirst(b.seed.InitiatorID, b.ws.OwnerID)
|
||||
|
||||
@@ -273,6 +277,30 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
|
||||
slog.F("workspace_id", resp.Workspace.ID),
|
||||
slog.F("build_number", resp.Build.BuildNumber))
|
||||
|
||||
// If this is a task workspace, link it to the workspace build.
|
||||
task, err := b.db.GetTaskByWorkspaceID(ownerCtx, resp.Workspace.ID)
|
||||
if err != nil {
|
||||
if b.taskAppID != uuid.Nil {
|
||||
require.Fail(b.t, "task app configured but failed to get task by workspace id", err)
|
||||
}
|
||||
} else {
|
||||
if b.taskAppID == uuid.Nil {
|
||||
require.Fail(b.t, "task app not configured but workspace is a task workspace")
|
||||
}
|
||||
|
||||
app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID)
|
||||
_, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{
|
||||
TaskID: task.ID,
|
||||
WorkspaceBuildNumber: resp.Build.BuildNumber,
|
||||
WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true},
|
||||
WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
})
|
||||
require.NoError(b.t, err, "upsert task workspace app")
|
||||
b.logger.Debug(context.Background(), "linked task to workspace build",
|
||||
slog.F("task_id", task.ID),
|
||||
slog.F("build_number", resp.Build.BuildNumber))
|
||||
}
|
||||
|
||||
for i := range b.params {
|
||||
b.params[i].WorkspaceBuildID = resp.Build.ID
|
||||
}
|
||||
@@ -623,3 +651,30 @@ func takeFirst[Value comparable](values ...Value) Value {
|
||||
return v != empty
|
||||
})
|
||||
}
|
||||
|
||||
// mustWorkspaceAppByWorkspaceAndBuildAndAppID finds a workspace app by
|
||||
// workspace ID, build number, and app ID. It returns the workspace app
|
||||
// if found, otherwise fails the test.
|
||||
func mustWorkspaceAppByWorkspaceAndBuildAndAppID(ctx context.Context, t testing.TB, db database.Store, workspaceID uuid.UUID, buildNumber int32, appID uuid.UUID) database.WorkspaceApp {
|
||||
t.Helper()
|
||||
|
||||
agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
|
||||
WorkspaceID: workspaceID,
|
||||
BuildNumber: buildNumber,
|
||||
})
|
||||
require.NoError(t, err, "get workspace agents")
|
||||
require.NotEmpty(t, agents, "no agents found for workspace")
|
||||
|
||||
for _, agent := range agents {
|
||||
apps, err := db.GetWorkspaceAppsByAgentID(ctx, agent.ID)
|
||||
require.NoError(t, err, "get workspace apps")
|
||||
for _, app := range apps {
|
||||
if app.ID == appID {
|
||||
return app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.FailNow(t, "could not find workspace app", "workspaceID=%s buildNumber=%d appID=%s", workspaceID, buildNumber, appID)
|
||||
return database.WorkspaceApp{} // Unreachable.
|
||||
}
|
||||
|
||||
@@ -12717,16 +12717,18 @@ SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, t
|
||||
WHERE tws.deleted_at IS NULL
|
||||
AND CASE WHEN $1::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = $1::UUID ELSE TRUE END
|
||||
AND CASE WHEN $2::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = $2::UUID ELSE TRUE END
|
||||
AND CASE WHEN $3::text != '' THEN tws.status = $3::task_status ELSE TRUE END
|
||||
ORDER BY tws.created_at DESC
|
||||
`
|
||||
|
||||
type ListTasksParams struct {
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID)
|
||||
rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID, arg.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ SELECT * FROM tasks_with_status tws
|
||||
WHERE tws.deleted_at IS NULL
|
||||
AND CASE WHEN @owner_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = @owner_id::UUID ELSE TRUE END
|
||||
AND CASE WHEN @organization_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = @organization_id::UUID ELSE TRUE END
|
||||
AND CASE WHEN @status::text != '' THEN tws.status = @status::task_status ELSE TRUE END
|
||||
ORDER BY tws.created_at DESC;
|
||||
|
||||
-- name: DeleteTask :one
|
||||
|
||||
@@ -391,6 +391,43 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string,
|
||||
return filter, parser.Errors
|
||||
}
|
||||
|
||||
// Tasks parses a search query for tasks.
|
||||
//
|
||||
// Supported query parameters:
|
||||
// - owner: string (username, UUID, or 'me' for current user)
|
||||
// - organization: string (organization UUID or name)
|
||||
// - status: string (pending, initializing, active, paused, error, unknown)
|
||||
func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UUID) (database.ListTasksParams, []codersdk.ValidationError) {
|
||||
filter := database.ListTasksParams{
|
||||
OwnerID: uuid.Nil,
|
||||
OrganizationID: uuid.Nil,
|
||||
Status: "",
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// Always lowercase for all searches.
|
||||
query = strings.ToLower(query)
|
||||
values, errors := searchTerms(query, func(term string, values url.Values) error {
|
||||
// Default unqualified terms to owner
|
||||
values.Add("owner", term)
|
||||
return nil
|
||||
})
|
||||
if len(errors) > 0 {
|
||||
return filter, errors
|
||||
}
|
||||
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID)
|
||||
filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
|
||||
filter.Status = parser.String(values, "", "status")
|
||||
|
||||
parser.ErrorExcessParams(values)
|
||||
return filter, parser.Errors
|
||||
}
|
||||
|
||||
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
|
||||
searchValues := make(url.Values)
|
||||
|
||||
|
||||
@@ -944,3 +944,199 @@ func TestSearchTemplates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := uuid.MustParse("10000000-0000-0000-0000-000000000001")
|
||||
orgID := uuid.MustParse("20000000-0000-0000-0000-000000000001")
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Query string
|
||||
ActorID uuid.UUID
|
||||
Expected database.ListTasksParams
|
||||
ExpectedErrorContains string
|
||||
Setup func(t *testing.T, db database.Store)
|
||||
}{
|
||||
{
|
||||
Name: "Empty",
|
||||
Query: "",
|
||||
Expected: database.ListTasksParams{},
|
||||
},
|
||||
{
|
||||
Name: "OwnerUsername",
|
||||
Query: "owner:alice",
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: userID,
|
||||
Username: "alice",
|
||||
})
|
||||
},
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OwnerMe",
|
||||
Query: "owner:me",
|
||||
ActorID: userID,
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OwnerUUID",
|
||||
Query: fmt.Sprintf("owner:%s", userID),
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "StatusActive",
|
||||
Query: "status:active",
|
||||
Expected: database.ListTasksParams{
|
||||
Status: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "StatusPending",
|
||||
Query: "status:pending",
|
||||
Expected: database.ListTasksParams{
|
||||
Status: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Organization",
|
||||
Query: "organization:acme",
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.Organization(t, db, database.Organization{
|
||||
ID: orgID,
|
||||
Name: "acme",
|
||||
})
|
||||
},
|
||||
Expected: database.ListTasksParams{
|
||||
OrganizationID: orgID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OrganizationUUID",
|
||||
Query: fmt.Sprintf("organization:%s", orgID),
|
||||
Expected: database.ListTasksParams{
|
||||
OrganizationID: orgID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Combined",
|
||||
Query: "owner:alice organization:acme status:active",
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.Organization(t, db, database.Organization{
|
||||
ID: orgID,
|
||||
Name: "acme",
|
||||
})
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: userID,
|
||||
Username: "alice",
|
||||
})
|
||||
},
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
OrganizationID: orgID,
|
||||
Status: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "QuotedOwner",
|
||||
Query: `owner:"alice"`,
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: userID,
|
||||
Username: "alice",
|
||||
})
|
||||
},
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "QuotedStatus",
|
||||
Query: `status:"pending"`,
|
||||
Expected: database.ListTasksParams{
|
||||
Status: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DefaultToOwner",
|
||||
Query: "alice",
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: userID,
|
||||
Username: "alice",
|
||||
})
|
||||
},
|
||||
Expected: database.ListTasksParams{
|
||||
OwnerID: userID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "InvalidOwner",
|
||||
Query: "owner:nonexistent",
|
||||
ExpectedErrorContains: "does not exist",
|
||||
},
|
||||
{
|
||||
Name: "InvalidOrganization",
|
||||
Query: "organization:nonexistent",
|
||||
ExpectedErrorContains: "does not exist",
|
||||
},
|
||||
{
|
||||
Name: "ExtraParam",
|
||||
Query: "owner:alice invalid:param",
|
||||
Setup: func(t *testing.T, db database.Store) {
|
||||
dbgen.User(t, db, database.User{
|
||||
ID: userID,
|
||||
Username: "alice",
|
||||
})
|
||||
},
|
||||
ExpectedErrorContains: "is not a valid query param",
|
||||
},
|
||||
{
|
||||
Name: "ExtraColon",
|
||||
Query: "owner:alice:extra",
|
||||
ExpectedErrorContains: "can only contain 1 ':'",
|
||||
},
|
||||
{
|
||||
Name: "PrefixColon",
|
||||
Query: ":owner",
|
||||
ExpectedErrorContains: "cannot start or end with ':'",
|
||||
},
|
||||
{
|
||||
Name: "SuffixColon",
|
||||
Query: "owner:",
|
||||
ExpectedErrorContains: "cannot start or end with ':'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
if c.Setup != nil {
|
||||
c.Setup(t, db)
|
||||
}
|
||||
|
||||
values, errs := searchquery.Tasks(context.Background(), db, c.Query, c.ActorID)
|
||||
if c.ExpectedErrorContains != "" {
|
||||
require.True(t, len(errs) > 0, "expect some errors")
|
||||
var s strings.Builder
|
||||
for _, err := range errs {
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
|
||||
}
|
||||
require.Contains(t, s.String(), c.ExpectedErrorContains)
|
||||
} else {
|
||||
require.Len(t, errs, 0, "expected no error")
|
||||
require.Equal(t, c.Expected, values, "expected values")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +734,7 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
|
||||
dbTasks, err := r.options.Database.ListTasks(ctx, database.ListTasksParams{
|
||||
OwnerID: uuid.Nil,
|
||||
OrganizationID: uuid.Nil,
|
||||
Status: "",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user