refactor: modify task creation endpoint to return a task, not workspace (#19637)

Relates to https://github.com/coder/internal/issues/898

Refactor the `POST /api/experimental/tasks/{user}` endpoint to return a
codersdk.Task instead of a codersdk.Workspace
This commit is contained in:
Danielle Maywood
2025-08-29 16:32:19 +01:00
committed by GitHub
parent 6e55ed8d08
commit 433f9c4a38
9 changed files with 121 additions and 111 deletions
+3 -3
View File
@@ -104,7 +104,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
templateVersionPresetID = preset.ID
}
workspace, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
task, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: templateVersionID,
TemplateVersionPresetID: templateVersionPresetID,
Prompt: taskInput,
@@ -116,8 +116,8 @@ func (r *RootCmd) taskCreate() *serpent.Command {
_, _ = fmt.Fprintf(
inv.Stdout,
"The task %s has been created at %s!\n",
cliui.Keyword(workspace.Name),
cliui.Timestamp(workspace.CreatedAt),
cliui.Keyword(task.Name),
cliui.Timestamp(task.CreatedAt),
)
return nil
+60 -56
View File
@@ -188,7 +188,6 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
WorkspaceOwner: owner.Username,
},
})
defer commitAudit()
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, r)
if err != nil {
@@ -196,7 +195,65 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusCreated, w)
task := taskFromWorkspace(w, req.Prompt)
httpapi.Write(ctx, rw, http.StatusCreated, task)
}
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
}
}
var currentState *codersdk.TaskStateEntry
if ws.LatestAppStatus != nil {
currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestAppStatus.CreatedAt,
State: codersdk.TaskState(ws.LatestAppStatus.State),
Message: ws.LatestAppStatus.Message,
URI: ws.LatestAppStatus.URI,
}
}
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},
WorkspaceAgentID: taskAgentID,
WorkspaceAgentLifecycle: taskAgentLifecycle,
WorkspaceAgentHealth: taskAgentHealth,
CreatedAt: ws.CreatedAt,
UpdatedAt: ws.UpdatedAt,
InitialPrompt: initialPrompt,
Status: ws.LatestBuild.Status,
CurrentState: currentState,
}
}
// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching
@@ -221,60 +278,7 @@ func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersd
tasks := make([]codersdk.Task, 0, len(apiWorkspaces))
for _, ws := range apiWorkspaces {
// 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
}
}
var currentState *codersdk.TaskStateEntry
if ws.LatestAppStatus != nil {
currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestAppStatus.CreatedAt,
State: codersdk.TaskState(ws.LatestAppStatus.State),
Message: ws.LatestAppStatus.Message,
URI: ws.LatestAppStatus.URI,
}
}
tasks = append(tasks, 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},
WorkspaceAgentID: taskAgentID,
WorkspaceAgentLifecycle: taskAgentLifecycle,
WorkspaceAgentHealth: taskAgentHealth,
CreatedAt: ws.CreatedAt,
UpdatedAt: ws.UpdatedAt,
InitialPrompt: promptsByBuildID[ws.LatestBuild.ID],
Status: ws.LatestBuild.Status,
CurrentState: currentState,
})
tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID]))
}
return tasks, nil
+9 -5
View File
@@ -419,19 +419,23 @@ func TestTasksCreate(t *testing.T) {
expClient := codersdk.NewExperimentalClient(client)
// When: We attempt to create a Task.
workspace, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Prompt: taskPrompt,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.True(t, task.WorkspaceID.Valid)
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Then: We expect a workspace to have been created.
assert.NotEmpty(t, workspace.Name)
assert.Equal(t, template.ID, workspace.TemplateID)
assert.NotEmpty(t, task.Name)
assert.Equal(t, template.ID, task.TemplateID)
// And: We expect it to have the "AI Prompt" parameter correctly set.
parameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
parameters, err := client.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, parameters, 1)
assert.Equal(t, codersdk.AITaskPromptParameterName, parameters[0].Name)
+7 -7
View File
@@ -53,23 +53,23 @@ type CreateTaskRequest struct {
Prompt string `json:"prompt"`
}
func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Workspace, error) {
func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Task, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s", user), request)
if err != nil {
return Workspace{}, err
return Task{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return Workspace{}, ReadBodyAsError(res)
return Task{}, ReadBodyAsError(res)
}
var workspace Workspace
if err := json.NewDecoder(res.Body).Decode(&workspace); err != nil {
return Workspace{}, err
var task Task
if err := json.NewDecoder(res.Body).Decode(&task); err != nil {
return Task{}, err
}
return workspace, nil
return task, nil
}
// TaskState represents the high-level lifecycle of a task.
+2 -2
View File
@@ -2686,8 +2686,8 @@ class ExperimentalApiMethods {
createTask = async (
user: string,
req: TypesGen.CreateTaskRequest,
): Promise<TypesGen.Workspace> => {
const response = await this.axios.post<TypesGen.Workspace>(
): Promise<TypesGen.Task> => {
const response = await this.axios.post<TypesGen.Task>(
`/api/experimental/tasks/${user}`,
req,
);
+8 -8
View File
@@ -1,7 +1,9 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { templateVersionPresets } from "api/queries/templates";
import type {
Preset,
Task,
Template,
TemplateVersionExternalAuth,
} from "api/typesGenerated";
@@ -28,13 +30,12 @@ import {
import { useAuthenticated } from "hooks/useAuthenticated";
import { useExternalAuth } from "hooks/useExternalAuth";
import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks";
import { type FC, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import TextareaAutosize from "react-textarea-autosize";
import { docs } from "utils/docs";
import { data } from "./data";
const textareaPlaceholder = "Prompt your AI agent to start a task...";
@@ -64,7 +65,7 @@ export const TaskPrompt: FC<TaskPromptProps> = ({
<CreateTaskForm
templates={templates}
onSuccess={(task) => {
navigate(`/tasks/${task.workspace.owner_name}/${task.workspace.name}`);
navigate(`/tasks/${task.owner_name}/${task.name}`);
}}
/>
);
@@ -188,12 +189,11 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
const createTaskMutation = useMutation({
mutationFn: async ({ prompt }: CreateTaskMutationFnProps) =>
data.createTask(
API.experimental.createTask(user.id, {
prompt,
user.id,
selectedTemplate.active_version_id,
selectedPresetId,
),
template_version_id: selectedTemplate.active_version_id,
template_version_preset_id: selectedPresetId,
}),
onSuccess: async (task) => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
@@ -2,6 +2,7 @@ import {
MockAIPromptPresets,
MockNewTaskData,
MockPresets,
MockTask,
MockTasks,
MockTemplate,
MockTemplateVersionExternalAuthGithub,
@@ -19,7 +20,6 @@ import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { data } from "./data";
import TasksPage from "./TasksPage";
const meta: Meta<typeof TasksPage> = {
@@ -248,7 +248,7 @@ export const CreateTaskSuccessfully: Story = {
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
@@ -272,7 +272,7 @@ export const CreateTaskError: Story = {
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
spyOn(data, "createTask").mockRejectedValue(
spyOn(API.experimental, "createTask").mockRejectedValue(
mockApiError({
message: "Failed to create task",
detail: "You don't have permission to create tasks.",
@@ -301,7 +301,7 @@ export const WithAuthenticatedExternalAuth: Story = {
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
@@ -327,7 +327,7 @@ export const MissingExternalAuth: Story = {
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithub,
]);
@@ -353,7 +353,7 @@ export const ExternalAuthError: Story = {
spyOn(API.experimental, "getTasks")
.mockResolvedValueOnce(MockTasks)
.mockResolvedValue([MockNewTaskData, ...MockTasks]);
spyOn(data, "createTask").mockResolvedValue(MockNewTaskData);
spyOn(API.experimental, "createTask").mockResolvedValue(MockTask);
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
mockApiError({
message: "Failed to load external auth",
-24
View File
@@ -1,24 +0,0 @@
import { API } from "api/api";
import type { Task } from "modules/tasks/tasks";
// TODO: This is a temporary solution while the BE does not return the Task in a
// right shape with a custom name. This should be removed once the BE is fixed.
export const data = {
async createTask(
prompt: string,
userId: string,
templateVersionId: string,
presetId: string | undefined,
): Promise<Task> {
const workspace = await API.experimental.createTask(userId, {
template_version_id: templateVersionId,
template_version_preset_id: presetId,
prompt,
});
return {
workspace,
prompt,
};
},
};
+26
View File
@@ -4903,6 +4903,32 @@ export const MockTasks = [
},
];
export const MockTask: TypesGen.Task = {
id: "test-task",
name: "task-wild-test-123",
organization_id: MockOrganization.id,
owner_id: MockUserOwner.id,
owner_name: MockUserOwner.username,
template_id: MockTemplate.id,
template_name: MockTemplate.name,
template_display_name: MockTemplate.display_name,
template_icon: MockTemplate.icon,
workspace_id: MockWorkspace.id,
workspace_agent_id: MockWorkspaceAgent.id,
workspace_agent_lifecycle: MockWorkspaceAgent.lifecycle_state,
workspace_agent_health: MockWorkspaceAgent.health,
initial_prompt: "Perform some task",
status: "running",
current_state: {
timestamp: "2022-05-17T17:39:01.382927298Z",
state: "idle",
message: "Should I continue?",
uri: "https://dev.coder.com",
},
created_at: "2022-05-17T17:39:01.382927298Z",
updated_at: "2022-05-17T17:39:01.382927298Z",
};
export const MockNewTaskData = {
prompt: "Create a new task",
workspace: {