Skip to content

Commit 1c75913

Browse files
Copilotintel352
andauthored
fix: address multi-workflow mode review feedback (#138)
* Initial plan * fix: address all review comments for multi-workflow mode - Extract defaultEnginePlugins() to eliminate plugin list duplication - Fix bootstrapAdmin to check ErrNotFound explicitly; return admin UUID - Fix seedWorkflow: handle List error, improve slug via slugify(), populate required WorkflowRecord fields (ProjectID, CreatedBy, UpdatedBy) using new ensureSystemProject() helper - Change JWT secret logger.Warn -> logger.Error - Use srvErrCh to propagate API server failures to initiate graceful shutdown - Fix URL display using net.SplitHostPort for non-localhost bind addresses - Fix misleading comment about admin infrastructure setup Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent f14b9fd commit 1c75913

1 file changed

Lines changed: 126 additions & 47 deletions

File tree

cmd/server/main.go

Lines changed: 126 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)