ci / test (push) Successful in 1m46s
Use NewLLMRouterDefault(kb) instead of NewLLMRouter(kb) in:
- action/router.go
- cmd/starter/main.go
The NewLLMRouter now requires LLMRouterConfig{} struct.
Backward compat wrapper NewLLMRouterDefault() maintains old API.
300 lines
9.9 KiB
Go
300 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.temporal.io/sdk/client"
|
|
"github.com/rockliang/poimen/workflows/action/llm"
|
|
"github.com/rockliang/poimen/workflows/internal/config"
|
|
"github.com/rockliang/poimen/workflows/internal/health"
|
|
"github.com/rockliang/poimen/workflows/internal/logging"
|
|
"github.com/rockliang/poimen/workflows/internal/routing"
|
|
"github.com/rockliang/poimen/workflows/statemachine"
|
|
)
|
|
|
|
func main() {
|
|
var (
|
|
// Orchestrator flags
|
|
repoPath = flag.String("repo", "", "target repo path")
|
|
remoteURL = flag.String("remote", "", "remote URL")
|
|
milestone = flag.String("milestone", "T0", "milestone ID")
|
|
dryRun = flag.Bool("dry-run", false, "disable git push/merge (orchestrator) or skip submit (routing)")
|
|
plannerModel = flag.String("planner-model", "reasoning", "planner model ID (local-llm)")
|
|
judgeModel = flag.String("judge-model", "reasoning", "judge model ID (local-llm)")
|
|
implementerModel = flag.String("implementer-model", "ornith:35b", "implementer model ID (local-llm ornith)")
|
|
piProvider = flag.String("pi-provider", "local-llm", "pi provider name for skills (local-llm)")
|
|
healthCheck = flag.Bool("health", false, "check health and exit")
|
|
|
|
// Routing workflow flags
|
|
routeMsg = flag.String("route", "", "natural language message for LLM routing")
|
|
specFile = flag.String("spec", "", "JSON workflow spec file (direct submit, skip LLM)")
|
|
cronSpec = flag.Bool("cron", false, "treat spec as CronWorkflowSpec")
|
|
)
|
|
flag.Parse()
|
|
|
|
// Initialize structured logging
|
|
if err := logging.InitLogger(); err != nil {
|
|
log.Fatalf("failed to initialize logger: %v", err)
|
|
}
|
|
defer logging.Sync()
|
|
|
|
// Load configuration first
|
|
cfg, err := config.LoadConfig()
|
|
if err != nil {
|
|
logging.Fatal("failed to load config", logging.Err(err))
|
|
}
|
|
|
|
// Connect to Temporal
|
|
logging.Info("connecting to Temporal", logging.String("hostPort", cfg.Temporal.HostPort), logging.String("namespace", cfg.Temporal.Namespace))
|
|
c, err := client.Dial(client.Options{
|
|
HostPort: cfg.Temporal.HostPort,
|
|
Namespace: cfg.Temporal.Namespace,
|
|
})
|
|
if err != nil {
|
|
logging.Fatal("failed to connect to temporal", logging.Err(err))
|
|
}
|
|
defer c.Close()
|
|
|
|
// If health check requested, do it and exit
|
|
if *healthCheck {
|
|
logging.Info("running health check")
|
|
healthChecker := health.NewChecker(c)
|
|
report := healthChecker.Check(context.Background())
|
|
jsonReport, _ := report.ToJSON()
|
|
fmt.Println(string(jsonReport))
|
|
if report.Status != health.StatusHealthy {
|
|
logging.Fatal("health check failed")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle routing workflow mode
|
|
if *routeMsg != "" || *specFile != "" {
|
|
runRoutingWorkflow(c, *routeMsg, *specFile, *cronSpec, *dryRun)
|
|
return
|
|
}
|
|
|
|
// Validate required flags for orchestrator workflow
|
|
if *repoPath == "" || *remoteURL == "" {
|
|
logging.Fatal("--repo and --remote flags are required (or use --route/--spec for routing workflow)")
|
|
}
|
|
|
|
|
|
|
|
// Build OrchestratorInput
|
|
input := statemachine.OrchestratorInput{
|
|
TargetRepoPath: *repoPath,
|
|
RemoteURL: *remoteURL,
|
|
Milestone: *milestone,
|
|
DryRun: *dryRun,
|
|
MaxCyclesBeforeCAN: 100,
|
|
PiProvider: *piProvider,
|
|
Config: statemachine.OrchestratorConfig{
|
|
SystemPrompt: "You are an expert software developer orchestrating multi-agent work.",
|
|
Skills: []statemachine.SkillRef{},
|
|
RolePrompts: map[string]statemachine.PromptSpec{
|
|
"planner": {
|
|
TemplateRef: "planner/default.tmpl",
|
|
Model: statemachine.ModelSpec{
|
|
ModelID: *plannerModel,
|
|
Thinking: "adaptive",
|
|
Effort: "high",
|
|
},
|
|
},
|
|
"judge": {
|
|
TemplateRef: "judge/default.tmpl",
|
|
Model: statemachine.ModelSpec{
|
|
ModelID: *judgeModel,
|
|
Thinking: "adaptive",
|
|
Effort: "high",
|
|
},
|
|
},
|
|
"implementer": {
|
|
TemplateRef: "implementer/default.tmpl",
|
|
Model: statemachine.ModelSpec{
|
|
ModelID: *implementerModel,
|
|
},
|
|
},
|
|
},
|
|
Tuning: statemachine.NewActivityTuning(),
|
|
},
|
|
}
|
|
|
|
// Health check: verify local LLM API is reachable
|
|
logging.Info("checking local LLM API connectivity", logging.String("url", "https://api.riotpiao.com"))
|
|
llmClient, err := llm.NewClient()
|
|
if err != nil {
|
|
logging.Fatal("failed to create LLM client", logging.Err(err))
|
|
}
|
|
if err := llmClient.HealthCheck(context.Background()); err != nil {
|
|
logging.Fatal("local LLM API health check failed", logging.Err(err), logging.String("hint", "ensure homelab-frontend gateway is running and accessible"))
|
|
}
|
|
logging.Info("local LLM API is reachable", logging.String("planner-model", *plannerModel), logging.String("judge-model", *judgeModel), logging.String("implementer-model", *implementerModel))
|
|
|
|
// Start workflow
|
|
workflowID := "orch-" + strings.ReplaceAll(*repoPath, "/", "-")
|
|
logging.Info("starting orchestrator workflow", logging.String("workflowID", workflowID), logging.String("repo", *repoPath))
|
|
run, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: "poimen-taskqueue",
|
|
}, statemachine.OrchestratorWorkflow, input)
|
|
if err != nil {
|
|
logging.Fatal("failed to start workflow", logging.Err(err))
|
|
}
|
|
|
|
fmt.Printf("\n=== Workflow Started ===\n")
|
|
fmt.Printf("Workflow ID: %s\n", workflowID)
|
|
fmt.Printf("Task Queue: poimen-taskqueue\n")
|
|
fmt.Printf("\n=== Model Configuration ===\n")
|
|
fmt.Printf("Planner Model: %s\n", *plannerModel)
|
|
fmt.Printf("Judge Model: %s\n", *judgeModel)
|
|
fmt.Printf("Implementer Model: %s\n", *implementerModel)
|
|
fmt.Printf("\n=== Monitoring ===\n")
|
|
fmt.Printf("Web UI: http://%s:8080/namespaces/%s/workflows/%s\n",
|
|
strings.Split(cfg.Temporal.HostPort, ":")[0], cfg.Temporal.Namespace, workflowID)
|
|
|
|
// Optionally wait for completion (with timeout)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
|
defer cancel()
|
|
|
|
var result statemachine.OrchestratorOutput
|
|
if err := run.Get(ctx, &result); err != nil {
|
|
fmt.Printf("\nWorkflow initiated (execution in progress).\n")
|
|
fmt.Printf("Check the Web UI for real-time status updates.\n")
|
|
} else {
|
|
fmt.Printf("\nWorkflow completed: %+v\n", result)
|
|
}
|
|
}
|
|
|
|
// runRoutingWorkflow handles --route and --spec flags
|
|
func runRoutingWorkflow(c client.Client, routeMsg, specFile string, isCron, dryRun bool) {
|
|
ctx := context.Background()
|
|
|
|
var spec *routing.WorkflowSpec
|
|
var cronSpec *routing.CronWorkflowSpec
|
|
|
|
if specFile != "" {
|
|
// Load spec from file
|
|
data, err := os.ReadFile(specFile)
|
|
if err != nil {
|
|
logging.Fatal("failed to read spec file", logging.Err(err))
|
|
}
|
|
|
|
validator := routing.NewValidator(nil) // nil KB = skip activity validation
|
|
if isCron {
|
|
cronSpec = &routing.CronWorkflowSpec{}
|
|
if err := json.Unmarshal(data, cronSpec); err != nil {
|
|
logging.Fatal("failed to parse cron spec", logging.Err(err))
|
|
}
|
|
// Validate
|
|
result := validator.ValidateCronWorkflowSpec(cronSpec)
|
|
if !result.Valid {
|
|
logging.Fatal("invalid cron spec", logging.String("errors", result.String()))
|
|
}
|
|
} else {
|
|
spec = &routing.WorkflowSpec{}
|
|
if err := json.Unmarshal(data, spec); err != nil {
|
|
logging.Fatal("failed to parse spec", logging.Err(err))
|
|
}
|
|
// Validate
|
|
result := validator.ValidateWorkflowSpec(spec)
|
|
if !result.Valid {
|
|
logging.Fatal("invalid spec", logging.String("errors", result.String()))
|
|
}
|
|
}
|
|
} else {
|
|
// Use LLM router
|
|
logging.Info("routing message via LLM", logging.String("message", routeMsg))
|
|
|
|
kb, err := routing.LoadKnowledgeBaseFromDefaultPath()
|
|
if err != nil {
|
|
logging.Fatal("failed to load knowledge base", logging.Err(err))
|
|
}
|
|
|
|
router, err := routing.NewLLMRouterDefault(kb)
|
|
if err != nil {
|
|
logging.Fatal("failed to create LLM router", logging.Err(err))
|
|
}
|
|
|
|
output, err := router.Route(ctx, routing.LLMRouterInput{Message: routeMsg})
|
|
if err != nil {
|
|
logging.Fatal("LLM routing failed", logging.Err(err))
|
|
}
|
|
|
|
if output.IsCron {
|
|
cronSpec = output.CronSpec
|
|
fmt.Printf("\n=== Generated Cron Spec ===\n")
|
|
fmt.Printf("Name: %s\n", cronSpec.Name)
|
|
fmt.Printf("Schedule: %s\n", cronSpec.Schedule)
|
|
fmt.Printf("States: %d\n", len(cronSpec.States))
|
|
} else {
|
|
spec = output.Spec
|
|
fmt.Printf("\n=== Generated Workflow Spec ===\n")
|
|
fmt.Printf("Name: %s\n", spec.Name)
|
|
fmt.Printf("States: %d\n", len(spec.States))
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
fmt.Printf("\n[dry-run] Spec generated but not submitted\n")
|
|
if spec != nil {
|
|
data, _ := json.MarshalIndent(spec, "", " ")
|
|
fmt.Printf("%s\n", data)
|
|
} else if cronSpec != nil {
|
|
data, _ := json.MarshalIndent(cronSpec, "", " ")
|
|
fmt.Printf("%s\n", data)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Submit to Temporal
|
|
if cronSpec != nil {
|
|
// For cron, we'd use Temporal's schedule feature
|
|
// For now, just start as regular workflow (cron scheduling TBD)
|
|
spec = &routing.WorkflowSpec{
|
|
Name: cronSpec.Name,
|
|
Input: cronSpec.Input,
|
|
States: cronSpec.States,
|
|
}
|
|
logging.Warn("cron scheduling not yet implemented, running as one-shot workflow")
|
|
}
|
|
|
|
workflowID := "routing-" + spec.Name + "-" + time.Now().Format("20060102-150405")
|
|
input := statemachine.RoutingWorkflowInput{Spec: spec}
|
|
|
|
run, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: "poimen-taskqueue",
|
|
}, statemachine.RoutingWorkflow, input)
|
|
if err != nil {
|
|
logging.Fatal("failed to start routing workflow", logging.Err(err))
|
|
}
|
|
|
|
fmt.Printf("\n=== Routing Workflow Started ===\n")
|
|
fmt.Printf("Workflow ID: %s\n", workflowID)
|
|
fmt.Printf("Run ID: %s\n", run.GetRunID())
|
|
|
|
// Wait briefly for result
|
|
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
var result statemachine.RoutingWorkflowOutput
|
|
if err := run.Get(waitCtx, &result); err != nil {
|
|
fmt.Printf("\nWorkflow running (check Temporal UI for status)\n")
|
|
} else {
|
|
fmt.Printf("\nWorkflow completed: %s\n", result.Status)
|
|
if len(result.StepResults) > 0 {
|
|
for step, res := range result.StepResults {
|
|
fmt.Printf(" %s: %v\n", step, res)
|
|
}
|
|
}
|
|
}
|
|
}
|