Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:40:33 +08:00

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 ""
}