package main import ( "context" "embed" "flag" "fmt" iofs "io/fs" "log" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "text/tabwriter" "time" "fn-registry/functions/core" "dag-engine/store" ) //go:embed all:frontend/dist var frontendDist embed.FS func main() { if len(os.Args) < 2 { printUsage() os.Exit(1) } cmd := os.Args[1] args := os.Args[2:] switch cmd { case "run": cmdRun(args) case "list": cmdList(args) case "status": cmdStatus(args) case "validate": cmdValidate(args) case "server": cmdServer(args) case "help", "-h", "--help": printUsage() default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) printUsage() os.Exit(1) } } func printUsage() { fmt.Println(`dag-engine — DAG workflow executor Usage: dag-engine [options] Commands: run Execute a DAG and show results list [dir] List DAGs with schedule and last status status [dag_name] Show execution history validate Parse and validate without executing server Start HTTP server with web frontend Server options: --port HTTP port (default: 8090) --dags-dir DAGs directory (default: ~/dagu/dags) --db SQLite database path (default: dag_engine.db) --scheduler Auto-start cron scheduler`) } // --- CLI Commands --- func cmdRun(args []string) { if len(args) < 1 { fmt.Fprintln(os.Stderr, "usage: dag-engine run ") os.Exit(1) } dagPath := args[0] cfg := DefaultConfig() // Parse optional flags after the path. fs := flag.NewFlagSet("run", flag.ExitOnError) fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path") fs.Parse(args[1:]) db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() executor := NewExecutor(db, filepath.Dir(dagPath)) fmt.Printf("Executing %s...\n", dagPath) ctx := context.Background() runID, err := executor.ExecuteDAG(ctx, dagPath, "manual") // Print results. if runID != "" { run, _ := db.GetRun(runID) steps, _ := db.ListStepResults(runID) if run != nil { fmt.Println() for _, s := range steps { icon := " " switch s.Status { case "success": icon = "OK" case "failed": icon = "!!" case "skipped": icon = "--" case "running": icon = ".." } fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs) if s.Status == "failed" && s.Stderr != "" { for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") { fmt.Printf(" %s\n", line) } } } fmt.Println() dur := "" if run.FinishedAt != nil { dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond)) } fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur) } } if err != nil { os.Exit(1) } } func cmdList(args []string) { cfg := DefaultConfig() if len(args) > 0 && !strings.HasPrefix(args[0], "-") { cfg.DagsDir = args[0] args = args[1:] } fs := flag.NewFlagSet("list", flag.ExitOnError) fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path") fs.Parse(args) db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() executor := NewExecutor(db, cfg.DagsDir) dags, err := executor.ListDAGs() if err != nil { log.Fatalf("list dags: %v", err) } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN") for _, d := range dags { sched := strings.Join(d.Schedule, ", ") tags := strings.Join(d.Tags, ", ") lastStatus := "-" lastRun := "-" if d.LastRun != nil { lastStatus = d.LastRun.Status lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04") } typ := d.Type if typ == "" { typ = "chain" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun) } w.Flush() } func cmdStatus(args []string) { cfg := DefaultConfig() fs := flag.NewFlagSet("status", flag.ExitOnError) fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path") limit := fs.Int("limit", 10, "number of runs to show") fs.Parse(args) dagName := "" if fs.NArg() > 0 { dagName = fs.Arg(0) } db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() runs, total, err := db.ListRuns(dagName, *limit, 0) if err != nil { log.Fatalf("list runs: %v", err) } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total) if dagName != "" { fmt.Fprintf(w, " for %s", dagName) } fmt.Fprintln(w) fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION") for _, r := range runs { dur := "-" if r.FinishedAt != nil { dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String() } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.DagName, r.Status, r.Trigger, r.StartedAt.Format("2006-01-02 15:04:05"), dur) } w.Flush() } func cmdValidate(args []string) { if len(args) < 1 { fmt.Fprintln(os.Stderr, "usage: dag-engine validate ") os.Exit(1) } data, err := os.ReadFile(args[0]) if err != nil { log.Fatalf("read: %v", err) } dag, err := core.DagParse(data) if err != nil { log.Fatalf("parse error: %v", err) } result := core.DagValidate(dag) fmt.Printf("DAG: %s\n", dag.Name) fmt.Printf("Steps: %d\n", len(dag.Steps)) fmt.Printf("Schedule: %v\n", dag.Schedule) fmt.Printf("Type: %s\n", dag.Type) if result.Valid { fmt.Println("Validation: PASS") for i, level := range result.Levels { fmt.Printf(" Level %d: %v\n", i, level) } } else { fmt.Println("Validation: FAIL") for _, e := range result.Errors { fmt.Printf(" ERROR: %s\n", e) } } for _, w := range result.Warnings { fmt.Printf(" WARNING: %s\n", w) } if !result.Valid { os.Exit(1) } } // --- Server Command --- func cmdServer(args []string) { cfg := DefaultConfig() fs := flag.NewFlagSet("server", flag.ExitOnError) cfg.ParseFlags(fs, args) db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() executor := NewExecutor(db, cfg.DagsDir) scheduler := NewScheduler(executor, cfg.DagsDir) // Prepare frontend FS. var feFS iofs.FS distFS, err := iofs.Sub(frontendDist, "frontend/dist") if err == nil { // Check if dist has content (built frontend exists). entries, _ := iofs.ReadDir(distFS, ".") if len(entries) > 0 { feFS = distFS log.Printf("serving frontend from embedded dist/") } } if feFS == nil { log.Printf("no frontend build found, API-only mode") } mux := http.NewServeMux() RegisterAPI(mux, executor, scheduler, feFS) handler := corsMiddleware(loggingMiddleware(mux)) if cfg.AutoScheduler { if err := scheduler.Start(); err != nil { log.Printf("scheduler start: %v", err) } } addr := fmt.Sprintf(":%d", cfg.Port) log.Printf("dag-engine server starting on http://0.0.0.0%s", addr) log.Printf("dags dir: %s", cfg.DagsDir) log.Printf("database: %s", cfg.DBPath) srv := &http.Server{Addr: addr, Handler: handler} // Graceful shutdown. go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh log.Println("shutting down...") scheduler.Stop() srv.Shutdown(context.Background()) }() if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("server: %v", err) } }