Feat v0.9.5 typed forms SDK primitive

Extract typed form system from models/workflow.go into standalone
server/forms/ package. Any package can now declare and validate
forms, not just workflow stages.

- New server/forms/ package (types + validation + condition evaluator)
- REST POST /api/v1/forms/validate endpoint
- Starlark forms.validate(template, data) module
- Frontend sw.forms.render(), .validate(), .validateRemote()
- Manifest form_template accepted at package level
- 16 new tests (12 forms + 4 Starlark module)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 16:54:02 +00:00
parent 6b9ce92103
commit 89d64ab2aa
16 changed files with 1415 additions and 323 deletions

View File

@@ -0,0 +1,86 @@
package sandbox
// forms_module.go
//
// Starlark forms module — validates form data against typed form templates.
// Gated by the forms.validate extension permission.
//
// Starlark API:
// result = forms.validate(template, data)
// # result["valid"] → True/False
// # result["errors"] → [{"key": "...", "message": "..."}, ...]
import (
"context"
"encoding/json"
"fmt"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"armature/forms"
)
// BuildFormsModule creates the "forms" Starlark module.
// Requires the forms.validate permission.
func BuildFormsModule(_ context.Context) *starlarkstruct.Module {
return MakeModule("forms", starlark.StringDict{
"validate": starlark.NewBuiltin("forms.validate", formsValidateBuiltin()),
})
}
func formsValidateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var templateVal, dataVal starlark.Value
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &templateVal, &dataVal); err != nil {
return nil, err
}
// Convert template dict → Go map → JSON → TypedFormTemplate
templateDict, ok := templateVal.(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("forms.validate: template must be a dict, got %s", templateVal.Type())
}
templateMap := DictToMap(templateDict)
templateJSON, err := json.Marshal(templateMap)
if err != nil {
return nil, fmt.Errorf("forms.validate: failed to marshal template: %w", err)
}
tpl := forms.ParseTypedFormTemplate(json.RawMessage(templateJSON))
if tpl == nil {
return nil, fmt.Errorf("forms.validate: template is invalid or empty")
}
// Convert data dict → Go map
dataDict, ok := dataVal.(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("forms.validate: data must be a dict, got %s", dataVal.Type())
}
dataMap := DictToMap(dataDict)
// Coerce to map[string]interface{}
data := make(map[string]interface{}, len(dataMap))
for k, v := range dataMap {
data[k] = v
}
// Validate
errs := forms.ValidateFormData(tpl, data)
// Build result dict
errList := make([]starlark.Value, len(errs))
for i, e := range errs {
d := starlark.NewDict(2)
_ = d.SetKey(starlark.String("key"), starlark.String(e.Key))
_ = d.SetKey(starlark.String("message"), starlark.String(e.Message))
errList[i] = d
}
result := starlark.NewDict(2)
_ = result.SetKey(starlark.String("valid"), starlark.Bool(len(errs) == 0))
_ = result.SetKey(starlark.String("errors"), starlark.NewList(errList))
return result, nil
}
}

View File

@@ -0,0 +1,116 @@
package sandbox
import (
"context"
"testing"
"go.starlark.net/starlark"
)
func runFormsScript(t *testing.T, script string) starlark.StringDict {
t.Helper()
mod := BuildFormsModule(context.Background())
predeclared := starlark.StringDict{"forms": mod}
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
if err != nil {
t.Fatal(err)
}
return globals
}
func TestFormsModule_ValidateValid(t *testing.T) {
globals := runFormsScript(t, `
tpl = {
"fields": [
{"key": "name", "type": "text", "label": "Name", "required": True},
{"key": "email", "type": "email", "label": "Email"},
]
}
result = forms.validate(tpl, {"name": "Alice", "email": "alice@example.com"})
`)
result := globals["result"].(*starlark.Dict)
valid, _, _ := result.Get(starlark.String("valid"))
if valid != starlark.True {
t.Fatalf("expected valid=True, got %v", valid)
}
errors, _, _ := result.Get(starlark.String("errors"))
errList := errors.(*starlark.List)
if errList.Len() != 0 {
t.Fatalf("expected 0 errors, got %d", errList.Len())
}
}
func TestFormsModule_ValidateRequired(t *testing.T) {
globals := runFormsScript(t, `
tpl = {
"fields": [
{"key": "name", "type": "text", "label": "Name", "required": True},
]
}
result = forms.validate(tpl, {})
`)
result := globals["result"].(*starlark.Dict)
valid, _, _ := result.Get(starlark.String("valid"))
if valid != starlark.False {
t.Fatalf("expected valid=False, got %v", valid)
}
errors, _, _ := result.Get(starlark.String("errors"))
errList := errors.(*starlark.List)
if errList.Len() != 1 {
t.Fatalf("expected 1 error, got %d", errList.Len())
}
errDict := errList.Index(0).(*starlark.Dict)
key, _, _ := errDict.Get(starlark.String("key"))
if key.(starlark.String).GoString() != "name" {
t.Fatalf("expected error key 'name', got %v", key)
}
}
func TestFormsModule_ValidateTypeErrors(t *testing.T) {
globals := runFormsScript(t, `
tpl = {
"fields": [
{"key": "age", "type": "number", "label": "Age", "validation": {"min": 0, "max": 150}},
{"key": "email", "type": "email", "label": "Email"},
]
}
result = forms.validate(tpl, {"age": -5, "email": "bad"})
`)
result := globals["result"].(*starlark.Dict)
valid, _, _ := result.Get(starlark.String("valid"))
if valid != starlark.False {
t.Fatalf("expected valid=False, got %v", valid)
}
errors, _, _ := result.Get(starlark.String("errors"))
errList := errors.(*starlark.List)
if errList.Len() != 2 {
t.Fatalf("expected 2 errors, got %d", errList.Len())
}
}
func TestFormsModule_ValidateCondition(t *testing.T) {
globals := runFormsScript(t, `
tpl = {
"fields": [
{"key": "type", "type": "text", "label": "Type"},
{"key": "details", "type": "text", "label": "Details", "required": True,
"condition": {"when": "type", "op": "eq", "value": "other"}},
]
}
# Condition not met — details not required
result1 = forms.validate(tpl, {"type": "normal"})
# Condition met — details required
result2 = forms.validate(tpl, {"type": "other"})
`)
result1 := globals["result1"].(*starlark.Dict)
valid1, _, _ := result1.Get(starlark.String("valid"))
if valid1 != starlark.True {
t.Fatalf("result1: expected valid=True, got %v", valid1)
}
result2 := globals["result2"].(*starlark.Dict)
valid2, _, _ := result2.Get(starlark.String("valid"))
if valid2 != starlark.False {
t.Fatalf("result2: expected valid=False, got %v", valid2)
}
}

View File

@@ -417,6 +417,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
})
}
case models.ExtPermFormValidate:
modules["forms"] = BuildFormsModule(ctx)
case models.ExtPermBatchExec:
hasBatchExec = true
}