175 lines
4.9 KiB
Go
175 lines
4.9 KiB
Go
package controllogic
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/broker"
|
|
)
|
|
|
|
// DebugEvent reports a single node execution for the live debug view. Value is
|
|
// only meaningful when HasValue is true (e.g. an action.write's written value or
|
|
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
|
|
type DebugEvent struct {
|
|
GraphID string `json:"graphId"`
|
|
NodeID string `json:"nodeId"`
|
|
Value float64 `json:"value"`
|
|
HasValue bool `json:"hasValue"`
|
|
TS int64 `json:"ts"` // unix millis
|
|
}
|
|
|
|
// DebugObserver receives node-execution events from running (and simulated)
|
|
// graphs. The server implements it; Observe must not block (the hub fans out
|
|
// drop-on-full so a slow editor never stalls the engine).
|
|
type DebugObserver interface {
|
|
Observe(DebugEvent)
|
|
}
|
|
|
|
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
|
|
type debugObsBox struct{ o DebugObserver }
|
|
|
|
// SetDebugObserver installs the sink for node-execution events. Safe to call
|
|
// once at startup; read lock-free by running flows.
|
|
func (e *Engine) SetDebugObserver(o DebugObserver) {
|
|
e.debugObs.Store(debugObsBox{o: o})
|
|
}
|
|
|
|
// SetDebugWatch replaces the set of graph ids with at least one live debug
|
|
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
|
|
// common (nobody watching) case costs a single atomic load. The hub owns the
|
|
// map and must not mutate it after publishing (it is read without a lock).
|
|
func (e *Engine) SetDebugWatch(ids map[string]bool) {
|
|
e.debugWatch.Store(ids)
|
|
}
|
|
|
|
// registerFire records a compiled graph's manual-fire channel under its route id
|
|
// (live graph id or simulate sandbox id).
|
|
func (e *Engine) registerFire(id string, ch chan string) {
|
|
e.fireMu.Lock()
|
|
e.fireChs[id] = ch
|
|
e.fireMu.Unlock()
|
|
}
|
|
|
|
// unregisterFire removes id's fire channel, but only if it still points at ch —
|
|
// so a newer generation that reused the same id (live reload) is not clobbered
|
|
// by the old generation's teardown.
|
|
func (e *Engine) unregisterFire(id string, ch chan string) {
|
|
e.fireMu.Lock()
|
|
if e.fireChs[id] == ch {
|
|
delete(e.fireChs, id)
|
|
}
|
|
e.fireMu.Unlock()
|
|
}
|
|
|
|
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
|
|
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
|
|
// false if the route is gone or its fire buffer is full. The receiving graph
|
|
// validates that triggerID is actually one of its trigger nodes.
|
|
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
|
|
e.fireMu.Lock()
|
|
ch := e.fireChs[graphID]
|
|
e.fireMu.Unlock()
|
|
if ch == nil || triggerID == "" {
|
|
return false
|
|
}
|
|
select {
|
|
case ch <- triggerID:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
if !cg.alwaysDebug {
|
|
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
|
|
if !w[cg.id] {
|
|
return
|
|
}
|
|
}
|
|
box, _ := cg.engine.debugObs.Load().(debugObsBox)
|
|
if box.o == nil {
|
|
return
|
|
}
|
|
box.o.Observe(DebugEvent{
|
|
GraphID: cg.id,
|
|
NodeID: nodeID,
|
|
Value: value,
|
|
HasValue: hasValue,
|
|
TS: time.Now().UnixMilli(),
|
|
})
|
|
}
|
|
|
|
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
|
|
// suppressed (data-source writes, config mutations, dialogs) but local vars,
|
|
// triggers, timers and the flow itself execute normally, emitting debug events
|
|
// the editor can visualise. It is independent of Reload's live generation. The
|
|
// returned stop func cancels the sandbox and waits for its goroutines to drain;
|
|
// it is safe to call more than once.
|
|
func (e *Engine) StartSimulate(g Graph) func() {
|
|
cg := compile(g)
|
|
cg.engine = e
|
|
cg.dryRun = true
|
|
cg.alwaysDebug = true
|
|
|
|
ctx, cancel := context.WithCancel(e.root)
|
|
wg := &sync.WaitGroup{}
|
|
cg.genCtx = ctx
|
|
cg.wg = wg
|
|
|
|
// Sandbox-local live cache so simulate reads don't disturb the live engine.
|
|
updates := make(chan broker.Update, 128)
|
|
var unsubs []func()
|
|
for _, r := range cg.refs {
|
|
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
|
|
if err != nil {
|
|
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
|
|
continue
|
|
}
|
|
unsubs = append(unsubs, unsub)
|
|
}
|
|
|
|
e.registerFire(cg.id, cg.fireCh)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-ctx.Done()
|
|
for _, u := range unsubs {
|
|
u()
|
|
}
|
|
e.unregisterFire(cg.id, cg.fireCh)
|
|
}()
|
|
|
|
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()
|
|
cg.onSignal(key, val)
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
cg.startTriggers()
|
|
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
cancel()
|
|
wg.Wait()
|
|
})
|
|
}
|
|
}
|