graph

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: AGPL-3.0 Imports: 26 Imported by: 0

Documentation

Overview

Package graph is the workflow evaluator: it reads an entry point, resolves the graph its edges describe, and says which task should run next and what that task carries. It decides; it does not execute.

The name is the one the root doc.go reserved for this work, and it is the only name this group claims. The three layers below are one package because the rules do not cut apart: the expression roots a step may read depend on its fan_out, the outputs it may declare depend on its brick manifest, and the barrier depends on both. A second package beside this one would have to export enough internal structure across the seam to make the seam pointless.

Parse, ParseFragment, Load   the entry point, closed, with includes, extends and
                             defaults resolved in the order the language fixes
LoadStored                   the same, for a version already stored, without the
                             bound added since one could be (agk.PortMaxBytes)
Check, Images, Build         the resolved graph, its edges, its cycles and the
                             manifest subset rules
Start, New, Evaluator        the run, as a state a Plan comes out of and a Result
                             goes back into

What this package refuses to be

It has no loop, no goroutine, no clock, no identifier generator and no Driver call. Nothing here opens a socket, pulls an image, speaks to a registry, reads or writes the artifact store, or knows that a database exists. Next and Record are functions of the state and their arguments and of nothing else, so the same sequence of Results always produces the same sequence of Plans, a run replays by replaying its Results, and every rule the documentation states is testable with nothing behind it but a map in a test file.

Everything from outside arrives as an argument, and each argument is there because a documented rule needs it:

fs.FS                        the repository tree, already pinned to the commit, so
                             an include and a schema $ref resolve inside it and can
                             never leave it
Remote                       the repositories a workflow include names, each tree
                             answered at the commit its ref resolved to, because
                             resolving one is reaching another repository
map[string]brick.Manifest    the manifests, already fetched, because reading
                             /agk/brick.yaml means pulling an image, and pulling an
                             image is executing
now time.Time                handed to Next, so that evaluation is pure and a replay
                             is exact rather than nearly exact

Images says which manifests to fetch, so the caller can do the fetching between Check and Build without this package ever holding a registry client.

Where the evaluator stops and the driver begins

The evaluator stops at a decision and the driver begins at a container. A Plan is the decision: the tasks that have become ready, the tasks that should be stopped, and the moment to ask again. Nothing in a Plan has happened yet, and calling Next twice with the same now returns the same Plan.

With one exception, which is a rule of the language and so is decided here rather than by whoever reads the Plan. A task stopped as superseded or sibling_failed while the run goes on ends cancelled in the pass that names its stop, as the table of stops says it ends, and its step is judged then; the second call finds it over and does not name it again. What its driver reports afterwards adds how the container exited and nothing else. agk run --local and the controller both read that from this package, which is what makes them give one shard state for one history rather than one each.

A Task is one shard of one attempt of one step, carrying everything a driver needs and nothing it has to look up. Task.Inputs is exactly the argument brick.WriteInputs takes and Task.Outputs is exactly the declared argument brick.Collect takes, so the driver is the thin thing between two calls that already exist. That is why the merge strategies are resolved here and not there: wait_all, zip, join and first are rules the workflow file states, and a rule hidden inside the driver is a rule the workflow file cannot show.

The interface the driver implements is stated here and called by nothing here:

type Driver interface {
    Run(ctx context.Context, t Task) (Result, error)
    Stop(ctx context.Context, s Stop) error
}

Stop is a method rather than a cancelled context because a task in flight may be held by a runner in another process, where a context does not reach. The loop that reads a Plan, hands each Task to a Driver and feeds each Result back belongs to cmd/agk and to the controller; putting it here would be the evaluator executing.

The task message of the documentation carries digests rather than envelopes. That is one serialisation of a Task, made by a server driver that spills the envelope to the store and puts the digest on the bus, and it is not a second decision.

The run, as a value

State is plain data with a Version field and a JSON round trip, holding an agk.Run as its head and restating nothing that sits there. The controller says failover is a state resume and never a rebuild, so the state has to be something a database can hold and a second process can pick up: load the State, call Next, get the Plan the dead instance would have got. Evaluator is a handle over a Graph and a State and holds no progress of its own.

Reading a workflow file

The entry point is read by hand, with closed decoding, rather than through a JSON Schema evaluator, on the precedent agk already set for the envelope and for the same reason: a refusal has to name the step, the port and the rule in the documentation's own words, and a JSON Schema error names a keyword and a JSON Pointer. The entry point and the brick manifest are the engine's own shapes; package schema stays what its own doc says it is, JSON Schema for the schemas a user writes, and is used here for exactly that, workflow inputs and brick params and port schemas.

The claim that this reader agrees with the released workflow.schema.json is not made in the code. It is made by corpus_test.go, which runs the vendored fixture corpus and holds each invalid document to the rule its index entry names. The index marks every invalid workflow fixture refused_by "schema" or "validator"; everything marked schema is shape, which closed decoding gives for nothing, and the twenty marked validator are, item for item, the rules this package owns. Rule values are spelled as the corpus spells them, so that holding a fixture to its rule is one lookup and not a translation table somebody has to keep in step.

The file is read with a YAML 1.2 parser. That is load bearing rather than a preference: a 1.1 parser reads the bare key on: as the boolean true and fails every document carrying a trigger.

Where the documentation is silent

Fifteen readings are taken here, each recorded beside the rule that applies it rather than only in this list, because otherwise whoever writes the driver, or the next reader of a workflow file, settles them again and differently.

Exit 125 and above is an infrastructure failure, charged to the runner and not to the brick, and retry.on has no name for it: its four names are transient, failed, lost and timeout. So it is its own band and cannot be retried by name. Folding it into transient would retry what the documentation charges to the runner.

A join publishes the unmatched envelope on the step's own unmatched output port, without the container ever touching it. The engine writes a port the brick never writes, which is why a step doing a join declares it in outputs.

A step whose if is false publishes empty envelopes with count 0 and no artifact, so materialising an empty port asks nothing of the store.

A failure without continue_on_error fixes the run verdict at failed, and evaluation continues. Otherwise when: [always] on a cleanup step never runs and the keyword means nothing, and the run states list three causes of cancelled, of which a step failure is not one.

Precedence among timed_out, cancelled and failed when more than one is true.

Whether max_parallel counts a retry attempt, and whether it is read per step or per step per attempt.

Whether a requeue after a loss waits out the backoff. It does not: a backoff spaces attempts so that a dependency which is briefly unwell is not hammered, and a requeue is the same attempt handed out again because a host went quiet.

Whether the ending of a dispatch the shard was requeued past ends the attempt. It does not: that dispatch was judged lost and handed out again, and the requeue, still running and owed an ending of its own, is what the attempt waits on.

Which position a step's inputs keyword is read in. It feeds a port, and a port is fed before the step is divided into shards, so it reads what a step reads and not what a shard does: no item, no matrix, and no secret, since the table exposes secrets to params and to secrets alone.

What a fan-out that has not arrived yet does to the expressions beside it. A step whose strategy is written in a block an included file carries has no fan_out in hand between Parse and Load, so its params are compiled in the widest position and the rule that item is read only under fan_out: item is left to Load, which has every block.

What a port the inputs keyword feeds carries. A list is a batch of items and an object is a batch of one, which is what a workflow input declared with an array schema and one declared with an object schema each mean on a port; a scalar is refused, because wrapping one would invent the key it sits under.

The identity of an item on such a port. It is derived from the step, the port and the rank rather than minted, because the evaluator has no identifier generator and because a minted one would move the cache key of every step below it on every evaluation.

run.attempt is 1. A replay is a new run and nothing moves out of a terminal run state, so a run has one attempt; the attempt a container is on is the task's, which AGK_ATTEMPT carries.

What fail_fast does to the shards nobody has handed out yet. They never start: they end cancelled with the ones in flight, a retry waiting out its backoff included, which is the staged rollout the documentation describes, where fail_fast "stops it at the first broken region". Its paragraph on fan-out names only "the shards still running". A step a merge: first cancelled ends its own the same way, since a cancelled step hands nothing out and a shard left pending in it would read as work still to come.

What becomes of a step that broke a rule of the language after the run had started: a zip on envelopes of differing lengths, a batch too large to travel, a condition that does not evaluate, a parameter the manifest refuses once it is resolved. The step failed, which the run states already have an answer for, rather than the evaluation stopping: a run whose Next returned an error would stay at running with nothing to record, and the states have no word for that.

What is deliberately not claimed here

The cache lookup and the reading of a run a replay reuses are not in this surface. CacheKey is computed, because the evaluator already holds the image digest, the step's script, the resolved params and the items of its inputs that make it, and kept on the shard it was handed out for; the controller looks it up, with the tree the step is handed, because a cache hit that resolves to an expired or one-shot artifact is not a hit and only the store knows that. A hit comes back as a Result like any other, a success carrying the envelopes and MemoisedFrom. A replay from a step is a new run started with Options.Reuse, the steps Upstream of it in the state the run it replays left them, made with Reused: which run that is, and whether what it produced is still there, is the store's knowledge and the controller's to read.

Index

Constants

View Source
const DefaultMaxRequeues = 3

DefaultMaxRequeues is how many times one key is handed out again after a loss where the installation sets nothing else.

A loss uses up no retry.max attempt, so without a bound of its own a step whose container takes down every host it lands on would be requeued until the run's timeout, and for ever where the run has none. The bound is the installation's and not the file's because a loss is charged to the infrastructure, and how many hosts one key may cost is for whoever runs them to weigh: an author who could raise it would be spending somebody else's fleet.

View Source
const InputSchemasMaxBytes = 128 << 10

InputSchemasMaxBytes is how much schema a workflow's declared inputs may compile, in all: each input's own schema, and each file it reaches, counted once for every input that reaches it.

A declaration is compiled wherever a run's inputs are bound, which on an installation is at a push and at the first start of a version, on behalf of whoever holds workflow:write or workflow:run. The JSON Schema library's compile time grows faster than the square of a schema's subschemas: an object of bare properties took 0.1 s at 64 KiB, 0.33 s at 128 KiB, 2.5 s at 256 KiB and 12 s at 512 KiB, holding about 10 MiB at 128. So the bound is 128 KiB, and an input schema that describes what a person types or a trigger sends is kilobytes.

View Source
const StateVersion = 1

StateVersion is the shape this package writes and the only one it reads. A state carries it so that a process reading a state written by another version refuses it rather than guessing at what a missing field used to mean.

Variables

View Source
var ErrRefused = errors.New("the workflow is refused")

ErrRefused is what every refusal of this package unwraps to, so that a caller can tell a workflow the language refuses from a failure of its own machinery with one errors.Is.

Functions

func Check

func Check(wf *Workflow) error

Check applies every rule of the language that is about the file as a whole: the rules a reader of one block cannot see, and that no JSON Schema can express, because they need the graph, the workflow's own boundary or its published surface.

It is the second of the three layers. Parse has already refused everything about the shape of the document; Build refuses what only a brick manifest can answer for. A workflow that parses is not yet a workflow that holds together, and a workflow that Check accepts is one whose graph is a graph: every edge names a step that exists and a port that step declares, and no path through it comes back to where it started.

Every refusal here is a *Refusal naming the step, the port and the rule in the documentation's own words, so that a person holding the file can find the line. The first refusal is returned: a workflow is refused, not annotated.

func Images

func Images(wf *Workflow) []string

Images names the image of every step whose manifest has to be read, so that a caller can fetch them between Check and Build.

A script step is not among them, and that is the rule rather than an omission: "the image is treated as a base image and nothing about its ports is inferred", so no manifest is read for it and asking a registry for one would refuse a workflow the language accepts. A sub-workflow call has no image at all.

The list is the distinct references, in order, so that a caller fetching them does it once per image and not once per step.

func IsLibrary added in v0.4.0

func IsLibrary(doc []byte) bool

IsLibrary says whether a repository's root file is written as a library's: a fragment, which another workflow includes and nothing runs, rather than an entry point.

It is told apart by what makes an entry point and a fragment never carries: apiVersion, kind and metadata, the three keys that say what a document is and what it is called. A root file carrying any one of them is an entry point, read as one and refused as one where it lacks the others, so that a workflow that forgot its apiVersion is told so rather than taken for a library nothing runs. A document that cannot be read is no library either, and the entry point's reading refuses it.

func Reusable added in v0.6.0

func Reusable(verdict agk.Verdict, run agk.RunState) bool

Reusable says whether a replay from a step below it may reuse a step that ended with verdict in a run that ended in run. A step not over has nothing to reuse, and nor has one its run's ending cancelled: a run cancelled or timed out ends every step under way as cancelled, and reusing one would start a replay whose step below it is skipped on a barrier that never lifts, a run that ends succeeded having done nothing it was asked for. A step a merge: first cancelled in such a run is refused with them, since nothing tells the two apart once the run has ended, and the replay from the start, or from the step above it, is still there to be asked for.

Types

type Annotations

type Annotations struct {
	ReadOnlyHint    *bool
	IdempotentHint  *bool
	DestructiveHint *bool
	OpenWorldHint   *bool
}

Annotations are the protocol's own hints, "passed through unchanged. They are hints to a client, never a permission: what a tool may do is decided by the grants, not by a field in the file."

Each one is a pointer because a hint the file does not write is not the same as a hint written false, and what is published is what was written.

type Backoff

type Backoff struct {
	Type BackoffType
	Base Duration
	Max  Duration
}

Backoff is how the wait between attempts grows. "exponential is the only type there is: the wait starts at base, lengthens after each failure and never goes past max."

type BackoffType

type BackoffType int

BackoffType is the shape of the wait. There is one, and it is named rather than assumed so that a file saying type: exponential reads as what it is.

const BackoffExponential BackoffType = iota

func (BackoffType) String

func (b BackoffType) String() string

type Call

type Call struct {
	Workflow string
	Ref      string
}

Call is a step that calls a sub-workflow instead of running a container: "workflow: <namespace>/<name>, with @<ref> appended to pin a tag or a commit, or in long form { workflow, ref }".

type Concurrency

type Concurrency struct {
	Group            string
	CancelInProgress bool
}

Concurrency is the policy a trigger follows when a run of the same workflow is still going. "Concurrency groups are scoped to the namespace, so one namespace can never block or cancel another's runs."

type Defaults

type Defaults struct {
	Timeout         *Duration
	Retain          *Retain
	Retry           *Retry
	Resources       *Resources
	Network         *Network
	EgressAllow     []string
	RunsOn          []string
	Secrets         []string
	Cache           *bool
	ContinueOnError *bool
	Idempotent      *bool
	Files           []FileSelector
	Shell           []string
	BeforeScript    []string
	AfterScript     []string
	When            []When
	// contains filtered or unexported fields
}

Defaults is the execution settings a workflow applies to every step that does not say otherwise, and it is exactly the set the documentation lists.

"What it may not carry: image, script, needs, inputs, outputs, params, if, merge, strategy, extends and workflow are refused there. Anything deciding what a step runs, or where it sits in the graph, belongs to the step: the cut is what keeps the graph readable from the steps block alone." The cut is this type: a step is these keywords and the ones that decide what it runs, and defaults is these alone.

Every field is optional. A pointer distinguishes a value the file wrote from one it did not, which matters for every boolean here: cache: false and no cache at all are different things to a step that extends a block setting it true.

type Driver

type Driver interface {
	Run(ctx context.Context, t Task) (Result, error)
	Stop(ctx context.Context, s Stop) error
}

Driver is what runs a container. It is stated here and called by nothing here.

Stating it on this side is the point. The evaluator says which task should run and what it carries; something else runs it, and both halves agree about the shape of the handover because the shape is written once, next to the values that cross it. What this package must never grow is a call to either method: the loop that reads a Plan, hands each Task to a Driver and feeds each Result back belongs to cmd/agk and to the controller, and putting it here would be the evaluator executing.

Stop is a method rather than a cancelled context because a task in flight may be held by a runner in another process, where a context does not reach. Cancelling a context stops the caller waiting; it does not stop a container on another machine, and the three rules that call off running work need the container stopped and not the waiting.

A test fakes the whole of it in four lines, a map from step name to Result, which is what keeps every rule in this package testable with nothing behind it.

type Duration

type Duration time.Duration

Duration is a duration as the workflow language writes it: "one number and one unit taken from ms, s, m, h and d". It is a time.Duration underneath, so that a caller computing a deadline converts and does not parse.

The grammar is narrow on purpose. "A compound such as 1h30m is refused, and so is a bare number", because a file that says 10m where a reader would otherwise have to work out what 600 means is the whole reason the unit is written at all. timeout, retain, the schedule jitter and both backoff values are all written that way.

func ParseDuration

func ParseDuration(s string) (Duration, error)

ParseDuration reads a duration written the way the language writes it.

A day is twenty-four hours here, which is what a retention of 90d and a run timeout of 4h mean beside each other. No calendar is involved: the engine is measuring how long something may take or stay, not what the date will be.

func (Duration) String

func (d Duration) String() string

String writes the duration back the way the file would write it: the largest unit it divides exactly by, so that a duration read from a file and written back out is the text the author wrote.

type Edge

type Edge struct {
	Step agk.Step
	Port agk.Port
	As   agk.Port
}

Edge is one inbound dependency: an output port of one step feeding an input port of this one. "That is the only form of dependency: there is no implicit stage, and the order in which steps appear in the file has no bearing on scheduling."

The short form of needs is a step name alone, which is "{ step: name, port: out, as: in }" written out.

type Evaluator

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

Evaluator is a handle over a Graph and a State. It holds no progress of its own: the graph is fixed at validation and the state is the run, so a second process given the same two answers the same way.

func New

func New(g *Graph, s *State, l agk.Limits, maxRequeues int, maxRunDuration time.Duration) (*Evaluator, error)

New resumes a run from a state. "Failover is a state resume and never a rebuild": load the State, call Next, get the Plan the instance that died would have got.

The size rules, max_requeues and max_run_duration are arguments and not state, because none of them is the run's: they are the namespace's and the installation's, and a pass is decided under the ones that hold when it is taken, so a run whose namespace is given a lower max_run_duration is held to it from its next pass. The size rules are read as Options reads them. The bound is the number itself, zero requeuing nothing, since a caller resuming a run has already read the installation's setting and has nothing left unset to fill in. A negative bound is refused: it counts no number of times, and reading it as none or as the default would be guessing which the installation meant. A max_run_duration of zero bounds nothing, and a negative one is refused for the same reason.

func Start

func Start(g *Graph, run agk.Run, o Options, at time.Time) (*Evaluator, error)

Start begins a run of a graph.

Every step of the graph gets an entry at once, so that a step nobody has reached is a pending entry rather than a missing one and the reduction to a run verdict can tell a state that has not been built from a graph with nothing in it.

func (*Evaluator) Cancel

func (e *Evaluator) Cancel(now time.Time)

Cancel ends the run the way a principal holding workflow:run or a concurrency group ends one. The tasks in flight are named by the next Plan, because stopping a container is the driver's and saying which ones is this package's.

func (*Evaluator) Graph

func (e *Evaluator) Graph() *Graph

Graph is the graph the run is evaluated against.

func (*Evaluator) Next

func (e *Evaluator) Next(now time.Time) (Plan, error)

Next says what should happen now: the tasks that have become ready, the tasks in flight that should be stopped, and the moment to ask again.

The moment is an argument rather than a clock so that evaluation is pure and a replay is exact. A caller that never comes back at Wake does not get a wrong Plan; it gets the right one late.

func (*Evaluator) Outputs

func (e *Evaluator) Outputs() (map[string]agk.Envelope, error)

Outputs are the envelopes the workflow's declared outputs name, which is what a finished run hands back. An output is a view of one step port, so it is available once that step has ended and not before.

func (*Evaluator) Record

func (e *Evaluator) Record(r Result, now time.Time) error

Record takes back what became of one task.

It is the only thing that enters the evaluator after a run has started, which is what makes "a run replays by replaying its Results" true. A result for an attempt that is over changes nothing: the task identifier is the idempotency key, a bus is allowed to deliver twice, and a late answer about an attempt already judged is a duplicate rather than news. Nor does an ending of a dispatch the shard has been requeued past, since the requeue kept the key and only the dispatch tells the two apart: that dispatch was judged lost and handed out again, and the attempt now waits on the requeue.

func (*Evaluator) State

func (e *Evaluator) State() *State

State is the run as a value: what a caller persists, and what New takes back.

type Event

type Event struct {
	Type      string
	Source    string
	Filter    string
	Namespace string
	Map       map[string]any
}

Event is a CloudEvents 1.0 subscription, with a CEL filter over the attributes and over data.

Namespace is the namespace whose events it hears, empty for the workflow's own: another namespace's are heard "where it granted the workflow's namespace read access". Map fills the workflow inputs from the event, as a webhook's fills them from its request.

type FanOut

type FanOut int

FanOut is how a step is divided into shards.

const (
	FanOutNone FanOut = iota
	FanOutItem
	FanOutBatch
	FanOutMatrix
)

func (FanOut) String

func (f FanOut) String() string

type FileSelector

type FileSelector struct {
	From string
	To   string
	Mode string
}

FileSelector narrows what a step receives from the repository tree, "which it otherwise gets whole under /agk/repo/".

The short form is a path or a glob and leaves To and Mode empty; the long form "relocates a path to wherever a tool insists on finding it". Narrowing is "an optimisation for large repositories, never a requirement, and never a permission boundary".

type Fired added in v0.5.0

type Fired struct {
	Commit      string
	Trigger     map[string]any
	Event       map[string]any
	TriggerKind string
	TriggeredBy string

	// Vars are the variables the workflow's namespace shows it, which the file's own are laid over
	// under vars, as the run the trigger starts reads them: the caller hands the run the same ones,
	// so that what its map and its filter read is what its steps read.
	Vars map[string]any
}

Fired is what a trigger's expressions read of the run it fires before the run exists: the request or the occurrence under trigger, the event under event, and the kind and the principal under run. The run's id and start are not yet, and an expression of the on block reading them is refused where it is evaluated.

type Fragment

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

Fragment is an included file: "a fragment, not an entry point. It carries hidden blocks and shared settings; it has no apiVersion, no kind and no metadata."

It is opaque, and deliberately: a Fragment comes from ParseFragment and goes into Load, so there is nothing for a caller to build one out of and no way to hand the evaluator a fragment that was never read as one. "Validating a fragment against the workflow schema is a category error", and so would constructing one be.

func ParseFragment

func ParseFragment(doc []byte) (*Fragment, error)

ParseFragment reads one included file.

What a fragment may carry is what reuse is for: hidden blocks, steps, defaults, vars, secrets and includes of its own. The keys that make a document an entry point are refused here, which is the reading the mcp rule states and the rest of the boundary follows: "the published surface is declared by the workflow that publishes it, so that reading one file tells you everything that workflow exposes". The same is true of the inputs, the outputs and the triggers, which are the workflow's other boundary; an included file that declared one of them would move the boundary out of the file that names it.

A port a fragment's steps and hidden blocks name is held to agk.PortMaxBytes, as Parse holds the entry point's.

type Graph

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

Graph is the resolved graph: the steps of a workflow, the edges between their ports, and an order that no cycle can be read out of.

It is built once, when a workflow is registered or a run starts, and read many times while the run goes. Nothing in it changes as a run proceeds: what changes is the State, and the two are separate so that one graph can serve every run of a version.

func Build

func Build(wf *Workflow, manifests map[string]brick.Manifest) (*Graph, error)

Build resolves a checked workflow into the graph a run is evaluated against, holding every step to the manifest of the brick it runs.

The manifests arrive as an argument, keyed by the image reference Images named, because "reading /agk/brick.yaml means pulling an image, and pulling an image is executing". The rules they answer for are the two the documentation states about a step and its brick, that its outputs are a subset of the manifest's ports and that its params satisfy the manifest's schemas, and neither can be read out of the workflow file alone.

Check runs first, here rather than only in the caller: a graph built out of a workflow whose edges name steps that do not exist is not a graph, and letting Build be reached without it would make the order of two calls a rule somebody has to remember.

func (*Graph) Brick added in v0.2.0

func (g *Graph) Brick(name agk.Step) (brick, version string, ok bool)

Brick is the name and the version the manifest of a step's image declares, and false for a step that runs no brick: a script step, whose image is a base image, or a call.

The manifest's and not the image reference's, because a pushed version names its images by digest, and a digest says which bytes ran but not which release of which brick they are.

func (*Graph) Consumers

func (g *Graph) Consumers(name agk.Step, port agk.Port) []agk.Step

Consumers are the steps that take one port of one step, in the order they were found. A workflow output is a consumer too and is not among them: it is not a step, and it is read off the workflow.

func (*Graph) Edges

func (g *Graph) Edges(name agk.Step) []Edge

Edges are the inbound edges of a step, in the order the file declares them. The order is load bearing: "wait_all waits for every upstream port, then concatenates items in edge declaration order".

func (*Graph) Fill added in v0.5.0

func (g *Graph) Fill(m map[string]any, f Fired) (map[string]any, error)

Fill evaluates what an entry of the on block fills the workflow inputs with, its map, over what fired it: "Build workflow inputs with map over the trigger context". A string carrying an expression is evaluated, and one filling the whole value keeps its type; a map or a list is evaluated through; anything else is the literal the file wrote. What it answers is bound by the one path every run takes, which holds each input to its schema before the run exists.

func (*Graph) FillFromEvent added in v0.5.0

func (g *Graph) FillFromEvent(m map[string]any, f Fired) (map[string]any, error)

FillFromEvent is Fill for an event trigger's map, which reads the event it matched as well.

func (*Graph) Hears added in v0.5.0

func (g *Graph) Hears(e Event, f Fired) (bool, error)

Hears says whether an event trigger's filter accepts the event f carries: true where it writes none, and otherwise its answer, which is true or false and nothing else, as a run condition's is. "Only an event it accepts starts a run", evaluated before any run exists.

func (*Graph) Order

func (g *Graph) Order() []agk.Step

Order is the steps in an order no edge points backwards through.

func (*Graph) Resolved added in v0.4.0

func (g *Graph) Resolved(commit string) ([]byte, error)

Resolved writes the graph down as wire.schema.json's resolvedGraph: what a version's hook accepted, as the engine resolves it for every run of the version, what the API answers and what the console draws.

It is a record rather than a file. Every keyword a step carries is the one resolution left, includes, extends, defaults and the step's own values applied in the language's order, and it is written only where it is not the language's default, so that two workflows resolving to the same steps read the same, whichever layers they took to get there. idempotent is the exception, always written, and so is shell on a script step. The script keywords a defaults block writes reach a script step alone, since "a brick runs its own entry point and a call runs no container, whatever the defaults write", and a record claiming a brick runs a before_script would be describing something that never happens.

It names steps, images, ports and edges, never a value a run was given: an expression is kept as written, for a run to evaluate. And it writes no namespace, since a namespace is the repository's and not part of what a commit resolves to: moving the repository moves nothing here.

commit is the commit the graph was resolved from, which the graph cannot know: it was built from a tree.

func (*Graph) Step

func (g *Graph) Step(name agk.Step) (*Step, bool)

Step is one step of the graph, resolved. It is returned by pointer because the evaluator reads it once per shard per evaluation and copying a step to answer a question about it is a copy nothing needed.

func (*Graph) Steps

func (g *Graph) Steps() []agk.Step

Steps are the steps of the workflow, in name order.

func (*Graph) Upstream added in v0.4.0

func (g *Graph) Upstream(name agk.Step) []agk.Step

Upstream answers the steps a step reads from, through its edges and theirs, in an order no edge points backwards through: what a replay from it reuses.

func (*Graph) Workflow

func (g *Graph) Workflow() *Workflow

Workflow is the file the graph was built from. It is the same value the caller parsed, not a copy: a graph is a reading of a workflow and never a second one.

type Include

type Include struct {
	Path     string
	Workflow WorkflowRef
}

Include is one entry of the include block: either a file of this repository, resolved inside the same commit, or another repository at a ref.

"A workflow include must carry ref, a path include must not, and a ref beside a path is refused. Requiring it is not tidiness: an unpinned cross-repository include would let another repository change what this commit does."

type Included added in v0.4.0

type Included struct {
	Path     string
	Workflow WorkflowRef
	Commit   string
}

Included is one include as resolution applied it: a file of this tree by its path from the root, or another repository with the ref the include wrote and the commit that ref resolved to, which every run of a version records so that the other repository moving a tag afterwards changes nothing this one does.

func LoadLibrary added in v0.4.0

func LoadLibrary(fsys fs.FS, remote Remote) ([]Included, error)

LoadLibrary reads a library repository's root agentiik.yaml as a fragment and resolves what it includes, which is what "its own hook validates as a fragment" means: the file read on a fragment's terms, every path include inside the tree and every workflow include reached through remote, each at most once and none coming back to a file still being resolved. It answers what was included, in the order it applied.

Nothing else is held of it. A step the library writes may extend a block the workflow including it declares, or be completed by that workflow's defaults, so the steps are resolved where the library is included, against everything the workflow reaches, and never on their own here.

func LoadStoredLibrary added in v0.4.0

func LoadStoredLibrary(fsys fs.FS, remote Remote) ([]Included, error)

LoadStoredLibrary is LoadLibrary for a library already stored, with the rules it was stored under, as LoadStored is Load's.

type Input

type Input struct {
	Schema   json.RawMessage
	Required bool
	Default  any
}

Input is one declared workflow input: the boundary a trigger fills.

Schema is the JSON Schema document as it was written, uncompiled, because compiling it resolves a $ref against the commit's tree and the tree is not this package's to hold. Required and Default are the two keywords that are not JSON Schema's.

type Join

type Join struct {
	On string
}

Join is the key join: "a JSON path, for example join: { on: "$.data.customer_id" }".

type MCP

type MCP struct {
	Name        string
	Description string
	Tools       []Tool
}

MCP is the published surface: which inputs and outputs a model-driven client may reach, under what names.

A workflow carries a pointer to one rather than a value, because "an empty list publishes a server with nothing on it, which is not the same as declaring no mcp block at all: the first serves an empty tool list, the second serves nothing and returns 404".

type Merge

type Merge int

Merge is how several edges arriving on one input port become the single envelope that port carries.

const (
	MergeWaitAll Merge = iota
	MergeZip
	MergeJoin
	MergeFirst
)

func (Merge) String

func (m Merge) String() string

type Metadata

type Metadata struct {
	Name      string
	Namespace string
	Labels    map[string]string
}

Metadata is what the workflow is called and which namespace owns it. "Ownership is not only naming: a workflow reaches the secrets, the quotas and the runner pools of the namespace that owns it, and of no other."

type Network

type Network int

Network is the posture a step's container gets. "Every task gets its own network, and there is no posture that puts a container on the host's."

const (
	NetworkNone Network = iota
	NetworkEgress
	NetworkInternal
)

func (Network) String

func (n Network) String() string

type Options

type Options struct {
	// Inputs are the workflow inputs the trigger supplied, already held to their
	// declared schemas with required and default applied. That validation is package
	// schema's and it happens before a run exists, so what arrives here is what the
	// run holds.
	Inputs map[string]any

	// Vars are the workflow and namespace variables, already merged, because which of
	// the two a name came from is the API's business and not an expression's.
	Vars map[string]any

	// Trigger is body, headers, query and scheduled_for. Event is the CloudEvents 1.0
	// document an event trigger matched.
	Trigger map[string]any
	Event   map[string]any

	// Limits are the namespace's four size rules, applied wherever an envelope is
	// built here: a merge, a publication and a port the inputs keyword feeds. The zero
	// value is the documented defaults, because a caller that says nothing about them
	// means the rules and not their absence.
	Limits agk.Limits

	// MaxRequeues is the installation's max_requeues: how many times one key is handed
	// out again after a loss. Nil is DefaultMaxRequeues, for the reason the zero Limits
	// are the defaults, and zero is an installation that requeues nothing, which is what
	// max_requeues: 0 says. A pointer, because the two are different settings, as a hint
	// a file does not write is not one written false: read as an int, the zero a caller
	// left unset and the zero an installation wrote would be one value, and one of them
	// would be read as the other.
	MaxRequeues *int

	// MaxRunDuration is the namespace's max_run_duration, which bounds the root timeout: a
	// workflow asking for more is held to it and one writing none is bounded by it. Zero
	// bounds nothing, as a namespace that sets none and agk run --local, which has no
	// namespace, bound nothing.
	MaxRunDuration time.Duration

	// Reuse are the steps a replay reuses, each in the state the run it replays left it,
	// made with Reused: they start over and are never handed out.
	Reuse map[agk.Step]StepState
}

Options is everything a run carries that the workflow file does not.

type Output

type Output struct {
	From struct {
		Step agk.Step
		Port agk.Port
	}
	Schema json.RawMessage
	Retain Retain
}

Output is one declared workflow output: a view of one step port.

From names the step and the port, and never more than one of each: "an output that concatenated several would hide which step produced what".

type Placed added in v0.4.0

type Placed struct {
	Path string
	To   string
	Mode string
}

Placed is one file a step's files select: its path in the tree, where it goes when a selector relocates it, absolute, and the mode a selector gives it, empty where none does.

func Place added in v0.4.0

func Place(selectors []FileSelector, tree []string) ([]Placed, error)

Place answers the files of a tree that selectors select, each once for every place it goes, in the order of the tree and then of the selectors. No selector is the whole tree, where every file stays where it is, since narrowing is "an optimisation for large repositories, never a requirement". A file two selectors put in one place is placed there once, with the mode of the last that gives one, and two files one place is refused.

type Plan

type Plan struct {
	Start []Task    `json:"start,omitempty"`
	Stop  []Stop    `json:"stop,omitempty"`
	Wake  time.Time `json:"wake,omitzero"`
}

Plan is what should happen next: the tasks that have become ready, the tasks that should be stopped, and the moment to ask again.

Wake is zero when nothing waits on the clock. It is not zero when something does, and two things do: a retry backoff, which places an attempt at a moment in the future, and the root timeout, which places the end of the run at one. A caller that never comes back at Wake does not get a wrong Plan; it gets the right one late.

type Position added in v0.4.0

type Position struct {
	File   string
	Line   int
	Column int
}

Position is where a refusal points: the file of the tree, and the line and column its node begins at, each counted from 1, as a YAML 1.2 parser counts them. Line and Column are zero where the refusal is about the file as a whole, or about something no one place in it holds, and File is empty where it is about no file of the tree at all, a brick manifest's among them.

"The refusal is written on git's error stream as file:line:column: message (rule)", so that a person holding the file is sent to the line rather than to the key somewhere above it.

func (Position) String added in v0.4.0

func (p Position) String() string

String is the position as a compiler writes one: file:line:column, file alone where no line is known, and nothing where there is no file.

type Refusal

type Refusal struct {
	Step   agk.Step
	Port   agk.Port
	Rule   Rule
	Detail string
	At     Position
}

Refusal is a workflow refused by a rule of the language, naming the step, the port and the rule, in the documentation's own words, and where in the tree it was written.

Step and Port are empty where the rule is not about one: a cycle is about a list of steps and a published tool is about the workflow's own boundary. Detail is the sentence, and it quotes the documentation rather than describing the code, because the person reading it is holding the file and not this package.

At is the file, line and column of the node refused, as far as the engine can know them: a hook writes a refusal as file:line:column: message (rule), and a person given a rule and a step still has to find which of four files wrote the value. A refusal about the tree or about a file as a whole names the file alone, and one about no file of the tree names none.

func (*Refusal) Error

func (r *Refusal) Error() string

func (*Refusal) Unwrap

func (r *Refusal) Unwrap() error

Unwrap makes every refusal one errors.Is, so that a caller separating a refused workflow from a failure of its own does not have to know the rules to do it.

type Remote added in v0.4.0

type Remote interface {
	Include(ref WorkflowRef) (fs.FS, string, error)
}

Remote reaches the repositories a workflow include names.

"A workflow include must carry ref, a tag or a commit, and needs workflow:read on that repository", and both the ref and the permission are the caller's to settle: which tag names which commit, who is asking and whether they may, are the installation's. What comes back is the other repository's tree at the commit the ref resolved to, and that commit, whole, which the resolved graph records so that the other repository moving a tag afterwards changes nothing this one does.

type Resources

type Resources struct {
	CPU    string
	Memory string
	PIDs   int
}

Resources is what a step asks for, "capped by the runner policy and by the namespace quota".

CPU and Memory are text because the language writes them as text: "cpu and memory are written exactly as the brick manifest writes them, quotation marks included: cpu: 1 unquoted is refused", so that half a core reads as 0.5 wherever it travels.

type Result

type Result struct {
	Task     agk.TaskID    `json:"task"`
	State    agk.TaskState `json:"state"`
	ExitCode int           `json:"exit_code,omitempty"`
	Requeue  int           `json:"requeue,omitempty"`
	Reason   string        `json:"reason,omitempty"`

	// NoExitCode says the result reported no exit code at all, which the 0 of ExitCode cannot
	// say. A stopped task whose container started may report none: a host answering a requeue
	// from a record it kept before stops kept their code has none to give. The evaluator reads
	// no code off a stopped task, so nothing is decided by it; it keeps the task's row from
	// reading 0, the code of success.
	NoExitCode bool `json:"no_exit_code,omitempty"`

	Outputs map[agk.Port]agk.Envelope

	// MemoisedFrom is the run a cache hit republished these outputs from, for a success no
	// container reported: "a hit republishes the same envelopes without starting a container".
	MemoisedFrom agk.RunID `json:"outputs,omitempty"`

	// Called is the run a call started, on the dispatch of a step that calls a workflow: what
	// the step waits on in place of a container, and whose outputs its ports become.
	Called agk.RunID `json:"called,omitempty"`

	DispatchedAt time.Time `json:"dispatched_at,omitzero"`
	StartedAt    time.Time `json:"started_at,omitzero"`
	FinishedAt   time.Time `json:"finished_at,omitzero"`
}

Result is what became of one task. It is the only thing that enters the evaluator after a run has started.

State may be non-terminal: recording the dispatch is what fixes the task's deadline, because the deadline runs from the moment the work became somebody's. A caller that records only terminal results gets a deadline computed from the moment the task was planned instead, which drifts forward with every plan, and that is the honest consequence of not telling the evaluator rather than a rule it can enforce.

ExitCode is read for a task that succeeded or failed and for no other state, through agk.Band, which is the exit code table and the only place a verdict comes from.

Outputs is one envelope per declared port, which is what brick.Collect returns, empty envelopes included: a port the container never wrote publishes an empty envelope, and that is success.

Requeue names the dispatch an ending is about, as ShardState counts them. The key names the unit of work and a requeue keeps it, so only this tells the requeue's ending from a late one of the dispatch it replaced, and only the requeue's is news. The dispatch before it was judged lost and handed out again: its loss has been heard, and whatever its runner reports afterwards is the report of a runner the attempt stopped waiting on, while the requeue is still out and still owed an answer.

Reason is why a failure that no container explains happened, in words for a person: the controller refusing to publish a task to a pool that does not accept its namespace, for one. It becomes the step's reason where the failure ends the step and nothing gave the step one first, since an exit code of 125 says whose account a failure is on and not what it was.

type Retain

type Retain struct {
	For     Duration
	Fetches int
}

Retain is how long the artifacts of an output stay fetchable, and how many times they may be fetched.

Fetches is zero where the file wrote none, which is "for as many fetches as anyone makes". Only a workflow output may carry one: "an artifact travelling between two steps is read once per shard and again by a replay".

type Retry

type Retry struct {
	Max     int
	On      []agk.Failure
	Backoff Backoff
}

Retry is the retry policy: how many further attempts, on which kinds of failure, and how long the wait between them grows.

type Rule

type Rule string

Rule is what a document is refused by, spelled as the fixture corpus spells it.

The spelling is the point. The released corpus names, for every invalid document, the rule it holds a reader to, and a test that puts a fixture beside the rule it pins is then one lookup rather than a translation table somebody has to keep in step. The corpus marks each invalid workflow refused_by "schema" or "validator": everything marked schema is shape, which closed decoding gives for nothing and which is refused here by an ordinary error naming the key, and the twenty marked validator are, item for item, the rules below that carry a corpus name.

const (
	// RuleCycleInGraph is "cycles are rejected at validation, before registration.
	// Looping is done by calling a sub-workflow".
	RuleCycleInGraph Rule = "cycle-in-graph"
	// RuleNeedsUnknownStep is an edge from a step the workflow does not declare. "An
	// edge is the only form of dependency there is", so it has to name something that
	// exists.
	RuleNeedsUnknownStep Rule = "needs-unknown-step"
	// RuleEdgePortNotDeclared is an edge taking an output port nobody declares.
	RuleEdgePortNotDeclared Rule = "edge-port-not-declared"
	// RuleStepOutputNotInManifest is a step declaring an output port its brick manifest
	// does not carry: "outputs must be a subset of the ports in the brick manifest".
	RuleStepOutputNotInManifest Rule = "step-output-not-in-manifest"
	// RuleStepInputPortNotInManifest is a step feeding an input port nobody declares.
	// "The runner mounts one directory per port the brick declares, and there is no
	// directory for a port the manifest never named."
	RuleStepInputPortNotInManifest Rule = "step-input-port-not-in-manifest"
	// RuleOutputFromUnknownStep is a workflow output taken from a step the workflow
	// does not declare.
	RuleOutputFromUnknownStep Rule = "output-from-unknown-step"
	// RuleSecretNotDeclared is a step mounting a secret the workflow never names.
	// "Secrets are mounted by the name the secrets block lists them under, and only
	// secrets the owning namespace declares can be named there."
	RuleSecretNotDeclared Rule = "secret-not-declared"
	// RuleScriptWithoutOutputs is a script step that declares no output port. "The
	// image is a base image, no manifest is read and nothing about its ports is
	// inferred, so outputs must be declared."
	RuleScriptWithoutOutputs Rule = "script-without-outputs"
	// RuleMCPToolInputNotDeclared is a published tool naming something the workflow
	// does not declare. "The reference is to a workflow input or output, never to a
	// step or a port."
	RuleMCPToolInputNotDeclared Rule = "mcp-tool-input-not-declared"
	// RuleMCPToolInputWithoutSchema is a tool whose input is a workflow input carrying
	// no schema. "A tool has to publish an inputSchema, so the input it maps has to
	// have one."
	RuleMCPToolInputWithoutSchema Rule = "mcp-tool-input-without-schema"
	// RuleMCPDuplicateToolName is two published tools carrying the same name. "A tool
	// identifier is unique within the workflow, because clients hold it."
	RuleMCPDuplicateToolName Rule = "mcp-duplicate-tool-name"
	// RuleMCPInIncludedFile is an mcp block in an included file. "Reuse applies to
	// steps and defaults; the published surface is declared by the workflow that
	// publishes it, so that reading one file tells you everything that workflow
	// exposes."
	RuleMCPInIncludedFile Rule = "mcp-in-included-file"
	// RuleExpressionUnknownRoot is an expression reading a context root that is not
	// exposed. The roots are the ten of the table and there is no eleventh.
	RuleExpressionUnknownRoot Rule = "expression-unknown-root"
	// RuleExpressionItemOutsideFanOutItem is an expression reading item where the step
	// is not sharded per item, "which is what keeps the controller from loading a
	// namespace's data in order to schedule".
	RuleExpressionItemOutsideFanOutItem Rule = "expression-item-outside-fan-out-item"
	// RuleExpressionSecretInCondition is a condition reading a secret. "secrets is
	// exposed to params and to secrets and nowhere else."
	RuleExpressionSecretInCondition Rule = "expression-secret-in-condition"
)

The rules the corpus names. Each one is a sentence of the documentation that no JSON Schema can express, because it needs the graph, the brick manifest, the included file or the expression language.

const (
	// RuleManifestMissing is a brick step whose manifest was not handed in. Reading
	// /agk/brick.yaml means pulling an image and pulling an image is executing, so the
	// manifests arrive as an argument and the evaluator says which ones it needs.
	RuleManifestMissing Rule = "manifest-missing"
	// RuleParamsAgainstManifest is a step parameter the manifest refuses: one it does
	// not declare, one it declares required and the step does not supply, or one whose
	// value departs from the schema the manifest gives it.
	RuleParamsAgainstManifest Rule = "params-against-manifest"
	// RuleZipLength is merge: zip on envelopes of differing lengths, which "produce a
	// validation failure".
	RuleZipLength Rule = "zip-length"
	// RuleMCPSyncTimeoutAboveCeiling is a sync tool waiting longer than the ceiling.
	// "A sync call is capped at 120 seconds because clients time out, and a workflow
	// that cannot finish inside it has to be async."
	RuleMCPSyncTimeoutAboveCeiling Rule = "mcp-sync-timeout-above-ceiling"
	// RuleMCPTimeoutOnAsyncTool is a timeout on an async tool, "where it would mean
	// nothing: the call returns the run identifier at once". The value is the corpus's
	// own name for the fixture that pins it.
	RuleMCPTimeoutOnAsyncTool Rule = "mcp-async-tool-with-timeout"
	// RuleExpressionSecretInText is a secret in a position inside a string. "An
	// expression that fills the whole value keeps its type; an expression embedded in
	// a string is converted to text", and a secret has no text: it is an opaque
	// reference, so the expression that reads one fills the whole value or nothing.
	RuleExpressionSecretInText Rule = "expression-secret-in-text"
	// RuleExpressionDoesNotCompile is an expression that is not one: it never closes,
	// it does not parse, or it compares values whose types cannot be compared. CEL
	// "evaluates in linear time, is mutation free, and not Turing-complete", and what
	// it cannot check before a run is the one thing this engine cannot promise.
	RuleExpressionDoesNotCompile Rule = "expression-does-not-compile"
)

The rules the corpus does not carry, because a fixture cannot show them: two of them need a manifest the corpus keeps for other fixtures, two are read when a run is already going, and one is a grammar the released schema carries in a pattern rather than in a fixture of its own. They are spelled the same way all the same, so that one vocabulary names every refusal this package makes.

const (
	// RuleIncludeLeavesTree is a path include naming a file outside the repository tree. "A
	// path include resolves inside the same commit", and nothing outside the tree is readable
	// at all.
	RuleIncludeLeavesTree Rule = "include-leaves-tree"
	// RuleIncludeMissing is a path include naming a file the commit does not hold.
	RuleIncludeMissing Rule = "include-missing"
	// RuleIncludeCycle is an include coming back to a file still being resolved, which would
	// never finish resolving.
	RuleIncludeCycle Rule = "include-cycle"
	// The keys that make a document an entry point, refused in an included file one rule
	// each, as the fragment group files one fixture each and mcp-in-included-file already
	// was. "An included file is a fragment, not an entry point."
	RuleAPIVersionInIncludedFile  Rule = "api-version-in-included-file"
	RuleKindInIncludedFile        Rule = "kind-in-included-file"
	RuleMetadataInIncludedFile    Rule = "metadata-in-included-file"
	RuleInputsInIncludedFile      Rule = "inputs-in-included-file"
	RuleOutputsInIncludedFile     Rule = "outputs-in-included-file"
	RuleOnInIncludedFile          Rule = "on-in-included-file"
	RuleConcurrencyInIncludedFile Rule = "concurrency-in-included-file"
	RuleTimeoutInIncludedFile     Rule = "timeout-in-included-file"
)

The rules the repository fixtures name, which are about the files of a tree together rather than about one document: what an include reaches, what an included file may carry, and the bound a port and a workflow output are held to in every file a version is made of. They are spelled as the corpus names them, so that a hook writing one on git's error stream and a fixture pinning it read the same word.

const (
	// RuleCronValueOutOfRange is a schedule whose five fields are cron's shape and name a value
	// its field does not count, a minute of 60, a step of 0, a range written backwards, or no
	// day that comes, the 30th of February: an occurrence that never comes.
	RuleCronValueOutOfRange Rule = "cron-value-out-of-range"
	// RuleTimezoneUnknown is a schedule's zone written as a name the IANA time zone database
	// does not hold, so that no instant can be read from it.
	RuleTimezoneUnknown Rule = "timezone-unknown"
	// RuleWebhookOutputNotDeclared is a sync webhook answering with an output the workflow does
	// not declare: what a caller is answered with is one of the workflow's own outputs.
	RuleWebhookOutputNotDeclared Rule = "webhook-output-not-declared"
	// RuleWebhookMapInputNotDeclared is a webhook's map filling an input the workflow does not
	// declare: map builds the run's inputs, each validated against its schema before the run
	// exists, and an input nobody declared has none.
	RuleWebhookMapInputNotDeclared Rule = "webhook-map-input-not-declared"
	// RuleEventMapInputNotDeclared is an event trigger's map filling an input the workflow does
	// not declare, for the reason a webhook's is refused.
	RuleEventMapInputNotDeclared Rule = "event-map-input-not-declared"
	// RuleWebhookDuplicatePath is two webhooks answering one path with one method: "within a
	// namespace a path and a method answer one trigger".
	RuleWebhookDuplicatePath Rule = "webhook-duplicate-path"
	// RulePortPastBound is a port past agk.PortMaxBytes, wherever it is written: "a port
	// becomes the file <name>.json, which a filesystem holds to 255 characters with its
	// suffix". The value is the name the corpus files the fixture pinning it under.
	RulePortPastBound Rule = "port-name-251-characters"
	// RuleWorkflowOutputPastBound is a workflow output past the same bound, for the same
	// reason.
	RuleWorkflowOutputPastBound Rule = "workflow-output-251-characters"
)

The rules about the triggers a workflow declares, each one what the schema lets through and the Triggers chapter refuses.

type Schedule

type Schedule struct {
	Cron     string
	Timezone string
	Jitter   Duration
	CatchUp  bool
}

Schedule is a five-field cron expression read in the timezone written beside it.

type SecretMount

type SecretMount struct {
	Name  string
	Mount string
}

SecretMount is one secret a task is given: the name the workflow knows it by, and the path under /agk/secrets/ the brick manifest asks for it at.

It carries no value and never will. "The runner mounts secret values on tmpfs" and the evaluator decides which secrets a task gets, not what they are.

type SecretParam

type SecretParam struct {
	Secret string `json:"secret"`
}

SecretParam is a step parameter whose value is a secret: a reference the runner redeems, and never the secret itself.

It exists because "sensitive is the only route by which a secret reaches a brick as a parameter rather than as a mounted file", and because a task message "names everything the runner needs and contains nothing sensitive". So the parameter travels as the name the workflow declared, and the value is put in /agk/params.json by the runner that has redeemed its grant, in the container and nowhere else.

type ShardState

type ShardState struct {
	Shard  agk.Shard      `json:"shard,omitzero"`
	Matrix map[string]any `json:"matrix,omitempty"`

	// Attempt counts from 1, as AGK_ATTEMPT does.
	Attempt int `json:"attempt"`

	// Requeue counts the times this attempt was handed out again after its task was
	// lost, from 0 for the first time it was handed out. A requeue keeps the attempt
	// and so the idempotency key, which leaves this as the one thing that tells two
	// dispatches of one key apart: the controller keeps a row for each, and an ending is
	// news only about the dispatch the shard is on. It is also what max_requeues is
	// counted against, which is why the bound is per key.
	Requeue int `json:"requeue,omitempty"`

	// Task and ExitCode are what the driver reported, and they are read together:
	// the exit code table is the only place a verdict comes from, and the code is
	// read for a task that succeeded or failed and for no other state.
	Task     agk.TaskState `json:"task"`
	ExitCode int           `json:"exit_code,omitempty"`

	// NoExitCode is Result's: the ending reported no exit code, which ExitCode's 0 cannot say.
	NoExitCode bool `json:"no_exit_code,omitempty"`

	// Stopped says the evaluator ended this shard itself, cancelled, in the pass that
	// named its stop as superseded or sibling_failed, rather than hearing the ending from a
	// driver. The driver's report of it comes later, to a shard that is over, and all it
	// may still add is how the container exited: Record reads it for that and for nothing
	// else.
	Stopped bool `json:"stopped,omitempty"`

	// Ports is what this shard published, before the shards of the step are
	// concatenated port by port into what the step publishes.
	Ports map[agk.Port]agk.Envelope `json:"ports,omitempty"`

	// MemoisedFrom is the run whose task published what a cache hit republished for this
	// shard, which no container ran.
	MemoisedFrom agk.RunID `json:"memoised_from,omitempty"`

	// Called is the run this shard's call started, where its step calls a workflow.
	Called agk.RunID `json:"called,omitempty"`

	// CacheKey is the memoisation key the shard's current attempt was handed out with, where
	// its step is cached, so that whoever records its ending can remember what it published
	// under the key it was asked for by, without building the task again.
	CacheKey string `json:"cache_key,omitempty"`

	// The four moments a task passes through. DispatchedAt is what fixes the
	// deadline, because the deadline runs from the moment the work became somebody's
	// and not from the moment it was decided on. NextAttemptAt is when the shard may
	// be handed out again, and is zero when there is nothing to wait for.
	DispatchedAt  time.Time `json:"dispatched_at,omitzero"`
	StartedAt     time.Time `json:"started_at,omitzero"`
	FinishedAt    time.Time `json:"finished_at,omitzero"`
	NextAttemptAt time.Time `json:"next_attempt_at,omitzero"`
}

ShardState is what has happened to one shard of one step: which shard it is, which attempt it is on, and what the driver last said about the task running it.

A shard that is retried keeps its place here and moves to its next attempt rather than being added beside itself. One shard is one piece of the work, however many attempts it takes, which is what lets max_parallel count what holds a runner and fail_fast name what to stop.

type State

type State struct {
	Version int            `json:"version"`
	Run     agk.Run        `json:"run"`
	Inputs  map[string]any `json:"inputs,omitempty"`
	Vars    map[string]any `json:"vars,omitempty"`

	// Trigger and Event are what started the run: "body, headers, query,
	// scheduled_for" and the CloudEvents document an event trigger matched. They sit
	// here rather than beside the evaluator because failover is a state resume: a
	// parameter written as ${{ trigger.body.cycle }} has to resolve to the same value
	// in the process that picks the run up, and a root the second process was never
	// given would resolve to nothing at all.
	Trigger map[string]any `json:"trigger,omitempty"`
	Event   map[string]any `json:"event,omitempty"`

	// Steps holds one entry per step of the graph, put there when the run starts,
	// so that a step nobody has reached is a pending entry rather than a missing
	// one and the reduction to a run verdict can tell the two apart.
	Steps map[agk.Step]StepState `json:"steps"`

	// Seq counts the decisions taken against this state. It is what a caller
	// persisting the state writes beside it to tell a stale copy from a current
	// one, and what makes two writers of one run detectable rather than silent.
	// A call that leaves the state as it was is no decision and does not move it,
	// so a caller has nothing to write when it has not moved.
	Seq int `json:"seq"`
}

State is one run in progress: what it was started with, and what has happened to each of its steps.

It restates nothing that sits in the agk.Run it holds. The run identifier, the workflow, the namespace, the commit and the state of the run itself are the run's, and a second copy here would be a second answer to what a run is.

func (*State) UnmarshalJSON added in v0.5.0

func (s *State) UnmarshalJSON(b []byte) error

UnmarshalJSON reads a state as it is written, and as it was written before v0.5.0, whose run carried its trigger kind as trigger rather than trigger_kind: a run in flight across the upgrade resumes from a state an earlier release stored.

Here rather than on agk.Run, whose method a struct embedding a run would take for its own and then decode nothing else with.

type Step

type Step struct {
	Image string
	Call  *Call

	Needs   []Edge
	Inputs  map[agk.Port]any
	Outputs []agk.Port
	Params  map[string]any

	If       string
	When     []When
	Merge    Merge
	Join     Join
	Strategy Strategy

	Retry           Retry
	Timeout         Duration
	Retain          Retain
	ContinueOnError bool
	Resources       Resources
	Network         Network
	EgressAllow     []string
	RunsOn          []string
	Secrets         []string
	Cache           bool
	Idempotent      bool
	Files           []FileSelector

	Script       []string
	BeforeScript []string
	AfterScript  []string
	Shell        []string

	// Extends is the hidden blocks this step inherits from, the one it names first and each
	// block that one extends after it, kept after resolution because a run detail that says
	// where a setting came from reads better than one that shows the setting alone.
	Extends []string
}

Step is one step of the graph, with every keyword resolved: what arrived from an include, then from the blocks it extends, then from defaults, then what the step wrote itself.

It carries an image or a call and never both, and a call "runs no container of its own", so Script, BeforeScript, AfterScript and Shell are refused beside one.

type StepState

type StepState struct {
	Verdict agk.Verdict  `json:"verdict"`
	Shards  []ShardState `json:"shards,omitempty"`

	// Ports is the publication: exactly one envelope per port the step declares,
	// written once, when the step ends. It is empty until then, which is what the
	// barrier below reads as a port that has not arrived.
	Ports map[agk.Port]agk.Envelope `json:"ports,omitempty"`

	// Since is when the step reached the verdict it carries. A published port is
	// stamped with it, and an empty envelope standing in for a step that published
	// nothing is stamped with it too, so that a batch of nothing still says when it
	// was not produced.
	Since time.Time `json:"since,omitzero"`

	// Reason says why, in the words of the rule that decided it, for the verdicts
	// where why is not obvious: a skip, a cancellation, a step whose when refused
	// every upstream state it saw. A run detail that says why reads better than one
	// that shows a verdict alone.
	Reason string `json:"reason,omitempty"`
}

StepState is what has happened to one step: the verdict it has reached, the shards it was split into, and the envelopes it published when it ended.

func Reused added in v0.4.0

func Reused(from StepState, run agk.RunID) StepState

Reused is the state a replay starts a step it reuses in: the verdict, the published envelopes and when, of the run it replays, stamped as the new run's own, since a step's inputs are concatenated from what its edges carry and one batch names one run. It carries no shard: no task of the new run did the work, and none is shown as having done it.

type Stop

type Stop struct {
	Task   agk.TaskID `json:"task"`
	Reason StopReason `json:"reason"`
}

Stop is one task in flight that should be stopped, and why.

It exists because three rules in the documentation call off work that is already running, and a decision to stop is as much a decision as a decision to start. A controller that only ever heard about starts would leave a shard running for an hour after the run it belongs to was cancelled.

type StopReason

type StopReason int

StopReason is why a task is being stopped. There are four, and each one is a rule the documentation states rather than a category invented here.

const (
	// StopSuperseded: a merge: first lifted the barrier on another edge, and no
	// other consumer needs what this task would publish. It names the tasks of the
	// cancelled step not started yet too, which never start.
	StopSuperseded StopReason = iota

	// StopSiblingFailed: fail_fast, where the first shard to fail stops the shards
	// still running beside it, and names the ones not started yet, which never start.
	StopSiblingFailed

	// StopDeadline: the deadline set by the root timeout of the entry point passed,
	// and the tasks still running are stopped.
	StopDeadline

	// StopCancelled: a principal holding workflow:run asked, or a concurrency group
	// did.
	StopCancelled
)

func (StopReason) MarshalText

func (s StopReason) MarshalText() ([]byte, error)

MarshalText writes the reason as it is spelled, and refuses one that has no spelling.

String names such a reason "stopped", which is a word for a log line and not a reason anybody can read back: written on the wire it would be a stop no runner could decode, for a container that then runs to its deadline.

func (StopReason) String

func (s StopReason) String() string

String names the reason.

func (*StopReason) UnmarshalText added in v0.2.0

func (s *StopReason) UnmarshalText(text []byte) error

UnmarshalText reads a reason back from its spelling, which is what a runner hearing a stop does with the one the controller wrote.

Only the four spellings are read. Anything else is refused rather than read as some reason, because the reason decides what the stopped task becomes, timed_out for the deadline and cancelled for the other three, and a guess would be a guess about that.

type Strategy

type Strategy struct {
	FanOut      FanOut
	Batch       int
	MaxParallel int
	Matrix      map[string][]any
	FailFast    bool
}

Strategy is the strategy block: how the step is divided, how much of it runs at once, what combinations it runs over, and whether one failing shard stops its siblings.

Batch is the size written inside batch(n), and it is zero unless FanOut is FanOutBatch.

type Task

type Task struct {
	// ID is the idempotency key, run/step/attempt/shard, derived from what makes
	// this task this task and never minted. A driver that has already done the work
	// recognises it, which is what makes at-least-once delivery survivable.
	ID agk.TaskID `json:"id"`

	// Dispatch is the task_id of the dispatch a runner took, and empty where nothing was
	// dispatched, as on a laptop. The evaluator never sets it: a control plane mints one per
	// dispatch, and a requeue after loss keeps the key and takes a new one. The driver names the
	// container's span by it, falling back to the key, so that each dispatch is a span of its own
	// and agk run --local still has one to name.
	Dispatch string `json:"dispatch,omitempty"`

	Run       agk.RunID `json:"run"`
	Workflow  string    `json:"workflow"`
	Namespace string    `json:"namespace"`
	Commit    string    `json:"commit"`

	Step    agk.Step  `json:"step"`
	Attempt int       `json:"attempt"`
	Shard   agk.Shard `json:"shard,omitzero"`

	// Image is what the container is, or Call is the sub-workflow this step is
	// instead. A step carries one or the other and never both.
	Image string `json:"image,omitempty"`
	Call  *Call  `json:"call,omitempty"`

	// CallInputs are what a call hands the workflow it calls as that workflow's inputs: each
	// value the step's inputs keyword writes, of whatever type it evaluates to, and each port
	// another step feeds it, as the list of its items' data. Empty for a step that runs an image.
	CallInputs map[string]any `json:"call_inputs,omitempty"`

	// The script keywords, where the image is a base image rather than a brick.
	// after_script runs in the same container even when script failed, so that a
	// diagnostic dump survives a failure, and its own exit code does not change the
	// step's verdict: keeping that true is the driver's, because it is the driver
	// that watches the two and reports one.
	Script       []string `json:"script,omitempty"`
	BeforeScript []string `json:"before_script,omitempty"`
	AfterScript  []string `json:"after_script,omitempty"`
	Shell        []string `json:"shell,omitempty"`

	// Params are resolved: every expression evaluated, the matrix combination
	// injected, and the whole validated against the manifest schema. The driver
	// writes them to /agk/params.json and asks nothing about them.
	Params map[string]any `json:"params,omitempty"`

	// Secrets are names and mount points and never values. The task message names
	// the secrets it needs and carries none of them; the values exist only after the
	// runner redeems its grant.
	Secrets []SecretMount `json:"secrets,omitempty"`

	// Inputs are merged and sharded already, one envelope per input port.
	Inputs map[agk.Port]agk.Envelope `json:"inputs,omitempty"`

	// Outputs are the ports declared, which is what is collected from /agk/out/ and
	// what publishes an empty envelope where the container wrote nothing.
	Outputs []agk.Port `json:"outputs,omitempty"`

	Files       []FileSelector `json:"files,omitempty"`
	Resources   Resources      `json:"resources,omitzero"`
	Network     Network        `json:"network"`
	EgressAllow []string       `json:"egress_allow,omitempty"`
	RunsOn      []string       `json:"runs_on,omitempty"`

	// Timeout bounds this shard and Deadline is the moment it lands on, which is
	// what AGK_DEADLINE carries and what the runner stops the container at.
	Timeout  Duration  `json:"timeout,omitempty"`
	Deadline time.Time `json:"deadline,omitzero"`

	// Idempotent says whether running this again on the same inputs is safe, which
	// is what decides whether a lost task may be requeued and whether the result may
	// be cached. Cache and CacheKey are memoisation: the key combines the image
	// digest, the resolved parameters and the digests of the input envelopes,
	// prefixed by the namespace, and it never crosses a namespace boundary.
	Idempotent bool   `json:"idempotent"`
	Cache      bool   `json:"cache,omitempty"`
	CacheKey   string `json:"cache_key,omitempty"`
}

Task is one shard of one attempt of one step, carrying everything a driver needs and nothing it has to look up.

Inputs is exactly the argument brick.WriteInputs takes and Outputs is exactly the declared argument brick.Collect takes, so the driver is the thin thing between two calls that already exist. That is why the merge strategies are resolved on this side: wait_all, zip, join and first are rules the workflow file states, and a rule hidden inside the driver is a rule the workflow file cannot show.

The task message of the documentation carries digests rather than envelopes. That is one serialisation of this, made by a server driver that spills the envelope to the store and puts the digest on the bus, and it is not a second decision.

type Tool

type Tool struct {
	Name        string
	Title       string
	Description string
	Input       ToolIO
	Output      *ToolIO
	Mode        ToolMode
	Timeout     Duration
	Annotations Annotations
}

Tool is one published tool. Name, Description and Input are the three the language requires: "a tool published without a description is exactly the tool a model has no way to decide to call".

type ToolIO

type ToolIO struct {
	Input  string
	Output string
}

ToolIO names the workflow input or output a tool is a view of. Exactly one of the two is set, decided by the key it was written under: "the reference is to a workflow input or output, never to a step or a port".

type ToolMode

type ToolMode int

ToolMode is how a call waits. "sync waits for the run and returns the output; async returns the run identifier at once."

const (
	ToolSync ToolMode = iota
	ToolAsync
)

func (ToolMode) String

func (m ToolMode) String() string

type Trigger

type Trigger struct {
	Schedule []Schedule
	Webhook  []Webhook
	Event    []Event
}

Trigger is the on block: what starts a run of this workflow.

The block is spelled on:, which is why the file has to be read by a YAML 1.2 parser: a 1.1 parser reads that bare key as the boolean true.

type Vars

type Vars map[string]any

Vars are the workflow variables, read by expressions under the vars root.

func (Vars) Over added in v0.6.0

func (v Vars) Over(namespace map[string]any) map[string]any

Over is what the vars root holds on a server: these, the workflow's own, laid over the variables its namespace shows it, a name both write taking the file's value, as a name an including file writes takes that file's. The file is the more specific of the two, written for this workflow and kept with its commit, where a namespace's variable is changed in place, so a namespace fills in what the file leaves unwritten and overrides nothing it decided.

Neither is written into. Where the namespace shows nothing, the answer is the file's own, as agk run --local reads them, which reaches no namespace.

type Webhook

type Webhook struct {
	Path     string
	Method   string
	Auth     string
	Response string
	Output   string
	Map      map[string]any
}

Webhook is one HTTP entry point. "Paths are namespaced as /hooks/<namespace>/<path>", which is the API's doing and not written here.

Method, Auth and Response hold the value in force: POST, hmac and async where the file writes none. Output is the workflow output a sync response returns, and empty for an async one.

type When

type When int

When is one upstream state that allows a step to start. "Defaults to [succeeded]."

Pending and running are not among them, and neither is cancelled: when reads the verdict of a step that has ended, and the four names are the four the language writes.

const (
	WhenSucceeded When = iota
	WhenFailed
	WhenSkipped
	WhenAlways
)

func (When) String

func (w When) String() string

String names the state the way when writes it, which is also the way a step verdict writes it: the comparison the language asks for is between the two, so one spelling serves both and a refusal quotes the list the author wrote.

type Workflow

type Workflow struct {
	APIVersion  string
	Kind        string
	Metadata    Metadata
	Inputs      map[string]Input
	Outputs     map[string]Output
	On          Trigger
	MCP         *MCP
	Include     []Include
	Vars        Vars
	Secrets     []string
	Defaults    Defaults
	Concurrency Concurrency
	Timeout     Duration
	Steps       map[agk.Step]Step
	// contains filtered or unexported fields
}

Workflow is agentiik.yaml as a value: "the source of truth for what a workflow does".

Everything the file declares is here and nothing else is. It is not the source of truth for who may do what, so no grant appears; it is not a run, so no state appears; and it holds no compiled schema, because compiling one needs the repository tree and the tree is the caller's to provide.

A Workflow arrives from Parse or from Load and not from a literal. The unexported fields below are what resolution and a refusal need: the steps as they were written, the hidden blocks they extend, and the document itself, so that a position can name the line an expression was written on.

func Load

func Load(fsys fs.FS, entry string, remote Remote) (*Workflow, error)

Load reads the entry point out of the repository tree and resolves what it includes.

"Resolution is ordered: includes first, in declaration order, then extends depth-first, then defaults, then the step's own values." That is an order of application, and the last writer wins, which is what makes "then the step's own values" the last of them mean anything.

The tree is an fs.FS already pinned to the commit, so "a path include resolves inside the same commit, so it can never be stale and nothing has to pin it", and it cannot name a file outside the tree. A workflow include is reached through remote, because resolving one is reaching another repository at a ref, and reaching another repository is not something this package does: remote answers the other repository's tree at the commit the ref resolved to, and this package reads it. A nil remote refuses every workflow include.

It reads a tree a version is about to be made of, with Parse and ParseFragment, so a port and a workflow output are held to agk.PortMaxBytes in every file. LoadStored reads what a version already holds.

func LoadStored added in v0.4.0

func LoadStored(fsys fs.FS, entry string, remote Remote) (*Workflow, error)

LoadStored is Load for a version already stored: the same resolution over the same files, with every rule but the ones added since a version could be stored, the bound a port and a workflow output are written to, agk.PortMaxBytes, among them.

A version recorded before that bound was may name a port or an output of 251 to 255 characters, and its runs, its replays and the rebuild of its graph have to go on as they did before the upgrade, since nothing may break for what an installation already holds. The bound is refused where a version is made, by agk validate, agk run --local, agk push and the push route, and never where one is read back, which is package version's Build.

func Parse

func Parse(doc []byte) (*Workflow, error)

Parse reads one entry point, closed, and applies every rule that is about the shape of the document: the keys the language defines, the grammars its names are written on, the values its enumerations allow and the durations its keywords take.

It is read by hand rather than against the released workflow.schema.json, on the precedent package agk set for the envelope and for the same reason: a refusal has to name the step, the port and the rule in the documentation's own words, and a JSON Schema error names a keyword and a JSON Pointer. That this reader agrees with the released schema is not claimed here; it is held by corpus_test.go, which runs the released fixture corpus.

What Parse does not do is anything that needs a second document. The rules about the graph, the manifest and the published surface are Check and Build, because a workflow that parses is not yet a workflow that holds together.

A file that declares includes is resolved as far as this document goes and no further: an extends naming a block the file does not carry is left standing, because an included fragment may carry it and Parse was not given the fragments. Load, which has them, refuses what is still unresolved.

It reads a file a version is about to be made of, so a port and a workflow output are held to agk.PortMaxBytes. LoadStored reads what a version already holds without it.

func (*Workflow) Armable added in v0.5.0

func (w *Workflow) Armable() error

Armable says whether the triggers a workflow declares can be armed: held to the rules a version is made by today, whatever its version was stored under. A version stored before them keeps running when somebody asks for a run, and its schedules, webhooks and events are armed once a version that passes lands on the default branch.

func (*Workflow) DeclaredInputs added in v0.2.0

func (wf *Workflow) DeclaredInputs(tree fs.FS) (map[string]schema.Input, error)

DeclaredInputs compiles the schema of every input the workflow declares against the tree of the commit it came from, into what schema.Bind binds a run's inputs against.

It is here, beside the declaration, so that agk run --local, agk push and the API that starts a run on an installation all compile a declaration one way: a workflow whose inputs bind one way on a laptop and another on a server is two workflows. The tree is the caller's, as it is for every other use of it: the working tree on a laptop, the commit's own files at a push, and the version's tree out of the object store on an installation.

The compiler is given the tree rather than a directory, so a { $ref: "./schemas/order.json" } resolves inside the commit it travelled with and a reference that leaves it is refused.

An input with no schema is accepted as it comes, which is what a workflow says when the shape of a value is not its business.

func (*Workflow) Entry added in v0.4.0

func (w *Workflow) Entry() string

Entry is the path of the entry point in the tree the workflow was loaded from, and empty for a workflow parsed from bytes alone.

func (*Workflow) Included added in v0.4.0

func (w *Workflow) Included() []Included

Included is every include resolution applied, in the order it applied them: what a file includes before the file itself, a file two others include once, where it was first reached. A path include of another repository's files is that repository's and is not among them.

func (*Workflow) InputFiles added in v0.6.0

func (wf *Workflow) InputFiles(tree fs.FS) (map[string]any, error)

InputFiles compiles the declaration as DeclaredInputs does, and answers each file of the tree the inputs' schemas reach by $ref, directly or through another, by its path, as parsed JSON: what a form needs beside the declaration to check a value before the run is asked for.

func (*Workflow) MetadataAt added in v0.4.0

func (w *Workflow) MetadataAt(key string) Position

MetadataAt is where the entry point writes metadata.name, metadata.namespace or metadata.labels, which a refusal of the name a push is made under points at.

func (*Workflow) SecretAt added in v0.4.0

func (w *Workflow) SecretAt(name string) Position

SecretAt is where a secret the workflow names was first named: its entry in the secrets block of the entry point, or of the first file resolution read that names it.

func (*Workflow) StepAt added in v0.4.0

func (w *Workflow) StepAt(step agk.Step, keyword string, below ...any) Position

StepAt is where a keyword of a resolved step was written: in the step itself, in a block it extends or in defaults, in whichever file the layer that won came from. below names a node under the keyword, an index of a list or a key of a block, and the deepest of them the document holds is the one answered, so that an edge written in its short form, a step name alone, is pointed at as the step name.

type WorkflowRef

type WorkflowRef struct {
	Namespace string
	Name      string
	Ref       string
}

WorkflowRef names one workflow, and a ref of it where one is pinned. It is written <namespace>/<name> everywhere it appears, with @<ref> appended in the short form of a sub-workflow call.

It is comparable, and it is what a workflow include asks Remote for: the evaluator never reaches another repository, so a caller resolves the ref and hands over that repository's tree at the commit it resolved to.

func (WorkflowRef) String added in v0.4.0

func (r WorkflowRef) String() string

String is the reference as <namespace>/<name>@<ref>, the ref left out where there is none.

Jump to

Keyboard shortcuts

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