fluid-core/cmd/fluid/main.go
tegwick 3407b9cf85
Some checks failed
ci / build (push) Has been cancelled
Add science control APIs and the hypothesis, experiment and promote CLI
Completes FLUID-WP-0006. The loop now runs end to end from the command
line: two competing presentation hypotheses, an experiment that issues a
routing policy rather than touching traffic, an amendment, a stop that
returns traffic to the default, a confirmed outcome, a resolved
competition, and a promotion the gate can refuse.

Starting or stopping an experiment returns the routing policy for the
operator to install rather than installing it. Blueprint 17 keeps the
controller out of the traffic path, and installing from the handler
would put it straight back in; emitting the document keeps the
separation visible instead of implied.

`fluid audit trace` now answers the section 25 questions from events
rather than summary records, and names the rivals a hypothesis beat: an
audit asking which hypotheses were considered is not answered by naming
only the winner.

Two fixes found by driving the CLI rather than only the tests. Go's flag
package stops at the first positional, so ids given after flags silently
swallowed them; ids are now taken before parsing. And there was no way
to attach a revision to the hypothesis that produced it, which left
`audit trace` unable to say why a revision existed -- `hypothesis attach`
closes that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 06:44:47 +02:00

164 lines
4.1 KiB
Go

// Command fluid is the FLUID operator CLI.
//
// It reads and writes the evidence store directly rather than going through the
// control-plane API, so that an operator can still inspect an interface's
// history when the control plane is down. Auditability must not depend on the
// availability of the thing being audited.
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
const usage = `fluid — FLUID operator CLI
Usage:
fluid [--store PATH] [--interface ID] <command> [arguments]
Commands:
intent put Record an interface evolution intent version
intent activate Make a recorded version the governing intent
intent show Show the active or a named intent version
revision publish Publish a signed revision descriptor
revision list List published revisions
revision show Show one revision descriptor
policy put Install a routing policy
policy show Show the current routing policy
pressure list List recorded interface pressure
pressure analyze Classify recent telemetry into pressure records
pressure dismiss Record that a pressure will not be acted on
cohort Summarize cohort populations
fitness compare Compare a candidate revision against its control
hypothesis Create, advance and compete hypotheses
experiment Design, start, stop and finalize experiments
promote Record a promotion decision
events Show audit events
telemetry Show recorded telemetry
audit trace Reconstruct the history behind a revision
Global flags:
--store PATH Evidence store path (default $FLUID_STORE or ./fluid.db)
--interface ID Interface identifier (default $FLUID_INTERFACE)
`
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
fmt.Fprintln(os.Stderr, "fluid:", err)
os.Exit(1)
}
}
type globals struct {
store string
iface string
remaining []string
}
func parseGlobals(args []string) (globals, error) {
g := globals{
store: envOr("FLUID_STORE", "fluid.db"),
iface: os.Getenv("FLUID_INTERFACE"),
}
i := 0
for i < len(args) {
switch args[i] {
case "--store":
if i+1 >= len(args) {
return g, errors.New("--store needs a value")
}
g.store, i = args[i+1], i+2
case "--interface":
if i+1 >= len(args) {
return g, errors.New("--interface needs a value")
}
g.iface, i = args[i+1], i+2
case "-h", "--help", "help":
fmt.Print(usage)
return g, flag.ErrHelp
default:
if strings.HasPrefix(args[i], "--") {
return g, fmt.Errorf("unknown global flag %q", args[i])
}
g.remaining = args[i:]
return g, nil
}
}
g.remaining = nil
return g, nil
}
func run(args []string) error {
g, err := parseGlobals(args)
if err != nil {
return err
}
if len(g.remaining) == 0 {
fmt.Print(usage)
return nil
}
ctx := context.Background()
cmd, rest := g.remaining[0], g.remaining[1:]
switch cmd {
case "intent":
return runIntent(ctx, g, rest)
case "revision":
return runRevision(ctx, g, rest)
case "policy":
return runPolicy(ctx, g, rest)
case "events":
return runEvents(ctx, g, rest)
case "telemetry":
return runTelemetry(ctx, g, rest)
case "pressure":
return runPressure(ctx, g, rest)
case "cohort":
return runCohort(ctx, g, rest)
case "fitness":
return runFitness(ctx, g, rest)
case "hypothesis":
return runHypothesis(ctx, g, rest)
case "experiment":
return runExperiment(ctx, g, rest)
case "promote":
return runPromote(ctx, g, rest)
case "audit":
return runAudit(ctx, g, rest)
default:
return fmt.Errorf("unknown command %q (try `fluid help`)", cmd)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// requireInterface fails clearly rather than silently operating on the wrong
// interface's history.
func (g globals) requireInterface() (string, error) {
if g.iface == "" {
return "", errors.New("no interface selected: pass --interface or set FLUID_INTERFACE")
}
return g.iface, nil
}
func (g globals) storePath() string {
if abs, err := filepath.Abs(g.store); err == nil {
return abs
}
return g.store
}