§3.8 first slice: workflow runner skeleton + DAG executor + observerd integration
Lands the structural piece of SPEC §3.8 (Observer-KB workflow runner)
documented in 97dd3f8: types + DAG runner + reference substitution +
provenance recording into observerd. Real-mode integrations
(matrix.search, distillation.score, drift.scorer, llm.chat) come in
follow-up commits — this commit proves the mechanics.
internal/workflow/types.go:
- Workflow / Node / NodeResult / RunResult types matching Archon's
YAML shape so existing workflows (e.g. lakehouse-architect-review.yaml)
load directly. Optional `mode` field added — implicit fall-back is
"llm.chat" matching Archon's convention.
- Mode signature: func(Context, map[string]any) (map[string]any, error)
- 4 sentinel errors: ErrCycle, ErrMissingDep, ErrUnknownMode,
ErrDuplicateNodeID, ErrUnresolvedRef
- Validate enforces structural invariants: unique IDs, every
depends_on resolves, no cycles
internal/workflow/runner.go:
- Kahn's-algorithm topological sort, stable for declaration-order
ties (deterministic execution + JSON output across runs)
- Reference substitution: $node_id.output.key.path resolves through
nested maps; $node_id alone resolves to the whole output map
- Skip cascade: a node whose dependency failed/skipped is skipped
with explicit "upstream node X failed" error in NodeResult, never
silently dropped
- Per-node provenance: NodeResult.StartedAt + DurationMs captured
for every execution
- Mode pre-validation: every node's mode checked against registry
BEFORE any node runs — typo catches in 5ms not after 6 nodes
internal/workflow/runner_test.go (14 tests, all PASS):
- Validate: missing name, no nodes, duplicate IDs, missing deps, cycles
- Run: single node, 3-node DAG with chained $-refs (shape→weakness→improvement),
failed-node skip cascade with independent siblings still running,
unknown-mode abort, unresolved-reference error, implicit
llm.chat fallback, provenance fields populated, inputs (not just
prompt) honor $-refs, topological-sort stability for ties
cmd/observerd extended:
- POST /observer/workflow/run executes a workflow, records each
node's execution as an ObservedOp (source="workflow"), returns
the full RunResult
- GET /observer/workflow/modes lists the registered mode names
- registerBuiltinModes wires fixture.echo + fixture.upper for v0;
real modes register here in follow-up commits
scripts/workflow_smoke.sh (4 assertions PASS):
- GET /modes lists fixture.echo + fixture.upper
- 3-node DAG executes: shape (uppercase "hello world") → weakness
(sees "HELLO WORLD" via $shape.output.upper ref) → improvement
(sees "HELLO WORLD" propagated through 2-hop $weakness.output.prompt)
- /observer/stats shows by_source.workflow == 3 (one per node) and
total == 3 — provenance lands as expected
- Unknown mode → 400 with "unknown mode" in error body
17-smoke regression all green. Acceptance gates G3.8.A (Archon-shape
workflow loads + executes topologically) + G3.8.B (per-node ObservedOps)
+ G3.8.C ($prior_node.output ref resolves, error on missing ref) all
satisfied. G3.8.D (in-process matrix.search dispatch) deferred until
a real mode is wired.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner executes Workflows. Modes are registered up-front; the
|
||||
// catalog is immutable after Build (callers compose by registering
|
||||
// at startup, then Run() the catalog repeatedly).
|
||||
type Runner struct {
|
||||
modes map[string]Mode
|
||||
}
|
||||
|
||||
// NewRunner returns an empty Runner. Use RegisterMode to populate.
|
||||
func NewRunner() *Runner {
|
||||
return &Runner{modes: make(map[string]Mode)}
|
||||
}
|
||||
|
||||
// RegisterMode adds a capability under the given name. Re-registering
|
||||
// the same name overwrites — useful for tests that want to replace a
|
||||
// mode with a stub. In production, register-once-at-startup is the
|
||||
// expected pattern.
|
||||
func (r *Runner) RegisterMode(name string, mode Mode) {
|
||||
r.modes[name] = mode
|
||||
}
|
||||
|
||||
// Modes returns the currently-registered mode names. Useful for
|
||||
// /v1/observer/workflow/modes-style discovery endpoints.
|
||||
func (r *Runner) Modes() []string {
|
||||
out := make([]string, 0, len(r.modes))
|
||||
for name := range r.modes {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Run executes a workflow. Validates structure, resolves nodes
|
||||
// topologically, executes each node with $-reference substitution,
|
||||
// records per-node results in RunResult.
|
||||
//
|
||||
// Aborting errors (cycle, missing dep, unknown mode) return early
|
||||
// with StatusAborted — no nodes execute. Per-node mode errors are
|
||||
// recorded in NodeResult.Error and execution continues with
|
||||
// independent nodes; downstream nodes that depended on the failing
|
||||
// one are SKIPPED with an explanatory error so the cascade is
|
||||
// visible in the result rather than silent.
|
||||
func (r *Runner) Run(ctx context.Context, w Workflow) (RunResult, error) {
|
||||
if err := w.Validate(); err != nil {
|
||||
return RunResult{
|
||||
Workflow: w.Name, Status: StatusAborted,
|
||||
StartedAt: time.Now(),
|
||||
}, err
|
||||
}
|
||||
|
||||
order, err := topoSort(w.Nodes)
|
||||
if err != nil {
|
||||
return RunResult{
|
||||
Workflow: w.Name, Status: StatusAborted,
|
||||
StartedAt: time.Now(),
|
||||
}, err
|
||||
}
|
||||
|
||||
// Verify every node's mode is registered before starting — fail
|
||||
// loud if someone references a typo'd mode name. Catches the bug
|
||||
// in 5ms instead of after 6 nodes have already run.
|
||||
for _, node := range w.Nodes {
|
||||
modeName := effectiveMode(node)
|
||||
if _, ok := r.modes[modeName]; !ok {
|
||||
return RunResult{
|
||||
Workflow: w.Name, Status: StatusAborted,
|
||||
StartedAt: time.Now(),
|
||||
}, fmt.Errorf("%w: %q (node %q)", ErrUnknownMode, modeName, node.ID)
|
||||
}
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
results := make(map[string]NodeResult, len(w.Nodes))
|
||||
resultsList := make([]NodeResult, 0, len(w.Nodes))
|
||||
failedNodes := make(map[string]bool) // node IDs whose result was Error
|
||||
skippedNodes := make(map[string]bool)
|
||||
|
||||
for _, nodeID := range order {
|
||||
node := findNode(w.Nodes, nodeID)
|
||||
modeName := effectiveMode(node)
|
||||
|
||||
// Skip if any dependency failed or was skipped — cascades
|
||||
// failure visibly so callers can see the chain.
|
||||
var skipReason string
|
||||
for _, dep := range node.DependsOn {
|
||||
if failedNodes[dep] {
|
||||
skipReason = fmt.Sprintf("upstream node %q failed", dep)
|
||||
break
|
||||
}
|
||||
if skippedNodes[dep] {
|
||||
skipReason = fmt.Sprintf("upstream node %q was skipped", dep)
|
||||
break
|
||||
}
|
||||
}
|
||||
if skipReason != "" {
|
||||
res := NodeResult{
|
||||
NodeID: node.ID, Mode: modeName,
|
||||
Error: skipReason,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
results[node.ID] = res
|
||||
resultsList = append(resultsList, res)
|
||||
skippedNodes[node.ID] = true
|
||||
continue
|
||||
}
|
||||
|
||||
nodeStart := time.Now()
|
||||
mode := r.modes[modeName] // pre-validated above; safe lookup
|
||||
|
||||
// Build the mode's input map with $-references resolved.
|
||||
input, refErr := buildInput(node, results)
|
||||
if refErr != nil {
|
||||
res := NodeResult{
|
||||
NodeID: node.ID, Mode: modeName,
|
||||
Error: refErr.Error(),
|
||||
StartedAt: nodeStart,
|
||||
DurationMs: time.Since(nodeStart).Milliseconds(),
|
||||
}
|
||||
results[node.ID] = res
|
||||
resultsList = append(resultsList, res)
|
||||
failedNodes[node.ID] = true
|
||||
continue
|
||||
}
|
||||
|
||||
modeCtx := Context{
|
||||
Ctx: ctx,
|
||||
WorkflowName: w.Name,
|
||||
NodeID: node.ID,
|
||||
Provider: w.Provider,
|
||||
Model: w.Model,
|
||||
}
|
||||
|
||||
output, err := mode(modeCtx, input)
|
||||
res := NodeResult{
|
||||
NodeID: node.ID,
|
||||
Mode: modeName,
|
||||
Output: output,
|
||||
StartedAt: nodeStart,
|
||||
DurationMs: time.Since(nodeStart).Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
failedNodes[node.ID] = true
|
||||
}
|
||||
results[node.ID] = res
|
||||
resultsList = append(resultsList, res)
|
||||
}
|
||||
|
||||
status := StatusSucceeded
|
||||
if len(failedNodes) > 0 || len(skippedNodes) > 0 {
|
||||
status = StatusPartial
|
||||
}
|
||||
return RunResult{
|
||||
Workflow: w.Name,
|
||||
Status: status,
|
||||
Nodes: resultsList,
|
||||
StartedAt: t0,
|
||||
DurationMs: time.Since(t0).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// effectiveMode returns the node's explicit mode if set, else
|
||||
// "llm.chat" (the implicit Archon convention).
|
||||
func effectiveMode(n Node) string {
|
||||
if n.Mode != "" {
|
||||
return n.Mode
|
||||
}
|
||||
return "llm.chat"
|
||||
}
|
||||
|
||||
// findNode is O(n) but called once per execution step on already-
|
||||
// validated workflows; n is small (typical workflow ≤10 nodes).
|
||||
func findNode(nodes []Node, id string) Node {
|
||||
for _, n := range nodes {
|
||||
if n.ID == id {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return Node{} // never reached on a Validated workflow
|
||||
}
|
||||
|
||||
// ─── Input building + reference substitution ────────────────────
|
||||
|
||||
// buildInput composes the input map a mode receives. Builds from
|
||||
// node.Inputs (deep-copy with $-refs substituted) plus injects the
|
||||
// "prompt" key from node.Prompt with $-refs substituted.
|
||||
//
|
||||
// $-reference syntax: $node_id.output.key — resolves to that key
|
||||
// in the prior node's output map. $node_id.output (no .key)
|
||||
// resolves to the whole output map. JSON-stringified inline.
|
||||
func buildInput(node Node, results map[string]NodeResult) (map[string]any, error) {
|
||||
out := make(map[string]any, len(node.Inputs)+1)
|
||||
for k, v := range node.Inputs {
|
||||
resolved, err := resolveRefs(v, results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = resolved
|
||||
}
|
||||
if node.Prompt != "" {
|
||||
resolvedPrompt, err := substituteStringRefs(node.Prompt, results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["prompt"] = resolvedPrompt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRefs walks any value (string, map, slice, scalar) and
|
||||
// substitutes $-references in any string elements.
|
||||
func resolveRefs(v any, results map[string]NodeResult) (any, error) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return substituteStringRefs(x, results)
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for k, vv := range x {
|
||||
r, err := resolveRefs(vv, results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = r
|
||||
}
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, vv := range x {
|
||||
r, err := resolveRefs(vv, results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = r
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return v, nil // numbers, bools, nil — pass through
|
||||
}
|
||||
}
|
||||
|
||||
// refRe matches $node_id or $node_id.output.key (where key is
|
||||
// dotted-path). Captures: 1=node_id, 2=optional ".output[.key]"
|
||||
// suffix.
|
||||
var refRe = regexp.MustCompile(`\$([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)`)
|
||||
|
||||
// substituteStringRefs replaces $node.output.key references in a
|
||||
// string with the resolved value (JSON-stringified for non-string
|
||||
// targets so the result is always a string).
|
||||
func substituteStringRefs(s string, results map[string]NodeResult) (string, error) {
|
||||
var firstErr error
|
||||
out := refRe.ReplaceAllStringFunc(s, func(match string) string {
|
||||
if firstErr != nil {
|
||||
return match
|
||||
}
|
||||
// Re-parse the match because ReplaceAllStringFunc gives the
|
||||
// whole match without submatches.
|
||||
m := refRe.FindStringSubmatch(match)
|
||||
nodeID := m[1]
|
||||
path := strings.TrimPrefix(m[2], ".")
|
||||
nodeRes, ok := results[nodeID]
|
||||
if !ok {
|
||||
firstErr = fmt.Errorf("%w: $%s (no such node, or node not yet run)", ErrUnresolvedRef, nodeID)
|
||||
return match
|
||||
}
|
||||
// path "output" or "output.X.Y" walks into nodeRes.Output
|
||||
val, err := walkPath(nodeRes.Output, path)
|
||||
if err != nil {
|
||||
firstErr = fmt.Errorf("%w: $%s — %v", ErrUnresolvedRef, nodeID+m[2], err)
|
||||
return match
|
||||
}
|
||||
return stringifyValue(val)
|
||||
})
|
||||
return out, firstErr
|
||||
}
|
||||
|
||||
// walkPath resolves a dotted path against a nested map. Empty path
|
||||
// returns the whole map. The first segment must be "output" — a
|
||||
// convention that matches the SPEC §3.8 reference shape and prevents
|
||||
// accidental access to other NodeResult fields.
|
||||
func walkPath(output map[string]any, path string) (any, error) {
|
||||
if path == "" {
|
||||
return output, nil // bare $node — entire NodeResult.Output
|
||||
}
|
||||
parts := strings.Split(path, ".")
|
||||
if parts[0] != "output" {
|
||||
return nil, fmt.Errorf("path must start with .output (got %q)", parts[0])
|
||||
}
|
||||
parts = parts[1:]
|
||||
var cur any = output
|
||||
for _, p := range parts {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot traverse into %T at segment %q", cur, p)
|
||||
}
|
||||
cur, ok = m[p]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key %q not found in output", p)
|
||||
}
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// stringifyValue renders a value as a string. For JSON-shaped values
|
||||
// (maps, slices, complex types), uses fmt.Sprintf %v which is
|
||||
// adequate for prompt-substitution. JSON marshaling would be cleaner
|
||||
// for complex types but adds a dep cycle for v0.
|
||||
func stringifyValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(x)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DAG resolution ──────────────────────────────────────────────
|
||||
|
||||
// topoSort returns node IDs in a topologically-sorted order such
|
||||
// that every dependency precedes its dependent. Cycles return an
|
||||
// error (Validate catches them first; this is defense in depth).
|
||||
func topoSort(nodes []Node) ([]string, error) {
|
||||
indeg := make(map[string]int, len(nodes))
|
||||
graph := make(map[string][]string, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if _, ok := indeg[n.ID]; !ok {
|
||||
indeg[n.ID] = 0
|
||||
}
|
||||
for _, dep := range n.DependsOn {
|
||||
graph[dep] = append(graph[dep], n.ID)
|
||||
indeg[n.ID]++
|
||||
}
|
||||
}
|
||||
// Kahn's algorithm — preserve original order for ties so output
|
||||
// is deterministic across runs.
|
||||
queue := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if indeg[n.ID] == 0 {
|
||||
queue = append(queue, n.ID)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(nodes))
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
out = append(out, cur)
|
||||
for _, child := range graph[cur] {
|
||||
indeg[child]--
|
||||
if indeg[child] == 0 {
|
||||
queue = append(queue, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) != len(nodes) {
|
||||
// Find a node still with non-zero indeg — that's where the
|
||||
// cycle is reachable from.
|
||||
for id, deg := range indeg {
|
||||
if deg > 0 {
|
||||
return nil, fmt.Errorf("%w: starting at node %q", ErrCycle, id)
|
||||
}
|
||||
}
|
||||
return nil, ErrCycle
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// detectCycle is the predicate-only variant called from Validate;
|
||||
// returns the offending node ID + true if a cycle exists.
|
||||
func detectCycle(nodes []Node) (string, bool) {
|
||||
_, err := topoSort(nodes)
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
// Best-effort extract — topoSort wraps the cycle-starting ID in
|
||||
// the error message; for v0 just signal "yes, somewhere."
|
||||
for _, n := range nodes {
|
||||
_ = n
|
||||
}
|
||||
return "(see runner error for details)", true
|
||||
}
|
||||
Reference in New Issue
Block a user