chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// Package flow implements the 'micro flow' command for event-driven
|
||||
// LLM orchestration of microservices.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/cmd"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "flow",
|
||||
Usage: "Event-driven LLM orchestration",
|
||||
Description: `Run flows that subscribe to broker events and use an LLM to
|
||||
orchestrate service calls in response.
|
||||
|
||||
This command runs a single-step flow from flags. For ordered, durable
|
||||
multi-step workflows (checkpointed steps that resume after a crash),
|
||||
define the flow in code with micro.FlowSteps and a Checkpoint — see
|
||||
examples/flow-durable.
|
||||
|
||||
Examples:
|
||||
# Run a flow that reacts to user creation events
|
||||
micro flow run --trigger events.user.created \
|
||||
--prompt "New user: {{.Data}}. Send welcome email." \
|
||||
--provider anthropic
|
||||
|
||||
# Run a one-shot flow with inline data
|
||||
micro flow exec --prompt "List all users and count them" \
|
||||
--provider anthropic
|
||||
|
||||
# Run a flow with a specific model
|
||||
micro flow exec --prompt "Create a test user" \
|
||||
--provider atlascloud --model deepseek-ai/DeepSeek-V3-0324`,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "run",
|
||||
Usage: "Start a flow that listens to broker events",
|
||||
Flags: flowFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runFlow(c, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "exec",
|
||||
Usage: "Execute a flow once with inline data",
|
||||
Flags: append(flowFlags(), &cli.StringFlag{
|
||||
Name: "data",
|
||||
Usage: "Input data for the flow (default: reads from --prompt only)",
|
||||
}),
|
||||
Action: func(c *cli.Context) error {
|
||||
return runFlow(c, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List running flows (from the registry, type=flow)",
|
||||
Action: listFlows,
|
||||
},
|
||||
{
|
||||
Name: "runs",
|
||||
Usage: "Show durable run history for a flow",
|
||||
ArgsUsage: "[name]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print durable run history as JSON for automation"},
|
||||
&cli.BoolFlag{Name: "pending", Usage: "Only show runs that have not completed"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, failed)"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
&cli.StringFlag{Name: "stage", Usage: "Only show runs currently checkpointed at this stage"},
|
||||
},
|
||||
Action: flowRuns,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// listFlows shows flows currently registered in the registry — the live
|
||||
// view, mirroring `micro agent list`.
|
||||
func listFlows(c *cli.Context) error {
|
||||
svcs, err := registry.ListServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, svc := range svcs {
|
||||
records, err := registry.GetService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
meta := records[0].Metadata
|
||||
isFlow := meta != nil && meta["type"] == "flow"
|
||||
if !isFlow && len(records[0].Nodes) > 0 {
|
||||
nm := records[0].Nodes[0].Metadata
|
||||
isFlow = nm != nil && nm["type"] == "flow"
|
||||
}
|
||||
if !isFlow {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
trigger := ""
|
||||
if meta != nil {
|
||||
trigger = meta["trigger"]
|
||||
}
|
||||
fmt.Printf(" \033[36m⚡\033[0m %-20s trigger: %s\n", svc.Name, trigger)
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" No running flows.")
|
||||
fmt.Println(" Run one with: micro flow run --trigger <topic> --prompt <...>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flowRuns shows a flow's durable run history from the store — the
|
||||
// historic view, available whether or not the flow is currently running.
|
||||
func flowRuns(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("flow name required: micro flow runs <name>")
|
||||
}
|
||||
runs, err := aiflow.StoreCheckpoint(nil, name).List(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts, err := validateFlowRunOptions(flowRunOptions{Pending: c.Bool("pending"), Status: c.String("status"), Stage: c.String("stage"), Limit: c.Int("limit")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runs = filterFlowRuns(runs, opts)
|
||||
if len(runs) == 0 {
|
||||
if c.Bool("pending") {
|
||||
fmt.Printf(" No pending runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" No runs recorded for flow %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
return writeFlowRuns(os.Stdout, runs, c.Bool("json"))
|
||||
}
|
||||
|
||||
type flowRunOptions struct {
|
||||
Pending bool
|
||||
Status string
|
||||
Stage string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func validateFlowRunOptions(opts flowRunOptions) (flowRunOptions, error) {
|
||||
switch opts.Status {
|
||||
case "", "running", "done", "failed":
|
||||
default:
|
||||
return opts, fmt.Errorf("invalid run status %q: expected running, done, or failed", opts.Status)
|
||||
}
|
||||
if opts.Limit < 0 {
|
||||
return opts, fmt.Errorf("invalid limit %d: expected a non-negative value", opts.Limit)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func filterFlowRuns(runs []aiflow.Run, opts flowRunOptions) []aiflow.Run {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]aiflow.Run, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
if opts.Pending && run.Status == "done" {
|
||||
continue
|
||||
}
|
||||
if opts.Status != "" && run.Status != opts.Status {
|
||||
continue
|
||||
}
|
||||
if opts.Stage != "" && run.State.Stage != opts.Stage {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, run)
|
||||
}
|
||||
if opts.Limit > 0 && len(filtered) > opts.Limit {
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
if filtered[i].Updated.Equal(filtered[j].Updated) {
|
||||
return filtered[i].Started.Before(filtered[j].Started)
|
||||
}
|
||||
return filtered[i].Updated.Before(filtered[j].Updated)
|
||||
})
|
||||
start := len(filtered) - opts.Limit
|
||||
limited := append([]aiflow.Run(nil), filtered[start:]...)
|
||||
return limited
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func pendingFlowRuns(runs []aiflow.Run) []aiflow.Run {
|
||||
return filterFlowRuns(runs, flowRunOptions{Pending: true})
|
||||
}
|
||||
|
||||
func writeFlowRuns(w io.Writer, runs []aiflow.Run, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
fmt.Fprintf(w, " %d run%s\n", len(runs), plural(len(runs)))
|
||||
for _, r := range runs {
|
||||
id := r.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
stage := r.State.Stage
|
||||
if stage == "" {
|
||||
stage = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s %-8s stage=%-12s updated=%s (%d steps)\n",
|
||||
id, r.Status, stage, r.Updated.Format("2006-01-02T15:04:05Z07:00"), len(r.Steps))
|
||||
for _, step := range r.Steps {
|
||||
fmt.Fprintf(w, " - %-12s %-11s attempts=%d", step.Name, step.Status, step.Attempts)
|
||||
if step.Error != "" {
|
||||
fmt.Fprintf(w, " error=%q", step.Error)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func flowFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{Name: "trigger", Usage: "Broker topic to subscribe to", EnvVars: []string{"MICRO_FLOW_TRIGGER"}},
|
||||
&cli.StringFlag{Name: "prompt", Usage: "Prompt template (use {{.Data}} for event data)", EnvVars: []string{"MICRO_FLOW_PROMPT"}},
|
||||
&cli.StringFlag{Name: "provider", Usage: "AI provider", Value: "openai", EnvVars: []string{"MICRO_AI_PROVIDER"}},
|
||||
&cli.StringFlag{Name: "api_key", Usage: "API key", EnvVars: []string{"MICRO_AI_API_KEY"}},
|
||||
&cli.StringFlag{Name: "model", Usage: "Model name", EnvVars: []string{"MICRO_AI_MODEL"}},
|
||||
&cli.StringFlag{Name: "base_url", Usage: "Provider base URL", EnvVars: []string{"MICRO_AI_BASE_URL"}},
|
||||
&cli.StringFlag{Name: "name", Usage: "Flow name", Value: "default"},
|
||||
}
|
||||
}
|
||||
|
||||
func runFlow(c *cli.Context, oneShot bool) error {
|
||||
prompt := c.String("prompt")
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("--prompt is required")
|
||||
}
|
||||
|
||||
provider := c.String("provider")
|
||||
apiKey := c.String("api_key")
|
||||
if apiKey == "" {
|
||||
apiKey = fallbackKey(provider)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key; set --api_key or the provider's env var")
|
||||
}
|
||||
|
||||
opts := []aiflow.Option{
|
||||
aiflow.Prompt(prompt),
|
||||
aiflow.Provider(provider),
|
||||
aiflow.APIKey(apiKey),
|
||||
}
|
||||
if v := c.String("trigger"); v != "" {
|
||||
opts = append(opts, aiflow.Trigger(v))
|
||||
}
|
||||
if v := c.String("model"); v != "" {
|
||||
opts = append(opts, aiflow.Model(v))
|
||||
}
|
||||
if v := c.String("base_url"); v != "" {
|
||||
opts = append(opts, aiflow.BaseURL(v))
|
||||
}
|
||||
|
||||
opts = append(opts, aiflow.OnResult(func(r aiflow.Result) {
|
||||
out, _ := json.MarshalIndent(r, "", " ")
|
||||
fmt.Println(string(out))
|
||||
}))
|
||||
|
||||
f := aiflow.New(c.String("name"), opts...)
|
||||
|
||||
reg := registry.DefaultRegistry
|
||||
br := broker.DefaultBroker
|
||||
cl := client.DefaultClient
|
||||
|
||||
if err := br.Connect(); err != nil {
|
||||
return fmt.Errorf("broker connect: %w", err)
|
||||
}
|
||||
|
||||
if err := f.Register(reg, br, cl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oneShot {
|
||||
data := c.String("data")
|
||||
if data == "" {
|
||||
data = prompt
|
||||
}
|
||||
return f.Execute(context.Background(), data)
|
||||
}
|
||||
|
||||
if c.String("trigger") == "" {
|
||||
return fmt.Errorf("--trigger is required for 'flow run' (use 'flow exec' for one-shot)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro flow\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Flow \033[36m%s\033[0m\n", f.Name())
|
||||
fmt.Printf(" Topic \033[36m%s\033[0m\n", c.String("trigger"))
|
||||
fmt.Printf(" Provider \033[36m%s\033[0m\n", provider)
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[2mListening for events. Ctrl-C to stop.\033[0m")
|
||||
fmt.Println()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
fmt.Printf("\nStopped. %d executions recorded.\n", len(f.Results()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackKey(provider string) string {
|
||||
envMap := map[string]string{
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"groq": "GROQ_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
"together": "TOGETHER_API_KEY",
|
||||
"atlascloud": "ATLASCLOUD_API_KEY",
|
||||
}
|
||||
if env, ok := envMap[provider]; ok {
|
||||
return os.Getenv(env)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
func TestWriteFlowRunsIncludesStepDetails(t *testing.T) {
|
||||
updated := time.Date(2026, 6, 24, 12, 30, 0, 0, time.UTC)
|
||||
runs := []aiflow.Run{{
|
||||
ID: "1234567890abcdef",
|
||||
Status: "failed",
|
||||
Updated: updated,
|
||||
State: aiflow.State{Stage: "charge"},
|
||||
Steps: []aiflow.StepRecord{
|
||||
{Name: "reserve", Status: "done", Attempts: 1},
|
||||
{Name: "charge", Status: "failed", Attempts: 3, Error: "card declined"},
|
||||
},
|
||||
}}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowRuns(&out, runs, false); err != nil {
|
||||
t.Fatalf("writeFlowRuns: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"1 run",
|
||||
"12345678 failed stage=charge",
|
||||
"updated=2026-06-24T12:30:00Z",
|
||||
"- reserve done attempts=1",
|
||||
`- charge failed attempts=3 error="card declined"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsInvalidStatus(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Status: "stuck"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid run status") {
|
||||
t.Fatalf("expected invalid status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlowRunOptionsRejectsNegativeLimit(t *testing.T) {
|
||||
_, err := validateFlowRunOptions(flowRunOptions{Limit: -1})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid limit") {
|
||||
t.Fatalf("expected invalid limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFlowRunsJSON(t *testing.T) {
|
||||
runs := []aiflow.Run{{ID: "run-1", Flow: "checkout", Status: "done"}}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := writeFlowRuns(&out, runs, true); err != nil {
|
||||
t.Fatalf("writeFlowRuns: %v", err)
|
||||
}
|
||||
var got []aiflow.Run
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "run-1" || got[0].Flow != "checkout" || got[0].Status != "done" {
|
||||
t.Fatalf("decoded runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingFlowRunsFiltersCompletedRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := pendingFlowRuns(runs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pendingFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("pending runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStatus(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
{ID: "run-4", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Status: "failed"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-4" {
|
||||
t.Fatalf("failed runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsStage(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed", State: aiflow.State{Stage: "reserve"}},
|
||||
{ID: "run-2", Status: "failed", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-3", Status: "running", State: aiflow.State{Stage: "charge"}},
|
||||
{ID: "run-4", Status: "done", State: aiflow.State{}},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Stage: "charge"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("charge-stage runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsLimitKeepsNewestRuns(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "done"},
|
||||
{ID: "run-2", Status: "failed"},
|
||||
{ID: "run-3", Status: "running"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-2" || got[1].ID != "run-3" {
|
||||
t.Fatalf("limited runs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFlowRunsCombinesPendingStatusAndLimit(t *testing.T) {
|
||||
runs := []aiflow.Run{
|
||||
{ID: "run-1", Status: "failed"},
|
||||
{ID: "run-2", Status: "done"},
|
||||
{ID: "run-3", Status: "failed"},
|
||||
{ID: "run-4", Status: "running"},
|
||||
{ID: "run-5", Status: "failed"},
|
||||
}
|
||||
|
||||
got := filterFlowRuns(runs, flowRunOptions{Pending: true, Status: "failed", Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filterFlowRuns returned %d runs, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "run-3" || got[1].ID != "run-5" {
|
||||
t.Fatalf("filtered runs = %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user