Implementation plan to port the panel-logic declared-local-variable feature (scalar + array, sizing policies) to the server-side control-logic engine and its editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
64 KiB
Control-Logic Array + Scalar Local Variables Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the declared-local-variable feature (scalar AND array, with sizing
policies) from the client-side panel-logic engine to the server-side control-logic
engine (internal/controllogic/) and its editor (ControlLogicEditor.tsx).
Architecture: The control-logic engine is currently float64-only: expressions
evaluate to float64, locals are an implicit map[string]float64, and there is no
declaration UI. This plan introduces a value union Value = float64 | []Value mirroring
the frontend's ArrVal, makes the expression evaluator value-polymorphic (array literals
[a,b], postfix indexing arr[i], array functions), adds Graph.StateVars []StateVar
declarations that initialise the locals map (with sizing policies), adds action.array.*
nodes, and adds a LocalVars declaration UI (reused from LogicEditor.tsx). Lua and the
debug overlay are adapted to carry array values without breaking their scalar paths.
Tech Stack: Go 1.22+ (internal/controllogic), Preact 10 + TypeScript
(ControlLogicEditor.tsx), esbuild bundling (no npm). The frontend web/src/lib/expr.ts
and web/src/lib/arraypolicy.ts are the canonical port templates — the Go code must
match their semantics exactly.
Global Constraints
- Value model:
Value=float64(leaf, booleans as 1/0) OR[]Value(array). Implemented in Go astype Value = anywith leavesfloat64and arrays[]Value. - Sizing policies (match
arraypolicy.tsexactly):dynamic→ cap atARRAY_MAX = 1_000_000dropping oldest;capped→ keep ≤capacitydropping oldest (FIFO/ring);fixed→ exactlycapacity(truncate / zero-pad), never grow/shrink. - Negative indices resolve relative to length (
idx(i, len)); out-of-range is an error caught bysafeEval→ NaN (scalar) or skipped (array node). - No npm / Node. Frontend builds via
make frontend(esbuild, strips types, NO typecheck). Typecheck on demand:cd web && npx tsc --noEmit -p tsconfig.json. - Baseline tsc noise to ALWAYS ignore: TS2604 Fragment, TS2322 'key'/RowProps
(TableWidget.tsx:210), TS7044 implicit-any 'e'.
<new-diagnostics>reminders are stale mid-edit snapshots — verify with filtered tsc/grep. - Do NOT
git add web/dist(gitignored). Commit source only. - Control-logic graphs persist as JSON (
internal/controllogic/store.go,json.MarshalIndent). A newStateVarsfield with ajson:"statevars,omitempty"tag round-trips automatically. - Scope note: Control logic gets
action.array.push/set/remove/pop/clearbut NOTaction.export(CSV download is browser-only; the server engine has no browser). Lua remains scalar-only (reading an array local from Lua yields NaN). - Verify clean at the end:
make frontend,make backend,go build ./...,go vet ./...,go test ./... -race,gofmt -l internal/.
Task 1: Go value model + sizing helpers (internal/controllogic/value.go)
Port web/src/lib/arraypolicy.ts plus the asNum/asArr/idx narrowing from
web/src/lib/expr.ts into a new Go file. This is pure, dependency-free, and fully
unit-testable in isolation — no engine wiring yet.
Files:
- Create:
internal/controllogic/value.go - Test:
internal/controllogic/value_test.go
Interfaces:
-
Produces (consumed by Tasks 3, 4, 5):
type Value = any— leaf isfloat64, array is[]Value.const ARRAY_MAX = 1_000_000type StateVar struct { Name, Type, Initial, Unit, Elem, Sizing string; Low, High float64; Capacity int }with JSON tags.func asNum(v Value) (float64, error)—float64leaf, else error.func asArr(v Value) ([]Value, error)—[]Value, else error.func normalizeValue(v any) Value— coerce JSON-decoded[]interface{}/float64/bool/intinto canonical (float64leaves,[]Valuearrays).func idxResolve(i float64, length int) (int, error)— negative-relative, range-checked.func parseInitialArray(sv StateVar) []Value— initial contents (mirrors arraypolicy).func applySizing(arr []Value, sv StateVar) []Value— clamp to policy.
-
Step 1: Write the failing test —
internal/controllogic/value_test.go
package controllogic
import (
"reflect"
"testing"
)
func TestAsNumAsArr(t *testing.T) {
if n, err := asNum(3.0); err != nil || n != 3 {
t.Fatalf("asNum(3)=%v,%v", n, err)
}
if _, err := asNum([]Value{1.0}); err == nil {
t.Fatal("asNum(array) should error")
}
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
t.Fatalf("asArr=%v,%v", a, err)
}
if _, err := asArr(3.0); err == nil {
t.Fatal("asArr(number) should error")
}
}
func TestIdxResolve(t *testing.T) {
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
t.Fatalf("idx(-1,3)=%v,%v", k, err)
}
if _, err := idxResolve(3, 3); err == nil {
t.Fatal("idx(3,3) should be out of range")
}
}
func TestNormalizeValue(t *testing.T) {
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
want := []Value{1.0, 1.0, []Value{2.0}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize=%#v want %#v", got, want)
}
if normalizeValue(5) != Value(5.0) {
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
}
}
func TestParseInitialArray(t *testing.T) {
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
t.Fatalf("fixed init = %#v", fixed)
}
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
t.Fatalf("dynamic init = %#v", dyn)
}
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
if len(empty) != 0 {
t.Fatalf("empty init = %#v", empty)
}
}
func TestApplySizing(t *testing.T) {
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
t.Fatalf("capped = %#v", capped)
}
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
t.Fatalf("fixed = %#v", fixed)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run 'TestAsNum|TestIdx|TestNormalize|TestParseInitial|TestApplySizing'
Expected: FAIL (undefined: asNum, asArr, idxResolve, normalizeValue, StateVar, Value, parseInitialArray, applySizing)
- Step 3: Write minimal implementation —
internal/controllogic/value.go
// Value model for control-logic locals/expressions. A Value is either a scalar
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
// web/src/lib/expr.ts. Pure, dependency-free.
package controllogic
import (
"encoding/json"
"fmt"
"strings"
)
// Value is a scalar (float64) or an array ([]Value).
type Value = any
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
const ARRAY_MAX = 1_000_000
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
type StateVar struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
Initial string `json:"initial"` // initial value, stored as a string
Unit string `json:"unit,omitempty"`
Low float64 `json:"low,omitempty"`
High float64 `json:"high,omitempty"`
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
Capacity int `json:"capacity,omitempty"` // array-only
}
func asNum(v Value) (float64, error) {
f, ok := v.(float64)
if !ok {
return 0, fmt.Errorf("expected a number, got an array")
}
return f, nil
}
func asArr(v Value) ([]Value, error) {
a, ok := v.([]Value)
if !ok {
return nil, fmt.Errorf("expected an array, got a number")
}
return a, nil
}
// idxResolve resolves a possibly-negative index against length; range-checked.
func idxResolve(i float64, length int) (int, error) {
k := int(i) // truncates toward zero, matching Math.trunc
if k < 0 {
k = length + k
}
if k < 0 || k >= length {
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
}
return k, nil
}
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
func normalizeValue(v any) Value {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case bool:
if t {
return 1.0
}
return 0.0
case []interface{}:
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
}
return out
case []Value:
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
}
return out
default:
return 0.0
}
}
func zeroFill(n int) []Value {
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = 0.0
}
return out
}
// parseInitialArray returns the starting contents of an array local. Mirrors
// arraypolicy.ts parseInitialArray.
func parseInitialArray(sv StateVar) []Value {
cap := sv.Capacity
raw := strings.TrimSpace(sv.Initial)
var parsed []Value
if raw != "" {
var j interface{}
if err := json.Unmarshal([]byte(raw), &j); err == nil {
if arr, ok := j.([]interface{}); ok {
parsed = normalizeValue(arr).([]Value)
}
}
}
if sv.Sizing == "fixed" {
if parsed == nil {
return zeroFill(cap)
}
out := make([]Value, 0, cap)
for i := 0; i < len(parsed) && i < cap; i++ {
out = append(out, parsed[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
}
if parsed == nil {
return []Value{}
}
return parsed
}
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
func applySizing(arr []Value, sv StateVar) []Value {
cap := sv.Capacity
switch sv.Sizing {
case "fixed":
out := make([]Value, 0, cap)
for i := 0; i < len(arr) && i < cap; i++ {
out = append(out, arr[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
case "capped":
if len(arr) > cap {
return arr[len(arr)-cap:]
}
return arr
default:
if len(arr) > ARRAY_MAX {
return arr[len(arr)-ARRAY_MAX:]
}
return arr
}
}
- Step 4: Run test to verify it passes
Run: go test ./internal/controllogic/ -run 'TestAsNum|TestIdx|TestNormalize|TestParseInitial|TestApplySizing'
Expected: PASS
- Step 5: Commit
git add internal/controllogic/value.go internal/controllogic/value_test.go
git commit -m "controllogic: add Value union + sizing helpers (port of arraypolicy.ts)"
Task 2: Graph.StateVars + store round-trip
Add the declarations field to the graph model and confirm it survives JSON persistence.
store.go serialises with json.MarshalIndent and unmarshals []Graph, so the new
field round-trips with no store changes — this task adds the field and a guard test.
Files:
- Modify:
internal/controllogic/model.go(Graph struct) - Test:
internal/controllogic/store_test.go(add a test; create the file if absent)
Interfaces:
-
Consumes:
StateVar(Task 1). -
Produces:
Graph.StateVars []StateVar(consumed by Task 4 for locals init, Task 7 for the editor). -
Step 1: Write the failing test
Add to internal/controllogic/store_test.go (create the file with this package header if it does not exist):
package controllogic
import "testing"
func TestStoreRoundTripStateVars(t *testing.T) {
dir := t.TempDir()
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
if err != nil {
t.Fatal(err)
}
g := Graph{
ID: "g1",
Name: "with-vars",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "0"},
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
},
}
if err := st.Save(g); err != nil { // Save returns only error
t.Fatal(err)
}
st2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
if err != nil {
t.Fatal(err)
}
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
}
}
The store API is confirmed (internal/controllogic/store.go): NewStore(storageDir string) (*Store, error), Save(g Graph) error, Get(id string) (Graph, error) (returns
ErrNotFound). The new StateVars field round-trips via the existing json.MarshalIndent
in saveLocked with no store changes.
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run TestStoreRoundTripStateVars
Expected: FAIL — Graph has no field StateVars.
- Step 3: Write minimal implementation
In internal/controllogic/model.go, add to the Graph struct (after Groups):
// StateVars declares graph-local variables (scalar or array). Live values are
// instantiated in memory per generation from these declarations; only the
// declarations persist. Mirrors the panel-logic statevars feature.
StateVars []StateVar `json:"statevars,omitempty"`
- Step 4: Run test to verify it passes
Run: go test ./internal/controllogic/ -run TestStoreRoundTripStateVars
Expected: PASS
- Step 5: Commit
git add internal/controllogic/model.go internal/controllogic/store_test.go
git commit -m "controllogic: add Graph.StateVars declarations (persisted via store JSON)"
Task 3: Make expr.go value-polymorphic
Rewrite internal/controllogic/expr.go to evaluate to Value (port of expr.ts): array
literals [a,b], postfix indexing arr[i], array functions, scalar/array function split,
min/max dual dispatch. EvalExpr/EvalBool keep their float64/bool returns
(scalar callers unaffected); a new EvalValue returns the full Value. The Resolver
type changes from func(...) float64 to func(...) Value — this ripples into Task 4's
resolver closures and any test resolvers.
Files:
- Rewrite:
internal/controllogic/expr.go - Test:
internal/controllogic/expr_test.go(create or extend)
Interfaces:
-
Consumes:
Value,asNum,asArr,idxResolve(Task 1). -
Produces (consumed by Tasks 4, 5):
type Resolver func(ds, name string) Valuefunc EvalValue(src string, resolve Resolver) Valuefunc EvalExpr(src string, resolve Resolver) float64(NaN if parse fails OR result is an array)func EvalBool(src string, resolve Resolver) boolfunc CollectRefs(src string) []RefLite(now also walksarr+indexnodes)func CheckExpr(src string) string
-
Step 1: Write the failing test —
internal/controllogic/expr_test.go
package controllogic
import (
"math"
"reflect"
"testing"
)
func numResolver(vals map[string]Value) Resolver {
return func(ds, name string) Value {
if v, ok := vals[ds+":"+name]; ok {
return v
}
return math.NaN()
}
}
func TestEvalValueScalar(t *testing.T) {
R := numResolver(nil)
if got := EvalExpr("2 + 3 * 4", R); got != 14 {
t.Fatalf("scalar = %v", got)
}
if !EvalBool("1 < 2 && 3 >= 3", R) {
t.Fatal("bool expr should be true")
}
}
func TestEvalValueArrayLiteralAndIndex(t *testing.T) {
R := numResolver(nil)
got := EvalValue("[1, 2, 3]", R)
if !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("array literal = %#v", got)
}
if v := EvalExpr("[10,20,30][-1]", R); v != 30 {
t.Fatalf("index -1 = %v", v)
}
}
func TestEvalArrayFuncs(t *testing.T) {
R := numResolver(map[string]Value{"local:buf": []Value{3.0, 1.0, 2.0}})
if v := EvalExpr("len(buf)", R); v != 3 {
t.Fatalf("len = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 6 {
t.Fatalf("sum = %v", v)
}
if v := EvalExpr("max(buf)", R); v != 3 {
t.Fatalf("max(array) = %v", v)
}
if v := EvalExpr("max(1, 9, 4)", R); v != 9 {
t.Fatalf("max(scalars) = %v", v)
}
got := EvalValue("push(buf, 7)", R)
if !reflect.DeepEqual(got, []Value{3.0, 1.0, 2.0, 7.0}) {
t.Fatalf("push = %#v", got)
}
}
func TestEvalExprArrayYieldsNaN(t *testing.T) {
if v := EvalExpr("[1,2]", numResolver(nil)); !math.IsNaN(v) {
t.Fatalf("array via EvalExpr should be NaN, got %v", v)
}
}
func TestCollectRefsArray(t *testing.T) {
refs := CollectRefs("[{ds:a}, b[0]] ")
keys := map[string]bool{}
for _, r := range refs {
keys[r.DS+":"+r.Name] = true
}
if !keys["ds:a"] || !keys["local:b"] {
t.Fatalf("refs = %#v", refs)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run 'TestEval|TestCollectRefsArray'
Expected: FAIL (undefined EvalValue; Resolver returns float64 so numResolver won't compile; no array support).
- Step 3: Write minimal implementation — replace the entire contents of
internal/controllogic/expr.go
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
//
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]), and a set
// of math + array functions. Two kinds of variable reference are resolved live:
//
// {ds:name} a data-source signal value (brace content split on FIRST ':').
// bareIdent a graph-local state variable (data source "local").
//
// Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The
// evaluator never uses reflection or eval; it walks a parsed AST against a
// caller-supplied Resolver.
package controllogic
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
)
// Resolver returns the current value of a signal/local reference.
type Resolver func(ds, name string) Value
// RefLite identifies one signal/local reference read by an expression.
type RefLite struct {
DS string
Name string
}
// ── AST ──────────────────────────────────────────────────────────────────────
type exprNode interface{ eval(R Resolver) Value }
type numNode struct{ v float64 }
type sigNode struct{ ds, name string }
type varNode struct{ name string }
type arrNode struct{ items []exprNode }
type indexNode struct{ a, i exprNode }
type unNode struct {
op string
a exprNode
}
type binNode struct {
op string
a, b exprNode
}
type ternNode struct{ c, a, b exprNode }
type callNode struct {
fn string
args []exprNode
}
func mustNum(v Value) float64 {
f, err := asNum(v)
if err != nil {
panic(err)
}
return f
}
func mustArr(v Value) []Value {
a, err := asArr(v)
if err != nil {
panic(err)
}
return a
}
func (n numNode) eval(R Resolver) Value { return n.v }
func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) Value { return R("local", n.name) }
func (n arrNode) eval(R Resolver) Value {
out := make([]Value, len(n.items))
for i, it := range n.items {
out[i] = it.eval(R)
}
return out
}
func (n indexNode) eval(R Resolver) Value {
arr := mustArr(n.a.eval(R))
k, err := idxResolve(mustNum(n.i.eval(R)), len(arr))
if err != nil {
panic(err)
}
return arr[k]
}
func (n unNode) eval(R Resolver) Value {
if n.op == "-" {
return -mustNum(n.a.eval(R))
}
if mustNum(n.a.eval(R)) == 0 {
return 1.0
}
return 0.0
}
func (n ternNode) eval(R Resolver) Value {
if mustNum(n.c.eval(R)) != 0 {
return n.a.eval(R)
}
return n.b.eval(R)
}
func (n callNode) eval(R Resolver) Value {
args := make([]Value, len(n.args))
for i, a := range n.args {
args[i] = a.eval(R)
}
// min/max: scalar-variadic OR single-array form.
if n.fn == "min" || n.fn == "max" {
if len(args) == 1 {
if arr, ok := args[0].([]Value); ok {
return reduceMinMax(n.fn, arr)
}
}
nums := make([]Value, len(args))
copy(nums, args)
return reduceMinMax(n.fn, nums)
}
if af, ok := arrFuncs[n.fn]; ok {
return af(args)
}
if sf, ok := scalarFuncs[n.fn]; ok {
nums := make([]float64, len(args))
for i, a := range args {
nums[i] = mustNum(a)
}
return sf(nums)
}
panic(fmt.Errorf("unknown function %q", n.fn))
}
func (n binNode) eval(R Resolver) Value {
a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R))
switch n.op {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
case "%":
return math.Mod(a, b)
case "<":
return boolf(a < b)
case "<=":
return boolf(a <= b)
case ">":
return boolf(a > b)
case ">=":
return boolf(a >= b)
case "==":
return boolf(a == b)
case "!=":
return boolf(a != b)
case "&&":
return boolf(a != 0 && b != 0)
case "||":
return boolf(a != 0 || b != 0)
}
panic(fmt.Errorf("unknown operator %q", n.op))
}
func boolf(b bool) float64 {
if b {
return 1
}
return 0
}
func reduceMinMax(fn string, arr []Value) Value {
if len(arr) == 0 {
if fn == "min" {
return math.Inf(1)
}
return math.Inf(-1)
}
m := mustNum(arr[0])
for _, x := range arr[1:] {
v := mustNum(x)
if fn == "min" {
m = math.Min(m, v)
} else {
m = math.Max(m, v)
}
}
return m
}
// ── Functions ────────────────────────────────────────────────────────────────
var scalarFuncs = map[string]func([]float64) float64{
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
"round": func(a []float64) float64 { return math.Round(a[0]) },
"sign": func(a []float64) float64 { return float64(signOf(a[0])) },
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
"log": func(a []float64) float64 { return math.Log(a[0]) },
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
}
var arrFuncs = map[string]func([]Value) Value{
"len": func(a []Value) Value { return float64(len(mustArr(a[0]))) },
"sum": func(a []Value) Value {
s := 0.0
for _, x := range mustArr(a[0]) {
s += mustNum(x)
}
return s
},
"mean": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return 0.0
}
s := 0.0
for _, x := range r {
s += mustNum(x)
}
return s / float64(len(r))
},
"slice": func(a []Value) Value {
r := mustArr(a[0])
s := 0
e := len(r)
if len(a) > 1 {
s = clampIdx(int(mustNum(a[1])), len(r))
}
if len(a) > 2 {
e = clampIdx(int(mustNum(a[2])), len(r))
}
if s > e {
s = e
}
out := make([]Value, 0, e-s)
out = append(out, r[s:e]...)
return out
},
"concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) },
"reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r },
"sort": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
sortNum(r)
return r
},
"scale": func(a []Value) Value {
r := mustArr(a[0])
k := mustNum(a[1])
out := make([]Value, len(r))
for i, x := range r {
out[i] = mustNum(x) * k
}
return out
},
"add": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y }) },
"sub": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y }) },
"push": func(a []Value) Value {
return append(append([]Value{}, mustArr(a[0])...), a[1])
},
"set": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
r[k] = a[2]
return r
},
"insert": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k := int(mustNum(a[1]))
if k < 0 {
k = 0
}
if k > len(r) {
k = len(r)
}
r = append(r, nil)
copy(r[k+1:], r[k:])
r[k] = a[2]
return r
},
"remove": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
return append(r[:k], r[k+1:]...)
},
"pop": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[:len(r)-1]...)
},
"shift": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[1:]...)
},
"indexOf": func(a []Value) Value {
r := mustArr(a[0])
for i, x := range r {
if valEq(x, a[1]) {
return float64(i)
}
}
return -1.0
},
"contains": func(a []Value) Value {
r := mustArr(a[0])
for _, x := range r {
if valEq(x, a[1]) {
return 1.0
}
}
return 0.0
},
"fill": func(a []Value) Value {
n := int(mustNum(a[0]))
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = a[1]
}
return out
},
}
func signOf(x float64) int {
switch {
case x > 0:
return 1
case x < 0:
return -1
default:
return 0
}
}
func clampIdx(i, length int) int {
if i < 0 {
i = length + i
}
if i < 0 {
i = 0
}
if i > length {
i = length
}
return i
}
func reverse(r []Value) {
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
}
func sortNum(r []Value) {
for i := 1; i < len(r); i++ {
for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- {
r[j-1], r[j] = r[j], r[j-1]
}
}
}
func zipNum(x, y []Value, f func(a, b float64) float64) []Value {
n := len(x)
if len(y) < n {
n = len(y)
}
out := make([]Value, 0, n)
for i := 0; i < n; i++ {
out = append(out, f(mustNum(x[i]), mustNum(y[i])))
}
return out
}
func valEq(a, b Value) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
return aok && bok && af == bf
}
// ── Tokenizer ────────────────────────────────────────────────────────────────
type tok struct {
k string
v string
}
func tokenize(src string) ([]tok, error) {
var toks []tok
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
continue
case c == '{':
end := -1
for j := i + 1; j < len(r); j++ {
if r[j] == '}' {
end = j
break
}
}
if end < 0 {
return nil, fmt.Errorf("unterminated { in expression")
}
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
i = end + 1
continue
}
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
j := i + 1
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
j++
}
toks = append(toks, tok{k: "num", v: string(r[i:j])})
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(r) && isIdentPart(r[j]) {
j++
}
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
i = j
continue
}
if i+1 < len(r) {
pair := string(r[i : i+2])
if two[pair] {
toks = append(toks, tok{k: pair})
i += 2
continue
}
}
if strings.ContainsRune("+-*/%<>!()?:,[]", c) {
toks = append(toks, tok{k: string(c)})
i++
continue
}
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
}
return toks, nil
}
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
// ── Parser (recursive descent) ────────────────────────────────────────────────
type parser struct {
toks []tok
p int
}
func (ps *parser) peek() (tok, bool) {
if ps.p < len(ps.toks) {
return ps.toks[ps.p], true
}
return tok{}, false
}
func (ps *parser) eat(k string) (tok, error) {
if ps.p >= len(ps.toks) {
return tok{}, fmt.Errorf("unexpected end of expression")
}
t := ps.toks[ps.p]
if k != "" && t.k != k {
return tok{}, fmt.Errorf("expected %q in expression", k)
}
ps.p++
return t, nil
}
func parse(src string) (exprNode, error) {
toks, err := tokenize(src)
if err != nil {
return nil, err
}
ps := &parser{toks: toks}
root, err := ps.ternary()
if err != nil {
return nil, err
}
if ps.p < len(ps.toks) {
return nil, fmt.Errorf("trailing tokens in expression")
}
return root, nil
}
func (ps *parser) atom() (exprNode, error) {
t, ok := ps.peek()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.k {
case "num":
ps.eat("")
v, err := strconv.ParseFloat(t.v, 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", t.v)
}
return numNode{v: v}, nil
case "[":
ps.eat("[")
var items []exprNode
if nx, ok := ps.peek(); ok && nx.k != "]" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
for {
nx2, ok := ps.peek()
if !ok || nx2.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
}
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
return arrNode{items: items}, nil
case "sig":
ps.eat("")
idx := strings.IndexByte(t.v, ':')
if idx < 0 {
return sigNode{ds: t.v, name: ""}, nil
}
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
case "ident":
ps.eat("")
id := t.v
if id == "true" {
return numNode{v: 1}, nil
}
if id == "false" {
return numNode{v: 0}, nil
}
if nx, ok := ps.peek(); ok && nx.k == "(" {
ps.eat("(")
var args []exprNode
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
for {
nx3, ok := ps.peek()
if !ok || nx3.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
}
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
if !knownFunc(id) {
return nil, fmt.Errorf("unknown function %q", id)
}
return callNode{fn: id, args: args}, nil
}
return varNode{name: id}, nil
case "(":
ps.eat("(")
e, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
return e, nil
}
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
}
func knownFunc(id string) bool {
if id == "min" || id == "max" {
return true
}
if _, ok := arrFuncs[id]; ok {
return true
}
_, ok := scalarFuncs[id]
return ok
}
func (ps *parser) primary() (exprNode, error) {
n, err := ps.atom()
if err != nil {
return nil, err
}
for {
nx, ok := ps.peek()
if !ok || nx.k != "[" {
return n, nil
}
ps.eat("[")
i, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
n = indexNode{a: n, i: i}
}
}
func (ps *parser) unary() (exprNode, error) {
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
ps.eat("")
a, err := ps.unary()
if err != nil {
return nil, err
}
return unNode{op: t.k, a: a}, nil
}
return ps.primary()
}
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
a, err := next()
if err != nil {
return nil, err
}
for {
t, ok := ps.peek()
if !ok || !contains(ops, t.k) {
return a, nil
}
op, _ := ps.eat("")
b, err := next()
if err != nil {
return nil, err
}
a = binNode{op: op.k, a: a, b: b}
}
}
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
func (ps *parser) ternary() (exprNode, error) {
c, err := ps.or()
if err != nil {
return nil, err
}
if t, ok := ps.peek(); ok && t.k == "?" {
ps.eat("?")
a, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(":"); err != nil {
return nil, err
}
b, err := ps.ternary()
if err != nil {
return nil, err
}
return ternNode{c: c, a: a, b: b}, nil
}
return c, nil
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── Cache + public API ─────────────────────────────────────────────────────────
type cacheEntry struct {
node exprNode
err error
}
var (
cacheMu sync.Mutex
cache = map[string]cacheEntry{}
)
func parseCached(src string) (exprNode, error) {
cacheMu.Lock()
e, ok := cache[src]
cacheMu.Unlock()
if ok {
return e.node, e.err
}
n, err := parse(src)
cacheMu.Lock()
cache[src] = cacheEntry{node: n, err: err}
cacheMu.Unlock()
return n, err
}
// EvalValue evaluates an expression, returning the full Value (number or array).
// Returns NaN on parse/eval failure.
func EvalValue(src string, resolve Resolver) Value {
n, err := parseCached(src)
if err != nil {
return math.NaN()
}
return safeEval(n, resolve)
}
func safeEval(n exprNode, resolve Resolver) (out Value) {
defer func() {
if recover() != nil {
out = math.NaN()
}
}()
return n.eval(resolve)
}
// EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval
// failure OR when the result is an array.
func EvalExpr(src string, resolve Resolver) float64 {
v := EvalValue(src, resolve)
if f, ok := v.(float64); ok {
return f
}
return math.NaN()
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar.
func EvalBool(src string, resolve Resolver) bool {
v := EvalExpr(src, resolve)
return !math.IsNaN(v) && v != 0
}
// CollectRefs returns every signal/local reference an expression reads.
func CollectRefs(src string) []RefLite {
root, err := parseCached(src)
if err != nil {
return nil
}
var out []RefLite
seen := map[string]bool{}
add := func(ds, name string) {
k := ds + "\x00" + name
if !seen[k] {
seen[k] = true
out = append(out, RefLite{DS: ds, Name: name})
}
}
var walk func(n exprNode)
walk = func(n exprNode) {
switch t := n.(type) {
case sigNode:
add(t.ds, t.name)
case varNode:
add("local", t.name)
case arrNode:
for _, it := range t.items {
walk(it)
}
case indexNode:
walk(t.a)
walk(t.i)
case unNode:
walk(t.a)
case binNode:
walk(t.a)
walk(t.b)
case ternNode:
walk(t.c)
walk(t.a)
walk(t.b)
case callNode:
for _, a := range t.args {
walk(a)
}
}
}
walk(root)
return out
}
// CheckExpr validates an expression; returns an error message or "" if it parses.
func CheckExpr(src string) string {
if strings.TrimSpace(src) == "" {
return ""
}
if _, err := parse(src); err != nil {
return err.Error()
}
return ""
}
- Step 4: Run test to verify it passes
Run: go test ./internal/controllogic/ -run 'TestEval|TestCollectRefsArray'
Expected: PASS. Then run the FULL package to surface Resolver-signature breakage in
existing files: go build ./internal/controllogic/ — expect compile errors in
engine.go/lua.go/debug.go (their resolver closures return float64). Those are
fixed in Tasks 4 and 6; if the build must stay green between tasks, the implementer may
temporarily adapt the closures with a Value-returning wrapper, but the real fix lands
in Task 4. Record any such temporary shim in the report so Task 4 removes it.
- Step 5: Commit
git add internal/controllogic/expr.go internal/controllogic/expr_test.go
git commit -m "controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs)"
END OF PART 1 OF THE PLAN (Tasks 1-3, backend value+expr foundation). Tasks 4-8 (engine locals+resolver, array action nodes, lua/debug adaptation, frontend editor, tests+docs) are specified in the continuation appended below.
Task 4: Engine locals as Value + init from declarations + value-aware resolver/write
Make the running engine carry array-capable locals: change compiledGraph.locals to
map[string]Value, add a decls map[string]StateVar built from g.StateVars, initialise
locals from declarations at compile, make setLocal apply sizing, make the resolve
closure return Value, and make write value-aware (local target → sized setLocal;
ds:name target → scalar Source.Write, arrays rejected). Update action.write to use
EvalValue. This removes any temporary Resolver shim introduced in Task 3.
Files:
- Modify:
internal/controllogic/engine.go - Test:
internal/controllogic/engine_test.go(create or extend)
Interfaces:
-
Consumes:
Value,StateVar,parseInitialArray,applySizing(Task 1);Resolver,EvalValue,EvalExpr,EvalBool,CollectRefs(Task 3);Graph.StateVars(Task 2). -
Produces: a
compiledGraphwhoselocals map[string]Valueis initialised from declared statevars;getLocal(name) Value;setLocal(name string, v Value)(sizing-aware);Engine.write(cg, target string, val Value). -
Step 1: Write the failing test —
internal/controllogic/engine_test.go
package controllogic
import (
"reflect"
"testing"
)
func TestCompileInitsLocalsFromDecls(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "7"},
{Name: "flag", Type: "bool", Initial: "true"},
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
},
}
cg := compile(g)
if got := cg.getLocal("count"); got != Value(7.0) {
t.Fatalf("count = %#v", got)
}
if got := cg.getLocal("flag"); got != Value(1.0) {
t.Fatalf("flag = %#v", got)
}
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("buf = %#v", got)
}
}
func TestSetLocalAppliesSizing(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
}}
cg := compile(g)
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
t.Fatalf("sized buf = %#v", got)
}
}
func TestResolverReturnsLocalValue(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
}}
cg := compile(g)
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
if v := EvalExpr("buf[1]", R); v != 20 {
t.Fatalf("buf[1] = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 30 {
t.Fatalf("sum(buf) = %v", v)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run 'TestCompileInitsLocals|TestSetLocalApplies|TestResolverReturnsLocal'
Expected: FAIL — locals is map[string]float64; getLocal returns float64; no decls init.
- Step 3: Write minimal implementation — edits to
internal/controllogic/engine.go
- In the
compiledGraphstruct, change the locals field and add decls:
locals map[string]Value
decls map[string]StateVar
- In
compile(g Graph), change the struct literal fieldlocals: map[string]float64{}tolocals: map[string]Value{}and adddecls: map[string]StateVar{}next to it. Then, AFTER thefor _, n := range g.Nodes { cg.byId[n.ID] = n }loop (before the wire loop, or anywhere beforereturn cg), initialise locals from declarations:
for _, sv := range g.StateVars {
cg.decls[sv.Name] = sv
if sv.Type == "array" {
cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv)
} else {
cg.locals[sv.Name] = parseScalarInitial(sv)
}
}
- Add the scalar-initial helper (near
setLocal):
func parseScalarInitial(sv StateVar) float64 {
s := strings.TrimSpace(sv.Initial)
switch s {
case "true":
return 1
case "false":
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return f
}
- Replace
setLocal/getLocal:
func (cg *compiledGraph) setLocal(name string, v Value) {
cg.stateMu.Lock()
if sv, ok := cg.decls[name]; ok && sv.Type == "array" {
if arr, isArr := v.([]Value); isArr {
v = applySizing(arr, sv)
}
}
cg.locals[name] = v
cg.stateMu.Unlock()
}
func (cg *compiledGraph) getLocal(name string) Value {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0.0
}
return v
}
- In the
resolveclosure insideactivate(currentlyfunc(ds, name string) float64), change its return type toValue. The body is unchanged exceptsys:dtand theliveGetcalls returnfloat64(which is aValue) — they need no edit. Result:
resolve := func(ds, name string) Value {
switch ds {
case "sys":
if name == "dt" {
return dt
}
return cg.engine.liveGet("sys", name)
case "local":
return cg.getLocal(name)
default:
return cg.engine.liveGet(ds, name)
}
}
Also change the runCtx.resolve field type from Resolver — it is already Resolver,
which now returns Value, so no change is needed there.
- Replace
writeto be value-aware:
// write applies an action.write/lua-set/config-read to a target: a bare/local
// name updates a graph-local var (arrays sized per its declaration); a ds:name
// target writes a scalar to the data source (arrays cannot be written and are
// dropped).
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
ds, name, ok := parseRef(target)
if !ok {
return
}
if ds == "local" {
cg.setLocal(name, val)
return
}
f, isNum := val.(float64)
if !isNum || math.IsNaN(f) {
return
}
if cg.dryRun {
return // simulate: no real data-source write
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(f, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, f); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
- Update
action.writeinrun()to evaluate a full value and debug-emit a scalar projection (array writes show NaN in the badge but still write the local):
case "action.write":
val := EvalValue(node.param("expr"), ctx.resolve)
if f, ok := val.(float64); ok {
cg.emitDebug(node.ID, f, true)
} else {
cg.emitDebug(node.ID, val, true)
}
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
NOTE: step 7's emitDebug(node.ID, val, true) array branch depends on Task 6
widening emitDebug's value parameter to Value. If Task 6 has not yet landed,
temporarily keep the scalar-only cg.emitDebug(node.ID, EvalExpr(...), true) form and
record it in the report; Task 6 restores the value form. (Build order runs Task 6
before Task 7, so this resolves within the backend phase.)
- The
runLuacallbackfunc(target string, val float64) { cg.engine.write(cg, target, val) }compiles unchanged (valfloat64 is aValue). Theaction.config.readcallcg.engine.write(cg, node.param("target"), v)(v float64) also compiles unchanged.
- Step 4: Run test to verify it passes
Run: go test ./internal/controllogic/ -run 'TestCompileInitsLocals|TestSetLocalApplies|TestResolverReturnsLocal'
Then: go build ./internal/controllogic/ (expect remaining errors ONLY in lua.go/debug.go
if Task 6 not yet done — see note). If building the whole package now, apply Task 6 first
or temporarily adapt. The two reviewer-visible deliverables here are the three passing
tests and a value-aware write.
- Step 5: Commit
git add internal/controllogic/engine.go internal/controllogic/engine_test.go
git commit -m "controllogic: locals as Value, init from declarations, value-aware write"
Task 5: action.array.* nodes in the engine
Add the five array action nodes to run() and their expression-ref collection to
compile(), mirroring the panel-logic handlers (web/src/lib/logic.ts lines 627-686).
Params match panel logic exactly: push{array,expr}, set{array,index,expr},
remove{array,index}, pop{array}, clear{array}.
Files:
- Modify:
internal/controllogic/engine.go - Test:
internal/controllogic/engine_test.go(extend)
Interfaces:
-
Consumes: value-aware
setLocal/getLocal/write,EvalValue,EvalExpr,applySizing,decls(Task 4); array funcs/idx (Tasks 1, 3). -
Produces: engine handling of node kinds
action.array.push|set|remove|pop|clear. -
Step 1: Write the failing test — add to
internal/controllogic/engine_test.go
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
t.Helper()
cg := compile(g)
// Minimal resolver: sys:dt=0, locals from cg, live=NaN.
R := func(ds, name string) Value {
switch ds {
case "local":
return cg.getLocal(name)
default:
return 0.0
}
}
ctx := &runCtx{fired: triggerID, resolve: R}
cg.follow(triggerID, "out", ctx)
return cg
}
func TestArrayPushNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
t.Fatalf("after push buf = %#v", got)
}
}
func TestArrayClearNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "c"}},
}
cg := runFlowOnce(t, g, "t")
// fixed sizing → clear yields zero-padded length-3.
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
t.Fatalf("after clear buf = %#v", got)
}
}
NOTE: open internal/controllogic/model.go and confirm the Node/Wire field names
and the params-map field (Params). Adjust the literals above to the real shapes before
running.
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run 'TestArrayPushNode|TestArrayClearNode'
Expected: FAIL — array node kinds hit the default branch (no mutation).
- Step 3: Write minimal implementation — edits to
internal/controllogic/engine.go
- Add a nested-index assignment helper (port of
setPathfrom logic.ts) nearwrite:
// setPath sets arr[path[0]]...[path[n-1]] = v, creating intermediate arrays and
// resolving negative indices relative to each sub-array's length.
func setPath(arr []Value, path []int, v Value) []Value {
if len(path) == 0 {
return arr
}
k := path[0]
if k < 0 {
k = len(arr) + k
}
if k < 0 {
return arr
}
for len(arr) <= k {
arr = append(arr, 0.0)
}
if len(path) == 1 {
arr[k] = v
return arr
}
sub, _ := arr[k].([]Value)
arr[k] = setPath(sub, path[1:], v)
return arr
}
- Add the five cases to the
run()switch (place them just beforeaction.dialogor afteraction.delay):
case "action.array.push":
name := strings.TrimSpace(node.param("array"))
if name != "" {
val := EvalValue(node.param("expr"), ctx.resolve)
cur, _ := cg.getLocal(name).([]Value)
cg.setLocal(name, append(append([]Value{}, cur...), val))
}
cg.follow(node.ID, "out", ctx)
case "action.array.set":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
var path []int
ok := true
for _, s := range strings.Split(node.param("index"), ",") {
f := EvalExpr(strings.TrimSpace(s), ctx.resolve)
if math.IsNaN(f) {
ok = false
break
}
path = append(path, int(f))
}
val := EvalValue(node.param("expr"), ctx.resolve)
if ok && len(path) > 0 {
arr = setPath(arr, path, val)
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.remove":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
i := int(EvalExpr(node.param("index"), ctx.resolve))
k := i
if k < 0 {
k = len(arr) + k
}
if k >= 0 && k < len(arr) {
arr = append(arr[:k], arr[k+1:]...)
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.pop":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
if len(arr) > 0 {
arr = arr[:len(arr)-1]
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.clear":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad)
}
cg.follow(node.ID, "out", ctx)
- In
compile()'s node loop, add ref-collection for the value-bearing array nodes so their expressions subscribe to referenced signals/locals:
case "action.array.push", "action.array.set":
wantExpr(n.param("expr"))
wantExpr(n.param("index"))
case "action.array.remove":
wantExpr(n.param("index"))
- Step 4: Run test to verify it passes
Run: go test ./internal/controllogic/ -run 'TestArrayPushNode|TestArrayClearNode'
Expected: PASS
- Step 5: Commit
git add internal/controllogic/engine.go internal/controllogic/engine_test.go
git commit -m "controllogic: add action.array.* nodes (push/set/remove/pop/clear)"
Task 6: Adapt lua.go + debug.go to Value
Make the debug event value-polymorphic (Value any) and keep Lua scalar-only safely
(reading an array local yields NaN). This closes the backend compile and restores the
array branch of action.write's debug emit (Task 4 step 7).
Files:
- Modify:
internal/controllogic/debug.go,internal/controllogic/lua.go - Test:
internal/controllogic/engine_test.go(extend) or build-only
Interfaces:
-
Consumes:
Value(Task 1), value-returningResolver(Task 3). -
Produces:
DebugEvent.Value any;emitDebug(nodeID string, value Value, hasValue bool); Luagetreturns NaN for array locals. -
Step 1: Write the failing test — add to
internal/controllogic/engine_test.go
func TestEmitDebugAcceptsArray(t *testing.T) {
// Compile-level guard: emitDebug must accept a Value (array) argument.
g := Graph{ID: "g", Name: "n"}
cg := compile(g)
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
cg.emitDebug("x", []Value{1.0, 2.0}, true)
cg.emitDebug("x", 3.0, true)
}
NOTE: confirm Engine is constructible as &Engine{} for this guard, or use the
package's existing test harness/helper for building an Engine. If debugWatch/debugObs
are atomic.Value with nil zero values, emitDebug already short-circuits safely; if not,
adjust the test to install a no-op observer.
- Step 2: Run test to verify it fails
Run: go test ./internal/controllogic/ -run TestEmitDebugAcceptsArray
Expected: FAIL to COMPILE — emitDebug and DebugEvent.Value are float64.
- Step 3: Write minimal implementation
In internal/controllogic/debug.go:
- Change
DebugEvent.Valuefromfloat64toany(keep thejson:"value"tag). - Change
emitDebugsignature tofunc (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool)and passValue: valuein theDebugEventliteral.
In internal/controllogic/lua.go:
- Add
"math"to imports. - In the
gethost function, the resolver now returnsValue; narrow to a scalar:
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
v := math.NaN()
if ok && lr.curResolve != nil {
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
v = f
}
}
s.Push(lua.LNumber(v))
return 1
}))
curResolve Resolver already returns Value after Task 3; curSet func(target string, val float64) is unchanged (Lua writes scalars).
- Step 4: Run test + full backend build
Run: go test ./internal/controllogic/ -run TestEmitDebugAcceptsArray
Then: go build ./... && go vet ./internal/controllogic/ && go test ./internal/controllogic/ -race
Expected: PASS / clean. Confirm Task 4 step 7's array emitDebug(node.ID, val, true) form
now compiles (revert any temporary shim).
- Step 5: Commit
git add internal/controllogic/debug.go internal/controllogic/lua.go internal/controllogic/engine_test.go
git commit -m "controllogic: debug value is Value (array-capable); lua get narrows to scalar"
Task 7: Frontend — declaration UI + array nodes in ControlLogicEditor.tsx
Add the local-variable declaration UI (reuse LocalVars from LogicEditor.tsx), the five
array node kinds (palette + labels + inspectors), and thread statevars through the graph
state. Serialisation is automatic: the graph object is JSON.stringify'd on save (line
247), so adding statevars to CLGraph persists it. The Go side already round-trips it
(Task 2).
Files:
- Modify:
web/src/LogicEditor.tsx(exportLocalVars) - Modify:
web/src/ControlLogicEditor.tsx - Reference (read for inspector patterns):
web/src/LogicEditor.tsxarray node inspectors (theaction.array.*blocks) and itsLocalVarsplacement.
Interfaces:
-
Consumes:
StateVar(from./lib/types),LocalVars(exported from LogicEditor),checkExpr(already imported, now array-aware via Task 3 on the TS side — already done in Phase 1). -
Produces: a control-logic editor that declares scalar+array locals and edits array nodes.
-
Step 1: Export
LocalVarsfromLogicEditor.tsx
Change function LocalVars({ ... }) (line 1451) to export function LocalVars({ ... }).
Verify it has no panel-only dependencies (it uses only StateVar, useState, Fragment,
and CSS classes — all available in ControlLogicEditor).
- Step 2: Add types + palette + labels in
ControlLogicEditor.tsx
a. Import StateVar and LocalVars:
import { LocalVars } from './LogicEditor';
import type { StateVar } from './lib/types';
b. Extend CLNodeKind (after 'action.delay' group) with:
| 'action.array.push'
| 'action.array.set'
| 'action.array.remove'
| 'action.array.pop'
| 'action.array.clear'
c. Add statevars?: StateVar[]; to the CLGraph interface (after wires/groups).
d. Add PALETTE entries (after the action.delay/action.log entries):
{ kind: 'action.array.push', label: 'Array push', params: { array: '', expr: '' } },
{ kind: 'action.array.set', label: 'Array set', params: { array: '', index: '0', expr: '' } },
{ kind: 'action.array.remove', label: 'Array remove', params: { array: '', index: '0' } },
{ kind: 'action.array.pop', label: 'Array pop', params: { array: '' } },
{ kind: 'action.array.clear', label: 'Array clear', params: { array: '' } },
e. Add the matching KIND_LABEL entries:
'action.array.push': 'Array push',
'action.array.set': 'Array set',
'action.array.remove': 'Array remove',
'action.array.pop': 'Array pop',
'action.array.clear': 'Array clear',
- Step 3: Render
LocalVarsand thread statevars
In the editor body (the graph && (<Fragment>... block around lines 366-383), render the
declaration UI. Place it in the cl-graph-bar or a sibling block, wired to patchGraph:
<LocalVars
statevars={graph.statevars ?? []}
onChange={(vars) => patchGraph({ statevars: vars })} />
patchGraph already shallow-merges into the graph and marks dirty (it is used for
name/enabled/scope). Confirm patchGraph's type accepts statevars; since CLGraph
now has the field, patchGraph({ statevars }) type-checks.
- Step 4: Add array node inspectors
In the inspector switch (the selected node param editors, ~lines 1140-1200 region for
action.write), add blocks for the five array kinds. Build an arrayLocals list from the
graph's array statevars and render a dropdown selector (mirror LogicEditor's array node
inspectors). Compute once near the inspector render:
const arrayLocals = (graph.statevars ?? [])
.filter(v => v.type === 'array').map(v => v.name);
Then, for selected.kind === 'action.array.push' etc.:
{selected.kind.startsWith('action.array.') && (
<Fragment>
<label class="prop-label">Array</label>
<select class="prop-select"
value={selected.params.array ?? ''}
onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
<option value="">— select array —</option>
{arrayLocals.map(n => <option key={n} value={n}>{n}</option>)}
{selected.params.array && !arrayLocals.includes(selected.params.array) &&
<option value={selected.params.array}>{selected.params.array} (unknown)</option>}
</select>
{(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
<Fragment>
<label class="prop-label">Index{selected.kind === 'action.array.set' ? ' (comma-separated for nested)' : ''}</label>
<input class="prop-input" value={selected.params.index ?? ''}
onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
</Fragment>
)}
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
<Fragment>
<label class="prop-label">Value (expression)</label>
<ExprInput value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} />
</Fragment>
)}
</Fragment>
)}
NOTE: match the ACTUAL expression-input component the editor uses (the summary shows an
expression editor wired with onChange={(v) => patchParams(selected.id, { expr: v })} near
line 1154 — reuse that exact component, named here ExprInput as a placeholder). Read the
action.write inspector and copy its expression-input element verbatim. Ensure the new
block does not collide with the existing per-kind if ladder (guard with the same
selection pattern the surrounding code uses).
- Step 5: Build + typecheck
Run: make frontend
Expected: builds clean (esbuild).
Run: cd web && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E 'ControlLogicEditor|LogicEditor'
Expected: no NEW errors beyond the baseline noise (TS2604 Fragment, TS2322 key/RowProps,
TS7044 'e'). Fix any real new error.
- Step 6: Commit
git add web/src/LogicEditor.tsx web/src/ControlLogicEditor.tsx
git commit -m "ControlLogicEditor: local-var declarations + array action nodes"
Task 8: Full verification sweep + docs
Run the complete build/test gauntlet and update documentation.
Files:
-
Modify:
docs/TECHNICAL_SPEC.md(control-logic section: note Value model, statevars, array nodes),TODO.md(mark the relevant item), and the control-logic help text if one exists (grepHelpModal.tsx/ any control-logic help for the node list). -
Step 1: Backend gauntlet
Run: make backend && go build ./... && go vet ./... && go test ./... -race && gofmt -l internal/
Expected: all clean; gofmt -l prints nothing.
- Step 2: Frontend build
Run: make frontend
Expected: clean. Then make all for the embedded binary.
-
Step 3: Update docs
-
In
docs/TECHNICAL_SPEC.md, in the control-logic section, document: locals now carry aValue(scalar or array); graphs may declarestatevars(scalar+array with sizing policies dynamic/capped/fixed); expressions support array literals/indexing/array functions; newaction.array.push/set/remove/pop/clearnodes; Lua remains scalar-only; CSV export is panel-only (not in control logic). -
In
TODO.md, mark the control-logic array/scalar local-variable item complete. -
If a control-logic node reference exists in help text, add the array nodes.
-
Step 4: Commit
git add docs/TECHNICAL_SPEC.md TODO.md web/src/HelpModal.tsx
git commit -m "docs: control-logic array+scalar locals (Value model, statevars, array nodes)"
Recommended build order
- Task 1 — value model + sizing (pure, isolated).
- Task 2 — Graph.StateVars + store round-trip.
- Task 3 — value-polymorphic expr.go (Resolver signature change ripples; may need a brief shim until Task 4/6).
- Task 4 — engine locals + resolver + write.
- Task 5 — array action nodes.
- Task 6 — lua/debug adaptation (closes the backend compile).
- Task 7 — frontend editor.
- Task 8 — verification + docs.
Backend phase (Tasks 1-6) should leave go build ./... and go test ./... -race green
before starting the frontend.
Riskiest parts
- Resolver signature change (Task 3 → 4/6):
Resolvergoes fromfloat64toValue, touching every closure and the lua/debug call sites. The plan sequences Task 6 right after the engine so the package compiles before the frontend. Any temporary shim must be recorded and removed. - Sizing on write:
setLocalmust applyapplySizingusingdecls, exactly aswriteLocalStatedoes on the panel side, or capped/fixed arrays drift from panel semantics. TheTestSetLocalAppliesSizingandTestArrayClearNodetests guard this. - Concurrency:
locals/declsare read/written undercg.stateMu.declsis built once incompilebefore the graph runs (no concurrent writers), so it needs no lock for reads inside the already-lockedsetLocal. Rungo test ./internal/controllogic -race. - Lua arrays: out of scope —
getreturns NaN for array locals;setwrites scalars only. Documented, not a bug.
Test strategy
- Go unit (
internal/controllogic): value/sizing round-trips (Task 1); store round-trip of statevars (Task 2); expr scalar + array literal/index/funcs + min/max dual-dispatch + CollectRefs over arrays + array-yields-NaN-via-EvalExpr (Task 3); locals-init-from-decls, setLocal sizing, resolver returns Value (Task 4); array node mutations incl. fixed-sizing clear (Task 5); emitDebug accepts array (Task 6);-raceacross the package. - Frontend:
make frontend+ filteredtscclean (Task 7). - Manual (
make all,go run ./cmd/uopi): create a control-logic graph, declare an array local (capped, capacity 5), add a timer→array.push flow, enable+save, confirm the array grows and caps; restart the server and confirm the declaration persists in the control-logic JSON; open the debug/simulate view and confirm nodes activate.
Build / verification
make frontend then make backend, go build ./..., go vet ./...,
go test ./... -race, gofmt -l internal/ — all clean.