Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4a4e80d2d | |||
| 95aeab3d1f | |||
| 6d66b2a8ec |
@@ -27,5 +27,3 @@ coderd/schedule/autostop.go @deansheather @DanielleMaywood
|
||||
# well as guidance from revenue.
|
||||
coderd/usage/ @deansheather @spikecurtis
|
||||
enterprise/coderd/usage/ @deansheather @spikecurtis
|
||||
|
||||
.github/ @jdomeracki-coder
|
||||
|
||||
+48
-56
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -71,21 +70,16 @@ const (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
// IgnorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
IgnorePorts map[int]string
|
||||
// ListeningPortsGetter is used to get the list of listening ports. Only
|
||||
// tests should set this. If unset, a default that queries the OS will be used.
|
||||
ListeningPortsGetter ListeningPortsGetter
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
IgnorePorts map[int]string
|
||||
PortCacheDuration time.Duration
|
||||
SSHMaxTimeout time.Duration
|
||||
TailnetListenPort uint16
|
||||
Subsystems []codersdk.AgentSubsystem
|
||||
@@ -143,7 +137,9 @@ func New(options Options) Agent {
|
||||
if options.ServiceBannerRefreshInterval == 0 {
|
||||
options.ServiceBannerRefreshInterval = 2 * time.Minute
|
||||
}
|
||||
|
||||
if options.PortCacheDuration == 0 {
|
||||
options.PortCacheDuration = 1 * time.Second
|
||||
}
|
||||
if options.Clock == nil {
|
||||
options.Clock = quartz.NewReal()
|
||||
}
|
||||
@@ -157,38 +153,30 @@ func New(options Options) Agent {
|
||||
options.Execer = agentexec.DefaultExecer
|
||||
}
|
||||
|
||||
if options.ListeningPortsGetter == nil {
|
||||
options.ListeningPortsGetter = &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
hardCtx, hardCancel := context.WithCancel(context.Background())
|
||||
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
|
||||
a := &agent{
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
listeningPortsHandler: listeningPortsHandler{
|
||||
getter: options.ListeningPortsGetter,
|
||||
ignorePorts: maps.Clone(options.IgnorePorts),
|
||||
},
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
ignorePorts: options.IgnorePorts,
|
||||
portCacheDuration: options.PortCacheDuration,
|
||||
reportMetadataInterval: options.ReportMetadataInterval,
|
||||
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
@@ -214,16 +202,20 @@ func New(options Options) Agent {
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
listeningPortsHandler listeningPortsHandler
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
// ignorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
ignorePorts map[int]string
|
||||
portCacheDuration time.Duration
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
|
||||
reconnectingPTYTimeout time.Duration
|
||||
reconnectingPTYServer *reconnectingpty.Server
|
||||
|
||||
+31
-33
@@ -2,31 +2,41 @@ package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/httpmw"
|
||||
)
|
||||
|
||||
func (a *agent) apiHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(
|
||||
httpmw.Recover(a.logger),
|
||||
tracing.StatusWriterMiddleware,
|
||||
loggermw.Logger(a.logger),
|
||||
)
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Hello from the agent!",
|
||||
})
|
||||
})
|
||||
|
||||
// Make a copy to ensure the map is not modified after the handler is
|
||||
// created.
|
||||
cpy := make(map[int]string)
|
||||
for k, b := range a.ignorePorts {
|
||||
cpy[k] = b
|
||||
}
|
||||
|
||||
cacheDuration := 1 * time.Second
|
||||
if a.portCacheDuration > 0 {
|
||||
cacheDuration = a.portCacheDuration
|
||||
}
|
||||
|
||||
lp := &listeningPortsHandler{
|
||||
ignorePorts: cpy,
|
||||
cacheDuration: cacheDuration,
|
||||
}
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
|
||||
@@ -47,7 +57,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/api/v0/read-file", a.HandleReadFile)
|
||||
@@ -62,21 +72,22 @@ func (a *agent) apiHandler() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
type ListeningPortsGetter interface {
|
||||
GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error)
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
// In production code, this is set to an osListeningPortsGetter, but it can be overridden for
|
||||
// testing.
|
||||
getter ListeningPortsGetter
|
||||
ignorePorts map[int]string
|
||||
ignorePorts map[int]string
|
||||
cacheDuration time.Duration
|
||||
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mut sync.Mutex
|
||||
//nolint: unused // used on some but not all platforms
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// handler returns a list of listening ports. This is tested by coderd's
|
||||
// TestWorkspaceAgentListeningPorts test.
|
||||
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
|
||||
ports, err := lp.getter.GetListeningPorts()
|
||||
ports, err := lp.getListeningPorts()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not scan for listening ports.",
|
||||
@@ -85,20 +96,7 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
filteredPorts := make([]codersdk.WorkspaceAgentListeningPort, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
if port.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(port.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
filteredPorts = append(filteredPorts, port)
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
|
||||
Ports: filteredPorts,
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cakturk/go-netstat/netstat"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
mut sync.Mutex
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
lp.mut.Lock()
|
||||
defer lp.mut.Unlock()
|
||||
|
||||
@@ -40,7 +33,12 @@ func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgent
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.WorkspaceAgentListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOSListeningPortsGetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
uut := &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Hour,
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
ports, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, port := range ports {
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
|
||||
if port.Port == uint16(l.Addr().(*net.TCPAddr).Port) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
|
||||
// check that we cache the ports
|
||||
err = l.Close()
|
||||
require.NoError(t, err)
|
||||
portsNew, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports, portsNew)
|
||||
|
||||
// note that it's unsafe to try to assert that a port does not exist in the response
|
||||
// because the OS may reallocate the port very quickly.
|
||||
}
|
||||
@@ -2,17 +2,9 @@
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"time"
|
||||
import "github.com/coder/coder/v2/codersdk"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
}
|
||||
|
||||
func (*osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
func (*listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
|
||||
// moment. The UI will not show any "no ports found" message to the user, so
|
||||
// the user won't suspect a thing.
|
||||
|
||||
+4
-20
@@ -28,9 +28,7 @@ import (
|
||||
)
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory. The invocation is set up to use a
|
||||
// global config directory for the given testing.TB, and keyring
|
||||
// usage disabled.
|
||||
// temporary testing directory.
|
||||
func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
|
||||
@@ -61,15 +59,6 @@ func NewWithCommand(
|
||||
t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
// Keyring usage is disabled here when --global-config is set because many existing
|
||||
// tests expect the session token to be stored on disk and is not properly instrumented
|
||||
// for parallel testing against the actual operating system keyring.
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
}
|
||||
|
||||
func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) *serpent.Invocation {
|
||||
// I really would like to fail test on error logs, but realistically, turning on by default
|
||||
// in all our CLI tests is going to create a lot of flaky noise.
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
|
||||
@@ -77,21 +66,16 @@ func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
Named("cli")
|
||||
i := &serpent.Invocation{
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Args: append([]string{"--global-config", string(configDir)}, args...),
|
||||
Stdin: io.LimitReader(nil, 0),
|
||||
Stdout: (&logWriter{prefix: "stdout", log: logger}),
|
||||
Stderr: (&logWriter{prefix: "stderr", log: logger}),
|
||||
Logger: logger,
|
||||
}
|
||||
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
|
||||
return i
|
||||
}
|
||||
|
||||
func NewWithDefaultKeyringCommand(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
// These can be overridden by the test.
|
||||
return i, configDir
|
||||
}
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
|
||||
@@ -8,7 +8,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"tasks"},
|
||||
Short: "Manage tasks",
|
||||
Short: "Experimental task commands.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
return i.Command.HelpHandler(i)
|
||||
},
|
||||
@@ -28,27 +28,27 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "create [input]",
|
||||
Short: "Create a task",
|
||||
Short: "Create an experimental task",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Create a task with direct input",
|
||||
Command: "coder task create \"Add authentication to the user service\"",
|
||||
Command: "coder exp task create \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with stdin input",
|
||||
Command: "echo \"Add authentication to the user service\" | coder task create",
|
||||
Command: "echo \"Add authentication to the user service\" | coder exp task create",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with a specific name",
|
||||
Command: "coder task create --name task1 \"Add authentication to the user service\"",
|
||||
Command: "coder exp task create --name task1 \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task from a specific template / preset",
|
||||
Command: "coder task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
|
||||
Command: "coder exp task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task for another user (requires appropriate permissions)",
|
||||
Command: "coder task create --owner user@example.com \"Add authentication to the user service\"",
|
||||
Command: "coder exp task create --owner user@example.com \"Add authentication to the user service\"",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
@@ -111,7 +111,8 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
ctx = inv.Context()
|
||||
expClient = codersdk.NewExperimentalClient(client)
|
||||
|
||||
taskInput string
|
||||
templateVersionID uuid.UUID
|
||||
@@ -207,7 +208,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
templateVersionPresetID = preset.ID
|
||||
}
|
||||
|
||||
task, err := client.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
|
||||
Name: taskName,
|
||||
TemplateVersionID: templateVersionID,
|
||||
TemplateVersionPresetID: templateVersionPresetID,
|
||||
@@ -69,7 +69,7 @@ func TestTaskCreate(t *testing.T) {
|
||||
ActiveVersionID: templateVersionID,
|
||||
},
|
||||
})
|
||||
case fmt.Sprintf("/api/v2/tasks/%s", username):
|
||||
case fmt.Sprintf("/api/experimental/tasks/%s", username):
|
||||
var req codersdk.CreateTaskRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
@@ -329,7 +329,7 @@ func TestTaskCreate(t *testing.T) {
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"task", "create"}
|
||||
args = []string{"exp", "task", "create"}
|
||||
sb strings.Builder
|
||||
err error
|
||||
)
|
||||
@@ -17,19 +17,19 @@ import (
|
||||
func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "delete <task> [<task> ...]",
|
||||
Short: "Delete tasks",
|
||||
Short: "Delete experimental tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Delete a single task.",
|
||||
Command: "$ coder task delete task1",
|
||||
Command: "$ coder exp task delete task1",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete multiple tasks.",
|
||||
Command: "$ coder task delete task1 task2 task3",
|
||||
Command: "$ coder exp task delete task1 task2 task3",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete a task without confirmation.",
|
||||
Command: "$ coder task delete task4 --yes",
|
||||
Command: "$ coder exp task delete task4 --yes",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
@@ -44,10 +44,11 @@ func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
var tasks []codersdk.Task
|
||||
for _, identifier := range inv.Args {
|
||||
task, err := client.TaskByIdentifier(ctx, identifier)
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
|
||||
for i, task := range tasks {
|
||||
display := displayList[i]
|
||||
if err := client.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
|
||||
if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil {
|
||||
return xerrors.Errorf("delete task %q: %w", display, err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(
|
||||
@@ -56,7 +56,7 @@ 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/tasks/me/exists":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/exists":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK,
|
||||
codersdk.Task{
|
||||
@@ -64,7 +64,7 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id1:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -82,13 +82,13 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
buildHandler: func(c *testCounters) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2:
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id2:
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id2),
|
||||
OwnerName: "me",
|
||||
Name: "uuid-task",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id2:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id2:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -104,24 +104,24 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
buildHandler: func(c *testCounters) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/first":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/first":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id3),
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id4:
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id4),
|
||||
OwnerName: "me",
|
||||
Name: "uuid-task-4",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id3:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id4:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -140,7 +140,7 @@ 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/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
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"`
|
||||
@@ -163,14 +163,14 @@ 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/tasks/me/bad":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/bad":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/bad":
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/bad":
|
||||
httpapi.InternalServerError(w, xerrors.New("boom"))
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
|
||||
@@ -193,7 +193,7 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
|
||||
client := codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
|
||||
args := append([]string{"task", "delete"}, tc.args...)
|
||||
args := append([]string{"exp", "task", "delete"}, tc.args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
inv = inv.WithContext(ctx)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -69,27 +69,27 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "list",
|
||||
Short: "List tasks",
|
||||
Short: "List experimental tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "List tasks for the current user.",
|
||||
Command: "coder task list",
|
||||
Command: "coder exp task list",
|
||||
},
|
||||
Example{
|
||||
Description: "List tasks for a specific user.",
|
||||
Command: "coder task list --user someone-else",
|
||||
Command: "coder exp task list --user someone-else",
|
||||
},
|
||||
Example{
|
||||
Description: "List all tasks you can view.",
|
||||
Command: "coder task list --all",
|
||||
Command: "coder exp task list --all",
|
||||
},
|
||||
Example{
|
||||
Description: "List all your running tasks.",
|
||||
Command: "coder task list --status running",
|
||||
Command: "coder exp task list --status running",
|
||||
},
|
||||
Example{
|
||||
Description: "As above, but only show IDs.",
|
||||
Command: "coder task list --status running --quiet",
|
||||
Command: "coder exp task list --status running --quiet",
|
||||
},
|
||||
),
|
||||
Aliases: []string{"ls"},
|
||||
@@ -135,13 +135,14 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
}
|
||||
|
||||
ctx := inv.Context()
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
targetUser := strings.TrimSpace(user)
|
||||
if targetUser == "" && !all {
|
||||
targetUser = codersdk.Me
|
||||
}
|
||||
|
||||
tasks, err := client.Tasks(ctx, &codersdk.TasksFilter{
|
||||
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
|
||||
Owner: targetUser,
|
||||
Status: codersdk.TaskStatus(statusFilter),
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
inv, root := clitest.New(t, "task", "list")
|
||||
inv, root := clitest.New(t, "exp", "task", "list")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -93,7 +93,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
wantPrompt := "build me a web app"
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
|
||||
|
||||
inv, root := clitest.New(t, "task", "list", "--column", "id,name,status,initial prompt")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -122,7 +122,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
pausedTask := 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, "task", "list", "--status=paused", "--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)
|
||||
@@ -153,7 +153,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
|
||||
|
||||
inv, root := clitest.New(t, "task", "list", "--user", "me")
|
||||
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.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
task2 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
|
||||
// Given: We add the `--quiet` flag
|
||||
inv, root := clitest.New(t, "task", "list", "--quiet")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--quiet")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -224,7 +224,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As the owner, list only member A tasks.
|
||||
inv, root := clitest.New(t, "task", "list", "--user", memberAUser.Username, "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json")
|
||||
//nolint:gocritic // Owner client is intended here to allow member tasks to be listed.
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
|
||||
|
||||
// As the owner, list all tasks to verify both member tasks are present.
|
||||
// Use JSON output to reliably validate filtering.
|
||||
inv, root := clitest.New(t, "task", "list", "--all", "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json")
|
||||
//nolint:gocritic // Owner client is intended here to allow all tasks to be listed.
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
@@ -28,7 +28,7 @@ func (r *RootCmd) taskLogs() *serpent.Command {
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Show logs for a given task.",
|
||||
Command: "coder task logs task1",
|
||||
Command: "coder exp task logs task1",
|
||||
}),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
@@ -41,15 +41,16 @@ func (r *RootCmd) taskLogs() *serpent.Command {
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
identifier = inv.Args[0]
|
||||
)
|
||||
|
||||
task, err := client.TaskByIdentifier(ctx, identifier)
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
|
||||
logs, err := client.TaskLogs(ctx, codersdk.Me, task.ID)
|
||||
logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get task logs: %w", err)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
userClient := client // user already has access to their own workspace
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -72,7 +72,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.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)
|
||||
|
||||
@@ -98,7 +98,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -121,7 +121,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", "doesnotexist")
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", "doesnotexist")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -139,7 +139,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "logs", uuid.Nil.String())
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", uuid.Nil.String())
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -155,7 +155,7 @@ func Test_TaskLogs(t *testing.T) {
|
||||
client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
userClient := client
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String())
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
@@ -17,10 +17,10 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
Short: "Send input to a task",
|
||||
Long: FormatExamples(Example{
|
||||
Description: "Send direct input to a task.",
|
||||
Command: "coder task send task1 \"Please also add unit tests\"",
|
||||
Command: "coder exp task send task1 \"Please also add unit tests\"",
|
||||
}, Example{
|
||||
Description: "Send input from stdin to a task.",
|
||||
Command: "echo \"Please also add unit tests\" | coder task send task1 --stdin",
|
||||
Command: "echo \"Please also add unit tests\" | coder exp task send task1 --stdin",
|
||||
}),
|
||||
Middleware: serpent.RequireRangeArgs(1, 2),
|
||||
Options: serpent.OptionSet{
|
||||
@@ -39,6 +39,7 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
identifier = inv.Args[0]
|
||||
|
||||
taskInput string
|
||||
@@ -59,12 +60,12 @@ func (r *RootCmd) taskSend() *serpent.Command {
|
||||
taskInput = inv.Args[1]
|
||||
}
|
||||
|
||||
task, err := client.TaskByIdentifier(ctx, identifier)
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task: %w", err)
|
||||
}
|
||||
|
||||
if err = client.TaskSend(ctx, codersdk.Me, task.ID, 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)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.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)
|
||||
|
||||
@@ -46,7 +46,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.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)
|
||||
|
||||
@@ -62,7 +62,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient := client
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.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)
|
||||
@@ -80,7 +80,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", "doesnotexist", "some task input")
|
||||
inv, root := clitest.New(t, "exp", "task", "send", "doesnotexist", "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -98,7 +98,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", uuid.Nil.String(), "some task input")
|
||||
inv, root := clitest.New(t, "exp", "task", "send", uuid.Nil.String(), "some task input")
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
@@ -114,7 +114,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.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)
|
||||
|
||||
@@ -47,11 +47,11 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Show the status of a given task.",
|
||||
Command: "coder task status task1",
|
||||
Command: "coder exp task status task1",
|
||||
},
|
||||
Example{
|
||||
Description: "Watch the status of a given task until it completes (idle or stopped).",
|
||||
Command: "coder task status task1 --watch",
|
||||
Command: "coder exp task status task1 --watch",
|
||||
},
|
||||
),
|
||||
Use: "status",
|
||||
@@ -83,9 +83,10 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
}
|
||||
|
||||
ctx := i.Context()
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
identifier := i.Args[0]
|
||||
|
||||
task, err := client.TaskByIdentifier(ctx, identifier)
|
||||
task, err := exp.TaskByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,7 +107,7 @@ func (r *RootCmd) taskStatus() *serpent.Command {
|
||||
// TODO: implement streaming updates instead of polling
|
||||
lastStatusRow := tsr
|
||||
for range t.C {
|
||||
task, err := client.TaskByID(ctx, task.ID)
|
||||
task, err := exp.TaskByID(ctx, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -36,7 +36,7 @@ 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/tasks/me/doesnotexist":
|
||||
case "/api/experimental/tasks/me/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
return
|
||||
default:
|
||||
@@ -52,7 +52,7 @@ 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/tasks/me/exists":
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
@@ -88,7 +88,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/tasks/me/exists":
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
@@ -103,7 +103,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
Status: codersdk.TaskStatusPending,
|
||||
})
|
||||
return
|
||||
case "/api/v2/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
defer calls.Add(1)
|
||||
switch calls.Load() {
|
||||
case 0:
|
||||
@@ -189,7 +189,6 @@ func Test_TaskStatus(t *testing.T) {
|
||||
"owner_id": "00000000-0000-0000-0000-000000000000",
|
||||
"owner_name": "me",
|
||||
"name": "exists",
|
||||
"display_name": "Task exists",
|
||||
"template_id": "00000000-0000-0000-0000-000000000000",
|
||||
"template_version_id": "00000000-0000-0000-0000-000000000000",
|
||||
"template_name": "",
|
||||
@@ -219,12 +218,11 @@ func Test_TaskStatus(t *testing.T) {
|
||||
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/tasks/me/exists":
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
DisplayName: "Task exists",
|
||||
OwnerName: "me",
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
@@ -256,7 +254,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now)))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
sb = strings.Builder{}
|
||||
args = []string{"task", "status", "--watch-interval", testutil.IntervalFast.String()}
|
||||
args = []string{"exp", "task", "status", "--watch-interval", testutil.IntervalFast.String()}
|
||||
)
|
||||
|
||||
t.Cleanup(srv.Close)
|
||||
@@ -60,14 +60,14 @@ func Test_Tasks(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "create task",
|
||||
cmdArgs: []string{"task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
|
||||
cmdArgs: []string{"exp", "task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
require.Contains(t, stdout, taskName, "task name should be in output")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list tasks after create",
|
||||
cmdArgs: []string{"task", "list", "--output", "json"},
|
||||
cmdArgs: []string{"exp", "task", "list", "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var tasks []codersdk.Task
|
||||
err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks)
|
||||
@@ -88,7 +88,7 @@ func Test_Tasks(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "get task status after create",
|
||||
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
|
||||
cmdArgs: []string{"exp", "task", "status", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var task codersdk.Task
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
|
||||
@@ -98,12 +98,12 @@ func Test_Tasks(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "send task message",
|
||||
cmdArgs: []string{"task", "send", taskName, "hello"},
|
||||
cmdArgs: []string{"exp", "task", "send", taskName, "hello"},
|
||||
// Assertions for this happen in the fake agent API handler.
|
||||
},
|
||||
{
|
||||
name: "read task logs",
|
||||
cmdArgs: []string{"task", "logs", taskName, "--output", "json"},
|
||||
cmdArgs: []string{"exp", "task", "logs", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var logs []codersdk.TaskLogEntry
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs")
|
||||
@@ -118,11 +118,12 @@ func Test_Tasks(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "delete task",
|
||||
cmdArgs: []string{"task", "delete", taskName, "--yes"},
|
||||
cmdArgs: []string{"exp", "task", "delete", taskName, "--yes"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
// The task should eventually no longer show up in the list of tasks
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
tasks, err := userClient.Tasks(ctx, &codersdk.TasksFilter{})
|
||||
expClient := codersdk.NewExperimentalClient(userClient)
|
||||
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{})
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
@@ -247,7 +248,8 @@ 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"
|
||||
task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(userClient)
|
||||
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: wantPrompt,
|
||||
Name: "test-task",
|
||||
+121
-192
@@ -2,85 +2,62 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
// keyringTestServiceName generates a unique service name for keyring tests
|
||||
// using the test name and a nanosecond timestamp to prevent collisions.
|
||||
func keyringTestServiceName(t *testing.T) string {
|
||||
t.Helper()
|
||||
var n uint32
|
||||
err := binary.Read(rand.Reader, binary.BigEndian, &n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// mockKeyring is a mock sessionstore.Backend implementation.
|
||||
type mockKeyring struct {
|
||||
credentials map[string]string // service name -> credential
|
||||
}
|
||||
|
||||
const mockServiceName = "mock-service-name"
|
||||
|
||||
func newMockKeyring() *mockKeyring {
|
||||
return &mockKeyring{credentials: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockKeyring) Read(_ *url.URL) (string, error) {
|
||||
cred, ok := m.credentials[mockServiceName]
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return fmt.Sprintf("%s_%v_%d", t.Name(), time.Now().UnixNano(), n)
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
type keyringTestEnv struct {
|
||||
serviceName string
|
||||
keyring sessionstore.Keyring
|
||||
inv *serpent.Invocation
|
||||
cfg config.Root
|
||||
clientURL *url.URL
|
||||
func (m *mockKeyring) Write(_ *url.URL, token string) error {
|
||||
m.credentials[mockServiceName] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupKeyringTestEnv(t *testing.T, clientURL string, args ...string) keyringTestEnv {
|
||||
t.Helper()
|
||||
|
||||
var root cli.RootCmd
|
||||
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
root.WithKeyringServiceName(serviceName)
|
||||
root.UseKeyringWithGlobalConfig()
|
||||
|
||||
inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...)
|
||||
|
||||
parsedURL, err := url.Parse(clientURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(parsedURL)
|
||||
})
|
||||
|
||||
return keyringTestEnv{serviceName, backend, inv, cfg, parsedURL}
|
||||
func (m *mockKeyring) Delete(_ *url.URL) error {
|
||||
_, ok := m.credentials[mockServiceName]
|
||||
if !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
delete(m.credentials, mockServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUseKeyring(t *testing.T) {
|
||||
// Verify that the --use-keyring flag default opts into using a keyring backend
|
||||
// for storing session tokens instead of plain text files.
|
||||
// Verify that the --use-keyring flag opts into using a keyring backend for
|
||||
// storing session tokens instead of plain text files.
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Login", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("keyring is not supported on this OS")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
@@ -88,16 +65,25 @@ func TestUseKeyring(t *testing.T) {
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Create CLI invocation which defaults to using the keyring
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// Create CLI invocation with --use-keyring flag
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--use-keyring",
|
||||
"--no-open",
|
||||
client.URL.String())
|
||||
inv := env.inv
|
||||
client.URL.String(),
|
||||
)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
// Inject the mock backend before running the command
|
||||
var root cli.RootCmd
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
root.WithSessionStorageBackend(mockBackend)
|
||||
inv.Command = cmd
|
||||
|
||||
// Run login in background
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
@@ -113,23 +99,19 @@ func TestUseKeyring(t *testing.T) {
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file was NOT created (using keyring instead)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring")
|
||||
|
||||
// Verify that the credential IS stored in OS keyring
|
||||
cred, err := env.keyring.Read(env.clientURL)
|
||||
require.NoError(t, err, "credential should be stored in OS keyring")
|
||||
// Verify that the credential IS stored in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "credential should be stored in mock keyring")
|
||||
require.Equal(t, client.SessionToken(), cred, "stored token should match login token")
|
||||
})
|
||||
|
||||
t.Run("Logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("keyring is not supported on this OS")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
@@ -137,17 +119,25 @@ func TestUseKeyring(t *testing.T) {
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First, login with the keyring (default)
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// First, login with --use-keyring
|
||||
loginInv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--use-keyring",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv := env.inv
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
// Inject the mock backend
|
||||
var loginRoot cli.RootCmd
|
||||
loginCmd, err := loginRoot.Command(loginRoot.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
loginRoot.WithSessionStorageBackend(mockBackend)
|
||||
loginInv.Command = loginCmd
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
@@ -160,23 +150,25 @@ func TestUseKeyring(t *testing.T) {
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify credential exists in OS keyring
|
||||
cred, err := env.keyring.Read(env.clientURL)
|
||||
// Verify credential exists in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "read credential should succeed before logout")
|
||||
require.NotEmpty(t, cred, "credential should exist before logout")
|
||||
require.NotEmpty(t, cred, "credential should exist after logout")
|
||||
|
||||
// Now logout using the same keyring service name
|
||||
// Now run logout with --use-keyring
|
||||
logoutInv, _ := clitest.New(t,
|
||||
"logout",
|
||||
"--use-keyring",
|
||||
"--yes",
|
||||
"--global-config", string(cfg),
|
||||
)
|
||||
|
||||
// Inject the same mock backend
|
||||
var logoutRoot cli.RootCmd
|
||||
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
|
||||
require.NoError(t, err)
|
||||
logoutRoot.WithKeyringServiceName(env.serviceName)
|
||||
logoutRoot.UseKeyringWithGlobalConfig()
|
||||
|
||||
logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd,
|
||||
"logout",
|
||||
"--yes",
|
||||
"--global-config", string(env.cfg),
|
||||
)
|
||||
logoutRoot.WithSessionStorageBackend(mockBackend)
|
||||
logoutInv.Command = logoutCmd
|
||||
|
||||
var logoutOut bytes.Buffer
|
||||
logoutInv.Stdout = &logoutOut
|
||||
@@ -184,18 +176,14 @@ func TestUseKeyring(t *testing.T) {
|
||||
err = logoutInv.Run()
|
||||
require.NoError(t, err, "logout should succeed")
|
||||
|
||||
// Verify the credential was deleted from OS keyring
|
||||
_, err = env.keyring.Read(env.clientURL)
|
||||
// Verify the credential was deleted from mock keyring
|
||||
_, err = mockBackend.Read(nil)
|
||||
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
|
||||
})
|
||||
|
||||
t.Run("DefaultFileStorage", func(t *testing.T) {
|
||||
t.Run("OmitFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("file storage is the default for Linux")
|
||||
}
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
@@ -203,13 +191,13 @@ func TestUseKeyring(t *testing.T) {
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// --use-keyring flag omitted (should use file-based storage)
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
@@ -226,9 +214,9 @@ func TestUseKeyring(t *testing.T) {
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when NOT using --use-keyring on Linux")
|
||||
require.NoError(t, err, "session file should exist when NOT using --use-keyring")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
@@ -246,18 +234,24 @@ func TestUseKeyring(t *testing.T) {
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Login using CODER_USE_KEYRING environment variable set to disable keyring usage,
|
||||
// which should have the same behavior on all platforms.
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// Login using CODER_USE_KEYRING environment variable instead of flag
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Environ.Set("CODER_USE_KEYRING", "false")
|
||||
inv.Environ.Set("CODER_USE_KEYRING", "true")
|
||||
|
||||
// Inject the mock backend
|
||||
var root cli.RootCmd
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
root.WithSessionStorageBackend(mockBackend)
|
||||
inv.Command = cmd
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
@@ -271,64 +265,21 @@ func TestUseKeyring(t *testing.T) {
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when CODER_USE_KEYRING set to false")
|
||||
// Verify that session file was NOT created (using keyring via env var)
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring via env var")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
|
||||
t.Run("DisableKeyringWithFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Login with --use-keyring=false to explicitly disable keyring usage, which
|
||||
// should have the same behavior on all platforms.
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
"login",
|
||||
"--use-keyring=false",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when --use-keyring=false is specified")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
// Verify credential is in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "credential should be stored in keyring when CODER_USE_KEYRING=true")
|
||||
require.NotEmpty(t, cred)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUseKeyringUnsupportedOS(t *testing.T) {
|
||||
// Verify that on unsupported operating systems, file-based storage is used
|
||||
// automatically even when --use-keyring is set to true (the default).
|
||||
// Verify that trying to use --use-keyring on an unsupported operating system produces
|
||||
// a helpful error message.
|
||||
t.Parallel()
|
||||
|
||||
// Only run this on an unsupported OS.
|
||||
@@ -336,60 +287,43 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
|
||||
t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS)
|
||||
}
|
||||
|
||||
t.Run("LoginWithDefaultKeyring", func(t *testing.T) {
|
||||
const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag"
|
||||
|
||||
t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// Try to login with --use-keyring on an unsupported OS
|
||||
inv, _ := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
"--use-keyring",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv := env.inv
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// The error should occur immediately, before any prompts
|
||||
loginErr := inv.Run()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (automatic fallback to file storage)
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist due to automatic fallback to file storage")
|
||||
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
// Verify we got an error about unsupported OS
|
||||
require.Error(t, loginErr)
|
||||
require.Contains(t, loginErr.Error(), expMessage)
|
||||
})
|
||||
|
||||
t.Run("LogoutWithDefaultKeyring", func(t *testing.T) {
|
||||
t.Run("LogoutWithUnsupportedKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First login to create a session (will use file storage due to automatic fallback)
|
||||
env := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// First login without keyring to create a session
|
||||
loginInv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv := env.inv
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
@@ -405,22 +339,17 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify session file exists
|
||||
sessionFile := path.Join(string(env.cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist before logout")
|
||||
|
||||
// Now logout - should succeed and delete the file
|
||||
logoutEnv := setupKeyringTestEnv(t, client.URL.String(),
|
||||
// Now try to logout with --use-keyring on an unsupported OS
|
||||
logoutInv, _ := clitest.New(t,
|
||||
"logout",
|
||||
"--use-keyring",
|
||||
"--yes",
|
||||
"--global-config", string(env.cfg),
|
||||
"--global-config", string(cfg),
|
||||
)
|
||||
|
||||
err = logoutEnv.inv.Run()
|
||||
require.NoError(t, err, "logout should succeed with automatic file storage fallback")
|
||||
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should be deleted after logout")
|
||||
err := logoutInv.Run()
|
||||
// Verify we got an error about unsupported OS
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), expMessage)
|
||||
})
|
||||
}
|
||||
|
||||
+3
-3
@@ -154,9 +154,9 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "login [<url>]",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Long: "By default, the session token is stored in the operating system keyring on " +
|
||||
"macOS and Windows and a plain text file on Linux. Use the --use-keyring flag " +
|
||||
"or CODER_USE_KEYRING environment variable to change the storage mechanism.",
|
||||
Long: "By default, the session token is stored in a plain text file. Use the " +
|
||||
"--use-keyring flag or set CODER_USE_KEYRING=true to store the token in " +
|
||||
"the operating system keyring instead.",
|
||||
Middleware: serpent.RequireRangeArgs(0, 1),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
+14
-39
@@ -56,7 +56,7 @@ var (
|
||||
// anything.
|
||||
ErrSilent = xerrors.New("silent error")
|
||||
|
||||
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage")
|
||||
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; remove the --use-keyring flag to use file-based storage")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -104,7 +104,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
|
||||
r.resetPassword(),
|
||||
r.sharing(),
|
||||
r.state(),
|
||||
r.tasksCommand(),
|
||||
r.templates(),
|
||||
r.tokens(),
|
||||
r.users(),
|
||||
@@ -150,6 +149,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.mcpCommand(),
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.tasksCommand(),
|
||||
r.boundary(),
|
||||
}
|
||||
}
|
||||
@@ -483,12 +483,10 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
Flag: varUseKeyring,
|
||||
Env: envUseKeyring,
|
||||
Description: "Store and retrieve session tokens using the operating system " +
|
||||
"keyring. This flag is ignored and file-based storage is used when " +
|
||||
"--global-config is set or keyring usage is not supported on the current " +
|
||||
"platform. Set to false to force file-based storage on supported platforms.",
|
||||
Default: "true",
|
||||
Value: serpent.BoolOf(&r.useKeyring),
|
||||
Group: globalGroup,
|
||||
"keyring. Currently only supported on Windows. By default, tokens are " +
|
||||
"stored in plain text files.",
|
||||
Value: serpent.BoolOf(&r.useKeyring),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: "debug-http",
|
||||
@@ -536,12 +534,10 @@ type RootCmd struct {
|
||||
disableDirect bool
|
||||
debugHTTP bool
|
||||
|
||||
disableNetworkTelemetry bool
|
||||
noVersionCheck bool
|
||||
noFeatureWarning bool
|
||||
useKeyring bool
|
||||
keyringServiceName string
|
||||
useKeyringWithGlobalConfig bool
|
||||
disableNetworkTelemetry bool
|
||||
noVersionCheck bool
|
||||
noFeatureWarning bool
|
||||
useKeyring bool
|
||||
}
|
||||
|
||||
// InitClient creates and configures a new client with authentication, telemetry,
|
||||
@@ -722,19 +718,8 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
|
||||
// flag.
|
||||
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
|
||||
if r.tokenBackend == nil {
|
||||
// Checking for the --global-config directory being set is a bit wonky but necessary
|
||||
// to allow extensions that invoke the CLI with this flag (e.g. VS code) to continue
|
||||
// working without modification. In the future we should modify these extensions to
|
||||
// either access the credential in the keyring (like Coder Desktop) or some other
|
||||
// approach that doesn't rely on the session token being stored on disk.
|
||||
assumeExtensionInUse := r.globalConfig != config.DefaultDir() && !r.useKeyringWithGlobalConfig
|
||||
keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
if r.useKeyring && !assumeExtensionInUse && keyringSupported {
|
||||
serviceName := sessionstore.DefaultServiceName
|
||||
if r.keyringServiceName != "" {
|
||||
serviceName = r.keyringServiceName
|
||||
}
|
||||
r.tokenBackend = sessionstore.NewKeyringWithService(serviceName)
|
||||
if r.useKeyring {
|
||||
r.tokenBackend = sessionstore.NewKeyring()
|
||||
} else {
|
||||
r.tokenBackend = sessionstore.NewFile(r.createConfig)
|
||||
}
|
||||
@@ -742,18 +727,8 @@ func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
|
||||
return r.tokenBackend
|
||||
}
|
||||
|
||||
// WithKeyringServiceName sets a custom keyring service name for testing purposes.
|
||||
// This allows tests to use isolated keyring storage while still exercising the
|
||||
// genuine storage backend selection logic in ensureTokenBackend().
|
||||
func (r *RootCmd) WithKeyringServiceName(serviceName string) {
|
||||
r.keyringServiceName = serviceName
|
||||
}
|
||||
|
||||
// UseKeyringWithGlobalConfig enables the use of the keyring storage backend
|
||||
// when the --global-config directory is set. This is only intended as an override
|
||||
// for tests, which require specifying the global config directory for test isolation.
|
||||
func (r *RootCmd) UseKeyringWithGlobalConfig() {
|
||||
r.useKeyringWithGlobalConfig = true
|
||||
func (r *RootCmd) WithSessionStorageBackend(backend sessionstore.Backend) {
|
||||
r.tokenBackend = backend
|
||||
}
|
||||
|
||||
type AgentAuth struct {
|
||||
|
||||
@@ -47,9 +47,9 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultServiceName is the service name used in keyrings for storing Coder CLI session
|
||||
// defaultServiceName is the service name used in keyrings for storing Coder CLI session
|
||||
// tokens.
|
||||
DefaultServiceName = "coder-v2-credentials"
|
||||
defaultServiceName = "coder-v2-credentials"
|
||||
)
|
||||
|
||||
// keyringProvider represents an operating system keyring. The expectation
|
||||
@@ -108,9 +108,17 @@ type Keyring struct {
|
||||
serviceName string
|
||||
}
|
||||
|
||||
// NewKeyring creates a Keyring with the default service name for production use.
|
||||
func NewKeyring() Keyring {
|
||||
return Keyring{
|
||||
provider: operatingSystemKeyring{},
|
||||
serviceName: defaultServiceName,
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyringWithService creates a Keyring Backend that stores credentials under the
|
||||
// specified service name. Generally, DefaultServiceName should be provided as the service
|
||||
// name except in tests which may need parameterization to avoid conflicting keyring use.
|
||||
// specified service name. This is primarily intended for testing to avoid conflicts
|
||||
// with production credentials and collisions between tests.
|
||||
func NewKeyringWithService(serviceName string) Keyring {
|
||||
return Keyring{
|
||||
provider: operatingSystemKeyring{},
|
||||
|
||||
Vendored
+3
-6
@@ -53,7 +53,6 @@ SUBCOMMANDS:
|
||||
stop Stop a workspace
|
||||
support Commands for troubleshooting issues with a Coder
|
||||
deployment.
|
||||
task Manage tasks
|
||||
templates Manage templates
|
||||
tokens Manage personal access tokens
|
||||
unfavorite Remove a workspace from your favorites
|
||||
@@ -109,12 +108,10 @@ variables or flags.
|
||||
--url url, $CODER_URL
|
||||
URL to a deployment.
|
||||
|
||||
--use-keyring bool, $CODER_USE_KEYRING (default: true)
|
||||
--use-keyring bool, $CODER_USE_KEYRING
|
||||
Store and retrieve session tokens using the operating system keyring.
|
||||
This flag is ignored and file-based storage is used when
|
||||
--global-config is set or keyring usage is not supported on the
|
||||
current platform. Set to false to force file-based storage on
|
||||
supported platforms.
|
||||
Currently only supported on Windows. By default, tokens are stored in
|
||||
plain text files.
|
||||
|
||||
-v, --verbose bool, $CODER_VERBOSE
|
||||
Enable verbose output.
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ USAGE:
|
||||
|
||||
Authenticate with Coder deployment
|
||||
|
||||
By default, the session token is stored in the operating system keyring on
|
||||
macOS and Windows and a plain text file on Linux. Use the --use-keyring flag
|
||||
or CODER_USE_KEYRING environment variable to change the storage mechanism.
|
||||
By default, the session token is stored in a plain text file. Use the
|
||||
--use-keyring flag or set CODER_USE_KEYRING=true to store the token in the
|
||||
operating system keyring instead.
|
||||
|
||||
OPTIONS:
|
||||
--first-user-email string, $CODER_FIRST_USER_EMAIL
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task
|
||||
|
||||
Manage tasks
|
||||
|
||||
Aliases: tasks
|
||||
|
||||
SUBCOMMANDS:
|
||||
create Create a task
|
||||
delete Delete tasks
|
||||
list List tasks
|
||||
logs Show a task's logs
|
||||
send Send input to a task
|
||||
status Show the status of a task.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task create [flags] [input]
|
||||
|
||||
Create a task
|
||||
|
||||
- Create a task with direct input:
|
||||
|
||||
$ coder task create "Add authentication to the user service"
|
||||
|
||||
- Create a task with stdin input:
|
||||
|
||||
$ echo "Add authentication to the user service" | coder task create
|
||||
|
||||
- Create a task with a specific name:
|
||||
|
||||
$ coder task create --name task1 "Add authentication to the user service"
|
||||
|
||||
- Create a task from a specific template / preset:
|
||||
|
||||
$ coder task create --template backend-dev --preset "My Preset" "Add
|
||||
authentication to the user service"
|
||||
|
||||
- Create a task for another user (requires appropriate permissions):
|
||||
|
||||
$ coder task create --owner user@example.com "Add authentication to the
|
||||
user service"
|
||||
|
||||
OPTIONS:
|
||||
-O, --org string, $CODER_ORGANIZATION
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
--name string
|
||||
Specify the name of the task. If you do not specify one, a name will
|
||||
be generated for you.
|
||||
|
||||
--owner string (default: me)
|
||||
Specify the owner of the task. Defaults to the current user.
|
||||
|
||||
--preset string, $CODER_TASK_PRESET_NAME (default: none)
|
||||
-q, --quiet bool
|
||||
Only display the created task's ID.
|
||||
|
||||
--stdin bool
|
||||
Reads from stdin for the task input.
|
||||
|
||||
--template string, $CODER_TASK_TEMPLATE_NAME
|
||||
--template-version string, $CODER_TASK_TEMPLATE_VERSION
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task delete [flags] <task> [<task> ...]
|
||||
|
||||
Delete tasks
|
||||
|
||||
Aliases: rm
|
||||
|
||||
- Delete a single task.:
|
||||
|
||||
$ $ coder task delete task1
|
||||
|
||||
- Delete multiple tasks.:
|
||||
|
||||
$ $ coder task delete task1 task2 task3
|
||||
|
||||
- Delete a task without confirmation.:
|
||||
|
||||
$ $ coder task delete task4 --yes
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task list [flags]
|
||||
|
||||
List tasks
|
||||
|
||||
Aliases: ls
|
||||
|
||||
- List tasks for the current user.:
|
||||
|
||||
$ coder task list
|
||||
|
||||
- List tasks for a specific user.:
|
||||
|
||||
$ coder task list --user someone-else
|
||||
|
||||
- List all tasks you can view.:
|
||||
|
||||
$ coder task list --all
|
||||
|
||||
- List all your running tasks.:
|
||||
|
||||
$ coder task list --status running
|
||||
|
||||
- As above, but only show IDs.:
|
||||
|
||||
$ coder task list --status running --quiet
|
||||
|
||||
OPTIONS:
|
||||
-a, --all bool (default: false)
|
||||
List tasks for all users you can view.
|
||||
|
||||
-c, --column [id|organization id|owner id|owner name|owner avatar url|name|display name|template id|template version id|template name|template display name|template icon|workspace id|workspace name|workspace status|workspace build number|workspace agent id|workspace agent lifecycle|workspace agent health|workspace app id|initial prompt|status|state|message|created at|updated at|state changed] (default: name,status,state,state changed,message)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
-q, --quiet bool (default: false)
|
||||
Only display task IDs.
|
||||
|
||||
--status pending|initializing|active|paused|error|unknown
|
||||
Filter by task status.
|
||||
|
||||
--user string
|
||||
List tasks for the specified user (username, "me").
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task logs [flags] <task>
|
||||
|
||||
Show a task's logs
|
||||
|
||||
- Show logs for a given task.:
|
||||
|
||||
$ coder task logs task1
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [id|content|type|time] (default: type,content)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task send [flags] <task> [<input> | --stdin]
|
||||
|
||||
Send input to a task
|
||||
|
||||
- Send direct input to a task.:
|
||||
|
||||
$ coder task send task1 "Please also add unit tests"
|
||||
|
||||
- Send input from stdin to a task.:
|
||||
|
||||
$ echo "Please also add unit tests" | coder task send task1 --stdin
|
||||
|
||||
OPTIONS:
|
||||
--stdin bool
|
||||
Reads the input from stdin.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task status [flags]
|
||||
|
||||
Show the status of a task.
|
||||
|
||||
Aliases: stat
|
||||
|
||||
- Show the status of a given task.:
|
||||
|
||||
$ coder task status task1
|
||||
|
||||
- Watch the status of a given task until it completes (idle or stopped).:
|
||||
|
||||
$ coder task status task1 --watch
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [id|organization id|owner id|owner name|owner avatar url|name|display name|template id|template version id|template name|template display name|template icon|workspace id|workspace name|workspace status|workspace build number|workspace agent id|workspace agent lifecycle|workspace agent health|workspace app id|initial prompt|status|state|message|created at|updated at|state changed|healthy] (default: state changed,status,healthy,state,message)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
--watch bool (default: false)
|
||||
Watch the task status output. This will stream updates to the terminal
|
||||
until the underlying workspace is stopped.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+5
-72
@@ -36,8 +36,6 @@ import (
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
const workspaceCacheRefreshInterval = 5 * time.Minute
|
||||
|
||||
// API implements the DRPC agent API interface from agent/proto. This struct is
|
||||
// instantiated once per agent connection and kept alive for the duration of the
|
||||
// session.
|
||||
@@ -56,8 +54,6 @@ type API struct {
|
||||
*SubAgentAPI
|
||||
*tailnet.DRPCService
|
||||
|
||||
cachedWorkspaceFields *CachedWorkspaceFields
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -96,7 +92,7 @@ type Options struct {
|
||||
UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
|
||||
}
|
||||
|
||||
func New(opts Options, workspace database.Workspace) *API {
|
||||
func New(opts Options) *API {
|
||||
if opts.Clock == nil {
|
||||
opts.Clock = quartz.NewReal()
|
||||
}
|
||||
@@ -118,13 +114,6 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
WorkspaceID: opts.WorkspaceID,
|
||||
}
|
||||
|
||||
// Don't cache details for prebuilds, though the cached fields will eventually be updated
|
||||
// by the refresh routine once the prebuild workspace is claimed.
|
||||
api.cachedWorkspaceFields = &CachedWorkspaceFields{}
|
||||
if !workspace.IsPrebuild() {
|
||||
api.cachedWorkspaceFields.UpdateValues(workspace)
|
||||
}
|
||||
|
||||
api.AnnouncementBannerAPI = &AnnouncementBannerAPI{
|
||||
appearanceFetcher: opts.AppearanceFetcher,
|
||||
}
|
||||
@@ -150,7 +139,6 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
|
||||
api.StatsAPI = &StatsAPI{
|
||||
AgentFn: api.agent,
|
||||
Workspace: api.cachedWorkspaceFields,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
StatsReporter: opts.StatsReporter,
|
||||
@@ -174,11 +162,10 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
}
|
||||
|
||||
api.MetadataAPI = &MetadataAPI{
|
||||
AgentFn: api.agent,
|
||||
Workspace: api.cachedWorkspaceFields,
|
||||
Database: opts.Database,
|
||||
Pubsub: opts.Pubsub,
|
||||
Log: opts.Log,
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
Pubsub: opts.Pubsub,
|
||||
Log: opts.Log,
|
||||
}
|
||||
|
||||
api.LogsAPI = &LogsAPI{
|
||||
@@ -218,10 +205,6 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
Database: opts.Database,
|
||||
}
|
||||
|
||||
// Start background cache refresh loop to handle workspace changes
|
||||
// like prebuild claims where owner_id and other fields may be modified in the DB.
|
||||
go api.startCacheRefreshLoop(opts.Ctx)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
@@ -271,56 +254,6 @@ func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// refreshCachedWorkspace periodically updates the cached workspace fields.
|
||||
// This ensures that changes like prebuild claims (which modify owner_id, name, etc.)
|
||||
// are eventually reflected in the cache without requiring agent reconnection.
|
||||
func (a *API) refreshCachedWorkspace(ctx context.Context) {
|
||||
ws, err := a.opts.Database.GetWorkspaceByID(ctx, a.opts.WorkspaceID)
|
||||
if err != nil {
|
||||
a.opts.Log.Warn(ctx, "failed to refresh cached workspace fields", slog.Error(err))
|
||||
a.cachedWorkspaceFields.Clear()
|
||||
return
|
||||
}
|
||||
|
||||
if ws.IsPrebuild() {
|
||||
return
|
||||
}
|
||||
|
||||
// If we still have the same values, skip the update and logging calls.
|
||||
if a.cachedWorkspaceFields.identity.Equal(database.WorkspaceIdentityFromWorkspace(ws)) {
|
||||
return
|
||||
}
|
||||
// Update fields that can change during workspace lifecycle (e.g., AutostartSchedule)
|
||||
a.cachedWorkspaceFields.UpdateValues(ws)
|
||||
|
||||
a.opts.Log.Debug(ctx, "refreshed cached workspace fields",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
slog.F("owner_id", ws.OwnerID),
|
||||
slog.F("name", ws.Name))
|
||||
}
|
||||
|
||||
// startCacheRefreshLoop runs a background goroutine that periodically refreshes
|
||||
// the cached workspace fields. This is primarily needed to handle prebuild claims
|
||||
// where the owner_id and other fields change while the agent connection persists.
|
||||
func (a *API) startCacheRefreshLoop(ctx context.Context) {
|
||||
// Refresh every 5 minutes. This provides a reasonable balance between:
|
||||
// - Keeping cache fresh for prebuild claims and other workspace updates
|
||||
// - Minimizing unnecessary database queries
|
||||
ticker := a.opts.Clock.TickerFunc(ctx, workspaceCacheRefreshInterval, func() error {
|
||||
a.refreshCachedWorkspace(ctx)
|
||||
return nil
|
||||
}, "cache_refresh")
|
||||
|
||||
// We need to wait on the ticker exiting.
|
||||
_ = ticker.Wait()
|
||||
|
||||
a.opts.Log.Debug(ctx, "cache refresh loop exited, invalidating the workspace cache on agent API",
|
||||
slog.F("workspace_id", a.cachedWorkspaceFields.identity.ID),
|
||||
slog.F("owner_id", a.cachedWorkspaceFields.identity.OwnerUsername),
|
||||
slog.F("name", a.cachedWorkspaceFields.identity.Name))
|
||||
a.cachedWorkspaceFields.Clear()
|
||||
}
|
||||
|
||||
func (a *API) publishWorkspaceUpdate(ctx context.Context, agent *database.WorkspaceAgent, kind wspubsub.WorkspaceEventKind) error {
|
||||
a.opts.PublishWorkspaceUpdateFn(ctx, a.opts.OwnerID, wspubsub.WorkspaceEvent{
|
||||
Kind: kind,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// CachedWorkspaceFields contains workspace data that is safe to cache for the
|
||||
// duration of an agent connection. These fields are used to reduce database calls
|
||||
// in high-frequency operations like stats reporting and metadata updates.
|
||||
// Prebuild workspaces should not be cached using this struct within the API struct,
|
||||
// however some of these fields for a workspace can be updated live so there is a
|
||||
// routine in the API for refreshing the workspace on a timed interval.
|
||||
//
|
||||
// IMPORTANT: ACL fields (GroupACL, UserACL) are NOT cached because they can be
|
||||
// modified in the database and we must use fresh data for authorization checks.
|
||||
type CachedWorkspaceFields struct {
|
||||
lock sync.RWMutex
|
||||
|
||||
identity database.WorkspaceIdentity
|
||||
}
|
||||
|
||||
func (cws *CachedWorkspaceFields) Clear() {
|
||||
cws.lock.Lock()
|
||||
defer cws.lock.Unlock()
|
||||
cws.identity = database.WorkspaceIdentity{}
|
||||
}
|
||||
|
||||
func (cws *CachedWorkspaceFields) UpdateValues(ws database.Workspace) {
|
||||
cws.lock.Lock()
|
||||
defer cws.lock.Unlock()
|
||||
cws.identity.ID = ws.ID
|
||||
cws.identity.OwnerID = ws.OwnerID
|
||||
cws.identity.OrganizationID = ws.OrganizationID
|
||||
cws.identity.TemplateID = ws.TemplateID
|
||||
cws.identity.Name = ws.Name
|
||||
cws.identity.OwnerUsername = ws.OwnerUsername
|
||||
cws.identity.TemplateName = ws.TemplateName
|
||||
cws.identity.AutostartSchedule = ws.AutostartSchedule
|
||||
}
|
||||
|
||||
// Returns the Workspace, true, unless the workspace has not been cached (nuked or was a prebuild).
|
||||
func (cws *CachedWorkspaceFields) AsWorkspaceIdentity() (database.WorkspaceIdentity, bool) {
|
||||
cws.lock.RLock()
|
||||
defer cws.lock.RUnlock()
|
||||
// Should we be more explicit about all fields being set to be valid?
|
||||
if cws.identity.Equal(database.WorkspaceIdentity{}) {
|
||||
return database.WorkspaceIdentity{}, false
|
||||
}
|
||||
return cws.identity, true
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package agentapi_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
func TestCacheClear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
user = database.User{
|
||||
ID: uuid.New(),
|
||||
Username: "bill",
|
||||
}
|
||||
template = database.Template{
|
||||
ID: uuid.New(),
|
||||
Name: "tpl",
|
||||
}
|
||||
workspace = database.Workspace{
|
||||
ID: uuid.New(),
|
||||
OwnerID: user.ID,
|
||||
OwnerUsername: user.Username,
|
||||
TemplateID: template.ID,
|
||||
Name: "xyz",
|
||||
TemplateName: template.Name,
|
||||
}
|
||||
workspaceAsCacheFields = agentapi.CachedWorkspaceFields{}
|
||||
)
|
||||
|
||||
workspaceAsCacheFields.UpdateValues(database.Workspace{
|
||||
ID: workspace.ID,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerUsername: workspace.OwnerUsername,
|
||||
TemplateID: workspace.TemplateID,
|
||||
Name: workspace.Name,
|
||||
TemplateName: workspace.TemplateName,
|
||||
AutostartSchedule: workspace.AutostartSchedule,
|
||||
},
|
||||
)
|
||||
|
||||
emptyCws := agentapi.CachedWorkspaceFields{}
|
||||
workspaceAsCacheFields.Clear()
|
||||
wsi, ok := workspaceAsCacheFields.AsWorkspaceIdentity()
|
||||
require.False(t, ok)
|
||||
ecwsi, ok := emptyCws.AsWorkspaceIdentity()
|
||||
require.False(t, ok)
|
||||
require.True(t, ecwsi.Equal(wsi))
|
||||
}
|
||||
|
||||
func TestCacheUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
user = database.User{
|
||||
ID: uuid.New(),
|
||||
Username: "bill",
|
||||
}
|
||||
template = database.Template{
|
||||
ID: uuid.New(),
|
||||
Name: "tpl",
|
||||
}
|
||||
workspace = database.Workspace{
|
||||
ID: uuid.New(),
|
||||
OwnerID: user.ID,
|
||||
OwnerUsername: user.Username,
|
||||
TemplateID: template.ID,
|
||||
Name: "xyz",
|
||||
TemplateName: template.Name,
|
||||
}
|
||||
workspaceAsCacheFields = agentapi.CachedWorkspaceFields{}
|
||||
)
|
||||
|
||||
workspaceAsCacheFields.UpdateValues(database.Workspace{
|
||||
ID: workspace.ID,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerUsername: workspace.OwnerUsername,
|
||||
TemplateID: workspace.TemplateID,
|
||||
Name: workspace.Name,
|
||||
TemplateName: workspace.TemplateName,
|
||||
AutostartSchedule: workspace.AutostartSchedule,
|
||||
},
|
||||
)
|
||||
|
||||
cws := agentapi.CachedWorkspaceFields{}
|
||||
cws.UpdateValues(workspace)
|
||||
wsi, ok := workspaceAsCacheFields.AsWorkspaceIdentity()
|
||||
require.True(t, ok)
|
||||
cwsi, ok := cws.AsWorkspaceIdentity()
|
||||
require.True(t, ok)
|
||||
require.True(t, wsi.Equal(cwsi))
|
||||
}
|
||||
@@ -12,17 +12,15 @@ import (
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
)
|
||||
|
||||
type MetadataAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Workspace *CachedWorkspaceFields
|
||||
Database database.Store
|
||||
Pubsub pubsub.Pubsub
|
||||
Log slog.Logger
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
Pubsub pubsub.Pubsub
|
||||
Log slog.Logger
|
||||
|
||||
TimeNowFn func() time.Time // defaults to dbtime.Now()
|
||||
}
|
||||
@@ -109,19 +107,7 @@ func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.B
|
||||
)
|
||||
}
|
||||
|
||||
// Inject RBAC object into context for dbauthz fast path, avoid having to
|
||||
// call GetWorkspaceByAgentID on every metadata update.
|
||||
rbacCtx := ctx
|
||||
if dbws, ok := a.Workspace.AsWorkspaceIdentity(); ok {
|
||||
rbacCtx, err = dbauthz.WithWorkspaceRBAC(ctx, dbws.RBACObject())
|
||||
if err != nil {
|
||||
// Don't error level log here, will exit the function. We want to fall back to GetWorkspaceByAgentID.
|
||||
//nolint:gocritic
|
||||
a.Log.Debug(ctx, "Cached workspace was present but RBAC object was invalid", slog.F("err", err))
|
||||
}
|
||||
}
|
||||
|
||||
err = a.Database.UpdateWorkspaceAgentMetadata(rbacCtx, dbUpdate)
|
||||
err = a.Database.UpdateWorkspaceAgentMetadata(ctx, dbUpdate)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace agent metadata in database: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package agentapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -17,14 +15,10 @@ import (
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type fakePublisher struct {
|
||||
@@ -90,10 +84,9 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
@@ -176,10 +169,9 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
@@ -246,10 +238,9 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
@@ -281,421 +272,4 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
Keys: []string{req.Metadata[0].Key, req.Metadata[1].Key, req.Metadata[2].Key},
|
||||
}, gotEvent)
|
||||
})
|
||||
|
||||
// Test RBAC fast path with valid RBAC object - should NOT call GetWorkspaceByAgentID
|
||||
// This test verifies that when a valid RBAC object is present in context, the dbauthz layer
|
||||
// uses the fast path and skips the GetWorkspaceByAgentID database call.
|
||||
t.Run("WorkspaceCached_SkipsDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
// Set up consistent IDs that represent a valid workspace->agent relationship
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
// In a real scenario, this agent would belong to a resource in the workspace above
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// DO NOT expect GetWorkspaceByAgentID - the fast path should skip this call
|
||||
// If GetWorkspaceByAgentID is called, the test will fail with "unexpected call"
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
api.Workspace.UpdateValues(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
})
|
||||
|
||||
// Create context with system actor so authorization passes
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
// Test RBAC slow path - invalid RBAC object should fall back to GetWorkspaceByAgentID
|
||||
// This test verifies that when the RBAC object has invalid IDs (nil UUIDs), the dbauthz layer
|
||||
// falls back to the slow path and calls GetWorkspaceByAgentID.
|
||||
t.Run("InvalidWorkspaceCached_RequiresDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByAgentID to be called because the RBAC fast path validation fails
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
}, nil)
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
// Create an invalid RBAC object with nil UUIDs for owner/org
|
||||
// This will fail dbauthz fast path validation and trigger GetWorkspaceByAgentID
|
||||
api.Workspace.UpdateValues(database.Workspace{
|
||||
ID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
OwnerID: uuid.Nil, // Invalid: fails dbauthz fast path validation
|
||||
OrganizationID: uuid.Nil, // Invalid: fails dbauthz fast path validation
|
||||
})
|
||||
|
||||
// Create context with system actor so authorization passes
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
// Test RBAC slow path - no RBAC object in context
|
||||
// This test verifies that when no RBAC object is present in context, the dbauthz layer
|
||||
// falls back to the slow path and calls GetWorkspaceByAgentID.
|
||||
t.Run("WorkspaceNotCached_RequiresDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByAgentID to be called because no RBAC object is in context
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
}, nil)
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
// Create context with system actor so authorization passes
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
|
||||
// Test cache refresh - AutostartSchedule updated
|
||||
// This test verifies that the cache refresh mechanism actually calls GetWorkspaceByID
|
||||
// and updates the cached workspace fields when the workspace is modified (e.g., autostart schedule changes).
|
||||
t.Run("CacheRefreshed_AutostartScheduleUpdated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
mClock = quartz.NewMock(t)
|
||||
tickerTrap = mClock.Trap().TickerFunc("cache_refresh")
|
||||
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
|
||||
agentID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
// Initial workspace - has Monday-Friday 9am autostart
|
||||
initialWorkspace := database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
TemplateID: templateID,
|
||||
Name: "my-workspace",
|
||||
OwnerUsername: "testuser",
|
||||
TemplateName: "test-template",
|
||||
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 9 * * 1-5"},
|
||||
}
|
||||
|
||||
// Updated workspace - user changed autostart to 5pm and renamed workspace
|
||||
updatedWorkspace := database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
TemplateID: templateID,
|
||||
Name: "my-workspace-renamed", // Changed!
|
||||
OwnerUsername: "testuser",
|
||||
TemplateName: "test-template",
|
||||
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 17 * * 1-5"}, // Changed!
|
||||
DormantAt: sql.NullTime{},
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByID to be called during cache refresh
|
||||
// This is the key assertion - proves the refresh mechanism is working
|
||||
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(updatedWorkspace, nil)
|
||||
|
||||
// API needs to fetch the agent when calling metadata update
|
||||
dbM.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(agent, nil)
|
||||
|
||||
// After refresh, metadata update should work with updated cache
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(ctx context.Context, params database.UpdateWorkspaceAgentMetadataParams) error {
|
||||
require.Equal(t, agent.ID, params.WorkspaceAgentID)
|
||||
require.Equal(t, []string{"test_key"}, params.Key)
|
||||
require.Equal(t, []string{"test_value"}, params.Value)
|
||||
require.Equal(t, []string{""}, params.Error)
|
||||
require.Len(t, params.CollectedAt, 1)
|
||||
return nil
|
||||
},
|
||||
).AnyTimes()
|
||||
|
||||
// May call GetWorkspaceByAgentID if slow path is used before refresh
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(updatedWorkspace, nil).AnyTimes()
|
||||
|
||||
// dbauthz will call Wrappers()
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Create roles with workspace permissions
|
||||
userRoles := rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleMember(),
|
||||
User: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{
|
||||
orgID.String(): {
|
||||
Member: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: uuid.New(),
|
||||
})
|
||||
|
||||
ctxWithActor := dbauthz.As(ctx, rbac.Subject{
|
||||
Type: rbac.SubjectTypeUser,
|
||||
FriendlyName: "testuser",
|
||||
Email: "testuser@example.com",
|
||||
ID: ownerID.String(),
|
||||
Roles: userRoles,
|
||||
Groups: []string{orgID.String()},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue())
|
||||
|
||||
// Create full API with cached workspace fields (initial state)
|
||||
api := agentapi.New(agentapi.Options{
|
||||
Ctx: ctxWithActor,
|
||||
AgentID: agentID,
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Log: testutil.Logger(t),
|
||||
Clock: mClock,
|
||||
Pubsub: pub,
|
||||
}, initialWorkspace) // Cache is initialized with 9am schedule and "my-workspace" name
|
||||
|
||||
// Wait for ticker to be set up and release it so it can fire
|
||||
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickerTrap.Close()
|
||||
|
||||
// Advance clock to trigger cache refresh and wait for it to complete
|
||||
_, aw := mClock.AdvanceNext()
|
||||
aw.MustWait(ctx)
|
||||
|
||||
// At this point, GetWorkspaceByID should have been called and cache updated
|
||||
// The cache now has the 5pm schedule and "my-workspace-renamed" name
|
||||
|
||||
// Now call metadata update to verify the refreshed cache works
|
||||
resp, err := api.MetadataAPI.BatchUpdateMetadata(ctxWithActor, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
type StatsAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Workspace *CachedWorkspaceFields
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
StatsReporter *workspacestats.Reporter
|
||||
@@ -47,21 +46,14 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If cache is empty (prebuild or invalid), fall back to DB
|
||||
var ws database.WorkspaceIdentity
|
||||
var ok bool
|
||||
if ws, ok = a.Workspace.AsWorkspaceIdentity(); !ok {
|
||||
w, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace by agent ID %q: %w", workspaceAgent.ID, err)
|
||||
}
|
||||
ws = database.WorkspaceIdentityFromWorkspace(w)
|
||||
getWorkspaceAgentByIDRow, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace by agent ID %q: %w", workspaceAgent.ID, err)
|
||||
}
|
||||
|
||||
workspace := getWorkspaceAgentByIDRow
|
||||
a.Log.Debug(ctx, "read stats report",
|
||||
slog.F("interval", a.AgentStatsRefreshInterval),
|
||||
slog.F("workspace_id", ws.ID),
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.F("payload", req),
|
||||
)
|
||||
|
||||
@@ -78,8 +70,9 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
|
||||
err = a.StatsReporter.ReportAgentStats(
|
||||
ctx,
|
||||
a.now(),
|
||||
ws,
|
||||
workspace,
|
||||
workspaceAgent,
|
||||
getWorkspaceAgentByIDRow.TemplateName,
|
||||
req.Stats,
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -52,19 +52,8 @@ func TestUpdateStates(t *testing.T) {
|
||||
ID: uuid.New(),
|
||||
Name: "abc",
|
||||
}
|
||||
workspaceAsCacheFields = agentapi.CachedWorkspaceFields{}
|
||||
)
|
||||
|
||||
workspaceAsCacheFields.UpdateValues(database.Workspace{
|
||||
ID: workspace.ID,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerUsername: workspace.OwnerUsername,
|
||||
TemplateID: workspace.TemplateID,
|
||||
Name: workspace.Name,
|
||||
TemplateName: workspace.TemplateName,
|
||||
AutostartSchedule: workspace.AutostartSchedule,
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -122,8 +111,7 @@ func TestUpdateStates(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &workspaceAsCacheFields,
|
||||
Database: dbM,
|
||||
Database: dbM,
|
||||
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
@@ -148,6 +136,9 @@ func TestUpdateStates(t *testing.T) {
|
||||
}
|
||||
defer wut.Close()
|
||||
|
||||
// Workspace gets fetched.
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
|
||||
// We expect an activity bump because ConnectionCount > 0.
|
||||
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
@@ -232,8 +223,7 @@ func TestUpdateStates(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &workspaceAsCacheFields,
|
||||
Database: dbM,
|
||||
Database: dbM,
|
||||
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
@@ -249,6 +239,9 @@ func TestUpdateStates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Workspace gets fetched.
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
|
||||
_, err := api.UpdateStats(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -267,8 +260,7 @@ func TestUpdateStates(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &workspaceAsCacheFields,
|
||||
Database: dbM,
|
||||
Database: dbM,
|
||||
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
@@ -341,17 +333,11 @@ func TestUpdateStates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
)
|
||||
// need to overwrite the cached fields for this test, but the struct has a lock
|
||||
ws := agentapi.CachedWorkspaceFields{}
|
||||
ws.UpdateValues(workspace)
|
||||
// ws.AutostartSchedule = workspace.AutostartSchedule
|
||||
|
||||
api := agentapi.StatsAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &ws,
|
||||
Database: dbM,
|
||||
Database: dbM,
|
||||
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
@@ -376,6 +362,9 @@ func TestUpdateStates(t *testing.T) {
|
||||
}
|
||||
defer wut.Close()
|
||||
|
||||
// Workspace gets fetched.
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
|
||||
// We expect an activity bump because ConnectionCount > 0. However, the
|
||||
// next autostart time will be set on the bump.
|
||||
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
|
||||
@@ -462,8 +451,7 @@ func TestUpdateStates(t *testing.T) {
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &workspaceAsCacheFields,
|
||||
Database: dbM,
|
||||
Database: dbM,
|
||||
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
|
||||
Database: dbM,
|
||||
Pubsub: ps,
|
||||
@@ -490,6 +478,9 @@ func TestUpdateStates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Workspace gets fetched.
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil)
|
||||
|
||||
// We expect an activity bump because ConnectionCount > 0.
|
||||
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
|
||||
+71
-134
@@ -7,15 +7,13 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/taskname"
|
||||
"cdr.dev/slog"
|
||||
|
||||
aiagentapi "github.com/coder/agentapi-sdk-go"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
@@ -25,21 +23,26 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/coderd/taskname"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
|
||||
aiagentapi "github.com/coder/agentapi-sdk-go"
|
||||
)
|
||||
|
||||
// @Summary Create a new AI task
|
||||
// @ID create-a-new-ai-task
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID create-task
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param request body codersdk.CreateTaskRequest true "Create task request"
|
||||
// @Success 201 {object} codersdk.Task
|
||||
// @Router /tasks/{user} [post]
|
||||
// @Router /api/experimental/tasks/{user} [post]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// This endpoint creates a new task for the given user.
|
||||
func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
@@ -107,25 +110,18 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
taskDisplayName := strings.TrimSpace(req.DisplayName)
|
||||
if taskDisplayName != "" {
|
||||
if len(taskDisplayName) > 64 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Display name must be 64 characters or less.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if taskName == "" {
|
||||
taskName = taskname.GenerateFallback()
|
||||
|
||||
// Generate task name and display name if either is not provided
|
||||
if taskName == "" || taskDisplayName == "" {
|
||||
generatedTaskName := taskname.Generate(ctx, api.Logger, req.Input)
|
||||
if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
|
||||
anthropicModel := taskname.GetAnthropicModelFromEnv()
|
||||
|
||||
if taskName == "" {
|
||||
taskName = generatedTaskName.Name
|
||||
}
|
||||
if taskDisplayName == "" {
|
||||
taskDisplayName = generatedTaskName.DisplayName
|
||||
generatedName, err := taskname.Generate(ctx, req.Input, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel))
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "unable to generate task name", slog.Error(err))
|
||||
} else {
|
||||
taskName = generatedName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +214,6 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
OrganizationID: templateVersion.OrganizationID,
|
||||
OwnerID: owner.ID,
|
||||
Name: taskName,
|
||||
DisplayName: taskDisplayName,
|
||||
WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation.
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
TemplateParameters: []byte("{}"),
|
||||
@@ -308,7 +303,6 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
|
||||
OwnerName: dbTask.OwnerUsername,
|
||||
OwnerAvatarURL: dbTask.OwnerAvatarUrl,
|
||||
Name: dbTask.Name,
|
||||
DisplayName: dbTask.DisplayName,
|
||||
TemplateID: ws.TemplateID,
|
||||
TemplateVersionID: dbTask.TemplateVersionID,
|
||||
TemplateName: ws.TemplateName,
|
||||
@@ -398,13 +392,16 @@ func deriveTaskCurrentState(
|
||||
}
|
||||
|
||||
// @Summary List AI tasks
|
||||
// @ID list-ai-tasks
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID list-tasks
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param q query string false "Search query for filtering tasks. Supports: owner:<username/uuid/me>, organization:<org-name/uuid>, status:<status>"
|
||||
// @Success 200 {object} codersdk.TasksListResponse
|
||||
// @Router /tasks [get]
|
||||
// @Router /api/experimental/tasks [get]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// 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)
|
||||
@@ -497,15 +494,20 @@ func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// @Summary Get AI task by ID or name
|
||||
// @ID get-ai-task-by-id-or-name
|
||||
// @Summary Get AI task by ID
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID get-task
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID, or task name"
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.Task
|
||||
// @Router /tasks/{user}/{task} [get]
|
||||
// @Router /api/experimental/tasks/{user}/{task} [get]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// taskGet is an experimental endpoint to fetch a single AI task by ID
|
||||
// (workspace ID). It returns a synthesized task response including
|
||||
// prompt and status.
|
||||
func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
@@ -570,14 +572,20 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, taskResp)
|
||||
}
|
||||
|
||||
// @Summary Delete AI task
|
||||
// @ID delete-ai-task
|
||||
// @Summary Delete AI task by ID
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID delete-task
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID, or task name"
|
||||
// @Success 202
|
||||
// @Router /tasks/{user}/{task} [delete]
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Success 202 "Task deletion initiated"
|
||||
// @Router /api/experimental/tasks/{user}/{task} [delete]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// taskDelete is an experimental endpoint to delete a task by ID.
|
||||
// It creates a delete workspace build and returns 202 Accepted if the build was
|
||||
// created.
|
||||
func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
@@ -638,96 +646,21 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// @Summary Update AI task input
|
||||
// @ID update-ai-task-input
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Tasks
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID, or task name"
|
||||
// @Param request body codersdk.UpdateTaskInputRequest true "Update task input request"
|
||||
// @Success 204
|
||||
// @Router /tasks/{user}/{task}/input [patch]
|
||||
func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
task = httpmw.TaskParam(r)
|
||||
auditor = api.Auditor.Load()
|
||||
taskResourceInfo = audit.AdditionalFields{}
|
||||
)
|
||||
|
||||
aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
AdditionalFields: taskResourceInfo,
|
||||
})
|
||||
defer commitAudit()
|
||||
aReq.Old = task.TaskTable()
|
||||
aReq.UpdateOrganizationID(task.OrganizationID)
|
||||
|
||||
var req codersdk.UpdateTaskInputRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Input) == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Task input is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var updatedTask database.TaskTable
|
||||
if err := api.Database.InTx(func(tx database.Store) error {
|
||||
task, err := tx.GetTaskByID(ctx, task.ID)
|
||||
if err != nil {
|
||||
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to fetch task.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if task.Status != database.TaskStatusPaused {
|
||||
return httperror.NewResponseError(http.StatusConflict, codersdk.Response{
|
||||
Message: "Unable to update task input, task must be paused.",
|
||||
Detail: "Please stop the task's workspace before updating the input.",
|
||||
})
|
||||
}
|
||||
|
||||
updatedTask, err = tx.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{
|
||||
ID: task.ID,
|
||||
Prompt: req.Input,
|
||||
})
|
||||
if err != nil {
|
||||
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update task input.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil); err != nil {
|
||||
httperror.WriteResponseError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
aReq.New = updatedTask
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// @Summary Send input to AI task
|
||||
// @ID send-input-to-ai-task
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID send-task-input
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID, or task name"
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Param request body codersdk.TaskSendRequest true "Task input request"
|
||||
// @Success 204
|
||||
// @Router /tasks/{user}/{task}/send [post]
|
||||
// @Success 204 "Input sent successfully"
|
||||
// @Router /api/experimental/tasks/{user}/{task}/send [post]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// taskSend submits task input to the task app by dialing the agent
|
||||
// directly over the tailnet. We enforce ApplicationConnect RBAC on the
|
||||
// workspace and validate the task app health.
|
||||
func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
task := httpmw.TaskParam(r)
|
||||
@@ -788,14 +721,18 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// @Summary Get AI task logs
|
||||
// @ID get-ai-task-logs
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID get-task-logs
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Tasks
|
||||
// @Tags Experimental
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID, or task name"
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.TaskLogsResponse
|
||||
// @Router /tasks/{user}/{task}/logs [get]
|
||||
// @Router /api/experimental/tasks/{user}/{task}/logs [get]
|
||||
//
|
||||
// EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable.
|
||||
// taskLogs reads task output by dialing the agent directly over the tailnet.
|
||||
// We enforce ApplicationConnect RBAC on the workspace and validate the task app health.
|
||||
func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
task := httpmw.TaskParam(r)
|
||||
|
||||
+84
-296
@@ -23,7 +23,6 @@ import (
|
||||
"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/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
@@ -124,7 +123,8 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
// Create a task with a specific prompt using the new data model.
|
||||
wantPrompt := "build me a web app"
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: wantPrompt,
|
||||
})
|
||||
@@ -140,7 +140,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// List tasks via experimental API and verify the prompt and status mapping.
|
||||
tasks, err := client.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me})
|
||||
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := slice.Find(tasks, func(t codersdk.Task) bool { return t.ID == task.ID })
|
||||
@@ -163,9 +163,10 @@ func TestTasks(t *testing.T) {
|
||||
anotherUser, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
template = createAITemplate(t, client, user)
|
||||
wantPrompt = "review my code"
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: wantPrompt,
|
||||
})
|
||||
@@ -199,7 +200,7 @@ func TestTasks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
updated, err := client.TaskByID(ctx, task.ID)
|
||||
updated, err := exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, task.ID, updated.ID, "task ID should match")
|
||||
@@ -213,18 +214,19 @@ func TestTasks(t *testing.T) {
|
||||
assert.NotEmpty(t, updated.WorkspaceStatus, "task status should not be empty")
|
||||
|
||||
// Fetch the task by name and verify the same result
|
||||
byName, err := client.TaskByOwnerAndName(ctx, codersdk.Me, task.Name)
|
||||
byName, err := exp.TaskByOwnerAndName(ctx, codersdk.Me, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byName, updated)
|
||||
|
||||
// Another member user should not be able to fetch the task
|
||||
_, err = anotherUser.TaskByID(ctx, task.ID)
|
||||
otherClient := codersdk.NewExperimentalClient(anotherUser)
|
||||
_, err = otherClient.TaskByID(ctx, task.ID)
|
||||
require.Error(t, err, "fetching task should fail by ID for another member user")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
// Also test by name
|
||||
_, err = anotherUser.TaskByOwnerAndName(ctx, task.OwnerName, task.Name)
|
||||
_, err = otherClient.TaskByOwnerAndName(ctx, task.OwnerName, task.Name)
|
||||
require.Error(t, err, "fetching task should fail by name for another member user")
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
@@ -233,7 +235,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
|
||||
|
||||
// Verify that the previous status still remains
|
||||
updated, err = client.TaskByID(ctx, task.ID)
|
||||
updated, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, updated.CurrentState, "current state should not be nil")
|
||||
assert.Equal(t, "all done", updated.CurrentState.Message)
|
||||
@@ -245,7 +247,7 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
// Verify that the status from the previous build has been cleared
|
||||
// and replaced by the agent initialization status.
|
||||
updated, err = client.TaskByID(ctx, task.ID)
|
||||
updated, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, previousCurrentState, updated.CurrentState)
|
||||
assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State)
|
||||
@@ -264,7 +266,8 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "delete me",
|
||||
})
|
||||
@@ -277,7 +280,7 @@ func TestTasks(t *testing.T) {
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
err = client.DeleteTask(ctx, "me", task.ID)
|
||||
err = exp.DeleteTask(ctx, "me", task.ID)
|
||||
require.NoError(t, err, "delete task request should be accepted")
|
||||
|
||||
// Poll until the workspace is deleted.
|
||||
@@ -299,7 +302,8 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := client.DeleteTask(ctx, "me", uuid.New())
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
err := exp.DeleteTask(ctx, "me", uuid.New())
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
require.Error(t, err, "expected an error for non-existent task")
|
||||
@@ -325,7 +329,8 @@ func TestTasks(t *testing.T) {
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
err := client.DeleteTask(ctx, "me", ws.ID)
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
err := exp.DeleteTask(ctx, "me", ws.ID)
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
require.Error(t, err, "expected an error for non-task workspace delete via tasks endpoint")
|
||||
@@ -344,7 +349,8 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "delete me not",
|
||||
})
|
||||
@@ -356,9 +362,10 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
// Another regular org member without elevated permissions.
|
||||
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
expOther := codersdk.NewExperimentalClient(otherClient)
|
||||
|
||||
// Attempt to delete the owner's task as a non-owner without permissions.
|
||||
err = otherClient.DeleteTask(ctx, "me", task.ID)
|
||||
err = expOther.DeleteTask(ctx, "me", task.ID)
|
||||
|
||||
var authErr *codersdk.Error
|
||||
require.Error(t, err, "expected an authorization error when deleting another user's task")
|
||||
@@ -376,7 +383,8 @@ func TestTasks(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
template := createAITemplate(t, client, user)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "delete me",
|
||||
})
|
||||
@@ -395,9 +403,9 @@ func TestTasks(t *testing.T) {
|
||||
// Provisionerdserver will attempt delete the related task when deleting a workspace.
|
||||
// This test ensures that we can still handle the case where, for some reason, the
|
||||
// task has not been marked as deleted, but the workspace has.
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err, "fetching a task should still work if its related workspace is deleted")
|
||||
err = client.DeleteTask(ctx, task.OwnerID.String(), task.ID)
|
||||
err = exp.DeleteTask(ctx, task.OwnerID.String(), task.ID)
|
||||
require.NoError(t, err, "should be possible to delete a task with no workspace")
|
||||
})
|
||||
|
||||
@@ -410,7 +418,8 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "delete me",
|
||||
})
|
||||
@@ -426,7 +435,7 @@ func TestTasks(t *testing.T) {
|
||||
// When; the task workspace is deleted
|
||||
coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete)
|
||||
// Then: the task associated with the workspace is also deleted
|
||||
_, err = client.TaskByID(ctx, task.ID)
|
||||
_, err = exp.TaskByID(ctx, task.ID)
|
||||
require.Error(t, err, "expected an error fetching the task")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr, "expected a codersdk.Error")
|
||||
@@ -485,9 +494,10 @@ func TestTasks(t *testing.T) {
|
||||
userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
agentAuthToken = uuid.NewString()
|
||||
template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL))
|
||||
exp = codersdk.NewExperimentalClient(userClient)
|
||||
)
|
||||
|
||||
task, err := userClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "send me food",
|
||||
})
|
||||
@@ -500,7 +510,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, task.WorkspaceBuildNumber)
|
||||
require.True(t, task.WorkspaceAgentID.Valid)
|
||||
@@ -526,7 +536,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, userClient, ws.ID).WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make the sidebar app unhealthy initially.
|
||||
@@ -536,7 +546,7 @@ func TestTasks(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello, Agent!",
|
||||
})
|
||||
require.Error(t, err, "wanted error due to unhealthy sidebar app")
|
||||
@@ -550,7 +560,7 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
statusResponse = agentapisdk.AgentStatus("bad")
|
||||
|
||||
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello, Agent!",
|
||||
})
|
||||
require.Error(t, err, "wanted error due to bad status")
|
||||
@@ -559,7 +569,7 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
//nolint:tparallel // Not intended to run in parallel.
|
||||
t.Run("SendOK", func(t *testing.T) {
|
||||
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "Hello, Agent!",
|
||||
})
|
||||
require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status")
|
||||
@@ -567,7 +577,7 @@ func TestTasks(t *testing.T) {
|
||||
|
||||
//nolint:tparallel // Not intended to run in parallel.
|
||||
t.Run("MissingContent", func(t *testing.T) {
|
||||
err = client.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{
|
||||
Input: "",
|
||||
})
|
||||
require.Error(t, err, "wanted error due to missing content")
|
||||
@@ -585,7 +595,8 @@ func TestTasks(t *testing.T) {
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
err := client.TaskSend(ctx, "me", uuid.New(), codersdk.TaskSendRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
err := exp.TaskSend(ctx, "me", uuid.New(), codersdk.TaskSendRequest{
|
||||
Input: "hi",
|
||||
})
|
||||
|
||||
@@ -651,9 +662,10 @@ func TestTasks(t *testing.T) {
|
||||
owner = coderdtest.CreateFirstUser(t, client)
|
||||
agentAuthToken = uuid.NewString()
|
||||
template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL))
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "show logs",
|
||||
})
|
||||
@@ -666,7 +678,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
task, err = client.TaskByIdentifier(ctx, task.ID.String())
|
||||
task, err = exp.TaskByIdentifier(ctx, task.ID.String())
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, task.WorkspaceBuildNumber)
|
||||
require.True(t, task.WorkspaceAgentID.Valid)
|
||||
@@ -692,13 +704,13 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:tparallel // Not intended to run in parallel.
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
// Fetch logs.
|
||||
resp, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
resp, err := exp.TaskLogs(ctx, "me", task.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Logs, 3)
|
||||
assert.Equal(t, 0, resp.Logs[0].ID)
|
||||
@@ -718,7 +730,7 @@ func TestTasks(t *testing.T) {
|
||||
t.Run("UpstreamError", func(t *testing.T) {
|
||||
shouldReturnError = true
|
||||
t.Cleanup(func() { shouldReturnError = false })
|
||||
_, err := client.TaskLogs(ctx, "me", task.ID)
|
||||
_, err := exp.TaskLogs(ctx, "me", task.ID)
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
require.Error(t, err)
|
||||
@@ -726,205 +738,6 @@ func TestTasks(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadGateway, sdkErr.StatusCode())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("UpdateInput", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disableProvisioner bool
|
||||
transition database.WorkspaceTransition
|
||||
cancelTransition bool
|
||||
deleteTask bool
|
||||
taskInput string
|
||||
wantStatus codersdk.TaskStatus
|
||||
wantErr string
|
||||
wantErrStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "TaskStatusInitializing",
|
||||
// We want to disable the provisioner so that the task
|
||||
// never gets provisioned (ensuring it stays in Initializing).
|
||||
disableProvisioner: true,
|
||||
taskInput: "Valid prompt",
|
||||
wantStatus: codersdk.TaskStatusInitializing,
|
||||
wantErr: "Unable to update",
|
||||
wantErrStatusCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "TaskStatusPaused",
|
||||
transition: database.WorkspaceTransitionStop,
|
||||
taskInput: "Valid prompt",
|
||||
wantStatus: codersdk.TaskStatusPaused,
|
||||
},
|
||||
{
|
||||
name: "TaskStatusError",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
cancelTransition: true,
|
||||
taskInput: "Valid prompt",
|
||||
wantStatus: codersdk.TaskStatusError,
|
||||
wantErr: "Unable to update",
|
||||
wantErrStatusCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "EmptyPrompt",
|
||||
transition: database.WorkspaceTransitionStop,
|
||||
// We want to ensure an empty prompt is rejected.
|
||||
taskInput: "",
|
||||
wantStatus: codersdk.TaskStatusPaused,
|
||||
wantErr: "Task input is required.",
|
||||
wantErrStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "TaskDeleted",
|
||||
transition: database.WorkspaceTransitionStop,
|
||||
deleteTask: true,
|
||||
taskInput: "Valid prompt",
|
||||
wantErr: httpapi.ResourceNotFoundResponse.Message,
|
||||
wantErrStatusCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, provisioner := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
template := createAITemplate(t, client, user)
|
||||
|
||||
if tt.disableProvisioner {
|
||||
provisioner.Close()
|
||||
}
|
||||
|
||||
// Given: We create a task
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "initial prompt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
|
||||
if !tt.disableProvisioner {
|
||||
// Given: The Task is running
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Given: We transition the task's workspace
|
||||
build := coderdtest.CreateWorkspaceBuild(t, client, workspace, tt.transition)
|
||||
if tt.cancelTransition {
|
||||
// Given: We cancel the workspace build
|
||||
err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{})
|
||||
require.NoError(t, err)
|
||||
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
||||
|
||||
// Then: We expect it to be canceled
|
||||
build, err = client.WorkspaceBuild(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.WorkspaceStatusCanceled, build.Status)
|
||||
} else {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if tt.deleteTask {
|
||||
err = client.DeleteTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// Given: Task has expected status
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantStatus, task.Status)
|
||||
}
|
||||
|
||||
// When: We attempt to update the task input
|
||||
err = client.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{
|
||||
Input: tt.taskInput,
|
||||
})
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
|
||||
if tt.wantErrStatusCode != 0 {
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, tt.wantErrStatusCode, apiErr.StatusCode())
|
||||
}
|
||||
|
||||
if !tt.deleteTask {
|
||||
// Then: We expect the input to **not** be updated
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, tt.taskInput, task.InitialPrompt)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
if !tt.deleteTask {
|
||||
// Then: We expect the input to be updated
|
||||
task, err = client.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.taskInput, task.InitialPrompt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NonExistentTask", 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)
|
||||
|
||||
// Attempt to update prompt for non-existent task
|
||||
err := client.UpdateTaskInput(ctx, user.UserID.String(), uuid.New(), codersdk.UpdateTaskInputRequest{
|
||||
Input: "Should fail",
|
||||
})
|
||||
require.Error(t, err)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("UnauthorizedUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
anotherUser, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
template := createAITemplate(t, client, user)
|
||||
|
||||
// Create a task as the first user
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "initial prompt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid)
|
||||
|
||||
// Wait for workspace to complete
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Stop the workspace
|
||||
build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
||||
|
||||
// Attempt to update prompt as another user should fail with 404 Not Found
|
||||
err = anotherUser.UpdateTaskInput(ctx, task.OwnerName, task.ID, codersdk.UpdateTaskInputRequest{
|
||||
Input: "Should fail - unauthorized",
|
||||
})
|
||||
require.Error(t, err)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTasksCreate(t *testing.T) {
|
||||
@@ -954,7 +767,9 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
})
|
||||
@@ -999,8 +814,10 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// When: We attempt to create a Task.
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
})
|
||||
@@ -1027,17 +844,14 @@ func TestTasksCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
taskName string
|
||||
taskDisplayName string
|
||||
expectFallbackName bool
|
||||
expectFallbackDisplayName bool
|
||||
expectError string
|
||||
name string
|
||||
taskName string
|
||||
expectFallbackName bool
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "ValidName",
|
||||
taskName: "a-valid-task-name",
|
||||
expectFallbackDisplayName: true,
|
||||
name: "ValidName",
|
||||
taskName: "a-valid-task-name",
|
||||
},
|
||||
{
|
||||
name: "NotValidName",
|
||||
@@ -1047,37 +861,8 @@ func TestTasksCreate(t *testing.T) {
|
||||
{
|
||||
name: "NoNameProvided",
|
||||
taskName: "",
|
||||
taskDisplayName: "A valid task display name",
|
||||
expectFallbackName: true,
|
||||
},
|
||||
{
|
||||
name: "ValidDisplayName",
|
||||
taskDisplayName: "A valid task display name",
|
||||
expectFallbackName: true,
|
||||
},
|
||||
{
|
||||
name: "NotValidDisplayName",
|
||||
taskDisplayName: "This is a task display name with a length greater than 64 characters.",
|
||||
expectError: "Display name must be 64 characters or less.",
|
||||
},
|
||||
{
|
||||
name: "NoDisplayNameProvided",
|
||||
taskName: "a-valid-task-name",
|
||||
taskDisplayName: "",
|
||||
expectFallbackDisplayName: true,
|
||||
},
|
||||
{
|
||||
name: "ValidNameAndDisplayName",
|
||||
taskName: "a-valid-task-name",
|
||||
taskDisplayName: "A valid task display name",
|
||||
},
|
||||
{
|
||||
name: "NoNameAndDisplayNameProvided",
|
||||
taskName: "",
|
||||
taskDisplayName: "",
|
||||
expectFallbackName: true,
|
||||
expectFallbackDisplayName: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1085,10 +870,11 @@ func TestTasksCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
expClient = codersdk.NewExperimentalClient(client)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
@@ -1103,11 +889,10 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
// When: We attempt to create a Task.
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "Some prompt",
|
||||
Name: tt.taskName,
|
||||
DisplayName: tt.taskDisplayName,
|
||||
})
|
||||
if tt.expectError == "" {
|
||||
require.NoError(t, err)
|
||||
@@ -1121,17 +906,8 @@ func TestTasksCreate(t *testing.T) {
|
||||
if !tt.expectFallbackName {
|
||||
require.Equal(t, tt.taskName, task.Name)
|
||||
}
|
||||
|
||||
// Then: We expect the correct display name to have been picked.
|
||||
require.NotEmpty(t, task.DisplayName)
|
||||
if !tt.expectFallbackDisplayName {
|
||||
require.Equal(t, tt.taskDisplayName, task.DisplayName)
|
||||
}
|
||||
} else {
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Equal(t, apiErr.Message, tt.expectError)
|
||||
require.ErrorContains(t, err, tt.expectError)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1154,8 +930,10 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// When: We attempt to create a Task.
|
||||
_, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
_, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
})
|
||||
@@ -1184,8 +962,10 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// When: We attempt to create a Task with an invalid template version ID.
|
||||
_, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
_, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: uuid.New(),
|
||||
Input: taskPrompt,
|
||||
})
|
||||
@@ -1221,7 +1001,9 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
})
|
||||
@@ -1278,7 +1060,9 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
Name: taskName,
|
||||
@@ -1312,14 +1096,16 @@ func TestTasksCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
task1, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
task1, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "First task",
|
||||
Name: "task-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
task2, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task2, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "Second task",
|
||||
Name: "task-2",
|
||||
@@ -1373,9 +1159,11 @@ func TestTasksCreate(t *testing.T) {
|
||||
}, template.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
|
||||
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
// Create a task using version 2 to verify the template_version_id is
|
||||
// stored correctly.
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: version2.ID,
|
||||
Input: "Use version 2",
|
||||
})
|
||||
|
||||
Generated
+227
-302
@@ -136,6 +136,233 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "List AI tasks",
|
||||
"operationId": "list-tasks",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TasksListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "Create a new AI task",
|
||||
"operationId": "create-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Create task request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.CreateTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "Get AI task by ID",
|
||||
"operationId": "get-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "Delete AI task by ID",
|
||||
"operationId": "delete-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Task deletion initiated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "Get AI task logs",
|
||||
"operationId": "get-task-logs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Experimental"
|
||||
],
|
||||
"summary": "Send input to AI task",
|
||||
"operationId": "send-task-input",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskSendRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Input sent successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/appearance": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -5452,294 +5679,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "List AI tasks",
|
||||
"operationId": "list-ai-tasks",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TasksListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Create a new AI task",
|
||||
"operationId": "create-a-new-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Create task request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.CreateTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Get AI task by ID or name",
|
||||
"operationId": "get-ai-task-by-id-or-name",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Delete AI task",
|
||||
"operationId": "delete-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/input": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Update AI task input",
|
||||
"operationId": "update-ai-task-input",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateTaskInputRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Get AI task logs",
|
||||
"operationId": "get-ai-task-logs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Send input to AI task",
|
||||
"operationId": "send-input-to-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskSendRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -13298,9 +13237,6 @@ const docTemplate = `{
|
||||
"codersdk.CreateTaskRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17900,9 +17836,6 @@ const docTemplate = `{
|
||||
"current_state": {
|
||||
"$ref": "#/definitions/codersdk.TaskStateEntry"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -19092,14 +19025,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateTaskInputRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateTemplateACL": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+215
-274
@@ -112,6 +112,221 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "List AI tasks",
|
||||
"operationId": "list-tasks",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TasksListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "Create a new AI task",
|
||||
"operationId": "create-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Create task request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.CreateTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "Get AI task by ID",
|
||||
"operationId": "get-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "Delete AI task by ID",
|
||||
"operationId": "delete-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Task deletion initiated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "Get AI task logs",
|
||||
"operationId": "get-task-logs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Experimental"],
|
||||
"summary": "Send input to AI task",
|
||||
"operationId": "send-task-input",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskSendRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Input sent successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/appearance": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -4811,266 +5026,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "List AI tasks",
|
||||
"operationId": "list-ai-tasks",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TasksListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Create a new AI task",
|
||||
"operationId": "create-a-new-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Create task request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.CreateTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Get AI task by ID or name",
|
||||
"operationId": "get-ai-task-by-id-or-name",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Delete AI task",
|
||||
"operationId": "delete-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/input": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Update AI task input",
|
||||
"operationId": "update-ai-task-input",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateTaskInputRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Get AI task logs",
|
||||
"operationId": "get-ai-task-logs",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Send input to AI task",
|
||||
"operationId": "send-input-to-ai-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Task ID, or task name",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Task input request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TaskSendRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -11928,9 +11883,6 @@
|
||||
"codersdk.CreateTaskRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16368,9 +16320,6 @@
|
||||
"current_state": {
|
||||
"$ref": "#/definitions/codersdk.TaskStateEntry"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -17503,14 +17452,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateTaskInputRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateTemplateACL": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1830,7 +1830,8 @@ func TestExecutorTaskWorkspace(t *testing.T) {
|
||||
createTaskWorkspace := func(t *testing.T, client *codersdk.Client, template codersdk.Template, ctx context.Context, input string) codersdk.Workspace {
|
||||
t.Helper()
|
||||
|
||||
task, err := client.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: input,
|
||||
})
|
||||
|
||||
+1
-25
@@ -99,7 +99,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
sharedhttpmw "github.com/coder/coder/v2/httpmw"
|
||||
"github.com/coder/coder/v2/provisionersdk"
|
||||
"github.com/coder/coder/v2/site"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
@@ -862,7 +861,7 @@ func New(options *Options) *API {
|
||||
prometheusMW := httpmw.Prometheus(options.PrometheusRegistry)
|
||||
|
||||
r.Use(
|
||||
sharedhttpmw.Recover(api.Logger),
|
||||
httpmw.Recover(api.Logger),
|
||||
httpmw.WithProfilingLabels,
|
||||
tracing.StatusWriterMiddleware,
|
||||
tracing.Middleware(api.TracerProvider),
|
||||
@@ -1024,9 +1023,6 @@ func New(options *Options) *API {
|
||||
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
|
||||
)
|
||||
|
||||
// NOTE(DanielleMaywood):
|
||||
// Tasks have been promoted to stable, but we have guaranteed a single release transition period
|
||||
// where these routes must remain. These should be removed no earlier than Coder v2.30.0
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
|
||||
@@ -1040,7 +1036,6 @@ func New(options *Options) *API {
|
||||
r.Use(httpmw.ExtractTaskParam(options.Database))
|
||||
r.Get("/", api.taskGet)
|
||||
r.Delete("/", api.taskDelete)
|
||||
r.Patch("/input", api.taskUpdateInput)
|
||||
r.Post("/send", api.taskSend)
|
||||
r.Get("/logs", api.taskLogs)
|
||||
})
|
||||
@@ -1654,25 +1649,6 @@ func New(options *Options) *API {
|
||||
r.Route("/init-script", func(r chi.Router) {
|
||||
r.Get("/{os}/{arch}", api.initScript)
|
||||
})
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
|
||||
r.Get("/", api.tasksList)
|
||||
|
||||
r.Route("/{user}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
|
||||
r.Post("/", api.tasksCreate)
|
||||
|
||||
r.Route("/{task}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractTaskParam(options.Database))
|
||||
r.Get("/", api.taskGet)
|
||||
r.Delete("/", api.taskDelete)
|
||||
r.Patch("/input", api.taskUpdateInput)
|
||||
r.Post("/send", api.taskSend)
|
||||
r.Get("/logs", api.taskLogs)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if options.SwaggerEndpoint {
|
||||
|
||||
@@ -217,7 +217,7 @@ var (
|
||||
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
// Unsure why provisionerd needs update and read personal
|
||||
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
|
||||
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
|
||||
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
|
||||
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
|
||||
// Provisionerd needs to read, update, and delete tasks associated with workspaces.
|
||||
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
@@ -2426,11 +2426,11 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab
|
||||
return q.db.GetLatestCryptoKeyByFeature(ctx, feature)
|
||||
}
|
||||
|
||||
func (q *querier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
|
||||
func (q *querier) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return database.WorkspaceAppStatus{}, err
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetLatestWorkspaceAppStatusByAppID(ctx, appID)
|
||||
return q.db.GetLatestWorkspaceAppStatusesByAppID(ctx, appID)
|
||||
}
|
||||
|
||||
func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
|
||||
@@ -5130,21 +5130,6 @@ func (q *querier) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg
|
||||
return q.db.UpdateTailnetPeerStatusByCoordinator(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) {
|
||||
// An actor is allowed to update the prompt of a task if they have
|
||||
// permission to update the task (same as UpdateTaskWorkspaceID).
|
||||
task, err := q.db.GetTaskByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
return database.TaskTable{}, err
|
||||
}
|
||||
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, task.RBACObject()); err != nil {
|
||||
return database.TaskTable{}, err
|
||||
}
|
||||
|
||||
return q.db.UpdateTaskPrompt(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) {
|
||||
// An actor is allowed to update the workspace ID of a task if they are the
|
||||
// owner of the task and workspace or have the appropriate permissions.
|
||||
@@ -5556,22 +5541,6 @@ func (q *querier) UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg d
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceAgentMetadata(ctx context.Context, arg database.UpdateWorkspaceAgentMetadataParams) error {
|
||||
// Fast path: Check if we have an RBAC object in context.
|
||||
// This is set by the workspace agent RPC handler to avoid the expensive
|
||||
// GetWorkspaceByAgentID query for every metadata update.
|
||||
// NOTE: The cached RBAC object is refreshed every 5 minutes in agentapi/api.go.
|
||||
if rbacObj, ok := WorkspaceRBACFromContext(ctx); ok {
|
||||
// Errors here will result in falling back to the GetWorkspaceAgentByID query, skipping
|
||||
// the cache in case the cached data is stale.
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbacObj); err == nil {
|
||||
return q.db.UpdateWorkspaceAgentMetadata(ctx, arg)
|
||||
}
|
||||
q.log.Debug(ctx, "fast path authorization failed, using slow path",
|
||||
slog.F("agent_id", arg.WorkspaceAgentID))
|
||||
}
|
||||
|
||||
// Slow path: Fallback to fetching the workspace for authorization if the RBAC object is not present (or is invalid)
|
||||
// in the request context.
|
||||
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.WorkspaceAgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2457,22 +2457,6 @@ func (s *MethodTestSuite) TestTasks() {
|
||||
|
||||
check.Args(arg).Asserts(task, policy.ActionUpdate, ws, policy.ActionUpdate).Returns(database.TaskTable{})
|
||||
}))
|
||||
s.Run("UpdateTaskPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
task := testutil.Fake(s.T(), faker, database.Task{})
|
||||
arg := database.UpdateTaskPromptParams{
|
||||
ID: task.ID,
|
||||
Prompt: "Updated prompt text",
|
||||
}
|
||||
|
||||
// Create a copy of the task with the updated prompt
|
||||
updatedTask := task
|
||||
updatedTask.Prompt = arg.Prompt
|
||||
|
||||
dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateTaskPrompt(gomock.Any(), arg).Return(updatedTask.TaskTable(), nil).AnyTimes()
|
||||
|
||||
check.Args(arg).Asserts(task, policy.ActionUpdate).Returns(updatedTask.TaskTable())
|
||||
}))
|
||||
s.Run("GetTaskByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
task := testutil.Fake(s.T(), faker, database.Task{})
|
||||
task.WorkspaceID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
|
||||
@@ -2864,9 +2848,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(l, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l)
|
||||
}))
|
||||
s.Run("GetLatestWorkspaceAppStatusByAppID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
s.Run("GetLatestWorkspaceAppStatusesByAppID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
appID := uuid.New()
|
||||
dbm.EXPECT().GetLatestWorkspaceAppStatusByAppID(gomock.Any(), appID).Return(database.WorkspaceAppStatus{}, nil).AnyTimes()
|
||||
dbm.EXPECT().GetLatestWorkspaceAppStatusesByAppID(gomock.Any(), appID).Return([]database.WorkspaceAppStatus{}, nil).AnyTimes()
|
||||
check.Args(appID).Asserts(rbac.ResourceSystem, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package dbauthz
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
)
|
||||
|
||||
func isWorkspaceRBACObjectEmpty(rbacObj rbac.Object) bool {
|
||||
// if any of these are true then the rbac.Object work a workspace is considered empty
|
||||
return rbacObj.Owner == "" || rbacObj.OrgID == "" || rbacObj.Owner == uuid.Nil.String() || rbacObj.OrgID == uuid.Nil.String()
|
||||
}
|
||||
|
||||
type workspaceRBACContextKey struct{}
|
||||
|
||||
// WithWorkspaceRBAC attaches a workspace RBAC object to the context.
|
||||
// RBAC fields on this RBAC object should not be used.
|
||||
//
|
||||
// This is primarily used by the workspace agent RPC handler to cache workspace
|
||||
// authorization data for the duration of an agent connection.
|
||||
func WithWorkspaceRBAC(ctx context.Context, rbacObj rbac.Object) (context.Context, error) {
|
||||
if rbacObj.Type != rbac.ResourceWorkspace.Type {
|
||||
return ctx, xerrors.New("RBAC Object must be of type Workspace")
|
||||
}
|
||||
if isWorkspaceRBACObjectEmpty(rbacObj) {
|
||||
return ctx, xerrors.Errorf("cannot attach empty RBAC object to context: %+v", rbacObj)
|
||||
}
|
||||
if len(rbacObj.ACLGroupList) != 0 || len(rbacObj.ACLUserList) != 0 {
|
||||
return ctx, xerrors.New("ACL fields for Workspace RBAC object must be nullified, the can be changed during runtime and should not be cached")
|
||||
}
|
||||
return context.WithValue(ctx, workspaceRBACContextKey{}, rbacObj), nil
|
||||
}
|
||||
|
||||
// WorkspaceRBACFromContext attempts to retrieve the workspace RBAC object from context.
|
||||
func WorkspaceRBACFromContext(ctx context.Context) (rbac.Object, bool) {
|
||||
obj, ok := ctx.Value(workspaceRBACContextKey{}).(rbac.Object)
|
||||
return obj, ok
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1584,13 +1582,11 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
|
||||
parameters = json.RawMessage([]byte("{}"))
|
||||
}
|
||||
|
||||
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
|
||||
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
OrganizationID: orig.OrganizationID,
|
||||
OwnerID: orig.OwnerID,
|
||||
Name: takeFirst(orig.Name, taskName.Name),
|
||||
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
|
||||
Name: takeFirst(orig.Name, taskname.GenerateFallback()),
|
||||
WorkspaceID: orig.WorkspaceID,
|
||||
TemplateVersionID: orig.TemplateVersionID,
|
||||
TemplateParameters: parameters,
|
||||
|
||||
@@ -1033,10 +1033,10 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
|
||||
func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetLatestWorkspaceAppStatusByAppID(ctx, appID)
|
||||
m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusByAppID").Observe(time.Since(start).Seconds())
|
||||
r0, r1 := m.s.GetLatestWorkspaceAppStatusesByAppID(ctx, appID)
|
||||
m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusesByAppID").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
@@ -3154,13 +3154,6 @@ func (m queryMetricsStore) UpdateTailnetPeerStatusByCoordinator(ctx context.Cont
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateTaskPrompt(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateTaskPrompt").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateTaskWorkspaceID(ctx, arg)
|
||||
|
||||
@@ -2172,19 +2172,19 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) *
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature)
|
||||
}
|
||||
|
||||
// GetLatestWorkspaceAppStatusByAppID mocks base method.
|
||||
func (m *MockStore) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (database.WorkspaceAppStatus, error) {
|
||||
// GetLatestWorkspaceAppStatusesByAppID mocks base method.
|
||||
func (m *MockStore) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]database.WorkspaceAppStatus, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusByAppID", ctx, appID)
|
||||
ret0, _ := ret[0].(database.WorkspaceAppStatus)
|
||||
ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusesByAppID", ctx, appID)
|
||||
ret0, _ := ret[0].([]database.WorkspaceAppStatus)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetLatestWorkspaceAppStatusByAppID indicates an expected call of GetLatestWorkspaceAppStatusByAppID.
|
||||
func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusByAppID(ctx, appID any) *gomock.Call {
|
||||
// GetLatestWorkspaceAppStatusesByAppID indicates an expected call of GetLatestWorkspaceAppStatusesByAppID.
|
||||
func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusesByAppID(ctx, appID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusByAppID", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusByAppID), ctx, appID)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusesByAppID", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusesByAppID), ctx, appID)
|
||||
}
|
||||
|
||||
// GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method.
|
||||
@@ -6770,21 +6770,6 @@ func (mr *MockStoreMockRecorder) UpdateTailnetPeerStatusByCoordinator(ctx, arg a
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTailnetPeerStatusByCoordinator", reflect.TypeOf((*MockStore)(nil).UpdateTailnetPeerStatusByCoordinator), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateTaskPrompt mocks base method.
|
||||
func (m *MockStore) UpdateTaskPrompt(ctx context.Context, arg database.UpdateTaskPromptParams) (database.TaskTable, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateTaskPrompt", ctx, arg)
|
||||
ret0, _ := ret[0].(database.TaskTable)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateTaskPrompt indicates an expected call of UpdateTaskPrompt.
|
||||
func (mr *MockStoreMockRecorder) UpdateTaskPrompt(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskPrompt", reflect.TypeOf((*MockStore)(nil).UpdateTaskPrompt), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateTaskWorkspaceID mocks base method.
|
||||
func (m *MockStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+1
-7
@@ -1826,12 +1826,9 @@ CREATE TABLE tasks (
|
||||
template_parameters jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
prompt text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
display_name character varying(127) DEFAULT ''::character varying NOT NULL
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.';
|
||||
|
||||
CREATE VIEW visible_users AS
|
||||
SELECT users.id,
|
||||
users.username,
|
||||
@@ -1967,7 +1964,6 @@ CREATE VIEW tasks_with_status AS
|
||||
tasks.prompt,
|
||||
tasks.created_at,
|
||||
tasks.deleted_at,
|
||||
tasks.display_name,
|
||||
CASE
|
||||
WHEN (tasks.workspace_id IS NULL) THEN 'pending'::task_status
|
||||
WHEN (build_status.status <> 'active'::task_status) THEN build_status.status
|
||||
@@ -3437,8 +3433,6 @@ CREATE INDEX workspace_agent_stats_template_id_created_at_user_id_idx ON workspa
|
||||
|
||||
COMMENT ON INDEX workspace_agent_stats_template_id_created_at_user_id_idx IS 'Support index for template insights endpoint to build interval reports faster.';
|
||||
|
||||
CREATE INDEX workspace_agents_auth_instance_id_deleted_idx ON workspace_agents USING btree (auth_instance_id, deleted);
|
||||
|
||||
CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (auth_token);
|
||||
|
||||
CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id);
|
||||
|
||||
@@ -141,19 +141,13 @@ ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:read';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:update';
|
||||
-- End enum extensions
|
||||
|
||||
-- Purge old API keys to speed up the migration for large deployments.
|
||||
-- Note: that problem should be solved in coderd once PR 20863 is released:
|
||||
-- https://github.com/coder/coder/blob/main/coderd/database/dbpurge/dbpurge.go#L85
|
||||
DELETE FROM api_keys WHERE expires_at < NOW() - INTERVAL '7 days';
|
||||
|
||||
-- Add new columns without defaults; backfill; then enforce NOT NULL
|
||||
ALTER TABLE api_keys ADD COLUMN scopes api_key_scope[];
|
||||
ALTER TABLE api_keys ADD COLUMN allow_list text[];
|
||||
|
||||
-- Backfill existing rows for compatibility
|
||||
UPDATE api_keys SET
|
||||
scopes = ARRAY[scope::api_key_scope],
|
||||
allow_list = ARRAY['*:*'];
|
||||
UPDATE api_keys SET scopes = ARRAY[scope::api_key_scope];
|
||||
UPDATE api_keys SET allow_list = ARRAY['*:*'];
|
||||
|
||||
-- Enforce NOT NULL
|
||||
ALTER TABLE api_keys ALTER COLUMN scopes SET NOT NULL;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
-- Drop view first before removing the display_name column from tasks
|
||||
DROP VIEW IF EXISTS tasks_with_status;
|
||||
|
||||
-- Remove display_name column from tasks
|
||||
ALTER TABLE tasks DROP COLUMN display_name;
|
||||
|
||||
-- Recreate view without the display_name column.
|
||||
-- This restores the view to its previous state after removing display_name from tasks.
|
||||
CREATE VIEW
|
||||
tasks_with_status
|
||||
AS
|
||||
SELECT
|
||||
tasks.*,
|
||||
CASE
|
||||
WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status
|
||||
|
||||
WHEN latest_build.job_status = 'failed' THEN 'error'::task_status
|
||||
|
||||
WHEN latest_build.transition IN ('stop', 'delete')
|
||||
AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status
|
||||
|
||||
WHEN latest_build.transition = 'start'
|
||||
AND latest_build.job_status = 'pending' THEN 'initializing'::task_status
|
||||
|
||||
WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN
|
||||
CASE
|
||||
WHEN agent_status.none THEN 'initializing'::task_status
|
||||
WHEN agent_status.connecting THEN 'initializing'::task_status
|
||||
WHEN agent_status.connected THEN
|
||||
CASE
|
||||
WHEN app_status.any_unhealthy THEN 'error'::task_status
|
||||
WHEN app_status.any_initializing THEN 'initializing'::task_status
|
||||
WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status,
|
||||
task_app.*,
|
||||
task_owner.*
|
||||
FROM
|
||||
tasks
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
vu.username AS owner_username,
|
||||
vu.name AS owner_name,
|
||||
vu.avatar_url AS owner_avatar_url
|
||||
FROM visible_users vu
|
||||
WHERE vu.id = tasks.owner_id
|
||||
) task_owner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT workspace_build_number, workspace_agent_id, workspace_app_id
|
||||
FROM task_workspace_apps task_app
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY workspace_build_number DESC
|
||||
LIMIT 1
|
||||
) task_app ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_build.transition,
|
||||
provisioner_job.job_status,
|
||||
workspace_build.job_id
|
||||
FROM workspace_builds workspace_build
|
||||
JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id
|
||||
WHERE workspace_build.workspace_id = tasks.workspace_id
|
||||
AND workspace_build.build_number = task_app.workspace_build_number
|
||||
) latest_build ON TRUE
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) = 0 AS none,
|
||||
bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting,
|
||||
bool_and(workspace_agent.lifecycle_state = 'ready') AS connected
|
||||
FROM workspace_agents workspace_agent
|
||||
WHERE workspace_agent.id = task_app.workspace_agent_id
|
||||
) agent_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy,
|
||||
bool_or(workspace_app.health = 'initializing') AS any_initializing,
|
||||
bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled
|
||||
FROM workspace_apps workspace_app
|
||||
WHERE workspace_app.id = task_app.workspace_app_id
|
||||
) app_status
|
||||
WHERE
|
||||
tasks.deleted_at IS NULL;
|
||||
@@ -1,158 +0,0 @@
|
||||
-- Add display_name column to tasks table
|
||||
ALTER TABLE tasks ADD COLUMN display_name VARCHAR(127) NOT NULL DEFAULT '';
|
||||
COMMENT ON COLUMN tasks.display_name IS 'Display name is a custom, human-friendly task name.';
|
||||
|
||||
-- Backfill existing tasks with truncated prompt as display name
|
||||
-- Replace newlines/tabs with spaces, truncate to 64 characters and add ellipsis if truncated
|
||||
UPDATE tasks
|
||||
SET display_name = CASE
|
||||
WHEN LENGTH(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g')) > 64
|
||||
THEN LEFT(REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g'), 63) || '…'
|
||||
ELSE REGEXP_REPLACE(prompt, E'[\\n\\r\\t]+', ' ', 'g')
|
||||
END
|
||||
WHERE display_name = '';
|
||||
|
||||
-- Recreate the tasks_with_status view to pick up the new display_name column.
|
||||
-- PostgreSQL resolves the tasks.* wildcard when the view is created, not when
|
||||
-- it's queried, so the view must be recreated after adding columns to tasks.
|
||||
DROP VIEW IF EXISTS tasks_with_status;
|
||||
|
||||
CREATE VIEW
|
||||
tasks_with_status
|
||||
AS
|
||||
SELECT
|
||||
tasks.*,
|
||||
-- Combine component statuses with precedence: build -> agent -> app.
|
||||
CASE
|
||||
WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status
|
||||
WHEN build_status.status != 'active' THEN build_status.status::task_status
|
||||
WHEN agent_status.status != 'active' THEN agent_status.status::task_status
|
||||
ELSE app_status.status::task_status
|
||||
END AS status,
|
||||
-- Attach debug information for troubleshooting status.
|
||||
jsonb_build_object(
|
||||
'build', jsonb_build_object(
|
||||
'transition', latest_build_raw.transition,
|
||||
'job_status', latest_build_raw.job_status,
|
||||
'computed', build_status.status
|
||||
),
|
||||
'agent', jsonb_build_object(
|
||||
'lifecycle_state', agent_raw.lifecycle_state,
|
||||
'computed', agent_status.status
|
||||
),
|
||||
'app', jsonb_build_object(
|
||||
'health', app_raw.health,
|
||||
'computed', app_status.status
|
||||
)
|
||||
) AS status_debug,
|
||||
task_app.*,
|
||||
agent_raw.lifecycle_state AS workspace_agent_lifecycle_state,
|
||||
app_raw.health AS workspace_app_health,
|
||||
task_owner.*
|
||||
FROM
|
||||
tasks
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
vu.username AS owner_username,
|
||||
vu.name AS owner_name,
|
||||
vu.avatar_url AS owner_avatar_url
|
||||
FROM
|
||||
visible_users vu
|
||||
WHERE
|
||||
vu.id = tasks.owner_id
|
||||
) task_owner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
task_app.workspace_build_number,
|
||||
task_app.workspace_agent_id,
|
||||
task_app.workspace_app_id
|
||||
FROM
|
||||
task_workspace_apps task_app
|
||||
WHERE
|
||||
task_id = tasks.id
|
||||
ORDER BY
|
||||
task_app.workspace_build_number DESC
|
||||
LIMIT 1
|
||||
) task_app ON TRUE
|
||||
|
||||
-- Join the raw data for computing task status.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_build.transition,
|
||||
provisioner_job.job_status,
|
||||
workspace_build.job_id
|
||||
FROM
|
||||
workspace_builds workspace_build
|
||||
JOIN
|
||||
provisioner_jobs provisioner_job
|
||||
ON provisioner_job.id = workspace_build.job_id
|
||||
WHERE
|
||||
workspace_build.workspace_id = tasks.workspace_id
|
||||
AND workspace_build.build_number = task_app.workspace_build_number
|
||||
) latest_build_raw ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_agent.lifecycle_state
|
||||
FROM
|
||||
workspace_agents workspace_agent
|
||||
WHERE
|
||||
workspace_agent.id = task_app.workspace_agent_id
|
||||
) agent_raw ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_app.health
|
||||
FROM
|
||||
workspace_apps workspace_app
|
||||
WHERE
|
||||
workspace_app.id = task_app.workspace_app_id
|
||||
) app_raw ON TRUE
|
||||
|
||||
-- Compute the status for each component.
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status
|
||||
WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status
|
||||
WHEN
|
||||
latest_build_raw.transition IN ('stop', 'delete')
|
||||
AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status
|
||||
WHEN
|
||||
latest_build_raw.transition = 'start'
|
||||
AND latest_build_raw.job_status = 'pending' THEN 'initializing'::task_status
|
||||
-- Build is running or done, defer to agent/app status.
|
||||
WHEN
|
||||
latest_build_raw.transition = 'start'
|
||||
AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) build_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
-- No agent or connecting.
|
||||
WHEN
|
||||
agent_raw.lifecycle_state IS NULL
|
||||
OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status
|
||||
-- Agent is running, defer to app status.
|
||||
-- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed.
|
||||
-- This may or may not affect the task status but this has to be caught by app health check.
|
||||
WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status
|
||||
-- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop
|
||||
-- build to be running.
|
||||
-- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`,
|
||||
-- but we cannot use them because the values were added in a migration.
|
||||
WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) agent_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status
|
||||
WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status
|
||||
WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) app_status
|
||||
WHERE
|
||||
tasks.deleted_at IS NULL;
|
||||
@@ -1 +0,0 @@
|
||||
DROP INDEX IF EXISTS public.workspace_agents_auth_instance_id_deleted_idx;
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX IF NOT EXISTS workspace_agents_auth_instance_id_deleted_idx ON public.workspace_agents (auth_instance_id, deleted);
|
||||
coderd/database/migrations/testdata/fixtures/000371_add_api_key_and_oauth2_provider_app_token.up.sql
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
-- Ensure api_keys and oauth2_provider_app_tokens have live data after
|
||||
-- migration 000371 deletes expired rows.
|
||||
INSERT INTO api_keys (
|
||||
id,
|
||||
hashed_secret,
|
||||
user_id,
|
||||
last_used,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
login_type,
|
||||
lifetime_seconds,
|
||||
ip_address,
|
||||
token_name,
|
||||
scopes,
|
||||
allow_list
|
||||
)
|
||||
VALUES (
|
||||
'fixture-api-key',
|
||||
'\xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
NOW() - INTERVAL '1 hour',
|
||||
NOW() + INTERVAL '30 days',
|
||||
NOW() - INTERVAL '1 day',
|
||||
NOW() - INTERVAL '1 day',
|
||||
'password',
|
||||
86400,
|
||||
'0.0.0.0',
|
||||
'fixture-api-key',
|
||||
ARRAY['workspace:read']::api_key_scope[],
|
||||
ARRAY['*:*']
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO oauth2_provider_app_tokens (
|
||||
id,
|
||||
created_at,
|
||||
expires_at,
|
||||
hash_prefix,
|
||||
refresh_hash,
|
||||
app_secret_id,
|
||||
api_key_id,
|
||||
audience,
|
||||
user_id
|
||||
)
|
||||
VALUES (
|
||||
'9f92f3c9-811f-4f6f-9a1c-3f2eed1f9f15',
|
||||
NOW() - INTERVAL '30 minutes',
|
||||
NOW() + INTERVAL '30 days',
|
||||
CAST('fixture-hash-prefix' AS bytea),
|
||||
CAST('fixture-refresh-hash' AS bytea),
|
||||
'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'fixture-api-key',
|
||||
'https://coder.example.com',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,7 +1,6 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -133,29 +132,11 @@ func (w ConnectionLog) RBACObject() rbac.Object {
|
||||
return obj
|
||||
}
|
||||
|
||||
// TaskTable converts a Task to it's reduced version.
|
||||
// A more generalized solution is to use json marshaling to
|
||||
// consistently keep these two structs in sync.
|
||||
// That would be a lot of overhead, and a more costly unit test is
|
||||
// written to make sure these match up.
|
||||
func (t Task) TaskTable() TaskTable {
|
||||
return TaskTable{
|
||||
ID: t.ID,
|
||||
OrganizationID: t.OrganizationID,
|
||||
OwnerID: t.OwnerID,
|
||||
Name: t.Name,
|
||||
DisplayName: t.DisplayName,
|
||||
WorkspaceID: t.WorkspaceID,
|
||||
TemplateVersionID: t.TemplateVersionID,
|
||||
TemplateParameters: t.TemplateParameters,
|
||||
Prompt: t.Prompt,
|
||||
CreatedAt: t.CreatedAt,
|
||||
DeletedAt: t.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (t Task) RBACObject() rbac.Object {
|
||||
return t.TaskTable().RBACObject()
|
||||
return rbac.ResourceTask.
|
||||
WithID(t.ID).
|
||||
WithOwner(t.OwnerID.String()).
|
||||
InOrg(t.OrganizationID)
|
||||
}
|
||||
|
||||
func (t TaskTable) RBACObject() rbac.Object {
|
||||
@@ -797,60 +778,3 @@ func (s UserSecret) RBACObject() rbac.Object {
|
||||
func (s AIBridgeInterception) RBACObject() rbac.Object {
|
||||
return rbac.ResourceAibridgeInterception.WithOwner(s.InitiatorID.String())
|
||||
}
|
||||
|
||||
// WorkspaceIdentity contains the minimal workspace fields needed for agent API metadata/stats reporting
|
||||
// and RBAC checks, without requiring a full database.Workspace object.
|
||||
type WorkspaceIdentity struct {
|
||||
// Add any other fields needed for IsPrebuild() if it relies on workspace fields
|
||||
// Identity fields
|
||||
ID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
|
||||
// Display fields for logging/metrics
|
||||
Name string
|
||||
OwnerUsername string
|
||||
TemplateName string
|
||||
|
||||
// Lifecycle fields needed for stats reporting
|
||||
AutostartSchedule sql.NullString
|
||||
}
|
||||
|
||||
func (w WorkspaceIdentity) RBACObject() rbac.Object {
|
||||
return Workspace{
|
||||
ID: w.ID,
|
||||
OwnerID: w.OwnerID,
|
||||
OrganizationID: w.OrganizationID,
|
||||
TemplateID: w.TemplateID,
|
||||
Name: w.Name,
|
||||
OwnerUsername: w.OwnerUsername,
|
||||
TemplateName: w.TemplateName,
|
||||
AutostartSchedule: w.AutostartSchedule,
|
||||
}.RBACObject()
|
||||
}
|
||||
|
||||
// IsPrebuild returns true if the workspace is a prebuild workspace.
|
||||
// A workspace is considered a prebuild if its owner is the prebuild system user.
|
||||
func (w WorkspaceIdentity) IsPrebuild() bool {
|
||||
return w.OwnerID == PrebuildsSystemUserID
|
||||
}
|
||||
|
||||
func (w WorkspaceIdentity) Equal(w2 WorkspaceIdentity) bool {
|
||||
return w.ID == w2.ID && w.OwnerID == w2.OwnerID && w.OrganizationID == w2.OrganizationID &&
|
||||
w.TemplateID == w2.TemplateID && w.Name == w2.Name && w.OwnerUsername == w2.OwnerUsername &&
|
||||
w.TemplateName == w2.TemplateName && w.AutostartSchedule == w2.AutostartSchedule
|
||||
}
|
||||
|
||||
func WorkspaceIdentityFromWorkspace(w Workspace) WorkspaceIdentity {
|
||||
return WorkspaceIdentity{
|
||||
ID: w.ID,
|
||||
OwnerID: w.OwnerID,
|
||||
OrganizationID: w.OrganizationID,
|
||||
TemplateID: w.TemplateID,
|
||||
Name: w.Name,
|
||||
OwnerUsername: w.OwnerUsername,
|
||||
TemplateName: w.TemplateName,
|
||||
AutostartSchedule: w.AutostartSchedule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,45 +58,6 @@ func TestWorkspaceTableConvert(t *testing.T) {
|
||||
"To resolve this, go to the 'func (w Workspace) WorkspaceTable()' and ensure all fields are converted.")
|
||||
}
|
||||
|
||||
// TestTaskTableConvert verifies all task fields are converted
|
||||
// when reducing a `Task` to a `TaskTable`.
|
||||
// This test is a guard rail to prevent developer oversight mistakes.
|
||||
func TestTaskTableConvert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
staticRandoms := &testutil.Random{
|
||||
String: func() string { return "foo" },
|
||||
Bool: func() bool { return true },
|
||||
Int: func() int64 { return 500 },
|
||||
Uint: func() uint64 { return 126 },
|
||||
Float: func() float64 { return 3.14 },
|
||||
Complex: func() complex128 { return 6.24 },
|
||||
Time: func() time.Time {
|
||||
return time.Date(2020, 5, 2, 5, 19, 21, 30, time.UTC)
|
||||
},
|
||||
}
|
||||
|
||||
// Copies the approach taken by TestWorkspaceTableConvert.
|
||||
//
|
||||
// If you use 'PopulateStruct' to create 2 tasks, using the same
|
||||
// "random" values for each type. Then they should be identical.
|
||||
//
|
||||
// So if 'task.TaskTable()' was missing any fields in its
|
||||
// conversion, the comparison would fail.
|
||||
|
||||
var task Task
|
||||
err := testutil.PopulateStruct(&task, staticRandoms)
|
||||
require.NoError(t, err)
|
||||
|
||||
var subset TaskTable
|
||||
err = testutil.PopulateStruct(&subset, staticRandoms)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, task.TaskTable(), subset,
|
||||
"'task.TaskTable()' is not missing at least 1 field when converting to 'TaskTable'. "+
|
||||
"To resolve this, go to the 'func (t Task) TaskTable()' and ensure all fields are converted.")
|
||||
}
|
||||
|
||||
// TestAuditLogsQueryConsistency ensures that GetAuditLogsOffset and CountAuditLogs
|
||||
// have identical WHERE clauses to prevent filtering inconsistencies.
|
||||
// This test is a guard rail to prevent developer oversight mistakes.
|
||||
|
||||
@@ -4218,7 +4218,6 @@ type Task struct {
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Status TaskStatus `db:"status" json:"status"`
|
||||
StatusDebug json.RawMessage `db:"status_debug" json:"status_debug"`
|
||||
WorkspaceBuildNumber sql.NullInt32 `db:"workspace_build_number" json:"workspace_build_number"`
|
||||
@@ -4242,8 +4241,6 @@ type TaskTable struct {
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
|
||||
// Display name is a custom, human-friendly task name.
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
}
|
||||
|
||||
type TaskWorkspaceApp struct {
|
||||
|
||||
@@ -238,7 +238,7 @@ type sqlcQuerier interface {
|
||||
GetInboxNotificationsByUserID(ctx context.Context, arg GetInboxNotificationsByUserIDParams) ([]InboxNotification, error)
|
||||
GetLastUpdateCheck(ctx context.Context) (string, error)
|
||||
GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error)
|
||||
GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (WorkspaceAppStatus, error)
|
||||
GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]WorkspaceAppStatus, error)
|
||||
GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error)
|
||||
GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error)
|
||||
GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error)
|
||||
@@ -686,7 +686,6 @@ type sqlcQuerier interface {
|
||||
UpdateProvisionerJobWithCompleteWithStartedAtByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error
|
||||
UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error)
|
||||
UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg UpdateTailnetPeerStatusByCoordinatorParams) error
|
||||
UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error)
|
||||
UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWorkspaceIDParams) (TaskTable, error)
|
||||
UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error
|
||||
UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error
|
||||
|
||||
@@ -13187,7 +13187,7 @@ SET
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
|
||||
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at
|
||||
`
|
||||
|
||||
type DeleteTaskParams struct {
|
||||
@@ -13209,13 +13209,12 @@ func (q *sqlQuerier) DeleteTask(ctx context.Context, arg DeleteTaskParams) (Task
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTaskByID = `-- name: GetTaskByID :one
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE id = $1::uuid
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error) {
|
||||
@@ -13232,7 +13231,6 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
&i.Status,
|
||||
&i.StatusDebug,
|
||||
&i.WorkspaceBuildNumber,
|
||||
@@ -13248,7 +13246,7 @@ func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error
|
||||
}
|
||||
|
||||
const getTaskByOwnerIDAndName = `-- name: GetTaskByOwnerIDAndName :one
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status
|
||||
WHERE
|
||||
owner_id = $1::uuid
|
||||
AND deleted_at IS NULL
|
||||
@@ -13274,7 +13272,6 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
&i.Status,
|
||||
&i.StatusDebug,
|
||||
&i.WorkspaceBuildNumber,
|
||||
@@ -13290,7 +13287,7 @@ func (q *sqlQuerier) GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByO
|
||||
}
|
||||
|
||||
const getTaskByWorkspaceID = `-- name: GetTaskByWorkspaceID :one
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status WHERE workspace_id = $1::uuid
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error) {
|
||||
@@ -13307,7 +13304,6 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
&i.Status,
|
||||
&i.StatusDebug,
|
||||
&i.WorkspaceBuildNumber,
|
||||
@@ -13324,10 +13320,10 @@ func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.
|
||||
|
||||
const insertTask = `-- name: InsertTask :one
|
||||
INSERT INTO tasks
|
||||
(id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at)
|
||||
(id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at
|
||||
`
|
||||
|
||||
type InsertTaskParams struct {
|
||||
@@ -13335,7 +13331,6 @@ type InsertTaskParams struct {
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"`
|
||||
TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"`
|
||||
@@ -13349,7 +13344,6 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task
|
||||
arg.OrganizationID,
|
||||
arg.OwnerID,
|
||||
arg.Name,
|
||||
arg.DisplayName,
|
||||
arg.WorkspaceID,
|
||||
arg.TemplateVersionID,
|
||||
arg.TemplateParameters,
|
||||
@@ -13368,13 +13362,12 @@ func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (Task
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listTasks = `-- name: ListTasks :many
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws
|
||||
SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, status_debug, workspace_build_number, workspace_agent_id, workspace_app_id, workspace_agent_lifecycle_state, workspace_app_health, owner_username, owner_name, owner_avatar_url FROM tasks_with_status tws
|
||||
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
|
||||
@@ -13408,7 +13401,6 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
&i.Status,
|
||||
&i.StatusDebug,
|
||||
&i.WorkspaceBuildNumber,
|
||||
@@ -13433,41 +13425,6 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateTaskPrompt = `-- name: UpdateTaskPrompt :one
|
||||
UPDATE
|
||||
tasks
|
||||
SET
|
||||
prompt = $1::text
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, display_name
|
||||
`
|
||||
|
||||
type UpdateTaskPromptParams struct {
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateTaskPrompt(ctx context.Context, arg UpdateTaskPromptParams) (TaskTable, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateTaskPrompt, arg.Prompt, arg.ID)
|
||||
var i TaskTable
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OrganizationID,
|
||||
&i.OwnerID,
|
||||
&i.Name,
|
||||
&i.WorkspaceID,
|
||||
&i.TemplateVersionID,
|
||||
&i.TemplateParameters,
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTaskWorkspaceID = `-- name: UpdateTaskWorkspaceID :one
|
||||
UPDATE
|
||||
tasks
|
||||
@@ -13485,7 +13442,7 @@ WHERE
|
||||
AND w.id = $2
|
||||
AND tv.id = tasks.template_version_id
|
||||
RETURNING
|
||||
tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at, tasks.display_name
|
||||
tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at
|
||||
`
|
||||
|
||||
type UpdateTaskWorkspaceIDParams struct {
|
||||
@@ -13507,7 +13464,6 @@ func (q *sqlQuerier) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWo
|
||||
&i.Prompt,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -20041,28 +19997,43 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups
|
||||
return new_or_stale, err
|
||||
}
|
||||
|
||||
const getLatestWorkspaceAppStatusByAppID = `-- name: GetLatestWorkspaceAppStatusByAppID :one
|
||||
const getLatestWorkspaceAppStatusesByAppID = `-- name: GetLatestWorkspaceAppStatusesByAppID :many
|
||||
SELECT id, created_at, agent_id, app_id, workspace_id, state, message, uri
|
||||
FROM workspace_app_statuses
|
||||
WHERE app_id = $1::uuid
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetLatestWorkspaceAppStatusByAppID(ctx context.Context, appID uuid.UUID) (WorkspaceAppStatus, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLatestWorkspaceAppStatusByAppID, appID)
|
||||
var i WorkspaceAppStatus
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.AgentID,
|
||||
&i.AppID,
|
||||
&i.WorkspaceID,
|
||||
&i.State,
|
||||
&i.Message,
|
||||
&i.Uri,
|
||||
)
|
||||
return i, err
|
||||
func (q *sqlQuerier) GetLatestWorkspaceAppStatusesByAppID(ctx context.Context, appID uuid.UUID) ([]WorkspaceAppStatus, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getLatestWorkspaceAppStatusesByAppID, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAppStatus
|
||||
for rows.Next() {
|
||||
var i WorkspaceAppStatus
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.AgentID,
|
||||
&i.AppID,
|
||||
&i.WorkspaceID,
|
||||
&i.State,
|
||||
&i.Message,
|
||||
&i.Uri,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many
|
||||
@@ -23751,7 +23722,6 @@ SET
|
||||
WHERE
|
||||
template_id = $3
|
||||
AND dormant_at IS NOT NULL
|
||||
AND deleted = false
|
||||
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
|
||||
-- should not have their dormant or deleting at set, as these are handled by the
|
||||
-- prebuilds reconciliation loop.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
-- name: InsertTask :one
|
||||
INSERT INTO tasks
|
||||
(id, organization_id, owner_id, name, display_name, workspace_id, template_version_id, template_parameters, prompt, created_at)
|
||||
(id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateTaskWorkspaceID :one
|
||||
@@ -64,14 +64,3 @@ WHERE
|
||||
id = @id::uuid
|
||||
AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- name: UpdateTaskPrompt :one
|
||||
UPDATE
|
||||
tasks
|
||||
SET
|
||||
prompt = @prompt::text
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
||||
@@ -73,12 +73,11 @@ RETURNING *;
|
||||
-- name: GetWorkspaceAppStatusesByAppIDs :many
|
||||
SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]);
|
||||
|
||||
-- name: GetLatestWorkspaceAppStatusByAppID :one
|
||||
-- name: GetLatestWorkspaceAppStatusesByAppID :many
|
||||
SELECT *
|
||||
FROM workspace_app_statuses
|
||||
WHERE app_id = @app_id::uuid
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1;
|
||||
ORDER BY created_at DESC, id DESC;
|
||||
|
||||
-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many
|
||||
SELECT DISTINCT ON (workspace_id)
|
||||
|
||||
@@ -846,7 +846,6 @@ SET
|
||||
WHERE
|
||||
template_id = @template_id
|
||||
AND dormant_at IS NOT NULL
|
||||
AND deleted = false
|
||||
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
|
||||
-- should not have their dormant or deleting at set, as these are handled by the
|
||||
-- prebuilds reconciliation loop.
|
||||
|
||||
@@ -7,17 +7,19 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
)
|
||||
|
||||
var (
|
||||
safeParams = []string{"page", "limit", "offset", "path"}
|
||||
safeParams = []string{"page", "limit", "offset"}
|
||||
countParams = []string{"ids", "template_ids"}
|
||||
)
|
||||
|
||||
@@ -122,18 +124,85 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
type RequestLogger interface {
|
||||
WithFields(fields ...slog.Field)
|
||||
WriteLog(ctx context.Context, status int)
|
||||
WithAuthContext(actor rbac.Subject)
|
||||
}
|
||||
|
||||
type SlogRequestLogger struct {
|
||||
log slog.Logger
|
||||
written bool
|
||||
message string
|
||||
start time.Time
|
||||
addFields func()
|
||||
log slog.Logger
|
||||
written bool
|
||||
message string
|
||||
start time.Time
|
||||
// Protects actors map for concurrent writes.
|
||||
mu sync.RWMutex
|
||||
actors map[rbac.SubjectType]rbac.Subject
|
||||
}
|
||||
|
||||
var _ RequestLogger = &SlogRequestLogger{}
|
||||
|
||||
func NewRequestLogger(log slog.Logger, message string, start time.Time) RequestLogger {
|
||||
return &SlogRequestLogger{
|
||||
log: log,
|
||||
written: false,
|
||||
message: message,
|
||||
start: start,
|
||||
actors: make(map[rbac.SubjectType]rbac.Subject),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SlogRequestLogger) WithFields(fields ...slog.Field) {
|
||||
c.log = c.log.With(fields...)
|
||||
}
|
||||
|
||||
func (c *SlogRequestLogger) WithAuthContext(actor rbac.Subject) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.actors[actor.Type] = actor
|
||||
}
|
||||
|
||||
func (c *SlogRequestLogger) addAuthContextFields() {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
usr, ok := c.actors[rbac.SubjectTypeUser]
|
||||
if ok {
|
||||
c.log = c.log.With(
|
||||
slog.F("requestor_id", usr.ID),
|
||||
slog.F("requestor_name", usr.FriendlyName),
|
||||
slog.F("requestor_email", usr.Email),
|
||||
)
|
||||
} else {
|
||||
// If there is no user, we log the requestor name for the first
|
||||
// actor in a defined order.
|
||||
for _, v := range actorLogOrder {
|
||||
subj, ok := c.actors[v]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.log = c.log.With(
|
||||
slog.F("requestor_name", subj.FriendlyName),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var actorLogOrder = []rbac.SubjectType{
|
||||
rbac.SubjectTypeAutostart,
|
||||
rbac.SubjectTypeCryptoKeyReader,
|
||||
rbac.SubjectTypeCryptoKeyRotator,
|
||||
rbac.SubjectTypeJobReaper,
|
||||
rbac.SubjectTypeNotifier,
|
||||
rbac.SubjectTypePrebuildsOrchestrator,
|
||||
rbac.SubjectTypeSubAgentAPI,
|
||||
rbac.SubjectTypeProvisionerd,
|
||||
rbac.SubjectTypeResourceMonitor,
|
||||
rbac.SubjectTypeSystemReadProvisionerDaemons,
|
||||
rbac.SubjectTypeSystemRestricted,
|
||||
}
|
||||
|
||||
func (c *SlogRequestLogger) WriteLog(ctx context.Context, status int) {
|
||||
if c.written {
|
||||
return
|
||||
@@ -141,9 +210,9 @@ func (c *SlogRequestLogger) WriteLog(ctx context.Context, status int) {
|
||||
c.written = true
|
||||
end := time.Now()
|
||||
|
||||
if c.addFields != nil {
|
||||
c.addFields()
|
||||
}
|
||||
// Right before we write the log, we try to find the user in the actors
|
||||
// and add the fields to the log.
|
||||
c.addAuthContextFields()
|
||||
|
||||
logger := c.log.With(
|
||||
slog.F("took", end.Sub(c.start)),
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package loggermw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
)
|
||||
|
||||
type RequestLogger interface {
|
||||
WithFields(fields ...slog.Field)
|
||||
WriteLog(ctx context.Context, status int)
|
||||
WithAuthContext(actor rbac.Subject)
|
||||
}
|
||||
|
||||
type RbacSlogRequestLogger struct {
|
||||
SlogRequestLogger
|
||||
// Protects actors map for concurrent writes.
|
||||
mu sync.RWMutex
|
||||
actors map[rbac.SubjectType]rbac.Subject
|
||||
}
|
||||
|
||||
var _ RequestLogger = &RbacSlogRequestLogger{}
|
||||
|
||||
func NewRequestLogger(log slog.Logger, message string, start time.Time) RequestLogger {
|
||||
rlogger := &RbacSlogRequestLogger{
|
||||
SlogRequestLogger: SlogRequestLogger{
|
||||
log: log,
|
||||
written: false,
|
||||
message: message,
|
||||
start: start,
|
||||
},
|
||||
actors: make(map[rbac.SubjectType]rbac.Subject),
|
||||
}
|
||||
rlogger.addFields = rlogger.addAuthContextFields
|
||||
return rlogger
|
||||
}
|
||||
|
||||
func (c *RbacSlogRequestLogger) WithAuthContext(actor rbac.Subject) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.actors[actor.Type] = actor
|
||||
}
|
||||
|
||||
var actorLogOrder = []rbac.SubjectType{
|
||||
rbac.SubjectTypeAutostart,
|
||||
rbac.SubjectTypeCryptoKeyReader,
|
||||
rbac.SubjectTypeCryptoKeyRotator,
|
||||
rbac.SubjectTypeJobReaper,
|
||||
rbac.SubjectTypeNotifier,
|
||||
rbac.SubjectTypePrebuildsOrchestrator,
|
||||
rbac.SubjectTypeSubAgentAPI,
|
||||
rbac.SubjectTypeProvisionerd,
|
||||
rbac.SubjectTypeResourceMonitor,
|
||||
rbac.SubjectTypeSystemReadProvisionerDaemons,
|
||||
rbac.SubjectTypeSystemRestricted,
|
||||
}
|
||||
|
||||
func (c *RbacSlogRequestLogger) addAuthContextFields() {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
usr, ok := c.actors[rbac.SubjectTypeUser]
|
||||
if ok {
|
||||
c.log = c.log.With(
|
||||
slog.F("requestor_id", usr.ID),
|
||||
slog.F("requestor_name", usr.FriendlyName),
|
||||
slog.F("requestor_email", usr.Email),
|
||||
)
|
||||
} else {
|
||||
// If there is no user, we log the requestor name for the first
|
||||
// actor in a defined order.
|
||||
for _, v := range actorLogOrder {
|
||||
subj, ok := c.actors[v]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.log = c.log.With(
|
||||
slog.F("requestor_name", subj.FriendlyName),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/websocket"
|
||||
@@ -364,31 +363,6 @@ func TestSafeQueryParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogger_AuthContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
sink := &fakeSink{}
|
||||
logger := slog.Make(sink)
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
logCtx := NewRequestLogger(logger, "GET", time.Now())
|
||||
|
||||
logCtx.WithAuthContext(rbac.Subject{
|
||||
ID: "test-user-id",
|
||||
FriendlyName: "test name",
|
||||
Email: "test@coder.com",
|
||||
Type: rbac.SubjectTypeUser,
|
||||
})
|
||||
|
||||
logCtx.WriteLog(ctx, http.StatusOK)
|
||||
|
||||
require.Len(t, sink.entries, 1, "log was written twice")
|
||||
require.Equal(t, sink.entries[0].Message, "GET")
|
||||
require.Equal(t, sink.entries[0].Fields[0].Value, "test-user-id")
|
||||
require.Equal(t, sink.entries[0].Fields[1].Value, "test name")
|
||||
require.Equal(t, sink.entries[0].Fields[2].Value, "test@coder.com")
|
||||
}
|
||||
|
||||
type fakeSink struct {
|
||||
entries []slog.SinkEntry
|
||||
newEntries chan slog.SinkEntry
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build slim
|
||||
|
||||
package loggermw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
type RequestLogger interface {
|
||||
WithFields(fields ...slog.Field)
|
||||
WriteLog(ctx context.Context, status int)
|
||||
}
|
||||
|
||||
var _ RequestLogger = &SlogRequestLogger{}
|
||||
|
||||
func NewRequestLogger(log slog.Logger, message string, start time.Time) RequestLogger {
|
||||
return &SlogRequestLogger{
|
||||
log: log,
|
||||
written: false,
|
||||
message: message,
|
||||
start: start,
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/httpmw"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
@@ -37,11 +37,6 @@ const (
|
||||
|
||||
var MetricLabelValueEncoder = strings.NewReplacer("\\", "\\\\", "|", "\\|", ",", "\\,", "=", "\\=")
|
||||
|
||||
type descCacheEntry struct {
|
||||
desc *prometheus.Desc
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
type MetricsAggregator struct {
|
||||
store map[metricKey]annotatedMetric
|
||||
|
||||
@@ -55,8 +50,6 @@ type MetricsAggregator struct {
|
||||
updateHistogram prometheus.Histogram
|
||||
cleanupHistogram prometheus.Histogram
|
||||
aggregateByLabels []string
|
||||
// per-aggregator cache of descriptors
|
||||
descCache map[string]descCacheEntry
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
@@ -114,6 +107,42 @@ func hashKey(req *updateRequest, m *agentproto.Stats_Metric) metricKey {
|
||||
|
||||
var _ prometheus.Collector = new(MetricsAggregator)
|
||||
|
||||
func (am *annotatedMetric) asPrometheus() (prometheus.Metric, error) {
|
||||
var (
|
||||
baseLabelNames = am.aggregateByLabels
|
||||
baseLabelValues []string
|
||||
extraLabels = am.Labels
|
||||
)
|
||||
|
||||
for _, label := range baseLabelNames {
|
||||
val, err := am.getFieldByLabel(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseLabelValues = append(baseLabelValues, val)
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(baseLabelNames)+len(extraLabels))
|
||||
labelValues := make([]string, 0, len(baseLabelNames)+len(extraLabels))
|
||||
|
||||
labels = append(labels, baseLabelNames...)
|
||||
labelValues = append(labelValues, baseLabelValues...)
|
||||
|
||||
for _, l := range extraLabels {
|
||||
labels = append(labels, l.Name)
|
||||
labelValues = append(labelValues, l.Value)
|
||||
}
|
||||
|
||||
desc := prometheus.NewDesc(am.Name, metricHelpForAgent, labels, nil)
|
||||
valueType, err := asPrometheusValueType(am.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prometheus.MustNewConstMetric(desc, valueType, am.Value, labelValues...), nil
|
||||
}
|
||||
|
||||
// getFieldByLabel returns the related field value for a given label
|
||||
func (am *annotatedMetric) getFieldByLabel(label string) (string, error) {
|
||||
var labelVal string
|
||||
@@ -335,7 +364,7 @@ func (ma *MetricsAggregator) Run(ctx context.Context) func() {
|
||||
}
|
||||
|
||||
for _, m := range input {
|
||||
promMetric, err := ma.asPrometheus(&m)
|
||||
promMetric, err := m.asPrometheus()
|
||||
if err != nil {
|
||||
ma.log.Error(ctx, "can't convert Prometheus value type", slog.F("name", m.Name), slog.F("type", m.Type), slog.F("value", m.Value), slog.Error(err))
|
||||
continue
|
||||
@@ -357,8 +386,6 @@ func (ma *MetricsAggregator) Run(ctx context.Context) func() {
|
||||
}
|
||||
}
|
||||
|
||||
ma.cleanupDescCache()
|
||||
|
||||
timer.ObserveDuration()
|
||||
cleanupTicker.Reset(ma.metricsCleanupInterval)
|
||||
ma.storeSizeGauge.Set(float64(len(ma.store)))
|
||||
@@ -380,86 +407,6 @@ func (ma *MetricsAggregator) Run(ctx context.Context) func() {
|
||||
func (*MetricsAggregator) Describe(_ chan<- *prometheus.Desc) {
|
||||
}
|
||||
|
||||
// cacheKeyForDesc is used to determine the cache key for a set of labels/extra labels. Used with the aggregators description cache.
|
||||
// for strings.Builder returned errors from these functions are always nil.
|
||||
// nolint:revive
|
||||
func cacheKeyForDesc(name string, baseLabelNames []string, extraLabels []*agentproto.Stats_Metric_Label) string {
|
||||
var b strings.Builder
|
||||
hint := len(name) + (len(baseLabelNames)+len(extraLabels))*8
|
||||
b.Grow(hint)
|
||||
b.WriteString(name)
|
||||
for _, ln := range baseLabelNames {
|
||||
b.WriteByte('|')
|
||||
b.WriteString(ln)
|
||||
}
|
||||
for _, l := range extraLabels {
|
||||
b.WriteByte('|')
|
||||
b.WriteString(l.Name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getOrCreateDec checks if we already have a metric description in the aggregators cache for a given combination of base
|
||||
// labels and extra labels. If we do not, we create a new description and cache it.
|
||||
func (ma *MetricsAggregator) getOrCreateDesc(name string, help string, baseLabelNames []string, extraLabels []*agentproto.Stats_Metric_Label) *prometheus.Desc {
|
||||
if ma.descCache == nil {
|
||||
ma.descCache = make(map[string]descCacheEntry)
|
||||
}
|
||||
key := cacheKeyForDesc(name, baseLabelNames, extraLabels)
|
||||
if d, ok := ma.descCache[key]; ok {
|
||||
d.lastUsed = time.Now()
|
||||
ma.descCache[key] = d
|
||||
return d.desc
|
||||
}
|
||||
nBase := len(baseLabelNames)
|
||||
nExtra := len(extraLabels)
|
||||
labels := make([]string, nBase+nExtra)
|
||||
copy(labels, baseLabelNames)
|
||||
for i, l := range extraLabels {
|
||||
labels[nBase+i] = l.Name
|
||||
}
|
||||
d := prometheus.NewDesc(name, help, labels, nil)
|
||||
ma.descCache[key] = descCacheEntry{d, time.Now()}
|
||||
return d
|
||||
}
|
||||
|
||||
// asPrometheus returns the annotatedMetric as a prometheus.Metric, it preallocates/fills by index, uses the aggregators
|
||||
// metric description cache, and a small stack buffer for values in order to reduce memory allocations.
|
||||
func (ma *MetricsAggregator) asPrometheus(am *annotatedMetric) (prometheus.Metric, error) {
|
||||
baseLabelNames := am.aggregateByLabels
|
||||
extraLabels := am.Labels
|
||||
|
||||
nBase := len(baseLabelNames)
|
||||
nExtra := len(extraLabels)
|
||||
nTotal := nBase + nExtra
|
||||
|
||||
var scratch [16]string
|
||||
var labelValues []string
|
||||
if nTotal <= len(scratch) {
|
||||
labelValues = scratch[:nTotal]
|
||||
} else {
|
||||
labelValues = make([]string, nTotal)
|
||||
}
|
||||
|
||||
for i, label := range baseLabelNames {
|
||||
val, err := am.getFieldByLabel(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelValues[i] = val
|
||||
}
|
||||
for i, l := range extraLabels {
|
||||
labelValues[nBase+i] = l.Value
|
||||
}
|
||||
|
||||
desc := ma.getOrCreateDesc(am.Name, metricHelpForAgent, baseLabelNames, extraLabels)
|
||||
valueType, err := asPrometheusValueType(am.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prometheus.MustNewConstMetric(desc, valueType, am.Value, labelValues...), nil
|
||||
}
|
||||
|
||||
var defaultAgentMetricsLabels = []string{agentmetrics.LabelUsername, agentmetrics.LabelWorkspaceName, agentmetrics.LabelAgentName, agentmetrics.LabelTemplateName}
|
||||
|
||||
// AgentMetricLabels are the labels used to decorate an agent's metrics.
|
||||
@@ -506,16 +453,6 @@ func (ma *MetricsAggregator) Update(ctx context.Context, labels AgentMetricLabel
|
||||
}
|
||||
}
|
||||
|
||||
// Move to a function for testability
|
||||
func (ma *MetricsAggregator) cleanupDescCache() {
|
||||
now := time.Now()
|
||||
for key, entry := range ma.descCache {
|
||||
if now.Sub(entry.lastUsed) > ma.metricsCleanupInterval {
|
||||
delete(ma.descCache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asPrometheusValueType(metricType agentproto.Stats_Metric_Type) (prometheus.ValueType, error) {
|
||||
switch metricType {
|
||||
case agentproto.Stats_Metric_GAUGE:
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package prometheusmetrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentmetrics"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestDescCache_DescExpire(t *testing.T) {
|
||||
const (
|
||||
testWorkspaceName = "yogi-workspace"
|
||||
testUsername = "yogi-bear"
|
||||
testAgentName = "main-agent"
|
||||
testTemplateName = "main-template"
|
||||
)
|
||||
|
||||
testLabels := AgentMetricLabels{
|
||||
Username: testUsername,
|
||||
WorkspaceName: testWorkspaceName,
|
||||
AgentName: testAgentName,
|
||||
TemplateName: testTemplateName,
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
// given
|
||||
registry := prometheus.NewRegistry()
|
||||
ma, err := NewMetricsAggregator(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), registry, time.Millisecond, agentmetrics.LabelAll)
|
||||
require.NoError(t, err)
|
||||
|
||||
given := []*agentproto.Stats_Metric{
|
||||
{Name: "a_counter_one", Type: agentproto.Stats_Metric_COUNTER, Value: 1},
|
||||
}
|
||||
|
||||
_, err = ma.asPrometheus(&annotatedMetric{
|
||||
given[0],
|
||||
testLabels.Username,
|
||||
testLabels.WorkspaceName,
|
||||
testLabels.AgentName,
|
||||
testLabels.TemplateName,
|
||||
// the rest doesn't matter for this test
|
||||
time.Now(),
|
||||
[]string{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
ma.cleanupDescCache()
|
||||
return len(ma.descCache) == 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
}
|
||||
|
||||
// TestDescCacheTimestampUpdate ensures that the timestamp update in getOrCreateDesc
|
||||
// updates the map entry because d is a copy, not a pointer.
|
||||
func TestDescCacheTimestampUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
ma, err := NewMetricsAggregator(slogtest.Make(t, nil), registry, time.Hour, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
baseLabelNames := []string{"label1", "label2"}
|
||||
extraLabels := []*agentproto.Stats_Metric_Label{
|
||||
{Name: "extra1", Value: "value1"},
|
||||
}
|
||||
|
||||
desc1 := ma.getOrCreateDesc("test_metric", "help text", baseLabelNames, extraLabels)
|
||||
require.NotNil(t, desc1)
|
||||
|
||||
key := cacheKeyForDesc("test_metric", baseLabelNames, extraLabels)
|
||||
initialEntry := ma.descCache[key]
|
||||
initialTime := initialEntry.lastUsed
|
||||
|
||||
desc2 := ma.getOrCreateDesc("test_metric", "help text", baseLabelNames, extraLabels)
|
||||
require.NotNil(t, desc2)
|
||||
|
||||
updatedEntry := ma.descCache[key]
|
||||
updatedTime := updatedEntry.lastUsed
|
||||
|
||||
require.NotEqual(t, initialTime, updatedTime,
|
||||
"Timestamp was NOT updated in map when accessing a metric description that should be cached")
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package prometheusmetrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentmetrics"
|
||||
)
|
||||
|
||||
@@ -38,52 +36,3 @@ func TestFilterAcceptableAgentLabels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchAsPrometheus(b *testing.B, base []string, extraN int) {
|
||||
am := annotatedMetric{
|
||||
Stats_Metric: &agentproto.Stats_Metric{
|
||||
Name: "blink_test_metric",
|
||||
Type: agentproto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Labels: make([]*agentproto.Stats_Metric_Label, extraN),
|
||||
},
|
||||
username: "user",
|
||||
workspaceName: "ws",
|
||||
agentName: "agent",
|
||||
templateName: "tmpl",
|
||||
aggregateByLabels: base,
|
||||
}
|
||||
for i := 0; i < extraN; i++ {
|
||||
am.Labels[i] = &agentproto.Stats_Metric_Label{Name: fmt.Sprintf("l%d", i), Value: "v"}
|
||||
}
|
||||
|
||||
ma := &MetricsAggregator{}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := ma.asPrometheus(&am)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Benchmark_asPrometheus(b *testing.B) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base []string
|
||||
extraN int
|
||||
}{
|
||||
{"base4_extra0", defaultAgentMetricsLabels, 0},
|
||||
{"base4_extra2", defaultAgentMetricsLabels, 2},
|
||||
{"base4_extra5", defaultAgentMetricsLabels, 5},
|
||||
{"base4_extra10", defaultAgentMetricsLabels, 10},
|
||||
{"base2_extra5", []string{agentmetrics.LabelUsername, agentmetrics.LabelWorkspaceName}, 5},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
benchAsPrometheus(b, tc.base, tc.extraN)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+61
-202
@@ -2,82 +2,39 @@ package taskname
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/aisdk-go"
|
||||
strutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultModel = anthropic.ModelClaude3_5HaikuLatest
|
||||
systemPrompt = `Generate a short task display name and name from this AI task prompt.
|
||||
Identify the main task (the core action and subject) and base both names on it.
|
||||
The task display name and name should be as similar as possible so a human can easily associate them.
|
||||
systemPrompt = `Generate a short workspace name from this AI task prompt.
|
||||
|
||||
Requirements for task display name (generate this first):
|
||||
- Human-readable description
|
||||
- Maximum 64 characters total
|
||||
- Should concisely describe the main task
|
||||
|
||||
Requirements for task name:
|
||||
- Should be derived from the display name
|
||||
Requirements:
|
||||
- Only lowercase letters, numbers, and hyphens
|
||||
- No spaces or underscores
|
||||
- Start with "task-"
|
||||
- Maximum 27 characters total
|
||||
- Should concisely describe the main task
|
||||
|
||||
Output format (must be valid JSON):
|
||||
{
|
||||
"display_name": "<display_name>",
|
||||
"task_name": "<task_name>"
|
||||
}
|
||||
- Descriptive of the main task
|
||||
|
||||
Examples:
|
||||
Prompt: "Help me debug a Python script" →
|
||||
{
|
||||
"display_name": "Debug Python script",
|
||||
"task_name": "python-debug"
|
||||
}
|
||||
- "Help me debug a Python script" → "task-python-debug"
|
||||
- "Create a React dashboard component" → "task-react-dashboard"
|
||||
- "Analyze sales data from Q3" → "task-analyze-q3-sales"
|
||||
- "Set up CI/CD pipeline" → "task-setup-cicd"
|
||||
|
||||
Prompt: "Create a React dashboard component" →
|
||||
{
|
||||
"display_name": "React dashboard component",
|
||||
"task_name": "react-dashboard"
|
||||
}
|
||||
|
||||
Prompt: "Analyze sales data from Q3" →
|
||||
{
|
||||
"display_name": "Analyze Q3 sales data",
|
||||
"task_name": "analyze-q3-sales"
|
||||
}
|
||||
|
||||
Prompt: "Set up CI/CD pipeline" →
|
||||
{
|
||||
"display_name": "CI/CD pipeline setup",
|
||||
"task_name": "setup-cicd"
|
||||
}
|
||||
|
||||
If a suitable name cannot be created, output exactly:
|
||||
{
|
||||
"display_name": "Task Unnamed",
|
||||
"task_name": "task-unnamed"
|
||||
}
|
||||
|
||||
Do not include any additional keys, explanations, or text outside the JSON.`
|
||||
If you cannot create a suitable name:
|
||||
- Respond with "task-unnamed"`
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -85,16 +42,30 @@ var (
|
||||
ErrNoNameGenerated = xerrors.New("no task name generated")
|
||||
)
|
||||
|
||||
type TaskName struct {
|
||||
Name string `json:"task_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
type options struct {
|
||||
apiKey string
|
||||
model anthropic.Model
|
||||
}
|
||||
|
||||
func getAnthropicAPIKeyFromEnv() string {
|
||||
type Option func(o *options)
|
||||
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
func WithModel(model anthropic.Model) Option {
|
||||
return func(o *options) {
|
||||
o.model = model
|
||||
}
|
||||
}
|
||||
|
||||
func GetAnthropicAPIKeyFromEnv() string {
|
||||
return os.Getenv("ANTHROPIC_API_KEY")
|
||||
}
|
||||
|
||||
func getAnthropicModelFromEnv() anthropic.Model {
|
||||
func GetAnthropicModelFromEnv() anthropic.Model {
|
||||
return anthropic.Model(os.Getenv("ANTHROPIC_MODEL"))
|
||||
}
|
||||
|
||||
@@ -108,85 +79,33 @@ func generateSuffix() string {
|
||||
return fmt.Sprintf("%04x", num)
|
||||
}
|
||||
|
||||
// generateFallback generates a random task name when other methods fail.
|
||||
// Uses Docker-style name generation with a collision-resistant suffix.
|
||||
func generateFallback() TaskName {
|
||||
func GenerateFallback() string {
|
||||
// We have a 32 character limit for the name.
|
||||
// We have a 5 character prefix `task-`.
|
||||
// We have a 5 character suffix `-ffff`.
|
||||
// This leaves us with 27 characters for the name.
|
||||
// This leaves us with 22 characters for the middle.
|
||||
//
|
||||
// `namesgenerator.GetRandomName(0)` can generate names
|
||||
// up to 27 characters, but we truncate defensively.
|
||||
// Unfortunately, `namesgenerator.GetRandomName(0)` will
|
||||
// generate names that are longer than 22 characters, so
|
||||
// we just trim these down to length.
|
||||
name := strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
|
||||
name = name[:min(len(name), 27)]
|
||||
name = name[:min(len(name), 22)]
|
||||
name = strings.TrimSuffix(name, "-")
|
||||
|
||||
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
|
||||
displayName := strings.ReplaceAll(name, "-", " ")
|
||||
if len(displayName) > 0 {
|
||||
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
|
||||
}
|
||||
|
||||
return TaskName{
|
||||
Name: taskName,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
return fmt.Sprintf("task-%s-%s", name, generateSuffix())
|
||||
}
|
||||
|
||||
// generateFromPrompt creates a task name directly from the prompt by sanitizing it.
|
||||
// This is used as a fallback when Claude fails to generate a name.
|
||||
func generateFromPrompt(prompt string) (TaskName, error) {
|
||||
// Normalize newlines and tabs to spaces
|
||||
prompt = regexp.MustCompile(`[\n\r\t]+`).ReplaceAllString(prompt, " ")
|
||||
|
||||
// Truncate prompt to 27 chars with full words for task name generation
|
||||
truncatedForName := prompt
|
||||
if len(prompt) > 27 {
|
||||
truncatedForName = strutil.Truncate(prompt, 27, strutil.TruncateWithFullWords)
|
||||
func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
o := options{}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
// Generate task name from truncated prompt
|
||||
name := strings.ToLower(truncatedForName)
|
||||
// Replace whitespace (\t \r \n and spaces) sequences with hyphens
|
||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, "-")
|
||||
// Remove all characters except lowercase letters, numbers, and hyphens
|
||||
name = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(name, "")
|
||||
// Collapse multiple consecutive hyphens into a single hyphen
|
||||
name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-")
|
||||
// Remove leading and trailing hyphens
|
||||
name = strings.Trim(name, "-")
|
||||
|
||||
if len(name) == 0 {
|
||||
return TaskName{}, ErrNoNameGenerated
|
||||
if o.model == "" {
|
||||
o.model = defaultModel
|
||||
}
|
||||
|
||||
taskName := fmt.Sprintf("%s-%s", name, generateSuffix())
|
||||
|
||||
// Use the initial prompt as display name, truncated to 64 chars with full words
|
||||
displayName := strutil.Truncate(prompt, 64, strutil.TruncateWithFullWords, strutil.TruncateWithEllipsis)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if len(displayName) == 0 {
|
||||
// Ensure display name is never empty
|
||||
displayName = strings.ReplaceAll(name, "-", " ")
|
||||
}
|
||||
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
|
||||
|
||||
return TaskName{
|
||||
Name: taskName,
|
||||
DisplayName: displayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateFromAnthropic uses Claude (Anthropic) to generate semantic task and display names from a user prompt.
|
||||
// It sends the prompt to Claude with a structured system prompt requesting JSON output containing both names.
|
||||
// Returns an error if the API call fails, the response is invalid, or Claude returns an "unnamed" placeholder.
|
||||
func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, model anthropic.Model) (TaskName, error) {
|
||||
anthropicModel := model
|
||||
if anthropicModel == "" {
|
||||
anthropicModel = defaultModel
|
||||
}
|
||||
if apiKey == "" {
|
||||
return TaskName{}, ErrNoAPIKey
|
||||
if o.apiKey == "" {
|
||||
return "", ErrNoAPIKey
|
||||
}
|
||||
|
||||
conversation := []aisdk.Message{
|
||||
@@ -207,95 +126,42 @@ func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, mo
|
||||
}
|
||||
|
||||
anthropicOptions := anthropic.DefaultClientOptions()
|
||||
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(apiKey))
|
||||
anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey))
|
||||
anthropicClient := anthropic.NewClient(anthropicOptions...)
|
||||
|
||||
stream, err := anthropicDataStream(ctx, anthropicClient, anthropicModel, conversation)
|
||||
stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation)
|
||||
if err != nil {
|
||||
return TaskName{}, xerrors.Errorf("create anthropic data stream: %w", err)
|
||||
return "", xerrors.Errorf("create anthropic data stream: %w", err)
|
||||
}
|
||||
|
||||
var acc aisdk.DataStreamAccumulator
|
||||
stream = stream.WithAccumulator(&acc)
|
||||
|
||||
if err := stream.Pipe(io.Discard); err != nil {
|
||||
return TaskName{}, xerrors.Errorf("pipe data stream")
|
||||
return "", xerrors.Errorf("pipe data stream")
|
||||
}
|
||||
|
||||
if len(acc.Messages()) == 0 {
|
||||
return TaskName{}, ErrNoNameGenerated
|
||||
return "", ErrNoNameGenerated
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
var taskNameResponse TaskName
|
||||
if err := json.Unmarshal([]byte(acc.Messages()[0].Content), &taskNameResponse); err != nil {
|
||||
return TaskName{}, xerrors.Errorf("failed to parse anthropic response: %w", err)
|
||||
}
|
||||
|
||||
taskNameResponse.Name = strings.TrimSpace(taskNameResponse.Name)
|
||||
taskNameResponse.DisplayName = strings.TrimSpace(taskNameResponse.DisplayName)
|
||||
|
||||
if taskNameResponse.Name == "" || taskNameResponse.Name == "task-unnamed" {
|
||||
return TaskName{}, xerrors.Errorf("anthropic returned invalid task name: %q", taskNameResponse.Name)
|
||||
}
|
||||
|
||||
if taskNameResponse.DisplayName == "" || taskNameResponse.DisplayName == "Task Unnamed" {
|
||||
return TaskName{}, xerrors.Errorf("anthropic returned invalid task display name: %q", taskNameResponse.DisplayName)
|
||||
taskName := acc.Messages()[0].Content
|
||||
if taskName == "task-unnamed" {
|
||||
return "", ErrNoNameGenerated
|
||||
}
|
||||
|
||||
// We append a suffix to the end of the task name to reduce
|
||||
// the chance of collisions. We truncate the task name to
|
||||
// a maximum of 27 bytes, so that when we append the
|
||||
// to a maximum of 27 bytes, so that when we append the
|
||||
// 5 byte suffix (`-` and 4 byte hex slug), it should
|
||||
// remain within the 32 byte workspace name limit.
|
||||
name := taskNameResponse.Name[:min(len(taskNameResponse.Name), 27)]
|
||||
name = strings.TrimSuffix(name, "-")
|
||||
name = fmt.Sprintf("%s-%s", name, generateSuffix())
|
||||
if err := codersdk.NameValid(name); err != nil {
|
||||
return TaskName{}, xerrors.Errorf("generated name %v not valid: %w", name, err)
|
||||
taskName = taskName[:min(len(taskName), 27)]
|
||||
taskName = fmt.Sprintf("%s-%s", taskName, generateSuffix())
|
||||
if err := codersdk.NameValid(taskName); err != nil {
|
||||
return "", xerrors.Errorf("generated name %v not valid: %w", taskName, err)
|
||||
}
|
||||
|
||||
displayName := taskNameResponse.DisplayName
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if len(displayName) == 0 {
|
||||
// Ensure display name is never empty
|
||||
displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ")
|
||||
}
|
||||
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
|
||||
|
||||
return TaskName{
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate creates a task name and display name from a user prompt.
|
||||
// It attempts multiple strategies in order of preference:
|
||||
// 1. Use Claude (Anthropic) to generate semantic names from the prompt if an API key is available
|
||||
// 2. Sanitize the prompt directly into a valid task name
|
||||
// 3. Generate a random name as a final fallback
|
||||
//
|
||||
// A suffix is always appended to task names to reduce collision risk.
|
||||
// This function always succeeds and returns a valid TaskName.
|
||||
func Generate(ctx context.Context, logger slog.Logger, prompt string) TaskName {
|
||||
if anthropicAPIKey := getAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" {
|
||||
taskName, err := generateFromAnthropic(ctx, prompt, anthropicAPIKey, getAnthropicModelFromEnv())
|
||||
if err == nil {
|
||||
return taskName
|
||||
}
|
||||
// Anthropic failed, fall through to next fallback
|
||||
logger.Error(ctx, "unable to generate task name and display name from Anthropic", slog.Error(err))
|
||||
}
|
||||
|
||||
// Try generating from prompt
|
||||
taskName, err := generateFromPrompt(prompt)
|
||||
if err == nil {
|
||||
return taskName
|
||||
}
|
||||
logger.Warn(ctx, "unable to generate task name and display name from prompt", slog.Error(err))
|
||||
|
||||
// Final fallback
|
||||
return generateFallback()
|
||||
return taskName, nil
|
||||
}
|
||||
|
||||
func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) {
|
||||
@@ -305,15 +171,8 @@ func anthropicDataStream(ctx context.Context, client anthropic.Client, model ant
|
||||
}
|
||||
|
||||
return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
|
||||
Model: model,
|
||||
// MaxTokens is set to 100 based on the maximum expected output size.
|
||||
// The worst-case JSON output is 134 characters:
|
||||
// - Base structure: 43 chars (including formatting)
|
||||
// - task_name: 27 chars max
|
||||
// - display_name: 64 chars max
|
||||
// Using Anthropic's token counting API, this worst-case output tokenizes to 70 tokens.
|
||||
// We set MaxTokens to 100 to provide a safety buffer.
|
||||
MaxTokens: 100,
|
||||
Model: model,
|
||||
MaxTokens: 24,
|
||||
System: system,
|
||||
Messages: messages,
|
||||
})), nil
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package taskname
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestGenerateFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
taskName := generateFallback()
|
||||
err := codersdk.NameValid(taskName.Name)
|
||||
require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", taskName.Name)
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
}
|
||||
|
||||
func TestGenerateFromPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
expectError bool
|
||||
expectedName string
|
||||
expectedDisplayName string
|
||||
}{
|
||||
{
|
||||
name: "EmptyPrompt",
|
||||
prompt: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "OnlySpaces",
|
||||
prompt: " ",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "OnlySpecialCharacters",
|
||||
prompt: "!@#$%^&*()",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "UppercasePrompt",
|
||||
prompt: "BUILD MY APP",
|
||||
expectError: false,
|
||||
expectedName: "build-my-app",
|
||||
expectedDisplayName: "BUILD MY APP",
|
||||
},
|
||||
{
|
||||
name: "PromptWithApostrophes",
|
||||
prompt: "fix user's dashboard",
|
||||
expectError: false,
|
||||
expectedName: "fix-users-dashboard",
|
||||
expectedDisplayName: "Fix user's dashboard",
|
||||
},
|
||||
{
|
||||
name: "LongPrompt",
|
||||
prompt: strings.Repeat("a", 100),
|
||||
expectError: false,
|
||||
expectedName: strings.Repeat("a", 27),
|
||||
expectedDisplayName: "A" + strings.Repeat("a", 62) + "…",
|
||||
},
|
||||
{
|
||||
name: "PromptWithMultipleSpaces",
|
||||
prompt: "build my app",
|
||||
expectError: false,
|
||||
expectedName: "build-my-app",
|
||||
expectedDisplayName: "Build my app",
|
||||
},
|
||||
{
|
||||
name: "PromptWithNewlines",
|
||||
prompt: "build\nmy\napp",
|
||||
expectError: false,
|
||||
expectedName: "build-my-app",
|
||||
expectedDisplayName: "Build my app",
|
||||
},
|
||||
{
|
||||
name: "TruncatesLongPromptAtWordBoundary",
|
||||
prompt: "implement real-time notifications dashboard",
|
||||
expectError: false,
|
||||
expectedName: "implement-real-time",
|
||||
expectedDisplayName: "Implement real-time notifications dashboard",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
taskName, err := generateFromPrompt(tc.prompt)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate task name
|
||||
require.Contains(t, taskName.Name, fmt.Sprintf("%s-", tc.expectedName))
|
||||
require.NoError(t, codersdk.NameValid(taskName.Name))
|
||||
|
||||
// Validate task display name
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
require.Equal(t, tc.expectedDisplayName, taskName.DisplayName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromAnthropic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiKey := getAnthropicAPIKeyFromEnv()
|
||||
if apiKey == "" {
|
||||
t.Skip("Skipping test as ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
}{
|
||||
{
|
||||
name: "SimplePrompt",
|
||||
prompt: "Create a finance planning app",
|
||||
},
|
||||
{
|
||||
name: "TechnicalPrompt",
|
||||
prompt: "Debug authentication middleware for OAuth2",
|
||||
},
|
||||
{
|
||||
name: "ShortPrompt",
|
||||
prompt: "Fix bug",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
taskName, err := generateFromAnthropic(ctx, tc.prompt, apiKey, getAnthropicModelFromEnv())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Log("Task name:", taskName.Name)
|
||||
t.Log("Task display name:", taskName.DisplayName)
|
||||
|
||||
// Validate task name
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
require.NoError(t, codersdk.NameValid(taskName.Name))
|
||||
|
||||
// Validate display name
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
require.NotEqual(t, "task-unnamed", taskName.Name)
|
||||
require.NotEqual(t, "Task Unnamed", taskName.DisplayName)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,51 +15,42 @@ const (
|
||||
anthropicEnvVar = "ANTHROPIC_API_KEY"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
t.Run("FromPrompt", func(t *testing.T) {
|
||||
// Ensure no API key in env for this test
|
||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||
func TestGenerateFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := taskname.GenerateFallback()
|
||||
err := codersdk.NameValid(name)
|
||||
require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", name)
|
||||
}
|
||||
|
||||
func TestGenerateTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Fallback", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app")
|
||||
|
||||
// Should succeed via prompt sanitization
|
||||
require.NoError(t, codersdk.NameValid(taskName.Name))
|
||||
require.Contains(t, taskName.Name, "create-a-finance-planning-")
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
require.Equal(t, "Create a finance planning app", taskName.DisplayName)
|
||||
name, err := taskname.Generate(ctx, "Some random prompt")
|
||||
require.ErrorIs(t, err, taskname.ErrNoAPIKey)
|
||||
require.Equal(t, "", name)
|
||||
})
|
||||
|
||||
t.Run("FromAnthropic", func(t *testing.T) {
|
||||
t.Run("Anthropic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiKey := os.Getenv(anthropicEnvVar)
|
||||
if apiKey == "" {
|
||||
t.Skipf("Skipping test as %s not set", anthropicEnvVar)
|
||||
}
|
||||
|
||||
// Set API key for this test
|
||||
t.Setenv("ANTHROPIC_API_KEY", apiKey)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
taskName := taskname.Generate(ctx, testutil.Logger(t), "Create a finance planning app")
|
||||
name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey))
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, "", name)
|
||||
|
||||
// Should succeed with Claude-generated names
|
||||
require.NoError(t, codersdk.NameValid(taskName.Name))
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
})
|
||||
|
||||
t.Run("Fallback", func(t *testing.T) {
|
||||
// Ensure no API key
|
||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Use a prompt that can't be sanitized (only special chars)
|
||||
taskName := taskname.Generate(ctx, testutil.Logger(t), "!@#$%^&*()")
|
||||
|
||||
// Should fall back to random name
|
||||
require.NoError(t, codersdk.NameValid(taskName.Name))
|
||||
require.NotEmpty(t, taskName.DisplayName)
|
||||
err = codersdk.NameValid(name)
|
||||
require.NoError(t, err, "name should be valid")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,17 +388,16 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
|
||||
// Treat the message as untrusted input.
|
||||
cleaned := strutil.UISanitize(req.Message)
|
||||
|
||||
// Get the latest status for the workspace app to detect no-op updates
|
||||
// Get the latest statuses for the workspace app to detect no-op updates
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
latestAppStatus, err := api.Database.GetLatestWorkspaceAppStatusByAppID(dbauthz.AsSystemRestricted(ctx), app.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
latestAppStatus, err := api.Database.GetLatestWorkspaceAppStatusesByAppID(dbauthz.AsSystemRestricted(ctx), app.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get latest workspace app status.",
|
||||
Message: "Failed to get latest workspace app statuses.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// If no rows found, latestAppStatus will be a zero-value struct (ID == uuid.Nil)
|
||||
|
||||
// nolint:gocritic // This is a system restricted operation.
|
||||
_, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{
|
||||
@@ -443,7 +442,7 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
|
||||
func (api *API) enqueueAITaskStateNotification(
|
||||
ctx context.Context,
|
||||
appID uuid.UUID,
|
||||
latestAppStatus database.WorkspaceAppStatus,
|
||||
latestAppStatus []database.WorkspaceAppStatus,
|
||||
newAppStatus codersdk.WorkspaceAppStatusState,
|
||||
workspace database.Workspace,
|
||||
agent database.WorkspaceAgent,
|
||||
@@ -493,16 +492,14 @@ func (api *API) enqueueAITaskStateNotification(
|
||||
}
|
||||
|
||||
// Skip if the latest persisted state equals the new state (no new transition)
|
||||
// Note: uuid.Nil check is valid here. If no previous status exists,
|
||||
// GetLatestWorkspaceAppStatusByAppID returns sql.ErrNoRows and we get a zero-value struct.
|
||||
if latestAppStatus.ID != uuid.Nil && latestAppStatus.State == database.WorkspaceAppStatusState(newAppStatus) {
|
||||
if len(latestAppStatus) > 0 && latestAppStatus[0].State == database.WorkspaceAppStatusState(newAppStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the initial "Working" notification when task first starts.
|
||||
// This is obvious to the user since they just created the task.
|
||||
// We still notify on first "Idle" status and all subsequent transitions.
|
||||
if latestAppStatus.ID == uuid.Nil && newAppStatus == codersdk.WorkspaceAppStatusStateWorking {
|
||||
if len(latestAppStatus) == 0 && newAppStatus == codersdk.WorkspaceAppStatusStateWorking {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+202
-73
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -932,45 +934,17 @@ func TestWorkspaceAgentTailnetDirectDisabled(t *testing.T) {
|
||||
require.False(t, p2p)
|
||||
}
|
||||
|
||||
type fakeListeningPortsGetter struct {
|
||||
sync.Mutex
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
}
|
||||
|
||||
func (g *fakeListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
g.Lock()
|
||||
defer g.Unlock()
|
||||
return slices.Clone(g.ports), nil
|
||||
}
|
||||
|
||||
func (g *fakeListeningPortsGetter) setPorts(ports ...codersdk.WorkspaceAgentListeningPort) {
|
||||
g.Lock()
|
||||
defer g.Unlock()
|
||||
g.ports = slices.Clone(ports)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testPort := codersdk.WorkspaceAgentListeningPort{
|
||||
Network: "tcp",
|
||||
ProcessName: "test-app",
|
||||
Port: 44762,
|
||||
}
|
||||
filteredPort := codersdk.WorkspaceAgentListeningPort{
|
||||
Network: "tcp",
|
||||
ProcessName: "postgres",
|
||||
Port: 5432,
|
||||
}
|
||||
|
||||
setup := func(t *testing.T, apps []*proto.App, dv *codersdk.DeploymentValues) (*codersdk.Client, uuid.UUID, *fakeListeningPortsGetter) {
|
||||
setup := func(t *testing.T, apps []*proto.App, dv *codersdk.DeploymentValues) (*codersdk.Client, uint16, uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
})
|
||||
|
||||
fLPG := &fakeListeningPortsGetter{}
|
||||
coderdPort, err := strconv.Atoi(client.URL.Port())
|
||||
require.NoError(t, err)
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
@@ -981,73 +955,228 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
return agents
|
||||
}).Do()
|
||||
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
|
||||
o.ListeningPortsGetter = fLPG
|
||||
o.PortCacheDuration = time.Millisecond
|
||||
})
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
|
||||
return client, resources[0].Agents[0].ID, fLPG
|
||||
return client, uint16(coderdPort), resources[0].Agents[0].ID
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setDV func(t *testing.T, dv *codersdk.DeploymentValues)
|
||||
}{
|
||||
{
|
||||
name: "Mainline",
|
||||
setDV: func(*testing.T, *codersdk.DeploymentValues) {},
|
||||
},
|
||||
{
|
||||
name: "BlockDirect",
|
||||
setDV: func(t *testing.T, dv *codersdk.DeploymentValues) {
|
||||
err := dv.DERP.Config.BlockDirect.Set("true")
|
||||
require.NoError(t, err)
|
||||
require.True(t, dv.DERP.Config.BlockDirect.Value())
|
||||
willFilterPort := func(port int) bool {
|
||||
if port < workspacesdk.AgentMinimumListeningPort || port > 65535 {
|
||||
return true
|
||||
}
|
||||
if _, ok := workspacesdk.AgentIgnoredListeningPorts[uint16(port)]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
generateUnfilteredPort := func(t *testing.T) (net.Listener, uint16) {
|
||||
var (
|
||||
l net.Listener
|
||||
port uint16
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
l, err = net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
tcpAddr, _ := l.Addr().(*net.TCPAddr)
|
||||
if willFilterPort(tcpAddr.Port) {
|
||||
_ = l.Close()
|
||||
return false
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
})
|
||||
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
|
||||
port = uint16(tcpAddr.Port)
|
||||
return true
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
return l, port
|
||||
}
|
||||
|
||||
generateFilteredPort := func(t *testing.T) (net.Listener, uint16) {
|
||||
var (
|
||||
l net.Listener
|
||||
port uint16
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
for ignoredPort := range workspacesdk.AgentIgnoredListeningPorts {
|
||||
if ignoredPort < 1024 || ignoredPort == 5432 {
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
l, err = net.Listen("tcp", fmt.Sprintf("localhost:%d", ignoredPort))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
})
|
||||
|
||||
port = ignoredPort
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
return l, port
|
||||
}
|
||||
|
||||
t.Run("LinuxAndWindows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
|
||||
t.Skip("only runs on linux and windows")
|
||||
return
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setDV func(t *testing.T, dv *codersdk.DeploymentValues)
|
||||
}{
|
||||
{
|
||||
name: "Mainline",
|
||||
setDV: func(*testing.T, *codersdk.DeploymentValues) {},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("OK_"+tc.name, func(t *testing.T) {
|
||||
{
|
||||
name: "BlockDirect",
|
||||
setDV: func(t *testing.T, dv *codersdk.DeploymentValues) {
|
||||
err := dv.DERP.Config.BlockDirect.Set("true")
|
||||
require.NoError(t, err)
|
||||
require.True(t, dv.DERP.Config.BlockDirect.Value())
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("OK_"+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
tc.setDV(t, dv)
|
||||
client, coderdPort, agentID := setup(t, nil, dv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Generate a random unfiltered port.
|
||||
l, lPort := generateUnfilteredPort(t)
|
||||
|
||||
// List ports and ensure that the port we expect to see is there.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := map[uint16]bool{
|
||||
// expect the listener we made
|
||||
lPort: false,
|
||||
// expect the coderdtest server
|
||||
coderdPort: false,
|
||||
}
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == "tcp" {
|
||||
if val, ok := expected[port.Port]; ok {
|
||||
if val {
|
||||
t.Fatalf("expected to find TCP port %d only once in response", port.Port)
|
||||
}
|
||||
}
|
||||
expected[port.Port] = true
|
||||
}
|
||||
}
|
||||
for port, found := range expected {
|
||||
if !found {
|
||||
t.Fatalf("expected to find TCP port %d in response", port)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the listener and check that the port is no longer in the response.
|
||||
require.NoError(t, l.Close())
|
||||
t.Log("checking for ports after listener close:")
|
||||
require.Eventually(t, func() bool {
|
||||
res, err = client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == "tcp" && port.Port == lPort {
|
||||
t.Logf("expected to not find TCP port %d in response", lPort)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
tc.setDV(t, dv)
|
||||
client, agentID, fLPG := setup(t, nil, dv)
|
||||
// Generate an unfiltered port that we will create an app for and
|
||||
// should not exist in the response.
|
||||
_, appLPort := generateUnfilteredPort(t)
|
||||
app := &proto.App{
|
||||
Slug: "test-app",
|
||||
Url: fmt.Sprintf("http://localhost:%d", appLPort),
|
||||
}
|
||||
|
||||
// Generate a filtered port that should not exist in the response.
|
||||
_, filteredLPort := generateFilteredPort(t)
|
||||
|
||||
client, coderdPort, agentID := setup(t, []*proto.App{app}, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
fLPG.setPorts(testPort)
|
||||
|
||||
// List ports and ensure that the port we expect to see is there.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []codersdk.WorkspaceAgentListeningPort{testPort}, res.Ports)
|
||||
|
||||
// Remove the port and check that the port is no longer in the response.
|
||||
fLPG.setPorts()
|
||||
res, err = client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res.Ports)
|
||||
sawCoderdPort := false
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == "tcp" {
|
||||
if port.Port == appLPort {
|
||||
t.Fatalf("expected to not find TCP port (app port) %d in response", appLPort)
|
||||
}
|
||||
if port.Port == filteredLPort {
|
||||
t.Fatalf("expected to not find TCP port (filtered port) %d in response", filteredLPort)
|
||||
}
|
||||
if port.Port == coderdPort {
|
||||
sawCoderdPort = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawCoderdPort {
|
||||
t.Fatalf("expected to find TCP port (coderd port) %d in response", coderdPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Run("Darwin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := &proto.App{
|
||||
Slug: testPort.ProcessName,
|
||||
Url: fmt.Sprintf("http://localhost:%d", testPort.Port),
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("only runs on darwin")
|
||||
return
|
||||
}
|
||||
|
||||
client, agentID, fLPG := setup(t, []*proto.App{app}, nil)
|
||||
client, _, agentID := setup(t, nil, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
fLPG.setPorts(testPort, filteredPort)
|
||||
// Create a TCP listener on a random port.
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
// List ports and ensure that the list is empty because we're on darwin.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res.Ports)
|
||||
require.Len(t, res.Ports, 0)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Optional:
|
||||
UpdateAgentMetricsFn: api.UpdateAgentMetrics,
|
||||
}, workspace)
|
||||
})
|
||||
|
||||
streamID := tailnet.StreamID{
|
||||
Name: fmt.Sprintf("%s-%s-%s", workspace.OwnerUsername, workspace.Name, workspaceAgent.Name),
|
||||
|
||||
@@ -1717,13 +1717,13 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
// if err != nil {
|
||||
// httpapi.InternalServerError(rw, err)
|
||||
// return
|
||||
// }
|
||||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), database.WorkspaceIdentityFromWorkspace(workspace), agent, stat, true)
|
||||
err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), workspace, agent, template.Name, stat, true)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
|
||||
@@ -4800,6 +4800,7 @@ func TestWorkspaceListTasks(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
@@ -4822,7 +4823,7 @@ func TestWorkspaceListTasks(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceWithoutTask.LatestBuild.ID)
|
||||
|
||||
// Given: a workspace associated with a task
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "Some task prompt",
|
||||
})
|
||||
|
||||
@@ -120,7 +120,7 @@ func (r *Reporter) ReportAppStats(ctx context.Context, stats []workspaceapps.Sta
|
||||
}
|
||||
|
||||
// nolint:revive // usage is a control flag while we have the experiment
|
||||
func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspace database.WorkspaceIdentity, workspaceAgent database.WorkspaceAgent, stats *agentproto.Stats, usage bool) error {
|
||||
func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspace database.Workspace, workspaceAgent database.WorkspaceAgent, templateName string, stats *agentproto.Stats, usage bool) error {
|
||||
// update agent stats
|
||||
r.opts.StatsBatcher.Add(now, workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, stats, usage)
|
||||
|
||||
@@ -130,7 +130,7 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac
|
||||
Username: workspace.OwnerUsername,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: workspaceAgent.Name,
|
||||
TemplateName: workspace.TemplateName,
|
||||
TemplateName: templateName,
|
||||
}, stats.Metrics)
|
||||
}
|
||||
|
||||
|
||||
+54
-38
@@ -28,17 +28,20 @@ import (
|
||||
const AITaskPromptParameterName = provider.TaskPromptParameterName
|
||||
|
||||
// CreateTaskRequest represents the request to create a new task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type CreateTaskRequest struct {
|
||||
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
|
||||
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
|
||||
Input string `json:"input"`
|
||||
Name string `json:"name,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTask creates a new task.
|
||||
func (c *Client) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Task, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/tasks/%s", user), request)
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
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 Task{}, err
|
||||
}
|
||||
@@ -57,6 +60,8 @@ func (c *Client) CreateTask(ctx context.Context, user string, request CreateTask
|
||||
}
|
||||
|
||||
// TaskStatus represents the status of a task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
@@ -93,6 +98,8 @@ func AllTaskStatuses() []TaskStatus {
|
||||
}
|
||||
|
||||
// TaskState represents the high-level lifecycle of a task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskState string
|
||||
|
||||
// TaskState enums.
|
||||
@@ -112,6 +119,8 @@ const (
|
||||
)
|
||||
|
||||
// Task represents a task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type Task struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid" table:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"`
|
||||
@@ -119,7 +128,6 @@ type Task struct {
|
||||
OwnerName string `json:"owner_name" table:"owner name"`
|
||||
OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"owner avatar url"`
|
||||
Name string `json:"name" table:"name,default_sort"`
|
||||
DisplayName string `json:"display_name" table:"display_name"`
|
||||
TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"`
|
||||
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid" table:"template version id"`
|
||||
TemplateName string `json:"template_name" table:"template name"`
|
||||
@@ -141,6 +149,8 @@ type Task struct {
|
||||
}
|
||||
|
||||
// TaskStateEntry represents a single entry in the task's state history.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskStateEntry struct {
|
||||
Timestamp time.Time `json:"timestamp" format:"date-time" table:"-"`
|
||||
State TaskState `json:"state" enum:"working,idle,completed,failed" table:"state"`
|
||||
@@ -149,6 +159,8 @@ type TaskStateEntry struct {
|
||||
}
|
||||
|
||||
// TasksFilter filters the list of tasks.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TasksFilter struct {
|
||||
// Owner can be a username, UUID, or "me".
|
||||
Owner string `json:"owner,omitempty"`
|
||||
@@ -161,6 +173,8 @@ type TasksFilter struct {
|
||||
}
|
||||
|
||||
// TaskListResponse is the response shape for tasks list.
|
||||
//
|
||||
// Experimental response shape for tasks list (server returns []Task).
|
||||
type TasksListResponse struct {
|
||||
Tasks []Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
@@ -192,12 +206,14 @@ func (f TasksFilter) asRequestOption() RequestOption {
|
||||
}
|
||||
|
||||
// Tasks lists all tasks belonging to the user or specified owner.
|
||||
func (c *Client) Tasks(ctx context.Context, filter *TasksFilter) ([]Task, error) {
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]Task, error) {
|
||||
if filter == nil {
|
||||
filter = &TasksFilter{}
|
||||
}
|
||||
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/tasks", nil, filter.asRequestOption())
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, filter.asRequestOption())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -214,10 +230,12 @@ func (c *Client) Tasks(ctx context.Context, filter *TasksFilter) ([]Task, error)
|
||||
return tres.Tasks, nil
|
||||
}
|
||||
|
||||
// TaskByID fetches a single task by its ID.
|
||||
// TaskByID fetches a single experimental task by its ID.
|
||||
// Only tasks owned by codersdk.Me are supported.
|
||||
func (c *Client) TaskByID(ctx context.Context, id uuid.UUID) (Task, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/tasks/%s/%s", "me", id.String()), nil)
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s", "me", id.String()), nil)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
@@ -234,12 +252,14 @@ func (c *Client) TaskByID(ctx context.Context, id uuid.UUID) (Task, error) {
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// TaskByOwnerAndName fetches a single task by its owner and name.
|
||||
func (c *Client) TaskByOwnerAndName(ctx context.Context, owner, ident string) (Task, error) {
|
||||
// TaskByOwnerAndName fetches a single experimental task by its owner and name.
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
func (c *ExperimentalClient) TaskByOwnerAndName(ctx context.Context, owner, ident string) (Task, error) {
|
||||
if owner == "" {
|
||||
owner = Me
|
||||
}
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/tasks/%s/%s", owner, ident), nil)
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s", owner, ident), nil)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
@@ -278,7 +298,7 @@ func splitTaskIdentifier(identifier string) (owner string, taskName string, err
|
||||
//
|
||||
// Since there is no TaskByOwnerAndName endpoint yet, this function uses the
|
||||
// list endpoint with filtering when a name is provided.
|
||||
func (c *Client) TaskByIdentifier(ctx context.Context, identifier string) (Task, error) {
|
||||
func (c *ExperimentalClient) TaskByIdentifier(ctx context.Context, identifier string) (Task, error) {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
|
||||
// Try parsing as UUID first.
|
||||
@@ -296,8 +316,10 @@ func (c *Client) TaskByIdentifier(ctx context.Context, identifier string) (Task,
|
||||
}
|
||||
|
||||
// DeleteTask deletes a task by its ID.
|
||||
func (c *Client) DeleteTask(ctx context.Context, user string, id uuid.UUID) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/tasks/%s/%s", user, id.String()), nil)
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uuid.UUID) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/tasks/%s/%s", user, id.String()), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -309,31 +331,17 @@ func (c *Client) DeleteTask(ctx context.Context, user string, id uuid.UUID) erro
|
||||
}
|
||||
|
||||
// TaskSendRequest is used to send task input to the tasks sidebar app.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskSendRequest struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
// TaskSend submits task input to the tasks sidebar app.
|
||||
func (c *Client) TaskSend(ctx context.Context, user string, id uuid.UUID, req TaskSendRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/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
|
||||
}
|
||||
|
||||
// UpdateTaskInputRequest is used to update a task's input.
|
||||
type UpdateTaskInputRequest struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
// UpdateTaskInput updates the task's input.
|
||||
func (c *Client) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID, req UpdateTaskInputRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/tasks/%s/%s/input", user, id.String()), req)
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
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
|
||||
}
|
||||
@@ -345,6 +353,8 @@ func (c *Client) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID,
|
||||
}
|
||||
|
||||
// TaskLogType indicates the source of a task log entry.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskLogType string
|
||||
|
||||
// TaskLogType enums.
|
||||
@@ -354,6 +364,8 @@ const (
|
||||
)
|
||||
|
||||
// TaskLogEntry represents a single log entry for a task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskLogEntry struct {
|
||||
ID int `json:"id" table:"id"`
|
||||
Content string `json:"content" table:"content"`
|
||||
@@ -362,13 +374,17 @@ type TaskLogEntry struct {
|
||||
}
|
||||
|
||||
// TaskLogsResponse contains the logs for a task.
|
||||
//
|
||||
// Experimental: This type is experimental and may change in the future.
|
||||
type TaskLogsResponse struct {
|
||||
Logs []TaskLogEntry `json:"logs"`
|
||||
}
|
||||
|
||||
// TaskLogs retrieves logs from the task app.
|
||||
func (c *Client) TaskLogs(ctx context.Context, user string, id uuid.UUID) (TaskLogsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/tasks/%s/%s/logs", user, id.String()), nil)
|
||||
// TaskLogs retrieves logs from the task's sidebar app via the experimental API.
|
||||
//
|
||||
// Experimental: This method is experimental and may change in the future.
|
||||
func (c *ExperimentalClient) TaskLogs(ctx context.Context, user string, id uuid.UUID) (TaskLogsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s/logs", user, id.String()), nil)
|
||||
if err != nil {
|
||||
return TaskLogsResponse{}, err
|
||||
}
|
||||
|
||||
@@ -1899,7 +1899,8 @@ var CreateTask = Tool[CreateTaskArgs, codersdk.Task]{
|
||||
args.User = codersdk.Me
|
||||
}
|
||||
|
||||
task, err := deps.coderClient.CreateTask(ctx, args.User, codersdk.CreateTaskRequest{
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
task, err := expClient.CreateTask(ctx, args.User, codersdk.CreateTaskRequest{
|
||||
Input: args.Input,
|
||||
TemplateVersionID: tvID,
|
||||
TemplateVersionPresetID: tvPresetID,
|
||||
@@ -1936,12 +1937,14 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{
|
||||
return codersdk.Response{}, xerrors.New("task_id is required")
|
||||
}
|
||||
|
||||
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
|
||||
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, xerrors.Errorf("resolve task: %w", err)
|
||||
}
|
||||
|
||||
err = deps.coderClient.DeleteTask(ctx, task.OwnerName, task.ID)
|
||||
err = expClient.DeleteTask(ctx, task.OwnerName, task.ID)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, xerrors.Errorf("delete task: %w", err)
|
||||
}
|
||||
@@ -1985,7 +1988,8 @@ var ListTasks = Tool[ListTasksArgs, ListTasksResponse]{
|
||||
args.User = codersdk.Me
|
||||
}
|
||||
|
||||
tasks, err := deps.coderClient.Tasks(ctx, &codersdk.TasksFilter{
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{
|
||||
Owner: args.User,
|
||||
Status: args.Status,
|
||||
})
|
||||
@@ -2028,7 +2032,9 @@ var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{
|
||||
return GetTaskStatusResponse{}, xerrors.New("task_id is required")
|
||||
}
|
||||
|
||||
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
|
||||
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
if err != nil {
|
||||
return GetTaskStatusResponse{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
|
||||
}
|
||||
@@ -2073,12 +2079,14 @@ var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{
|
||||
return codersdk.Response{}, xerrors.New("input is required")
|
||||
}
|
||||
|
||||
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
|
||||
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
|
||||
}
|
||||
|
||||
err = deps.coderClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{
|
||||
err = expClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{
|
||||
Input: args.Input,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2115,12 +2123,14 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{
|
||||
return codersdk.TaskLogsResponse{}, xerrors.New("task_id is required")
|
||||
}
|
||||
|
||||
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
expClient := codersdk.NewExperimentalClient(deps.coderClient)
|
||||
|
||||
task, err := expClient.TaskByIdentifier(ctx, args.TaskID)
|
||||
if err != nil {
|
||||
return codersdk.TaskLogsResponse{}, err
|
||||
}
|
||||
|
||||
logs, err := deps.coderClient.TaskLogs(ctx, task.OwnerName, task.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)
|
||||
}
|
||||
|
||||
@@ -1025,8 +1025,11 @@ func TestTools(t *testing.T) {
|
||||
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.
|
||||
_, err := client.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{
|
||||
_, err := expClient.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "task for member",
|
||||
Name: "list-task-workspace-member",
|
||||
@@ -1036,7 +1039,7 @@ func TestTools(t *testing.T) {
|
||||
// Create tasks for taskUser. These should show up in the list.
|
||||
for i := range 5 {
|
||||
taskName := fmt.Sprintf("list-task-workspace-%d", i)
|
||||
task, err := taskClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
task, err := taskExpClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: fmt.Sprintf("task %d", i),
|
||||
Name: taskName,
|
||||
|
||||
@@ -32,7 +32,7 @@ We track the following resources:
|
||||
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| PrebuildsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>reconciliation_paused</td><td>true</td></tr></tbody></table> |
|
||||
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>use_terraform_workspace_cache</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
# Configure a template for Dev Containers
|
||||
# Configure a template for dev containers
|
||||
|
||||
To enable Dev Containers in workspaces, configure your template with the Dev Containers
|
||||
To enable dev containers in workspaces, configure your template with the dev containers
|
||||
modules and configurations outlined in this doc.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Dev Containers require a **Linux or macOS workspace**. Windows is not supported.
|
||||
|
||||
## Configuration Modes
|
||||
|
||||
There are two approaches to configuring Dev Containers in Coder:
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
Use the [`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer) Terraform resource to explicitly define which Dev
|
||||
Containers should be started in your workspace. This approach provides:
|
||||
|
||||
- Predictable behavior and explicit control
|
||||
- Clear template configuration
|
||||
- Easier troubleshooting
|
||||
- Better for production environments
|
||||
|
||||
This is the recommended approach for most use cases.
|
||||
|
||||
### Project Discovery
|
||||
|
||||
Enable automatic discovery of Dev Containers in Git repositories. Project discovery automatically scans Git repositories for `.devcontainer/devcontainer.json` or `.devcontainer.json` files and surfaces them in the Coder UI. See the [Environment Variables](#environment-variables) section for detailed configuration options.
|
||||
|
||||
## Install the Dev Containers CLI
|
||||
|
||||
Use the
|
||||
@@ -47,7 +23,7 @@ Alternatively, install the devcontainer CLI manually in your base image.
|
||||
|
||||
The
|
||||
[`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer)
|
||||
resource automatically starts a Dev Container in your workspace, ensuring it's
|
||||
resource automatically starts a dev container in your workspace, ensuring it's
|
||||
ready when you access the workspace:
|
||||
|
||||
```terraform
|
||||
@@ -74,140 +50,30 @@ resource "coder_devcontainer" "my-repository" {
|
||||
|
||||
## Enable Dev Containers Integration
|
||||
|
||||
Dev Containers integration is **enabled by default** in Coder 2.24.0 and later.
|
||||
You don't need to set any environment variables unless you want to change the
|
||||
default behavior.
|
||||
|
||||
If you need to explicitly disable Dev Containers, set the
|
||||
`CODER_AGENT_DEVCONTAINERS_ENABLE` environment variable to `false`:
|
||||
To enable the dev containers integration in your workspace, you must set the
|
||||
`CODER_AGENT_DEVCONTAINERS_ENABLE` environment variable to `true` in your
|
||||
workspace container:
|
||||
|
||||
```terraform
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = "codercom/oss-dogfood:latest"
|
||||
env = [
|
||||
"CODER_AGENT_DEVCONTAINERS_ENABLE=false", # Explicitly disable
|
||||
"CODER_AGENT_DEVCONTAINERS_ENABLE=true",
|
||||
# ... Other environment variables.
|
||||
]
|
||||
# ... Other container configuration.
|
||||
}
|
||||
```
|
||||
|
||||
See the [Environment Variables](#environment-variables) section below for more
|
||||
details on available configuration options.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables control Dev Container behavior in your
|
||||
workspace. Both `CODER_AGENT_DEVCONTAINERS_ENABLE` and
|
||||
`CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE` are **enabled by default**,
|
||||
so you typically don't need to set them unless you want to explicitly disable
|
||||
the feature.
|
||||
|
||||
### CODER_AGENT_DEVCONTAINERS_ENABLE
|
||||
|
||||
**Default: `true`** • **Added in: v2.24.0**
|
||||
|
||||
Enables the Dev Containers integration in the Coder agent.
|
||||
|
||||
The Dev Containers feature is enabled by default. You can explicitly disable it
|
||||
by setting this to `false`.
|
||||
|
||||
### CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE
|
||||
|
||||
**Default: `true`** • **Added in: v2.25.0**
|
||||
|
||||
Enables automatic discovery of Dev Containers in Git repositories.
|
||||
|
||||
When enabled, the agent will:
|
||||
|
||||
- Scan the agent directory for Git repositories
|
||||
- Look for `.devcontainer/devcontainer.json` or `.devcontainer.json` files
|
||||
- Surface discovered Dev Containers automatically in the Coder UI
|
||||
- Respect `.gitignore` patterns during discovery
|
||||
|
||||
You can disable automatic discovery by setting this to `false` if you prefer to
|
||||
use only the `coder_devcontainer` resource for explicit configuration.
|
||||
|
||||
### CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE
|
||||
|
||||
**Default: `false`** • **Added in: v2.25.0**
|
||||
|
||||
Automatically starts Dev Containers discovered via project discovery.
|
||||
|
||||
When enabled, discovered Dev Containers will be automatically built and started
|
||||
during workspace initialization. This only applies to Dev Containers found via
|
||||
project discovery. Dev Containers defined with the `coder_devcontainer` resource
|
||||
always auto-start regardless of this setting.
|
||||
|
||||
## Per-Container Customizations
|
||||
|
||||
Individual Dev Containers can be customized using the `customizations.coder` block
|
||||
in your `devcontainer.json` file. These customizations allow you to control
|
||||
container-specific behavior without modifying your template.
|
||||
|
||||
### Ignore Specific Containers
|
||||
|
||||
Use the `ignore` option to hide a Dev Container from Coder completely:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"ignore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `ignore` is set to `true`:
|
||||
|
||||
- The Dev Container won't appear in the Coder UI
|
||||
- Coder won't manage or monitor the container
|
||||
|
||||
This is useful when you have Dev Containers in your repository that you don't
|
||||
want Coder to manage.
|
||||
|
||||
### Per-Container Auto-Start
|
||||
|
||||
Control whether individual Dev Containers should auto-start using the
|
||||
`autoStart` option:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Container",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"customizations": {
|
||||
"coder": {
|
||||
"autoStart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `autoStart` option only applies when global auto-start is
|
||||
enabled via `CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE=true`. If
|
||||
the global setting is disabled, containers won't auto-start regardless of this
|
||||
setting.
|
||||
|
||||
When `autoStart` is set to `true`:
|
||||
|
||||
- The Dev Container automatically builds and starts during workspace
|
||||
initialization
|
||||
- Works on a per-container basis (you can enable it for some containers but not
|
||||
others)
|
||||
|
||||
When `autoStart` is set to `false` or omitted:
|
||||
|
||||
- The Dev Container is discovered and shown in the UI
|
||||
- Users must manually start it via the UI
|
||||
This environment variable is required for the Coder agent to detect and manage
|
||||
dev containers. Without it, the agent will not attempt to start or connect to
|
||||
dev containers even if the `coder_devcontainer` resource is defined.
|
||||
|
||||
## Complete Template Example
|
||||
|
||||
Here's a simplified template example that uses Dev Containers with manual
|
||||
configuration:
|
||||
Here's a simplified template example that enables the dev containers
|
||||
integration:
|
||||
|
||||
```terraform
|
||||
terraform {
|
||||
@@ -241,38 +107,18 @@ resource "coder_devcontainer" "my-repository" {
|
||||
agent_id = coder_agent.dev.id
|
||||
workspace_folder = "/home/coder/my-repository"
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative: Project Discovery Mode
|
||||
|
||||
You can enable automatic starting of discovered Dev Containers:
|
||||
|
||||
```terraform
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = "codercom/oss-dogfood:latest"
|
||||
env = [
|
||||
# Project discovery is enabled by default, but autostart is not.
|
||||
# Enable autostart to automatically build and start discovered containers:
|
||||
"CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE=true",
|
||||
"CODER_AGENT_DEVCONTAINERS_ENABLE=true",
|
||||
# ... Other environment variables.
|
||||
]
|
||||
# ... Other container configuration.
|
||||
}
|
||||
```
|
||||
|
||||
With this configuration:
|
||||
|
||||
- Project discovery is enabled (default behavior)
|
||||
- Discovered containers are automatically started (via the env var)
|
||||
- The `coder_devcontainer` resource is **not** required
|
||||
- Developers can work with multiple projects seamlessly
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> When using project discovery, you still need to install the devcontainers CLI
|
||||
> using the module or in your base image.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Dev Containers Integration](../../../user-guides/devcontainers/index.md)
|
||||
|
||||
@@ -94,23 +94,23 @@ Users can generate a long-lived API key from the Coder UI or CLI. Follow the ins
|
||||
|
||||
The table below shows tested AI clients and their compatibility with AI Bridge. Click each client name for vendor-specific configuration instructions. Report issues or share compatibility updates in the [aibridge](https://github.com/coder/aibridge) issue tracker.
|
||||
|
||||
| Client | OpenAI support | Anthropic support | Notes |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------|----------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [Claude Code](https://docs.claude.com/en/docs/claude-code/settings#environment-variables) | - | ✅ | Works out of the box and can be preconfigured in templates. |
|
||||
| Claude Code (VS Code) | - | ✅ | May require signing in once; afterwards respects workspace environment variables. |
|
||||
| Cursor | ❌ | ❌ | Support dropped for `v1/chat/completions` endpoints; `v1/responses` support is in progress [#16](https://github.com/coder/aibridge/issues/16) |
|
||||
| [Roo Code](https://docs.roocode.com/features/api-configuration-profiles#creating-and-managing-profiles) | ✅ | ✅ | Use the **OpenAI Compatible** provider with the legacy format to avoid `/v1/responses`. |
|
||||
| [Codex CLI](https://github.com/openai/codex/blob/main/docs/config.md#model_providers) | ✅ | N/A | `gpt-5-codex` support is [in progress](https://github.com/coder/aibridge/issues/16). |
|
||||
| [GitHub Copilot (VS Code)](https://code.visualstudio.com/docs/copilot/customization/language-models#_add-an-openaicompatible-model) | ✅ | ❌ | Requires the pre-release extension. Anthropic endpoints are not supported. |
|
||||
| [Goose](https://block.github.io/goose/docs/getting-started/providers/#available-providers) | ❓ | ❓ | |
|
||||
| [Goose Desktop](https://block.github.io/goose/docs/getting-started/providers/#available-providers) | ❓ | ✅ | |
|
||||
| WindSurf | ❌ | ❌ | No option to override the base URL. |
|
||||
| Sourcegraph Amp | ❌ | ❌ | No option to override the base URL. |
|
||||
| Kiro | ❌ | ❌ | No option to override the base URL. |
|
||||
| [Copilot CLI](https://github.com/github/copilot-cli/issues/104) | ❌ | ❌ | No support for custom base URLs and uses a `GITHUB_TOKEN` for authentication. |
|
||||
| [Kilo Code](https://kilocode.ai/docs/features/api-configuration-profiles#creating-and-managing-profiles) | ✅ | ✅ | Similar to Roo Code. |
|
||||
| Gemini CLI | ❌ | ❌ | Not supported yet. |
|
||||
| [Amazon Q CLI](https://aws.amazon.com/q/) | ❌ | ❌ | Limited to Amazon Q subscriptions; no custom endpoint support. |
|
||||
| Client | OpenAI support | Anthropic support | Notes |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------|----------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [Claude Code](https://docs.claude.com/en/docs/claude-code/settings#environment-variables) | - | ✅ | Works out of the box and can be preconfigured in templates. |
|
||||
| Claude Code (VS Code) | - | ✅ | May require signing in once; afterwards respects workspace environment variables. |
|
||||
| [Cursor](https://cursor.com/docs/settings/api-keys) | ⚠️ | ❌ | Only non-reasoning models like `gpt-4.1` are available when using a custom endpoint. Requests still transit Cursor's cloud. There is no central admin setting to configure this. |
|
||||
| [Roo Code](https://docs.roocode.com/features/api-configuration-profiles#creating-and-managing-profiles) | ✅ | ✅ | Use the **OpenAI Compatible** provider with the legacy format to avoid `/v1/responses`. |
|
||||
| [Codex CLI](https://github.com/openai/codex/blob/main/docs/config.md#model_providers) | ✅ | N/A | `gpt-5-codex` support is [in progress](https://github.com/coder/aibridge/issues/16). |
|
||||
| [GitHub Copilot (VS Code)](https://code.visualstudio.com/docs/copilot/customization/language-models#_add-an-openaicompatible-model) | ✅ | ❌ | Requires the pre-release extension. Anthropic endpoints are not supported. |
|
||||
| [Goose](https://block.github.io/goose/docs/getting-started/providers/#available-providers) | ❓ | ❓ | |
|
||||
| [Goose Desktop](https://block.github.io/goose/docs/getting-started/providers/#available-providers) | ❓ | ✅ | |
|
||||
| WindSurf | ❌ | ❌ | No option to override the base URL. |
|
||||
| Sourcegraph Amp | ❌ | ❌ | No option to override the base URL. |
|
||||
| Kiro | ❌ | ❌ | No option to override the base URL. |
|
||||
| [Copilot CLI](https://github.com/github/copilot-cli/issues/104) | ❌ | ❌ | No support for custom base URLs and uses a `GITHUB_TOKEN` for authentication. |
|
||||
| [Kilo Code](https://kilocode.ai/docs/features/api-configuration-profiles#creating-and-managing-profiles) | ✅ | ✅ | Similar to Roo Code. |
|
||||
| Gemini CLI | ❌ | ❌ | Not supported yet. |
|
||||
| [Amazon Q CLI](https://aws.amazon.com/q/) | ❌ | ❌ | Limited to Amazon Q subscriptions; no custom endpoint support. |
|
||||
|
||||
Legend: ✅ works, ⚠️ limited support, ❌ not supported, ❓ not yet verified, — not applicable.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
AI Bridge is a smart gateway for AI. It acts as an intermediary between your users' coding agents / IDEs
|
||||
AI Bridge is a smart proxy for AI. It acts as a man-in-the-middle between your users' coding agents / IDEs
|
||||
and providers like OpenAI and Anthropic. By intercepting all the AI traffic between these clients and
|
||||
the upstream APIs, AI Bridge can record user prompts, token usage, and tool invocations.
|
||||
|
||||
|
||||
@@ -33,10 +33,6 @@ Set the following when routing [OpenAI-compatible](https://coder.com/docs/refere
|
||||
|
||||
The default base URL (`https://api.openai.com/v1/`) works for the native OpenAI service. Point the base URL at your preferred OpenAI-compatible endpoint (for example, a hosted proxy or LiteLLM deployment) when needed.
|
||||
|
||||
If you'd like to create an [OpenAI key](https://platform.openai.com/api-keys) with minimal privileges, this is the minimum required set:
|
||||
|
||||

|
||||
|
||||
### Anthropic
|
||||
|
||||
Set the following when routing [Anthropic-compatible](https://coder.com/docs/reference/cli/server#--aibridge-anthropic-key) traffic through AI Bridge:
|
||||
@@ -46,8 +42,6 @@ Set the following when routing [Anthropic-compatible](https://coder.com/docs/ref
|
||||
|
||||
The default base URL (`https://api.anthropic.com/`) targets Anthropic's public API. Override it for Anthropic-compatible brokers.
|
||||
|
||||
Anthropic does not allow [API keys](https://console.anthropic.com/settings/keys) to have restricted permissions at the time of writing (Nov 2025).
|
||||
|
||||
### Amazon Bedrock
|
||||
|
||||
Set the following when routing [Amazon Bedrock](https://coder.com/docs/reference/cli/server#--aibridge-bedrock-region) traffic through AI Bridge:
|
||||
|
||||
+226
-9
@@ -1,13 +1,230 @@
|
||||
# Tasks CLI
|
||||
|
||||
The Tasks CLI documentation has moved to the auto-generated CLI reference pages:
|
||||
The Coder CLI provides experimental commands for managing tasks programmatically. These are available under `coder exp task`:
|
||||
|
||||
- [task](../reference/cli/task.md) - Main tasks command
|
||||
- [task create](../reference/cli/task_create.md) - Create a task
|
||||
- [task delete](../reference/cli/task_delete.md) - Delete tasks
|
||||
- [task list](../reference/cli/task_list.md) - List tasks
|
||||
- [task logs](../reference/cli/task_logs.md) - Show task logs
|
||||
- [task send](../reference/cli/task_send.md) - Send input to a task
|
||||
- [task status](../reference/cli/task_status.md) - Show task status
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task
|
||||
|
||||
For the complete CLI reference, see the [CLI documentation](../reference/cli/index.md).
|
||||
Experimental task commands.
|
||||
|
||||
Aliases: tasks
|
||||
|
||||
SUBCOMMANDS:
|
||||
create Create an experimental task
|
||||
delete Delete experimental tasks
|
||||
list List experimental tasks
|
||||
logs Show a task's logs
|
||||
send Send input to a task
|
||||
status Show the status of a task.
|
||||
```
|
||||
|
||||
## Creating tasks
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task create [flags] [input]
|
||||
|
||||
Create an experimental task
|
||||
|
||||
- Create a task with direct input:
|
||||
|
||||
$ coder exp task create "Add authentication to the user service"
|
||||
|
||||
- Create a task with stdin input:
|
||||
|
||||
$ echo "Add authentication to the user service" | coder exp task create
|
||||
|
||||
- Create a task with a specific name:
|
||||
|
||||
$ coder exp task create --name task1 "Add authentication to the user service"
|
||||
|
||||
- Create a task from a specific template / preset:
|
||||
|
||||
$ coder exp task create --template backend-dev --preset "My Preset" "Add authentication to the user service"
|
||||
|
||||
- Create a task for another user (requires appropriate permissions):
|
||||
|
||||
$ coder exp task create --owner user@example.com "Add authentication to the user service"
|
||||
|
||||
OPTIONS:
|
||||
-O, --org string, $CODER_ORGANIZATION
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
--name string
|
||||
Specify the name of the task. If you do not specify one, a name will be generated for you.
|
||||
|
||||
--owner string (default: me)
|
||||
Specify the owner of the task. Defaults to the current user.
|
||||
|
||||
--preset string, $CODER_TASK_PRESET_NAME (default: none)
|
||||
-q, --quiet bool
|
||||
Only display the created task's ID.
|
||||
|
||||
--stdin bool
|
||||
Reads from stdin for the task input.
|
||||
|
||||
--template string, $CODER_TASK_TEMPLATE_NAME
|
||||
--template-version string, $CODER_TASK_TEMPLATE_VERSION
|
||||
```
|
||||
|
||||
## Deleting Tasks
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task delete [flags] <task> [<task> ...]
|
||||
|
||||
Delete experimental tasks
|
||||
|
||||
Aliases: rm
|
||||
|
||||
- Delete a single task.:
|
||||
|
||||
$ $ coder exp task delete task1
|
||||
|
||||
- Delete multiple tasks.:
|
||||
|
||||
$ $ coder exp task delete task1 task2 task3
|
||||
|
||||
- Delete a task without confirmation.:
|
||||
|
||||
$ $ coder exp task delete task4 --yes
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
```
|
||||
|
||||
## Listing tasks
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task list [flags]
|
||||
|
||||
List experimental tasks
|
||||
|
||||
Aliases: ls
|
||||
|
||||
- List tasks for the current user.:
|
||||
|
||||
$ coder exp task list
|
||||
|
||||
- List tasks for a specific user.:
|
||||
|
||||
$ coder exp task list --user someone-else
|
||||
|
||||
- List all tasks you can view.:
|
||||
|
||||
$ coder exp task list --all
|
||||
|
||||
- List all your running tasks.:
|
||||
|
||||
$ coder exp task list --status running
|
||||
|
||||
- As above, but only show IDs.:
|
||||
|
||||
$ coder exp task list --status running --quiet
|
||||
|
||||
OPTIONS:
|
||||
-a, --all bool (default: false)
|
||||
List tasks for all users you can view.
|
||||
|
||||
-c, --column [id|organization id|owner id|owner name|name|template id|template name|template display name|template icon|workspace id|workspace agent id|workspace agent lifecycle|workspace agent health|initial prompt|status|state|message|created at|updated at|state changed] (default: name,status,state,state changed,message)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
-q, --quiet bool (default: false)
|
||||
Only display task IDs.
|
||||
|
||||
--status string
|
||||
Filter by task status (e.g. running, failed, etc).
|
||||
|
||||
--user string
|
||||
List tasks for the specified user (username, "me").
|
||||
```
|
||||
|
||||
## Viewing Task Logs
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task logs [flags] <task>
|
||||
|
||||
Show a task's logs
|
||||
|
||||
- Show logs for a given task.:
|
||||
|
||||
$ coder exp task logs task1
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [id|content|type|time] (default: type,content)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
```
|
||||
|
||||
## Sending input to a task
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task send [flags] <task> [<input> | --stdin]
|
||||
|
||||
Send input to a task
|
||||
|
||||
- Send direct input to a task.:
|
||||
|
||||
$ coder exp task send task1 "Please also add unit tests"
|
||||
|
||||
- Send input from stdin to a task.:
|
||||
|
||||
$ echo "Please also add unit tests" | coder exp task send task1 --stdin
|
||||
|
||||
OPTIONS:
|
||||
--stdin bool
|
||||
Reads the input from stdin.
|
||||
```
|
||||
|
||||
## Viewing Task Status
|
||||
|
||||
```console
|
||||
USAGE:
|
||||
coder exp task status [flags]
|
||||
|
||||
Show the status of a task.
|
||||
|
||||
Aliases: stat
|
||||
|
||||
- Show the status of a given task.:
|
||||
|
||||
$ coder exp task status task1
|
||||
|
||||
- Watch the status of a given task until it completes (idle or stopped).:
|
||||
|
||||
$ coder exp task status task1 --watch
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [state changed|status|healthy|state|message] (default: state changed,status,healthy,state,message)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
--watch bool (default: false)
|
||||
Watch the task status output. This will stream updates to the terminal until the underlying workspace is stopped.
|
||||
```
|
||||
|
||||
> **Note**: The `--watch` flag will automatically exit when the task reaches a terminal state. Watch mode ends when:
|
||||
>
|
||||
> - The workspace is stopped
|
||||
> - The workspace agent becomes unhealthy or is shutting down
|
||||
> - The task completes (reaches a non-working state like completed, failed, or canceled)
|
||||
|
||||
## Identifying Tasks
|
||||
|
||||
Tasks can be identified in CLI commands using either:
|
||||
|
||||
- **Task Name**: The human-readable name (e.g., `my-task-name`)
|
||||
> Note: Tasks owned by other users can be identified by their owner and name (e.g., `alice/her-task`).
|
||||
- **Task ID**: The UUID identifier (e.g., `550e8400-e29b-41d4-a716-446655440000`)
|
||||
|
||||
@@ -8,11 +8,11 @@ Coder [integrates with IDEs](../user-guides/workspace-access/index.md) such as C
|
||||
|
||||
These agents work well inside existing Coder workspaces as they can simply be enabled via an extension or are built-into the editor.
|
||||
|
||||
## Agents with Coder Tasks
|
||||
## Agents with Coder Tasks (Beta)
|
||||
|
||||
In cases where the IDE is secondary, such as prototyping or long-running background jobs, agents like Claude Code or Aider are better for the job and new SaaS interfaces like [Devin](https://devin.ai) and [ChatGPT Codex](https://openai.com/index/introducing-codex/) are emerging.
|
||||
|
||||
[Coder Tasks](./tasks.md) is an interface inside Coder to run and manage coding agents with a chat-based UI. Unlike SaaS-based products, Coder Tasks is self-hosted (included in your Coder deployment) and allows you to run any terminal-based agent such as Claude Code or Codex's Open Source CLI.
|
||||
[Coder Tasks](./tasks.md) is a new interface inside Coder to run and manage coding agents with a chat-based UI. Unlike SaaS-based products, Coder Tasks is self-hosted (included in your Coder deployment) and allows you to run any terminal-based agent such as Claude Code or Codex's Open Source CLI.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Coder Tasks
|
||||
# Coder Tasks (Beta)
|
||||
|
||||
Coder Tasks is an interface for running & managing coding agents such as Claude Code and Aider, powered by Coder workspaces.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
+4
-39
@@ -1166,6 +1166,10 @@
|
||||
"title": "Enterprise",
|
||||
"path": "./reference/api/enterprise.md"
|
||||
},
|
||||
{
|
||||
"title": "Experimental",
|
||||
"path": "./reference/api/experimental.md"
|
||||
},
|
||||
{
|
||||
"title": "Files",
|
||||
"path": "./reference/api/files.md"
|
||||
@@ -1210,10 +1214,6 @@
|
||||
"title": "Schemas",
|
||||
"path": "./reference/api/schemas.md"
|
||||
},
|
||||
{
|
||||
"title": "Tasks",
|
||||
"path": "./reference/api/tasks.md"
|
||||
},
|
||||
{
|
||||
"title": "Templates",
|
||||
"path": "./reference/api/templates.md"
|
||||
@@ -1771,41 +1771,6 @@
|
||||
"description": "Generate a support bundle to troubleshoot issues connecting to a workspace.",
|
||||
"path": "reference/cli/support_bundle.md"
|
||||
},
|
||||
{
|
||||
"title": "task",
|
||||
"description": "Manage tasks",
|
||||
"path": "reference/cli/task.md"
|
||||
},
|
||||
{
|
||||
"title": "task create",
|
||||
"description": "Create a task",
|
||||
"path": "reference/cli/task_create.md"
|
||||
},
|
||||
{
|
||||
"title": "task delete",
|
||||
"description": "Delete tasks",
|
||||
"path": "reference/cli/task_delete.md"
|
||||
},
|
||||
{
|
||||
"title": "task list",
|
||||
"description": "List tasks",
|
||||
"path": "reference/cli/task_list.md"
|
||||
},
|
||||
{
|
||||
"title": "task logs",
|
||||
"description": "Show a task's logs",
|
||||
"path": "reference/cli/task_logs.md"
|
||||
},
|
||||
{
|
||||
"title": "task send",
|
||||
"description": "Send input to a task",
|
||||
"path": "reference/cli/task_send.md"
|
||||
},
|
||||
{
|
||||
"title": "task status",
|
||||
"description": "Show the status of a task.",
|
||||
"path": "reference/cli/task_status.md"
|
||||
},
|
||||
{
|
||||
"title": "templates",
|
||||
"description": "Manage templates",
|
||||
|
||||
Generated
+204
@@ -0,0 +1,204 @@
|
||||
# Experimental
|
||||
|
||||
## List AI tasks
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \
|
||||
-H 'Accept: */*' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/experimental/tasks`
|
||||
|
||||
### Parameters
|
||||
|
||||
| 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
|
||||
|
||||
> 200 Response
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TasksListResponse](schemas.md#codersdktaskslistresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Create a new AI task
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: */*' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/experimental/tasks/{user}`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "string",
|
||||
"name": "string",
|
||||
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
|
||||
"template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------------------------------------------------------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `body` | body | [codersdk.CreateTaskRequest](schemas.md#codersdkcreatetaskrequest) | true | Create task request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Task](schemas.md#codersdktask) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get AI task by ID
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \
|
||||
-H 'Accept: */*' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/experimental/tasks/{user}/{task}`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `task` | path | string(uuid) | true | Task ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Task](schemas.md#codersdktask) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Delete AI task by ID
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`DELETE /api/experimental/tasks/{user}/{task}`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `task` | path | string(uuid) | true | Task ID |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------------|-------------------------|--------|
|
||||
| 202 | [Accepted](https://tools.ietf.org/html/rfc7231#section-6.3.3) | Task deletion initiated | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get AI task logs
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/logs \
|
||||
-H 'Accept: */*' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/experimental/tasks/{user}/{task}/logs`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `task` | path | string(uuid) | true | Task ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TaskLogsResponse](schemas.md#codersdktasklogsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Send input to AI task
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/send \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/experimental/tasks/{user}/{task}/send`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|----------------------------------------------------------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `task` | path | string(uuid) | true | Task ID |
|
||||
| `body` | body | [codersdk.TaskSendRequest](schemas.md#codersdktasksendrequest) | true | Task input request |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-----------------------------------------------------------------|-------------------------|--------|
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | Input sent successfully | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user