Files
uopi/internal/controllogic/engine.go
T
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

1247 lines
33 KiB
Go

package controllogic
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
)
// errUnknownSource is returned by config-apply writes targeting a data source
// that isn't registered with the broker.
var errUnknownSource = errors.New("unknown data source")
// formatAny renders a config value for the audit log.
func formatAny(v any) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case string:
return x
default:
return fmt.Sprintf("%v", v)
}
}
// Guards against runaway flows (cycles / pathological loops).
const (
maxSteps = 100000
maxLoop = 100000
)
// Engine runs all enabled control-logic graphs continuously under a root
// context. Reload tears down the current generation (subscriptions, timers,
// in-flight flows) and rebuilds from the store's enabled graphs.
type Engine struct {
broker *broker.Broker
store *Store
cfg *confmgr.Store
audit audit.Recorder
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// notifier delivers action.dialog requests to connected clients. Stored in
// an atomic so flow goroutines can read it without taking e.mu (which Reload
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// debugObs receives per-node execution events for the live debug view, and
// debugWatch is the set of graph ids with at least one live subscriber. Both
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
// short-circuits cheaply when the firing graph has no watcher.
debugObs atomic.Value // debugObsBox
debugWatch atomic.Value // map[string]bool (immutable snapshot)
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
// run. Entries are added/removed as generations (and simulate sandboxes) come
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
fireMu sync.Mutex
fireChs map[string]chan string
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
type notifierBox struct{ n Notifier }
// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64
// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
e.notifier.Store(notifierBox{n: n})
}
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
fireChs: map[string]chan string{},
}
}
// ── reference / value helpers ──────────────────────────────────────────────────
func refKey(ds, name string) string { return ds + "\x00" + name }
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
func parseRef(target string) (ds, name string, ok bool) {
t := strings.TrimSpace(target)
if t == "" {
return "", "", false
}
i := strings.IndexByte(t, ':')
if i < 0 {
return "local", t, true
}
return t[:i], t[i+1:], true
}
func toNum(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int:
return float64(x)
case bool:
if x {
return 1
}
return 0
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
if err != nil {
return math.NaN()
}
return f
default:
return math.NaN()
}
}
// ── lifecycle ───────────────────────────────────────────────────────────────
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
// graph mutation). It is a no-op-safe full restart of the running generation.
func (e *Engine) Reload() {
e.mu.Lock()
defer e.mu.Unlock()
// Tear down the previous generation and wait for its goroutines to exit.
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
if e.wg != nil {
e.wg.Wait()
e.wg = nil
}
e.liveMu.Lock()
e.live = map[string]float64{}
e.liveMu.Unlock()
graphs := e.store.List()
var compiled []*compiledGraph
refs := map[string]RefLite{}
for i := range graphs {
g := graphs[i]
if !g.Enabled {
continue
}
cg := compile(g)
compiled = append(compiled, cg)
for k, r := range cg.refs {
refs[k] = r
}
}
if len(compiled) == 0 {
return
}
genCtx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
e.cancel = cancel
e.wg = wg
for _, cg := range compiled {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
e.registerFire(cg.id, cg.fireCh)
}
// One shared updates channel feeds a single dispatch goroutine; every
// subscription delivers into it. Subscriptions are released on teardown.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
wg.Add(1)
go func() {
defer wg.Done()
<-genCtx.Done()
for _, u := range unsubs {
u()
}
for _, cg := range compiled {
e.unregisterFire(cg.id, cg.fireCh)
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
for _, cg := range compiled {
cg.onSignal(key, val)
}
case <-genCtx.Done():
return
}
}
}()
// Start timer and cron triggers.
for _, cg := range compiled {
cg.startTriggers()
}
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
}
// liveGet reads the current value of a signal from the shared cache.
func (e *Engine) liveGet(ds, name string) float64 {
if ds == "sys" {
if name == "time" {
return float64(time.Now().UnixNano()) / 1e9
}
return math.NaN() // sys:dt handled per-activation
}
e.liveMu.RLock()
defer e.liveMu.RUnlock()
v, ok := e.live[refKey(ds, name)]
if !ok {
return math.NaN()
}
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 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)
}
// applyConfig resolves a config instance + its set and writes every parameter
// to its target signal via the owning data source (confmgr.Apply). Each write is
// audited. A bare instance id or a missing config store is a no-op (logged).
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
if e.cfg == nil || instanceID == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
return
}
write := func(ds, signal string, value any) error {
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: signal,
Value: formatAny(value),
Detail: "control logic config apply: " + inst.Name,
Outcome: audit.OutcomeOK,
}
var werr error
if ds == "local" {
cg.setLocal(signal, toNum(value))
} else if src, ok := e.broker.Source(ds); ok {
werr = src.Write(e.root, signal, value)
} else {
werr = errUnknownSource
}
if werr != nil {
ev.Outcome = audit.OutcomeError
ev.Error = werr.Error()
}
e.audit.Record(ev)
return werr
}
confmgr.Apply(set, inst, write)
}
// readConfigParam resolves a single parameter's value from a config instance,
// coercing it to float64 for use as a control-logic value. Returns ok=false if
// the store/instance/param is missing or the value isn't numeric.
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
if e.cfg == nil || instanceID == "" || key == "" {
return 0, false
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
return 0, false
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
return 0, false
}
for _, p := range set.Parameters {
if p.Key != key {
continue
}
v, ok := inst.Resolve(p)
if !ok {
return 0, false
}
f := toNum(v)
if math.IsNaN(f) {
return 0, false
}
return f, true
}
return 0, false
}
// writeConfigParam sets a single parameter's value on a config instance and
// saves a new revision (git-style). The value is coerced to the parameter's
// declared type (numeric/bool/string) so it validates against the set. A
// missing store/instance/param is a no-op (logged). Audited.
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
if e.cfg == nil || instanceID == "" || key == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
return
}
var param *confmgr.Parameter
for i := range set.Parameters {
if set.Parameters[i].Key == key {
param = &set.Parameters[i]
break
}
}
if param == nil {
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
return
}
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[key] = coerceParamValue(*param, val)
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.update",
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
Outcome: audit.OutcomeOK,
}
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// createConfigInstance creates a new config instance for setID, optionally
// copying values from an existing instance (fromID). A missing store/set is a
// no-op (logged). Audited. The new instance's id is logged but not written back
// to a flow variable (control-logic variables are numeric, ids are strings).
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
if e.cfg == nil || setID == "" {
return
}
if name == "" {
name = "auto"
}
values := map[string]any{}
if fromID != "" {
if src, err := e.cfg.GetInstance(fromID); err == nil {
for k, v := range src.Values {
values[k] = v
}
} else {
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
}
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.create",
Detail: "control logic config create: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID
}
e.audit.Record(ev)
}
// snapshotConfig captures the current value of every target signal of a set and
// stores them as a new config instance. A missing store/set is a no-op (logged).
// Audited. The new instance's id is logged but not written back to a flow
// variable (control-logic variables are numeric, ids are strings).
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
if e.cfg == nil || setID == "" {
return
}
set, err := e.cfg.GetSet(setID)
if err != nil {
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
return
}
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
defer cancel()
read := func(ds, signal string) (any, error) {
if ds == "local" {
return cg.getLocal(signal), nil
}
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
if err != nil {
return nil, err
}
return v.Data, nil
}
snap := confmgr.Snapshot(set, read)
if name == "" {
name = set.Name + " snapshot"
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.snapshot",
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
}
e.audit.Record(ev)
}
// coerceParamValue converts a numeric flow value to the Go type a config
// parameter expects, so the resulting instance validates against its set.
func coerceParamValue(p confmgr.Parameter, val float64) any {
switch p.Type {
case confmgr.TypeInt:
return int64(val)
case confmgr.TypeBool:
return val != 0
case confmgr.TypeString, confmgr.TypeEnum:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
return val
}
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
func (e *Engine) emitDialog(n Node) {
box, _ := e.notifier.Load().(notifierBox)
if box.n == nil {
return
}
kind := strings.TrimSpace(n.param("kind"))
if kind != "error" && kind != "input" {
kind = "info"
}
box.n.Notify(Dialog{
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
Kind: kind,
Title: n.param("title"),
Message: n.param("message"),
Target: strings.TrimSpace(n.param("target")),
Users: splitCSV(n.param("users")),
Groups: splitCSV(n.param("groups")),
})
}
// ── compiled graph ─────────────────────────────────────────────────────────────
type wireOut struct {
to string
port string
}
// compiledGraph holds the runtime state for one enabled graph.
type compiledGraph struct {
engine *Engine
genCtx context.Context
wg *sync.WaitGroup
// dryRun suppresses real side effects (data-source writes, config mutations,
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
// fire regardless of debugWatch (the sandbox is its own subscriber).
dryRun bool
alwaysDebug bool
id string
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
// fireCh receives node ids of triggers the debug UI wants to fire manually.
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
// in activate always happens on a tracked goroutine.
fireCh chan string
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
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]Value
decls map[string]StateVar
}
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
id: g.ID,
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
inc: map[string][]string{},
refs: map[string]RefLite{},
watchers: map[string][]string{},
luaNodes: map[string]*luaRuntime{},
levelState: map[string]bool{},
prevBool: map[string]bool{},
prevVal: map[string]float64{},
hasVal: map[string]bool{},
lastFire: map[string]int64{},
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 == "" {
port = "out"
}
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
cg.inc[w.To] = append(cg.inc[w.To], w.From)
}
want := func(ds, name string) {
if ds == "sys" || ds == "local" {
return
}
if name == "" {
return
}
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
}
wantExpr := func(expr string) {
for _, r := range CollectRefs(expr) {
want(r.DS, r.Name)
}
}
for _, n := range g.Nodes {
switch n.Kind {
case "trigger.threshold", "trigger.change", "trigger.alarm":
if ds, name, ok := parseRef(n.param("signal")); ok {
want(ds, name)
key := refKey(ds, name)
cg.watchers[key] = append(cg.watchers[key], n.ID)
}
case "flow.if":
wantExpr(n.param("cond"))
case "flow.loop":
if n.param("mode") == "while" {
wantExpr(n.param("cond"))
}
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")) {
want(r.DS, r.Name)
}
}
}
return cg
}
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) Value {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0.0
}
return v
}
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
// are actual triggers in this graph.
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
for {
select {
case id := <-cg.fireCh:
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
cg.activate(id)
}
case <-cg.genCtx.Done():
return
}
}
}()
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
case "trigger.timer":
node := n
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
d := intervalOf(node)
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-t.C:
cg.activate(node.ID)
case <-cg.genCtx.Done():
return
}
}
}()
case "trigger.cron":
hasCron = true
}
}
if hasCron {
cg.startCron()
}
}
func (cg *compiledGraph) startCron() {
type cronNode struct {
id string
sched *Schedule
}
var crons []cronNode
for _, n := range cg.byId {
if n.Kind != "trigger.cron" {
continue
}
sched, err := ParseSchedule(n.param("spec"))
if err != nil {
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
continue
}
crons = append(crons, cronNode{id: n.ID, sched: sched})
}
if len(crons) == 0 {
return
}
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
t := time.NewTicker(time.Second)
defer t.Stop()
lastMinute := -1
for {
select {
case now := <-t.C:
minute := now.Hour()*60 + now.Minute()
if minute == lastMinute {
continue // fire at most once per minute
}
lastMinute = minute
for _, c := range crons {
if c.sched.Match(now) {
cg.activate(c.id)
}
}
case <-cg.genCtx.Done():
return
}
}
}()
}
func intervalOf(n Node) time.Duration {
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
if err != nil || ms < 50 {
ms = 1000
}
return time.Duration(ms) * time.Millisecond
}
// ── trigger evaluation ─────────────────────────────────────────────────────────
// onSignal drives threshold/alarm (rising edge) and change triggers when a
// watched signal updates.
func (cg *compiledGraph) onSignal(key string, value float64) {
for _, id := range cg.watchers[key] {
node, ok := cg.byId[id]
if !ok {
continue
}
switch node.Kind {
case "trigger.threshold":
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.alarm":
lo := parseFloat(node.param("min"))
hi := parseFloat(node.param("max"))
cur := !math.IsNaN(value) && (value < lo || value > hi)
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.change":
cg.stateMu.Lock()
had := cg.hasVal[id]
prev := cg.prevVal[id]
cg.prevVal[id] = value
cg.hasVal[id] = true
cg.stateMu.Unlock()
if had && value != prev {
cg.activate(id)
}
}
}
}
func testThreshold(val float64, op string, cmp float64) bool {
if math.IsNaN(val) {
return false
}
switch op {
case "<":
return val < cmp
case ">=":
return val >= cmp
case "<=":
return val <= cmp
case "==":
return val == cmp
case "!=":
return val != cmp
default: // ">"
return val > cmp
}
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}
// ── execution ──────────────────────────────────────────────────────────────────
type runCtx struct {
fired string
steps int
resolve Resolver
}
// activate spawns a flow run for a trigger on its own goroutine so that
// action.delay does not block signal dispatch or other flows.
func (cg *compiledGraph) activate(triggerID string) {
now := time.Now().UnixNano()
cg.stateMu.Lock()
last, had := cg.lastFire[triggerID]
cg.lastFire[triggerID] = now
cg.stateMu.Unlock()
dt := 0.0
if had {
dt = float64(now-last) / 1e9
}
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)
}
}
cg.emitDebug(triggerID, 0, false)
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
ctx := &runCtx{fired: triggerID, resolve: resolve}
cg.follow(triggerID, "out", ctx)
}()
}
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
for _, w := range cg.out[fromID] {
if w.port == port {
cg.run(w.to, ctx)
}
}
}
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
if ctx.steps > maxSteps {
return
}
ctx.steps++
node, ok := cg.byId[nodeID]
if !ok {
return
}
select {
case <-cg.genCtx.Done():
return
default:
}
cg.emitDebug(node.ID, 0, false)
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
cg.follow(node.ID, "out", ctx)
}
case "flow.if":
branch := "else"
pass := EvalBool(node.param("cond"), ctx.resolve)
if pass {
branch = "then"
}
v := 0.0
if pass {
v = 1
}
cg.emitDebug(node.ID, v, true)
cg.follow(node.ID, branch, ctx)
case "flow.loop":
if node.param("mode") == "while" {
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
cg.follow(node.ID, "body", ctx)
}
} else {
n := int(EvalExpr(node.param("count"), ctx.resolve))
if n < 0 {
n = 0
}
if n > maxLoop {
n = maxLoop
}
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
cg.follow(node.ID, "body", ctx)
}
}
cg.follow(node.ID, "done", ctx)
case "action.write":
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)
case "action.config.apply":
if !cg.dryRun {
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.read":
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
if ok {
cg.engine.write(cg, node.param("target"), v)
}
cg.follow(node.ID, "out", ctx)
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
if !cg.dryRun {
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
}
cg.follow(node.ID, "out", ctx)
case "action.config.create":
if !cg.dryRun {
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
if !cg.dryRun {
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
}
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
ms = v
}
if ms > 0 {
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
select {
case <-t.C:
case <-cg.genCtx.Done():
t.Stop()
return
}
}
cg.follow(node.ID, "out", ctx)
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
label := strings.TrimSpace(node.param("label"))
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
cg.follow(node.ID, "out", ctx)
case "action.lua":
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
case "action.dialog":
if !cg.dryRun {
cg.engine.emitDialog(node)
}
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)
}
}
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
inputs := cg.inc[gateID]
if len(inputs) == 0 {
return false
}
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
for _, src := range inputs {
if src == fired || cg.levelState[src] {
continue
}
return false
}
return true
}
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
// the engine can subscribe to the signals the script reads.
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
func luaGetRefs(script string) []RefLite {
var out []RefLite
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
if ds, name, ok := parseRef(m[1]); ok {
out = append(out, RefLite{DS: ds, Name: name})
}
}
return out
}
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
lr := cg.luaNodes[nodeID]
if lr == nil {
return
}
lr.run(ctx.resolve, func(target string, val float64) {
cg.engine.write(cg, target, val)
}, func(msg string) {
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
})
}