engine

package
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Jun 26, 2026 License: Apache-2.0 Imports: 36 Imported by: 0

Documentation

Overview

engine/local_dispatcher_agent.go — the AgentStep dispatch path. Symmetric to runCode (in local_dispatcher.go); separated because the agent path is self-contained (~150 LOC) and the two paths don't share helpers. See the LocalDispatcher.Run kind-switch in local_dispatcher.go for the entry point.

Package engine implements the tree-walking interpreter, the Dispatcher seam, the commit boundary, outcome classification, RunState, the log fold, and the runtime addressing helpers that extend ir's static path scheme.

Node addressing — engine.IterPath / AttemptPath / ItemPath composed with ir.PathFor / ir.ChildPath upstream — is the single source of truth for journal keys and OTel awf.node.path. Do NOT format `iter-N` / `attempt-N` / `item-N` strings elsewhere; route every runtime path through these helpers (CLAUDE.md invariant: "Node addressing is one pure function (engine/path)").

Index

Constants

View Source
const (
	EventRunStarted  = "run.started"
	EventCallStarted = "call.started"
	// EventNodeStarted is emitted by the interpreter when a STEP node enters
	// dispatch (Phase 6 slice 6.1). OBSERVATIONAL — Fold ignores it (default
	// arm); obs projects it as the START of a two-event span. See NodeStartedData.
	EventNodeStarted   = "node.started"
	EventRunResumed    = "run.resumed"
	EventNodeCompleted = "node.completed"
	EventBranchTaken   = "branch.taken"
	EventLoopIter      = "loop.iter"
	EventRetryAttempt  = "retry.attempt"
	EventNodeFailed    = "node.failed"
	EventRunFinished   = "run.finished"
	// Phase 3 slice 3.1 addition. Observational only — Fold default-arm-ignores
	// the event. The state effect of a skip comes from the target scope recording
	// its own normal completion event (loop.iter for a skipped iter, run.finished
	// for a root skip, etc.). Phase 6 obs will project node.skipped as a "skipped"
	// span marker; cli inspect/trace renders it.
	EventNodeSkipped = "node.skipped"

	// EventSkillsSelected is the deterministic routing decision for an agent
	// step's skills: block. It is written by runAgentStep before dispatch and
	// folded on resume so the step reuses the recorded skill IDs rather than
	// re-running the router.
	EventSkillsSelected = "skills.selected"
)

Phase 2.1 event-type names — the events the fold dispatches on. These are the wire-format string values stored in state.Event.Type; renaming any of them would invalidate every existing log. The vocabulary expands as later slices add writers: 2.4 added "retry.attempt"; 2.5 adds "node.failed" + "run.finished" (terminal events the interpreter / CLI emit). node.started writer landed in Phase 6 slice 6.1 (obs is the natural consumer; the Fold's default-switch-arm means a later writer can land additively without breaking old logs). Phase 3 adds "node.skipped" (3.1), "gate.attempt" (3.3), "map.item" (3.4), and "signal.received" / "run.paused" / "run.cancelled" (3.5). Phase 5 slice 5.2 added "agent.event". Native skill routing adds "skills.selected". Still future: "io.chunk".

View Source
const (
	// Phase 3 slice 3.4. Committed by the map handler (engine/map.go) per item,
	// AFTER the item's body terminates (whether the body produced an ok-final-
	// outcome, a failure, or a skip-target-detected ok). N is the 0-based item
	// index (matching engine.ItemPath's "item-N" suffix and spec §5.7's
	// `{{ <as>.index }}` 0-based convention). Status is one of ItemPassed /
	// ItemFailed. A PLAIN map commits one map.item per item as it terminates; a
	// PRUNE map instead commits its whole disposition atomically via map.frontier
	// (below), so map.item is never emitted by a prune map.
	EventMapItem = "map.item"

	// EventMapFrontier is the SP5 prune-map disposition commit. A prune map's
	// per-item status is a GLOBAL frontier decision (keep: top(k) / stop_when)
	// that is only valid once every item has reported its score — so, unlike a
	// plain map (one map.item per item as it terminates), a prune map commits the
	// ENTIRE per-item disposition as ONE atomic event after the frontier settles.
	//
	// This atomicity is load-bearing for resume. A per-item commit loop could
	// crash mid-pass, leaving a PARTIAL frontier durable; resume would then skip
	// the committed survivors (their scores never re-enter a fresh controller) and
	// re-derive the uncommitted remainder against an incomplete score set —
	// producing MORE survivors than keep: top(k) allows, silently. One atomic
	// event makes the disposition all-or-nothing: resume either replays the full
	// frontier verbatim (event present) or re-runs the whole map from a clean
	// slate (event absent — the bodies replay from their own committed
	// node.completed, and the frontier is decided fresh with no committed
	// disposition to contradict). This is the runtime half of the man page's "the
	// frontier is never re-derived from raw scores" guarantee (awf-workflow.5).
	EventMapFrontier = "map.frontier"
)
View Source
const (
	ItemPassed = "item_passed"
	ItemFailed = "item_failed"
	// ItemPruned is the THIRD terminal map-item status (SP5, spec §3.2b — the
	// one format revision). An item the prune frontier discarded: NEITHER a
	// pass NOR a failure. tallyResults ignores it (it is removed from both the
	// numerator and the min_success denominator); it raises no error and does
	// not trip an enclosing try/catch. It is a map-item STATUS, NOT an
	// engine.Outcome value — the mechanical Outcome enum (ok/retryable/permanent/
	// rejected) is deliberately untouched. A pruned item commits a map.item
	// event (no Outcome field), exactly like item_failed; it never commits a
	// node.completed with a non-ok outcome (the fold's only-ok-commits invariant
	// holds).
	ItemPruned = "item_pruned"
)

Map item statuses — the Status field on MapItemData. NOT the same vocabulary as the Outcome enum (engine/runstate.go) or AttemptPassed/AttemptRejected: a map item's body can succeed (item_passed), fail (item_failed), or be discarded by the prune frontier (item_pruned); the map as a WHOLE returns OutcomeOK if the success count meets MinSuccess, else returns an error.

View Source
const (
	AttemptPassed   = "attempt_passed"
	AttemptRejected = "attempt_rejected"
)

Gate attempt outcomes — the AttemptOutcome field on GateAttemptData. NOT the same vocabulary as the Outcome enum (engine/runstate.go): a gate attempt can pass or reject as a verdict; the gate as a WHOLE returns OutcomeOK on passed-attempt OR OutcomeRejected on max-attempts-reached.

View Source
const (
	// Phase 3 slice 3.5. signal.received: the interpreter writes this AFTER
	// signal.Broker hands off a delivery payload AND that payload validates
	// against the SignalStep's output_schema. The payload is CAS'd into Blobs
	// first (commit-atomicity invariant per spec §8); SignalReceivedData.PayloadRef
	// holds the resulting blob ref. Multiple deliveries to the same await are
	// impossible (the await consumes exactly one and commits via node.completed),
	// but multiple signal.received events MAY appear in a log for distinct await
	// steps consuming distinct signals.
	EventSignalReceived = "signal.received"

	// Phase 3 slice 3.5. run.paused: the interpreter writes this when control-
	// file polling (engine/controls.go) detects a pause.json file in the run's
	// control directory. NON-TERMINAL — `awf resume` does NOT refuse on
	// run.paused (unlike run.finished / node.failed / run.cancelled).
	EventRunPaused = "run.paused"

	// Phase 3 slice 3.5. run.cancelled: the interpreter writes this when
	// control-file polling detects a cancel.json file. TERMINAL — `awf resume`
	// refuses (slice 2.6's 3-class refusal grows to 4). Cancel propagates ctx-
	// cancel through running goroutines; finally blocks run on ctx-cancel
	// (slice 3.1 invariant); the event is appended + fsync'd before engine.Run
	// returns (Outcome(""), signal.ErrCancelled).
	EventRunCancelled = "run.cancelled"
)
View Source
const (
	BackendFake   = "fake"
	BackendDocker = "docker"
	BackendNative = "native"
)

Backend kind constants — the wire vocabulary written into RunStartedData.Backend (engine.RunStartedData) and the matching --backend flag values consumed by cli/run.go. These three uses MUST agree on spelling; centralizing the constants here triple-checks them at compile time.

BackendNative is the production default (slice 4.7). A pre-slice-4.5 log (no Backend field in its run.started payload) decodes to "" — cli/resume.go maps "" → BackendDocker so legacy logs resume against the slice-4.5 default. This IS a behavior change for legacy logs (pre-slice-4.5 cli.Run hard-wired fake); documented in the slice-4.5 PR body's Migration section. Native runs ARE resumable — snapshot: workspace workdirs restore from a gzip-tar archive on resume; the host base environment is not pinned (see cli/backend.go readBackendKindFromLog + container/native/snapshot.go).

View Source
const (
	AttemptSegmentPrefix = "attempt-"
	GateSegmentPrefix    = "gate["
	CallWorkflowSegment  = "workflow"
)

Exported segment prefixes for callers that need to recognize the runtime-addressing tokens (e.g. engine/gate_path.go's enclosingGateForEvaluate string-walks ctxPaths to find an enclosing gate). Centralized here so the addressing-token vocabulary has a single source of truth.

View Source
const AgentEventInlineThreshold = 4096

AgentEventInlineThreshold is the per-event inline/offload boundary, in bytes. Payloads at or above this size are offloaded to Blobs; below it they are inlined. Exported so obs (obs/content.go) bounds inlined payloads by the SAME value — the two must not drift or an inlined payload could be truncated in the trace with no CAS ref to recover it.

View Source
const (
	// Phase 5 slice 5.2. agent.event: the interpreter writes one entry per
	// agent.AgentEvent buffered in DispatchResult.AgentEvents, BEFORE the
	// node.completed commit. OBSERVATIONAL — Fold ignores it (default arm).
	// Phase 6 obs will project these as OTel span events. Mirrors how
	// retry.attempt is treated: appended for trace/obs, invisible to resume.
	EventAgentEvent = "agent.event"
)
View Source
const (
	// Phase 3 slice 3.3. Committed by the gate executor (engine/gate.go) at the
	// END of each attempt — i.e., AFTER the evaluator's last step committed and
	// the gate's `until` evaluated against the verdict. Crash≠verdict invariant:
	// a mechanical failure of any generate/evaluate step propagates BEFORE this
	// event is written (design §D step 1-2); only a real evaluation consumes an
	// attempt.
	EventGateAttempt = "gate.attempt"
)
View Source
const EventNodeInvalidated = "node.invalidated"

EventNodeInvalidated removes committed node paths from the folded RunState so `awf resume --from` re-runs them. The first fold event that DELETES state; sound because it is appended after the node.completed events it supersedes and fold is a strict-sequence-order pass (last event per path wins).

View Source
const (
	// P3 A3. Committed by the react executor (engine/react.go) at the END of each
	// round — AFTER the round's .model leaf and every dispatched .tool-J leaf have
	// committed. A pure {N} round cursor; finish_reason lives on the .model leaf.
	// Durability: Append+Sync (gate-style), NOT loop.iter's fsync-riding append,
	// because tool side-effects are not first-run-equivalent on resume (spec §4.1).
	EventReactRound = "react.round"
)
View Source
const ReasonImageUnavailable = "image_unavailable"

ReasonImageUnavailable is the sole tolerated per-item failure cause on a map.item's Reason field (P6a): a non-empty runtime image reference that could not be booted. A deterministic render/definition fault is NOT a reason — it fails the whole map as permanent_failure (see engine/map.go).

View Source
const RootModuleID = ""

Variables

View Source
var ErrAgentEventSinkBackpressure = errors.New("engine: agent event sink backpressure")
View Source
var ErrNodeNotImplemented = errors.New("engine: node kind not implemented")

ErrNodeNotImplemented is the sentinel the interpreter returns for any node kind not yet implemented in the current runtime. Phase 5 slice 5.2 closes the last remaining case (AgentStep / uses:) — all graph node kinds are now implemented. The sentinel is retained as a public export because the CLI layer (cli/execute.go) maps it to an exit code, and removing it would be a breaking API change.

Distinct from engine.ErrUnsupportedKind (the dispatcher's per-step sentinel) — the two answer different questions.

Wrap with kind + path for diagnostic clarity:

fmt.Errorf("%w: %s at path %q", ErrNodeNotImplemented, kindName, path)
View Source
var ErrUnsupportedKind = errors.New("engine: step kind not supported by LocalDispatcher (signal steps go through engine/signal_step.go)")

ErrUnsupportedKind is returned by LocalDispatcher for kinds it doesn't directly handle in this slice. Phase 5 slice 5.2 wires AgentStep through LocalDispatcher.runAgent; SignalStep is handled by the interpreter (engine/signal_step.go) and never reaches the dispatcher (its case in LocalDispatcher.Run is defense-in-depth only).

Functions

func AgentRuntimeRef

func AgentRuntimeRef(wf *ir.Workflow, moduleID, uses string) string

func AttemptPath

func AttemptPath(gatePath string, attempt int) string

AttemptPath appends a per-attempt suffix to a gate's static path:

AttemptPath("gate[0]", 2) → "gate[0].attempt-2"

`attempt` is 1-based. Callers compose `.generate` / `.evaluate` onto the result with ir.ChildPath (or a literal `+".generate"`) — those are the gate-internal branch names the validator already addresses.

func CallWorkflowRuntimePath

func CallWorkflowRuntimePath(callPath string) string

CallWorkflowRuntimePath appends the reserved child-workflow segment under a call-step path.

func CheckRuntimesDrift

func CheckRuntimesDrift(recorded, current []ResolvedRuntime) error

func ComputeNodeKey

func ComputeNodeKey(subtreeDigest string, inputRefs []string, runtimePins []string) string

ComputeNodeKey derives a stable, collision-resistant cache key for a deterministic node from three inputs:

  1. subtreeDigest — the scheme-prefixed sha256 of the node's JCS-canonical definition (produced by ir.NodeSubtreeDigest, supplied by Task 5).
  2. inputRefs — ordered list of artifact refs that feed this node (CAS hashes of the resolved input_files). Sorted before hashing so insertion order in the workflow graph does not affect the key.
  3. runtimePins — additional runtime-determined values that affect the node (e.g. workflow input hash, image digest). Sorted before hashing.

Framing: each string is written as its uint64 little-endian byte-length followed by the raw bytes. Each list is prefixed with its uint64 count. This makes the encoding injective: ["a","bc"] ≠ ["ab","c"] (element boundaries are explicit) and [refs…] + [pins…] ≠ [refs+pins…] + [] (the list count separates the two sections). The hash stream is:

<subtreeDigest bytes (framed)>
<uint64 len(inputRefs)>  <uint64 len(ref₀)><ref₀> …
<uint64 len(runtimePins)> <uint64 len(pin₀)><pin₀> …

Returns ir.DigestScheme + hex(sha256(framed stream)).

Exported for Task-6 resume verification and tests. Internal callers use the same symbol (no unexported alias needed — the name is the public contract).

func ComputeRerunInvalidation

func ComputeRerunInvalidation(wf *ir.Workflow, rs *RunState, target string) ([]string, error)

ComputeRerunInvalidation returns the sorted set of committed paths to invalidate for `--from target`: target's subtree ∪ every committed path whose top-level root slot is greater than target's. Errors if target is unsupported (rerunSupported); its root segment isn't in the current graph; OR any committed path's top-level segment is absent from the current graph (a structure-changing edit — node removed/renamed, or a control node reordered so its "<kw>[N]" segment changed). `--from` bypasses the digest pin, so refusing loud beats silently dropping a node and replaying stale output. `--from` assumes a STRUCTURE-PRESERVING edit (bodies/scripts, not graph shape); a pure top-level STEP reorder is not refused (ids are position-independent) but is safe (re-runs per the new order).

func ComputeVerifyingTraceTarget

func ComputeVerifyingTraceTarget(wf *ir.Workflow, rs *RunState) (string, error)

ComputeVerifyingTraceTarget returns the top-level rerun target for a resume against an EDITED definition whose change is confined to node bodies: the earliest-slot committed node that cannot be reused, or "" if every committed node is reusable.

A committed path is reusable when all of:

  • rerunSupported(p) == nil (v1: top-level or nested inside parallels only)
  • the static node at p is a *ir.CodeStep
  • rs.Completed[p].NodeSubtreeDigest != "" (non-legacy record)
  • ir.NodeSubtreeDigest(static[p]) matches the recorded value

If a committed path's top-level segment is not in the current workflow graph (addressing shift), an error is returned — the caller must hard-error.

Pure function; no I/O.

func ContainerSpecFor

func ContainerSpecFor(wf *ir.Workflow, composeFiles map[string][]byte, name string) container.ContainerSpec

ContainerSpecFor builds the DTO Backend.Create consumes from the IR + a composeFiles lookup. Reads Image + Resources for image-mode (slice 4.1); reads Compose bytes + ComposePath + Service for compose-mode (slice 4.3) by looking up composeFiles by the container's declared compose path.

Exported (capitalized) so cli/run.go and cli/resume.go can call it during container provisioning. The internal caller (engine/map.go) reads composeFiles from ld.ComposeFiles directly — see dispatchItem's call site. There is only one implementation of "build spec from IR" and it is a pure function: output depends only on (wf, composeFiles, name).

wf must be non-nil (caller invariant — the loader always produces a valid Workflow before provisioning).

Returns ContainerSpec{Name: name} for an undeclared name (validator should have caught this; defensive return).

func ItemPath

func ItemPath(mapPath string, item int) string

ItemPath appends a per-element suffix to a map's static path:

ItemPath("map[0]", 3) → "map[0].item-3"

`item` is 0-based — matching the `{{ <as>.index }}` array-index binding documented in spec §5.7 (the first element is item-0; conventional array indexing). Phase 2 doesn't execute `map`, but the helper ships now so the addressing grammar is complete and Phase 3 inherits it unchanged.

func ItemStepPath

func ItemStepPath(mapPath string, item int, suffix string) string

ItemStepPath appends a body-step path tail to a map item's runtime path, producing the journal key the engine reads a per-item body step's committed output from:

ItemStepPath("map[0]", 3, "score")        → "map[0].item-3.score"
ItemStepPath("map[0]", 3, "stepA.stepB")  → "map[0].item-3.stepA.stepB"
ItemStepPath("map[0]", 3, "")             → "map[0].item-3"

`suffix` is the (possibly multi-segment, possibly empty) body path tail — e.g. the last body step id for prune's score lookup, or the WalkNodes suffix for reduce/aggregate fan-in. Centralized here (not hand-joined at the call sites) so the runtime-address '.'-join stays one source of truth (CLAUDE.md "node addressing is one pure function").

func IterPath

func IterPath(bodyPath string, iter int) string

IterPath appends a per-iteration suffix to a loop body's static path, producing the runtime form the journal and OTel both use:

IterPath("loop[0].body", 3) → "loop[0].body.iter-3"

`iter` is 1-based (the first iteration is iter-1) — matching the design's "iter-3" example in runtime-design §5 and awf-workflow(5) (CHECKPOINTING AND RESUME).

func LiveResumePreflightRequests

func LiveResumePreflightRequests(ld *ir.LoadedDefinition, rs *RunState, resolver agent.Resolver) ([]agent.LiveResumePreflightRequest, error)

LiveResumePreflightRequests returns data-only adapter requests for the next uncommitted live frontier. It folds over the supplied RunState only; it does not append events, dispatch work, or mutate the caller's RunState.

func LoadRunStartedDefinitionSnapshot

func LoadRunStartedDefinitionSnapshot(blobs state.Blobs, ref string) (*ir.LoadedDefinition, error)

LoadRunStartedDefinitionSnapshot is the inverse of StoreRunStartedDefinitionSnapshot: it fetches the snapshot blob and reconstructs the loaded definition. ir.LoadedDefinition round-trips through JSON byte-identically (ir.NodeList's marshalers and ir.unmarshalNode are exact inverses), so graph projection over the reconstructed definition matches the original.

func ModelPath

func ModelPath(roundPath string) string

ModelPath is the per-round synthetic model leaf: "<round>.model".

func ParentPath

func ParentPath(path string) (string, bool)

ParentPath returns the runtime-address parent of a node path, and false when the node is a top-level child of the run root (no parent segment).

Every scope boundary in the runtime address grammar is a '.'-join — control keywords are "keyword[N]" (bracket-bounded, '.'-free), branch labels (.then/.else/.do/.catch/.finally/.body/.generate/.evaluate), iteration suffixes (.iter-N / .attempt-N / .item-N), and step ids (validated '.'-free) — so the parent is the path with its final '.'-segment trimmed. This is the inverse of ir.ChildPath / IterPath / AttemptPath / ItemPath and lives here so the addressing grammar stays one source of truth (CLAUDE.md invariant). obs (Phase 6) walks it to synthesize control-scope spans.

func QualifiedAssetKey

func QualifiedAssetKey(moduleID, assetID string) string

func QualifiedContainerKey

func QualifiedContainerKey(runtimeParent, container string) string

QualifiedContainerKey scopes a workflow-declared container name to the runtime parent that owns the handle.

func ResolveRerunTarget

func ResolveRerunTarget(wf *ir.Workflow, rs *RunState, events []state.Event, arg string) (string, error)

ResolveRerunTarget resolves a --from argument to one node path, in priority: (1) an exact committed path; (2) a top-level graph node segment — a CONTAINER like "parallel[1]"/"map[3]" has no committed key of its own (only children), so it is matched against rerunRootSlots; (3) the trailing node.failed event path (a failed uncommitted frontier node, matched by exact path or bare trailing id); (4) a unique TRAILING segment (a bare step id, e.g. "merge" -> "parallel[0].merge"). A bare id shared by two committed paths (ids are unique only within a sibling list) is an error listing candidates.

func RoundPath

func RoundPath(reactPath string, round int) string

RoundPath appends a per-round suffix to a react node's runtime path:

RoundPath("react[0]", 2) → "react[0].round-2"   (round is 1-based)

func SplitContainerRef

func SplitContainerRef(ref string) (bare, service string)

SplitContainerRef parses a step's container reference into a bare name and optional service override. The spec §3 form `container: lab:db` addresses the `db` service of the `lab` compose project; bare `container: lab` uses the default service. Mirrors the validator's split in ir/validate_structural.go checkContainerRef (kept parallel by docstring; not shared code — see slice 4.3 plan Design Q4).

Splits only the FIRST colon; "lab:db:replica" yields ("lab", "db:replica"). The Backend further splits or rejects as needed.

func StepPathIndex

func StepPathIndex(wf *ir.Workflow) map[string]string

StepPathIndex walks wf.Graph and returns a map from step id → static IR path. It indexes every step, wherever it sits (the recursion + path-construction is ir.WalkNodes). The static path is the producer's *definition* address; engine.Scope.stepRuntimePath translates it to the runtime address (inserting iter-K / attempt-M / item-K) at resolution time.

Duplicate step ids are caught by the validator (AWF1004); this trusts the input was validated and last-write-wins on duplicates.

func StoreRunStartedAssets

func StoreRunStartedAssets(blobs state.Blobs, assets map[string]ir.LoadedAsset) (map[string]RunStartedAsset, error)

func StoreRunStartedAssetsForLoadedDefinition

func StoreRunStartedAssetsForLoadedDefinition(blobs state.Blobs, ld *ir.LoadedDefinition) (map[string]RunStartedAsset, error)

func StoreRunStartedDefinitionSnapshot

func StoreRunStartedDefinitionSnapshot(blobs state.Blobs, ld *ir.LoadedDefinition) (string, error)

StoreRunStartedDefinitionSnapshot serializes the full loaded definition (canonical IR) and stores it as a single content-addressed blob, returning the ref. The snapshot is the run's own copy of its definition, recorded once at run start so a read-only viewer can render the run faithfully against the structure it actually executed against — even after the on-disk file is edited (e.g. `awf ui` overlaying a past run).

It is NEVER consulted for resume or pinning: §8 drift is always decided against the live file (cli/resume.go, cli/outputs.go). This is a view-only artifact. CAS dedup means repeated runs of an unedited definition share one blob. A nil definition yields an empty ref (no blob written).

func ToolPath

func ToolPath(roundPath string, j int) string

ToolPath is the per-round, per-call tool impl leaf: "<round>.tool-J" (J = the tool_call's stable Index).

func ValidateAgainstSchema

func ValidateAgainstSchema(raw []byte, schema *ir.JSONSchema) (map[string]any, error)

ValidateAgainstSchema decodes raw as JSON and validates the decoded value against schema using santhosh-tekuri/jsonschema/v6 — the same library slice 1.4 uses for static schema-well-formedness checks. Returns the decoded map on success; errors wrap the decode / compile / validation failure cause.

Used by:

  • engine/local_dispatcher.go's runCode (validates $AWF_OUTPUT against the step's output_schema).
  • cli/run.go's cliRun (validates --input JSON against workflow.input).

The schema cast (map[string]any(*schema)) works because IR schemas come from JSON-unmarshaled YAML — all nested values are native map[string]any, not the JSONSchema defined-type. Slice 2.4 Revision #3 documented the defensive pattern for Go-constructed schemas: round-trip via json.Marshal/Unmarshal first. The Phase 2 fixture set + CLI input path stays on the YAML-decoded path so this isn't a concern; the docstring preserves the warning for future callers.

func ValidateArtifactContract

func ValidateArtifactContract(path string, content []byte, contract OutputFileContract) error

func ValidateJSONAgainstSchema

func ValidateJSONAgainstSchema(raw []byte, schema *ir.JSONSchema) (any, error)

func ValidateOutputMap

func ValidateOutputMap(output map[string]any, schema *ir.JSONSchema) error

ValidateOutputMap is the typed-output validator for agent steps. The agent Adapter returns Output as map[string]any already (no JSON bytes to parse); this helper marshals both the output and the schema to JSON so that ValidateAgainstSchema receives native JSON-decoded types throughout. This handles Go-constructed schemas (e.g. test fixtures using []string for "required") that would otherwise confuse the JSON schema compiler — round-tripping via json.Marshal/Unmarshal normalises them to []any.

Returns nil on success. Returns a wrapped error on schema violation.

Types

type AdapterResolver added in v0.1.1

type AdapterResolver interface {
	AgentResolver() agent.Resolver
}

AdapterResolver is an optional interface that Dispatcher implementations may satisfy to expose their agent.Resolver to the interpreter. It exposes a read-only agent.Resolver (Lookup-only) that is stable for the lifetime of Run; it exists so the interpreter can resolve an agent step's adapter to query optional capabilities (e.g. Caps.PersistentSession). The interpreter uses it to set ResolvedInputs.SessionDir (the per-run claude projects/ subtree to capture/restore) for PersistentSession adapters.

type AgentEventData

type AgentEventData struct {
	Kind           string `json:"kind"`                       // adapter-specific (e.g. Claude Code: "system", "assistant", "user", "tool_use", "tool_result", "thinking", "result", "rate_limit")
	Stream         string `json:"stream,omitempty"`           // "stdout" | "stderr"
	Size           int    `json:"size"`                       // payload byte count (whether inline or offloaded)
	Live           bool   `json:"live,omitempty"`             // true means payload/display fields follow live normalized/redacted policy
	DisplayClass   string `json:"display_class,omitempty"`    // sanitized scalar copy of agent.EventDisplay.Class for live events
	DisplayTool    string `json:"display_tool,omitempty"`     // sanitized scalar copy of EventDisplay.Tool for live events
	DisplaySummary string `json:"display_summary,omitempty"`  // sanitized scalar copy of EventDisplay.Text for live events
	DisplayLines   int    `json:"display_lines,omitempty"`    // scalar copy of EventDisplay.Lines for live tool results
	DisplayBytes   int    `json:"display_bytes,omitempty"`    // scalar copy of EventDisplay.Bytes for live tool results
	DisplayIsError bool   `json:"display_is_error,omitempty"` // scalar copy of EventDisplay.IsError for live events
	PayloadInline  []byte `json:"payload_inline,omitempty"`   // strict: raw event bytes; live: normalized/redacted bytes when Size < threshold
	PayloadRef     string `json:"payload_ref,omitempty"`      // CAS pointer when Size >= threshold
}

AgentEventData is the payload of an agent.event log entry. The dispatcher (engine/local_dispatcher.go runAgent) drains <-chan agent.AgentEvent from adapter.Launch and buffers them into DispatchResult.AgentEvents; the interpreter-level engine/agent_step.go writes one AgentEventData per buffered event via Log.Append BEFORE Commit (so the journal records the stream alongside the node it belongs to).

Payload offload policy: PayloadInline carries the event bytes when `Size < AgentEventInlineThreshold` (4096 bytes, mirroring io.chunk per the Phase 5 spec slice 5.2 row). Payloads at or above that threshold land in Blobs and PayloadRef carries the CAS pointer; PayloadInline is then nil. Strict adapters may continue to write raw harness bytes. Live adapters set Live; their payload bytes must already be normalized/redacted by the adapter and are defensively display-sanitized before this event is written.

agent.event is OBSERVATIONAL — Fold ignores it. Resume reconstructs RunState from node.completed events; agent events are for trace/obs only (Phase 6 will project them as OTel span events). This mirrors how retry.attempt is treated.

type AttemptResult

type AttemptResult struct {
	N              int
	AttemptOutcome string
	Verdict        map[string]any
}

AttemptResult is one element of RunState.GateAttempts[gatePath]. Records what happened on a single gate.attempt — the per-attempt verdict and whether `until` accepted it. Built by the Fold from a gate.attempt event (slice 3.3 engine/fold.go) and by the gate executor's runtime RecordGateAttempt call (engine/gate.go). The template evaluator's `evaluate.*` scope reads the LATEST entry for the enclosing gate path (engine/scope.go).

AttemptOutcome is one of AttemptPassed / AttemptRejected. N is 1-based — the first attempt is N=1.

Verdict is the materialized typed outputs of the last evaluator step (its output_schema-validated map); the wire format stores VerdictRef (CAS pointer), the Fold materializes via Blobs.Get. READ-ONLY (callers MUST NOT mutate — same caveat as NodeResult.Outputs).

type BranchTakenData

type BranchTakenData struct {
	Which string `json:"which"` // "then" | "else"
}

BranchTakenData is the if-decision marker (spec §5.1). Fold uses Which to know which branch was visited so resume re-walks the same one. Always "then" or "else".

type CallStartedData

type CallStartedData struct {
	InputRef   string            `json:"input_ref"`
	InputFiles map[string]string `json:"input_files,omitempty"`
	Runtimes   []ResolvedRuntime `json:"runtimes,omitempty"`
}

CallStartedData is the durable subworkflow-call input pin. The root run.started.workflow_digest already pins imported workflow canonical IR, compose bytes, and asset bytes; call.started only records the call-specific typed input, child input file refs, and runtime resolutions.

type CallStartedRecord

type CallStartedRecord struct {
	Input      map[string]any
	InputRef   string
	InputFiles map[string]string
	Runtimes   []ResolvedRuntime
}

CallStartedRecord is the folded state for one call.started event. It pins the materialized subworkflow input to the durable CAS ref that was committed in the event, plus the runtime resolutions scoped to that call.

Input is the materialized JSON object from InputRef. READ-ONLY (callers MUST NOT mutate — same caveat as NodeResult.Outputs).

type DispatchResult

type DispatchResult struct {
	Outcome  Outcome
	ExitCode *int
	Outputs  map[string]any           // pre-commit typed outputs (nil if step has no output_schema or parse failed)
	Stdout   []byte                   // pre-commit raw stdout
	Files    []container.CapturedFile // pre-commit captured file contents (path + bytes)
	Err      error                    // set on non-ok outcomes; nil on Outcome == ok

	// RetryAfter is an optional server-supplied "wait this long before retrying"
	// hint surfaced on a retryable_failure — parsed by an adapter from a provider
	// Retry-After header or a rate-limit reset time (e.g. Anthropic
	// anthropic-ratelimit-*-reset, or the Claude Code rate_limit event's resetsAt).
	// Zero means "no hint, use the policy curve". RunWithRetry feeds it to
	// retry.Policy.EffectiveBackoff so the next sleep waits at least this long
	// (capped at retry.MaxHonoredRetryAfter). Runtime-only: never journaled —
	// Commit reads named fields off DispatchResult, not this one.
	RetryAfter time.Duration

	// Slice 5.2 — agent-step events. The dispatcher's runAgent drains the
	// adapter's <-chan AgentEvent and buffers each here for the
	// interpreter-level engine/agent_step.go to write as agent.event log
	// entries (Blobs-offload ≥ 4 KiB). Nil for code steps. Per CLAUDE.md
	// "interpreter is the only writer to state" — the dispatcher does NOT
	// call Log.Append; it buffers and returns.
	AgentEvents []agent.AgentEvent

	// Slice 6.1 — the adapter's per-step metrics (cost/tokens/turns), produced
	// by runAgent from agent.AgentResult.Metrics. nil for code/signal steps.
	// Commit persists it VERBATIM on node.completed; the engine never
	// interprets it. obs (Phase 6) projects it into awf.cost.* / gen_ai.usage.*.
	Metrics *agent.MetricSet

	// Transcript is the adapter-provided verbatim {user, assistant} pair. Commit
	// content-addresses it when the step participates in a conversation. The engine
	// never holds a with:-derived prompt — closes the opacity gap.
	Transcript agent.ThreadTurn

	// Live is an optional data-only live dispatch handoff. If present, the
	// interpreter calls RunOptions.LiveFinalizer after node.completed commits.
	Live *LiveDispatchRecord

	// SnapshotRef is the CoW workspace diff captured by the dispatcher after a
	// successful exec, for a snapshot:workspace container (empty otherwise).
	// The Backend already Put it to Blobs; Commit records it, never re-Puts.
	SnapshotRef string
	// Container is the step's bare container name (for node.completed.container —
	// resume's snapshot→container mapping + obs's awf.container.name).
	Container string

	// SessionTranscript is the captured claude session `projects/` subtree as a
	// gzip-tar, read by the dispatcher (via Backend.ReadTreeAt) after a
	// successful agent turn. Commit content-addresses it into Blobs and records
	// SessionRef. json:"-" — never journaled raw (mirrors Transcript at :185).
	SessionTranscript []byte `json:"-"`
	// SessionRef is set by Commit after Put. Operational, ref-only.
	SessionRef string

	// Node and InputRefs are populated ONLY on the code-step dispatch path
	// (engine/interpreter.go) so Commit can compute the NodeKey for deterministic
	// nodes. All other Commit call sites (agent_step, react, reduce) leave these
	// zero (nil Node → isDeterministicNode returns false → empty key). Do NOT
	// populate these from non-code paths.
	Node      ir.Node  // the ir.CodeStep node; nil for all non-code paths
	InputRefs []string // sorted CAS refs of the resolved input_files blobs; nil if no input_files
}

DispatchResult is the pre-commit shape returned by Dispatcher.Run. The interpreter (slice 2.5) passes it to engine.Commit to (a) Put each artifact to Blobs and (b) append a node.completed event — Commit then returns the post-commit NodeResult that lands in RunState.Completed.

Outcome is mechanically classified per spec §6 (ClassifyOutcome). On a non-ok outcome, Err carries the underlying cause (the AWFOutput parse error, the Backend.Exec transport error, ctx.DeadlineExceeded for timeout) — engine.RunWithRetry surfaces it as the final returned error if retries are exhausted; the interpreter (slice 2.5) reads it to attribute a node.failed event.

func RunWithRetry

func RunWithRetry(
	ctx context.Context,
	dispatcher Dispatcher,
	intent NodeIntent,
	policy retry.Policy,
	clk clock.Clock,
	log state.Log,
) (DispatchResult, <-chan container.IOChunk, error)

RunWithRetry runs dispatcher.Run up to policy.Attempts times. Between attempts it sleeps policy.BackoffFor(attempt) via clk.Sleep (deterministic under clock.Fake or testing/synctest with clock.System). Each non-final attempt's outcome is recorded as a retry.attempt event in log; the final attempt's outcome is NOT recorded by RunWithRetry — the caller (slice 2.5's interpreter) is responsible for the final node.completed (success) or node.failed (exhausted / permanent) event.

Returns the LAST attempt's DispatchResult + IOChunk channel. Intermediate IOChunk channels are drained (so the per-attempt streams don't leak); only the final attempt's channel is forwarded to the live tap.

Termination conditions, in order:

  • Outcome == OutcomeOK → return (dr, chunks, nil)
  • Outcome == OutcomePermanentFailure→ return (dr, chunks, dr.Err)
  • dispatcher.Run returns a non-nil error (interpreter bug — e.g. unknown container, ErrUnsupportedKind) → return (dr, nil, err)
  • attempt == policy.Attempts (last) → return (dr, chunks, lastErr)
  • clk.Sleep returns ctx.Err() → return (last dr, nil, ctx.Err())

Revision #12: drain happens AFTER dispatcher.Run returns and BEFORE the next sleep — preventing Phase 4 streaming-backend deadlock where the channel buffer fills during sleep with the writer goroutine still pushing.

retry.attempt event emission: best-effort. If Log.Append fails for the retry.attempt event, RunWithRetry surfaces the error AS the final returned error (we can't proceed without trusting the log).

type Dispatcher

type Dispatcher interface {
	Run(ctx context.Context, intent NodeIntent) (DispatchResult, <-chan container.IOChunk, error)
}

Dispatcher is the seam between the interpreter (what to run) and the executor (how to run it). Phase 2 ships one concrete impl (LocalDispatcher, in local_dispatcher.go); future impls (QueueDispatcher, K8sDispatcher) slot in without an interpreter rewrite per runtime-design.md §5.

The Dispatcher executes ONE attempt of a single node and returns its raw (pre-commit) result. Retry orchestration lives in engine.RunWithRetry; the commit boundary lives in engine.Commit. The dispatcher itself does NOT touch state.Log or state.Blobs (CLAUDE.md invariant: "the interpreter is the only writer to state" — and the dispatcher is downstream of the interpreter, not the interpreter itself).

Returned channel: the per-attempt IOChunk stream from container.Backend.Exec. The caller (RunWithRetry / interpreter) drains it for the live tap; the channel is closed by the backend before Exec returns (matches the slice 2.2 fake's pre-buffered shape; the Docker impl in Phase 4 may stream live — receive-only direction keeps both compatible). On non-nil error, the channel is nil; callers MUST nil-check before ranging (a `for range nil-chan` deadlocks).

type ErrContainerRequired

type ErrContainerRequired struct {
	Ref string
}

func (*ErrContainerRequired) Error

func (e *ErrContainerRequired) Error() string

type ErrRuntimeDrift

type ErrRuntimeDrift struct {
	Ref       string
	Container string
	Recorded  string
	Current   string
}

func (*ErrRuntimeDrift) Error

func (e *ErrRuntimeDrift) Error() string

type GateAttemptData

type GateAttemptData struct {
	N              int    `json:"n"`
	AttemptOutcome string `json:"attempt_outcome"`
	VerdictRef     string `json:"verdict_ref,omitempty"`
}

GateAttemptData is the payload of a gate.attempt event (Phase 3 slice 3.3). N is 1-based. AttemptOutcome is one of AttemptPassed / AttemptRejected.

VerdictRef points at the last evaluator step's typed outputs in Blobs (same CAS namespace as NodeCompletedData.OutputsRef); the Fold dereferences it to populate RunState.GateAttempts[gatePath]'s AttemptResult.Verdict. Per spec §5.5 the final evaluate node MUST declare output_schema, so VerdictRef is always non-empty for a well-formed gate.attempt; omitempty is only to avoid a spurious "verdict_ref":"" key on the wire.

type LiveDispatchRecord

type LiveDispatchRecord struct {
	AdapterRef     string
	SessionKey     string
	SessionKeyHash string
	LeaseID        string
	ActiveTurnID   string
	ProviderTurnID string
	RunID          string
	NodePath       string
	Epoch          uint32
	CommittedUnix  int64
}

LiveDispatchRecord is the data-only handoff a live-capable dispatcher returns after a successful turn. The interpreter may finalize it after node.completed is committed and synced. It intentionally contains no callbacks or hidden behavior; ownership of finalization hooks stays in RunOptions.

type LiveHomePin

type LiveHomePin struct {
	Path   string `json:"path"`
	Digest string `json:"digest"`
}

type LocalDispatcher

type LocalDispatcher struct {
	Backend container.Backend
	Handles map[string]container.Handle
	// RuntimeParent scopes declared container names for call workflows. Empty
	// means root workflow; non-empty handles are keyed as
	// QualifiedContainerKey(RuntimeParent, container).
	RuntimeParent string
	// ComposeFiles is the workflow's compose-file bytes by workflow-relative
	// path — sourced from ir.LoadedDefinition.ComposeFiles at construction.
	// engine/map.go reads this when Creating per-item containers for compose-
	// mode `containers:` entries; image-mode entries ignore it. Nil-safe (an
	// image-mode-only workflow may leave this unset).
	ComposeFiles map[string][]byte

	// Slice 5.2 — agent-step seam.
	//
	// Resolver is the agent.Resolver the dispatcher consults to look up
	// agent adapters by AgentStep.Uses. Nil means no agents registered —
	// any AgentStep dispatch returns *agent.ErrAdapterNotFound (mapped to
	// permanent_failure: the workflow references a `uses:` ref the
	// operator didn't register). Production wiring lives in cli/execute.go
	// (Task 11); tests inject *agent.Registry directly.
	//
	// Concurrency: Resolver.Lookup MUST be safe for concurrent use (Phase
	// 3 parallel branches dispatch concurrently). agent.Registry from
	// slice 5.1 satisfies this via sync.RWMutex.
	Resolver agent.Resolver

	// AgentEventTap is the io.Writer the dispatcher writes one-line-per-
	// AgentEvent stderr output to. Nil disables the live tap entirely
	// (tests). The CLI sets this to stderr (Task 11). Separate from the
	// io.Writer threaded through engine.Run (which is for IOChunks) so
	// agent events render with their own per-kind formatting without
	// interleaving with code-step stdout.
	AgentEventTap io.Writer

	// RenderAgentEvent formats each live AgentEvent to the AgentEventTap writer.
	// nil → the built-in terse fallback (writeAgentEventTap). cli injects a
	// TTY-aware, per-kind renderer; the engine stays presentation-agnostic.
	RenderAgentEvent func(io.Writer, agent.AgentEvent)

	// StepCostLine, when true, makes runAgent write one cost/token line per
	// agent step to AgentEventTap (slice 6.2 live renderer). Default false so
	// engine unit tests' exact tap-write-count assertions are unaffected; the
	// CLI (cli/execute.go) sets it true. Sourced from the adapter's already-
	// extracted MetricSet — never parses harness output, never touches obs.
	StepCostLine bool

	// M1 session restore — read-only access for the agent restore site.
	//
	// RunState is the folded run state for the current run. When non-nil and
	// RunState.SessionRefs[path] is set, runAgent restores the committed session
	// transcript into the container BEFORE launching the agent (the inverse of the
	// capture block). Nil means no prior session map (first run or non-session step).
	// The interpreter sets this in interpreter.go (engine.Run's LocalDispatcher
	// construction); direct LocalDispatcher callers (tests) can set it too.
	//
	// NOTE: the dispatcher NEVER writes to RunState; only the interpreter (via
	// engine.Commit + runstate.RecordCompleted) updates it. The restore path only
	// calls RunState.SessionRefs (read) + Blobs.Get (read) + Backend.WriteFileAt.
	RunState *RunState

	// Blobs is the shared content-addressed store. Read-only from the dispatcher:
	// the restore path calls Blobs.Get to materialize the committed session transcript.
	// Only engine.Commit (interpreter-side) calls Blobs.Put — never the dispatcher.
	Blobs state.Blobs
}

LocalDispatcher is the Phase 2 Dispatcher impl — runs code steps in-process via container.Backend, returns DispatchResult with mechanical Outcome. AgentStep is dispatched via runAgent (slice 5.2+); SignalStep returns ErrUnsupportedKind so the interpreter halts cleanly.

The dispatcher OWNS NO state: it doesn't Create / Destroy containers (the interpreter does, slice 2.5), it doesn't write to Log / Blobs (engine.Commit does), it doesn't loop on failure (engine.RunWithRetry does). One attempt in, one DispatchResult out.

Handles is the declared-container-name → pre-Created container.Handle map. The interpreter Creates each handle before the first dispatch into it and Destroys at run-end / cancellation. Lookup-miss is a hard error (not a retryable failure) — it indicates an interpreter bug.

func (*LocalDispatcher) AgentResolver added in v0.1.1

func (d *LocalDispatcher) AgentResolver() agent.Resolver

AgentResolver implements AdapterResolver — exposes the dispatcher's agent.Resolver to the interpreter so it can wire ResolvedInputs.SessionDir.

func (*LocalDispatcher) Run

Run executes one attempt of intent.Node. See the Dispatcher interface doc for the IOChunk channel contract; in particular, on a non-nil returned error the channel is nil (callers MUST check err first).

func (*LocalDispatcher) WithItemHandle

func (d *LocalDispatcher) WithItemHandle(name string, h container.Handle) *LocalDispatcher

WithItemHandle returns a shallow clone of d with Handles cloned and the (name → h) entry overridden (or inserted). Slice 3.4: the map executor (engine/map.go) calls this per item to retarget body's container lookup to a per-item handle. Cheap — Handles is typically 1-3 entries.

The returned LocalDispatcher shares Backend and ComposeFiles with d (both are read-only from the dispatcher's perspective — no deep copy needed). Mutating the returned dispatcher's Handles after construction is safe (it's a fresh map).

type LoopIterData

type LoopIterData struct {
	N int `json:"n"` // 1-based iteration number
}

LoopIterData is the per-iteration marker emitted at the end of each loop body (spec §5.2 — do-while, so this fires AFTER the iteration's body completes). Fold tracks the max N per loop path so resume knows how many iterations have committed.

type MapFrontierData

type MapFrontierData struct {
	Items []MapItemData `json:"items"`
}

MapFrontierData is the payload of a map.frontier event (SP5). Items is the FULL per-item disposition a prune map decided on this run, index-ordered. Items already committed by a prior run are NOT re-emitted (resume replays them from the earlier event). Each element reuses MapItemData (N + Status; the forensic ImageDigest/Reason fields stay empty for the prune path, exactly as the pre-SP5 deferred commit did) so Fold rebuilds MapItemRecords with the same code as the plain map.item arm.

type MapItemData

type MapItemData struct {
	N      int    `json:"n"`
	Status string `json:"status"`
	// ImageDigest is the content digest of the runtime-resolved image this
	// element booted (P6a) — empty for a statically-imaged map. omitempty keeps
	// pre-P6a logs and static maps byte-identical (additive, like SnapshotRef).
	ImageDigest string `json:"image_digest,omitempty"`
	// Reason is a machine-readable INFRA cause for a tolerated item_failed (P6a):
	// currently only ReasonImageUnavailable (a non-empty runtime image ref that
	// could not be booted). Empty for item_passed and for a plain body failure.
	// It distinguishes "couldn't boot this element" from "this element ran and
	// produced a negative result" (the Tekton/Temporal infra-vs-result split)
	// WITHOUT a new status — the three-value Status tally (item_passed /
	// item_failed / item_pruned) and MinSuccess math are untouched; it is NOT a
	// quality/result verdict (that is the gate's job). A
	// deterministic render/definition fault is NOT a reason — it fails the whole
	// map as permanent_failure. Audit/forensic field: no production reader today
	// (consumers are the docker RepoDigests follow-up + a future obs projection).
	Reason string `json:"reason,omitempty"`
	// Outcome is the item body's rolled-up mechanical outcome when Status ==
	// item_failed: "retryable_failure" | "permanent_failure" | "rejected".
	// Empty for item_passed / item_pruned, for the image_unavailable path, and
	// for pre-this-change logs. Resume re-run selection (see runMap reconciliation)
	// re-runs ONLY a retryable item; permanent / rejected / absent replay-as-failed.
	Outcome string `json:"outcome,omitempty"`
	// Cause is a bounded, human-readable rendering of the item body's failure
	// (bodyErr) when Status == item_failed. Forensic only — empty for passed/
	// pruned and pre-this-change logs (omitempty). Distinct from Outcome (the
	// mechanical class) and Reason (the infra cause). Not equality-checked by
	// the fold → determinism-safe.
	Cause string `json:"cause,omitempty"`
}

MapItemData is the payload of a map.item event (Phase 3 slice 3.4). N is 0-based (per engine.ItemPath + spec §5.7). Status is one of ItemPassed / ItemFailed.

NB: unlike gate.attempt (which carries verdict_ref), map.item carries NO item-value reference — the bound over[N] value is derived from re-evaluating `over` on resume entry (slice 3.4 Design Q3). Adding it later would be a backward-compatible extension (omitempty on the new field).

type MapItemRecord

type MapItemRecord struct {
	N         int
	ItemValue any
	Status    string
	// Outcome mirrors MapItemData.Outcome (the item body's mechanical outcome on
	// failure). Drives resume re-run selection; empty for passed/pruned/image.
	// Read ONLY from the FOLDED record (engine.Fold's map.item arm). Like
	// ImageDigest/Reason, the live mirror is intentionally NOT maintained
	// post-commit (updateMapItemStatus sets only Status) — the sole reader, the
	// resume reconciliation, runs on a freshly-Folded RunState, so the live value
	// is never observed. A future live reader MUST add write-through here first.
	Outcome string
	// ImageDigest / Reason are folded from the map.item event (P6a) — the durable
	// record of what this committed element booted and, on failure, why. Read-back,
	// never re-derived (run state; ItemValue is re-derived from `over`).
	// Audit/forensic only: NO production reader today — committed map items are
	// replayed-as-skipped on resume and never re-boot, so the digest is not a
	// re-pin input. Consumers are the docker RepoDigests follow-up + a future obs
	// projection.
	ImageDigest string
	Reason      string
	// Cause mirrors MapItemData.Cause. Forensic; read from the FOLDED record.
	Cause string
}

MapItemRecord is one element of RunState.MapItems[mapPath]. Records what happened on a single map item — its bound over[N] value and whether body completed ok (slice 3.4, design §E).

N is 0-based (matching engine.ItemPath's "item-N" suffix + spec §5.7's `{{ <as>.index }}` convention). Status is one of ItemPassed / ItemFailed (defined in engine/events.go).

ItemValue is the materialized over[N] value, populated by the map executor (engine/map.go) BEFORE dispatching the item's body. On resume, the Fold populates Status from the map.item event but leaves ItemValue NIL (Design Q3); the runtime re-evaluates `over` and calls UpdateMapItemValue to fill it in before body re-execution. READ-ONLY (callers MUST NOT mutate — same caveat as NodeResult.Outputs).

type NodeCompletedData

type NodeCompletedData struct {
	Outcome    string            `json:"outcome"` // always "ok" — only ok-steps commit
	ExitCode   *int              `json:"exit_code,omitempty"`
	OutputsRef string            `json:"outputs_ref,omitempty"`
	StdoutRef  string            `json:"stdout_ref,omitempty"` // CAS pointer; empty if step produced no stdout (or has none — agent/signal)
	Files      map[string]string `json:"files,omitempty"`      // declared path → CAS ref
	// Slice 6.1 — the adapter's per-step metrics, persisted VERBATIM (zero engine
	// interpretation). nil/omitted for code & signal steps. omitempty keeps
	// legacy logs decoding to nil (additive, like Backend slice 4.5 / Runtimes
	// slice 5.1). obs projects this into awf.cost.* / gen_ai.usage.*.
	Metrics *agent.MetricSet `json:"metrics,omitempty"`
	// Slice 7.1 — recorded ONLY when snapshot:workspace captured a CoW diff
	// (the dispatcher Put it to Blobs pre-commit; Commit records the ref, never
	// re-Puts). omitempty keeps pre-Phase-7 logs and non-snapshot steps byte-
	// identical (additive, like Metrics slice 6.1). Resume folds these into a
	// separate snapshot map; obs reads them off this event.
	SnapshotRef string `json:"snapshot_ref,omitempty"` // CoW workspace diff ref (snapshot:workspace only)
	Container   string `json:"container,omitempty"`    // bare container name (resume snapshot mapping + obs)
	// TranscriptRef is the CAS pointer for the participating turn's verbatim {user,
	// assistant} pair (continues: threading). Empty for non-participating steps.
	// omitempty keeps non-conversation logs byte-identical (additive, like SnapshotRef).
	TranscriptRef string `json:"transcript_ref,omitempty"`
	// NodeKey is the content-address cache key for deterministic (code) steps —
	// H(nodeSubtreeDigest ‖ sorted CAS refs of resolved input_files ‖ ∅ runtime pins).
	// Empty for non-deterministic steps (agent, react, reduce, signal, etc.).
	// omitempty keeps pre-WS6b logs byte-identical (additive, no fold breakage).
	// Task 6 (resume reuse) reads this to skip re-execution when inputs are unchanged.
	NodeKey string `json:"node_key,omitempty"`
	// NodeSubtreeDigest is the structural hash of the node definition (ir.NodeSubtreeDigest)
	// for deterministic (code) steps — the subtree component of NodeKey, recorded separately
	// so Task 6b2 (resume verifying-trace) can compare it against the current node definition
	// without recomputing NodeKey. Empty for non-deterministic steps (agent, react, reduce,
	// signal, etc.). omitempty keeps pre-WS6b2 logs byte-identical (additive, no fold breakage).
	NodeSubtreeDigest string `json:"node_subtree_digest,omitempty"`
	SessionRef        string `json:"session_ref,omitempty"` // native-session transcript blob ref (session adapters only)
}

NodeCompletedData is the commit-class event: the engine appends this only after the step's typed outputs and any declared output_files have been Put into Blobs. Its existence in the log IS the completion record (spec §8). On resume the fold reads these to skip already-committed nodes.

OutputsRef is empty if the step has no output_schema (no typed outputs); StdoutRef is empty if the step produced no stdout (or has none — agent/signal); Files is empty if the step has no output_files. omitempty keeps the on-disk JSON minimal.

type NodeFailedData

type NodeFailedData struct {
	Outcome string `json:"outcome"`
	Error   string `json:"error,omitempty"`
}

NodeFailedData is the payload of a node.failed event — emitted by the interpreter when a step terminates without committing. Outcome is always "retryable_failure" (exhausted retries) or "permanent_failure" (declared non_retryable_exit_codes hit, or the interpreter classifying a template-eval error per slice 2.5 Design question 7). Error is the free-text rendering of the underlying cause; on retryable exhaustion it's the LAST attempt's error (the same string RunWithRetry returns to the interpreter).

The fold ignores node.failed events — they're observational only. Phase 6's obs will project them as failed spans; slice 2.6's resume will refuse to resume a run whose tail event is node.failed (the run is terminal).

func NodeFailedDataFromEvent

func NodeFailedDataFromEvent(e state.Event) (NodeFailedData, error)

NodeFailedDataFromEvent unmarshals a node.failed event's payload. Used by the resume guard's crash-window branch (no run.finished present).

type NodeIntent

type NodeIntent struct {
	Path           string
	Node           ir.Node
	ResolvedInputs ResolvedInputs
	IdempotencyKey string
	IsGateEvaluate bool
	RunContext     agent.RunContext
	// contains filtered or unexported fields
}

NodeIntent carries everything one dispatch attempt needs. The interpreter (slice 2.5) builds one per code-step (Phase 2 has no per-attempt template refs, so the same intent is passed to every attempt of RunWithRetry).

IdempotencyKey is the template.Substitute'd value of CodeStep.IdempotencyKey (empty if the step has no idempotency_key declared). The dispatcher exposes it to the step via the AWF_IDEMPOTENCY_KEY env var. AWF §10: "stable template the external system uses to dedupe; the runtime passes the resolved key to the step on every attempt." Phase 2 is the once-substituted form per plan Design question 2 + ResolvedInputs contract.

type NodeInvalidatedData

type NodeInvalidatedData struct {
	Paths []string `json:"paths"`
}

NodeInvalidatedData is the payload of a node.invalidated event: the runtime paths to drop from every path-keyed RunState index.

type NodeResult

type NodeResult struct {
	Outcome    Outcome
	ExitCode   *int              // code step only (nil for agent/signal)
	Outputs    map[string]any    // typed; materialized from OutputsRef. READ-ONLY (see NodeResult doc).
	OutputsRef string            // CAS pointer (validates against Outputs)
	Stdout     []byte            // materialized stdout; READ-ONLY (see NodeResult doc). nil if step produced no stdout.
	StdoutRef  string            // CAS pointer (validates against Stdout)
	Files      map[string]string // declared path → CAS ref. READ-ONLY (see NodeResult doc).
	Transcript agent.ThreadTurn  // materialized from TranscriptRef by Fold (continues: threading). READ-ONLY. Zero value when the step didn't participate.
	// NodeKey is the content-address cache key folded from NodeCompletedData.NodeKey.
	// Non-empty only for deterministic (code) steps. Task 6 reads this during resume
	// to skip re-execution when the key matches the expected key for the current inputs.
	NodeKey string
	// NodeSubtreeDigest is the structural hash of the node definition folded from
	// NodeCompletedData.NodeSubtreeDigest. Non-empty only for deterministic (code) steps.
	// Task 6b2 (resume verifying-trace) compares this against the current node's
	// ir.NodeSubtreeDigest to decide per-node reuse.
	NodeSubtreeDigest string
}

NodeResult is the fold result for one completed node — stored in RunState.Completed keyed by node.path. Phase 2 populates this from a `node.completed` event; Phase 3+ adds gate-attempt aggregation but the per-node shape stays the same.

Both Outputs and OutputsRef (and Stdout / StdoutRef) are present: the *Ref field is the CAS pointer the journal stored, the materialized field is what the fold loaded via Blobs.Get. The template evaluator reads the materialized values; obs (Phase 6) reads the *Ref fields. Keeping both means callers don't re-dereference per resolution.

IMPORTANT — aliasing: Outputs and Files are maps and Stdout is a slice; Go's value-copy of a struct shares the underlying storage. Callers MUST treat RunState.Completed[*].Outputs, .Files, and .Stdout as read-only — mutating an entry through a copied NodeResult corrupts the fold-committed record. Pinned by TestNodeResultCopyIsShallow (engine/runstate_test.go).

func Commit

func Commit(log state.Log, blobs state.Blobs, path string, dr DispatchResult, participates bool) (NodeResult, error)

Commit persists a successful node result. The order — Blobs.Put each artifact FIRST, then Log.Append, then Log.Sync through appendNodeCompleted — is the spec §8 + CLAUDE.md "content-address-then-pointer-swap" invariant in code. A crash between Blobs.Put and Log.Append leaves orphan blobs only; no node.completed event ever references a missing artifact.

Commit takes the pre-commit DispatchResult and returns the post-commit NodeResult (refs filled, original materialized values preserved for callers that don't want to round-trip via Blobs.Get).

dr.Outcome MUST be OutcomeOK — non-ok outcomes never commit (their node.failed event is the interpreter's responsibility, slice 2.5; Commit returns an error to surface the caller bug).

participates is the continues:-threading gate: when true, the adapter-provided verbatim {user, assistant} pair (dr.Transcript) is content-addressed alongside the other artifacts (before the node.completed append) and recorded as TranscriptRef. When false the transcript is ignored entirely (code/signal steps and non-conversation agent steps), keeping their logs byte-identical.

Errors at any step propagate. The log is left in a consistent state in all failure modes: either the node.completed landed (full success) or it didn't (any partial blob writes are orphans, GC-deferred per spec §11.5).

type NodeSkippedData

type NodeSkippedData struct {
	Path   string `json:"path,omitempty"`
	Reason string `json:"reason,omitempty"`
}

NodeSkippedData is the observational marker emitted as a Skip unwinds through a scope (Phase 3 design §B). Path is the path of the skipped scope (e.g. "loop[0].body.iter-2" for a skipped loop iter, "" for a root skip). Reason is the author's free-text from `skip: <reason>` (spec §5.6).

Fold IGNORES this event (default arm). The state effect of a skip comes from the target scope recording its own normal completion event (e.g. loop.iter{k} for a skipped iter).

type NodeStartedData

type NodeStartedData struct {
	Kind string `json:"kind"`
}

NodeStartedData is the payload of a node.started event (Phase 6 slice 6.1). Emitted by the interpreter when a STEP node enters dispatch (after the resume short-circuit, so replayed nodes don't re-emit). Kind is "code" | "agent" | "signal". OBSERVATIONAL — Fold ignores it (default arm); obs projects it as the START of a two-event span, finalized by the matching node.completed / node.failed. A node.started with no terminal event is the Pending/Incomplete (in-flight or crashed) span.

type Outcome

type Outcome string

Outcome is the mechanical-only classification a step ends with (AWF standard §6). Quality is the gate's job — there is no `success` / `semantic_failure` class. The string form is the on-disk and OTel value; do not rename without a migration.

const (
	// OutcomeOK — clean exit (code 0 + schema-valid output) / signal delivered.
	OutcomeOK Outcome = "ok"
	// OutcomeRetryableFailure — transient: launch/transport error, timeout, nonzero
	// exit not declared permanent, unparseable agent output.
	OutcomeRetryableFailure Outcome = "retryable_failure"
	// OutcomePermanentFailure — agent refusal / policy block, or an exit code in
	// non_retryable_exit_codes.
	OutcomePermanentFailure Outcome = "permanent_failure"
	// OutcomeRejected — a gate exhausted MaxAttempts without passing (Phase 3
	// slice 3.3, spec §5.5). The gate executor is the SOLE PRODUCER in runtime
	// code paths (ClassifyOutcome never returns it). ParseOutcome accepts it
	// at the wire boundary so OTel/CLI consumers can round-trip the value, but
	// per spec §8 only ok-steps commit: a node.completed event with
	// outcome:"rejected" is corruption (Fold rejects it — pinned by
	// TestFold_NodeCompletedRejectedFails). Rejections propagate via the gate
	// handler's return tuple + the gate.attempt event's attempt_outcome field.
	OutcomeRejected Outcome = "rejected"
)

func ClassifyOutcome

func ClassifyOutcome(exitCode int, awfParseErr error, callErr error, nonRetryableExitCodes []int) Outcome

ClassifyOutcome is the spec §6 mechanical mapping from a single dispatch attempt's raw inputs (exit code, AWF_OUTPUT parse outcome, Backend.Exec transport outcome) to the typed Outcome the engine carries forward.

Priority order (deterministic — required because multiple causes can be non-nil at once on a chaotic call):

  1. callErr (transport / timeout / launch failure) → retryable_failure
  2. exit ∈ nonRetryableExitCodes → permanent_failure
  3. exit != 0 → retryable_failure
  4. awfParseErr != nil (exit 0, unparseable output)→ retryable_failure
  5. exit == 0 and awfParseErr == nil → ok

The "permanent wins over parse error" priority is deliberate: if the author declared `non_retryable_exit_codes: [78]` and the step exited 78 with broken AWFOutput, the author's policy decision (permanent) trumps the parse detail. Authors who want a parse error to escalate to permanent_failure should not declare an exit code as permanent.

Note: callErr beats permanent here. A transport failure (network error, container died unexpectedly) is mechanically a retryable_failure regardless of what exit code the step would have produced — we never saw the exit code; what we saw is the call failing. Spec §6 puts launch/transport in the retryable bucket.

func ParseOutcome

func ParseOutcome(s string) (Outcome, error)

ParseOutcome validates an on-disk / on-wire outcome string and returns the typed Outcome. Unknown strings are an error — the fold uses this as the trust boundary between the JSON wire format and in-memory typed state (CLAUDE.md invariant: "Outcomes are mechanical only"). Future code that parses an outcome from any untrusted source (CLI flag, OTel attribute, external API) MUST also go through this function instead of casting `Outcome(s)` directly.

func Run

func Run(
	ctx context.Context,
	def *ir.LoadedDefinition,
	runstate *RunState,
	dispatcher Dispatcher,
	log state.Log,
	blobs state.Blobs,
	clk clock.Clock,
	opts RunOptions,
) (Outcome, error)

Run is the top-level interpreter entry point. Walks def.Workflow.Graph recursively; for each node, computes its runtime path; consults runstate to skip committed nodes (the resume invariant — slice 2.6 exercises the non-empty-Completed case); otherwise dispatches per-kind. Halts on the first error or non-ok outcome that escapes any try/catch enclosing it (slice 3.1 added try/catch; future slices add gate/parallel/map — each defines its own absorption rule per Phase 3 spec §5).

Returns:

  • (OutcomeOK, nil) — every node committed ok, OR a Skip unwound to the workflow root (spec §5.6)
  • (OutcomeRetryableFailure | OutcomePermanentFailure, <step's err>) — a step terminated; the run.finished event records the same outcome
  • ("", <internal-error>) — interpreter / CLI bug (unknown container, ErrNodeNotImplemented, log/blobs failure). The CLI distinguishes this from step failures via the empty Outcome (slice 2.5 DQ4).

dispatcher's Handles map MUST be populated for every container the workflow declares; engine.Run is NOT responsible for Backend.Create / Destroy (the CLI owns lifecycle, slice 2.5 DQ3). An unknown-container reference inside a step is an internal error.

type OutputFileContract

type OutputFileContract struct {
	Format string
	Schema *ir.JSONSchema
}

type PauseMarker

type PauseMarker struct {
	NodePath string
	Reason   string
}

PauseMarker is the in-memory mirror of the latest run.paused event. LookupPaused returns nil if no pause is active. NON-TERMINAL — the engine does NOT halt on a stale LookupPaused; the controls polling helper is the live decision-maker.

NodePath is the runtime path the operator requested via `awf pause --before <node-path>` (empty for unconditional pause). Reason is the operator's free-text from `awf pause --reason <text>`.

type ReactRoundData

type ReactRoundData struct {
	N int `json:"n"`
}

ReactRoundData is the payload of a react.round marker. N is 1-based.

type ReactRoundRecord

type ReactRoundRecord struct {
	N int // 1-based round number; finish_reason is read from the .model leaf (spec §4.1)
}

ReactRoundRecord is one element of RunState.ReactRounds[reactPath]. Records a committed react.round event — the round cursor the engine uses on resume to compute startK := len(ReactRounds[R])+1 (the first uncommitted round). N is 1-based; finish_reason is read from the .model leaf on the same path (spec §4.1).

type ResolvedInputs

type ResolvedInputs struct {
	Command               string
	Env                   map[string]string
	OutputFiles           []string
	OutputFileContracts   map[string]OutputFileContract
	OutputSchema          *ir.JSONSchema
	NonRetryableExitCodes []int
	Timeout               time.Duration

	// InputFiles are the resolved (path → bytes) artifacts to stage via
	// Backend.CopyTo BEFORE Exec/Launch. The interpreter resolves each ref to a
	// CAS blob and Blobs.Get's the bytes (the dispatcher never touches Blobs).
	InputFiles []container.InputFile

	// ContainerlessFiles are the resolved input_files for a CONTAINERLESS agent
	// step — delivered to the adapter as inline message parts (each a label +
	// content + sniffed MIME) instead of staged into a container. Populated by
	// engine/agent_step.go's runAgentStep only when the step omits container:;
	// nil for container-backed steps (which use InputFiles) and code steps.
	ContainerlessFiles []agent.InputFile

	// Snapshot is "workspace" iff the step's container is a snapshot:workspace
	// container; the dispatcher captures a CoW diff after a successful exec.
	Snapshot string

	// SessionConfigDirRel is the per-run claude config directory (the value mapped
	// to CLAUDE_CONFIG_DIR), in StagingRoot form (native relative `.awf/...`, docker
	// absolute `/work/.awf/...`); empty for non-claude-family steps. The dispatcher
	// resolves it to an absolute path and threads it via inv.SessionConfigDir. Set
	// by the interpreter for container-backed IsolatedConfigDir adapters.
	SessionConfigDirRel string

	// SessionDir is the per-run claude session `projects/` directory to
	// capture/restore, in StagingRoot form (= SessionConfigDirRel + "/projects");
	// empty for non-session steps. The engine captures the whole subtree at commit
	// (Backend.ReadTreeAt) and restores it on the generator frontier
	// (Backend.WriteTreeAt). Set by the interpreter for PersistentSession adapters;
	// Milestone-1 tests set it directly.
	SessionDir string

	// Slice 5.2 — agent-step fields. Zero values when the node is a CodeStep
	// (runCode ignores them); populated by engine/agent_step.go runAgentStep
	// before dispatch.
	Uses string       // matches AgentStep.Uses; LocalDispatcher.runAgent looks up resolver.Lookup(Uses)
	With ir.RawConfig // post-template-substitution opaque adapter config; adapter.ValidateConfig + adapter.Launch read it

	// Slice 5.3 — agent-step gate feedback (the prior evaluator verdict on
	// repair attempts N>1). Populated by engine/agent_step.go runAgentStep
	// from the enclosing gate's runstate.LookupGateAttempts(gatePath) when
	// the step's path is inside a `.generate.` subtree. Nil on attempt 1 of
	// a gate, on non-gate paths, and for non-agent steps.
	//
	// The dispatcher's runAgent threads this into AgentInvocation.Feedback,
	// where adapters that consume an implicit "previous verdict:" preamble
	// (the Claude Code adapter, slice 5.3) read it. This is the SECONDARY
	// channel for gate feedback; the PRIMARY channel is the author-controlled
	// template substitution `{{ evaluate.<field> }}` that lands in With.prompt
	// before the dispatcher runs (Phase 3.3 wiring, unchanged).
	Feedback ir.RawConfig

	// Thread mirrors Feedback's plumbing: assembled by engine/agent_step.go from the
	// committed log via stepRuntimePath, copied into AgentInvocation.Thread by runAgent.
	// The dispatcher has no RunState access, so assembly happens interpreter-side.
	Thread []agent.ThreadTurn

	// ContextEvidence carries evaluator-only source evidence assembled by the
	// interpreter. It is copied into AgentInvocation.ContextEvidence by runAgent
	// and must not be rendered as active prior conversation.
	ContextEvidence []agent.ThreadTurn
}

ResolvedInputs is what the interpreter precomputes (Phase 2.5) before invoking the dispatcher — see the ResolvedInputs contract table in the slice 2.4 plan for what each field carries. The dispatcher consumes verbatim; no template.Substitute call happens inside.

type ResolvedRuntime

type ResolvedRuntime struct {
	Ref       string `json:"ref"`
	Version   string `json:"version"`
	Container string `json:"container,omitempty"` // slice 5.1 — IR-declared container name (spec §3); different containers may have different binary versions
}

ResolvedRuntime is one element of RunStartedData.Runtimes — a `uses:` ref + the concrete version it resolved to + the IR-declared container the version was resolved IN (per Phase 5 slice 5.1, decision 5: different containers may have different `claude` binaries on PATH, so drift detection is scoped per-(ref, container) pair, not just per-ref). Phase 2-4 don't populate this; the type exists so the wire format is stable from Phase 5.

func ResolveRuntimes

func ResolveRuntimes(ctx context.Context, refs []RuntimeRef, resolver agent.Resolver, handles map[string]container.Handle) ([]ResolvedRuntime, error)

type RetryAttemptData

type RetryAttemptData struct {
	N       int    `json:"n"`
	Outcome string `json:"outcome"`
	Error   string `json:"error,omitempty"`
}

RetryAttemptData is the payload of a retry.attempt event — emitted by engine.RunWithRetry once per non-final attempt (so on an N-attempt run that ultimately succeeds or exhausts, N-1 retry.attempt events land before the final node.completed / node.failed). Durability class is non-critical — node.completed remains the only authoritative completion record per spec §8, so retry.attempt rides the next fsync (RunWithRetry never calls Log.Sync() after one).

Outcome is the per-attempt classified outcome (retryable_failure / permanent_failure — never ok, since an ok attempt isn't recorded as a retry step). Error is the free-text rendering of the attempt's error (transport, parse, schema failure).

type RunCancelledData

type RunCancelledData struct {
	Reason string `json:"reason,omitempty"`
}

RunCancelledData is the payload of a run.cancelled event (Phase 3 slice 3.5). Reason is the operator-supplied free-text from `awf cancel --reason <text>` ("" if not set). TERMINAL event — `awf resume` refuses any log with this event in it.

type RunFinishedData

type RunFinishedData struct {
	Outcome string `json:"outcome"`
}

RunFinishedData is the payload of the run.finished event — the terminal marker the CLI appends after engine.Run returns. Outcome is the run-level rollup: "ok" if every step committed; otherwise the failing step's outcome. The fold ignores run.finished (it's observational); slice 2.6's resume refuses to resume a run with a run.finished event in its log (the run is terminal and shouldn't be re-entered).

func RunFinishedDataFromEvent

func RunFinishedDataFromEvent(e state.Event) (RunFinishedData, error)

RunFinishedDataFromEvent unmarshals a run.finished event's payload. Thin accessor used by the resume guard (cli/resume.go) to read the terminal rollup.

type RunOptions

type RunOptions struct {
	// Tap, if non-nil, receives the live-tap output: one "[step.id] <chunk>" per
	// IOChunk produced by each step. nil disables the tap entirely.
	Tap io.Writer

	// Broker is the signal broker for pause/cancel IPC. nil is valid — signal
	// steps fail with a clear error when reached with a nil broker.
	Broker *signal.Broker

	// Assets is the recorded run-start asset manifest. Task 5 consumes it when
	// resolving asset.<id> input_files; nil preserves pre-assets behavior.
	Assets map[string]RunStartedAsset

	// InputFiles is the supplied top-level workflow input-file manifest:
	// input-file name → CAS blob ref of the bytes supplied at run start. Seeds
	// runstate.InputFiles so a step referencing input.files.<name> resolves to
	// the supplied bytes; nil preserves pre-input-files behavior. Only set on
	// first run — on resume it is nil and runstate.InputFiles is fold-restored.
	InputFiles map[string]string

	// LiveFinalizer, if non-nil, is called after a successful live agent step's
	// node.completed event has been appended and synced.
	LiveFinalizer func(context.Context, LiveDispatchRecord) error

	// Resume is true on `awf resume` re-entry; the map reconciliation re-runs
	// transiently-failed items only when set (spec §6.3). false on fresh `awf run`.
	Resume bool

	// RerunFrom, when non-empty, is a committed node runtime path. At resume
	// start the engine invalidates that node's subtree + everything after its
	// top-level ancestor (engine/rerun.go), re-running them. Set by
	// `awf resume --from`.
	RerunFrom string
}

RunOptions carries optional runtime hooks for Run.

type RunPausedData

type RunPausedData struct {
	NodePath string `json:"node_path,omitempty"`
	Reason   string `json:"reason,omitempty"`
}

RunPausedData is the payload of a run.paused event (Phase 3 slice 3.5). NodePath is the runtime path the operator requested via `awf pause --before <node-path>` (empty for unconditional pause). Reason is the operator's free-text from `awf pause --reason <text>` ("" if not set).

NodePath does NOT pin where execution resumes — `awf resume` always re-enters at the uncommitted frontier per spec §8. NodePath is observational only.

type RunResumedData

type RunResumedData struct {
	Epoch uint32 `json:"epoch"`
}

RunResumedData is the payload of the run.resumed event — emitted at the top of every `awf resume` by slice 2.6. The Epoch counter is the resume-invocation index (1 after the first `awf run`; 2 after the first `awf resume`; …).

type RunStartedAsset

type RunStartedAsset struct {
	DeclaredPath string                `json:"declared_path"`
	IsDir        bool                  `json:"is_dir"`
	Files        []RunStartedAssetFile `json:"files"`
}

RunStartedAsset is the durable run-start manifest for one workflow asset. The map key in RunStartedData.Assets is the asset id; no duplicated ID is stored here, so readers have one authoritative key.

type RunStartedAssetFile

type RunStartedAssetFile struct {
	Path   string `json:"path"`
	Ref    string `json:"ref"`
	Size   int64  `json:"size"`
	SHA256 string `json:"sha256"`
}

RunStartedAssetFile points at one content-addressed asset file snapshot.

type RunStartedData

type RunStartedData struct {
	RunID            string                     `json:"run_id"`
	WorkflowDigest   string                     `json:"workflow_digest"`
	StructuralDigest string                     `json:"structural_digest,omitempty"` // WS-6a; "" in pre-WS6 logs (omitempty)
	WorkflowID       string                     `json:"workflow_id,omitempty"`       // slice 6.1 — obs awf.workflow.id (standard §9); empty in pre-6.1 logs
	WorkflowVersion  int                        `json:"workflow_version,omitempty"`  // slice 6.1 — obs awf.workflow.version; 0 in pre-6.1 logs
	InputRef         string                     `json:"input_ref,omitempty"`         // empty if Workflow.Input is nil
	Backend          string                     `json:"backend,omitempty"`           // slice 4.5; "" → BackendDocker on resume
	Assets           map[string]RunStartedAsset `json:"assets,omitempty"`
	// InputFiles records the supplied top-level workflow input-file manifest:
	// input-file name → CAS blob ref of the bytes supplied at run start. Folded
	// back into RunState.InputFiles on resume (mirrors Assets) so input.files.<name>
	// resolves identically on first run and resume. Empty in logs from before
	// top-level input files were a supply channel (omitempty).
	InputFiles map[string]string `json:"input_files,omitempty"`
	LiveHome   *LiveHomePin      `json:"live_home,omitempty"`
	Runtimes   []ResolvedRuntime `json:"runtimes,omitempty"`
	// DefinitionRef is a CAS blob ref to the run's full canonical definition (the JSON of
	// ir.LoadedDefinition), snapshotted once at run start. It lets read-only viewers (e.g.
	// `awf ui`) render a past run faithfully against the structure it executed against, even
	// after the on-disk file is edited. View-only: NEVER consulted for resume/pinning — §8
	// drift is always decided against the live file. Empty in logs from before the snapshot
	// landed (omitempty); viewers fall back to the current file then.
	DefinitionRef string `json:"definition_ref,omitempty"`
}

RunStartedData is the payload of the first event in a run (and the only event the run.id, workflow_digest, input_ref, and backend kind live in — the fold reads them from here).

Backend is the kind string the cli/run.go writer set from the --backend flag (slice 4.5; one of BackendFake / BackendDocker / BackendNative). cli/resume.go reads it back to pick the same Backend on resume — no --backend flag mismatch class. Empty in pre-slice-4.5 logs (omitempty); consumer maps "" → BackendDocker. BackendNative is resumable (slice 4.7) — resume of a native log dispatches Restore; snapshot: workspace workdirs restore from a gzip-tar archive while the host base environment is not pinned (see cli/backend.go readBackendKindFromLog + container/native/snapshot.go).

Phase 2: Runtimes is always empty (no `uses:` execution). Phase 5 populates it with {ref, resolved-version} per agent step, and resume verifies the resolved versions against the live registry. `omitempty` on Runtimes means both nil and empty-slice writers produce identical on-disk JSON (the key is absent) — avoids the silent `"runtimes":null` vs `"runtimes":[]` wire drift a Phase 5 writer would otherwise create by forgetting to initialize an empty slice.

func RunStartedDataFromEvents

func RunStartedDataFromEvents(events []state.Event) (RunStartedData, error)

RunStartedDataFromEvents decodes the first run.started payload without dereferencing any blob refs. It is for resume-side pins and manifests; Fold remains responsible for materializing committed runtime state.

type RunState

type RunState struct {
	RunID            string
	WorkflowDigest   string
	StructuralDigest string         // WS-6a; "" for pre-WS6 logs (field absent in legacy run.started)
	Input            map[string]any // resolved from run.started.Data.input_ref via Blobs.Get
	// Assets is the recorded run-start asset manifest. Fold restores it from
	// run.started without dereferencing refs; engine.Run may also seed it from
	// RunOptions for first-run execution before any resume fold exists.
	Assets map[string]RunStartedAsset
	// InputFiles is the supplied top-level workflow input-file manifest:
	// input-file name → CAS blob ref. Fold restores it from run.started
	// (mirrors Assets); engine.Run seeds it from RunOptions on first run. The
	// root scope (interpreter_context.go) reads it so input.files.<name>
	// resolves to the supplied bytes both on first run and on resume.
	InputFiles map[string]string
	// Epoch is the *runtime* resume-invocation counter — set to 1 on run.started, then
	// overwritten by each run.resumed event's payload (see Fold). This is NOT the same
	// counter as state.Event.Epoch (which is auto-stamped by state.Log on each Append
	// and bumped by OpenLog on each reopen — see state/log.go). The two counters can
	// diverge if the writer makes a mistake: slice 2.6's cli resume MUST emit
	// RunResumedData{Epoch: rs.Epoch + 1} after each fold, otherwise the runtime
	// counter goes stale while state.Event.Epoch keeps incrementing.
	Epoch uint32

	Completed map[string]NodeResult // node.path → result
	Branches  map[string]string     // if-node path → "then" | "else"
	LoopIters map[string]int        // loop-node path → max completed iteration (1-based)

	// GateAttempts records the per-attempt verdicts for every gate that has
	// committed at least one attempt. Slice 3.3 addition; gate path → ordered
	// slice of AttemptResult (oldest first). The slice grows as the gate
	// executor records additional attempts; resume's Fold rebuilds it from
	// gate.attempt events.
	//
	// The template evaluator's `evaluate.*` scope reads the LAST element for
	// the enclosing gate path on attempt n > 1 (slice 3.3 engine/scope.go).
	GateAttempts map[string][]AttemptResult

	// ReactRounds records committed rounds per react node (react[N] path →
	// ordered slice, oldest first). startK := len(ReactRounds[R])+1 is the
	// resume cursor for a react: step at path R. P3 A3 addition.
	//
	// The template evaluator does NOT read this; it is used exclusively by the
	// react executor (engine/react.go, Phase 4) for resume continuation and by
	// Fold for replay. READ-ONLY aliasing contract: LookupReactRounds returns
	// the live backing array — callers MUST NOT mutate elements.
	ReactRounds map[string][]ReactRoundRecord

	// MapItems records the per-item status for every map that has committed
	// at least one map.item event. Slice 3.4 addition; map path → ordered
	// slice of MapItemRecord in arrival order (the order RecordMapItem was
	// called, which mirrors log-append order). NOT N-ascending: concurrent
	// item-body goroutines may commit out-of-N-order, so item N=3 can land
	// in the slice before N=1. Pinned by TestRunStateMapItemsRoundTrip.
	//
	// LookupByN(N) helpers are NOT provided — the map handler walks the
	// slice (typically ≤ Concurrency items in flight) when it needs to
	// check "is item N already committed?"
	//
	// The template evaluator's `<as>` resolution scope (engine/scope.go)
	// reads MapItems to find the bound value for an enclosing map's item.
	MapItems map[string][]MapItemRecord

	// Signals records per-name signal deliveries observationally — slice 3.5
	// addition. Signal name → ordered slice of SignalEntry{Seq, PayloadRef}.
	// Fold rebuilds from signal.received events on resume; the await step
	// (engine/signal_step.go) appends to it after each commit. PURELY
	// OBSERVATIONAL: no handler reads it for control flow (the path-keyed
	// SignalReceivedAt below is the half-commit-resume lookup). Phase 6 obs
	// will project this as a per-signal delivery timeline.
	//
	// Each Append is single-writer (interpreter is the only writer); concurrent
	// Lookups are safe under the mu.
	Signals map[string][]SignalEntry

	// CallStarted records durable subworkflow-call input pins. Call path →
	// materialized input + input ref + per-call runtime resolutions. Built by
	// Fold from call.started events; runtime writers mirror fresh commits via
	// RecordCallStarted.
	CallStarted map[string]CallStartedRecord

	// Paused is the latest non-cleared pause marker. Nil if no pause is
	// active. Slice 3.5 addition.
	Paused *PauseMarker

	// Cancelled is true iff a run.cancelled event has been folded in OR the
	// background poller (engine/controls.go) detected cancel.json mid-run.
	Cancelled bool

	// CancelReason is the operator-supplied free-text from cancel.json's
	// Reason field. Set by the poller alongside Cancelled. engine.Run reads
	// it when appending the run.cancelled event.
	CancelReason string

	// SignalReceivedAt is the path-keyed half-commit-resume mechanism (slice
	// 3.5 design Q7). Populated by Fold from signal.received events; read by
	// runSignalStep before calling broker.Receive — if a SignalReceivedEntry
	// exists for the await's path, the prior run committed signal.received
	// but not node.completed; the handler skips the Receive call + the
	// signal.received append and writes only node.completed.
	SignalReceivedAt map[string]SignalReceivedEntry

	// SnapshotRefs maps a container name to its latest committed snapshot ref
	// (snapshot:workspace containers only); resume restores each from this ref.
	// Slice 7.1 addition. Built by Fold from node.completed events' SnapshotRef
	// / Container fields — last write wins per container (a container is
	// snapshotted at every commit, so the last one is the resume point). Empty
	// for non-snapshot containers and pre-Phase-7 logs (the event fields are
	// omitempty, so the Fold guard skips them).
	SnapshotRefs map[string]string

	// SessionRefs maps a generate NODE PATH to its latest committed native-session
	// transcript ref. Unlike SnapshotRefs (container-keyed), a session belongs to a
	// specific generate node. Fold-populated (pre-Run), then read-only during Run.
	// IMPORTANT: unlike SnapshotRefs, SessionRefs IS cleared by clearInvalidatedPaths
	// (rerun.go) — a re-run generate node must drop its stale session.
	SessionRefs map[string]string

	// SelectedSkills records skills.selected decisions by runtime node path.
	// Agent steps consult it on resume to replay routed skill IDs without
	// re-running the router against a possibly changed query.
	SelectedSkills map[string]SkillsSelectedData
	// contains filtered or unexported fields
}

RunState is the in-memory fold of the log: the interpreter consults it to skip committed nodes, the template evaluator consults it to resolve refs.

Built by Fold (engine/fold.go). The same code path serves first-run (empty log → empty RunState) and resume (folded log → populated RunState).

Phase 2 + Phase 3 complete. All planned Phase 3 fields have landed: gate attempts (slice 3.3), map items (slice 3.4), and signals/pause/cancel (slice 3.5). RunState.Epoch ≠ state.Event.Epoch — see comment on the Epoch field below.

func Fold

func Fold(events []state.Event, blobs state.Blobs) (*RunState, error)

Fold replays a committed-event sequence into a RunState. The events come from state.Log.Fold(); per Phase 1.5's log contract (state/log.go — OpenLog truncates the file at the first short read / CRC mismatch), every record arriving here is CRC-valid and JSON-decodable. Blobs is consulted to materialize Input (from run.started.InputRef) and per-step Outputs (from node.completed.OutputsRef) into typed map[string]any values.

(Spec §A sketches `FoldLog(log state.Log)`; the name was changed to `Fold` with a `[]state.Event` argument for testability — callers do `events, _ := log.Fold(); rs, _ := engine.Fold(events, blobs)`. Same semantics, no extra `state.Log` dependency in unit tests.)

Phase 2 semantics:

  • The first event (if any) MUST be run.started; it seeds RunID, WorkflowDigest, Input, Epoch=1. A non-run.started first event is corruption or a writer bug.
  • A second run.started is a fold error — the engine never writes it twice.
  • Each run.resumed event sets Epoch from its payload.
  • node.completed populates Completed[path] (the commit record). Outcome is validated via ParseOutcome AND additionally required to be OutcomeOK — only ok-steps commit, per spec §8 + CLAUDE.md "a 'done' record must never reference [an incomplete] state."
  • branch.taken populates Branches[path] (which side of an if).
  • loop.iter populates LoopIters[path] (events are seq-ordered, so the latest assignment gives the max — which is correct, since loops only advance forward).
  • gate.attempt populates GateAttempts[path] (slice 3.3) — verdict_ref dereferenced via Blobs.Get; events arrive in seq-order so append-order is the natural attempt-order. Missing verdict_ref is a hard error (spec §8 commit-atomicity invariant — the writer must Put the verdict blob BEFORE appending gate.attempt).
  • map.item populates MapItems[path] (slice 3.4) — MapItemRecord{N, Status, ItemValue: nil}. ItemValue is NOT in the wire format (derived from `over` per Design Q3); the runtime fills it via UpdateMapItemValue BEFORE body re-execution on resume. Append-order = arrival-order; items may commit out-of-N-order because the map dispatches concurrently (spec §5.7).
  • signal.received populates Signals[d.Name] (observational queue) AND SignalReceivedAt[e.Path] (path-keyed half-commit-resume entry — slice 3.5 design Q7). REFS ONLY — no Blobs.Get inside Fold; payloads are materialized at use to support non-object payloads from unschema'd signal steps (C6 fix).
  • call.started populates CallStarted[path] with the materialized subworkflow input object, input ref, and per-call runtime resolutions. Duplicate call.started events for one path are corruption.
  • run.paused is OBSERVATIONAL — Fold IGNORES it (default arm, same as node.failed). rs.Paused is a runtime-only flag set exclusively by engine.Run's live pollControls goroutine (C7 fix — avoids stale-Paused bug on resume).
  • run.cancelled sets Cancelled to true (slice 3.5; TERMINAL — cli/resume.go refuses).
  • Anything else (future event types not yet written by Phase 2 slices) → ignored.

Errors (any fold error → resume cannot proceed safely):

  • Non-run.started first event / duplicate run.started.
  • Malformed Data (JSON parse failure on a known event type).
  • Blobs.Get failure for a referenced blob (broken §8 atomicity invariant — a node.completed referenced a missing artifact).
  • Unknown Outcome string in node.completed (corruption — ParseOutcome catches it).
  • Non-`ok` Outcome in node.completed (corruption — only ok-steps commit; the extra check beyond ParseOutcome closes a gap a bare ParseOutcome would miss).

func NewRunState

func NewRunState(runID, workflowDigest string, input map[string]any) *RunState

NewRunState constructs a fresh RunState with the three maps pre-allocated and identity fields set. The Epoch is initialized to 1 — the first-run baseline; slice 2.6's resume increments via the run.resumed event in the fold path. Input is the optional run-input map (nil if the workflow has no input schema or --input wasn't passed).

Eliminates the "caller forgets to allocate Completed/Branches/LoopIters" footgun. Fold (engine/fold.go) constructs its own RunState with capacity- hinted maps; this constructor is for non-Fold construction paths (CLI first-run, tests).

func (*RunState) AppendSignal

func (rs *RunState) AppendSignal(name string, e SignalEntry)

AppendSignal enqueues e on the per-name queue. The interpreter calls this AFTER a successful Log.Append + Log.Sync of the corresponding signal.received event (in-memory mirrors durable log, not the other way around). Thread-safe.

func (*RunState) CallStartedPaths

func (rs *RunState) CallStartedPaths() []string

CallStartedPaths returns the recorded call.started paths in deterministic order. Thread-safe; the returned slice is a copy.

func (*RunState) IsCancelled

func (rs *RunState) IsCancelled() bool

IsCancelled reports the cancelled flag. Thread-safe.

func (*RunState) LookupBranch

func (rs *RunState) LookupBranch(path string) (string, bool)

LookupBranch returns the "then"|"else" recorded for if-path and a present flag. Thread-safe.

func (*RunState) LookupCallStarted

func (rs *RunState) LookupCallStarted(path string) (CallStartedRecord, bool)

LookupCallStarted returns the call.started record stored for path and a present flag. Thread-safe. READ-ONLY — callers MUST NOT mutate the returned record's Input map, InputFiles map, or Runtimes slice.

func (*RunState) LookupCancelReason

func (rs *RunState) LookupCancelReason() string

LookupCancelReason returns the stored reason. Empty if no reason was supplied or SetCancelReason has not been called. Thread-safe.

func (*RunState) LookupCompleted

func (rs *RunState) LookupCompleted(path string) (NodeResult, bool)

LookupCompleted returns the NodeResult stored for path and a present flag. Thread-safe; slice 3.2 (parallel) branches call this concurrently.

func (*RunState) LookupGateAttempts

func (rs *RunState) LookupGateAttempts(gatePath string) []AttemptResult

LookupGateAttempts returns the per-attempt slice recorded for gatePath, or nil if no attempt has been recorded yet (the attempt-1 case for the template `evaluate.*` scope's empty-feedback contract). Thread-safe.

READ-ONLY: callers MUST NOT mutate elements of the returned slice or any AttemptResult.Verdict map within it — the slice is the live internal backing array (same aliasing contract as NodeResult.Outputs). Pinned by TestGateAttemptsReturnedSliceIsReadOnly (engine/runstate_test.go).

func (*RunState) LookupLoopIters

func (rs *RunState) LookupLoopIters(path string) int

LookupLoopIters returns the latest committed iter for loop-path (0 if no iter committed yet). Thread-safe.

func (*RunState) LookupMapItems

func (rs *RunState) LookupMapItems(mapPath string) []MapItemRecord

LookupMapItems returns a SHALLOW COPY of the per-item slice recorded for mapPath (a fresh slice header pointing at copies of the MapItemRecord values; the MapItemRecord.ItemValue interfaces still alias the underlying maps). Returns nil if no item has been recorded yet. Thread-safe.

Why a copy, not the live backing array (slice 3.4 design Q3 + C1): updateMapItemStatus mutates the live backing array under mu when a body goroutine terminates. If LookupMapItems returned the live header, concurrent resolveAsBinding callers reading other items' fields would race with that update (the mutation is past the LookupMapItems mu.Unlock). A shallow copy gives the caller an immutable snapshot — race-clean under -race.

READ-ONLY: callers MUST NOT mutate the returned slice or the MapItemRecord.ItemValue maps within it (aliasing through `any` is shared). Pinned by TestMapItemRecordCopyIsShallow (ItemValue maps are aliased; the slice header is fresh).

func (*RunState) LookupPaused

func (rs *RunState) LookupPaused() *PauseMarker

LookupPaused returns the current marker (nil if not paused). Thread-safe. Returns a SHALLOW COPY so concurrent readers can't mutate the live marker.

func (*RunState) LookupReactRounds

func (rs *RunState) LookupReactRounds(r string) []ReactRoundRecord

LookupReactRounds returns the per-round slice recorded for reactPath, or nil if no round has been recorded yet (the round-1 case for resume — the caller computes startK := len(result)+1). Thread-safe.

READ-ONLY: callers MUST NOT mutate elements of the returned slice — the slice is the live internal backing array (same aliasing contract as LookupGateAttempts / NodeResult.Outputs). The react executor (Phase 4) consults this read-only; only RecordReactRound appends.

func (*RunState) LookupSelectedSkills

func (rs *RunState) LookupSelectedSkills(path string) (SkillsSelectedData, bool)

LookupSelectedSkills returns the skills.selected payload recorded for path. The returned value is a defensive copy; callers may inspect it but must not mutate RunState through it.

func (*RunState) LookupSignalReceivedAt

func (rs *RunState) LookupSignalReceivedAt(path string) (SignalReceivedEntry, bool)

LookupSignalReceivedAt returns the SignalReceivedEntry stored for path and a present flag. The half-commit-resume mechanism (slice 3.5 design Q7): runSignalStep checks this BEFORE calling broker.Receive. Thread-safe.

func (*RunState) LookupSignals

func (rs *RunState) LookupSignals(name string) []SignalEntry

LookupSignals returns the queue for name (nil if no signal has been recorded). Thread-safe. READ-ONLY — callers MUST NOT mutate.

func (*RunState) RecordBranch

func (rs *RunState) RecordBranch(path string, which string)

RecordBranch stores which ("then"|"else") for if-path. Thread-safe.

func (*RunState) RecordCallStarted

func (rs *RunState) RecordCallStarted(path string, rec CallStartedRecord)

RecordCallStarted stores rec for path. The call executor calls this AFTER a successful Log.Append + Log.Sync of the corresponding call.started event — in-memory state mirrors the durable log, not the other way around. Thread-safe.

func (*RunState) RecordCompleted

func (rs *RunState) RecordCompleted(path string, nr NodeResult)

RecordCompleted stores nr for path. Thread-safe.

func (*RunState) RecordGateAttempt

func (rs *RunState) RecordGateAttempt(gatePath string, ar AttemptResult)

RecordGateAttempt appends ar to the slice at gatePath. The gate executor (engine/gate.go) calls this AFTER a successful Log.Append + Log.Sync of the corresponding gate.attempt event — in-memory state mirrors the durable log, not the other way around. Thread-safe.

func (*RunState) RecordLoopIter

func (rs *RunState) RecordLoopIter(path string, k int)

RecordLoopIter stores k as the latest committed iter for loop-path. Thread-safe.

func (*RunState) RecordMapItem

func (rs *RunState) RecordMapItem(mapPath string, mr MapItemRecord)

RecordMapItem upserts mr into the slice at mapPath, keyed by N. If an existing record has the same N it is replaced in place (last-wins); otherwise the record is appended. The upsert semantics are load-bearing for resume: Task 2 (re-run of a retryable item) appends a second map.item{N} event to the log; without upsert both fold and live state would hold two records for the same item-N, causing double-counting in aggregateMapOutputs and breaking value-binding in scope.go. Thread-safe.

func (*RunState) RecordReactRound

func (rs *RunState) RecordReactRound(r string, rr ReactRoundRecord)

RecordReactRound appends rr to the slice at r. The react executor (engine/react.go) calls this AFTER a successful Log.Append + Log.Sync of the corresponding react.round event — in-memory state mirrors the durable log, not the other way around. Thread-safe.

func (*RunState) RecordSelectedSkills

func (rs *RunState) RecordSelectedSkills(path string, data SkillsSelectedData)

RecordSelectedSkills stores the skills.selected payload for path. The interpreter calls this only after the corresponding event has been appended and synced; Fold uses it when reconstructing state on resume.

func (*RunState) RecordSignalReceivedAt

func (rs *RunState) RecordSignalReceivedAt(path string, e SignalReceivedEntry)

RecordSignalReceivedAt stores e for path. Used by Fold (engine/fold.go's signal.received arm) when reconstructing post-resume state. The runtime path also goes through this when committing a fresh signal.received — keeps the in-memory mirror current. Thread-safe.

func (*RunState) SetCancelReason

func (rs *RunState) SetCancelReason(reason string)

SetCancelReason stores the operator-supplied reason from cancel.json. The background poller (engine/controls.go) calls this alongside SetCancelled when cancel.json is detected. engine.Run reads it when appending the run.cancelled event. Thread-safe.

(Field-vs-method naming: the field is `CancelReason`; the accessor is `LookupCancelReason` to avoid the Go field/method name collision. Same pattern as Paused / LookupPaused.)

func (*RunState) SetCancelled

func (rs *RunState) SetCancelled(v bool)

SetCancelled sets the cancelled flag. Thread-safe.

func (*RunState) SetPaused

func (rs *RunState) SetPaused(pm *PauseMarker)

SetPaused sets the in-memory pause marker. nil clears. Thread-safe.

func (*RunState) UpdateMapItemValue

func (rs *RunState) UpdateMapItemValue(mapPath string, n int, value any)

UpdateMapItemValue sets the ItemValue field of the existing MapItemRecord at (mapPath, n). Used post-resume by the map executor: Fold rebuilds MapItems from map.item events with ItemValue:nil; the executor re-evaluates `over` and calls this to populate ItemValue before the body's templates resolve `<as>` refs (Design Q3).

No-op if no record at (mapPath, n) exists — UpdateMapItemValue is an in-memory mirror update, not a writer of truth. Thread-safe.

type RuntimeRef

type RuntimeRef struct {
	ModuleID      string
	NodePath      string
	RuntimeParent string
	Uses          string
	Container     string
}

func WalkRuntimeRefs

func WalkRuntimeRefs(moduleID, runtimeParent string, wf *ir.Workflow) []RuntimeRef

type Scope

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

Scope is the slice 2.3 adapter that satisfies template.Scope by reading from a RunState and a workflow IR. The interpreter constructs one per template evaluation (substitute a run: command, evaluate an if.cond, evaluate a gate.until) with the ctxPath set to the runtime path of the node about to be processed.

Phase 3 reference vocabulary: run.id, input.<field>, step.<id>.exit_code, step.<id>.stdout, step.<id>.<field>, evaluate.<field> (slice 3.3), <as>.<field> (slice 3.4 — bound inside a map body via the per-item record in RunState.MapItems). Roots not in this list return AWF4002 unresolved.

verdictOverride: optional. When non-nil, evaluate.* resolves against it directly INSTEAD of consulting RunState.GateAttempts. The gate executor (engine/gate.go) sets this when evaluating gate.until — the just-produced verdict isn't yet in GateAttempts (the gate.attempt event hasn't committed), so the override carries the verdict through.

Nested loops are out of scope for slice 2.3 (see plan Design question 3); stepRuntimePath errors with a clear "nested loops not supported" message rather than silently computing a wrong path.

func NewScope

func NewScope(rs *RunState, wf *ir.Workflow, ctxPath string) *Scope

NewScope wires the inputs into a Scope. ctxPath is the runtime path of the node about to be evaluated — the static IR path with each loop-body segment suffixed by ".iter-N" for the current iteration. See the plan's "ctxPath contract" table for what to pass at each evaluation site.

func NewScopeWithInput

func NewScopeWithInput(rs *RunState, wf *ir.Workflow, ctxPath string, input map[string]any) *Scope

NewScopeWithInput constructs a Scope whose input.* refs resolve against input instead of the run's top-level input. Used for child workflows with typed call inputs.

func NewScopeWithInputAndFiles

func NewScopeWithInputAndFiles(rs *RunState, wf *ir.Workflow, ctxPath string, input map[string]any, inputFiles map[string]string) *Scope

NewScopeWithInputAndFiles constructs a Scope whose input.* refs resolve against typed call input when provided, and whose input.files.<name> refs resolve against the call invocation's recorded input file refs.

func NewScopeWithVerdict

func NewScopeWithVerdict(rs *RunState, wf *ir.Workflow, ctxPath string, verdict map[string]any) *Scope

NewScopeWithVerdict constructs a Scope with verdictOverride set. Used by the gate executor (engine/gate.go) when evaluating gate.until: the just-produced verdict is bound to evaluate.* before the gate.attempt event commits.

gatePath is the static gate path (e.g. "gate[0]"); ctxPath is synthesized as "<gatePath>.attempt-<N>.until" — but callers pass the full synthesized path, not gatePath alone (this constructor doesn't synthesize).

func (*Scope) Resolve

func (s *Scope) Resolve(ref *template.Ref) (any, error)

Resolve implements template.Scope. Dispatches on the first ref segment; the AWF4001 size check is NOT performed here — template.resolveRefValue applies it uniformly after the Scope returns.

Returned composite values (map[string]any, []any) alias the underlying RunState — callers MUST NOT mutate them (see RunState.NodeResult's READ-ONLY caveat).

func (*Scope) ResolveArtifactPath

func (s *Scope) ResolveArtifactPath(id, containerPath string) (string, error)

ResolveArtifactPath resolves a producer step id + its substituted container path to the committed CAS blob ref (NodeResult.Files[path], which is PATH-keyed). Reuses stepIndex + stepRuntimePath so map/loop/gate multiplicity is handled identically to scalar refs.

func (*Scope) ResolveDeclaredArtifactPath

func (s *Scope) ResolveDeclaredArtifactPath(id, declaredPath string) (string, error)

ResolveDeclaredArtifactPath resolves a producer id + its declared output_files path to the committed CAS blob ref. Most step artifacts substitute the declared path in the consumer scope to mirror capture-time substitution. Named reduced map artifacts are different: the reducer owned capture-time substitution, so the declared path is rendered against the map product's reducer scope.

func (*Scope) ResolveWorkflowInputFile

func (s *Scope) ResolveWorkflowInputFile(name string) (string, error)

type SelectedSkill

type SelectedSkill struct {
	ID    string  `json:"id"`
	Score float64 `json:"score"`
}

SelectedSkill is one routed skill ID and its router score.

type SignalEntry

type SignalEntry struct {
	Seq        int
	PayloadRef string
}

SignalEntry is one element of RunState.Signals[name]. Records a delivered signal — observational only. Slice 3.5 addition.

Seq is the broker-assigned monotonic counter per signal name (1-based; matches signal/broker.go's seq allocation). PayloadRef is the CAS pointer; callers that need the materialized payload call blobs.Get(PayloadRef) + json.Unmarshal at use (the deferred-materialization pattern from Temporal).

REFS ONLY — no materialized Payload field. See SignalReceivedEntry's doc-comment for the rationale (non-object payloads break json.Unmarshal into map[string]any at Fold time).

type SignalReceivedData

type SignalReceivedData struct {
	Name       string `json:"name"`
	Seq        int    `json:"seq"`
	PayloadRef string `json:"payload_ref,omitempty"`
}

SignalReceivedData is the payload of a signal.received event (Phase 3 slice 3.5). Name is the signal-name the await step blocked on. Seq is the broker- assigned monotonic counter (1-based per name; see signal/broker.go). PayloadRef is the CAS pointer for the typed payload. Empty PayloadRef means the signal carried no payload (signal step with no output_schema, valid per spec §4.3).

type SignalReceivedEntry

type SignalReceivedEntry struct {
	Seq        int
	PayloadRef string
}

SignalReceivedEntry is the path-keyed record of a journaled signal.received event (slice 3.5 design Q7 — half-commit resume mechanism). Fold populates SignalReceivedAt[event.Path] from each signal.received event; the runSignalStep handler checks for an existing entry BEFORE calling broker.Receive — if present, it's the half-commit case (signal.received landed but node.completed did not), so the handler skips the Receive + signal.received-append and writes only the missing node.completed.

Seq is the broker-assigned counter from the originating signal-<name>-<seq>.json. PayloadRef is the CAS pointer (the same one node.completed will reference).

REFS ONLY — no materialized Payload field. An earlier draft stored the typed payload directly, but that assumed payloads are always JSON objects; unschema'd signals can carry non-object payloads (spec §4.3 allows it), which would have broken Fold's json.Unmarshal into map[string]any. The refined design stores only refs; the half-commit handler re-derives the typed payload via Blobs.Get + ValidateAgainstSchema(payload, schema) when the SignalStep has an output_schema declared.

type SkillsSelectedData

type SkillsSelectedData struct {
	Library       string             `json:"library"`
	LibraryDigest string             `json:"library_digest"`
	Router        string             `json:"router"`
	RouterVersion string             `json:"router_version"`
	RouterParams  map[string]float64 `json:"router_params"`
	Selected      []SelectedSkill    `json:"selected"`
}

SkillsSelectedData is the payload of skills.selected. The metadata pins the exact corpus/router snapshot used to produce Selected; on resume, runAgentStep validates it against the current run-start asset snapshot before reusing the recorded IDs.

type SkipUnwind

type SkipUnwind struct {
	TargetPath string
	Reason     string
}

SkipUnwind is the typed sentinel `Skip` (spec §5.6) raises to unwind the recursive interpreter walk to its target scope (workflow root, loop iter, try.do, parallel branch, gate attempt, map item — the last three land in slices 3.2 / 3.3 / 3.4). Implements `error` so it flows through the existing `(Outcome, error)` propagation pattern; recognizing handlers use `var su *SkipUnwind; errors.As(err, &su)` to distinguish it from a real failure.

NOT an OutcomeError-equivalent — Skip is terminal-ok at its target scope, not failure. `runTry` recognizes SkipUnwind from its Do block and skips Catch (no catch on a skip — finally still runs).

TargetPath is empty after runSkip; recognizing handlers may populate it when they ARE the target (for tracing). Reason is the author's free-text from `skip: <reason>` (spec §5.6).

Phase 3 design decision 5 (revised): plain error returns + SkipUnwind sentinel only — no typed OutcomeError wrapper, since unconditional catch (decision 7) only needs `err != nil` and `var su *SkipUnwind; errors.As(err, &su)`.

func (*SkipUnwind) Error

func (e *SkipUnwind) Error() string

Error renders the SkipUnwind for diagnostic purposes — engine internals don't log SkipUnwind directly (it's a control sentinel, not a fault), but if it leaks into a generic error path the message identifies what happened.

type WorkflowExportResult

type WorkflowExportResult struct {
	Outputs map[string]any
	Files   map[string]string
}

func EvaluateExports

func EvaluateExports(rs *RunState, wf *ir.Workflow, ctxPath string, input map[string]any, blobs state.Blobs) (WorkflowExportResult, error)

EvaluateExports evaluates a workflow's outputs:/output_schema/output_files against an ALREADY-CORRECT (rs, ctxPath, input). The caller constructs rs: a sub-workflow call prefix-strips via childRunStateForCall and passes the parent path; a top-level run (awf outputs) passes the folded RunState directly with ctxPath="" and input=nil. Shared so both paths are one implementation (mirrors the engine's top-level-vs-call split in interpreter_context.go).

Jump to

Keyboard shortcuts

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