feat(coderd): use task data model for list (#20394)

Updates coder/internal#976
This commit is contained in:
Mathias Fredriksson
2025-10-23 20:22:51 +03:00
committed by GitHub
parent 2c6cbf15e2
commit a106d67c07
28 changed files with 985 additions and 622 deletions
+10 -34
View File
@@ -5,7 +5,6 @@ import (
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/pretty"
@@ -47,43 +46,19 @@ func (r *RootCmd) taskDelete() *serpent.Command {
}
exp := codersdk.NewExperimentalClient(client)
type toDelete struct {
ID uuid.UUID
Owner string
Display string
}
var items []toDelete
var tasks []codersdk.Task
for _, identifier := range inv.Args {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return xerrors.New("task identifier cannot be empty or whitespace")
}
// Check task identifier, try UUID first.
if id, err := uuid.Parse(identifier); err == nil {
task, err := exp.TaskByID(ctx, id)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", identifier, err)
}
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName})
continue
}
// Non-UUID, treat as a workspace identifier (name or owner/name).
ws, err := namedWorkspace(ctx, client, identifier)
task, err := exp.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", identifier, err)
}
display := ws.FullName()
items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName})
tasks = append(tasks, task)
}
// Confirm deletion of the tasks.
var displayList []string
for _, it := range items {
displayList = append(displayList, it.Display)
for _, task := range tasks {
displayList = append(displayList, fmt.Sprintf("%s/%s", task.OwnerName, task.Name))
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))),
@@ -94,12 +69,13 @@ func (r *RootCmd) taskDelete() *serpent.Command {
return err
}
for _, item := range items {
if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil {
return xerrors.Errorf("delete task %q: %w", item.Display, err)
for i, task := range tasks {
display := displayList[i]
if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
return xerrors.Errorf("delete task %q: %w", display, err)
}
_, _ = fmt.Fprintln(
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()),
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, display)+" at "+cliui.Timestamp(time.Now()),
)
}
+41 -17
View File
@@ -56,12 +56,18 @@ func TestExpTaskDelete(t *testing.T) {
taskID := uuid.MustParse(id1)
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists":
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
ID: taskID,
Name: "exists",
OwnerName: "me",
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: taskID,
Name: "exists",
OwnerName: "me",
}},
Count: 1,
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
c.deleteCalls.Add(1)
@@ -104,12 +110,18 @@ func TestExpTaskDelete(t *testing.T) {
firstID := uuid.MustParse(id3)
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first":
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
ID: firstID,
Name: "first",
OwnerName: "me",
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: firstID,
Name: "first",
OwnerName: "me",
}},
Count: 1,
})
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
@@ -139,8 +151,14 @@ func TestExpTaskDelete(t *testing.T) {
buildHandler: func(_ *testCounters) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist":
httpapi.ResourceNotFound(w)
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{},
Count: 0,
})
default:
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
}
@@ -156,12 +174,18 @@ func TestExpTaskDelete(t *testing.T) {
taskID := uuid.MustParse(id5)
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad":
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
c.nameResolves.Add(1)
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
ID: taskID,
Name: "bad",
OwnerName: "me",
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: taskID,
Name: "bad",
OwnerName: "me",
}},
Count: 1,
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5:
httpapi.InternalServerError(w, xerrors.New("boom"))
+5 -4
View File
@@ -8,6 +8,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
@@ -98,10 +99,10 @@ func (r *RootCmd) taskList() *serpent.Command {
Options: serpent.OptionSet{
{
Name: "status",
Description: "Filter by task status (e.g. running, failed, etc).",
Description: "Filter by task status.",
Flag: "status",
Default: "",
Value: serpent.StringOf(&statusFilter),
Value: serpent.EnumOf(&statusFilter, slice.ToStrings(codersdk.AllTaskStatuses())...),
},
{
Name: "all",
@@ -142,8 +143,8 @@ func (r *RootCmd) taskList() *serpent.Command {
}
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
Owner: targetUser,
WorkspaceStatus: statusFilter,
Owner: targetUser,
Status: codersdk.TaskStatus(statusFilter),
})
if err != nil {
return xerrors.Errorf("list tasks: %w", err)
+32 -13
View File
@@ -22,6 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
@@ -29,7 +30,7 @@ import (
)
// makeAITask creates an AI-task workspace.
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) {
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) database.Task {
t.Helper()
tv := dbfake.TemplateVersion(t, db).
@@ -91,14 +92,32 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU
)
require.NoError(t, err)
return build.Workspace
// Create a task record in the tasks table for the new data model.
task := dbgen.Task(t, db, database.TaskTable{
OrganizationID: orgID,
OwnerID: ownerID,
Name: build.Workspace.Name,
WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true},
TemplateVersionID: tv.TemplateVersion.ID,
TemplateParameters: []byte("{}"),
Prompt: prompt,
CreatedAt: dbtime.Now(),
})
// Link the task to the workspace app.
dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{
TaskID: task.ID,
WorkspaceBuildNumber: build.Build.BuildNumber,
WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
})
return task
}
func TestExpTaskList(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
t.Run("NoTasks_Table", func(t *testing.T) {
t.Parallel()
@@ -130,7 +149,7 @@ func TestExpTaskList(t *testing.T) {
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
wantPrompt := "build me a web app"
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
clitest.SetupConfig(t, memberClient, root)
@@ -142,7 +161,7 @@ func TestExpTaskList(t *testing.T) {
require.NoError(t, err)
// Validate the table includes the task and status.
pty.ExpectMatch(ws.Name)
pty.ExpectMatch(task.Name)
pty.ExpectMatch("running")
pty.ExpectMatch(wantPrompt)
})
@@ -157,11 +176,11 @@ func TestExpTaskList(t *testing.T) {
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Create two AI tasks: one running, one stopped.
running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
runningTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
stoppedTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
// Use JSON output to reliably validate filtering.
inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json")
inv, root := clitest.New(t, "exp", "task", "list", "--status=paused", "--output=json")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -177,8 +196,8 @@ func TestExpTaskList(t *testing.T) {
// Only the stopped task is returned.
require.Len(t, tasks, 1, "expected one task after filtering")
require.Equal(t, stopped.ID, tasks[0].ID)
require.NotEqual(t, running.ID, tasks[0].ID)
require.Equal(t, stoppedTask.ID, tasks[0].ID)
require.NotEqual(t, runningTask.ID, tasks[0].ID)
})
t.Run("UserFlag_Me_Table", func(t *testing.T) {
@@ -190,7 +209,7 @@ func TestExpTaskList(t *testing.T) {
_, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
inv, root := clitest.New(t, "exp", "task", "list", "--user", "me")
//nolint:gocritic // Owner client is intended here smoke test the member task not showing up.
@@ -202,7 +221,7 @@ func TestExpTaskList(t *testing.T) {
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
pty.ExpectMatch(ws.Name)
pty.ExpectMatch(task.Name)
})
t.Run("Quiet", func(t *testing.T) {
+7 -15
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
@@ -41,24 +40,17 @@ func (r *RootCmd) taskLogs() *serpent.Command {
}
var (
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
task = inv.Args[0]
taskID uuid.UUID
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
identifier = inv.Args[0]
)
if id, err := uuid.Parse(task); err == nil {
taskID = id
} else {
ws, err := namedWorkspace(ctx, client, task)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", task, err)
}
taskID = ws.ID
task, err := exp.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task %q: %w", identifier, err)
}
logs, err := exp.TaskLogs(ctx, codersdk.Me, taskID)
logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID)
if err != nil {
return xerrors.Errorf("get task logs: %w", err)
}
+13 -15
View File
@@ -23,8 +23,6 @@ import (
func Test_TaskLogs(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed up-stack!")
testMessages := []agentapisdk.Message{
{
Id: 0,
@@ -40,15 +38,15 @@ func Test_TaskLogs(t *testing.T) {
},
}
t.Run("ByWorkspaceName_JSON", func(t *testing.T) {
t.Run("ByTaskName_JSON", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client // user already has access to their own workspace
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", workspace.Name, "--output", "json")
inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -66,15 +64,15 @@ func Test_TaskLogs(t *testing.T) {
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
})
t.Run("ByWorkspaceID_JSON", func(t *testing.T) {
t.Run("ByTaskID_JSON", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String(), "--output", "json")
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String(), "--output", "json")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -92,15 +90,15 @@ func Test_TaskLogs(t *testing.T) {
require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type)
})
t.Run("ByWorkspaceID_Table", func(t *testing.T) {
t.Run("ByTaskID_Table", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String())
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -114,7 +112,7 @@ func Test_TaskLogs(t *testing.T) {
require.Contains(t, output, "output")
})
t.Run("WorkspaceNotFound_ByName", func(t *testing.T) {
t.Run("TaskNotFound_ByName", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -132,7 +130,7 @@ func Test_TaskLogs(t *testing.T) {
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
})
t.Run("WorkspaceNotFound_ByID", func(t *testing.T) {
t.Run("TaskNotFound_ByID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -154,10 +152,10 @@ func Test_TaskLogs(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError))
userClient := client
inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String())
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
clitest.SetupConfig(t, userClient, root)
err := inv.WithContext(ctx).Run()
+7 -15
View File
@@ -3,7 +3,6 @@ package cli
import (
"io"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
@@ -39,12 +38,11 @@ func (r *RootCmd) taskSend() *serpent.Command {
}
var (
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
task = inv.Args[0]
ctx = inv.Context()
exp = codersdk.NewExperimentalClient(client)
identifier = inv.Args[0]
taskInput string
taskID uuid.UUID
)
if stdin {
@@ -62,18 +60,12 @@ func (r *RootCmd) taskSend() *serpent.Command {
taskInput = inv.Args[1]
}
if id, err := uuid.Parse(task); err == nil {
taskID = id
} else {
ws, err := namedWorkspace(ctx, client, task)
if err != nil {
return xerrors.Errorf("resolve task: %w", err)
}
taskID = ws.ID
task, err := exp.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve task: %w", err)
}
if err = exp.TaskSend(ctx, codersdk.Me, taskID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
if err = exp.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil {
return xerrors.Errorf("send input to task: %w", err)
}
+13 -15
View File
@@ -22,17 +22,15 @@ import (
func Test_TaskSend(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed up-stack!")
t.Run("ByWorkspaceName_WithArgument", func(t *testing.T) {
t.Run("ByTaskName_WithArgument", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "carry on with the task")
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "carry on with the task")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -40,15 +38,15 @@ func Test_TaskSend(t *testing.T) {
require.NoError(t, err)
})
t.Run("ByWorkspaceID_WithArgument", func(t *testing.T) {
t.Run("ByTaskID_WithArgument", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", workspace.ID.String(), "carry on with the task")
inv, root := clitest.New(t, "exp", "task", "send", task.ID.String(), "carry on with the task")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
@@ -56,15 +54,15 @@ func Test_TaskSend(t *testing.T) {
require.NoError(t, err)
})
t.Run("ByWorkspaceName_WithStdin", func(t *testing.T) {
t.Run("ByTaskName_WithStdin", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
userClient := client
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "--stdin")
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "--stdin")
inv.Stdout = &stdout
inv.Stdin = strings.NewReader("carry on with the task")
clitest.SetupConfig(t, userClient, root)
@@ -73,7 +71,7 @@ func Test_TaskSend(t *testing.T) {
require.NoError(t, err)
})
t.Run("WorkspaceNotFound_ByName", func(t *testing.T) {
t.Run("TaskNotFound_ByName", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -91,7 +89,7 @@ func Test_TaskSend(t *testing.T) {
require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message)
})
t.Run("WorkspaceNotFound_ByID", func(t *testing.T) {
t.Run("TaskNotFound_ByID", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -113,10 +111,10 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
var stdout strings.Builder
inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "some task input")
inv, root := clitest.New(t, "exp", "task", "send", task.Name, "some task input")
inv.Stdout = &stdout
clitest.SetupConfig(t, userClient, root)
+3 -15
View File
@@ -5,7 +5,6 @@ import (
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
@@ -84,21 +83,10 @@ func (r *RootCmd) taskStatus() *serpent.Command {
}
ctx := i.Context()
ec := codersdk.NewExperimentalClient(client)
exp := codersdk.NewExperimentalClient(client)
identifier := i.Args[0]
taskID, err := uuid.Parse(identifier)
if err != nil {
// Try to resolve the task as a named workspace
// TODO: right now tasks are still "workspaces" under the hood.
// We should update this once we have a proper task model.
ws, err := namedWorkspace(ctx, client, identifier)
if err != nil {
return err
}
taskID = ws.ID
}
task, err := ec.TaskByID(ctx, taskID)
task, err := exp.TaskByIdentifier(ctx, identifier)
if err != nil {
return err
}
@@ -119,7 +107,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
// TODO: implement streaming updates instead of polling
lastStatusRow := tsr
for range t.C {
task, err := ec.TaskByID(ctx, taskID)
task, err := exp.TaskByID(ctx, task.ID)
if err != nil {
return err
}
+105 -41
View File
@@ -36,26 +36,17 @@ func Test_TaskStatus(t *testing.T) {
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users/me/workspace/doesnotexist":
httpapi.ResourceNotFound(w)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
}
},
},
{
args: []string{"err-fetching-workspace"},
expectError: assert.AnError.Error(),
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users/me/workspace/err-fetching-workspace":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
})
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
httpapi.InternalServerError(w, assert.AnError)
case "/api/experimental/tasks":
if r.URL.Query().Get("q") == "owner:\"me\"" {
httpapi.Write(ctx, w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{},
Count: 0,
})
return
}
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
@@ -69,10 +60,34 @@ func Test_TaskStatus(t *testing.T) {
hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users/me/workspace/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
})
case "/api/experimental/tasks":
if r.URL.Query().Get("q") == "owner:\"me\"" {
httpapi.Write(ctx, w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
OwnerName: "me",
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
CreatedAt: now,
UpdatedAt: now,
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateWorking,
Timestamp: now,
Message: "Thinking furiously...",
},
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
Status: codersdk.TaskStatusActive,
}},
Count: 1,
})
return
}
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
@@ -101,31 +116,54 @@ func Test_TaskStatus(t *testing.T) {
args: []string{"exists", "--watch"},
expectOutput: `
STATE CHANGED STATUS HEALTHY STATE MESSAGE
5s ago pending true
4s ago running true
3s ago running true working Reticulating splines...
2s ago running true complete Splines reticulated successfully!`,
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) {
var calls atomic.Int64
return func(w http.ResponseWriter, r *http.Request) {
defer calls.Add(1)
switch r.URL.Path {
case "/api/v2/users/me/workspace/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
})
case "/api/experimental/tasks":
if r.URL.Query().Get("q") == "owner:\"me\"" {
// Return initial task state for --watch test
httpapi.Write(ctx, w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
OwnerName: "me",
WorkspaceStatus: codersdk.WorkspaceStatusPending,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-5 * time.Second),
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
Status: codersdk.TaskStatusPending,
}},
Count: 1,
})
return
}
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
defer calls.Add(1)
switch calls.Load() {
case 0:
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
WorkspaceStatus: codersdk.WorkspaceStatusPending,
Name: "exists",
OwnerName: "me",
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
CreatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-5 * time.Second),
UpdatedAt: now.Add(-4 * time.Second),
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
Status: codersdk.TaskStatusPending,
Status: codersdk.TaskStatusInitializing,
})
return
case 1:
@@ -194,8 +232,8 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE
"id": "11111111-1111-1111-1111-111111111111",
"organization_id": "00000000-0000-0000-0000-000000000000",
"owner_id": "00000000-0000-0000-0000-000000000000",
"owner_name": "",
"name": "",
"owner_name": "me",
"name": "exists",
"template_id": "00000000-0000-0000-0000-000000000000",
"template_version_id": "00000000-0000-0000-0000-000000000000",
"template_name": "",
@@ -204,8 +242,10 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE
"workspace_id": null,
"workspace_status": "running",
"workspace_agent_id": null,
"workspace_agent_lifecycle": null,
"workspace_agent_health": null,
"workspace_agent_lifecycle": "ready",
"workspace_agent_health": {
"healthy": true
},
"workspace_app_id": null,
"initial_prompt": "",
"status": "active",
@@ -218,14 +258,38 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE
"created_at": "2025-08-26T12:34:56Z",
"updated_at": "2025-08-26T12:34:56Z"
}`,
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) {
ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users/me/workspace/exists":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
})
case "/api/experimental/tasks":
if r.URL.Query().Get("q") == "owner:\"me\"" {
httpapi.Write(ctx, w, http.StatusOK, struct {
Tasks []codersdk.Task `json:"tasks"`
Count int `json:"count"`
}{
Tasks: []codersdk.Task{{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Name: "exists",
OwnerName: "me",
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
CreatedAt: ts,
UpdatedAt: ts,
CurrentState: &codersdk.TaskStateEntry{
State: codersdk.TaskStateWorking,
Timestamp: ts.Add(time.Second),
Message: "Thinking furiously...",
},
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
Healthy: true,
},
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
Status: codersdk.TaskStatusActive,
}},
Count: 1,
})
return
}
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
+13 -9
View File
@@ -17,7 +17,6 @@ import (
"golang.org/x/xerrors"
agentapisdk "github.com/coder/agentapi-sdk-go"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
@@ -36,8 +35,6 @@ import (
func Test_Tasks(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed up-stack!")
// Given: a template configured for tasks
var (
ctx = testutil.Context(t, testutil.WaitLong)
@@ -241,7 +238,7 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes
// setupCLITaskTest creates a test workspace with an AI task template and agent,
// with a fake agent API configured with the provided set of handlers.
// Returns the user client and workspace.
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Workspace) {
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) {
t.Helper()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
@@ -254,11 +251,18 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
wantPrompt := "test prompt"
workspace := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: codersdk.AITaskPromptParameterName, Value: wantPrompt},
}
exp := codersdk.NewExperimentalClient(userClient)
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: wantPrompt,
Name: "test-task",
})
require.NoError(t, err)
// Wait for the task's underlying workspace to be built
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
@@ -269,7 +273,7 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).
WaitFor(coderdtest.AgentsReady)
return userClient, workspace
return userClient, task
}
// createAITaskTemplate creates a template configured for AI tasks with a sidebar app.
+63 -177
View File
@@ -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
View File
@@ -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) {
+1 -24
View File
@@ -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": {
+1 -24
View File
@@ -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": {
+62 -7
View File
@@ -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.
}
+3 -1
View File
@@ -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
}
+1
View File
@@ -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
+37
View File
@@ -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)
+196
View File
@@ -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")
}
})
}
}
+1
View File
@@ -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
+109 -21
View File
@@ -103,6 +103,17 @@ const (
TaskStatusError TaskStatus = "error"
)
func AllTaskStatuses() []TaskStatus {
return []TaskStatus{
TaskStatusPending,
TaskStatusInitializing,
TaskStatusActive,
TaskStatusPaused,
TaskStatusError,
TaskStatusUnknown,
}
}
// TaskState represents the high-level lifecycle of a task.
//
// Experimental: This type is experimental and may change in the future.
@@ -160,18 +171,37 @@ type TaskStateEntry struct {
type TasksFilter struct {
// Owner can be a username, UUID, or "me".
Owner string `json:"owner,omitempty"`
// Status filters the tasks by their status.
//
// TODO(mafredri): Enable this field.
// Status TaskStatus `json:"status,omitempty"`
// WorkspaceStatus is a workspace status to filter by.
//
// Deprecated: Use TaskStatus instead.
WorkspaceStatus string `json:"workspace_status,omitempty" typescript:"-"`
// Offset is the number of tasks to skip before returning results.
Offset int `json:"offset,omitempty" typescript:"-"`
// Limit is a limit on the number of tasks returned.
Limit int `json:"limit,omitempty" typescript:"-"`
// Organization can be an organization name or UUID.
Organization string `json:"organization,omitempty"`
// Status filters the tasks by their task status.
Status TaskStatus `json:"status,omitempty"`
// FilterQuery allows specifying a raw filter query.
FilterQuery string `json:"filter_query,omitempty"`
}
func (f TasksFilter) asRequestOption() RequestOption {
return func(r *http.Request) {
var params []string
// Make sure all user input is quoted to ensure it's parsed as a single
// string.
if f.Owner != "" {
params = append(params, fmt.Sprintf("owner:%q", f.Owner))
}
if f.Organization != "" {
params = append(params, fmt.Sprintf("organization:%q", f.Organization))
}
if f.Status != "" {
params = append(params, fmt.Sprintf("status:%q", string(f.Status)))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
}
q := r.URL.Query()
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
}
}
// Tasks lists all tasks belonging to the user or specified owner.
@@ -182,15 +212,7 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]
filter = &TasksFilter{}
}
var wsFilter WorkspaceFilter
wsFilter.Owner = filter.Owner
wsFilter.Status = filter.WorkspaceStatus
page := Pagination{
Offset: filter.Offset,
Limit: filter.Limit,
}
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption())
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, filter.asRequestOption())
if err != nil {
return nil, err
}
@@ -233,6 +255,72 @@ func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task,
return task, nil
}
func splitTaskIdentifier(identifier string) (owner string, taskName string, err error) {
parts := strings.Split(identifier, "/")
switch len(parts) {
case 1:
owner = Me
taskName = parts[0]
case 2:
owner = parts[0]
taskName = parts[1]
default:
return "", "", xerrors.Errorf("invalid task identifier: %q", identifier)
}
return owner, taskName, nil
}
// TaskByIdentifier fetches and returns a task by an identifier, which may be
// either a UUID, a name (for a task owned by the current user), or a
// "user/task" combination, where user is either a username or UUID.
//
// Since there is no TaskByOwnerAndName endpoint yet, this function uses the
// list endpoint with filtering when a name is provided.
func (c *ExperimentalClient) TaskByIdentifier(ctx context.Context, identifier string) (Task, error) {
identifier = strings.TrimSpace(identifier)
// Try parsing as UUID first.
if taskID, err := uuid.Parse(identifier); err == nil {
return c.TaskByID(ctx, taskID)
}
// Not a UUID, treat as identifier.
owner, taskName, err := splitTaskIdentifier(identifier)
if err != nil {
return Task{}, err
}
tasks, err := c.Tasks(ctx, &TasksFilter{
Owner: owner,
})
if err != nil {
return Task{}, xerrors.Errorf("list tasks for owner %q: %w", owner, err)
}
if taskID, err := uuid.Parse(taskName); err == nil {
// Find task by ID.
for _, task := range tasks {
if task.ID == taskID {
return task, nil
}
}
} else {
// Find task by name.
for _, task := range tasks {
if task.Name == taskName {
return task, nil
}
}
}
// Mimic resource not found from API.
var notFoundErr error = &Error{
Response: Response{Message: "Resource not found or you do not have access to this resource"},
}
return Task{}, xerrors.Errorf("task %q not found for owner %q: %w", taskName, owner, notFoundErr)
}
// DeleteTask deletes a task by its ID.
//
// Experimental: This method is experimental and may change in the future.
+75
View File
@@ -0,0 +1,75 @@
package codersdk
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_splitTaskIdentifier(t *testing.T) {
t.Parallel()
tests := []struct {
name string
identifier string
expectedOwner string
expectedTask string
expectErr bool
}{
{
name: "bare task name",
identifier: "mytask",
expectedOwner: Me,
expectedTask: "mytask",
expectErr: false,
},
{
name: "owner/task format",
identifier: "alice/her-task",
expectedOwner: "alice",
expectedTask: "her-task",
expectErr: false,
},
{
name: "uuid/task format",
identifier: "550e8400-e29b-41d4-a716-446655440000/task1",
expectedOwner: "550e8400-e29b-41d4-a716-446655440000",
expectedTask: "task1",
expectErr: false,
},
{
name: "owner/uuid format",
identifier: "alice/3abe1dcf-cd87-4078-8b54-c0e2058ad2e2",
expectedOwner: "alice",
expectedTask: "3abe1dcf-cd87-4078-8b54-c0e2058ad2e2",
expectErr: false,
},
{
name: "too many slashes",
identifier: "owner/task/extra",
expectErr: true,
},
{
name: "empty parts acceptable",
identifier: "/task",
expectedOwner: "",
expectedTask: "task",
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
owner, taskName, err := splitTaskIdentifier(tt.identifier)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedOwner, owner)
assert.Equal(t, tt.expectedTask, taskName)
}
})
}
}
+11 -6
View File
@@ -266,20 +266,25 @@ func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (codersdk
return codersdk.WorkspaceAgent{}, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames)
}
// namedWorkspace gets a workspace by owner/name or just name
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
// Parse owner and workspace name
func splitNameAndOwner(identifier string) (name string, owner string) {
// Parse owner and name (workspace, task).
parts := strings.SplitN(identifier, "/", 2)
var owner, workspaceName string
if len(parts) == 2 {
owner = parts[0]
workspaceName = parts[1]
name = parts[1]
} else {
owner = "me"
workspaceName = identifier
name = identifier
}
return name, owner
}
// namedWorkspace gets a workspace by owner/name or just name
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
workspaceName, owner := splitNameAndOwner(identifier)
// Handle -- separator format (convert to / format)
if strings.Contains(identifier, "--") && !strings.Contains(identifier, "/") {
dashParts := strings.SplitN(identifier, "--", 2)
+19 -57
View File
@@ -1909,24 +1909,12 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{
expClient := codersdk.NewExperimentalClient(deps.coderClient)
var owner string
id, err := uuid.Parse(args.TaskID)
if err == nil {
task, err := expClient.TaskByID(ctx, id)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("get task %q: %w", args.TaskID, err)
}
owner = task.OwnerName
} else {
ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err)
}
owner = ws.OwnerName
id = ws.ID
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("resolve task: %w", err)
}
err = expClient.DeleteTask(ctx, owner, id)
err = expClient.DeleteTask(ctx, task.OwnerName, task.ID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("delete task: %w", err)
}
@@ -1938,8 +1926,8 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{
}
type ListTasksArgs struct {
WorkspaceStatus string `json:"status"`
User string `json:"user"`
Status codersdk.TaskStatus `json:"status"`
User string `json:"user"`
}
type ListTasksResponse struct {
@@ -1972,8 +1960,8 @@ var ListTasks = Tool[ListTasksArgs, ListTasksResponse]{
expClient := codersdk.NewExperimentalClient(deps.coderClient)
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{
Owner: args.User,
WorkspaceStatus: args.WorkspaceStatus,
Owner: args.User,
Status: args.Status,
})
if err != nil {
return ListTasksResponse{}, xerrors.Errorf("list tasks: %w", err)
@@ -1990,7 +1978,7 @@ type GetTaskStatusArgs struct {
}
type GetTaskStatusResponse struct {
Status codersdk.WorkspaceStatus `json:"status"`
Status codersdk.TaskStatus `json:"status"`
State *codersdk.TaskStateEntry `json:"state"`
}
@@ -2016,22 +2004,13 @@ var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{
expClient := codersdk.NewExperimentalClient(deps.coderClient)
id, err := uuid.Parse(args.TaskID)
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID)
if err != nil {
return GetTaskStatusResponse{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err)
}
id = ws.ID
}
task, err := expClient.TaskByID(ctx, id)
if err != nil {
return GetTaskStatusResponse{}, xerrors.Errorf("get task %q: %w", args.TaskID, err)
return GetTaskStatusResponse{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
}
return GetTaskStatusResponse{
Status: task.WorkspaceStatus,
Status: task.Status,
State: task.CurrentState,
}, nil
},
@@ -2071,12 +2050,13 @@ var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{
}
expClient := codersdk.NewExperimentalClient(deps.coderClient)
id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID)
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.Response{}, err
return codersdk.Response{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
}
err = expClient.TaskSend(ctx, owner, id, codersdk.TaskSendRequest{
err = expClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{
Input: args.Input,
})
if err != nil {
@@ -2114,12 +2094,13 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{
}
expClient := codersdk.NewExperimentalClient(deps.coderClient)
id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID)
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.TaskLogsResponse{}, err
}
logs, err := expClient.TaskLogs(ctx, owner, id)
logs, err := expClient.TaskLogs(ctx, task.OwnerName, task.ID)
if err != nil {
return codersdk.TaskLogsResponse{}, xerrors.Errorf("get task logs %q: %w", args.TaskID, err)
}
@@ -2128,13 +2109,6 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{
},
}
// normalizedNamedWorkspace normalizes the workspace name before getting the
// workspace by name.
func normalizedNamedWorkspace(ctx context.Context, client *codersdk.Client, name string) (codersdk.Workspace, error) {
// Maybe namedWorkspace should itself call NormalizeWorkspaceInput?
return namedWorkspace(ctx, client, NormalizeWorkspaceInput(name))
}
// NormalizeWorkspaceInput converts workspace name input to standard format.
// Handles the following input formats:
// - workspace → workspace
@@ -2205,15 +2179,3 @@ func taskIDDescription(action string) string {
func userDescription(action string) string {
return fmt.Sprintf("Username or ID of the user for which to %s. Omit or use the `me` keyword to %s for the authenticated user.", action, action)
}
func resolveTaskID(ctx context.Context, coderClient *codersdk.Client, taskID string) (uuid.UUID, string, error) {
id, err := uuid.Parse(taskID)
if err == nil {
return id, codersdk.Me, nil
}
ws, err := normalizedNamedWorkspace(ctx, coderClient, taskID)
if err != nil {
return uuid.UUID{}, codersdk.Me, xerrors.Errorf("get task workspace %q: %w", taskID, err)
}
return ws.ID, ws.OwnerName, nil
}
+126 -93
View File
@@ -35,6 +35,7 @@ import (
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
@@ -881,11 +882,9 @@ func TestTools(t *testing.T) {
}
})
t.Run("WorkspaceDeleteTask", func(t *testing.T) {
t.Run("DeleteTask", func(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
@@ -896,21 +895,37 @@ func TestTools(t *testing.T) {
},
}).Do()
// nolint:gocritic // This is in a test package and does not end up in the build
ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{
Name: "delete-task-workspace-1",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(nil).Do()
})
task1 := dbgen.Task(t, store, database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
Name: ws1Table.Name,
WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true},
TemplateVersionID: aiTV.TemplateVersion.ID,
Prompt: "delete task 1",
})
_ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do()
// nolint:gocritic // This is in a test package and does not end up in the build
_ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
ws2Table := dbgen.Workspace(t, store, database.WorkspaceTable{
Name: "delete-task-workspace-2",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(nil).Do()
})
task2 := dbgen.Task(t, store, database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
Name: ws2Table.Name,
WorkspaceID: uuid.NullUUID{UUID: ws2Table.ID, Valid: true},
TemplateVersionID: aiTV.TemplateVersion.ID,
Prompt: "delete task 2",
})
_ = dbfake.WorkspaceBuild(t, store, ws2Table).WithTask(nil).Do()
tests := []struct {
name string
@@ -920,13 +935,13 @@ func TestTools(t *testing.T) {
{
name: "ByUUID",
args: toolsdk.DeleteTaskArgs{
TaskID: ws1.Workspace.ID.String(),
TaskID: task1.ID.String(),
},
},
{
name: "ByWorkspaceIdentifier",
name: "ByIdentifier",
args: toolsdk.DeleteTaskArgs{
TaskID: "delete-task-workspace-2",
TaskID: task2.Name,
},
},
{
@@ -975,47 +990,64 @@ func TestTools(t *testing.T) {
}
})
t.Run("WorkspaceListTasks", func(t *testing.T) {
t.Run("ListTasks", func(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
taskClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
taskClient, taskUser := coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil)
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: owner.UserID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
// Create a template with AI task support using the proper flow.
version := coderdtest.CreateTemplateVersion(t, client, owner.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,
}}},
},
}).Do()
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
expClient := codersdk.NewExperimentalClient(client)
taskExpClient := codersdk.NewExperimentalClient(taskClient)
// This task should not show up since listing is user-scoped.
// nolint:gocritic // This is in a test package and does not end up in the build
_ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "list-task-workspace-member",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(nil).Do()
_, err := expClient.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "task for member",
Name: "list-task-workspace-member",
})
require.NoError(t, err)
// These tasks should show up.
// Create tasks for taskUser. These should show up in the list.
for i := range 5 {
// nolint:gocritic // This is in a test package and does not end up in the build
var transition database.WorkspaceTransition
taskName := fmt.Sprintf("list-task-workspace-%d", i)
task, err := taskExpClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: fmt.Sprintf("task %d", i),
Name: taskName,
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have workspace ID")
// For the first task, stop the workspace to make it paused.
if i == 0 {
// nolint:gocritic // This is in a test package and does not end up in the build
transition = database.WorkspaceTransitionStop
ws, err := taskClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, ws.LatestBuild.ID)
// Stop the workspace to set task status to paused.
build, err := taskClient.CreateWorkspaceBuild(ctx, task.WorkspaceID.UUID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, build.ID)
}
// nolint:gocritic // This is in a test package and does not end up in the build
_ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: fmt.Sprintf("list-task-workspace-%d", i),
OrganizationID: owner.OrganizationID,
OwnerID: taskUser.ID,
TemplateID: aiTV.Template.ID,
}).Seed(database.WorkspaceBuild{Transition: transition}).WithTask(nil).Do()
}
tests := []struct {
@@ -1038,7 +1070,7 @@ func TestTools(t *testing.T) {
{
name: "ListFiltered",
args: toolsdk.ListTasksArgs{
WorkspaceStatus: "stopped",
Status: codersdk.TaskStatusPaused,
},
expected: []string{
"list-task-workspace-0",
@@ -1068,11 +1100,9 @@ func TestTools(t *testing.T) {
}
})
t.Run("WorkspaceGetTask", func(t *testing.T) {
t.Run("GetTask", func(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
@@ -1083,33 +1113,41 @@ func TestTools(t *testing.T) {
},
}).Do()
// nolint:gocritic // This is in a test package and does not end up in the build
ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{
Name: "get-task-workspace-1",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(nil).Do()
})
task := dbgen.Task(t, store, database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
Name: "get-task-1",
WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true},
TemplateVersionID: aiTV.TemplateVersion.ID,
Prompt: "get task",
})
_ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do()
tests := []struct {
name string
args toolsdk.GetTaskStatusArgs
expected codersdk.WorkspaceStatus
expected codersdk.TaskStatus
error string
}{
{
name: "ByUUID",
args: toolsdk.GetTaskStatusArgs{
TaskID: ws1.Workspace.ID.String(),
TaskID: task.ID.String(),
},
expected: codersdk.WorkspaceStatusRunning,
expected: codersdk.TaskStatusInitializing,
},
{
name: "ByWorkspaceIdentifier",
name: "ByIdentifier",
args: toolsdk.GetTaskStatusArgs{
TaskID: "get-task-workspace-1",
TaskID: task.Name,
},
expected: codersdk.WorkspaceStatusRunning,
expected: codersdk.TaskStatusInitializing,
},
{
name: "NoID",
@@ -1301,8 +1339,6 @@ func TestTools(t *testing.T) {
t.Run("SendTaskInput", func(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
// Start a fake AgentAPI that accepts GET /status and POST /message.
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/status" {
@@ -1340,13 +1376,21 @@ func TestTools(t *testing.T) {
},
}).Do()
// nolint:gocritic // This is in a test package and does not end up in the build
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "send-task-input",
wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{
Name: "send-task-input-ws",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(&proto.App{Url: srv.URL}).Do()
})
task := dbgen.Task(t, store, database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
Name: "send-task-input",
WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true},
TemplateVersionID: aiTV.TemplateVersion.ID,
Prompt: "send task input",
})
ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do()
_ = agenttest.New(t, client.URL, ws.AgentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait()
@@ -1359,14 +1403,14 @@ func TestTools(t *testing.T) {
{
name: "ByUUID",
args: toolsdk.SendTaskInputArgs{
TaskID: ws.Workspace.ID.String(),
TaskID: task.ID.String(),
Input: "frob the baz",
},
},
{
name: "ByWorkspaceIdentifier",
name: "ByIdentifier",
args: toolsdk.SendTaskInputArgs{
TaskID: "send-task-input",
TaskID: task.Name,
Input: "frob the baz",
},
},
@@ -1404,7 +1448,7 @@ func TestTools(t *testing.T) {
TaskID: r.Workspace.ID.String(),
Input: "this is ignored",
},
error: "Task is not configured with a sidebar app",
error: "Resource not found",
},
}
@@ -1429,8 +1473,6 @@ func TestTools(t *testing.T) {
t.Run("GetTaskLogs", func(t *testing.T) {
t.Parallel()
t.Skip("TODO(mafredri): Remove, fixed down-stack!")
messages := []agentapi.Message{
{
Id: 0,
@@ -1471,13 +1513,21 @@ func TestTools(t *testing.T) {
},
}).Do()
// nolint:gocritic // This is in a test package and does not end up in the build
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "get-task-logs",
wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{
Name: "get-task-logs-ws",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(&proto.App{Url: srv.URL}).Do()
})
task := dbgen.Task(t, store, database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
Name: "get-task-logs",
WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true},
TemplateVersionID: aiTV.TemplateVersion.ID,
Prompt: "get task logs",
})
ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do()
_ = agenttest.New(t, client.URL, ws.AgentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait()
@@ -1491,14 +1541,14 @@ func TestTools(t *testing.T) {
{
name: "ByUUID",
args: toolsdk.GetTaskLogsArgs{
TaskID: ws.Workspace.ID.String(),
TaskID: task.ID.String(),
},
expected: messages,
},
{
name: "ByWorkspaceIdentifier",
name: "ByIdentifier",
args: toolsdk.GetTaskLogsArgs{
TaskID: "get-task-logs",
TaskID: task.Name,
},
expected: messages,
},
@@ -1526,7 +1576,7 @@ func TestTools(t *testing.T) {
args: toolsdk.GetTaskLogsArgs{
TaskID: r.Workspace.ID.String(),
},
error: "Task is not configured with a sidebar app",
error: "Resource not found",
},
}
@@ -1777,23 +1827,6 @@ func TestMain(m *testing.M) {
if runtime.GOOS == "windows" && tool.Name == "coder_workspace_bash" {
continue
}
ignored := false
for _, ignore := range []string{
"coder_delete_task",
"coder_list_tasks",
"coder_get_task_status",
"coder_send_task_input",
"coder_get_task_logs",
"coder_get_task_logs",
} {
if ignore == tool.Name {
ignored = true
break
}
}
if ignored {
continue
}
untested = append(untested, tool.Name)
}
}
+3 -6
View File
@@ -15,12 +15,9 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \
### Parameters
| Name | In | Type | Required | Description |
|------------|-------|---------|----------|-------------------------------------------|
| `q` | query | string | false | Search query for filtering tasks |
| `after_id` | query | string | false | Return tasks after this ID for pagination |
| `limit` | query | integer | false | Maximum number of tasks to return |
| `offset` | query | integer | false | Offset for pagination |
| Name | In | Type | Required | Description |
|------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------|
| `q` | query | string | false | Search query for filtering tasks. Supports: owner:<username/uuid/me>, organization:<org-name/uuid>, status:<status> |
### Example responses
+12
View File
@@ -4810,6 +4810,18 @@ export interface TasksFilter {
* Owner can be a username, UUID, or "me".
*/
readonly owner?: string;
/**
* Organization can be an organization name or UUID.
*/
readonly organization?: string;
/**
* Status filters the tasks by their task status.
*/
readonly status?: TaskStatus;
/**
* FilterQuery allows specifying a raw filter query.
*/
readonly filter_query?: string;
}
// From codersdk/deployment.go