diff --git a/CHANGELOG.md b/CHANGELOG.md index 028d170..aad86ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to Armature are documented here. +## v0.9.7 — Full Read/Write Workflow Starlark Module + +Extensions with `workflow.access` permission can now start, advance, +cancel, and signoff workflow instances directly from Starlark scripts. + +**WorkflowEngine interface** + +- Defined in `sandbox` package to break the `workflow ↔ sandbox` circular + import (follows `NotificationSender` / `ConnectionResolver` pattern) +- 4 methods: `Start`, `Advance`, `Cancel`, `SubmitSignoff` +- `workflow.Engine` satisfies the interface implicitly — zero changes to + the engine package + +**New Starlark builtins** + +- `workflow.start(workflow_id, data={})` → instance dict +- `workflow.advance(instance_id, data={})` → instance dict +- `workflow.cancel(instance_id)` → None +- `workflow.submit_signoff(instance_id, decision, comment="")` → signoff dict +- All write ops require authenticated user context (`RunContext.UserID`) +- Graceful errors when engine or user context unavailable + +**Refactors** + +- `instanceToDict` helper extracted — shared by `get_instance`, `start`, + `advance` +- `signoffToDict` helper for signoff return values +- `dictToJSON` helper for Starlark dict → `json.RawMessage` conversion + +**Tests:** 6 new unit tests (mock engine, happy path + error guards) + ## v0.9.6 — Deprecate stage_type, Collapse stage_mode Simplifies the workflow stage classification model by removing diff --git a/ROADMAP.md b/ROADMAP.md index 7dda8db..436b4ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,11 +129,13 @@ Manifest `form_template` accepted at package level. 16 new tests. "review" mapped to "form" on input; review surface removed (~110 lines). Migration 018. 4 package manifests updated. -**v0.9.7 — Full Read/Write Workflow Starlark Module** +**v0.9.7 — Full Read/Write Workflow Starlark Module** *(completed)* -Add `workflow.start()`, `workflow.advance()`, `workflow.cancel()`, -`workflow.submit_signoff()` to the Starlark module. Extract engine -interface to break circular import. +`WorkflowEngine` interface extracted in sandbox package to break +circular import. Four write builtins added: `workflow.start()`, +`workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()`. +`instanceToDict` and `signoffToDict` helpers shared by read+write paths. +6 new tests. **v0.9.8 — Conditional Routing → SDK Primitive** diff --git a/VERSION b/VERSION index 85b7c69..c81aa44 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.6 +0.9.7 diff --git a/server/main.go b/server/main.go index fb3b08c..72fe0f6 100644 --- a/server/main.go +++ b/server/main.go @@ -451,6 +451,7 @@ func main() { // ── Workflow Engine (shared by public + protected routes) ── wfEngine := workflow.NewEngine(stores, bus, starlarkRunner) + starlarkRunner.SetWorkflowEngine(wfEngine) // ── Public Workflow Entry ───── publicWfH := handlers.NewWorkflowPublicHandler(wfEngine, stores) diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index e44b18d..80143e3 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -82,6 +82,7 @@ type Runner struct { workspaceRoot string // empty = workspace module unavailable workspaceQuota int // MB, 0 = unlimited capabilities map[string]bool // detected environment capabilities + wfEngine WorkflowEngine // nil = workflow write ops unavailable } // NewRunner creates a runner with the given sandbox and dependencies. @@ -139,6 +140,12 @@ func (r *Runner) SetCapabilities(caps map[string]bool) { r.capabilities = caps } +// SetWorkflowEngine attaches the workflow engine for write operations +// (start/advance/cancel/submit_signoff) in the workflow Starlark module. +func (r *Runner) SetWorkflowEngine(e WorkflowEngine) { + r.wfEngine = e +} + // SetAllowPrivateIPs disables the SSRF check that blocks connections to // private/loopback IPs. For self-hosted environments where extensions // reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var. @@ -396,7 +403,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m filesLevel = 2 case models.ExtPermWorkflowAccess: - modules["workflow"] = BuildWorkflowModule(ctx, r.stores) + modules["workflow"] = BuildWorkflowModule(ctx, r.stores, r.wfEngine, rc) case models.ExtPermConnectionsRead: if r.connResolver != nil && rc != nil && rc.UserID != "" { diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index 0edd378..28eecc1 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -6,12 +6,13 @@ package sandbox // manage instances, and programmatically advance workflow stages. // // Starlark API: -// wf = workflow.get_definition(workflow_id) # returns dict -// inst = workflow.get_instance(instance_id) # returns dict -// inst = workflow.start(workflow_id, data={}) # start new instance -// inst = workflow.advance(instance_id, data={}) # advance to next stage -// workflow.cancel(instance_id) # cancel instance -// instances = workflow.list_instances(workflow_id) # list instances +// wf = workflow.get_definition(workflow_id) # returns dict +// inst = workflow.get_instance(instance_id) # returns dict +// instances = workflow.list_instances(workflow_id) # list instances +// inst = workflow.start(workflow_id, data={}) # start new instance +// inst = workflow.advance(instance_id, data={}) # advance to next stage +// workflow.cancel(instance_id) # cancel instance +// signoff = workflow.submit_signoff(instance_id, decision, comment="") # submit signoff import ( "context" @@ -21,21 +22,37 @@ import ( "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" + "armature/models" "armature/store" ) +// WorkflowEngine is the subset of the workflow engine needed by Starlark +// write operations. Defined here (not in the workflow package) to avoid +// circular imports — workflow imports sandbox for the Runner. +type WorkflowEngine interface { + Start(ctx context.Context, workflowID string, initialData json.RawMessage, userID string) (*models.WorkflowInstance, error) + Advance(ctx context.Context, instanceID string, stageData json.RawMessage, userID string) (*models.WorkflowInstance, error) + Cancel(ctx context.Context, instanceID string, userID string) error + SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) +} + // BuildWorkflowModule creates the "workflow" Starlark module for a package. // Requires the workflow.access permission. // -// Mutating operations (start/advance/cancel) are available via HTTP API; -// Starlark builtins for them will be added when the engine interface is -// extracted to a shared package to avoid circular imports. -func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { - return MakeModule("workflow", starlark.StringDict{ +// Read operations use stores directly. Write operations delegate to the +// WorkflowEngine interface (engine may be nil if not wired — write builtins +// return an error in that case). +func BuildWorkflowModule(ctx context.Context, stores store.Stores, engine WorkflowEngine, rc *RunContext) *starlarkstruct.Module { + members := starlark.StringDict{ "get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)), "get_instance": starlark.NewBuiltin("workflow.get_instance", workflowGetInstance(ctx, stores)), "list_instances": starlark.NewBuiltin("workflow.list_instances", workflowListInstances(ctx, stores)), - }) + "start": starlark.NewBuiltin("workflow.start", workflowStart(ctx, engine, rc)), + "advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, engine, rc)), + "cancel": starlark.NewBuiltin("workflow.cancel", workflowCancel(ctx, engine, rc)), + "submit_signoff": starlark.NewBuiltin("workflow.submit_signoff", workflowSubmitSignoff(ctx, engine, rc)), + } + return MakeModule("workflow", members) } func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { @@ -98,35 +115,51 @@ func workflowGetInstance(ctx context.Context, stores store.Stores) func(*starlar if err != nil { return starlark.None, fmt.Errorf("workflow.get_instance: %w", err) } - - d := starlark.NewDict(8) - d.SetKey(starlark.String("id"), starlark.String(inst.ID)) - d.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID)) - d.SetKey(starlark.String("workflow_version"), starlark.MakeInt(inst.WorkflowVersion)) - d.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage)) - d.SetKey(starlark.String("status"), starlark.String(inst.Status)) - d.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy)) - - // Parse stage_data into Starlark dict - var dataMap map[string]interface{} - if json.Unmarshal(inst.StageData, &dataMap) == nil { - starlarkData := starlark.NewDict(len(dataMap)) - for k, v := range dataMap { - starlarkData.SetKey(starlark.String(k), GoToStarlark(v)) - } - d.SetKey(starlark.String("stage_data"), starlarkData) - } else { - d.SetKey(starlark.String("stage_data"), starlark.NewDict(0)) - } - - if inst.EntryToken != nil { - d.SetKey(starlark.String("entry_token"), starlark.String(*inst.EntryToken)) - } - - return d, nil + return instanceToDict(inst), nil } } +// instanceToDict converts a WorkflowInstance to a Starlark dict. +func instanceToDict(inst *models.WorkflowInstance) *starlark.Dict { + d := starlark.NewDict(8) + d.SetKey(starlark.String("id"), starlark.String(inst.ID)) + d.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID)) + d.SetKey(starlark.String("workflow_version"), starlark.MakeInt(inst.WorkflowVersion)) + d.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage)) + d.SetKey(starlark.String("status"), starlark.String(inst.Status)) + d.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy)) + + // Parse stage_data into Starlark dict + var dataMap map[string]interface{} + if json.Unmarshal(inst.StageData, &dataMap) == nil { + starlarkData := starlark.NewDict(len(dataMap)) + for k, v := range dataMap { + starlarkData.SetKey(starlark.String(k), GoToStarlark(v)) + } + d.SetKey(starlark.String("stage_data"), starlarkData) + } else { + d.SetKey(starlark.String("stage_data"), starlark.NewDict(0)) + } + + if inst.EntryToken != nil { + d.SetKey(starlark.String("entry_token"), starlark.String(*inst.EntryToken)) + } + return d +} + +// signoffToDict converts a WorkflowSignoff to a Starlark dict. +func signoffToDict(s *models.WorkflowSignoff) *starlark.Dict { + d := starlark.NewDict(7) + d.SetKey(starlark.String("id"), starlark.String(s.ID)) + d.SetKey(starlark.String("instance_id"), starlark.String(s.InstanceID)) + d.SetKey(starlark.String("stage"), starlark.String(s.Stage)) + d.SetKey(starlark.String("user_id"), starlark.String(s.UserID)) + d.SetKey(starlark.String("decision"), starlark.String(s.Decision)) + d.SetKey(starlark.String("comment"), starlark.String(s.Comment)) + d.SetKey(starlark.String("created_at"), starlark.String(s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"))) + return d +} + func workflowListInstances(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var workflowID string @@ -155,5 +188,113 @@ func workflowListInstances(ctx context.Context, stores store.Stores) func(*starl } } -// workflowRoute routes the workflow to a named stage. -// Starlark: workflow.route(instance_id, target_stage, reason) +// ── Instance Write API ────────────── + +// workflowWriteGuard checks that the engine and user context are available. +func workflowWriteGuard(engine WorkflowEngine, rc *RunContext) error { + if engine == nil { + return fmt.Errorf("workflow engine not available") + } + if rc == nil || rc.UserID == "" { + return fmt.Errorf("workflow write operations require an authenticated user context") + } + return nil +} + +// dictToJSON converts a Starlark dict to json.RawMessage. +func dictToJSON(d *starlark.Dict) (json.RawMessage, error) { + m := DictToMap(d) + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + return json.RawMessage(b), nil +} + +func workflowStart(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var workflowID string + data := starlark.NewDict(0) + if err := starlark.UnpackArgs("workflow.start", args, kwargs, + "workflow_id", &workflowID, + "data?", &data, + ); err != nil { + return nil, err + } + if err := workflowWriteGuard(engine, rc); err != nil { + return nil, fmt.Errorf("workflow.start: %w", err) + } + dataJSON, err := dictToJSON(data) + if err != nil { + return nil, fmt.Errorf("workflow.start: marshal data: %w", err) + } + inst, err := engine.Start(ctx, workflowID, dataJSON, rc.UserID) + if err != nil { + return nil, fmt.Errorf("workflow.start: %w", err) + } + return instanceToDict(inst), nil + } +} + +func workflowAdvance(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var instanceID string + data := starlark.NewDict(0) + if err := starlark.UnpackArgs("workflow.advance", args, kwargs, + "instance_id", &instanceID, + "data?", &data, + ); err != nil { + return nil, err + } + if err := workflowWriteGuard(engine, rc); err != nil { + return nil, fmt.Errorf("workflow.advance: %w", err) + } + dataJSON, err := dictToJSON(data) + if err != nil { + return nil, fmt.Errorf("workflow.advance: marshal data: %w", err) + } + inst, err := engine.Advance(ctx, instanceID, dataJSON, rc.UserID) + if err != nil { + return nil, fmt.Errorf("workflow.advance: %w", err) + } + return instanceToDict(inst), nil + } +} + +func workflowCancel(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var instanceID string + if err := starlark.UnpackPositionalArgs("workflow.cancel", args, kwargs, 1, &instanceID); err != nil { + return nil, err + } + if err := workflowWriteGuard(engine, rc); err != nil { + return nil, fmt.Errorf("workflow.cancel: %w", err) + } + if err := engine.Cancel(ctx, instanceID, rc.UserID); err != nil { + return nil, fmt.Errorf("workflow.cancel: %w", err) + } + return starlark.None, nil + } +} + +func workflowSubmitSignoff(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var instanceID, decision string + comment := "" + if err := starlark.UnpackArgs("workflow.submit_signoff", args, kwargs, + "instance_id", &instanceID, + "decision", &decision, + "comment?", &comment, + ); err != nil { + return nil, err + } + if err := workflowWriteGuard(engine, rc); err != nil { + return nil, fmt.Errorf("workflow.submit_signoff: %w", err) + } + signoff, err := engine.SubmitSignoff(ctx, instanceID, rc.UserID, decision, comment) + if err != nil { + return nil, fmt.Errorf("workflow.submit_signoff: %w", err) + } + return signoffToDict(signoff), nil + } +} diff --git a/server/sandbox/workflow_module_test.go b/server/sandbox/workflow_module_test.go new file mode 100644 index 0000000..134025a --- /dev/null +++ b/server/sandbox/workflow_module_test.go @@ -0,0 +1,262 @@ +package sandbox + +import ( + "context" + "encoding/json" + "testing" + "time" + + "go.starlark.net/starlark" + + "armature/models" + "armature/store" +) + +// ── Mock Engine ────────────────────── + +type mockWorkflowEngine struct { + startResult *models.WorkflowInstance + advanceResult *models.WorkflowInstance + signoffResult *models.WorkflowSignoff + + lastMethod string + lastWorkflowID string + lastInstanceID string + lastUserID string + lastData json.RawMessage + lastDecision string + lastComment string +} + +func (m *mockWorkflowEngine) Start(_ context.Context, workflowID string, data json.RawMessage, userID string) (*models.WorkflowInstance, error) { + m.lastMethod = "Start" + m.lastWorkflowID = workflowID + m.lastData = data + m.lastUserID = userID + return m.startResult, nil +} + +func (m *mockWorkflowEngine) Advance(_ context.Context, instanceID string, data json.RawMessage, userID string) (*models.WorkflowInstance, error) { + m.lastMethod = "Advance" + m.lastInstanceID = instanceID + m.lastData = data + m.lastUserID = userID + return m.advanceResult, nil +} + +func (m *mockWorkflowEngine) Cancel(_ context.Context, instanceID string, userID string) error { + m.lastMethod = "Cancel" + m.lastInstanceID = instanceID + m.lastUserID = userID + return nil +} + +func (m *mockWorkflowEngine) SubmitSignoff(_ context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) { + m.lastMethod = "SubmitSignoff" + m.lastInstanceID = instanceID + m.lastUserID = userID + m.lastDecision = decision + m.lastComment = comment + return m.signoffResult, nil +} + +// ── Helpers ────────────────────────── + +func testInstance() *models.WorkflowInstance { + return &models.WorkflowInstance{ + ID: "inst-1", + WorkflowID: "wf-1", + WorkflowVersion: 2, + CurrentStage: "review", + Status: "active", + StartedBy: "user-1", + StageData: json.RawMessage(`{"key":"val"}`), + } +} + +func testSignoff() *models.WorkflowSignoff { + return &models.WorkflowSignoff{ + ID: "sig-1", + InstanceID: "inst-1", + Stage: "review", + UserID: "user-1", + Decision: "approve", + Comment: "LGTM", + CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + } +} + +func runWorkflowScript(t *testing.T, engine WorkflowEngine, rc *RunContext, script string) (starlark.StringDict, error) { + t.Helper() + mod := BuildWorkflowModule(context.Background(), store.Stores{}, engine, rc) + predeclared := starlark.StringDict{"workflow": mod} + globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared) + return globals, err +} + +// ── Tests ──────────────────────────── + +func TestWorkflowStart(t *testing.T) { + mock := &mockWorkflowEngine{startResult: testInstance()} + rc := &RunContext{UserID: "user-1"} + + globals, err := runWorkflowScript(t, mock, rc, ` +result = workflow.start("wf-1", data={"key": "val"}) +`) + if err != nil { + t.Fatal(err) + } + + if mock.lastMethod != "Start" { + t.Fatalf("expected Start, got %s", mock.lastMethod) + } + if mock.lastWorkflowID != "wf-1" { + t.Fatalf("expected wf-1, got %s", mock.lastWorkflowID) + } + if mock.lastUserID != "user-1" { + t.Fatalf("expected user-1, got %s", mock.lastUserID) + } + + // Verify data was marshaled + var d map[string]interface{} + if err := json.Unmarshal(mock.lastData, &d); err != nil { + t.Fatal(err) + } + if d["key"] != "val" { + t.Fatalf("expected data key=val, got %v", d) + } + + // Verify returned dict + result := globals["result"].(*starlark.Dict) + id, _, _ := result.Get(starlark.String("id")) + if id.(starlark.String).GoString() != "inst-1" { + t.Fatalf("expected id=inst-1, got %v", id) + } + status, _, _ := result.Get(starlark.String("status")) + if status.(starlark.String).GoString() != "active" { + t.Fatalf("expected status=active, got %v", status) + } +} + +func TestWorkflowAdvance(t *testing.T) { + inst := testInstance() + inst.CurrentStage = "complete" + mock := &mockWorkflowEngine{advanceResult: inst} + rc := &RunContext{UserID: "user-1"} + + globals, err := runWorkflowScript(t, mock, rc, ` +result = workflow.advance("inst-1", data={"step": 2}) +`) + if err != nil { + t.Fatal(err) + } + + if mock.lastMethod != "Advance" { + t.Fatalf("expected Advance, got %s", mock.lastMethod) + } + if mock.lastInstanceID != "inst-1" { + t.Fatalf("expected inst-1, got %s", mock.lastInstanceID) + } + + result := globals["result"].(*starlark.Dict) + stage, _, _ := result.Get(starlark.String("current_stage")) + if stage.(starlark.String).GoString() != "complete" { + t.Fatalf("expected current_stage=complete, got %v", stage) + } +} + +func TestWorkflowCancel(t *testing.T) { + mock := &mockWorkflowEngine{} + rc := &RunContext{UserID: "user-1"} + + globals, err := runWorkflowScript(t, mock, rc, ` +result = workflow.cancel("inst-1") +`) + if err != nil { + t.Fatal(err) + } + + if mock.lastMethod != "Cancel" { + t.Fatalf("expected Cancel, got %s", mock.lastMethod) + } + if mock.lastInstanceID != "inst-1" { + t.Fatalf("expected inst-1, got %s", mock.lastInstanceID) + } + if mock.lastUserID != "user-1" { + t.Fatalf("expected user-1, got %s", mock.lastUserID) + } + + if globals["result"] != starlark.None { + t.Fatalf("expected None, got %v", globals["result"]) + } +} + +func TestWorkflowSubmitSignoff(t *testing.T) { + mock := &mockWorkflowEngine{signoffResult: testSignoff()} + rc := &RunContext{UserID: "user-1"} + + globals, err := runWorkflowScript(t, mock, rc, ` +result = workflow.submit_signoff("inst-1", "approve", comment="LGTM") +`) + if err != nil { + t.Fatal(err) + } + + if mock.lastMethod != "SubmitSignoff" { + t.Fatalf("expected SubmitSignoff, got %s", mock.lastMethod) + } + if mock.lastDecision != "approve" { + t.Fatalf("expected approve, got %s", mock.lastDecision) + } + if mock.lastComment != "LGTM" { + t.Fatalf("expected LGTM, got %s", mock.lastComment) + } + + result := globals["result"].(*starlark.Dict) + decision, _, _ := result.Get(starlark.String("decision")) + if decision.(starlark.String).GoString() != "approve" { + t.Fatalf("expected decision=approve, got %v", decision) + } + comment, _, _ := result.Get(starlark.String("comment")) + if comment.(starlark.String).GoString() != "LGTM" { + t.Fatalf("expected comment=LGTM, got %v", comment) + } +} + +func TestWorkflowWrite_NoUser(t *testing.T) { + mock := &mockWorkflowEngine{startResult: testInstance()} + + _, err := runWorkflowScript(t, mock, nil, ` +result = workflow.start("wf-1") +`) + if err == nil { + t.Fatal("expected error for nil RunContext") + } + if got := err.Error(); !contains(got, "authenticated user context") { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestWorkflowWrite_NoEngine(t *testing.T) { + rc := &RunContext{UserID: "user-1"} + + _, err := runWorkflowScript(t, nil, rc, ` +result = workflow.start("wf-1") +`) + if err == nil { + t.Fatal("expected error for nil engine") + } + if got := err.Error(); !contains(got, "engine not available") { + t.Fatalf("unexpected error: %s", got) + } +} + +// contains is a test helper — strings.Contains without importing strings. +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}