pkg

package
v0.11.0 Latest Latest
Warning

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

Go to latest
Published: Oct 17, 2026 License: BSD-2-Clause Imports: 33 Imported by: 0

Documentation

Overview

Package pkg holds the controller's backend-independent control API: the request and response shapes a caller speaks over A2A, and the single interface every backend implements. Nothing in this package names a backend — that is the point of it.

Index

Constants

View Source
const (
	// DefaultDockerImage is the image every worker runs. ⚠️ It is untagged, so docker
	// resolves it to :latest — and no :latest is published for this image, upstream or
	// locally, so a create that used this value verbatim would fail with docker's own
	// resolution error rather than anything naming the real cause. The binary therefore
	// requires the image to be configured and overrides this field; it survives as the
	// package's own placeholder, not as a value that resolves.
	DefaultDockerImage = "bborbe/agent-claude"
	// DefaultAgentNameBase prefixes the name each worker's Agent Card advertises; the worker
	// id is appended so two workers never advertise the same name.
	DefaultAgentNameBase = "claude-interactive"
	// DefaultAnthropicModel is the model the worker's turns run against.
	DefaultAnthropicModel = "MiniMax-M2.7-highspeed"
	// DefaultAllowedTools is the tool allow-list the worker's agent runs with.
	DefaultAllowedTools = "Read,Bash,Grep,Glob,Write,Edit,WebSearch,WebFetch"
	// DefaultPortStart and DefaultPortEnd bound the host-port search. 9090 is commonly taken
	// on a laptop and 9091/9092 are held by hand-started containers that are out of bounds,
	// so the search starts above them.
	DefaultPortStart = 9093
	DefaultPortEnd   = 9199
)

The worker container's fixed settings. They mirror the local-docker-run recipe the agent image documents, which is the command create automates.

View Source
const (
	// DefaultKubernetesTemplateConfig is the Config every worker is cloned from. Cloning a
	// live Config keeps the image pin, resources and priority class in the one place the chart
	// already maintains them, so a worker never runs an image the shared pod does not.
	DefaultKubernetesTemplateConfig = "claude-interactive"
	// DefaultKubernetesIngressClassName is the ingress class every worker Ingress uses.
	DefaultKubernetesIngressClassName = "traefik"
)

The defaults for the k8s backend's settings.

View Source
const (
	// DefaultWeztermBinary is the wezterm binary's absolute path.
	//
	// ⚠️ IT IS ABSOLUTE ON PURPOSE AND A BARE NAME MUST NEVER BE SUBSTITUTED. wezterm is not
	// on a launchd job's PATH — it exists only inside the app bundle — and `exec.Command`
	// resolves a bare name with `exec.LookPath` against THIS process's PATH, never against the
	// child's `cmd.Env`. Prefixing the child's PATH therefore cannot repair it, and the failure
	// is silent: the spawn never happens and nothing says why.
	DefaultWeztermBinary = "/Applications/WezTerm.app/Contents/MacOS/wezterm"
	// DefaultLocalTasksDir is the vault directory a task file lives in, relative to the
	// configured vault root. It is the private-personal vault's own layout, which is the vault
	// a local worker serves.
	DefaultLocalTasksDir = "25 Tasks"
)

The local backend's fixed settings.

View Source
const A2APath = "/a2a"

A2APath is the route the A2A JSON-RPC binding is served at.

View Source
const AgentCardPath = a2asrv.WellKnownAgentCardPath

AgentCardPath is the well-known path the Agent Card is served at, as the A2A spec defines it. It is the SDK's own constant, re-exported so the service's wiring reads both A2A routes from one place.

View Source
const (
	// AuthorizationHeader is the request header carrying the caller's bearer credential.
	AuthorizationHeader = "Authorization"
)

The bearer gate's header and scheme. A caller presents the controller's token as `Authorization: Bearer <token>`, the scheme the A2A binding and every sibling service use.

Variables

This section is empty.

Functions

func DefaultLocalStateFile added in v0.10.0

func DefaultLocalStateFile(ctx context.Context) (string, error)

DefaultLocalStateFile returns the ownership store's default path, i.e. ~/.local/state/a2a-worker-controller/local-workers.json.

func NewA2AExecutor added in v0.2.0

func NewA2AExecutor(provider BackendProvider, backend Backend) a2asrv.AgentExecutor

NewA2AExecutor creates the A2A executor for the control API.

provider resolves a backend name to the WorkerBackend that serves it; backend is the name the controller was configured with. Both are injected — the executor reads no global and no environment — so the same executor serves any backend, and a test drives it with a stub through the real selection wiring.

func NewA2AHandler added in v0.2.0

func NewA2AHandler(executor a2asrv.AgentExecutor) http.Handler

NewA2AHandler serves the A2A JSON-RPC binding over the given executor. The body is capped before the SDK handler sees it.

func NewAgentCard added in v0.2.0

func NewAgentCard(config AgentCardConfig) *a2a.AgentCard

NewAgentCard builds the Agent Card this service advertises. Its single supported interface is the JSON-RPC binding at config.PublicURL, which is the externally reachable address supplied as configuration — never the listen address and never a value derived from a request. The card carries no credential.

func NewAgentCardHandler added in v0.2.0

func NewAgentCardHandler(card *a2a.AgentCard) http.Handler

NewAgentCardHandler serves the Agent Card. The card is public by design: it advertises the endpoint and skills and carries no credential.

Types

type AgentCardConfig added in v0.2.0

type AgentCardConfig struct {
	// Name names the deployed agent in the Agent Card.
	Name string
	// PublicURL is the externally reachable A2A endpoint the card advertises verbatim.
	// It is never derived from the listen address or a request header.
	PublicURL string
}

AgentCardConfig is what the A2A Agent Card advertises about this service.

type Auth added in v0.4.1

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

Auth is the controller's authentication decision: the bearer token every gated request must present.

The zero value is not a usable default — it refuses every gated request, so a controller built without a token serves only the exempt admin paths. That is the fail-closed outcome: a decision left unmade cannot silently open a surface that creates and destroys containers. There is deliberately no opt-out value. The reference gate carries one so a deliberately unexposed consumer can state its choice; this controller has no deployment in which serving /a2a unauthenticated is correct, so the escape hatch is omitted rather than left unused.

func NewAuthToken added in v0.4.1

func NewAuthToken(token string) Auth

NewAuthToken returns the Auth that requires token as the bearer credential on every gated request. An empty token is not a usable credential: it yields the same fail-closed value as the zero Auth.

func (Auth) Wrap added in v0.4.1

func (a Auth) Wrap(next http.Handler) http.Handler

Wrap returns the handler that serves next only to a request carrying this decision's bearer credential.

It wraps the whole router rather than individual routes, so /a2a and any route registered after this change are covered by construction rather than by remembering to add the gate. A refused request is answered before the wrapped handler runs, so it never reaches the executor.

type Backend

type Backend string

Backend names the implementation that serves workers. Its value space belongs to the backend implementations and to the selection wiring, never to this schema: a constant naming a backend here would put that backend's identity above the seam this package exists to hold, and a second backend would have to edit the shared schema to become selectable.

const (
	// BackendDocker runs each worker as a container on the local host.
	BackendDocker Backend = "docker"
	// BackendKubernetes runs each worker as a workload in the cluster.
	BackendKubernetes Backend = "k8s"
	// BackendLocal runs each worker as a WezTerm tab on the local host, started with the
	// vault's own launcher. It is the one backend whose worker serves no A2A endpoint: a tab
	// is reached through its pane, not through a URL.
	BackendLocal Backend = "local"
)

The backend names. They live here and nowhere else: this file is the one place above the seam that names a backend, so a backend becomes selectable by editing this selection wiring alone. The shared schema (types.go) names none of them, which is what keeps a backend's identity from becoming part of the control API.

type BackendProvider added in v0.2.0

type BackendProvider interface {
	// Get returns the backend serving the named backend, or an error when the name is
	// not one the controller knows or its implementation is not wired.
	Get(ctx context.Context, backend Backend) (WorkerBackend, error)
}

BackendProvider resolves a backend name to the WorkerBackend that serves it.

func NewBackendProvider added in v0.2.0

func NewBackendProvider(
	docker WorkerBackend,
	kubernetes WorkerBackend,
	local WorkerBackend,
) BackendProvider

NewBackendProvider creates the backend selection wiring.

docker, kubernetes and local are the implementations of the three backends the controller supports, in the order their names are declared above. Any of them may be nil while its implementation is not yet wired — and a backend that is not selected is passed as nil on purpose, because its settings exist only for it; Get then refuses that backend rather than handing back a nil seam that would fail on first use.

type ControlRequest

type ControlRequest struct {
	Operation Operation `json:"operation"`
	// Create is set when Operation is OperationCreate.
	Create *CreateRequest `json:"create,omitempty"`
	// Stop is set when Operation is OperationStop.
	Stop *StopRequest `json:"stop,omitempty"`
}

ControlRequest is one control-API call. Exactly one operation-specific field is set and it matches Operation; a request that sets none, or more than one, is refused rather than guessed at.

type ControlResponse

type ControlResponse struct {
	Operation Operation       `json:"operation"`
	Create    *CreateResponse `json:"create,omitempty"`
	List      *ListResponse   `json:"list,omitempty"`
	Stop      *StopResponse   `json:"stop,omitempty"`
}

ControlResponse is the controller's reply to one ControlRequest. Exactly one operation-specific field is set and it matches Operation.

type CreateRequest

type CreateRequest struct {
	Task TaskName `json:"task"`
	// Env carries extra environment for the worker, keyed by variable name. It is how a
	// caller hands a worker the settings its own tooling needs — the attention store's URL
	// and token, for instance.
	//
	// ⚠️ Every entry travels through the child environment, never in argv, because the
	// controller cannot tell a credential from a plain setting and create is a public
	// surface. A name colliding with one of the controller's own settings is refused rather
	// than merged: silently overriding AGENT_TYPE would start a worker outside the gated
	// service path, which is the setting its permission gate depends on.
	Env map[string]string `json:"env,omitempty"`
}

CreateRequest asks for a worker serving one task.

type CreateResponse

type CreateResponse struct {
	Worker Worker `json:"worker"`
	// Token is the worker's own bearer credential. It must never be logged, echoed into a
	// transcript, or written to a file.
	Token string `json:"token"`
}

CreateResponse is the reply to a create. It is the only place a worker's token is ever returned: the controller keeps no copy it depends on, so a caller that loses this reply stops the worker and creates another rather than asking the controller to repeat it.

type DockerClient added in v0.3.0

type DockerClient interface {
	// Run runs docker with args and the extra environment, returning its standard output.
	// The extra environment is layered on the caller's own and is where a credential
	// travels, so the value never reaches argv.
	Run(ctx context.Context, args []string, extraEnv []string) (string, error)
}

DockerClient runs one docker CLI invocation. It is the backend's only door to the daemon and it is injected, so the backend reads no global and a test drives it with a fake that records the argv and the environment it was handed and answers with canned output — no daemon required.

func NewDockerCLIClient added in v0.3.0

func NewDockerCLIClient() DockerClient

NewDockerCLIClient creates the DockerClient that runs the docker CLI.

type DockerConfig added in v0.3.0

type DockerConfig struct {
	// Image is the image every worker container runs.
	Image string
	// AgentNameBase prefixes the Agent Card name each worker advertises.
	AgentNameBase string
	// Model is the model the worker's turns run against.
	Model string
	// AllowedTools is the tool allow-list the worker's agent runs with.
	AllowedTools string
	// PortStart and PortEnd bound the host ports the backend will publish on.
	PortStart int
	PortEnd   int
}

DockerConfig holds the docker backend's settings. It carries no credential: the token a worker is created with is minted per worker and returned once.

func DefaultDockerConfig added in v0.3.0

func DefaultDockerConfig() DockerConfig

DefaultDockerConfig is the configuration the controller uses when none is supplied. Its values are the local-docker-run recipe's own.

type EgressDestination added in v0.6.0

type EgressDestination struct {
	Namespace string
	PodLabels map[string]string
	Port      int32
}

EgressDestination is one in-cluster destination a worker pod may reach: the pods in Namespace — all of them, or only those matching PodLabels — on TCP Port. DNS is not one of them — every worker policy allows it implicitly, because without it no other destination resolves.

PodLabels exists because a namespace-wide port is too wide on a shared namespace: in nuke's `dev`, port 9090 is served by the vault writers and by the trading gateways alike, so `dev:9090` would open both. `dev/app=vault-obsidian-personal:9090` opens the vault alone.

func ParseEgressDestinations added in v0.6.0

func ParseEgressDestinations(ctx context.Context, value string) ([]EgressDestination, error)

ParseEgressDestinations parses a comma-separated `namespace[/key=value]:port` list, the form the EGRESS_ALLOW setting takes (`dev:8788,dev/app=vault-obsidian-personal:9090`). The optional `/key=value` narrows the destination to pods carrying that label. An empty value is an empty list — a worker that may reach nothing but DNS. A malformed entry is refused rather than skipped, because a dropped entry silently denies a destination the operator meant to allow.

type KubernetesClient added in v0.5.0

type KubernetesClient interface {
	// GetConfig returns the named Config.
	GetConfig(ctx context.Context, name string) (*agentv1.Config, error)
	// ListConfigs returns every Config matching labelSelector.
	ListConfigs(ctx context.Context, labelSelector string) ([]agentv1.Config, error)
	// CreateConfig creates config and returns it as stored, carrying its uid.
	CreateConfig(ctx context.Context, config *agentv1.Config) (*agentv1.Config, error)
	// DeleteConfig deletes the named Config. Its owned objects are garbage-collected.
	DeleteConfig(ctx context.Context, name string) error
	// GetSecret returns the named Secret.
	GetSecret(ctx context.Context, name string) (*corev1.Secret, error)
	// CreateSecret creates secret.
	CreateSecret(ctx context.Context, secret *corev1.Secret) error
	// CreateService creates service.
	CreateService(ctx context.Context, service *corev1.Service) error
	// CreateIngress creates ingress.
	CreateIngress(ctx context.Context, ingress *networkingv1.Ingress) error
	// CreateNetworkPolicy creates policy.
	CreateNetworkPolicy(ctx context.Context, policy *networkingv1.NetworkPolicy) error
}

KubernetesClient is the k8s backend's only door to the cluster. It is injected, so the backend reads no global and a test drives it with a mock — no cluster required. Every call is scoped to the one namespace the client was built for.

func NewInClusterKubernetesClient added in v0.5.0

func NewInClusterKubernetesClient(ctx context.Context, namespace string) (KubernetesClient, error)

NewInClusterKubernetesClient creates the KubernetesClient from the pod's own service account. It is the only constructor production uses: the controller runs in the cluster it manages, so it needs no kubeconfig and holds no credential beyond its service account.

type KubernetesConfig added in v0.5.0

type KubernetesConfig struct {
	// Namespace is where every worker Config, Secret, Service and Ingress is written.
	Namespace string
	// TemplateConfig names the Config every worker is cloned from.
	TemplateConfig string
	// AgentNameBase prefixes the name each worker's Agent Card advertises.
	AgentNameBase string
	// IngressDomain is the parent domain of every worker host, so a worker named
	// a2a-worker-<id> is served at https://a2a-worker-<id>.<IngressDomain>/a2a.
	IngressDomain string
	// IngressClassName is the ingress class every worker Ingress uses.
	IngressClassName string
	// TLSSecretName names the wildcard certificate Secret covering IngressDomain.
	TLSSecretName string
	// WorkerPriorityClass is the PriorityClass every worker pod runs under. Empty keeps the
	// template's class. It exists because a class carries its own ResourceQuota on the cluster:
	// workers that inherited the template's class would share the template's pod cap, which the
	// template's own pod already fills.
	WorkerPriorityClass string
	// EgressAllow lists the in-cluster destinations each worker's NetworkPolicy allows, on top
	// of DNS. Everything else — the public internet included — is denied.
	EgressAllow []EgressDestination
}

KubernetesConfig holds the k8s backend's settings. It carries no credential: a worker's shared credentials are copied from its template's Secret inside the cluster, and its own token is minted per worker and returned once.

func DefaultKubernetesConfig added in v0.5.0

func DefaultKubernetesConfig() KubernetesConfig

DefaultKubernetesConfig is the k8s backend's configuration before the deployment-specific values (namespace, domain, certificate) are set.

type ListResponse

type ListResponse struct {
	Workers []Worker `json:"workers"`
}

ListResponse is the reply to a list.

type LocalConfig added in v0.10.0

type LocalConfig struct {
	// VaultDir is the vault root the workers' task files live in. It is also the directory
	// each worker's tab starts in, so the worker runs where its task lives.
	VaultDir string
	// ClaudeScript is the absolute path to the vault's claude_script launcher — the `cc-*`
	// script every worker's tab execs.
	ClaudeScript string
	// WindowID names the WezTerm window a worker's tab opens in, from LOCAL_WINDOW_ID. Empty
	// omits the flag entirely, and the tab then lands in the window the spawning process is
	// associated with — which under launchd is the default window. It exists because the flag
	// is part of the spawn this backend mirrors; the controller has no role map to resolve one
	// from, so it is configured rather than derived.
	WindowID string
}

LocalConfig holds the local backend's settings. It carries no credential: a tab worker authenticates nothing — the caller reaches it through its pane, not through a URL.

type LocalWorker added in v0.10.0

type LocalWorker struct {
	WorkerID WorkerID `json:"worker_id"`
	Task     TaskName `json:"task"`
	// PaneID is the WezTerm pane the worker runs in. It is the worker's only handle.
	PaneID string `json:"pane_id"`
	// GuiID names the WezTerm GUI instance the pane belongs to: the live GUI socket's path,
	// which carries the GUI process's own pid. It is what makes PaneID an identity rather than
	// a bare counter — a pane id restarts from a low number when the mux is recreated, so after
	// a GUI restart a stale entry's id can name a pane this controller never opened.
	GuiID string `json:"gui_id"`
	// SessionID is the Claude Code session the worker's tab bound to the task, empty until
	// that binding lands.
	SessionID string `json:"session_id"`
	// CreateSessionID is the session id the task file ALREADY CARRIED when this worker was
	// created — in practice the id of an earlier session of the same task, because this
	// worker's own session cannot have started before its tab did.
	//
	// ⚠️ IT EXISTS SO THAT AN EARLIER SESSION'S ID IS NEVER REPORTED AS THIS WORKER'S. The
	// task-scoped `claude_session_id` frontmatter field is stamped by the vault when it is
	// empty and LEFT ALONE on every later binding, so once a worker has been stopped and
	// another created for the same task, the file still names the STOPPED worker's session —
	// and nothing ever rewrites it, so that value would otherwise be permanent. The snapshot
	// is what refreshSessionIDs measures against: a value that was already there when this
	// worker started cannot be its own, and a value that appeared afterwards can.
	CreateSessionID string `json:"create_session_id"`
}

LocalWorker is one worker the local backend owns, as the ownership store records it.

The store is this backend's resource state. It exists because a WezTerm pane carries no labels: unlike a container, a pane cannot be asked which task it serves, so the mapping from worker to pane has to be written down somewhere the controller can read back after a restart.

type Operation

type Operation string

Operation names one control-API operation. It travels as a plain string so a caller written against this schema needs no shared Go type to address the controller.

const (
	// OperationCreate asks the controller for a worker serving one task.
	OperationCreate Operation = "create"
	// OperationList asks the controller for every worker it owns.
	OperationList Operation = "list"
	// OperationStop asks the controller to stop and remove one worker.
	OperationStop Operation = "stop"
)

type PortProber added in v0.3.0

type PortProber interface {
	// IsFree reports whether port can be bound on the host.
	IsFree(ctx context.Context, port int) bool
}

PortProber reports whether a host port is free to bind. It is injected so the port search is testable without opening a socket.

func NewTCPPortProber added in v0.3.0

func NewTCPPortProber() PortProber

NewTCPPortProber creates the PortProber that bind-checks a port on the host.

type StopRequest

type StopRequest struct {
	WorkerID WorkerID `json:"worker_id"`
}

StopRequest names the worker to stop and remove.

type StopResponse

type StopResponse struct {
	Worker Worker `json:"worker"`
}

StopResponse is the reply to a stop. It carries the stopped worker so a caller can confirm which one the controller acted on.

type StubWorkerBackend added in v0.2.0

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

StubWorkerBackend is a throwaway in-memory WorkerBackend. It exists for two reasons.

It makes the executor testable with no container, no cluster and no credential: a test drives the real executor and the real selection wiring against it. And it is the second implementation of WorkerBackend, which is what proves the seam — the executor drives it through the same interface it drives a real backend through, without knowing which one it holds.

It is absent from the selection wiring in backend-provider.go: no Backend value resolves to it. It is therefore reachable only by tests and by explicit construction, and can never serve a production request.

func NewStubWorkerBackend added in v0.2.0

func NewStubWorkerBackend() *StubWorkerBackend

NewStubWorkerBackend creates an empty in-memory backend.

func (*StubWorkerBackend) Create added in v0.2.0

Create records a worker for the requested task and returns it with a token derived from its id.

⚠️ A TASK THAT ALREADY HAS A WORKER IS REFUSED, matching the contract the real backends keep. The stub's token is *derived* rather than kept, so it could answer where they cannot — but answering here would let a spec certify a reply the real backends refuse, which is the exact divergence a stub must not introduce. A stub that is easier to satisfy than the thing it stands in for tests the stub.

func (*StubWorkerBackend) List added in v0.2.0

List returns every worker the stub holds, in no particular order.

func (*StubWorkerBackend) Stop added in v0.2.0

Stop removes the named worker and returns it, reporting it as stopped. A worker the stub does not hold is refused rather than silently ignored.

type TaskName

type TaskName string

TaskName is the vault task a worker serves. It names the work, not the worker — a task has at most one worker, and a second create for a task that already has one is REFUSED, naming that worker, rather than answered with it.

type WeztermClient added in v0.10.0

type WeztermClient interface {
	// GuiID returns an identifier for the GUI instance the other three calls talk to — the
	// live GUI socket's path, which carries the GUI process's own pid. It differs after a GUI
	// restart, and that is the whole point of recording it: a pane id on its own is a per-mux
	// counter that restarts with the mux, so only a GUI id makes it interpretable.
	//
	// ⚠️ IT IS RESOLVED ONCE PER BACKEND CALL AND THEN PASSED TO EVERY OTHER METHOD, RATHER
	// THAN RESOLVED AGAIN INSIDE EACH. The resolution picks the NEWEST live socket, so two
	// resolutions within one call can name two different GUIs, and a GUI that restarts between
	// them would have a pane opened in one GUI recorded under the other's identity — which is
	// the single thing the recorded identity exists to prevent.
	GuiID(ctx context.Context) (string, error)
	// Spawn opens a tab running inner as `bash -lc inner`, in the GUI guiID names, in the
	// window windowID names when it is not empty, and returns the new pane's id.
	Spawn(ctx context.Context, guiID string, windowID string, inner string) (string, error)
	// PaneIDs returns the id of every pane the GUI guiID names has.
	PaneIDs(ctx context.Context, guiID string) ([]string, error)
	// KillPane closes the pane paneID names in the GUI guiID names.
	KillPane(ctx context.Context, guiID string, paneID string) error
}

WeztermClient is the local backend's only door to WezTerm. It is injected, so the backend reads no global and a test drives the real code path with a fake that records the command line it was handed — no GUI and no pane required.

func NewWeztermCLIClient added in v0.10.0

func NewWeztermCLIClient(binary string) WeztermClient

NewWeztermCLIClient creates the WeztermClient that runs the wezterm binary at binary.

binary is an absolute path — see DefaultWeztermBinary for why a name would not resolve under launchd.

type Worker

type Worker struct {
	WorkerID WorkerID    `json:"worker_id"`
	Task     TaskName    `json:"task"`
	A2AURL   string      `json:"a2a_url"`
	Backend  Backend     `json:"backend"`
	State    WorkerState `json:"state"`
	// PaneID is the WezTerm pane the worker runs in. Empty unless the worker runs in a tab.
	PaneID string `json:"pane_id"`
	// SessionID is the Claude Code session the worker's tab is bound to. Empty unless the
	// worker runs in a tab, and empty there too until the session's own task binding lands.
	SessionID string `json:"session_id"`
}

Worker is one worker as the control API reports it. It deliberately carries no token: a worker's token is returned once by create and never again, so no listing, no log and no restart can leak one.

⚠️ PaneID AND SessionID ARE A DELIBERATE WIDENING OF THIS SHARED SCHEMA. What this file keeps out is a backend's IDENTITY: no backend is named here, so a second backend does not have to edit the schema to become selectable. These two fields are not that — they record how a worker is reached when it has no URL. A worker that runs in a WezTerm tab serves no A2A endpoint, so its pane is the only handle a caller has on it, and without these fields there would be nowhere for that handle to travel. A caller reads them when a2a_url is empty and ignores them otherwise; both are empty for a backend whose workers answer at their own URL.

type WorkerBackend added in v0.2.0

type WorkerBackend interface {
	// Create starts one worker serving the requested task and returns it together with
	// its own bearer token. ⚠️ A task that already has a worker is REFUSED, naming that
	// worker's id, rather than answered: only one worker is ever started per task, and the
	// existing worker's token cannot be returned because the backend kept no copy of it —
	// so a success reply would name a worker no caller could authenticate to, which reads
	// as a working worker while being unreachable. The caller's recovery is to stop the
	// named worker and create again.
	Create(ctx context.Context, req CreateRequest) (*CreateResponse, error)

	// List returns every worker the backend owns. It is rebuilt from the backend's own
	// resource state on every call, never from controller memory, so a controller
	// restart loses nothing.
	List(ctx context.Context) (*ListResponse, error)

	// Stop stops and removes the named worker and returns it.
	Stop(ctx context.Context, req StopRequest) (*StopResponse, error)
}

WorkerBackend is the single seam every backend implements. It is expressible with the control-API schema alone: it takes the schema's request shapes and returns its response shapes, so nothing above the seam names a backend and a new backend needs no change to the executor or to the HTTP surface.

⚠️ THE SCHEMA HALF OF THAT IS NO LONGER UNCONDITIONAL. Adding a backend whose workers are not reachable at a URL — the local backend, whose workers are WezTerm tabs — widened Worker with pane_id and session_id (types.go), because a tab has no A2A URL and the pane is the only handle a caller gets on it. That widening was deliberate and is recorded there; what still holds is the narrower rule, that a backend's IDENTITY never reaches this schema. A backend whose workers answer at their own URL still needs no schema change at all.

func NewDockerBackend added in v0.3.0

func NewDockerBackend(client DockerClient, prober PortProber, config DockerConfig) WorkerBackend

NewDockerBackend creates the WorkerBackend that runs each worker as a docker container.

client is the backend's only door to the daemon and prober decides which host port is free; both are injected, so the backend reads no global and a test drives it without a daemon. config carries the image, the Agent Card name prefix, the model and the host-port range.

func NewKubernetesBackend added in v0.5.0

func NewKubernetesBackend(
	client KubernetesClient,
	builder WorkerResourceBuilder,
	templateConfig string,
) WorkerBackend

NewKubernetesBackend creates the WorkerBackend that runs each worker as a per-task pod.

client is the backend's only door to the cluster and builder renders every object a worker is made of; both are injected, so a test drives the backend without a cluster. templateConfig names the Config every worker is cloned from.

func NewLocalBackend added in v0.10.0

func NewLocalBackend(client WeztermClient, store WorkerStore, config LocalConfig) WorkerBackend

NewLocalBackend creates the WorkerBackend that opens each worker as a WezTerm tab.

client is the backend's only door to WezTerm and store is its ownership record; both are injected, so the backend reads no global and a test drives it without a GUI. config carries the vault directory and the launcher every tab runs.

type WorkerID

type WorkerID string

WorkerID identifies one worker the controller created. It is the value a caller passes back to stop a worker and the value list reports, so it stays stable across a controller restart: it is read back from the worker's own labels rather than minted per request and held in memory.

type WorkerResourceBuilder added in v0.5.0

type WorkerResourceBuilder interface {
	// BuildConfig clones template into the Config for one worker serving task.
	BuildConfig(template agentv1.Config, workerID WorkerID, task TaskName) *agentv1.Config
	// BuildSecret renders the worker's credential Secret, owned by owner: every key of
	// template, with the bearer replaced by token.
	BuildSecret(owner agentv1.Config, template corev1.Secret, token string) *corev1.Secret
	// BuildService renders the ClusterIP Service in front of owner's pod.
	BuildService(owner agentv1.Config) *corev1.Service
	// BuildIngress renders the TLS Ingress that exposes owner's Service on its own host.
	BuildIngress(owner agentv1.Config) *networkingv1.Ingress
	// BuildNetworkPolicy renders the egress NetworkPolicy that confines owner's pod.
	BuildNetworkPolicy(owner agentv1.Config) *networkingv1.NetworkPolicy
	// A2AURL is the address the worker named name advertises.
	A2AURL(name string) string
}

WorkerResourceBuilder renders every Kubernetes object a k8s worker is made of. It decides what a worker looks like; it never talks to the cluster, so every method is a pure function of its arguments and the backend's configuration.

func NewWorkerResourceBuilder added in v0.5.0

func NewWorkerResourceBuilder(config KubernetesConfig) WorkerResourceBuilder

NewWorkerResourceBuilder creates the WorkerResourceBuilder that renders workers for config's namespace, ingress domain and certificate.

type WorkerState

type WorkerState string

WorkerState is what list reports about a worker's lifecycle.

const (
	// WorkerStateRunning means the worker exists and is reachable: at its own A2A URL for a
	// backend whose workers serve one, or through its pane for a tab worker, which serves none.
	WorkerStateRunning WorkerState = "running"
	// WorkerStateStopped means the worker no longer exists. It appears only in the reply to
	// the stop that removed it; list never returns it.
	WorkerStateStopped WorkerState = "stopped"
	// WorkerStateUnknown means the worker's backing resource exists but the controller
	// cannot classify it, so a caller must not read it as running.
	WorkerStateUnknown WorkerState = "unknown"
)

type WorkerStore added in v0.10.0

type WorkerStore interface {
	// List returns every worker the store holds, empty when the store does not exist yet.
	List(ctx context.Context) ([]LocalWorker, error)
	// Save replaces the store's contents with workers.
	Save(ctx context.Context, workers []LocalWorker) error
}

WorkerStore persists the workers the local backend owns. It is injected, so the backend reads no global and a test drives it against a temporary file.

List and Save are the whole surface: the backend reads the set, changes it, and writes it back under its own mutex, so there is no per-entry mutation to get wrong.

⚠️ THE STORE IS SINGLE-WRITER BY CONTRACT, AND THAT IS A PROPERTY OF THE CONFIGURATION RATHER THAN OF THIS INTERFACE. Save replaces the file's whole contents, so two controllers pointed at one path would each overwrite the other's set: the last write wins and the other controller's workers are left running with no record naming them, which is exactly the state Stop cannot reach. The backend's mutex serializes its calls within ONE process and nothing serializes two processes, so one state file belongs to exactly one controller.

func NewJSONWorkerStore added in v0.10.0

func NewJSONWorkerStore(path string) WorkerStore

NewJSONWorkerStore creates the WorkerStore backed by the JSON file at path. A leading ~ is resolved against the home directory, so the default path can be passed through unchanged.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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