Implemented confi snapshots
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// writableSource is a minimal in-memory DataSource that records the last value
|
||||
// written to each signal, so config-apply/read nodes can be tested end-to-end.
|
||||
type writableSource struct {
|
||||
mu sync.Mutex
|
||||
written map[string]any
|
||||
}
|
||||
|
||||
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
|
||||
|
||||
func (s *writableSource) Name() string { return "tgt" }
|
||||
func (s *writableSource) Connect(context.Context) error { return nil }
|
||||
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: sig, Writable: true}, nil
|
||||
}
|
||||
// Subscribe delivers the signal's last-written value once (if any), so a
|
||||
// one-shot ReadNow (used by config snapshot) resolves immediately.
|
||||
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
s.mu.Lock()
|
||||
v, ok := s.written[sig]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- datasource.Value{Data: v, Timestamp: time.Now()}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return func() {}, nil
|
||||
}
|
||||
func (s *writableSource) Write(_ context.Context, signal string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.written[signal] = value
|
||||
return nil
|
||||
}
|
||||
func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
func (s *writableSource) get(signal string) (any, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.written[signal]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr
|
||||
// store seeded with a set + instance, and an Engine bound to both.
|
||||
func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
src := newWritableSource()
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
|
||||
cfg, err := confmgr.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("confmgr.New:", err)
|
||||
}
|
||||
set, err := cfg.CreateSet(confmgr.ConfigSet{
|
||||
Name: "tuning",
|
||||
Parameters: []confmgr.Parameter{
|
||||
{Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0},
|
||||
{Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0},
|
||||
},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal("CreateSet:", err)
|
||||
}
|
||||
inst, err := cfg.CreateInstance(confmgr.ConfigInstance{
|
||||
Name: "warm",
|
||||
SetID: set.ID,
|
||||
Values: map[string]any{"gain": 2.5, "offset": 10.0},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal("CreateInstance:", err)
|
||||
}
|
||||
|
||||
e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log)
|
||||
return e, src, inst.ID
|
||||
}
|
||||
|
||||
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.applyConfig(cg, instID)
|
||||
|
||||
if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 {
|
||||
t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok)
|
||||
}
|
||||
if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 {
|
||||
t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.applyConfig(cg, "does-not-exist")
|
||||
|
||||
if len(src.written) != 0 {
|
||||
t.Errorf("expected no writes, got %v", src.written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
|
||||
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 {
|
||||
t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok)
|
||||
}
|
||||
// Missing param key.
|
||||
if _, ok := e.readConfigParam(instID, "nope"); ok {
|
||||
t.Errorf("readConfigParam(nope) returned ok=true, want false")
|
||||
}
|
||||
// Missing instance.
|
||||
if _, ok := e.readConfigParam("does-not-exist", "gain"); ok {
|
||||
t.Errorf("readConfigParam(missing instance) returned ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.writeConfigParam(cg, instID, "gain", 7.5)
|
||||
|
||||
// A new revision should now resolve to the written value.
|
||||
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 {
|
||||
t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok)
|
||||
}
|
||||
inst, err := e.cfg.GetInstance(instID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
if inst.Version < 2 {
|
||||
t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateConfigInstance(t *testing.T) {
|
||||
e, _, srcID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
src, err := e.cfg.GetInstance(srcID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
|
||||
e.createConfigInstance(cg, src.SetID, "cold", srcID)
|
||||
|
||||
insts, err := e.cfg.List(confmgr.KindInstance)
|
||||
if err != nil {
|
||||
t.Fatal("List:", err)
|
||||
}
|
||||
var found *confmgr.ConfigInstance
|
||||
for i := range insts {
|
||||
if insts[i].Name == "cold" {
|
||||
full, err := e.cfg.GetInstance(insts[i].ID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
found = &full
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("created instance 'cold' not found")
|
||||
}
|
||||
// Values copied from the source instance.
|
||||
if got := toNum(found.Values["gain"]); got != 2.5 {
|
||||
t.Errorf("copied gain = %v, want 2.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotConfig(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
// Seed current live values for the set's target signals.
|
||||
_ = src.Write(context.Background(), "GAIN", 3.5)
|
||||
_ = src.Write(context.Background(), "OFFSET", -2.0)
|
||||
|
||||
seed, err := e.cfg.GetInstance(instID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
e.snapshotConfig(cg, seed.SetID, "snap1")
|
||||
|
||||
insts, err := e.cfg.List(confmgr.KindInstance)
|
||||
if err != nil {
|
||||
t.Fatal("List:", err)
|
||||
}
|
||||
var found *confmgr.ConfigInstance
|
||||
for i := range insts {
|
||||
if insts[i].Name == "snap1" {
|
||||
full, err := e.cfg.GetInstance(insts[i].ID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
found = &full
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("snapshot instance 'snap1' not found")
|
||||
}
|
||||
if got := toNum(found.Values["gain"]); got != 3.5 {
|
||||
t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got)
|
||||
}
|
||||
if got := toNum(found.Values["offset"]); got != -2.0 {
|
||||
t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
@@ -13,8 +15,25 @@ import (
|
||||
|
||||
"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
|
||||
@@ -27,6 +46,7 @@ const (
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
cfg *confmgr.Store
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
@@ -59,13 +79,14 @@ func (e *Engine) SetNotifier(n Notifier) {
|
||||
|
||||
// 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, rec audit.Recorder, log *slog.Logger) *Engine {
|
||||
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,
|
||||
@@ -270,6 +291,237 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
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.
|
||||
@@ -673,6 +925,30 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.engine.write(cg, node.param("target"), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.apply":
|
||||
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.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.create":
|
||||
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":
|
||||
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 {
|
||||
|
||||
@@ -55,6 +55,8 @@ type Graph struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
|
||||
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
}
|
||||
|
||||
@@ -17,19 +17,25 @@ var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||
//
|
||||
// Git-style versioning: the live graph lives in controllogic.json (the current
|
||||
// revision), while every superseded revision is preserved as a backup file
|
||||
// {id}.vN.json under versionsDir. Promote/Fork build on these backups.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
trashDir string
|
||||
items map[string]Graph
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
trashDir string
|
||||
versionsDir string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
|
||||
items: map[string]Graph{},
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
|
||||
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
@@ -94,14 +100,46 @@ func (s *Store) Get(id string) (Graph, error) {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Save inserts or replaces a graph and persists the store.
|
||||
// Save inserts or replaces a graph and persists the store. When replacing an
|
||||
// existing graph the superseded revision is backed up as {id}.vN.json and the
|
||||
// new graph's Version is bumped; a brand-new graph starts at version 1.
|
||||
func (s *Store) Save(g Graph) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.items[g.ID]; ok {
|
||||
oldV := old.Version
|
||||
if oldV < 1 {
|
||||
oldV = 1
|
||||
old.Version = 1
|
||||
}
|
||||
if err := s.backupLocked(old); err != nil {
|
||||
return fmt.Errorf("back up control logic revision: %w", err)
|
||||
}
|
||||
g.Version = oldV + 1
|
||||
} else if g.Version < 1 {
|
||||
g.Version = 1
|
||||
}
|
||||
s.items[g.ID] = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// backupLocked writes a single graph revision to versionsDir as {id}.vN.json.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Store) backupLocked(g Graph) error {
|
||||
if err := os.MkdirAll(s.versionsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(g, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) versionPath(id string, version int) string {
|
||||
return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version))
|
||||
}
|
||||
|
||||
// Delete removes a graph by id, first writing a copy into the trash folder so it
|
||||
// can be recovered if needed. The trash backup is best-effort: a failure to write
|
||||
// it does not prevent the delete (but is reported).
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VersionMeta describes a single persisted revision of a control-logic graph,
|
||||
// mirroring storage.VersionMeta so the frontend can treat all versioned
|
||||
// document types uniformly.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision of the graph, newest
|
||||
// first. The live revision (held in controllogic.json) is flagged Current.
|
||||
func (s *Store) Versions(id string) ([]VersionMeta, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cur, ok := s.items[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
|
||||
var savedAt time.Time
|
||||
if info, err := os.Stat(s.path); err == nil {
|
||||
savedAt = info.ModTime()
|
||||
}
|
||||
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
|
||||
|
||||
entries, err := os.ReadDir(s.versionsDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json")
|
||||
v, err := strconv.Atoi(vStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g, err := s.readVersion(id, v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns a specific revision of the graph. The current revision is
|
||||
// served from the live store; older revisions come from their backup file.
|
||||
func (s *Store) GetVersion(id string, version int) (Graph, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cur, ok := s.items[id]
|
||||
if !ok {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
if version == curV {
|
||||
return cur, nil
|
||||
}
|
||||
return s.readVersion(id, version)
|
||||
}
|
||||
|
||||
// readVersion loads a backup revision file. Caller must hold s.mu.
|
||||
func (s *Store) readVersion(id string, version int) (Graph, error) {
|
||||
data, err := os.ReadFile(s.versionPath(id, version))
|
||||
if os.IsNotExist(err) {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
var g Graph
|
||||
if err := json.Unmarshal(data, &g); err != nil {
|
||||
return Graph{}, fmt.Errorf("parse revision: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Promote makes a past revision current by re-saving it on top of history. The
|
||||
// existing current revision is preserved as a backup, so promotion is
|
||||
// non-destructive. Returns the resulting (new current) graph.
|
||||
func (s *Store) Promote(id string, version int) (Graph, error) {
|
||||
g, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
g.Tag = fmt.Sprintf("restored from v%d", version)
|
||||
if err := s.Save(g); err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
// Fork creates a brand-new graph from a specific revision, assigning a fresh id
|
||||
// and resetting its version to 1. Returns the new graph.
|
||||
func (s *Store) Fork(id string, version int) (Graph, error) {
|
||||
g, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli())
|
||||
g.Version = 1
|
||||
g.Tag = ""
|
||||
if g.Name != "" {
|
||||
g.Name = g.Name + " (fork)"
|
||||
}
|
||||
if err := s.Save(g); err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package controllogic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestControlLogicVersioning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := Graph{ID: "cl-1", Name: "loop", Enabled: true,
|
||||
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v1: %v", err)
|
||||
}
|
||||
if got, _ := s.Get("cl-1"); got.Version != 1 {
|
||||
t.Fatalf("after create: version=%d", got.Version)
|
||||
}
|
||||
|
||||
// Two edits → v2, v3.
|
||||
g.Name = "loop-2"
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v2: %v", err)
|
||||
}
|
||||
g.Name = "loop-3"
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v3: %v", err)
|
||||
}
|
||||
if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" {
|
||||
t.Fatalf("current: version=%d name=%q", got.Version, got.Name)
|
||||
}
|
||||
|
||||
versions, err := s.Versions("cl-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("want 3 versions, got %d", len(versions))
|
||||
}
|
||||
if !versions[0].Current || versions[0].Version != 3 {
|
||||
t.Errorf("newest should be current v3: %+v", versions[0])
|
||||
}
|
||||
|
||||
v1, err := s.GetVersion("cl-1", 1)
|
||||
if err != nil || v1.Name != "loop" {
|
||||
t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err)
|
||||
}
|
||||
|
||||
// Promote v1 → v4.
|
||||
promoted, err := s.Promote("cl-1", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Promote: %v", err)
|
||||
}
|
||||
if promoted.Version != 4 || promoted.Name != "loop" {
|
||||
t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name)
|
||||
}
|
||||
|
||||
// Fork v3 → new id, version 1.
|
||||
forked, err := s.Fork("cl-1", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.Version != 1 || forked.ID == "cl-1" {
|
||||
t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version)
|
||||
}
|
||||
if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name {
|
||||
t.Errorf("forked graph not stored: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user