Compare commits

...

8 Commits

Author SHA1 Message Date
Martino Ferrari 336095c052 plan updated 2026-06-26 14:08:28 +02:00
Martino Ferrari c53a49e540 controllogic: fix data race in setPath nested-array assignment
setPath mutated nested sub-arrays in place; since graph-local slices (and
their nested sub-slices) may be read concurrently by other flow goroutines,
an action.array.set with a multi-level path raced readers of the same local.
The single-threaded TS source mutates in place safely, but the Go port runs
flows on concurrent goroutines, so setPath now copies on descent (every level
returns a freshly allocated slice). Adds a -race regression test that
concurrently drives nested set + inner-element reads, plus a nested-set
correctness test asserting the prior stored value is not mutated.

Found by whole-branch review (Opus); missed by the per-task reviews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 20:11:14 +02:00
Martino Ferrari b6bc9dc2f2 docs: control-logic array+scalar locals (Value model, statevars, array nodes) 2026-06-24 20:02:55 +02:00
Martino Ferrari 3133e50e09 controllogic: drop sign alias shim + add array node tests (set/remove/pop) 2026-06-24 20:02:52 +02:00
Martino Ferrari 550ec06bc8 ControlLogicEditor: local-var declarations + array action nodes 2026-06-24 19:56:28 +02:00
Martino Ferrari 9c1f2685a7 controllogic: debug value is Value (array-capable); lua get narrows to scalar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:50:47 +02:00
Martino Ferrari 088063d9cd controllogic: add action.array.* nodes (push/set/remove/pop/clear) 2026-06-24 19:45:40 +02:00
Martino Ferrari a6fa4e7c7c controllogic: locals as Value, init from declarations, value-aware write
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:40:29 +02:00
15 changed files with 522 additions and 57 deletions
+6 -6
View File
@@ -94,14 +94,14 @@
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
- [ ] Syntetic signal editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
- [ ] Logic editor:
- [x] Logic editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- array functions should work with new local array
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). Box stays unchecked until **Phase 2** (server-side `internal/controllogic` Go port, see below) lands.
- [ ] Control loop:
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- [x] array functions should work with new local array
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). **Phase 2 (server-side `internal/controllogic` Go port, see below) has now landed**, so this feature is complete across both engines.
- [x] Control loop:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
- add full support to server side array values
- [x] add full support to server side array values — DONE (Phase 2): `Value = float64 | []Value` model (`internal/controllogic/value.go`), `Graph.StateVars` (number/bool/array with dynamic/capped/fixed sizing) persisted in store JSON, value-polymorphic `expr.go` (array literals, negative-wrap indexing, full array-function table), and `action.array.push|set|remove|pop|clear` nodes. Lua block stays scalar-only (array local → NaN); CSV export remains panel-logic-only
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
- [x] possibility to fork any version
- [x] click to view the version
+23 -3
View File
@@ -376,9 +376,9 @@ now produces **index-aligned** CSV: each configured array becomes a column (cust
labels supported), rows aligned by element index. Array statevars and all array nodes
round-trip through the panel XML (`<statevar type="array" elem=… sizing=… capacity=…>`).
> **Note:** This is Phase 1 (panel logic, client-side TypeScript). The equivalent array
> support in the server-side control-logic engine (`internal/controllogic`) is a separate
> Phase 2 effort and is not yet implemented.
> **Note:** The above is Phase 1 (panel logic, client-side TypeScript). The equivalent array
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
### 3.9 Control Logic Engine
@@ -389,6 +389,26 @@ and writes results back to signals. Graphs are persisted by a store and managed
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
can be individually enabled/disabled.
**Array-valued local variables (Phase 2).** Engine locals now carry a `Value` — the tagged
union `Value = float64 | []Value` (`internal/controllogic/value.go`), the Go port of the
panel-logic `ArrVal` model, with booleans represented as `1`/`0` at the leaves. A graph may
declare `statevars`: each has a `Name`, a `Type` (`number` | `bool` | `array`), an `Initial`
expression, and — for arrays — a `Sizing` policy and `Capacity`. The three sizing policies
mirror the frontend: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`, oldest dropped
FIFO past the cap), **capped** (length kept ≤ `Capacity`, oldest dropped FIFO), and **fixed**
(exactly `Capacity` elements — truncated or zero-padded). Sizing is enforced on write. State
vars are persisted in the graph's store JSON and seeded into locals at compile time.
The expression evaluator (`internal/controllogic/expr.go`) is value-polymorphic: it supports
array literals `[a, b, c]`, indexing `arr[i]` (negative indices wrap from the end), and a
table of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`,
`add`, `sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill`
(plus `min`/`max`, which accept either scalars or an array). These are pure — they produce new
values and never mutate a stored local. Five action nodes write back to a declared array
statevar: `action.array.push`, `…set`, `…remove`, `…pop`, and `…clear`. The embedded Lua block
remains **scalar-only**: reading an array local from Lua yields `NaN`. CSV export
(`action.export`) is panel-logic-only and is **not** available in control logic.
The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action
nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a
broker-backed, audited write closure; `action.config.read` resolves a single parameter value
@@ -224,13 +224,7 @@ func normalizeValue(v any) Value {
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:
case []interface{}: // note: []Value == []interface{} since Value = any, so this covers both
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
+5 -5
View File
@@ -103,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, instID)
@@ -117,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, "does-not-exist")
@@ -144,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.writeConfigParam(cg, instID, "gain", 7.5)
@@ -163,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
src, err := e.cfg.GetInstance(srcID)
if err != nil {
@@ -197,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
// Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5)
+2 -2
View File
@@ -14,7 +14,7 @@ import (
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
Value any `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
@@ -83,7 +83,7 @@ func (e *Engine) FireTrigger(graphID, triggerID string) bool {
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
func (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
+3 -3
View File
@@ -79,7 +79,7 @@ func TestDebugObserverCapturesNodeValues(t *testing.T) {
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
@@ -136,7 +136,7 @@ func TestFireTriggerForcesRun(t *testing.T) {
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42 {
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
@@ -187,7 +187,7 @@ func TestSimulateSuppressesWrites(t *testing.T) {
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
+150 -11
View File
@@ -276,17 +276,51 @@ func (e *Engine) liveGet(ds, name string) float64 {
return v
}
// setPath assigns v at the nested index path within arr, growing (zero-filling)
// as needed and resolving negative indices against length. Adapted from logic.ts;
// unlike the single-threaded TS source it copies on descent rather than mutating in
// place, because a graph-local slice (and its nested sub-slices) may be read
// concurrently by other flow goroutines — in-place mutation of shared backing would
// race. Every level returns a freshly allocated slice.
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
}
out := append([]Value{}, arr...) // copy: backing may be shared with the live local
for len(out) <= k {
out = append(out, 0.0)
}
if len(path) == 1 {
out[k] = v
return out
}
sub, _ := out[k].([]Value)
out[k] = setPath(sub, path[1:], v)
return out
}
// write applies an action.write/lua-set to a target: a bare name updates a
// graph-local var; a ds:name target writes to the data source.
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
ds, name, ok := parseRef(target)
if !ok || math.IsNaN(val) {
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
}
@@ -301,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Value: strconv.FormatFloat(f, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
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()
@@ -608,7 +642,8 @@ type compiledGraph struct {
prevVal map[string]float64 // last value for change triggers
hasVal map[string]bool
lastFire map[string]int64 // ns wall clock each trigger last fired
locals map[string]float64
locals map[string]Value
decls map[string]StateVar
}
func compile(g Graph) *compiledGraph {
@@ -626,12 +661,21 @@ func compile(g Graph) *compiledGraph {
prevVal: map[string]float64{},
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
locals: map[string]Value{},
decls: map[string]StateVar{},
fireCh: make(chan string, 16),
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
}
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)
}
}
for _, w := range g.Wires {
port := w.FromPort
if port == "" {
@@ -672,6 +716,13 @@ func compile(g Graph) *compiledGraph {
}
case "action.write", "action.log":
wantExpr(n.param("expr"))
case "action.array.push":
wantExpr(n.param("expr"))
case "action.array.set":
wantExpr(n.param("expr"))
wantExpr(n.param("index"))
case "action.array.remove":
wantExpr(n.param("index"))
case "action.lua":
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
for _, r := range luaGetRefs(n.param("script")) {
@@ -682,18 +733,38 @@ func compile(g Graph) *compiledGraph {
return cg
}
func (cg *compiledGraph) setLocal(name string, v float64) {
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
}
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) float64 {
func (cg *compiledGraph) getLocal(name string) Value {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0
return 0.0
}
return v
}
@@ -897,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
dt = float64(now-last) / 1e9
}
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
resolve := func(ds, name string) Value {
switch ds {
case "sys":
if name == "dt" {
@@ -986,7 +1057,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.follow(node.ID, "done", ctx)
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
val := EvalValue(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
@@ -1057,6 +1128,74 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
}
cg.follow(node.ID, "out", ctx)
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)
default:
cg.follow(node.ID, "out", ctx)
}
+2 -2
View File
@@ -87,7 +87,7 @@ func TestCoerceParamValue(t *testing.T) {
}
func TestSign(t *testing.T) {
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
if signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", signOf(5), signOf(-5), signOf(0))
}
}
+242
View File
@@ -0,0 +1,242 @@
package controllogic
import (
"context"
"reflect"
"sync"
"testing"
)
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
// the flow from triggerID synchronously and returns the compiled graph so callers
// can inspect locals.
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
t.Helper()
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
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")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
t.Fatalf("after clear buf = %#v", got)
}
}
func TestArraySetNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
t.Fatalf("after set buf = %#v", got)
}
}
func TestArrayRemoveNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
},
Wires: []Wire{{From: "t", To: "r"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
t.Fatalf("after remove buf = %#v", got)
}
}
func TestArrayPopNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
t.Fatalf("after pop buf = %#v", got)
}
}
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
// in place — the stored value must be replaced by a fresh tree.
func TestArraySetNodeNested(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
before := cg.getLocal("grid")
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
t.Fatalf("after nested set grid = %#v", got)
}
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
}
}
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(2)
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
go func() {
defer wg.Done()
// Read the inner element concurrently; with the in-place setPath bug the
// writer mutates this same sub-array backing, producing a data race.
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
_ = sub[0]
}
}
}()
}
wg.Wait()
}
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)
}
}
func TestEmitDebugAcceptsArray(t *testing.T) {
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)
}
-2
View File
@@ -353,8 +353,6 @@ func signOf(x float64) int {
}
}
// sign is an alias for signOf, kept for backward compatibility with existing tests.
func sign(x float64) int { return signOf(x) }
func clampIdx(i, length int) int {
if i < 0 {
i = length + i
+2 -2
View File
@@ -7,7 +7,7 @@ import (
)
func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
resolve := func(ds, name string) Value {
switch {
case ds == "stub" && name == "x":
return 10
@@ -46,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
}
func TestEvalExprErrors(t *testing.T) {
r := func(ds, name string) Value { return 0 } // shim: was float64 (Task 4 removes)
r := func(ds, name string) Value { return 0 }
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
+4 -3
View File
@@ -1,6 +1,7 @@
package controllogic
import (
"math"
"sync"
lua "github.com/yuin/gopher-lua"
@@ -60,9 +61,9 @@ func (lr *luaRuntime) ensure() error {
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
var v float64
if ok && lr.curResolve != nil { // shim: Task 6 finalizes lua Value handling
if f, fok := lr.curResolve(ds, name).(float64); fok {
v := math.NaN()
if ok && lr.curResolve != nil {
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
v = f
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ type debugOut struct {
Type string `json:"type"` // always "debugNode"
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
Value any `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"`
}
+71
View File
@@ -10,6 +10,8 @@ import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './l
import { wsClient } from './lib/ws';
import { useAuth } from './lib/auth';
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
import { LocalVars } from './LogicEditor';
import type { StateVar } from './lib/types';
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
@@ -27,6 +29,11 @@ type CLNodeKind =
| 'action.log'
| 'action.lua'
| 'action.dialog'
| 'action.array.push'
| 'action.array.set'
| 'action.array.remove'
| 'action.array.pop'
| 'action.array.clear'
| 'action.config.apply'
| 'action.config.read'
| 'action.config.write'
@@ -51,6 +58,7 @@ interface CLGraph {
owner?: string;
scope?: string;
scopeGroups?: string[];
statevars?: StateVar[];
}
interface DataSource { name: string; }
@@ -101,6 +109,11 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
{ 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: '' } },
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } },
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
@@ -122,6 +135,11 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
'action.log': 'Log',
'action.lua': 'Lua script',
'action.dialog': 'Dialog',
'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',
'action.config.apply': 'Apply config',
'action.config.read': 'Read config',
'action.config.write': 'Write config',
@@ -377,6 +395,9 @@ export default function ControlLogicEditor({ onClose }: Props) {
<ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
<LocalVars
statevars={graph.statevars ?? []}
onChange={(vars) => patchGraph({ statevars: vars })} />
</div>
<div class="cl-editor-main">
<FlowEditor graph={graph}
@@ -1158,6 +1179,51 @@ function FlowEditor({ graph, onChange }: {
</Fragment>
)}
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set' ||
selected.kind === 'action.array.remove' || selected.kind === 'action.array.pop' ||
selected.kind === 'action.array.clear') && (() => {
const arrayLocals = (graph.statevars ?? []).filter(v => v.type === 'array');
const cur = selected.params.array ?? '';
return (
<Fragment>
<div class="wizard-field">
<label>Array (local variable)</label>
<select class="prop-select" value={cur}
onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
<option value=""> select array </option>
{arrayLocals.map(v => <option key={v.name} value={v.name}>{v.name}</option>)}
{cur && !arrayLocals.some(v => v.name === cur) && <option value={cur}>{cur} (unknown)</option>}
</select>
{arrayLocals.length === 0 && (
<p class="hint">No array locals declared yet add one in the Local vars panel.</p>
)}
</div>
{(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
<div class="wizard-field">
<label>Index{selected.kind === 'action.array.set' ? ' (comma-separated for nested)' : ''}</label>
<input class="prop-input" value={selected.params.index ?? ''}
placeholder="e.g. 0 or 2,1"
onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
</div>
)}
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint={selected.kind === 'action.array.push'
? 'Value to append to the end of the array.'
: 'Value to store at the given index / path.'} />
)}
{(selected.kind === 'action.array.pop' || selected.kind === 'action.array.clear') && (
<p class="hint">{selected.kind === 'action.array.pop'
? 'Removes and returns the last element of the array.'
: 'Removes all elements from the array.'}</p>
)}
</Fragment>
);
})()}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
<label>Delay (ms)</label>
@@ -1426,6 +1492,11 @@ function nodeSummary(n: CLNode): string {
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
case 'action.array.push': return `push ${n.params.expr || ''}${n.params.array || '?'}`;
case 'action.array.set': return `${n.params.array || '?'}[${n.params.index || '?'}] = ${n.params.expr || ''}`;
case 'action.array.remove': return `remove ${n.params.array || '?'}[${n.params.index || '?'}]`;
case 'action.array.pop': return `pop ${n.params.array || '?'}`;
case 'action.array.clear': return `clear ${n.params.array || '?'}`;
default: return '';
}
}
+1 -1
View File
@@ -1448,7 +1448,7 @@ function nodeSummary(n: LogicNode): string {
// Inline editor for the panel's local state variables, shown in the logic
// palette so they can be created without leaving the (full-width) logic tab.
function LocalVars({ statevars, onChange }: {
export function LocalVars({ statevars, onChange }: {
statevars: StateVar[];
onChange: (vars: StateVar[]) => void;
}) {