From 983d761bbe75d749deb07dc9a57ee58cce70b83b Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 14:32:14 +0000 Subject: [PATCH] Feat v0.9.2 converter consolidation (#75) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 38 +++++ ROADMAP.md | 11 +- VERSION | 2 +- server/handlers/packages.go | 6 +- server/handlers/starlark_helpers.go | 68 -------- .../handlers/workflow_assignment_handlers.go | 14 +- server/handlers/workflow_engine_test.go | 12 +- server/handlers/workflow_hooks.go | 45 +----- server/handlers/workflow_hooks_test.go | 6 +- server/handlers/workflow_instance_handlers.go | 14 +- server/handlers/workflow_packages.go | 2 +- server/handlers/workflow_store_test.go | 2 +- server/models/snapshot.go | 25 +++ server/sandbox/convert.go | 101 ++++++++++++ server/sandbox/convert_test.go | 150 ++++++++++++++++++ server/sandbox/files_module.go | 76 +-------- server/sandbox/realtime_module.go | 45 +----- server/sandbox/realtime_module_test.go | 6 +- server/sandbox/workflow_module.go | 33 +--- server/triggers/event.go | 40 +---- server/triggers/webhook.go | 38 +---- server/workflow/automated.go | 76 +-------- server/workflow/engine.go | 29 +--- server/workflow/scanner.go | 2 +- 24 files changed, 362 insertions(+), 479 deletions(-) create mode 100644 server/models/snapshot.go create mode 100644 server/sandbox/convert.go create mode 100644 server/sandbox/convert_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 85560fd..d41d211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to Armature are documented here. +## v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup + +Consolidates duplicated Go↔Starlark conversion code and snapshot +parsers into canonical locations, removing ~350 lines of copy-paste +across 8 files. + +**Converter consolidation** + +- New `sandbox/convert.go` with four exported functions: + `GoToStarlark`, `StarlarkToGo`, `DictToMap`, `MapToDict`. +- Superset implementation handles all Go primitive types (nil, bool, + int, int64, float64, string), containers (map, slice), and Starlark + Tuple — covering every variant that previously existed. +- Deleted duplicate converters from `workflow/automated.go`, + `handlers/starlark_helpers.go`, `handlers/workflow_hooks.go`, + `sandbox/workflow_module.go`, `sandbox/realtime_module.go`, + `sandbox/files_module.go`, `triggers/event.go`, `triggers/webhook.go`. +- SQL-specific converters in `db_module.go` (error-returning, `[]byte` + handling) intentionally excluded — different semantics. + +**Snapshot parser consolidation** + +- New `models/snapshot.go` with `ParseSnapshotStages()` handling both + wrapped `{"stages":[...]}` and legacy flat `[...]` formats. +- Deleted three identical parsers from `workflow/engine.go`, + `handlers/workflow_instance_handlers.go`, and + `handlers/workflow_assignment_handlers.go`. + +**Snapshot format standardization** + +- `workflow_packages.go` publish path now emits wrapped format, + matching `workflows.go`. All snapshot creation is consistent. + +**Tests:** 7 new converter tests with round-trip coverage. Existing +engine, handler, and workflow tests updated and passing. + +--- + ## v0.9.1 — Server-Side Sub-Path Routing Hardens multi-surface routing so full-page refreshes on sub-paths work diff --git a/ROADMAP.md b/ROADMAP.md index 1d73aa9..a8d9870 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,13 +91,12 @@ Added `aggregateAccess()` for early auth short-circuit on all-authenticated packages. SDK seeds initial history state for back-button resilience. 8 handler integration tests + 5 aggregateAccess unit tests. -**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** +**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** *(completed)* -Design doc: `docs/DESIGN-workflow-redesign.md` - -Three files contain near-identical Go↔Starlark converters; three copies -of the snapshot parser exist. Consolidate into `sandbox/convert.go` and -one exported snapshot function. Standardize on wrapped snapshot format. +Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go` +(4 exported functions) and snapshot parsers into `models/snapshot.go`. +Standardized on wrapped snapshot format. ~350 lines of duplication removed. +Design doc: `docs/DESIGN-workflow-redesign.md`. **v0.9.3 — Team User Roles** diff --git a/VERSION b/VERSION index f374f66..2003b63 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.1 +0.9.2 diff --git a/server/handlers/packages.go b/server/handlers/packages.go index a970845..8199296 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -917,7 +917,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) { } // Build a call dict identical to what executeExtensionTool sends. - // Build the arguments as an interface{} map for jsonToStarlark. + // Build the arguments as an interface{} map for GoToStarlark. var args interface{} if req.Arguments != nil { argsJSON, _ := json.Marshal(req.Arguments) @@ -927,7 +927,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) { callDict := starlark.NewDict(3) _ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName)) _ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID)) - _ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args)) + _ = callDict.SetKey(starlark.String("arguments"), sandbox.GoToStarlark(args)) userID, _ := c.Get("user_id") rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)} @@ -945,7 +945,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) { } // Serialize the Starlark return value to Go types. - result := starlarkValueToGo(val) + result := sandbox.StarlarkToGo(val) c.JSON(http.StatusOK, gin.H{ "result": result, "output": output, diff --git a/server/handlers/starlark_helpers.go b/server/handlers/starlark_helpers.go index d970bde..2cd2730 100644 --- a/server/handlers/starlark_helpers.go +++ b/server/handlers/starlark_helpers.go @@ -2,77 +2,9 @@ package handlers import ( "encoding/json" - "fmt" "log" - - "go.starlark.net/starlark" ) -// jsonToStarlark converts an arbitrary Go value (typically from JSON unmarshal) -// to a Starlark value for passing into extension scripts. -func jsonToStarlark(v any) starlark.Value { - switch val := v.(type) { - case nil: - return starlark.None - case bool: - return starlark.Bool(val) - case float64: - if val == float64(int64(val)) { - return starlark.MakeInt64(int64(val)) - } - return starlark.Float(val) - case string: - return starlark.String(val) - case []any: - elems := make([]starlark.Value, len(val)) - for i, e := range val { - elems[i] = jsonToStarlark(e) - } - return starlark.NewList(elems) - case map[string]any: - d := starlark.NewDict(len(val)) - for k, v := range val { - _ = d.SetKey(starlark.String(k), jsonToStarlark(v)) - } - return d - default: - return starlark.String(fmt.Sprintf("%v", val)) - } -} - -// starlarkValueToGo converts a Starlark value back to a Go value suitable -// for JSON serialization. -func starlarkValueToGo(v starlark.Value) any { - switch val := v.(type) { - case starlark.NoneType: - return nil - case starlark.Bool: - return bool(val) - case starlark.Int: - i, _ := val.Int64() - return i - case starlark.Float: - return float64(val) - case starlark.String: - return string(val) - case *starlark.List: - out := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - out[i] = starlarkValueToGo(val.Index(i)) - } - return out - case *starlark.Dict: - out := make(map[string]any) - for _, item := range val.Items() { - k, _ := item[0].(starlark.String) - out[string(k)] = starlarkValueToGo(item[1]) - } - return out - default: - return v.String() - } -} - // ParseSchemaVersion extracts the schema_version from a package manifest. func ParseSchemaVersion(manifest any) int { switch m := manifest.(type) { diff --git a/server/handlers/workflow_assignment_handlers.go b/server/handlers/workflow_assignment_handlers.go index 8992145..8a633fe 100644 --- a/server/handlers/workflow_assignment_handlers.go +++ b/server/handlers/workflow_assignment_handlers.go @@ -262,7 +262,7 @@ func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assig // Resolve stage name and SLA from the published version snapshot ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) if ver != nil { - stages := parseSnapshotStagesForEnrich(ver.Snapshot) + stages, _ := models.ParseSnapshotStages(ver.Snapshot) for _, s := range stages { if s.Name == a.Stage { v.StageName = s.Name @@ -287,15 +287,3 @@ func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assig return views } -// parseSnapshotStagesForEnrich is a lightweight stage parser for enrichment. -func parseSnapshotStagesForEnrich(snapshot json.RawMessage) []models.WorkflowStage { - var wrapped struct { - Stages []models.WorkflowStage `json:"stages"` - } - if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 { - return wrapped.Stages - } - var stages []models.WorkflowStage - json.Unmarshal(snapshot, &stages) - return stages -} diff --git a/server/handlers/workflow_engine_test.go b/server/handlers/workflow_engine_test.go index 4857b52..3cb1bb0 100644 --- a/server/handlers/workflow_engine_test.go +++ b/server/handlers/workflow_engine_test.go @@ -51,7 +51,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) { } } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot} if err := s.Workflows.Publish(ctx, ver); err != nil { t.Fatalf("publish: %v", err) @@ -140,7 +140,7 @@ func TestEngine_BranchRouting(t *testing.T) { for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // Test: priority=high → escalation @@ -189,7 +189,7 @@ func TestEngine_PublicEntry(t *testing.T) { for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // StartPublic @@ -259,7 +259,7 @@ func TestEngine_SignoffGate(t *testing.T) { for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1) @@ -313,7 +313,7 @@ func TestEngine_SignoffRejection(t *testing.T) { for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID) @@ -495,7 +495,7 @@ func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) { for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) } - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}) // Engine with no runner — automated stage silently skips, instance stays at auto-stage diff --git a/server/handlers/workflow_hooks.go b/server/handlers/workflow_hooks.go index 1b96d88..2ab5292 100644 --- a/server/handlers/workflow_hooks.go +++ b/server/handlers/workflow_hooks.go @@ -78,7 +78,7 @@ func FireOnAdvanceHook( // Parse stage_data into Starlark dict var dataMap map[string]interface{} if json.Unmarshal(stageData, &dataMap) == nil { - _ = ctxDict.SetKey(starlark.String("stage_data"), jsonToStarlark(dataMap)) + _ = ctxDict.SetKey(starlark.String("stage_data"), sandbox.GoToStarlark(dataMap)) } else { _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0)) } @@ -117,7 +117,7 @@ func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult { // Check for enriched stage_data if sdVal, found, _ := d.Get(starlark.String("stage_data")); found { if sd, ok := sdVal.(*starlark.Dict); ok { - goMap := starlarkDictToMap(sd) + goMap := sandbox.DictToMap(sd) if data, err := json.Marshal(goMap); err == nil { result.EnrichedData = data return result @@ -128,44 +128,3 @@ func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult { return nil } -// starlarkDictToMap converts a Starlark dict to a Go map. -func starlarkDictToMap(d *starlark.Dict) map[string]any { - result := make(map[string]any, d.Len()) - for _, item := range d.Items() { - k, ok := item[0].(starlark.String) - if !ok { - continue - } - result[string(k)] = starlarkToGo(item[1]) - } - return result -} - -// starlarkToGo converts a Starlark value to a Go value. -func starlarkToGo(v starlark.Value) any { - switch val := v.(type) { - case starlark.NoneType: - return nil - case starlark.Bool: - return bool(val) - case starlark.Int: - if i, ok := val.Int64(); ok { - return i - } - return val.String() - case starlark.Float: - return float64(val) - case starlark.String: - return string(val) - case *starlark.List: - result := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - result[i] = starlarkToGo(val.Index(i)) - } - return result - case *starlark.Dict: - return starlarkDictToMap(val) - default: - return v.String() - } -} diff --git a/server/handlers/workflow_hooks_test.go b/server/handlers/workflow_hooks_test.go index 7de9ffd..50df7aa 100644 --- a/server/handlers/workflow_hooks_test.go +++ b/server/handlers/workflow_hooks_test.go @@ -5,6 +5,8 @@ import ( "testing" "go.starlark.net/starlark" + + "armature/sandbox" ) // ── parseOnAdvanceResult ──────────────────── @@ -82,7 +84,7 @@ func TestParseOnAdvanceResult_EmptyDict(t *testing.T) { } } -// ── starlarkDictToMap ─────────────────────── +// ── DictToMap ─────────────────────── func TestStarlarkDictToMap_NestedStructure(t *testing.T) { inner := starlark.NewDict(1) @@ -98,7 +100,7 @@ func TestStarlarkDictToMap_NestedStructure(t *testing.T) { _ = outer.SetKey(starlark.String("nested"), inner) _ = outer.SetKey(starlark.String("members"), members) - m := starlarkDictToMap(outer) + m := sandbox.DictToMap(outer) if m["title"] != "Test" { t.Errorf("expected title='Test', got %v", m["title"]) diff --git a/server/handlers/workflow_instance_handlers.go b/server/handlers/workflow_instance_handlers.go index d287c5d..c13f787 100644 --- a/server/handlers/workflow_instance_handlers.go +++ b/server/handlers/workflow_instance_handlers.go @@ -199,7 +199,7 @@ func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances // SLA check from version snapshot ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion) if ver != nil { - stages := parseSnapshotStagesForView(ver.Snapshot) + stages, _ := models.ParseSnapshotStages(ver.Snapshot) for _, s := range stages { if s.Name == inst.CurrentStage { v.StageName = s.Name @@ -217,18 +217,6 @@ func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances return views } -func parseSnapshotStagesForView(snapshot json.RawMessage) []models.WorkflowStage { - var wrapped struct { - Stages []models.WorkflowStage `json:"stages"` - } - if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 { - return wrapped.Stages - } - var stages []models.WorkflowStage - json.Unmarshal(snapshot, &stages) - return stages -} - // CancelTeamInstance cancels an instance belonging to a team workflow. // POST /api/v1/teams/:teamId/workflow-instances/:iid/cancel func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) { diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index 26997d1..368eb3a 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -256,7 +256,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st // Publish version 1 (snapshot of stages) and activate the workflow // so it's immediately usable after install. stages, _ := stores.Workflows.ListStages(reqCtx, workflowID) - snapshot, _ := json.Marshal(stages) + snapshot, _ := json.Marshal(map[string]any{"stages": stages}) ver := &models.WorkflowVersion{ WorkflowID: workflowID, VersionNumber: 1, diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index 61414d6..d1ccffe 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -82,7 +82,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s } // Publish version 1 - snapshot, _ := json.Marshal([]models.WorkflowStage{*s1, *s2}) + snapshot, _ := json.Marshal(map[string]any{"stages": []models.WorkflowStage{*s1, *s2}}) ver := &models.WorkflowVersion{ WorkflowID: wf.ID, VersionNumber: 1, diff --git a/server/models/snapshot.go b/server/models/snapshot.go new file mode 100644 index 0000000..b6c5386 --- /dev/null +++ b/server/models/snapshot.go @@ -0,0 +1,25 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +// ParseSnapshotStages parses a version snapshot into a slice of +// WorkflowStage. It handles both the wrapped format +// {"stages": [...], "workflow": {...}} and the legacy flat array [...]. +func ParseSnapshotStages(raw json.RawMessage) ([]WorkflowStage, error) { + // Try wrapped format first + var wrapped struct { + Stages []WorkflowStage `json:"stages"` + } + if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 { + return wrapped.Stages, nil + } + // Fallback to flat array + var stages []WorkflowStage + if err := json.Unmarshal(raw, &stages); err != nil { + return nil, fmt.Errorf("corrupt version snapshot: %w", err) + } + return stages, nil +} diff --git a/server/sandbox/convert.go b/server/sandbox/convert.go new file mode 100644 index 0000000..4ccaa9b --- /dev/null +++ b/server/sandbox/convert.go @@ -0,0 +1,101 @@ +package sandbox + +import ( + "fmt" + + "go.starlark.net/starlark" +) + +// GoToStarlark converts a Go value (typically from JSON unmarshal) to a +// starlark.Value. Handles nil, bool, int, int64, float64, string, +// map[string]any, []any, and their interface{} equivalents. Unknown +// types are stringified. +func GoToStarlark(v any) starlark.Value { + switch val := v.(type) { + case nil: + return starlark.None + case bool: + return starlark.Bool(val) + case int: + return starlark.MakeInt(val) + case int64: + return starlark.MakeInt64(val) + case float64: + if val == float64(int64(val)) { + return starlark.MakeInt64(int64(val)) + } + return starlark.Float(val) + case string: + return starlark.String(val) + case map[string]any: + return MapToDict(val) + case []any: + elems := make([]starlark.Value, len(val)) + for i, e := range val { + elems[i] = GoToStarlark(e) + } + return starlark.NewList(elems) + default: + return starlark.String(fmt.Sprintf("%v", val)) + } +} + +// StarlarkToGo converts a starlark.Value to a Go value suitable for +// JSON serialization. Handles None, Bool, Int, Float, String, List, +// Dict, and Tuple. +func StarlarkToGo(v starlark.Value) any { + switch val := v.(type) { + case starlark.NoneType: + return nil + case starlark.Bool: + return bool(val) + case starlark.Int: + if i, ok := val.Int64(); ok { + return i + } + return val.String() + case starlark.Float: + return float64(val) + case starlark.String: + return string(val) + case *starlark.List: + result := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + result[i] = StarlarkToGo(val.Index(i)) + } + return result + case *starlark.Dict: + return DictToMap(val) + case starlark.Tuple: + result := make([]any, len(val)) + for i, e := range val { + result[i] = StarlarkToGo(e) + } + return result + default: + return v.String() + } +} + +// DictToMap converts a *starlark.Dict to map[string]any. +// Non-string keys are silently skipped. +func DictToMap(d *starlark.Dict) map[string]any { + result := make(map[string]any, d.Len()) + for _, item := range d.Items() { + k, ok := starlark.AsString(item[0]) + if !ok { + continue + } + result[k] = StarlarkToGo(item[1]) + } + return result +} + +// MapToDict converts a map[string]any to *starlark.Dict. +func MapToDict(m map[string]any) *starlark.Dict { + d := starlark.NewDict(len(m)) + for k, v := range m { + _ = d.SetKey(starlark.String(k), GoToStarlark(v)) + } + return d +} diff --git a/server/sandbox/convert_test.go b/server/sandbox/convert_test.go new file mode 100644 index 0000000..c360724 --- /dev/null +++ b/server/sandbox/convert_test.go @@ -0,0 +1,150 @@ +package sandbox + +import ( + "testing" + + "go.starlark.net/starlark" +) + +func TestGoToStarlark_Primitives(t *testing.T) { + tests := []struct { + name string + in any + want string // starlark.Value.String() + }{ + {"nil", nil, "None"}, + {"bool true", true, "True"}, + {"bool false", false, "False"}, + {"int", 42, "42"}, + {"int64", int64(99), "99"}, + {"float64 integer", float64(5), "5"}, + {"float64 fractional", 3.14, "3.14"}, + {"string", "hello", `"hello"`}, + {"unknown type", struct{}{}, `"{}"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GoToStarlark(tt.in) + if got.String() != tt.want { + t.Errorf("GoToStarlark(%v) = %s, want %s", tt.in, got.String(), tt.want) + } + }) + } +} + +func TestGoToStarlark_Containers(t *testing.T) { + // map[string]any + m := map[string]any{"a": 1.0, "b": "two"} + d := GoToStarlark(m) + if _, ok := d.(*starlark.Dict); !ok { + t.Fatalf("expected *starlark.Dict, got %T", d) + } + + // []any + s := []any{1.0, "x", true} + l := GoToStarlark(s) + if list, ok := l.(*starlark.List); !ok || list.Len() != 3 { + t.Fatalf("expected *starlark.List len 3, got %T", l) + } + +} + +func TestStarlarkToGo_Primitives(t *testing.T) { + tests := []struct { + name string + in starlark.Value + want any + }{ + {"None", starlark.None, nil}, + {"True", starlark.True, true}, + {"Int", starlark.MakeInt(7), int64(7)}, + {"Float", starlark.Float(2.5), 2.5}, + {"String", starlark.String("hi"), "hi"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StarlarkToGo(tt.in) + if got != tt.want { + t.Errorf("StarlarkToGo(%s) = %v (%T), want %v (%T)", tt.in, got, got, tt.want, tt.want) + } + }) + } +} + +func TestStarlarkToGo_Tuple(t *testing.T) { + tup := starlark.Tuple{starlark.MakeInt(1), starlark.String("a")} + got := StarlarkToGo(tup) + arr, ok := got.([]any) + if !ok || len(arr) != 2 { + t.Fatalf("expected []any len 2, got %T", got) + } + if arr[0] != int64(1) { + t.Errorf("arr[0] = %v, want 1", arr[0]) + } +} + +func TestRoundTrip(t *testing.T) { + original := map[string]any{ + "name": "test", + "count": float64(42), + "active": true, + "tags": []any{"a", "b"}, + "nested": map[string]any{ + "x": float64(1), + }, + } + + sv := GoToStarlark(original) + back := StarlarkToGo(sv) + m, ok := back.(map[string]any) + if !ok { + t.Fatalf("round-trip produced %T, want map[string]any", back) + } + + if m["name"] != "test" { + t.Errorf("name = %v", m["name"]) + } + if m["count"] != int64(42) { + t.Errorf("count = %v (%T)", m["count"], m["count"]) + } + if m["active"] != true { + t.Errorf("active = %v", m["active"]) + } + tags, ok := m["tags"].([]any) + if !ok || len(tags) != 2 { + t.Fatalf("tags = %v (%T)", m["tags"], m["tags"]) + } + nested, ok := m["nested"].(map[string]any) + if !ok { + t.Fatalf("nested = %T", m["nested"]) + } + if nested["x"] != int64(1) { + t.Errorf("nested.x = %v", nested["x"]) + } +} + +func TestDictToMap(t *testing.T) { + d := starlark.NewDict(2) + _ = d.SetKey(starlark.String("k"), starlark.String("v")) + _ = d.SetKey(starlark.MakeInt(1), starlark.String("skip")) // non-string key + + m := DictToMap(d) + if len(m) != 1 { + t.Errorf("expected 1 entry (non-string key skipped), got %d", len(m)) + } + if m["k"] != "v" { + t.Errorf("m[k] = %v", m["k"]) + } +} + +func TestMapToDict(t *testing.T) { + m := map[string]any{"a": "b", "n": float64(3)} + d := MapToDict(m) + if d.Len() != 2 { + t.Errorf("expected 2 entries, got %d", d.Len()) + } + v, found, _ := d.Get(starlark.String("a")) + if !found || v.(starlark.String) != "b" { + t.Errorf("dict[a] = %v", v) + } +} diff --git a/server/sandbox/files_module.go b/server/sandbox/files_module.go index 58960f4..06f73c4 100644 --- a/server/sandbox/files_module.go +++ b/server/sandbox/files_module.go @@ -164,7 +164,7 @@ func filesGet(ctx context.Context, cfg FilesModuleConfig) func(*starlark.Thread, metaRC.Close() var m map[string]any if json.Unmarshal(metaBytes, &m) == nil { - metaDict = mapToStarlarkDict(m) + metaDict = MapToDict(m) } } @@ -207,7 +207,7 @@ func filesMeta(ctx context.Context, cfg FilesModuleConfig) func(*starlark.Thread metaRC.Close() var m map[string]any if json.Unmarshal(metaBytes, &m) == nil { - metaDict = mapToStarlarkDict(m) + metaDict = MapToDict(m) } } @@ -396,77 +396,13 @@ func filesMaxSize() int64 { return filesDefaultMaxSize } -// starlarkDictToMap converts a *starlark.Dict to map[string]any. +// starlarkDictToMap converts a *starlark.Dict to map[string]any, validating +// that all keys are strings. Returns an error if a non-string key is found. func starlarkDictToMap(d *starlark.Dict) (map[string]any, error) { - m := make(map[string]any, d.Len()) for _, item := range d.Items() { - k, ok := starlark.AsString(item[0]) - if !ok { + if _, ok := starlark.AsString(item[0]); !ok { return nil, fmt.Errorf("metadata keys must be strings, got %s", item[0].Type()) } - m[k] = starlarkValueToGo(item[1]) - } - return m, nil -} - -// starlarkValueToGo converts a starlark.Value to a Go value for JSON serialization. -func starlarkValueToGo(v starlark.Value) any { - switch x := v.(type) { - case starlark.String: - return string(x) - case starlark.Int: - if i, ok := x.Int64(); ok { - return i - } - return x.String() - case starlark.Float: - return float64(x) - case starlark.Bool: - return bool(x) - case *starlark.List: - out := make([]any, x.Len()) - for i := 0; i < x.Len(); i++ { - out[i] = starlarkValueToGo(x.Index(i)) - } - return out - case *starlark.Dict: - m, _ := starlarkDictToMap(x) - return m - default: - return v.String() - } -} - -// mapToStarlarkDict converts a map[string]any to *starlark.Dict. -func mapToStarlarkDict(m map[string]any) *starlark.Dict { - d := starlark.NewDict(len(m)) - for k, v := range m { - d.SetKey(starlark.String(k), goValueToStarlark(v)) - } - return d -} - -// goValueToStarlark converts a Go value (from JSON) to starlark.Value. -func goValueToStarlark(v any) starlark.Value { - switch x := v.(type) { - case string: - return starlark.String(x) - case float64: - if x == float64(int64(x)) { - return starlark.MakeInt64(int64(x)) - } - return starlark.Float(x) - case bool: - return starlark.Bool(x) - case []any: - elems := make([]starlark.Value, len(x)) - for i, e := range x { - elems[i] = goValueToStarlark(e) - } - return starlark.NewList(elems) - case map[string]any: - return mapToStarlarkDict(x) - default: - return starlark.None } + return DictToMap(d), nil } diff --git a/server/sandbox/realtime_module.go b/server/sandbox/realtime_module.go index b32aa9c..d17086a 100644 --- a/server/sandbox/realtime_module.go +++ b/server/sandbox/realtime_module.go @@ -83,7 +83,7 @@ func realtimePublish(ctx context.Context, bus *events.Bus, packageID string) fun if k == "_pkg" { continue // reserved field } - payload[k] = starlarkToGoVal(item[1]) + payload[k] = StarlarkToGo(item[1]) } } @@ -108,46 +108,3 @@ func realtimePublish(ctx context.Context, bus *events.Bus, packageID string) fun } } -// starlarkToGoVal converts a Starlark value to a Go value for JSON marshaling. -func starlarkToGoVal(v starlark.Value) any { - switch val := v.(type) { - case starlark.NoneType: - return nil - case starlark.Bool: - return bool(val) - case starlark.Int: - if i, ok := val.Int64(); ok { - return i - } - return val.String() - case starlark.Float: - return float64(val) - case starlark.String: - return string(val) - case *starlark.Dict: - m := make(map[string]any, val.Len()) - for _, item := range val.Items() { - k, ok := starlark.AsString(item[0]) - if !ok { - k = item[0].String() - } - m[k] = starlarkToGoVal(item[1]) - } - return m - case *starlark.List: - n := val.Len() - s := make([]any, n) - for i := 0; i < n; i++ { - s[i] = starlarkToGoVal(val.Index(i)) - } - return s - case starlark.Tuple: - s := make([]any, len(val)) - for i, v := range val { - s[i] = starlarkToGoVal(v) - } - return s - default: - return v.String() - } -} diff --git a/server/sandbox/realtime_module_test.go b/server/sandbox/realtime_module_test.go index 6b40131..a87e061 100644 --- a/server/sandbox/realtime_module_test.go +++ b/server/sandbox/realtime_module_test.go @@ -133,7 +133,7 @@ realtime.publish("ch:1", "big.event", {"data": big}) } } -func TestStarlarkToGoVal(t *testing.T) { +func TestStarlarkToGo_Realtime(t *testing.T) { tests := []struct { name string val starlark.Value @@ -149,9 +149,9 @@ func TestStarlarkToGoVal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := starlarkToGoVal(tt.val) + got := StarlarkToGo(tt.val) if got != tt.want { - t.Errorf("starlarkToGoVal(%s) = %v (%T), want %v (%T)", tt.val, got, got, tt.want, tt.want) + t.Errorf("StarlarkToGo(%s) = %v (%T), want %v (%T)", tt.val, got, got, tt.want, tt.want) } }) } diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index c7b2ef4..c163166 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -112,7 +112,7 @@ func workflowGetInstance(ctx context.Context, stores store.Stores) func(*starlar if json.Unmarshal(inst.StageData, &dataMap) == nil { starlarkData := starlark.NewDict(len(dataMap)) for k, v := range dataMap { - starlarkData.SetKey(starlark.String(k), goValToStarlark(v)) + starlarkData.SetKey(starlark.String(k), GoToStarlark(v)) } d.SetKey(starlark.String("stage_data"), starlarkData) } else { @@ -155,36 +155,5 @@ func workflowListInstances(ctx context.Context, stores store.Stores) func(*starl } } -// goValToStarlark converts a Go value to a Starlark value. -func goValToStarlark(v interface{}) starlark.Value { - switch val := v.(type) { - case nil: - return starlark.None - case bool: - return starlark.Bool(val) - case float64: - if val == float64(int(val)) { - return starlark.MakeInt(int(val)) - } - return starlark.Float(val) - case string: - return starlark.String(val) - case map[string]interface{}: - d := starlark.NewDict(len(val)) - for k, v := range val { - d.SetKey(starlark.String(k), goValToStarlark(v)) - } - return d - case []interface{}: - elems := make([]starlark.Value, len(val)) - for i, v := range val { - elems[i] = goValToStarlark(v) - } - return starlark.NewList(elems) - default: - return starlark.String(fmt.Sprintf("%v", v)) - } -} - // workflowRoute routes the workflow to a named stage. // Starlark: workflow.route(instance_id, target_stage, reason) diff --git a/server/triggers/event.go b/server/triggers/event.go index 5ff0879..dc77ed0 100644 --- a/server/triggers/event.go +++ b/server/triggers/event.go @@ -3,7 +3,6 @@ package triggers import ( "context" "encoding/json" - "fmt" "log" "time" @@ -72,7 +71,7 @@ func (e *Engine) fireEventTrigger(triggerID, packageID, entryPoint string, ev ev if len(ev.Payload) > 0 { var payloadMap map[string]any if json.Unmarshal(ev.Payload, &payloadMap) == nil { - _ = ctxDict.SetKey(starlark.String("event_payload"), mapToStarlark(payloadMap)) + _ = ctxDict.SetKey(starlark.String("event_payload"), sandbox.MapToDict(payloadMap)) } else { _ = ctxDict.SetKey(starlark.String("event_payload"), starlark.String(string(ev.Payload))) } @@ -114,43 +113,6 @@ func (e *Engine) hasPermission(ctx context.Context, packageID, permission string return false } -// mapToStarlark converts a map[string]any to a Starlark dict. -func mapToStarlark(m map[string]any) *starlark.Dict { - d := starlark.NewDict(len(m)) - for k, v := range m { - _ = d.SetKey(starlark.String(k), goToStarlark(v)) - } - return d -} - -// goToStarlark converts a Go value to a Starlark value. -func goToStarlark(v any) starlark.Value { - switch val := v.(type) { - case nil: - return starlark.None - case bool: - return starlark.Bool(val) - case int: - return starlark.MakeInt(val) - case int64: - return starlark.MakeInt64(val) - case float64: - return starlark.Float(val) - case string: - return starlark.String(val) - case map[string]any: - return mapToStarlark(val) - case []any: - elems := make([]starlark.Value, len(val)) - for i, e := range val { - elems[i] = goToStarlark(e) - } - return starlark.NewList(elems) - default: - return starlark.String(fmt.Sprintf("%v", val)) - } -} - // RunContext builds a sandbox.RunContext for a trigger invocation. func triggerRunContext(userID, teamID string) *sandbox.RunContext { if userID == "" { diff --git a/server/triggers/webhook.go b/server/triggers/webhook.go index 2700ade..6add64a 100644 --- a/server/triggers/webhook.go +++ b/server/triggers/webhook.go @@ -13,6 +13,7 @@ import ( "go.starlark.net/starlark" "armature/models" + "armature/sandbox" ) // HandleWebhook is the Gin handler for inbound webhook triggers. @@ -152,7 +153,7 @@ func parseWebhookResponse(val starlark.Value) webhookResponse { } if bodyVal, found, _ := d.Get(starlark.String("body")); found { - resp.body = starlarkToGo(bodyVal) + resp.body = sandbox.StarlarkToGo(bodyVal) } else { resp.body = gin.H{"ok": true} } @@ -168,41 +169,6 @@ func parseWebhookResponse(val starlark.Value) webhookResponse { return webhookResponse{status: 200, body: gin.H{"ok": true}} } -// starlarkToGo converts a Starlark value to a Go value (for JSON serialization). -func starlarkToGo(v starlark.Value) any { - switch val := v.(type) { - case starlark.NoneType: - return nil - case starlark.Bool: - return bool(val) - case starlark.Int: - if i, ok := val.Int64(); ok { - return i - } - return val.String() - case starlark.Float: - return float64(val) - case starlark.String: - return string(val) - case *starlark.List: - result := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - result[i] = starlarkToGo(val.Index(i)) - } - return result - case *starlark.Dict: - result := make(map[string]any, val.Len()) - for _, item := range val.Items() { - if k, ok := item[0].(starlark.String); ok { - result[string(k)] = starlarkToGo(item[1]) - } - } - return result - default: - return v.String() - } -} - // verifyHMAC checks the HMAC-SHA256 signature of a webhook payload. func verifyHMAC(body []byte, secret, signature string) bool { if signature == "" { diff --git a/server/workflow/automated.go b/server/workflow/automated.go index 7dbe4b2..bbbf077 100644 --- a/server/workflow/automated.go +++ b/server/workflow/automated.go @@ -9,6 +9,7 @@ import ( "armature/events" "armature/models" + "armature/sandbox" "go.starlark.net/starlark" ) @@ -82,7 +83,7 @@ func (e *Engine) processAutomatedStage(ctx context.Context, inst *models.Workflo // Parse stage_data into Starlark dict var dataMap map[string]interface{} if json.Unmarshal(inst.StageData, &dataMap) == nil { - _ = ctxDict.SetKey(starlark.String("stage_data"), goToStarlark(dataMap)) + _ = ctxDict.SetKey(starlark.String("stage_data"), sandbox.GoToStarlark(dataMap)) } else { _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0)) } @@ -128,7 +129,7 @@ func (e *Engine) handleHookResult(ctx context.Context, inst *models.WorkflowInst var enrichedData json.RawMessage if dataVal, found, _ := d.Get(starlark.String("data")); found { if sd, ok := dataVal.(*starlark.Dict); ok { - goMap := starlarkDictToMap(sd) + goMap := sandbox.DictToMap(sd) if data, err := json.Marshal(goMap); err == nil { enrichedData = data } @@ -165,74 +166,3 @@ func parseHookRef(ref string) (string, string) { return ref, "on_run" } -// ── Starlark conversion helpers ───────────── - -func goToStarlark(v any) starlark.Value { - switch val := v.(type) { - case nil: - return starlark.None - case bool: - return starlark.Bool(val) - case float64: - if val == float64(int(val)) { - return starlark.MakeInt(int(val)) - } - return starlark.Float(val) - case string: - return starlark.String(val) - case map[string]interface{}: - d := starlark.NewDict(len(val)) - for k, v := range val { - _ = d.SetKey(starlark.String(k), goToStarlark(v)) - } - return d - case []interface{}: - elems := make([]starlark.Value, len(val)) - for i, v := range val { - elems[i] = goToStarlark(v) - } - return starlark.NewList(elems) - default: - return starlark.String(fmt.Sprintf("%v", v)) - } -} - -func starlarkDictToMap(d *starlark.Dict) map[string]any { - result := make(map[string]any, d.Len()) - for _, item := range d.Items() { - k, ok := item[0].(starlark.String) - if !ok { - continue - } - result[string(k)] = starlarkToGo(item[1]) - } - return result -} - -func starlarkToGo(v starlark.Value) any { - switch val := v.(type) { - case starlark.NoneType: - return nil - case starlark.Bool: - return bool(val) - case starlark.Int: - if i, ok := val.Int64(); ok { - return i - } - return val.String() - case starlark.Float: - return float64(val) - case starlark.String: - return string(val) - case *starlark.List: - result := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - result[i] = starlarkToGo(val.Index(i)) - } - return result - case *starlark.Dict: - return starlarkDictToMap(val) - default: - return v.String() - } -} diff --git a/server/workflow/engine.go b/server/workflow/engine.go index fa9d6ea..721d5ca 100644 --- a/server/workflow/engine.go +++ b/server/workflow/engine.go @@ -18,25 +18,6 @@ import ( // MaxConsecutiveAutomated is the cycle guard limit for automated stages. const MaxConsecutiveAutomated = 10 -// parseSnapshotStages handles both snapshot formats: -// - Wrapped: {"stages": [...], "workflow": {...}} (from Publish handler) -// - Legacy: [...] (from early tests) -func parseSnapshotStages(raw json.RawMessage) ([]models.WorkflowStage, error) { - // Try wrapped format first - var wrapped struct { - Stages []models.WorkflowStage `json:"stages"` - } - if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 { - return wrapped.Stages, nil - } - // Fallback to flat array - var stages []models.WorkflowStage - if err := json.Unmarshal(raw, &stages); err != nil { - return nil, fmt.Errorf("corrupt version snapshot: %w", err) - } - return stages, nil -} - // Engine orchestrates workflow instance lifecycle. type Engine struct { stores store.Stores @@ -64,7 +45,7 @@ func (e *Engine) Start(ctx context.Context, workflowID string, initialData json. return nil, fmt.Errorf("no published version: %w", err) } - stages, err := parseSnapshotStages(ver.Snapshot) + stages, err := models.ParseSnapshotStages(ver.Snapshot) if err != nil { return nil, err } @@ -142,7 +123,7 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa return nil, fmt.Errorf("version not found: %w", err) } - stages, err := parseSnapshotStages(ver.Snapshot) + stages, err := models.ParseSnapshotStages(ver.Snapshot) if err != nil { return nil, err } @@ -348,7 +329,7 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData if err != nil { return nil, fmt.Errorf("version not found: %w", err) } - stages, err := parseSnapshotStages(ver.Snapshot) + stages, err := models.ParseSnapshotStages(ver.Snapshot) if err != nil { return nil, err } @@ -383,7 +364,7 @@ func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision if err != nil { return nil, fmt.Errorf("version not found: %w", err) } - stages, err := parseSnapshotStages(ver.Snapshot) + stages, err := models.ParseSnapshotStages(ver.Snapshot) if err != nil { return nil, err } @@ -448,7 +429,7 @@ func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models if err != nil { return nil } - stages, parseErr := parseSnapshotStages(ver.Snapshot) + stages, parseErr := models.ParseSnapshotStages(ver.Snapshot) if parseErr != nil { return nil } diff --git a/server/workflow/scanner.go b/server/workflow/scanner.go index 3d73dd3..26d568e 100644 --- a/server/workflow/scanner.go +++ b/server/workflow/scanner.go @@ -91,7 +91,7 @@ func (sc *Scanner) runScan() { if err != nil { return nil } - stages, parseErr := parseSnapshotStages(v.Snapshot) + stages, parseErr := models.ParseSnapshotStages(v.Snapshot) if parseErr != nil { return nil }