diff --git a/claude.md b/claude.md index 7af946b..8486a8f 100644 --- a/claude.md +++ b/claude.md @@ -117,7 +117,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end. -Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever). +Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever). ### Monitors diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 21f5a1e..27a667b 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "io" "net/http" @@ -185,6 +186,10 @@ func updateStep(c *gin.Context) { return } if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil { + if errors.Is(err, services.ErrDefaultStep) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -194,6 +199,10 @@ func updateStep(c *gin.Context) { func deleteStep(c *gin.Context) { if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil { + if errors.Is(err, services.ErrDefaultStep) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 4cd172f..0c6c516 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "time" @@ -13,6 +14,24 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// ErrDefaultStep is returned when a caller tries to edit or delete a step that +// came from the image's default library. Those rows are re-seeded from disk on +// every boot, so an edit would be silently reverted and a delete would come +// back — refusing is honest about who owns them. +var ErrDefaultStep = errors.New("this step ships with Vantage and cannot be edited or deleted; duplicate it to make your own copy") + +func isDefaultStep(ctx context.Context, instanceID, stepID string) (bool, error) { + var s models.WorkflowStep + err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s) + if errors.Is(err, mongo.ErrNoDocuments) { + return false, nil + } + if err != nil { + return false, err + } + return s.Source == "default", nil +} + func wfCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 10*time.Second) } @@ -115,6 +134,11 @@ func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep, func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error { ctx, cancel := wfCtx() defer cancel() + if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil { + return err + } else if def { + return ErrDefaultStep + } _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{ "name": s.Name, "description": s.Description, @@ -131,6 +155,11 @@ func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error { func DeleteStep(instanceID, stepID string) error { ctx, cancel := wfCtx() defer cancel() + if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil { + return err + } else if def { + return ErrDefaultStep + } if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil { return err } diff --git a/web/app/(app)/steps/page.tsx b/web/app/(app)/steps/page.tsx index 6312d9a..9f7e5fd 100644 --- a/web/app/(app)/steps/page.tsx +++ b/web/app/(app)/steps/page.tsx @@ -175,14 +175,16 @@ export default function StepsPage() {
Export - + {s.source !== "default" && ( + + )}
diff --git a/web/app/(app)/workflows/[id]/page.tsx b/web/app/(app)/workflows/[id]/page.tsx index 683ab4f..7bf7309 100644 --- a/web/app/(app)/workflows/[id]/page.tsx +++ b/web/app/(app)/workflows/[id]/page.tsx @@ -457,7 +457,8 @@ export default function WorkflowBuilder() {