mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(coderd): add experimental tasks send endpoint (#19941)
Fixes coder/internal#902
This commit is contained in:
committed by
GitHub
parent
615585d5d1
commit
5317d309d0
+292
-1
@@ -1,19 +1,25 @@
|
|||||||
package coderd
|
package coderd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/audit"
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/httpapi"
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
@@ -590,3 +596,288 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// Delete build created successfully.
|
// Delete build created successfully.
|
||||||
rw.WriteHeader(http.StatusAccepted)
|
rw.WriteHeader(http.StatusAccepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskSend submits task input to the tasks sidebar app by dialing the agent
|
||||||
|
// directly over the tailnet. We enforce ApplicationConnect RBAC on the
|
||||||
|
// workspace and validate the sidebar app health.
|
||||||
|
func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
taskID, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req codersdk.TaskSendRequest
|
||||||
|
if !httpapi.Read(ctx, rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Input == "" {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Task input is required.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
|
||||||
|
status, err := agentapiDoStatusRequest(ctx, client, appURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "stable" {
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Task app is not ready to accept input.",
|
||||||
|
Detail: fmt.Sprintf("Status: %s", status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
reqBody.Content = req.Input
|
||||||
|
reqBody.Type = "user"
|
||||||
|
|
||||||
|
req, err := agentapiNewRequest(ctx, http.MethodPost, appURL, "message", reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Failed to reach task app endpoint.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128))
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Task app rejected the message.",
|
||||||
|
Detail: fmt.Sprintf("Upstream status: %d; Body: %s", resp.StatusCode, body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"$schema":"http://localhost:3284/schemas/MessageResponseBody.json","ok":true}
|
||||||
|
// {"$schema":"http://localhost:3284/schemas/ErrorModel.json","title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"location":"body.type","value":"oof"}]}
|
||||||
|
var respBody map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Failed to decode task app response body.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := respBody["status"].(string); !ok || v != "ok" {
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Task app rejected the message.",
|
||||||
|
Detail: fmt.Sprintf("Upstream response: %v", respBody),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
httperror.WriteResponseError(ctx, rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authAndDoWithTaskSidebarAppClient centralizes the shared logic to:
|
||||||
|
//
|
||||||
|
// - Fetch the task workspace
|
||||||
|
// - Authorize ApplicationConnect on the workspace
|
||||||
|
// - Validate the AI task and sidebar app health
|
||||||
|
// - Dial the agent and construct an HTTP client to the apps loopback URL
|
||||||
|
//
|
||||||
|
// The provided callback receives the context, an HTTP client that dials via the
|
||||||
|
// agent, and the base app URL (as a value URL) to perform any request.
|
||||||
|
func (api *API) authAndDoWithTaskSidebarAppClient(
|
||||||
|
r *http.Request,
|
||||||
|
taskID uuid.UUID,
|
||||||
|
do func(ctx context.Context, client *http.Client, appURL *url.URL) error,
|
||||||
|
) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
workspaceID := taskID
|
||||||
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
if httpapi.Is404Error(err) {
|
||||||
|
return httperror.ErrResourceNotFound
|
||||||
|
}
|
||||||
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error fetching workspace.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connecting to applications requires ApplicationConnect on the workspace.
|
||||||
|
if !api.Authorize(r, policy.ActionApplicationConnect, workspace) {
|
||||||
|
return httperror.ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error fetching workspace resources.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(data.builds) == 0 || len(data.templates) == 0 {
|
||||||
|
return httperror.ErrResourceNotFound
|
||||||
|
}
|
||||||
|
build := data.builds[0]
|
||||||
|
if build.HasAITask == nil || !*build.HasAITask || build.AITaskSidebarAppID == nil || *build.AITaskSidebarAppID == uuid.Nil {
|
||||||
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Task is not configured with a sidebar app.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the sidebar app details to get the URL and validate app health.
|
||||||
|
sidebarAppID := *build.AITaskSidebarAppID
|
||||||
|
agentID, sidebarApp, ok := func() (uuid.UUID, codersdk.WorkspaceApp, bool) {
|
||||||
|
for _, res := range build.Resources {
|
||||||
|
for _, agent := range res.Agents {
|
||||||
|
for _, app := range agent.Apps {
|
||||||
|
if app.ID == sidebarAppID {
|
||||||
|
return agent.ID, app, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uuid.Nil, codersdk.WorkspaceApp{}, false
|
||||||
|
}()
|
||||||
|
if !ok {
|
||||||
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Task sidebar app not found in latest build.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an informative error if the app isn't healthy rather than trying
|
||||||
|
// and failing.
|
||||||
|
switch sidebarApp.Health {
|
||||||
|
case codersdk.WorkspaceAppHealthDisabled:
|
||||||
|
// No health check, pass through.
|
||||||
|
case codersdk.WorkspaceAppHealthInitializing:
|
||||||
|
return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{
|
||||||
|
Message: "Task sidebar app is initializing. Try again shortly.",
|
||||||
|
})
|
||||||
|
case codersdk.WorkspaceAppHealthUnhealthy:
|
||||||
|
return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{
|
||||||
|
Message: "Task sidebar app is unhealthy.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the direct app URL and dial the agent.
|
||||||
|
if sidebarApp.URL == "" {
|
||||||
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Task sidebar app URL is not configured.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
parsedURL, err := url.Parse(sidebarApp.URL)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error parsing task app URL.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if parsedURL.Scheme != "http" {
|
||||||
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Only http scheme is supported for direct agent-dial.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30)
|
||||||
|
defer dialCancel()
|
||||||
|
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Failed to reach task app endpoint.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return agentConn.DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return do(ctx, client, parsedURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentapiNewRequest(ctx context.Context, method string, appURL *url.URL, appURLPath string, body any) (*http.Request, error) {
|
||||||
|
u := *appURL
|
||||||
|
u.Path = path.Join(appURL.Path, appURLPath)
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Failed to marshal task app request body.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Failed to create task app request.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentapiDoStatusRequest(ctx context.Context, client *http.Client, appURL *url.URL) (string, error) {
|
||||||
|
req, err := agentapiNewRequest(ctx, http.MethodGet, appURL, "status", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Failed to reach task app endpoint.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Task app status returned an error.",
|
||||||
|
Detail: fmt.Sprintf("Status code: %d", resp.StatusCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"$schema":"http://localhost:3284/schemas/StatusResponseBody.json","status":"stable"}
|
||||||
|
var respBody struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||||
|
return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
|
||||||
|
Message: "Failed to decode task app status response body.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return respBody.Status, nil
|
||||||
|
}
|
||||||
|
|||||||
+217
-1
@@ -1,7 +1,10 @@
|
|||||||
package coderd_test
|
package coderd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,10 +12,15 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/agent"
|
||||||
|
"github.com/coder/coder/v2/agent/agenttest"
|
||||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/util/slice"
|
"github.com/coder/coder/v2/coderd/util/slice"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||||
"github.com/coder/coder/v2/provisioner/echo"
|
"github.com/coder/coder/v2/provisioner/echo"
|
||||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||||
"github.com/coder/coder/v2/testutil"
|
"github.com/coder/coder/v2/testutil"
|
||||||
@@ -147,9 +155,26 @@ func TestAITasksPrompts(t *testing.T) {
|
|||||||
func TestTasks(t *testing.T) {
|
func TestTasks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) codersdk.Template {
|
type aiTemplateOpts struct {
|
||||||
|
appURL string
|
||||||
|
authToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiTemplateOpt func(*aiTemplateOpts)
|
||||||
|
|
||||||
|
withSidebarURL := func(url string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.appURL = url } }
|
||||||
|
withAgentToken := func(token string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.authToken = token } }
|
||||||
|
|
||||||
|
createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, opts ...aiTemplateOpt) codersdk.Template {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
opt := aiTemplateOpts{
|
||||||
|
authToken: uuid.New().String(),
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&opt)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a template version that supports AI tasks with the AI Prompt parameter.
|
// Create a template version that supports AI tasks with the AI Prompt parameter.
|
||||||
taskAppID := uuid.New()
|
taskAppID := uuid.New()
|
||||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
@@ -176,11 +201,15 @@ func TestTasks(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Id: uuid.NewString(),
|
Id: uuid.NewString(),
|
||||||
Name: "example",
|
Name: "example",
|
||||||
|
Auth: &proto.Agent_Token{
|
||||||
|
Token: opt.authToken,
|
||||||
|
},
|
||||||
Apps: []*proto.App{
|
Apps: []*proto.App{
|
||||||
{
|
{
|
||||||
Id: taskAppID.String(),
|
Id: taskAppID.String(),
|
||||||
Slug: "task-sidebar",
|
Slug: "task-sidebar",
|
||||||
DisplayName: "Task Sidebar",
|
DisplayName: "Task Sidebar",
|
||||||
|
Url: opt.appURL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -384,6 +413,193 @@ func TestTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Send", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("IntegrationOK", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||||
|
owner := coderdtest.CreateFirstUser(t, client)
|
||||||
|
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||||
|
|
||||||
|
createStatusResponse := func(status string) string {
|
||||||
|
return `
|
||||||
|
{
|
||||||
|
"$schema": "http://localhost:3284/schemas/StatusResponseBody.json",
|
||||||
|
"status": "` + status + `"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
statusResponse := createStatusResponse("stable")
|
||||||
|
|
||||||
|
// Start a fake AgentAPI that accepts GET /status and POST /message.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/status" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = fmt.Fprint(w, statusResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == "/message" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
assert.Equal(t, `{"content":"Hello, Agent!","type":"user"}`, string(b), "expected message content")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
io.WriteString(w, `{"status": "ok"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Create an AI-capable template whose sidebar app points to our fake AgentAPI.
|
||||||
|
authToken := uuid.NewString()
|
||||||
|
template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken))
|
||||||
|
|
||||||
|
// Create a workspace (task) from the AI-capable template.
|
||||||
|
ws := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||||
|
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||||
|
{Name: codersdk.AITaskPromptParameterName, Value: "send a message"},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||||
|
|
||||||
|
// Start a fake agent so the workspace agent is connected before sending the message.
|
||||||
|
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
|
||||||
|
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
|
||||||
|
o.Client = agentClient
|
||||||
|
})
|
||||||
|
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady)
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||||
|
|
||||||
|
// Lookup the sidebar app ID.
|
||||||
|
w, err := client.Workspace(ctx, ws.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var sidebarAppID uuid.UUID
|
||||||
|
for _, res := range w.LatestBuild.Resources {
|
||||||
|
for _, ag := range res.Agents {
|
||||||
|
for _, app := range ag.Apps {
|
||||||
|
if app.Slug == "task-sidebar" {
|
||||||
|
sidebarAppID = app.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEqual(t, uuid.Nil, sidebarAppID)
|
||||||
|
|
||||||
|
// Make the sidebar app unhealthy initially.
|
||||||
|
err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
|
||||||
|
ID: sidebarAppID,
|
||||||
|
Health: database.WorkspaceAppHealthUnhealthy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
exp := codersdk.NewExperimentalClient(userClient)
|
||||||
|
err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{
|
||||||
|
Input: "Hello, Agent!",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "wanted error due to unhealthy sidebar app")
|
||||||
|
|
||||||
|
// Make the sidebar app healthy.
|
||||||
|
err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
|
||||||
|
ID: sidebarAppID,
|
||||||
|
Health: database.WorkspaceAppHealthHealthy,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
statusResponse = createStatusResponse("bad")
|
||||||
|
|
||||||
|
err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{
|
||||||
|
Input: "Hello, Agent!",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "wanted error due to bad status")
|
||||||
|
|
||||||
|
statusResponse = createStatusResponse("stable")
|
||||||
|
|
||||||
|
// Send task input to the tasks sidebar app and expect 204.e
|
||||||
|
err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{
|
||||||
|
Input: "Hello, Agent!",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingContent", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
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).
|
||||||
|
ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||||
|
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||||
|
{Name: codersdk.AITaskPromptParameterName, Value: "do work"},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||||
|
|
||||||
|
exp := codersdk.NewExperimentalClient(client)
|
||||||
|
err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{
|
||||||
|
Input: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TaskNotFound", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||||
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
|
||||||
|
exp := codersdk.NewExperimentalClient(client)
|
||||||
|
err := exp.TaskSend(ctx, "me", uuid.New(), codersdk.TaskSendRequest{
|
||||||
|
Input: "hi",
|
||||||
|
})
|
||||||
|
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NotATask", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
|
||||||
|
// Create a template without AI tasks.
|
||||||
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||||
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||||
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
|
|
||||||
|
ws := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||||
|
|
||||||
|
exp := codersdk.NewExperimentalClient(client)
|
||||||
|
err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{
|
||||||
|
Input: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
var sdkErr *codersdk.Error
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorAs(t, err, &sdkErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTasksCreate(t *testing.T) {
|
func TestTasksCreate(t *testing.T) {
|
||||||
|
|||||||
@@ -1012,6 +1012,7 @@ func New(options *Options) *API {
|
|||||||
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
|
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
|
||||||
r.Get("/{id}", api.taskGet)
|
r.Get("/{id}", api.taskGet)
|
||||||
r.Delete("/{id}", api.taskDelete)
|
r.Delete("/{id}", api.taskDelete)
|
||||||
|
r.Post("/{id}/send", api.taskSend)
|
||||||
r.Post("/", api.tasksCreate)
|
r.Post("/", api.tasksCreate)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -206,3 +206,21 @@ func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uui
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSendRequest is used to send task input to the tasks sidebar app.
|
||||||
|
type TaskSendRequest struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSend submits task input to the tasks sidebar app.
|
||||||
|
func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.UUID, req TaskSendRequest) error {
|
||||||
|
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/send", user, id.String()), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusNoContent {
|
||||||
|
return ReadBodyAsError(res)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Generated
+5
@@ -2883,6 +2883,11 @@ export interface Task {
|
|||||||
readonly updated_at: string;
|
readonly updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From codersdk/aitasks.go
|
||||||
|
export interface TaskSendRequest {
|
||||||
|
readonly input: string;
|
||||||
|
}
|
||||||
|
|
||||||
// From codersdk/aitasks.go
|
// From codersdk/aitasks.go
|
||||||
export type TaskState = "completed" | "failed" | "idle" | "working";
|
export type TaskState = "completed" | "failed" | "idle" | "working";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user