Ctrl K
ComunidadGuías…

codersdk/workspaceagents.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package codersdk

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/coderd/tracing"
	"github.com/coder/coder/v2/codersdk/wsjson"
	"github.com/coder/websocket"
)

type WorkspaceAgentStatus string

// This is also in database/modelmethods.go and should be kept in sync.
const (
	WorkspaceAgentConnecting   WorkspaceAgentStatus = "connecting"
	WorkspaceAgentConnected    WorkspaceAgentStatus = "connected"
	WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
	WorkspaceAgentTimeout      WorkspaceAgentStatus = "timeout"
)

// WorkspaceAgentLifecycle represents the lifecycle state of a workspace agent.
//
// The agent lifecycle starts in the "created" state, and transitions to
// "starting" when the agent reports it has begun preparing (e.g. started
// executing the startup script).
type WorkspaceAgentLifecycle string

// WorkspaceAgentLifecycle enums.
const (
	WorkspaceAgentLifecycleCreated         WorkspaceAgentLifecycle = "created"
	WorkspaceAgentLifecycleStarting        WorkspaceAgentLifecycle = "starting"
	WorkspaceAgentLifecycleStartTimeout    WorkspaceAgentLifecycle = "start_timeout"
	WorkspaceAgentLifecycleStartError      WorkspaceAgentLifecycle = "start_error"
	WorkspaceAgentLifecycleReady           WorkspaceAgentLifecycle = "ready"
	WorkspaceAgentLifecycleShuttingDown    WorkspaceAgentLifecycle = "shutting_down"
	WorkspaceAgentLifecycleShutdownTimeout WorkspaceAgentLifecycle = "shutdown_timeout"
	WorkspaceAgentLifecycleShutdownError   WorkspaceAgentLifecycle = "shutdown_error"
	WorkspaceAgentLifecycleOff             WorkspaceAgentLifecycle = "off"
)

// Starting returns true if the agent is in the process of starting.
func (l WorkspaceAgentLifecycle) Starting() bool {
	switch l {
	case WorkspaceAgentLifecycleCreated, WorkspaceAgentLifecycleStarting:
		return true
	default:
		return false
	}
}

// ShuttingDown returns true if the agent is in the process of shutting
// down or has shut down.
func (l WorkspaceAgentLifecycle) ShuttingDown() bool {
	switch l {
	case WorkspaceAgentLifecycleShuttingDown, WorkspaceAgentLifecycleShutdownTimeout, WorkspaceAgentLifecycleShutdownError, WorkspaceAgentLifecycleOff:
		return true
	default:
		return false
	}
}

// WorkspaceAgentLifecycleOrder is the order in which workspace agent
// lifecycle states are expected to be reported during the lifetime of
// the agent process. For instance, the agent can go from starting to
// ready without reporting timeout or error, but it should not go from
// ready to starting. This is merely a hint for the agent process, and
// is not enforced by the server.
var WorkspaceAgentLifecycleOrder = []WorkspaceAgentLifecycle{
	WorkspaceAgentLifecycleCreated,
	WorkspaceAgentLifecycleStarting,
	WorkspaceAgentLifecycleStartTimeout,
	WorkspaceAgentLifecycleStartError,
	WorkspaceAgentLifecycleReady,
	WorkspaceAgentLifecycleShuttingDown,
	WorkspaceAgentLifecycleShutdownTimeout,
	WorkspaceAgentLifecycleShutdownError,
	WorkspaceAgentLifecycleOff,
}

// WorkspaceAgentStartupScriptBehavior defines whether or not the startup script
// should be considered blocking or non-blocking. The blocking behavior means
// that the agent will not be considered ready until the startup script has
// completed and, for example, SSH connections will wait for the agent to be
// ready (can be overridden).
//
// Presently, non-blocking is the default, but this may change in the future.
// Deprecated: `coder_script` allows configuration on a per-script basis.
type WorkspaceAgentStartupScriptBehavior string

const (
	WorkspaceAgentStartupScriptBehaviorBlocking    WorkspaceAgentStartupScriptBehavior = "blocking"
	WorkspaceAgentStartupScriptBehaviorNonBlocking WorkspaceAgentStartupScriptBehavior = "non-blocking"
)

type WorkspaceAgentMetadataResult struct {
	CollectedAt time.Time `json:"collected_at" format:"date-time"`
	// Age is the number of seconds since the metadata was collected.
	// It is provided in addition to CollectedAt to protect against clock skew.
	Age   int64  `json:"age"`
	Value string `json:"value"`
	Error string `json:"error"`
}

// WorkspaceAgentMetadataDescription is a description of dynamic metadata the agent should report
// back to coderd. It is provided via the `metadata` list in the `coder_agent`
// block.
type WorkspaceAgentMetadataDescription struct {
	DisplayName string `json:"display_name"`
	Key         string `json:"key"`
	Script      string `json:"script"`
	Interval    int64  `json:"interval"`
	Timeout     int64  `json:"timeout"`
}

type WorkspaceAgentMetadata struct {
	Result      WorkspaceAgentMetadataResult      `json:"result"`
	Description WorkspaceAgentMetadataDescription `json:"description"`
}

type DisplayApp string

const (
	DisplayAppVSCodeDesktop  DisplayApp = "vscode"
	DisplayAppVSCodeInsiders DisplayApp = "vscode_insiders"
	DisplayAppWebTerminal    DisplayApp = "web_terminal"
	DisplayAppPortForward    DisplayApp = "port_forwarding_helper"
	DisplayAppSSH            DisplayApp = "ssh_helper"
)

type WorkspaceAgent struct {
	ID               uuid.UUID               `json:"id" format:"uuid"`
	ParentID         uuid.NullUUID           `json:"parent_id" format:"uuid"`
	CreatedAt        time.Time               `json:"created_at" format:"date-time"`
	UpdatedAt        time.Time               `json:"updated_at" format:"date-time"`
	FirstConnectedAt *time.Time              `json:"first_connected_at,omitempty" format:"date-time"`
	LastConnectedAt  *time.Time              `json:"last_connected_at,omitempty" format:"date-time"`
	DisconnectedAt   *time.Time              `json:"disconnected_at,omitempty" format:"date-time"`
	StartedAt        *time.Time              `json:"started_at,omitempty" format:"date-time"`
	ReadyAt          *time.Time              `json:"ready_at,omitempty" format:"date-time"`
	Status           WorkspaceAgentStatus    `json:"status"`
	LifecycleState   WorkspaceAgentLifecycle `json:"lifecycle_state"`
	Name             string                  `json:"name"`
	ResourceID       uuid.UUID               `json:"resource_id" format:"uuid"`
	InstanceID       string                  `json:"instance_id,omitempty"`
	Architecture     string                  `json:"architecture"`
	// EnvironmentVariables lists the variables set on the agent. Non-empty
	// values are redacted as "***REDACTED***" because they often hold credentials.
	EnvironmentVariables map[string]string `json:"environment_variables"`
	OperatingSystem      string            `json:"operating_system"`
	LogsLength           int32             `json:"logs_length"`
	LogsOverflowed       bool              `json:"logs_overflowed"`
	Directory            string            `json:"directory,omitempty"`
	ExpandedDirectory    string            `json:"expanded_directory,omitempty"`
	Version              string            `json:"version"`
	APIVersion           string            `json:"api_version"`
	Apps                 []WorkspaceApp    `json:"apps"`
	// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
	DERPLatency              map[string]DERPRegion     `json:"latency,omitempty"`
	ConnectionTimeoutSeconds int32                     `json:"connection_timeout_seconds"`
	TroubleshootingURL       string                    `json:"troubleshooting_url"`
	Subsystems               []AgentSubsystem          `json:"subsystems"`
	Health                   WorkspaceAgentHealth      `json:"health"` // Health reports the health of the agent.
	DisplayApps              []DisplayApp              `json:"display_apps"`
	LogSources               []WorkspaceAgentLogSource `json:"log_sources"`
	Scripts                  []WorkspaceAgentScript    `json:"scripts"`
	// Metadata is only populated on the workspaces list endpoint when the
	// request opts in with the include_agent_metadata search key, and it
	// only carries the requested keys. The description's script is always
	// empty here: it can be long, and list consumers want values.
	Metadata []WorkspaceAgentMetadata `json:"metadata,omitempty"`

	// StartupScriptBehavior is a legacy field that is deprecated in favor
	// of the `coder_script` resource. It's only referenced by old clients.
	// Deprecated: Remove in the future!
	StartupScriptBehavior WorkspaceAgentStartupScriptBehavior `json:"startup_script_behavior"`
}

type WorkspaceAgentLogSource struct {
	WorkspaceAgentID uuid.UUID `json:"workspace_agent_id" format:"uuid"`
	ID               uuid.UUID `json:"id" format:"uuid"`
	CreatedAt        time.Time `json:"created_at" format:"date-time"`
	DisplayName      string    `json:"display_name"`
	Icon             string    `json:"icon"`
}

type WorkspaceAgentScriptStatus string

// This is also in database/models.go and should be kept in sync.
const (
	WorkspaceAgentScriptStatusOK            WorkspaceAgentScriptStatus = "ok"
	WorkspaceAgentScriptStatusExitFailure   WorkspaceAgentScriptStatus = "exit_failure"
	WorkspaceAgentScriptStatusTimedOut      WorkspaceAgentScriptStatus = "timed_out"
	WorkspaceAgentScriptStatusPipesLeftOpen WorkspaceAgentScriptStatus = "pipes_left_open"
	WorkspaceAgentScriptStatusSkipped       WorkspaceAgentScriptStatus = "skipped"
	WorkspaceAgentScriptStatusCanceled      WorkspaceAgentScriptStatus = "canceled"
)

type WorkspaceAgentScript struct {
	ID               uuid.UUID     `json:"id" format:"uuid"`
	LogSourceID      uuid.UUID     `json:"log_source_id" format:"uuid"`
	LogPath          string        `json:"log_path"`
	Script           string        `json:"script"`
	Cron             string        `json:"cron"`
	RunOnStart       bool          `json:"run_on_start"`
	RunOnStop        bool          `json:"run_on_stop"`
	StartBlocksLogin bool          `json:"start_blocks_login"`
	Timeout          time.Duration `json:"timeout"`
	DisplayName      string        `json:"display_name"`
	// ResourceAddress is the full Terraform address of the coder_script
	// resource, for example "module.git_clone.coder_script.clone". It is
	// empty for scripts that are not coder_script resources.
	ResourceAddress string `json:"resource_address"`
	// Dependencies lists the scripts this one waits for, as the
	// provisioner resolved them. It is omitted when empty.
	Dependencies []WorkspaceAgentScriptDependency `json:"dependencies,omitempty"`
	ExitCode     *int32                           `json:"exit_code,omitempty"`
	Status       *WorkspaceAgentScriptStatus      `json:"status,omitempty"`
}

// WorkspaceAgentScriptDependencyRequirement is the outcome a prerequisite
// must reach before the dependent script may run.
type WorkspaceAgentScriptDependencyRequirement string

const (
	WorkspaceAgentScriptDependencyRequirementSuccess    WorkspaceAgentScriptDependencyRequirement = "success"
	WorkspaceAgentScriptDependencyRequirementCompletion WorkspaceAgentScriptDependencyRequirement = "completion"
)

// Valid reports whether the requirement is one of the two supported values.
func (r WorkspaceAgentScriptDependencyRequirement) Valid() bool {
	switch r {
	case WorkspaceAgentScriptDependencyRequirementSuccess,
		WorkspaceAgentScriptDependencyRequirementCompletion:
		return true
	default:
		return false
	}
}

// WorkspaceAgentScriptDependency names a prerequisite script and the
// outcome it must reach.
type WorkspaceAgentScriptDependency struct {
	PrerequisiteResourceAddress string                                    `json:"prerequisite_resource_address"`
	Requirement                 WorkspaceAgentScriptDependencyRequirement `json:"requirement"`
}

type WorkspaceAgentHealth struct {
	Healthy bool   `json:"healthy" example:"false"`                              // Healthy is true if the agent is healthy.
	Reason  string `json:"reason,omitempty" example:"agent has lost connection"` // Reason is a human-readable explanation of the agent's health. It is empty if Healthy is true.
}

type DERPRegion struct {
	Preferred           bool    `json:"preferred"`
	LatencyMilliseconds float64 `json:"latency_ms"`
}

type WorkspaceAgentLog struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"created_at" format:"date-time"`
	Output    string    `json:"output"`
	Level     LogLevel  `json:"level"`
	SourceID  uuid.UUID `json:"source_id" format:"uuid"`
}

// Text formats the log entry as human-readable text.
func (l WorkspaceAgentLog) Text(agentName, sourceName string) string {
	var sb strings.Builder
	_, _ = sb.WriteString(l.CreatedAt.Format(time.RFC3339))
	_, _ = sb.WriteString(" [")
	_, _ = sb.WriteString(string(l.Level))
	_, _ = sb.WriteString("] [agent")
	if agentName != "" {
		_, _ = sb.WriteString(".")
		_, _ = sb.WriteString(agentName)
	}
	if sourceName != "" {
		_, _ = sb.WriteString("|")
		_, _ = sb.WriteString(sourceName)
	}
	_, _ = sb.WriteString("] ")
	_, _ = sb.WriteString(l.Output)
	return sb.String()
}

type AgentSubsystem string

const (
	AgentSubsystemEnvbox     AgentSubsystem = "envbox"
	AgentSubsystemEnvbuilder AgentSubsystem = "envbuilder"
	AgentSubsystemExectrace  AgentSubsystem = "exectrace"
)

func (s AgentSubsystem) Valid() bool {
	switch s {
	case AgentSubsystemEnvbox, AgentSubsystemEnvbuilder, AgentSubsystemExectrace:
		return true
	default:
		return false
	}
}

// WatchWorkspaceAgentMetadata watches the metadata of a workspace agent.
// The returned channel will be closed when the context is canceled. Exactly
// one error will be sent on the error channel. The metadata channel is never closed.
func (c *Client) WatchWorkspaceAgentMetadata(ctx context.Context, id uuid.UUID) (<-chan []WorkspaceAgentMetadata, <-chan error) {
	ctx, span := tracing.StartSpan(ctx)
	defer span.End()

	metadataChan := make(chan []WorkspaceAgentMetadata, 256)

	ready := make(chan struct{})
	watch := func() error {
		res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/watch-metadata", id), nil)
		if err != nil {
			return err
		}
		if res.StatusCode != http.StatusOK {
			return ReadBodyAsError(res)
		}

		nextEvent := ServerSentEventReader(ctx, res.Body)
		defer res.Body.Close()

		firstEvent := true
		for {
			select {
			case <-ctx.Done():
				return ctx.Err()
			default:
			}

			sse, err := nextEvent()
			if err != nil {
				return err
			}

			if firstEvent {
				close(ready) // Only close ready after the first event is received.
				firstEvent = false
			}

			// Ignore pings.
			if sse.Type == ServerSentEventTypePing {
				continue
			}

			b, ok := sse.Data.([]byte)
			if !ok {
				return xerrors.Errorf("unexpected data type: %T", sse.Data)
			}

			switch sse.Type {
			case ServerSentEventTypeData:
				var met []WorkspaceAgentMetadata
				err = json.Unmarshal(b, &met)
				if err != nil {
					return xerrors.Errorf("unmarshal metadata: %w", err)
				}
				metadataChan <- met
			case ServerSentEventTypeError:
				var r Response
				err = json.Unmarshal(b, &r)
				if err != nil {
					return xerrors.Errorf("unmarshal error: %w", err)
				}
				return xerrors.Errorf("%+v", r)
			default:
				return xerrors.Errorf("unexpected event type: %s", sse.Type)
			}
		}
	}

	errorChan := make(chan error, 1)
	go func() {
		defer close(errorChan)
		err := watch()
		select {
		case <-ready:
		default:
			close(ready) // Error before first event.
		}
		errorChan <- err
	}()

	// Wait until first event is received and the subscription is registered.
	<-ready

	return metadataChan, errorChan
}

// WorkspaceAgent returns an agent by ID.
func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) {
	res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s", id), nil)
	if err != nil {
		return WorkspaceAgent{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return WorkspaceAgent{}, ReadBodyAsError(res)
	}
	var workspaceAgent WorkspaceAgent
	err = ReadBodyAsJSON(res, &workspaceAgent)
	if err != nil {
		return WorkspaceAgent{}, err
	}
	return workspaceAgent, nil
}

type IssueReconnectingPTYSignedTokenRequest struct {
	// URL is the URL of the reconnecting-pty endpoint you are connecting to.
	URL     string    `json:"url" validate:"required"`
	AgentID uuid.UUID `json:"agentID" format:"uuid" validate:"required"`
}

type IssueReconnectingPTYSignedTokenResponse struct {
	SignedToken string `json:"signed_token"`
}

func (c *Client) IssueReconnectingPTYSignedToken(ctx context.Context, req IssueReconnectingPTYSignedTokenRequest) (IssueReconnectingPTYSignedTokenResponse, error) {
	res, err := c.Request(ctx, http.MethodPost, "/api/v2/applications/reconnecting-pty-signed-token", req)
	if err != nil {
		return IssueReconnectingPTYSignedTokenResponse{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return IssueReconnectingPTYSignedTokenResponse{}, ReadBodyAsError(res)
	}
	var resp IssueReconnectingPTYSignedTokenResponse
	return resp, ReadBodyAsJSON(res, &resp)
}

type WorkspaceAgentListeningPortsResponse struct {
	// If there are no ports in the list, nothing should be displayed in the UI.
	// There must not be a "no ports available" message or anything similar, as
	// there will always be no ports displayed on platforms where our port
	// detection logic is unsupported.
	Ports []WorkspaceAgentListeningPort `json:"ports"`
}

type WorkspaceAgentListeningPort struct {
	ProcessName string `json:"process_name"` // may be empty
	Network     string `json:"network"`      // only "tcp" at the moment
	Port        uint16 `json:"port"`
}

// WorkspaceAgentListeningPorts returns a list of ports that are currently being
// listened on inside the workspace agent's network namespace.
func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (WorkspaceAgentListeningPortsResponse, error) {
	res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/listening-ports", agentID), nil)
	if err != nil {
		return WorkspaceAgentListeningPortsResponse{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return WorkspaceAgentListeningPortsResponse{}, ReadBodyAsError(res)
	}
	var listeningPorts WorkspaceAgentListeningPortsResponse
	return listeningPorts, ReadBodyAsJSON(res, &listeningPorts)
}

// WorkspaceAgentDevcontainerStatus is the status of a devcontainer.
type WorkspaceAgentDevcontainerStatus string

// WorkspaceAgentDevcontainerStatus enums.
const (
	WorkspaceAgentDevcontainerStatusRunning  WorkspaceAgentDevcontainerStatus = "running"
	WorkspaceAgentDevcontainerStatusStopped  WorkspaceAgentDevcontainerStatus = "stopped"
	WorkspaceAgentDevcontainerStatusStarting WorkspaceAgentDevcontainerStatus = "starting"
	WorkspaceAgentDevcontainerStatusStopping WorkspaceAgentDevcontainerStatus = "stopping"
	WorkspaceAgentDevcontainerStatusDeleting WorkspaceAgentDevcontainerStatus = "deleting"
	WorkspaceAgentDevcontainerStatusError    WorkspaceAgentDevcontainerStatus = "error"
)

func (s WorkspaceAgentDevcontainerStatus) Transitioning() bool {
	switch s {
	case WorkspaceAgentDevcontainerStatusStarting,
		WorkspaceAgentDevcontainerStatusStopping,
		WorkspaceAgentDevcontainerStatusDeleting:
		return true
	default:
		return false
	}
}

// WorkspaceAgentDevcontainer defines the location of a devcontainer
// configuration in a workspace that is visible to the workspace agent.
type WorkspaceAgentDevcontainer struct {
	ID              uuid.UUID     `json:"id" format:"uuid"`
	Name            string        `json:"name"`
	WorkspaceFolder string        `json:"workspace_folder"`
	ConfigPath      string        `json:"config_path,omitempty"`
	SubagentID      uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`

	// Additional runtime fields.
	Status    WorkspaceAgentDevcontainerStatus `json:"status"`
	Dirty     bool                             `json:"dirty"`
	Container *WorkspaceAgentContainer         `json:"container,omitempty"`
	Agent     *WorkspaceAgentDevcontainerAgent `json:"agent,omitempty"`

	Error string `json:"error,omitempty"`
}

func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) bool {
	return d.ID == other.ID &&
		d.Name == other.Name &&
		d.WorkspaceFolder == other.WorkspaceFolder &&
		d.SubagentID == other.SubagentID &&
		d.Status == other.Status &&
		d.Dirty == other.Dirty &&
		(d.Container == nil && other.Container == nil ||
			(d.Container != nil && other.Container != nil && d.Container.ID == other.Container.ID)) &&
		(d.Agent == nil && other.Agent == nil ||
			(d.Agent != nil && other.Agent != nil && *d.Agent == *other.Agent)) &&
		d.Error == other.Error
}

// IsTerraformDefined returns true if this devcontainer has resources defined
// in Terraform.
func (d WorkspaceAgentDevcontainer) IsTerraformDefined() bool {
	return d.SubagentID.Valid
}

// WorkspaceAgentDevcontainerAgent represents the sub agent for a
// devcontainer.
type WorkspaceAgentDevcontainerAgent struct {
	ID        uuid.UUID `json:"id" format:"uuid"`
	Name      string    `json:"name"`
	Directory string    `json:"directory"`
}

// WorkspaceAgentContainer describes a devcontainer of some sort
// that is visible to the workspace agent. This struct is an abstraction
// of potentially multiple implementations, and the fields will be
// somewhat implementation-dependent.
type WorkspaceAgentContainer struct {
	// CreatedAt is the time the container was created.
	CreatedAt time.Time `json:"created_at" format:"date-time"`
	// ID is the unique identifier of the container.
	ID string `json:"id"`
	// FriendlyName is the human-readable name of the container.
	FriendlyName string `json:"name"`
	// Image is the name of the container image.
	Image string `json:"image"`
	// Labels is a map of key-value pairs of container labels.
	Labels map[string]string `json:"labels"`
	// Running is true if the container is currently running.
	Running bool `json:"running"`
	// Ports includes ports exposed by the container.
	Ports []WorkspaceAgentContainerPort `json:"ports"`
	// Status is the current status of the container. This is somewhat
	// implementation-dependent, but should generally be a human-readable
	// string.
	Status string `json:"status"`
	// Volumes is a map of "things" mounted into the container. Again, this
	// is somewhat implementation-dependent.
	Volumes map[string]string `json:"volumes"`
}

func (c *WorkspaceAgentContainer) Match(idOrName string) bool {
	if c.ID == idOrName {
		return true
	}
	if c.FriendlyName == idOrName {
		return true
	}
	return false
}

// WorkspaceAgentContainerPort describes a port as exposed by a container.
type WorkspaceAgentContainerPort struct {
	// Port is the port number *inside* the container.
	Port uint16 `json:"port"`
	// Network is the network protocol used by the port (tcp, udp, etc).
	Network string `json:"network"`
	// HostIP is the IP address of the host interface to which the port is
	// bound. Note that this can be an IPv4 or IPv6 address.
	HostIP string `json:"host_ip,omitempty"`
	// HostPort is the port number *outside* the container.
	HostPort uint16 `json:"host_port,omitempty"`
}

// WorkspaceAgentListContainersResponse is the response to the list containers
// request.
type WorkspaceAgentListContainersResponse struct {
	// Devcontainers is a list of devcontainers visible to the workspace agent.
	Devcontainers []WorkspaceAgentDevcontainer `json:"devcontainers"`
	// Containers is a list of containers visible to the workspace agent.
	Containers []WorkspaceAgentContainer `json:"containers"`
	// Warnings is a list of warnings that may have occurred during the
	// process of listing containers. This should not include fatal errors.
	Warnings []string `json:"warnings,omitempty"`
}

func workspaceAgentContainersLabelFilter(kvs map[string]string) RequestOption {
	return func(r *http.Request) {
		q := r.URL.Query()
		for k, v := range kvs {
			kv := fmt.Sprintf("%s=%s", k, v)
			q.Add("label", kv)
		}
		r.URL.RawQuery = q.Encode()
	}
}

// WorkspaceAgentListContainers returns a list of containers that are currently
// running on a Docker daemon accessible to the workspace agent.
func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.UUID, labels map[string]string) (WorkspaceAgentListContainersResponse, error) {
	lf := workspaceAgentContainersLabelFilter(labels)
	res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/containers", agentID), nil, lf)
	if err != nil {
		return WorkspaceAgentListContainersResponse{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return WorkspaceAgentListContainersResponse{}, ReadBodyAsError(res)
	}
	var cr WorkspaceAgentListContainersResponse

	return cr, ReadBodyAsJSON(res, &cr)
}

func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid.UUID) (<-chan WorkspaceAgentListContainersResponse, io.Closer, error) {
	reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/containers/watch", agentID))
	if err != nil {
		return nil, nil, err
	}

	conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
		// We want `NoContextTakeover` compression to balance improving
		// bandwidth cost/latency with minimal memory usage overhead.
		CompressionMode: websocket.CompressionNoContextTakeover,
		HTTPClient: &http.Client{
			Transport: c.HTTPClient.Transport,
		},
		HTTPHeader: http.Header{
			SessionTokenHeader: []string{c.SessionToken()},
		},
	})
	if err != nil {
		if res == nil {
			return nil, nil, err
		}
		return nil, nil, ReadBodyAsError(res)
	}

	// When a workspace has a few devcontainers running, or a single devcontainer
	// has a large amount of apps, then each payload can easily exceed 32KiB.
	// We up the limit to 4MiB to give us plenty of headroom for workspaces that
	// have lots of dev containers with lots of apps.
	conn.SetReadLimit(1 << 22) // 4MiB

	d := wsjson.NewDecoder[WorkspaceAgentListContainersResponse](conn, websocket.MessageText, c.logger)
	return d.Chan(), d, nil
}

// WorkspaceAgentDeleteDevcontainer deletes the devcontainer with the given ID.
func (c *Client) WorkspaceAgentDeleteDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) error {
	res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s", agentID, devcontainerID), nil)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusNoContent {
		return ReadBodyAsError(res)
	}
	return nil
}

// WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID.
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) (Response, error) {
	res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s/recreate", agentID, devcontainerID), nil)
	if err != nil {
		return Response{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusAccepted {
		return Response{}, ReadBodyAsError(res)
	}
	var m Response
	if err := ReadBodyAsJSON(res, &m); err != nil {
		return Response{}, xerrors.Errorf("decode response body: %w", err)
	}
	return m, nil
}

//nolint:revive // Follow is a control flag on the server as well.
func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []WorkspaceAgentLog, io.Closer, error) {
	var queryParams []string
	if after != 0 {
		queryParams = append(queryParams, fmt.Sprintf("after=%d", after))
	}
	if follow {
		queryParams = append(queryParams, "follow")
	}
	var query string
	if len(queryParams) > 0 {
		query = "?" + strings.Join(queryParams, "&")
	}
	reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/logs%s", agentID, query))
	if err != nil {
		return nil, nil, err
	}

	if !follow {
		resp, err := c.Request(ctx, http.MethodGet, reqURL.String(), nil)
		if err != nil {
			return nil, nil, xerrors.Errorf("execute request: %w", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			return nil, nil, ReadBodyAsError(resp)
		}

		var logs []WorkspaceAgentLog
		err = ReadBodyAsJSON(resp, &logs)
		if err != nil {
			return nil, nil, xerrors.Errorf("decode startup logs: %w", err)
		}

		ch := make(chan []WorkspaceAgentLog, 1)
		ch <- logs
		close(ch)
		return ch, closeFunc(func() error { return nil }), nil
	}

	httpClient := &http.Client{
		Transport: c.HTTPClient.Transport,
	}
	conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
		HTTPClient: httpClient,
		HTTPHeader: http.Header{
			SessionTokenHeader: []string{c.SessionToken()},
		},
		CompressionMode: websocket.CompressionDisabled,
	})
	if err != nil {
		if res == nil {
			return nil, nil, err
		}
		return nil, nil, ReadBodyAsError(res)
	}
	d := wsjson.NewDecoder[[]WorkspaceAgentLog](conn, websocket.MessageText, c.logger)
	return d.Chan(), d, nil
}

// WorkspaceAgentGitClientMessageType represents the type of a client
// message sent to the git watch WebSocket.
type WorkspaceAgentGitClientMessageType string

const (
	// WorkspaceAgentGitClientMessageTypeRefresh requests an immediate
	// re-scan of all subscribed repositories.
	WorkspaceAgentGitClientMessageTypeRefresh WorkspaceAgentGitClientMessageType = "refresh"
)

// WorkspaceAgentGitClientMessage is a message sent from the client to
// the agent over the git watch WebSocket.
type WorkspaceAgentGitClientMessage struct {
	Type WorkspaceAgentGitClientMessageType `json:"type"`
}

// WorkspaceAgentGitServerMessageType represents the type of a server
// message sent from the git watch WebSocket.
type WorkspaceAgentGitServerMessageType string

const (
	// WorkspaceAgentGitServerMessageTypeChanges contains a delta of
	// repository changes since the last emitted update.
	WorkspaceAgentGitServerMessageTypeChanges WorkspaceAgentGitServerMessageType = "changes"
	// WorkspaceAgentGitServerMessageTypeError signals a server-side
	// error.
	WorkspaceAgentGitServerMessageTypeError WorkspaceAgentGitServerMessageType = "error"
)

// WorkspaceAgentGitServerMessage is a message sent from the agent to
// the client over the git watch WebSocket.
type WorkspaceAgentGitServerMessage struct {
	Type         WorkspaceAgentGitServerMessageType `json:"type"`
	ScannedAt    *time.Time                         `json:"scanned_at,omitempty" format:"date-time"`
	Repositories []WorkspaceAgentRepoChanges        `json:"repositories,omitempty"`
	Message      string                             `json:"message,omitempty"`
}

// WorkspaceAgentRepoChanges describes the current state of a single
// git repository's working tree. When Removed is true the repo root
// directory or its .git subdirectory no longer exists; all other
// fields (Branch, RemoteOrigin, UnifiedDiff) are empty/zero.
type WorkspaceAgentRepoChanges struct {
	RepoRoot     string `json:"repo_root"`
	Branch       string `json:"branch"`
	RemoteOrigin string `json:"remote_origin,omitempty"`
	UnifiedDiff  string `json:"unified_diff,omitempty"`
	Removed      bool   `json:"removed,omitempty"`
}