web

package
v0.0.0-...-8b69688 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: MIT Imports: 43 Imported by: 0

Documentation

Overview

Package web serves the pipeline UI: a read-and-operate view of what the runner has done and is doing, over the same sqlite store the CLI writes.

It is a second front end on the existing model, never a second model. Every page answers a question the store already holds the answer to — what jobs exist and how they depend on each other, what a run did step by step, why a step was skipped, what an agent actually said — and the two mutations it offers (enqueue a job, decide an approval) go through the same rows `steps web` and `steps approvals approve` use. Nothing here is a parallel execution path.

The server is single-user and binds loopback by default, where it asks for nothing: the thing it would authenticate against does not exist — this is the local runner's own UI, in the same trust domain as the terminal that started it. A deployment that is NOT that turns on HTTP Basic (see auth.go), which is the whole of the authentication here.

Index

Constants

View Source
const (
	LoginPending    = "pending"
	LoginAuthorized = "authorized"
	LoginFailed     = "failed"
)

Login states, which the CLI polls for.

View Source
const MCPCallbackPath = "/mcp/callback"

MCPCallbackPath is where an authorization server redirects the browser to finish a login this daemon started; the CLI builds the redirect URI from it, so it is one constant.

Variables

View Source
var ErrNoSuchPipeline = errors.New("no such pipeline")

ErrNoSuchPipeline is a verb naming a pipeline this daemon does not hold.

View Source
var ErrRefused = errors.New("the pipeline was refused")

ErrRefused is the sender's to fix — unparseable, or naming something this machine cannot supply — which is why it is not a 500.

View Source
var ErrRevisionMoved = errors.New("the pipeline's configuration changed since it was read")

ErrRevisionMoved is a compare-and-set the daemon refused: the configuration moved between the sender's diff and its set.

Functions

func FormatBinaryBytes

func FormatBinaryBytes(n int64) string

FormatBinaryBytes renders a disk or transfer size in BINARY units, deliberately unlike formatBytes.

That one is decimal so an agent's payload is comparable to the byte limits it is bounded by. This is about disks and wire transfers, which every tool a reader will cross-check against — the shim's own tmpfs warning, the EC2 console, df — reports in KiB/MiB/GiB.

func FormatUSD

func FormatUSD(amount float64) string

FormatUSD renders a dollar figure at the precision an agent run actually costs. Four decimals rather than two: a single cheap step lands in the fractions of a cent, and rounding those to $0.00 loses exactly the number the column exists to show — the same reading an unpriced run must never produce.

Exported because the terminal report (`steps runs cost`) prints the same figures from the same rows, and two spellings of "how much" would disagree on exactly the cheap runs where the difference is the whole answer.

func PrepareQueue

func PrepareQueue(ctx context.Context, target *Pipeline)

PrepareQueue is the startup recovery `steps web` does, mirrored here so recovery happens the same way regardless of which front end drains the queue. It is recovery ONLY: what a configuration says about who may run is SyncQueueLimits' job, because that has to happen again on every reload while this must happen exactly once.

The caller runs this BEFORE starting any drain or poll goroutine, which is a requirement rather than a convention: ResetStaleRunning is three statements with no transaction around them, and an enqueue landing between two of them leaves a row no later poll re-queues.

It recovers unconditionally, which is a statement about deployment rather than a shortcut: recovery reads every `running` row as an abandoned leftover, and `steps web` is the only daemon there is — so a row it finds running at startup belongs to a process that is gone. Two of them against one state file would each undo the other, which is the deployment mistake the one-process-per-database rule names. It was once answered by a file lock this process raced for, and then by --no-watch, which went with the separate watcher; see store.ResetStaleRunning for why the lock went.

func SyncQueueLimits

func SyncQueueLimits(ctx context.Context, target *Pipeline)

SyncQueueLimits mirrors the served configuration's admission rules into the tables ClaimNextJob reads.

NOT part of PrepareQueue, though it once was: it belongs to ADOPTING a configuration, which happens at startup and at every reload, while recovery belongs to starting the process. ResetStaleRunning reads every `running` row as an abandoned leftover, which is true once, at startup, and false while this process is mid-build. Everything a swap changes about who may run — a job joining a serial group, a max_in_flight raised or removed — lives in SQL rather than in the Config, so a configuration adopted without this serves pages promising a `serial:` the queue goes on ignoring.

Types

type Authorizer

type Authorizer interface {
	StartLogin(pipeline *Pipeline, server string, req LoginRequest) (LoginStatus, error)
	LoginStatus(server string) (LoginStatus, bool)
	// LoginCallback is the handler for the pending login that minted state, nil when none did.
	LoginCallback(state string) http.Handler
	// MCPState is what the token-holder knows about one server: its saved credential, and the last probe anybody asked for. Never a request of its own — the page calls this on every poll.
	MCPState(pipeline *Pipeline, server string) MCPState
	// StartProbe connects to one server in the background, recording the result for MCPState to report. Refuses what it cannot honestly probe; a probe already in flight is left alone rather than duplicated.
	StartProbe(pipeline *Pipeline, server string) error
}

Authorizer runs logins and reports what a declared mcp server is worth. An interface for the reason Manager is one — depguard keeps internal/mcp out of this package — and optional: a manager that is not one answers 501 rather than pretending.

type LocalRunner

type LocalRunner struct {
	// contains filtered or unexported fields
}

LocalRunner drains each pipeline's queue in this process. It is the Runner the `steps web` command installs; a read-only server has none.

func NewLocalRunner

func NewLocalRunner(
	initial map[string]workspace.Provider, pinned map[string]string, concurrent int, force bool,
) *LocalRunner

NewLocalRunner builds a runner over per-pipeline workspace providers, keyed by pipeline slug. concurrent below 1 means one job at a time.

func (*LocalRunner) Abort

func (r *LocalRunner) Abort(target *Pipeline, runID string) bool

Abort only cancels: the run still unwinds through its on_abort and ensure hooks, and gives back its serial slot when it actually ends.

func (*LocalRunner) AbortQueued

func (r *LocalRunner) AbortQueued(ctx context.Context, target *Pipeline, jobName string) (bool, error)

AbortQueued drops a job's queued run before it starts, and the force it was queued with.

func (*LocalRunner) Close

func (r *LocalRunner) Close()

Close retires every workspace this runner holds. Only a provider's own Close removes the tree it created, so a shutdown that closed the stores alone left a steps-* directory per pipeline behind with nothing to reap it.

func (*LocalRunner) Drain

func (r *LocalRunner) Drain(ctx context.Context, pipelines []*Pipeline)

Drain runs each pipeline's queue until ctx is canceled. One goroutine per pipeline: they have separate databases, so they never contend, and a slow job in one pipeline must not stall another's queue.

func (*LocalRunner) DrainPipeline

func (r *LocalRunner) DrainPipeline(ctx context.Context, target *Pipeline)

DrainPipeline runs one pipeline's queue with `concurrent` workers.

Workers rather than one loop because a daemon that is mid-build stops noticing everything else: the queue this drains is also where a browser trigger and an approval release land, and one long agent step used to make all of them wait. What it does NOT do is loosen the pipeline's own limits — ClaimNextJob admits in a single statement against serial: and max_in_flight, so extra workers find nothing to claim rather than running what the pipeline forbade.

func (*LocalRunner) Enqueue

func (r *LocalRunner) Enqueue(ctx context.Context, target *Pipeline, jobName, reason string, force bool) (int64, error)

Enqueue puts a job on the pipeline's queue.

func (*LocalRunner) RemoveProvider

func (r *LocalRunner) RemoveProvider(slug string)

RemoveProvider retires a destroyed pipeline's workspace, once nothing is running in it.

func (*LocalRunner) SetProvider

func (r *LocalRunner) SetProvider(slug string, provider workspace.Provider)

SetProvider installs the workspace a pipeline's runs materialize in, retiring whatever it replaces once the runs holding it finish.

func (*LocalRunner) StopWith

func (r *LocalRunner) StopWith(process context.Context)

StopWith names the context whose end is the process going down, as opposed to one pipeline's drain being stopped. Call it before draining.

type LoginRequest

type LoginRequest struct {
	Base string `json:"base"`
	// Return is where the browser is sent once the callback has answered, and it is a path on THIS daemon — refused otherwise (see redirectFor's sibling). Empty is a login a terminal is waiting on, which keeps the callback's plain "you can close this window": the CLI is the thing that reports the outcome there. A page sets it because the exchange finishes after the callback has answered, so the failure that matters most has nowhere else to land.
	Return string `json:"return,omitempty"`
}

LoginRequest starts one. Base is the address the CLI reached this daemon on, userinfo already removed: it is proven to work, the browser finishing the flow sits beside that CLI, and it is what the redirect URI is built from — so a password left on it would be handed to the authorization server.

type LoginStatus

type LoginStatus struct {
	State string `json:"state"`
	// ID names THIS attempt, because a login is tracked by server NAME and a second Connect replaces the first under it. Without it a request can only ask "what is the state of the login called tracker", which after a replacement is somebody else's.
	ID string `json:"id,omitempty"`
	// AuthorizeURL appears once discovery and registration are done, which is network work the start request does not wait out.
	AuthorizeURL string `json:"authorize_url,omitempty"`
	// Message is the refusal, in the flow's own words — including the one that matters most unattended: authorized, but with a token that cannot be renewed.
	Message   string `json:"message,omitempty"`
	TokenPath string `json:"token_path,omitempty"`
}

LoginStatus is where one login stands. Shared with the client for the reason SetRequest is.

type MCPCredential

type MCPCredential struct {
	Connected bool
	// Detail is the sentence a reader acts on — "connected, renews automatically", "authorized for a different endpoint".
	Detail string
}

MCPCredential is what the daemon knows about an oauth server's saved token without spending it. It carries no part of the token: a page renders this.

type MCPProbe

type MCPProbe struct {
	// Running is a probe still in flight, which the page's own poll will replace with its result.
	Running bool
	OK      bool
	Detail  string
	At      time.Time
	// Tools is what the server answered with, which is the question behind the question: a grant names a tool, and whether that name still exists is what a reader is really checking.
	Tools []string
}

MCPProbe is the outcome of a Test: a live connection to the server, made because somebody asked for one and never on a page load.

type MCPState

type MCPState struct {
	Credential MCPCredential
	// Probe is the last Test of this server, nil when nobody has asked.
	Probe *MCPProbe
}

MCPState is everything the token-holder knows about one declared server. internal/web cannot import internal/mcp (depguard), and should not: a token file is not this package's business, and the interface is what keeps it that way.

type Manager

type Manager interface {
	// Set applies a configuration under name, creating the pipeline if this
	// daemon does not hold one. expectSHA is the revision the sender diffed
	// against: empty means "I did not look", and a mismatch is refused rather
	// than applied over whatever arrived in between.
	Set(ctx context.Context, name string, req SetRequest) (SetResult, error)
	Destroy(ctx context.Context, name string) error
	Rename(ctx context.Context, from, to string) error
}

Manager applies what a `steps pipeline` verb asks for. An interface for the reason Runner is one: this package serves the surface and chooses neither a store driver nor a workspace provider.

type Option

type Option func(*Server)

Option configures a server at construction, which is where the middleware table is built: an auth setting applied afterwards would be a server that answered without it first.

func WithBasicAuth

func WithBasicAuth(username, password string) Option

WithBasicAuth demands these credentials on every route but the webhook one. Both halves are the caller's to validate — see cli's WebCmd, where half a pair refuses to start.

type Pipeline

type Pipeline struct {
	// Slug is the name `steps pipeline set` was given: the route, the store's pipelines.name and the Config's name are ONE identity, not three kept in agreement.
	Slug string

	Store store.Store
	// Bus carries live run events for runs this process itself executes.
	// Runs started by a separate `steps run` land in the store but not on
	// this bus, which is why every live view falls back to replaying the
	// stored events rather than assuming the bus saw everything.
	Bus *events.Bus
	// Hooks answers POST /p/<slug>/hooks/<resource>, a delivery to a webhook resource. Built by the caller (trigger.HookHandler): this package serves the surface and does not own the queue, the division that keeps the runner an interface. Nil only in a test.
	Hooks func(w http.ResponseWriter, r *http.Request, resource string)
	// contains filtered or unexported fields
}

Pipeline is one loaded pipeline the server serves, with its own config and its own store handle. Two served pipelines may now share a state FILE (see --db), but never a store handle: each one is scoped to its own pipeline row, which is what keeps their histories and caches apart.

func NewPipeline

func NewPipeline(slug, path string, cfg *config.Config, st store.Store, bus *events.Bus) *Pipeline

NewPipeline builds a served pipeline around the configuration it starts with.

A constructor rather than a struct literal because cfg is behind an atomic pointer: it is read by handlers, the drain and the poller at once, and a literal would leave it nil for whatever ran before the caller filled it in.

func (*Pipeline) Config

func (p *Pipeline) Config() *config.Config

Config is the configuration being served right now.

Every reader goes through here rather than holding the pointer across a swap: a job started before a reload must run the plan it was queued against, which it does by taking this once, while the NEXT job takes the new one.

func (*Pipeline) Path

func (p *Pipeline) Path() string

Path is where the served configuration was set from.

func (*Pipeline) SetConfig

func (p *Pipeline) SetConfig(cfg *config.Config)

SetConfig swaps in a configuration a `steps pipeline set` just applied.

func (*Pipeline) SetPath

func (p *Pipeline) SetPath(path string)

SetPath records where a set that just landed was sent from.

type PipelineConfig

type PipelineConfig struct {
	Name     string            `json:"name"`
	SHA      string            `json:"sha"`
	Source   string            `json:"source"`
	Includes map[string][]byte `json:"includes,omitempty"`
	From     string            `json:"from,omitempty"`
	Paused   bool              `json:"paused"`
}

PipelineConfig is what get prints and what set diffs against: the source, its includes (bytes, for the reason SetRequest's are), and the sha that names both.

type PipelineSummary

type PipelineSummary struct {
	Name   string `json:"name"`
	SHA    string `json:"sha"`
	From   string `json:"from,omitempty"`
	Jobs   int    `json:"jobs"`
	Paused bool   `json:"paused"`
}

PipelineSummary is one row of `steps pipeline list`. Exported and shared with the client for the reason SetRequest is: two copies of a wire struct drift into a silently-dropped field rather than a build error.

type PlacementView

type PlacementView struct {
	store.Placement
}

PlacementView is one placed step's machine as the template reads it.

Exported because `steps runs where` renders the same rows through it. Two spellings of "which machine" had already drifted: the CLI's copy never learned Volatile, so the terminal — where an operator debugging a placed step looks first — reported a tmpfs workdir as an ordinary disk.

func (PlacementView) Filesystem

func (p PlacementView) Filesystem() string

Filesystem is what the tree landed on, or a stated silence.

Empty is never drawn as an ordinary disk: a shim on a platform with no statfs genuinely cannot say, and tmpfs — the answer this column exists to surface — would otherwise hide behind a plausible blank.

func (PlacementView) Identity

func (p PlacementView) Identity() string

Identity is who the step ran as, blank when the shim did not say — never an invented 0, which would read as root.

func (PlacementView) Machine

func (p PlacementView) Machine() string

Machine names the host, and the image if the step ran in a container on it.

func (PlacementView) Platform

func (p PlacementView) Platform() string

Platform is what the worker reported itself to be.

func (PlacementView) Received

func (p PlacementView) Received() string

Received is what came back from it: the tree the step produced there. A worker keeps what it produces too, so this is the cost of a LOCAL reader wanting the bytes, not of the step having made them.

func (PlacementView) Sent

func (p PlacementView) Sent() string

Sent is what actually crossed to reach this machine. A worker keeps what it receives, so a step whose inputs were already there honestly reads 0 B.

func (PlacementView) Volatile

func (p PlacementView) Volatile() bool

Volatile marks a workdir that is MEMORY, so the row can say so in the colour every other warning on this page uses. It is the single most expensive thing a worker URL can get wrong and the least visible.

type Runner

type Runner interface {
	// Enqueue queues a job for execution, returning the queue row id.
	Enqueue(ctx context.Context, pipeline *Pipeline, jobName, reason string, force bool) (int64, error)
	// Abort cancels a run this process is executing, and reports false when it is not running here.
	Abort(pipeline *Pipeline, runID string) bool
	// AbortQueued drops a job's queued run before it starts, and reports false when nothing was queued.
	AbortQueued(ctx context.Context, pipeline *Pipeline, jobName string) (bool, error)
}

Runner is what the web layer needs in order to act rather than only report: enqueue a job for execution, and report what is currently running. Implemented by the in-process drainer (see runner.go); an interface so the HTTP layer can be tested without starting real jobs.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server serves whatever has been set into it. The registry is behind a lock rather than fixed at construction because a handler, the drain and the poll loop all read it while another request writes it.

func New

func New(pipelines []*Pipeline, runner Runner, opts ...Option) (*Server, error)

New builds a server over whatever pipelines it is handed, which may be none: a daemon is configured by `steps pipeline set` and by nothing else, so empty is the ordinary starting state rather than an error.

func (*Server) Add

func (s *Server) Add(pipeline *Pipeline) error

Add starts serving a pipeline, refusing a name already held.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler exposes the router, for tests and for embedding.

func (*Server) Lookup

func (s *Server) Lookup(slug string) *Pipeline

Lookup is the pipeline served under slug, nil when this daemon holds none.

One return rather than the comma-ok a map gives, because the two say the same thing and a caller that reads the bool and keeps the pointer is exactly the shape a nil-flow analyzer cannot follow.

func (*Server) Remove

func (s *Server) Remove(slug string) *Pipeline

Remove hands the pipeline back so the caller can shut down what it started for it, and nil when this daemon was not serving one.

func (*Server) Served

func (s *Server) Served() []*Pipeline

Served is what this daemon holds right now, by slug.

func (*Server) SetManager

func (s *Server) SetManager(manager Manager)

SetManager is separate from New because the manager needs the server it registers into.

func (*Server) Start

func (s *Server) Start(ctx context.Context, addr string) error

Start serves until ctx is canceled, then shuts down gracefully.

type SetRequest

type SetRequest struct {
	Source string `json:"source"`
	// A daemon has no sibling filesystem, so an include that does not travel here cannot be resolved at all (see config.Bundle); bytes rather than strings because encoding/json replaces each invalid UTF-8 byte of a string with U+FFFD, which ran a Latin-1 run_file: as bytes nobody sent.
	Includes map[string][]byte `json:"includes,omitempty"`
	// ExpectSHA is the compare-and-set. Empty means the sender did not look, which a script may legitimately do.
	ExpectSHA string `json:"expect_sha,omitempty"`
	// From is the SENDER's path, recorded for a reader wondering where a served configuration came from, and never opened here.
	From string `json:"from,omitempty"`
}

SetRequest is one upload: the substituted YAML, the files it includes, and the revision the sender believed it was replacing.

type SetResult

type SetResult struct {
	SHA string `json:"sha"`
	// Created, replaced and unchanged are three outcomes a person reads differently, so the answer says which.
	Created   bool `json:"created"`
	Unchanged bool `json:"unchanged"`
}

SetResult is what the daemon did, so the terminal that asked can say so.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL