§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:
+111
-2
@@ -18,18 +18,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.agentview.dev/profit/golangLAKEHOUSE/internal/observer"
|
||||
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
||||
"git.agentview.dev/profit/golangLAKEHOUSE/internal/workflow"
|
||||
)
|
||||
|
||||
const maxRequestBytes = 4 << 20 // 4 MiB cap on request bodies
|
||||
@@ -66,7 +70,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
h := &handlers{store: store}
|
||||
runner := workflow.NewRunner()
|
||||
registerBuiltinModes(runner, store)
|
||||
|
||||
h := &handlers{store: store, runner: runner}
|
||||
if err := shared.Run("observerd", cfg.Observerd.Bind, h.register, cfg.Auth); err != nil {
|
||||
slog.Error("server", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -74,12 +81,15 @@ func main() {
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
store *observer.Store
|
||||
store *observer.Store
|
||||
runner *workflow.Runner
|
||||
}
|
||||
|
||||
func (h *handlers) register(r chi.Router) {
|
||||
r.Get("/observer/stats", h.handleStats)
|
||||
r.Post("/observer/event", h.handleEvent)
|
||||
r.Post("/observer/workflow/run", h.handleWorkflowRun)
|
||||
r.Get("/observer/workflow/modes", h.handleWorkflowModes)
|
||||
}
|
||||
|
||||
func (h *handlers) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -107,6 +117,105 @@ func (h *handlers) handleEvent(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// workflowRunRequest is the POST /observer/workflow/run body — a
|
||||
// Workflow definition in JSON form (matches Archon's YAML shape but
|
||||
// JSON-serialized for the HTTP path).
|
||||
type workflowRunRequest struct {
|
||||
Workflow workflow.Workflow `json:"workflow"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleWorkflowRun(r http.ResponseWriter, req *http.Request) {
|
||||
var body workflowRunRequest
|
||||
if !decodeJSON(r, req, &body) {
|
||||
return
|
||||
}
|
||||
res, err := h.runner.Run(req.Context(), body.Workflow)
|
||||
// Record per-node provenance into the observer ring AS the
|
||||
// workflow runs — same shape as any other ObservedOp so the
|
||||
// existing /observer/stats aggregation surfaces workflow ops
|
||||
// alongside scenario ops without a schema change.
|
||||
for _, n := range res.Nodes {
|
||||
op := observer.ObservedOp{
|
||||
Endpoint: "/observer/workflow/run/" + body.Workflow.Name + "/" + n.NodeID,
|
||||
InputSummary: fmt.Sprintf("workflow=%s node=%s mode=%s", body.Workflow.Name, n.NodeID, n.Mode),
|
||||
Success: n.Error == "",
|
||||
DurationMs: n.DurationMs,
|
||||
OutputSummary: summarizeOutput(n.Output),
|
||||
Source: observer.Source("workflow"),
|
||||
Error: n.Error,
|
||||
Timestamp: n.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if recErr := h.store.Record(op); recErr != nil {
|
||||
slog.Warn("workflow run: provenance record failed", "err", recErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Aborting errors (cycle, missing dep, unknown mode) — surface
|
||||
// as 4xx because the workflow definition itself is wrong.
|
||||
slog.Warn("workflow run aborted", "err", err)
|
||||
writeJSON(r, http.StatusBadRequest, map[string]any{
|
||||
"error": err.Error(),
|
||||
"result": res,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(r, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *handlers) handleWorkflowModes(w http.ResponseWriter, _ *http.Request) {
|
||||
modes := h.runner.Modes()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"modes": modes,
|
||||
"count": len(modes),
|
||||
})
|
||||
}
|
||||
|
||||
// summarizeOutput renders a workflow node's output map for the
|
||||
// ObservedOp's OutputSummary string. Best-effort — long values get
|
||||
// truncated rather than ballooning the ring buffer's memory.
|
||||
func summarizeOutput(output map[string]any) string {
|
||||
if output == nil {
|
||||
return "(nil)"
|
||||
}
|
||||
bs, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(marshal err: %v)", err)
|
||||
}
|
||||
if len(bs) > 256 {
|
||||
return string(bs[:256]) + "...(truncated)"
|
||||
}
|
||||
return string(bs)
|
||||
}
|
||||
|
||||
// registerBuiltinModes wires the modes the runner knows about. v0
|
||||
// ships with fixture.echo + fixture.upper for testing the runner
|
||||
// mechanics; real-mode integrations (matrix.search, distillation.
|
||||
// score, drift.scorer, llm.chat) land in follow-up commits.
|
||||
//
|
||||
// Each mode's signature matches workflow.Mode. The store parameter
|
||||
// is reserved for modes that need to record their own ObservedOps
|
||||
// (most don't — the runner records per-node provenance generically).
|
||||
func registerBuiltinModes(r *workflow.Runner, _ *observer.Store) {
|
||||
r.RegisterMode("fixture.echo", func(_ workflow.Context, input map[string]any) (map[string]any, error) {
|
||||
// Verbatim copy of input → output. Useful for ref-substitution
|
||||
// chains in smokes.
|
||||
out := make(map[string]any, len(input))
|
||||
for k, v := range input {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
r.RegisterMode("fixture.upper", func(_ workflow.Context, input map[string]any) (map[string]any, error) {
|
||||
// Returns {"upper": strings.ToUpper(input["prompt"])}. Toy
|
||||
// mode for proving DAG ref substitution end-to-end.
|
||||
prompt, _ := input["prompt"].(string)
|
||||
return map[string]any{"upper": strings.ToUpper(prompt)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// stub to silence "imported and not used" until a real mode uses it
|
||||
var _ = context.Background
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
|
||||
|
||||
Reference in New Issue
Block a user