e071084ebe
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
349 lines
9.6 KiB
Go
349 lines
9.6 KiB
Go
// 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 ""
|
|
}
|