d9414e4cba
Full DAG engine app with CLI subcommands (run, list, status, validate, server) and React/Mantine web frontend. Uses net/http + embedded Vite build. SQLite store for run history. Scheduler with cron_ticker for automated execution. Compatible with existing dagu YAML format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
337 lines
7.4 KiB
Go
337 lines
7.4 KiB
Go
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 <command> [options]
|
|
|
|
Commands:
|
|
run <path.yaml> Execute a DAG and show results
|
|
list [dir] List DAGs with schedule and last status
|
|
status [dag_name] Show execution history
|
|
validate <path.yaml> Parse and validate without executing
|
|
server Start HTTP server with web frontend
|
|
|
|
Server options:
|
|
--port <port> HTTP port (default: 8090)
|
|
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
|
|
--db <path> 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 <path.yaml>")
|
|
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 <path.yaml>")
|
|
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)
|
|
}
|
|
}
|