@@ -5,9 +5,11 @@ import (
55 "database/sql"
66 "encoding/json"
77 "flag"
8+ "errors"
89 "fmt"
910 "log"
1011 "log/slog"
12+ "net"
1113 "net/http"
1214 "os"
1315 "os/signal"
@@ -93,14 +95,10 @@ var (
9395 adminUIDir = flag .String ("admin-ui-dir" , "" , "Path to admin UI static assets directory (overrides ADMIN_UI_DIR env var). Leave empty to use the path in admin/config.yaml" )
9496)
9597
96- // buildEngine creates the workflow engine with all handlers registered and built from config.
97- func buildEngine (cfg * config.WorkflowConfig , logger * slog.Logger ) (* workflow.StdEngine , * dynamic.Loader , * dynamic.ComponentRegistry , error ) {
98- app := modular .NewStdApplication (nil , logger )
99- engine := workflow .NewStdEngine (app , logger )
100-
101- // Load all engine plugins — each registers its module factories, step factories,
102- // trigger factories, and workflow handlers via engine.LoadPlugin.
103- plugins := []plugin.EnginePlugin {
98+ // defaultEnginePlugins returns the standard set of engine plugins used by all engine instances.
99+ // Centralising the list here avoids duplication between buildEngine and runMultiWorkflow.
100+ func defaultEnginePlugins () []plugin.EnginePlugin {
101+ return []plugin.EnginePlugin {
104102 pluginlicense .New (),
105103 pluginhttp .New (),
106104 pluginobs .New (),
@@ -119,7 +117,16 @@ func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.Std
119117 pluginai .New (),
120118 pluginplatform .New (),
121119 }
122- for _ , p := range plugins {
120+ }
121+
122+ // buildEngine creates the workflow engine with all handlers registered and built from config.
123+ func buildEngine (cfg * config.WorkflowConfig , logger * slog.Logger ) (* workflow.StdEngine , * dynamic.Loader , * dynamic.ComponentRegistry , error ) {
124+ app := modular .NewStdApplication (nil , logger )
125+ engine := workflow .NewStdEngine (app , logger )
126+
127+ // Load all engine plugins — each registers its module factories, step factories,
128+ // trigger factories, and workflow handlers via engine.LoadPlugin.
129+ for _ , p := range defaultEnginePlugins () {
123130 if err := engine .LoadPlugin (p ); err != nil {
124131 log .Fatalf ("Failed to load plugin %s: %v" , p .Name (), err )
125132 }
@@ -1316,8 +1323,11 @@ func runMultiWorkflow(logger *slog.Logger) error {
13161323 logger .Info ("Database migrations applied" )
13171324
13181325 // 3. Bootstrap admin user if credentials provided
1326+ var adminUserID uuid.UUID
13191327 if * adminEmail != "" && * adminPassword != "" {
1320- if err := bootstrapAdmin (ctx , pg .Users (), * adminEmail , * adminPassword , logger ); err != nil {
1328+ var err error
1329+ adminUserID , err = bootstrapAdmin (ctx , pg .Users (), * adminEmail , * adminPassword , logger )
1330+ if err != nil {
13211331 return fmt .Errorf ("bootstrap admin: %w" , err )
13221332 }
13231333 }
@@ -1326,26 +1336,7 @@ func runMultiWorkflow(logger *slog.Logger) error {
13261336 engineBuilder := func (cfg * config.WorkflowConfig , l * slog.Logger ) (* workflow.StdEngine , modular.Application , error ) {
13271337 app := modular .NewStdApplication (nil , l )
13281338 engine := workflow .NewStdEngine (app , l )
1329- plugins := []plugin.EnginePlugin {
1330- pluginlicense .New (),
1331- pluginhttp .New (),
1332- pluginobs .New (),
1333- pluginmessaging .New (),
1334- pluginsm .New (),
1335- pluginauth .New (),
1336- pluginstorage .New (),
1337- pluginapi .New (),
1338- pluginpipeline .New (),
1339- plugincicd .New (),
1340- pluginff .New (),
1341- pluginsecrets .New (),
1342- pluginmodcompat .New (),
1343- pluginscheduler .New (),
1344- pluginintegration .New (),
1345- pluginai .New (),
1346- pluginplatform .New (),
1347- }
1348- for _ , p := range plugins {
1339+ for _ , p := range defaultEnginePlugins () {
13491340 if loadErr := engine .LoadPlugin (p ); loadErr != nil {
13501341 return nil , nil , fmt .Errorf ("load plugin %s: %w" , p .Name (), loadErr )
13511342 }
@@ -1365,7 +1356,9 @@ func runMultiWorkflow(logger *slog.Logger) error {
13651356
13661357 // 5. Seed initial workflow from -config if provided
13671358 if * configFile != "" {
1368- if err := seedWorkflow (ctx , pg , * configFile , logger ); err != nil {
1359+ if adminUserID == uuid .Nil {
1360+ logger .Warn ("Skipping workflow seed: -admin-email and -admin-password are required for seeding" )
1361+ } else if err := seedWorkflow (ctx , pg , * configFile , adminUserID , logger ); err != nil {
13691362 logger .Warn ("Failed to seed workflow from config" , "file" , * configFile , "error" , err )
13701363 }
13711364 }
@@ -1374,7 +1367,7 @@ func runMultiWorkflow(logger *slog.Logger) error {
13741367 secret := envOrFlag ("JWT_SECRET" , jwtSecret )
13751368 if secret == "" {
13761369 secret = "dev-secret-change-me"
1377- logger .Warn ("No JWT secret configured — using insecure default" )
1370+ logger .Error ("No JWT secret configured — using insecure default; set JWT_SECRET env var or -jwt-secret flag " )
13781371 }
13791372 stores := workflowapi.Stores {
13801373 Users : pg .Users (),
@@ -1397,7 +1390,7 @@ func runMultiWorkflow(logger *slog.Logger) error {
13971390 }
13981391 apiRouter := workflowapi .NewRouter (stores , apiCfg )
13991392
1400- // 7. Also set up single-config admin infrastructure
1393+ // 7. Set up admin UI and management infrastructure for workflow management
14011394 singleCfg , err := loadConfig (logger )
14021395 if err != nil {
14031396 return fmt .Errorf ("load config: %w" , err )
@@ -1435,22 +1428,34 @@ func runMultiWorkflow(logger *slog.Logger) error {
14351428 }
14361429 }
14371430
1438- // Start API server
1431+ // Start API server; propagate failures back so we can initiate shutdown.
1432+ srvErrCh := make (chan error , 1 )
14391433 go func () {
14401434 logger .Info ("Multi-workflow API listening" , "addr" , * addr )
14411435 if err := srv .ListenAndServe (); err != nil && err != http .ErrServerClosed {
14421436 logger .Error ("API server error" , "error" , err )
1437+ srvErrCh <- err
14431438 }
14441439 }()
14451440
1446- fmt .Printf ("Multi-workflow API on http://localhost%s/api/v1/\n " , * addr )
1441+ // Build display address: if the host part is empty or 0.0.0.0/::/[::], use "localhost".
1442+ displayAddr := * addr
1443+ if host , port , splitErr := net .SplitHostPort (* addr ); splitErr == nil &&
1444+ (host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" ) {
1445+ displayAddr = ":" + port
1446+ }
1447+ fmt .Printf ("Multi-workflow API on http://localhost%s/api/v1/\n " , displayAddr )
14471448 fmt .Println ("Admin UI on http://localhost:8081" )
14481449
1449- // Wait for termination signal
1450+ // Wait for termination signal or server failure.
14501451 sigCh := make (chan os.Signal , 1 )
14511452 signal .Notify (sigCh , syscall .SIGINT , syscall .SIGTERM )
1452- <- sigCh
1453- fmt .Println ("Shutting down..." )
1453+ select {
1454+ case <- sigCh :
1455+ fmt .Println ("Shutting down..." )
1456+ case <- srvErrCh :
1457+ logger .Error ("API server failed; initiating shutdown" )
1458+ }
14541459 cancel ()
14551460
14561461 // Graceful shutdown
@@ -1471,16 +1476,20 @@ func runMultiWorkflow(logger *slog.Logger) error {
14711476}
14721477
14731478// bootstrapAdmin creates an admin user if one doesn't already exist.
1474- func bootstrapAdmin (ctx context.Context , users evstore.UserStore , email , password string , logger * slog.Logger ) error {
1479+ // It returns the admin user's UUID so callers can associate resources with them.
1480+ func bootstrapAdmin (ctx context.Context , users evstore.UserStore , email , password string , logger * slog.Logger ) (uuid.UUID , error ) {
14751481 existing , err := users .GetByEmail (ctx , email )
1482+ if err != nil && ! errors .Is (err , evstore .ErrNotFound ) {
1483+ return uuid .Nil , fmt .Errorf ("check existing admin: %w" , err )
1484+ }
14761485 if err == nil && existing != nil {
14771486 logger .Info ("Admin user already exists" , "email" , email )
1478- return nil
1487+ return existing . ID , nil
14791488 }
14801489
14811490 hash , err := bcrypt .GenerateFromPassword ([]byte (password ), bcrypt .DefaultCost )
14821491 if err != nil {
1483- return fmt .Errorf ("hash password: %w" , err )
1492+ return uuid . Nil , fmt .Errorf ("hash password: %w" , err )
14841493 }
14851494 now := time .Now ()
14861495 admin := & evstore.User {
@@ -1493,14 +1502,73 @@ func bootstrapAdmin(ctx context.Context, users evstore.UserStore, email, passwor
14931502 UpdatedAt : now ,
14941503 }
14951504 if err := users .Create (ctx , admin ); err != nil {
1496- return fmt .Errorf ("create admin user: %w" , err )
1505+ return uuid . Nil , fmt .Errorf ("create admin user: %w" , err )
14971506 }
14981507 logger .Info ("Bootstrapped admin user" , "email" , email )
1499- return nil
1508+ return admin .ID , nil
1509+ }
1510+
1511+ // slugify converts a string into a URL-friendly slug: lowercase, ASCII alphanumeric
1512+ // characters and hyphens only, with consecutive hyphens collapsed and leading/trailing
1513+ // hyphens trimmed.
1514+ func slugify (s string ) string {
1515+ var b strings.Builder
1516+ for _ , r := range strings .ToLower (s ) {
1517+ if (r >= 'a' && r <= 'z' ) || (r >= '0' && r <= '9' ) || r == '-' {
1518+ b .WriteRune (r )
1519+ } else {
1520+ b .WriteRune ('-' )
1521+ }
1522+ }
1523+ result := b .String ()
1524+ for strings .Contains (result , "--" ) {
1525+ result = strings .ReplaceAll (result , "--" , "-" )
1526+ }
1527+ return strings .Trim (result , "-" )
1528+ }
1529+
1530+ // ensureSystemProject finds or creates the "system" company and "default" project
1531+ // used to associate seed workflows with the required database entities.
1532+ func ensureSystemProject (ctx context.Context , pg * evstore.PGStore , ownerID uuid.UUID ) (* evstore.Project , error ) {
1533+ const companySlug = "system"
1534+ const projectSlug = "default"
1535+
1536+ company , err := pg .Companies ().GetBySlug (ctx , companySlug )
1537+ if errors .Is (err , evstore .ErrNotFound ) {
1538+ company = & evstore.Company {Name : "System" , Slug : companySlug , OwnerID : ownerID }
1539+ if createErr := pg .Companies ().Create (ctx , company ); createErr != nil {
1540+ if ! errors .Is (createErr , evstore .ErrDuplicate ) {
1541+ return nil , fmt .Errorf ("create system company: %w" , createErr )
1542+ }
1543+ // Another process created it concurrently; fetch it.
1544+ if company , err = pg .Companies ().GetBySlug (ctx , companySlug ); err != nil {
1545+ return nil , fmt .Errorf ("get system company: %w" , err )
1546+ }
1547+ }
1548+ } else if err != nil {
1549+ return nil , fmt .Errorf ("get system company: %w" , err )
1550+ }
1551+
1552+ project , err := pg .Projects ().GetBySlug (ctx , company .ID , projectSlug )
1553+ if errors .Is (err , evstore .ErrNotFound ) {
1554+ project = & evstore.Project {CompanyID : company .ID , Name : "Default" , Slug : projectSlug }
1555+ if createErr := pg .Projects ().Create (ctx , project ); createErr != nil {
1556+ if ! errors .Is (createErr , evstore .ErrDuplicate ) {
1557+ return nil , fmt .Errorf ("create default project: %w" , createErr )
1558+ }
1559+ if project , err = pg .Projects ().GetBySlug (ctx , company .ID , projectSlug ); err != nil {
1560+ return nil , fmt .Errorf ("get default project: %w" , err )
1561+ }
1562+ }
1563+ } else if err != nil {
1564+ return nil , fmt .Errorf ("get default project: %w" , err )
1565+ }
1566+
1567+ return project , nil
15001568}
15011569
15021570// seedWorkflow imports a YAML config as the initial workflow into the database.
1503- func seedWorkflow (ctx context.Context , pg * evstore.PGStore , configPath string , logger * slog.Logger ) error {
1571+ func seedWorkflow (ctx context.Context , pg * evstore.PGStore , configPath string , adminUserID uuid. UUID , logger * slog.Logger ) error {
15041572 // Validate the config is loadable
15051573 if _ , err := config .LoadFromFile (configPath ); err != nil {
15061574 return fmt .Errorf ("load config file: %w" , err )
@@ -1513,26 +1581,37 @@ func seedWorkflow(ctx context.Context, pg *evstore.PGStore, configPath string, l
15131581
15141582 name := filepath .Base (configPath )
15151583 name = strings .TrimSuffix (name , filepath .Ext (name ))
1516- slug := strings . ToLower ( strings . ReplaceAll ( name , " " , "-" ) )
1584+ slug := slugify ( name )
15171585
1518- // Check if a workflow with this slug already exists in any project
1519- existing , _ := pg .Workflows ().List (ctx , evstore.WorkflowFilter {})
1586+ // Check if a workflow with this slug already exists in any project.
1587+ existing , err := pg .Workflows ().List (ctx , evstore.WorkflowFilter {})
1588+ if err != nil {
1589+ return fmt .Errorf ("list existing workflows: %w" , err )
1590+ }
15201591 for _ , wf := range existing {
15211592 if wf .Slug == slug {
15221593 logger .Info ("Seed workflow already exists" , "slug" , slug )
15231594 return nil
15241595 }
15251596 }
15261597
1598+ project , err := ensureSystemProject (ctx , pg , adminUserID )
1599+ if err != nil {
1600+ return fmt .Errorf ("ensure system project: %w" , err )
1601+ }
1602+
15271603 now := time .Now ()
15281604 record := & evstore.WorkflowRecord {
15291605 ID : uuid .New (),
1606+ ProjectID : project .ID ,
15301607 Name : name ,
15311608 Slug : slug ,
15321609 Description : "Seeded from " + configPath ,
15331610 ConfigYAML : string (yamlBytes ),
15341611 Version : 1 ,
15351612 Status : evstore .WorkflowStatusDraft ,
1613+ CreatedBy : adminUserID ,
1614+ UpdatedBy : adminUserID ,
15361615 CreatedAt : now ,
15371616 UpdatedAt : now ,
15381617 }
0 commit comments