From 434f14ae3ac33fade8f0eba39982f8bf7c1a7cbf Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:15:52 +0100 Subject: [PATCH 1/9] feat(server): inline step ref + workflow validation --- server/internal/models/workflow.go | 5 +++- server/internal/services/validate.go | 19 +++++++++++++++ server/internal/services/validate_test.go | 29 +++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 server/internal/services/validate.go create mode 100644 server/internal/services/validate_test.go diff --git a/server/internal/models/workflow.go b/server/internal/models/workflow.go index 3c03019..9880c56 100644 --- a/server/internal/models/workflow.go +++ b/server/internal/models/workflow.go @@ -22,12 +22,15 @@ type WorkflowStep struct { DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"` DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"` SecretRefs []string `bson:"secret_refs" json:"secret_refs"` + Source string `bson:"source" json:"source"` // "user" | "default" + Slug string `bson:"slug,omitempty" json:"slug,omitempty"` CreatedAt time.Time `bson:"created_at" json:"created_at"` UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } type WorkflowStepRef struct { - StepID string `bson:"step_id" json:"step_id"` + StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"` + Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"` Order int `bson:"order" json:"order"` OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry" MaxRetries int `bson:"max_retries" json:"max_retries"` diff --git a/server/internal/services/validate.go b/server/internal/services/validate.go new file mode 100644 index 0000000..c2bf6c5 --- /dev/null +++ b/server/internal/services/validate.go @@ -0,0 +1,19 @@ +package services + +import ( + "fmt" + + "github.com/mrhid6/vantage/server/internal/models" +) + +// ValidateWorkflow checks each step ref sets exactly one of step_id / inline. +func ValidateWorkflow(w models.Workflow) error { + for i, ref := range w.Steps { + hasLib := ref.StepID != "" + hasInline := ref.Inline != nil + if hasLib == hasInline { + return fmt.Errorf("step %d: exactly one of step_id or inline must be set", i) + } + } + return nil +} diff --git a/server/internal/services/validate_test.go b/server/internal/services/validate_test.go new file mode 100644 index 0000000..8121e16 --- /dev/null +++ b/server/internal/services/validate_test.go @@ -0,0 +1,29 @@ +package services + +import ( + "testing" + + "github.com/mrhid6/vantage/server/internal/models" +) + +func TestValidateWorkflow(t *testing.T) { + inline := &models.WorkflowStep{Name: "x", Interpreter: "bash", Script: "echo hi"} + cases := []struct { + name string + ref models.WorkflowStepRef + wantErr bool + }{ + {"library only", models.WorkflowStepRef{StepID: "abc"}, false}, + {"inline only", models.WorkflowStepRef{Inline: inline}, false}, + {"both set", models.WorkflowStepRef{StepID: "abc", Inline: inline}, true}, + {"neither set", models.WorkflowStepRef{}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateWorkflow(models.Workflow{Steps: []models.WorkflowStepRef{tc.ref}}) + if (err != nil) != tc.wantErr { + t.Fatalf("got err=%v want wantErr=%v", err, tc.wantErr) + } + }) + } +} From 813f9e6fef905899b20e15ea3c4562bf4e585578 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:17:50 +0100 Subject: [PATCH 2/9] feat(server): derive declared_outputs from script + slugify --- server/internal/services/stepscan.go | 44 ++++++++++++++++++++++ server/internal/services/stepscan_test.go | 45 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 server/internal/services/stepscan.go create mode 100644 server/internal/services/stepscan_test.go diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go new file mode 100644 index 0000000..df0d281 --- /dev/null +++ b/server/internal/services/stepscan.go @@ -0,0 +1,44 @@ +package services + +import ( + "regexp" + "strings" +) + +// keyAssign matches an env-var assignment target: KEY= (captures KEY). +var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`) + +// DeriveOutputs scans a step script and returns the output keys it writes to +// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are +// considered. Deduplicated, first-seen order preserved. +func DeriveOutputs(script string) []string { + out := []string{} + seen := map[string]bool{} + for _, line := range strings.Split(script, "\n") { + if !strings.Contains(line, "WORKFLOW_ENV") { + continue + } + for _, m := range keyAssign.FindAllStringSubmatch(line, -1) { + key := m[1] + // Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments). + if key == "WORKFLOW_ENV" || key == "env" { + continue + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, key) + } + } + return out +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// Slugify converts a step name into a stable kebab-case slug. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} diff --git a/server/internal/services/stepscan_test.go b/server/internal/services/stepscan_test.go new file mode 100644 index 0000000..c02c032 --- /dev/null +++ b/server/internal/services/stepscan_test.go @@ -0,0 +1,45 @@ +package services + +import ( + "reflect" + "testing" +) + +func TestDeriveOutputs(t *testing.T) { + script := `#!/bin/bash +echo "test=123" >> $WORKFLOW_ENV +echo "other=hi" >> "$WORKFLOW_ENV" +printf 'third=1\n' >> $WORKFLOW_ENV +echo "test=456" >> $WORKFLOW_ENV +echo "ignored=nope" +NORMAL=assignment +` + got := DeriveOutputs(script) + want := []string{"test", "other", "third"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestDeriveOutputsPowershell(t *testing.T) { + script := `"result=ok" >> $env:WORKFLOW_ENV +Add-Content $env:WORKFLOW_ENV "count=5"` + got := DeriveOutputs(script) + want := []string{"result", "count"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestDeriveOutputsNone(t *testing.T) { + got := DeriveOutputs("echo hello\nNOPE=1") + if len(got) != 0 { + t.Fatalf("got %v want empty", got) + } +} + +func TestSlugify(t *testing.T) { + if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" { + t.Fatalf("got %q", got) + } +} From 7342c46d99a6b5993456f050254330a195112bb0 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:20:12 +0100 Subject: [PATCH 3/9] feat(server): auto-derive outputs on save + resolve inline steps --- server/internal/services/resolve_test.go | 37 ++++++++++++++++++++ server/internal/services/workflow_runner.go | 33 ++++++++++++++++++ server/internal/services/workflows.go | 38 +++++++++++++++++++-- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 server/internal/services/resolve_test.go diff --git a/server/internal/services/resolve_test.go b/server/internal/services/resolve_test.go new file mode 100644 index 0000000..3131bfc --- /dev/null +++ b/server/internal/services/resolve_test.go @@ -0,0 +1,37 @@ +package services + +import ( + "testing" + + "github.com/mrhid6/vantage/server/internal/models" +) + +func TestResolveInlineStep(t *testing.T) { + ref := models.WorkflowStepRef{ + Order: 2, + OnFailure: "", + Inline: &models.WorkflowStep{ + Name: "adhoc", + Interpreter: "bash", + Script: "echo hi", + SecretRefs: []string{"TOKEN"}, + DeclaredInputs: []models.InputParam{ + {Name: "REGION", Default: "eu"}, + }, + }, + Inputs: map[string]string{"REGION": "us"}, + } + rs := resolveInlineStep(ref) + if rs.Name != "adhoc" || rs.Script != "echo hi" || rs.Order != 2 { + t.Fatalf("bad resolve: %+v", rs) + } + if rs.OnFailure != "stop" { + t.Fatalf("want default on_failure=stop, got %q", rs.OnFailure) + } + if rs.Inputs["REGION"] != "us" { + t.Fatalf("want input override us, got %q", rs.Inputs["REGION"]) + } + if len(rs.SecretRefs) != 1 || rs.SecretRefs[0] != "TOKEN" { + t.Fatalf("bad secret refs: %v", rs.SecretRefs) + } +} diff --git a/server/internal/services/workflow_runner.go b/server/internal/services/workflow_runner.go index 1a62160..72d5d73 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -83,6 +83,10 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { defer cancel() out := make([]models.ResolvedStep, 0, len(wf.Steps)) for _, ref := range wf.Steps { + if ref.Inline != nil { + out = append(out, resolveInlineStep(ref)) + continue + } lib, err := getStep(ctx, ref.StepID) if err != nil { return nil, err @@ -123,6 +127,35 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { return out, nil } +// resolveInlineStep freezes an ad-hoc (inline) step ref into a ResolvedStep. +func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep { + in := ref.Inline + inputs := map[string]string{} + for _, p := range in.DeclaredInputs { + if ref.Inputs != nil { + if v, ok := ref.Inputs[p.Name]; ok { + inputs[p.Name] = v + continue + } + } + inputs[p.Name] = p.Default + } + onFailure := ref.OnFailure + if onFailure == "" { + onFailure = "stop" + } + return models.ResolvedStep{ + Order: ref.Order, + Name: in.Name, + Interpreter: in.Interpreter, + Script: in.Script, + SecretRefs: in.SecretRefs, + OnFailure: onFailure, + MaxRetries: ref.MaxRetries, + Inputs: inputs, + } +} + // executeRun fans out one goroutine per server run and waits for all to finish. func executeRun(runID string) { run, err := GetRun(runID) diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 36ea584..eb0d519 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -60,8 +60,9 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { s.StepID = uuid.New().String() s.CreatedAt = time.Now() s.UpdatedAt = s.CreatedAt - if s.DeclaredOutputs == nil { - s.DeclaredOutputs = []string{} + s.DeclaredOutputs = DeriveOutputs(s.Script) + if s.Source == "" { + s.Source = "user" } if s.SecretRefs == nil { s.SecretRefs = []string{} @@ -83,7 +84,7 @@ func UpdateStep(stepID string, s models.WorkflowStep) error { "description": s.Description, "interpreter": s.Interpreter, "script": s.Script, - "declared_outputs": s.DeclaredOutputs, + "declared_outputs": DeriveOutputs(s.Script), "declared_inputs": s.DeclaredInputs, "secret_refs": s.SecretRefs, "updated_at": time.Now(), @@ -178,6 +179,10 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { if w.Steps == nil { w.Steps = []models.WorkflowStepRef{} } + if err := ValidateWorkflow(w); err != nil { + return nil, err + } + normalizeInlineSteps(&w) if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil { return nil, err } @@ -187,6 +192,10 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { func UpdateWorkflow(id string, w models.Workflow) error { ctx, cancel := wfCtx() defer cancel() + if err := ValidateWorkflow(w); err != nil { + return err + } + normalizeInlineSteps(&w) _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{ "name": w.Name, "target_server_ids": w.TargetServerIDs, @@ -196,6 +205,29 @@ func UpdateWorkflow(id string, w models.Workflow) error { return err } +// normalizeInlineSteps derives outputs for inline steps and strips fields that +// only belong to library steps. +func normalizeInlineSteps(w *models.Workflow) { + for i := range w.Steps { + in := w.Steps[i].Inline + if in == nil { + continue + } + in.DeclaredOutputs = DeriveOutputs(in.Script) + in.StepID = "" + in.Slug = "" + in.Source = "" + in.CreatedAt = time.Time{} + in.UpdatedAt = time.Time{} + if in.SecretRefs == nil { + in.SecretRefs = []string{} + } + if in.DeclaredInputs == nil { + in.DeclaredInputs = []models.InputParam{} + } + } +} + func DeleteWorkflow(id string) error { ctx, cancel := wfCtx() defer cancel() From baa7bb239d243048c5bd28370772e170831e0783 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:22:35 +0100 Subject: [PATCH 4/9] feat(server): step import/export/parse endpoints --- server/internal/api/workflows.go | 45 +++++++++++++ server/internal/services/stepio.go | 86 +++++++++++++++++++++++++ server/internal/services/stepio_test.go | 60 +++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 server/internal/services/stepio.go create mode 100644 server/internal/services/stepio_test.go diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index de8780c..1e061e0 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "io" "net/http" "os" "regexp" @@ -19,6 +20,9 @@ func registerWorkflowRoutes(g *gin.RouterGroup) { g.POST("/steps", createStep) g.PUT("/steps/:id", updateStep) g.DELETE("/steps/:id", deleteStep) + g.GET("/steps/:id/export", exportStep) + g.POST("/steps/import", importStep) + g.POST("/steps/parse", parseStep) g.GET("/workflows", listWorkflows) g.POST("/workflows", createWorkflow) @@ -187,6 +191,47 @@ func deleteStep(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deleted": true}) } +func exportStep(c *gin.Context) { + b, err := services.ExportStep(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id"))) + c.Data(http.StatusOK, "application/json", b) +} + +func importStep(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + out, err := services.ImportStepToLibrary(body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) + c.JSON(http.StatusCreated, out) +} + +// parseStep validates a step doc and returns the normalized step WITHOUT +// persisting — used by the editor to insert an imported ad-hoc (inline) step. +func parseStep(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s, err := services.ParseStepDoc(body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, s) +} + func listWorkflows(c *gin.Context) { wfs, err := services.ListWorkflows() if err != nil { diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go new file mode 100644 index 0000000..ad6f12e --- /dev/null +++ b/server/internal/services/stepio.go @@ -0,0 +1,86 @@ +package services + +import ( + "encoding/json" + "fmt" + + "github.com/mrhid6/vantage/server/internal/models" +) + +const StepDocKind = "vantage.step/v1" + +// StepDoc is the portable, id-free representation of a step. +type StepDoc struct { + Kind string `json:"kind"` + Name string `json:"name"` + Description string `json:"description"` + Interpreter string `json:"interpreter"` + Script string `json:"script"` + DeclaredOutputs []string `json:"declared_outputs"` + DeclaredInputs []models.InputParam `json:"declared_inputs"` + SecretRefs []string `json:"secret_refs"` +} + +// ExportStepDoc builds a portable doc from a library step (ids/source stripped). +func ExportStepDoc(s models.WorkflowStep) StepDoc { + return StepDoc{ + Kind: StepDocKind, + Name: s.Name, + Description: s.Description, + Interpreter: s.Interpreter, + Script: s.Script, + DeclaredOutputs: s.DeclaredOutputs, + DeclaredInputs: s.DeclaredInputs, + SecretRefs: s.SecretRefs, + } +} + +// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with +// declared_outputs recomputed from the script. +func ParseStepDoc(b []byte) (models.WorkflowStep, error) { + var d StepDoc + if err := json.Unmarshal(b, &d); err != nil { + return models.WorkflowStep{}, fmt.Errorf("invalid step JSON: %w", err) + } + if d.Kind != StepDocKind { + return models.WorkflowStep{}, fmt.Errorf("unsupported kind %q (want %q)", d.Kind, StepDocKind) + } + if d.Name == "" || d.Interpreter == "" { + return models.WorkflowStep{}, fmt.Errorf("step name and interpreter are required") + } + if d.SecretRefs == nil { + d.SecretRefs = []string{} + } + if d.DeclaredInputs == nil { + d.DeclaredInputs = []models.InputParam{} + } + return models.WorkflowStep{ + Name: d.Name, + Description: d.Description, + Interpreter: d.Interpreter, + Script: d.Script, + DeclaredOutputs: DeriveOutputs(d.Script), + DeclaredInputs: d.DeclaredInputs, + SecretRefs: d.SecretRefs, + }, nil +} + +// ImportStepToLibrary parses a doc and persists it as a new user library step. +func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) { + s, err := ParseStepDoc(b) + if err != nil { + return nil, err + } + return CreateStep(s) +} + +// ExportStep loads a library step and marshals it to a portable doc. +func ExportStep(stepID string) ([]byte, error) { + ctx, cancel := wfCtx() + defer cancel() + s, err := getStep(ctx, stepID) + if err != nil { + return nil, err + } + return json.MarshalIndent(ExportStepDoc(*s), "", " ") +} diff --git a/server/internal/services/stepio_test.go b/server/internal/services/stepio_test.go new file mode 100644 index 0000000..d5499e7 --- /dev/null +++ b/server/internal/services/stepio_test.go @@ -0,0 +1,60 @@ +package services + +import ( + "encoding/json" + "testing" + + "github.com/mrhid6/vantage/server/internal/models" +) + +func mkStep() models.WorkflowStep { + return models.WorkflowStep{ + StepID: "should-not-export", Source: "default", Name: "Restart", + Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV", + SecretRefs: []string{"TOK"}, + } +} + +func TestParseStepDocValid(t *testing.T) { + raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash", + "script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"], + "declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}` + s, err := ParseStepDoc([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if s.Name != "Restart" || s.Interpreter != "bash" { + t.Fatalf("bad parse: %+v", s) + } + // declared_outputs recomputed from script, ignoring the file's ["stale"]. + if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" { + t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs) + } + if s.StepID != "" || s.Source != "" { + t.Fatalf("parse must not set id/source") + } +} + +func TestParseStepDocBadKind(t *testing.T) { + if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil { + t.Fatal("want error for bad kind") + } +} + +func TestParseStepDocBadJSON(t *testing.T) { + if _, err := ParseStepDoc([]byte(`{`)); err == nil { + t.Fatal("want error for bad json") + } +} + +func TestExportStepDocRoundTrip(t *testing.T) { + doc := ExportStepDoc(mkStep()) + b, _ := json.Marshal(doc) + s, err := ParseStepDoc(b) + if err != nil { + t.Fatal(err) + } + if s.Name != "Restart" || s.Interpreter != "bash" { + t.Fatalf("round trip lost data: %+v", s) + } +} From 15c9da1b01fa095163d8a546b0c6ba73ca90ab5f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:25:41 +0100 Subject: [PATCH 5/9] feat(server): default steps seed-on-boot + admin re-sync --- server/cmd/main.go | 6 ++ server/internal/api/workflows.go | 11 +++ server/internal/services/defaults.go | 94 +++++++++++++++++++++++ server/internal/services/defaults_test.go | 38 +++++++++ server/internal/services/workflows.go | 7 ++ 5 files changed, 156 insertions(+) create mode 100644 server/internal/services/defaults.go create mode 100644 server/internal/services/defaults_test.go diff --git a/server/cmd/main.go b/server/cmd/main.go index f5b12c2..4aa877f 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -31,6 +31,12 @@ func main() { log.Printf("warning: failed to ensure workflow indexes: %v", err) } + if created, updated, err := services.SeedDefaultSteps(); err != nil { + log.Printf("warning: failed to seed default steps: %v", err) + } else { + log.Printf("default steps seeded: %d created, %d updated", created, updated) + } + services.StartLogSweeper() redisAddr := getEnv("REDIS_ADDR", "localhost:6379") diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 1e061e0..76f183e 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -22,6 +22,7 @@ func registerWorkflowRoutes(g *gin.RouterGroup) { g.DELETE("/steps/:id", deleteStep) g.GET("/steps/:id/export", exportStep) g.POST("/steps/import", importStep) + g.POST("/steps/seed-defaults", seedDefaults) g.POST("/steps/parse", parseStep) g.GET("/workflows", listWorkflows) @@ -201,6 +202,16 @@ func exportStep(c *gin.Context) { c.Data(http.StatusOK, "application/json", b) } +func seedDefaults(c *gin.Context) { + created, updated, err := services.SeedDefaultSteps() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) + c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated}) +} + func importStep(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { diff --git a/server/internal/services/defaults.go b/server/internal/services/defaults.go new file mode 100644 index 0000000..068c335 --- /dev/null +++ b/server/internal/services/defaults.go @@ -0,0 +1,94 @@ +package services + +import ( + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// DefaultStepsDir returns the directory holding default step JSON files. +func DefaultStepsDir() string { + dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR") + if dir == "" { + dir = filepath.Join("data", "default-steps") + } + _ = os.MkdirAll(dir, 0700) + return dir +} + +// readDefaultStepFiles parses every *.json in the defaults dir into +// source=default library steps (with slug set). Non-json and invalid files are +// skipped silently; a slug is derived from the step name. +func readDefaultStepFiles() ([]models.WorkflowStep, error) { + matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json")) + if err != nil { + return nil, err + } + out := []models.WorkflowStep{} + for _, path := range matches { + b, err := os.ReadFile(path) + if err != nil { + continue + } + s, err := ParseStepDoc(b) + if err != nil { + continue + } + s.Source = "default" + s.Slug = Slugify(s.Name) + if s.Slug == "" { + continue + } + out = append(out, s) + } + return out, nil +} + +// SeedDefaultSteps upserts default steps from disk keyed on {slug, source}. +// Re-sync overwrites default-step content; user steps are never touched. +func SeedDefaultSteps() (created, updated int, err error) { + steps, err := readDefaultStepFiles() + if err != nil { + return 0, 0, err + } + ctx, cancel := wfCtx() + defer cancel() + col := db.Col("workflow_steps") + for _, s := range steps { + filter := bson.M{"slug": s.Slug, "source": "default"} + set := bson.M{ + "name": s.Name, + "description": s.Description, + "interpreter": s.Interpreter, + "script": s.Script, + "declared_outputs": s.DeclaredOutputs, + "declared_inputs": s.DeclaredInputs, + "secret_refs": s.SecretRefs, + "updated_at": time.Now(), + } + res, uerr := col.UpdateOne(ctx, filter, bson.M{ + "$set": set, + "$setOnInsert": bson.M{ + "step_id": uuid.New().String(), + "slug": s.Slug, + "source": "default", + "created_at": time.Now(), + }, + }, options.UpdateOne().SetUpsert(true)) + if uerr != nil { + return created, updated, uerr + } + if res.UpsertedCount > 0 { + created++ + } else if res.ModifiedCount > 0 { + updated++ + } + } + return created, updated, nil +} diff --git a/server/internal/services/defaults_test.go b/server/internal/services/defaults_test.go new file mode 100644 index 0000000..8f6a463 --- /dev/null +++ b/server/internal/services/defaults_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultStepsDirEnv(t *testing.T) { + dir := filepath.Join(t.TempDir(), "ds") + t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir) + got := DefaultStepsDir() + if got != dir { + t.Fatalf("got %q want %q", got, dir) + } + if _, err := os.Stat(dir); err != nil { + t.Fatalf("dir not created: %v", err) + } +} + +func TestReadDefaultStepFiles(t *testing.T) { + dir := t.TempDir() + t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir) + good := `{"kind":"vantage.step/v1","name":"Ping Host","interpreter":"bash","script":"ping -c1 x=1 >> $WORKFLOW_ENV"}` + os.WriteFile(filepath.Join(dir, "ping.json"), []byte(good), 0600) + os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0600) + + steps, err := readDefaultStepFiles() + if err != nil { + t.Fatal(err) + } + if len(steps) != 1 { + t.Fatalf("want 1 step, got %d", len(steps)) + } + if steps[0].Slug != "ping-host" || steps[0].Source != "default" { + t.Fatalf("bad seed step: %+v", steps[0]) + } +} diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index eb0d519..124d1e1 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -25,6 +25,13 @@ func EnsureWorkflowIndexes() error { }); err != nil { return err } + if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true). + SetPartialFilterExpression(bson.M{"source": "default"}), + }); err != nil { + return err + } if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "workflow_id", Value: 1}}, Options: options.Index().SetUnique(true), }); err != nil { From b543cd1b3d882b40a5c7adda8f47bf013efdc345 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:28:41 +0100 Subject: [PATCH 6/9] feat(web): api client for step import/export/inline/defaults --- web/app/workflows/[id]/page.tsx | 2 +- web/lib/api.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 5f9e7e7..cc9656b 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -84,7 +84,7 @@ export default function WorkflowBuilder() { return
Loading…
; } - const libById = (sid: string) => library?.find((l) => l.step_id === sid); + const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined); const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order); const selectedRef = selected !== null ? sortedSteps[selected] : null; diff --git a/web/lib/api.ts b/web/lib/api.ts index 157f87b..bb726d7 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -148,10 +148,13 @@ export interface WorkflowStep { declared_outputs: string[]; declared_inputs: InputParam[]; secret_refs: string[]; + source?: "user" | "default"; + slug?: string; } export interface WorkflowStepRef { - step_id: string; + step_id?: string; + inline?: WorkflowStep; order: number; on_failure: "stop" | "continue" | "retry"; max_retries: number; @@ -419,6 +422,30 @@ export const api = { return request(`/steps/${stepId}`, { method: "DELETE" }); }, + exportStepUrl(stepId: string): string { + return `/api/steps/${stepId}/export`; + }, + + importStep(doc: unknown): Promise { + return request("/steps/import", { + method: "POST", + body: JSON.stringify(doc), + }); + }, + + parseStep(doc: unknown): Promise { + return request("/steps/parse", { + method: "POST", + body: JSON.stringify(doc), + }); + }, + + seedDefaults(): Promise<{ created: number; updated: number }> { + return request<{ created: number; updated: number }>("/steps/seed-defaults", { + method: "POST", + }); + }, + // Workflows listWorkflows(): Promise { return request("/workflows"); From 90ce7af769be0b89e175526c816980841a3270b6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:31:28 +0100 Subject: [PATCH 7/9] feat(web): step export/import, sync defaults, auto outputs, default badge --- web/app/workflows/[id]/page.tsx | 81 +++++++++++++++++++++- web/components/workflows/EditStepModal.tsx | 18 +++-- 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index cc9656b..bd6e2b7 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -27,6 +27,14 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { ); } +function DefaultBadge() { + return ( + + default + + ); +} + export default function WorkflowBuilder() { const params = useParams<{ id: string }>(); const id = params.id; @@ -39,6 +47,10 @@ export default function WorkflowBuilder() { const [saving, setSaving] = useState(false); const [running, setRunning] = useState(false); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [importing, setImporting] = useState(false); + const [syncing, setSyncing] = useState(false); + const fileInputRef = useRef(null); const [groupKeys, setGroupKeys] = useState>({}); const [editWorkflowOpen, setEditWorkflowOpen] = useState(false); const [editingStep, setEditingStep] = useState(null); @@ -178,6 +190,38 @@ export default function WorkflowBuilder() { updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } }); }; + const onImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setImporting(true); + setError(null); + try { + const doc = JSON.parse(await file.text()); + await api.importStep(doc); + await queryClient.invalidateQueries({ queryKey: ["steps"] }); + setNotice("Step imported."); + } catch (err) { + setError((err as Error).message); + } finally { + setImporting(false); + e.target.value = ""; + } + }; + + const onSyncDefaults = async () => { + setSyncing(true); + setError(null); + try { + const { created, updated } = await api.seedDefaults(); + await queryClient.invalidateQueries({ queryKey: ["steps"] }); + setNotice(`${created} created, ${updated} updated`); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase())); const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash"); const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell"); @@ -236,6 +280,9 @@ export default function WorkflowBuilder() { {error && (
{error}
)} + {notice && ( +
{notice}
+ )}
{/* LEFT: library */} @@ -253,6 +300,26 @@ export default function WorkflowBuilder() { +
+
+ + + +
+ {step.source === "default" && } {step.name} + e.stopPropagation()} + className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block" + title="Export step" + > + ⬇ +
-
- {outputs.map((o) => ( +
+ {(step?.declared_outputs ?? []).length === 0 && ( +

No declared outputs.

+ )} + {(step?.declared_outputs ?? []).map((o) => ( - {o} + {o} ))}
-
- setNewOut(e.target.value)} /> - -
+

Outputs are detected automatically from lines writing to $WORKFLOW_ENV.

From fbda26a1886631c014774461e0d8f7ab32f689f1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:34:51 +0100 Subject: [PATCH 8/9] feat(web): ad-hoc inline steps + import-to-inline in workflow editor --- web/app/workflows/[id]/page.tsx | 161 +++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 21 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index bd6e2b7..dc0992e 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -35,6 +35,14 @@ function DefaultBadge() { ); } +function AdhocBadge() { + return ( + + ad-hoc + + ); +} + export default function WorkflowBuilder() { const params = useParams<{ id: string }>(); const id = params.id; @@ -49,8 +57,10 @@ export default function WorkflowBuilder() { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [importing, setImporting] = useState(false); + const [importingInline, setImportingInline] = useState(false); const [syncing, setSyncing] = useState(false); const fileInputRef = useRef(null); + const inlineFileInputRef = useRef(null); const [groupKeys, setGroupKeys] = useState>({}); const [editWorkflowOpen, setEditWorkflowOpen] = useState(false); const [editingStep, setEditingStep] = useState(null); @@ -140,6 +150,45 @@ export default function WorkflowBuilder() { setWf({ ...wf, steps: resequence(next) }); }; + const appendRef = (ref: WorkflowStepRef) => { + setWf({ ...wf, steps: resequence([...sortedSteps, ref]) }); + }; + + const addAdhocStep = () => { + appendRef({ + inline: { + step_id: "", + name: "New ad-hoc step", + description: "", + interpreter: "bash", + script: "", + declared_outputs: [], + declared_inputs: [], + secret_refs: [], + }, + order: wf.steps.length, + on_failure: "stop", + max_retries: 0, + }); + }; + + const onImportInline = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setImportingInline(true); + setError(null); + try { + const doc = JSON.parse(await file.text()); + const step = await api.parseStep(doc); + appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 }); + } catch (err) { + setError((err as Error).message); + } finally { + setImportingInline(false); + e.target.value = ""; + } + }; + const moveStep = (from: number, pos: number) => { const next = [...sortedSteps]; const [item] = next.splice(from, 1); @@ -177,6 +226,12 @@ export default function WorkflowBuilder() { steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)), }); + const updateInline = (idx: number, patch: Partial) => + setWf({ + ...wf, + steps: wf.steps.map((r, i) => (i === idx && r.inline ? { ...r, inline: { ...r.inline, ...patch } } : r)), + }); + const removeStep = (idx: number) => { const remaining = resequence(wf.steps.filter((_, i) => i !== idx)); setWf({ ...wf, steps: remaining }); @@ -320,6 +375,26 @@ export default function WorkflowBuilder() { Sync defaults
+
+ + + +
{ const lib = libById(ref.step_id); const outs = upstreamOutputsFor(i); - const script = ref.overrides?.script ?? lib?.script ?? ""; + const script = ref.inline?.script ?? ref.overrides?.script ?? lib?.script ?? ""; const wfIdx = wf.steps.indexOf(ref); const isSelected = selected === i; return ( @@ -405,8 +480,10 @@ export default function WorkflowBuilder() { {i + 1} - {lib?.name ?? ref.step_id} - {lib && } + {ref.inline?.name ?? lib?.name ?? ref.step_id} + {ref.inline && } + {lib && !ref.inline && } + {ref.inline && }
                                             {script.slice(0, 200)}
@@ -439,27 +516,69 @@ export default function WorkflowBuilder() {
                                     Step {selected + 1} · Inspector
                                 
                                 
- {selectedLib && } -

{selectedLib?.name ?? selectedRef.step_id}

+ {selectedRef.inline && } + {selectedLib && !selectedRef.inline && } + {selectedRef.inline && } +

{selectedRef.inline?.name ?? selectedLib?.name ?? selectedRef.step_id}

-
- -