Phase 15: snapshots, forks, export/import, cognitive replay, experiments

- snapshots: checksummed JSON archive of every mind-scoped state table plus
  identity/personality/config and the event position; stored, downloadable
- fork: restore into a new mind with all ids remapped; event references are
  kept so history before the fork is shared by reference
- export/import: same archive; import is hostile-input validated (size,
  allow-listed tables, row caps, column names, checksum, forced mind_id)
- compare: deterministic counts, belief diffs by proposition, personality
  and config diffs, event mix
- replay: Runtime.Step for synchronous drivers; sandbox fork driven one
  stimulus at a time under a forbidden capability context; recorded model
  responses via a replaying router; steps labelled exact / approximate /
  diverged with recorded-vs-replayed digests
- experiments: definition (overrides, stimuli, expectations, seed, duration),
  background run in a disabled subject mind, stored and exportable results
- migration 0015, API, UI (Snapshots and Replay tabs, Experiments section,
  compare page), noemactl snapshots/compare/replay/experiments, docs
This commit is contained in:
Andy Dixon
2026-07-03 22:12:31 +01:00
parent 2ac8752bac
commit 072422d916
34 changed files with 3140 additions and 7 deletions
+2
View File
@@ -36,6 +36,7 @@ import (
"github.com/andydixon/noema/internal/reflection"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
@@ -61,6 +62,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}}
deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log}
deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds}
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+8
View File
@@ -0,0 +1,8 @@
package api
import (
"bytes"
"io"
)
func bytesReader(b []byte) io.Reader { return bytes.NewReader(b) }
+1
View File
@@ -69,6 +69,7 @@ func (s *Server) routes() {
s.capabilityRoutes()
s.integrationRoutes()
s.dreamRoutes()
s.studyRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+429
View File
@@ -0,0 +1,429 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/experiments"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/replay"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/pkg/ids"
)
// studyRoutes: snapshots, forks, export/import, comparison, replay, experiments.
func (s *Server) studyRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/snapshots", require(auth.RoleObserver, s.snapshotsList))
m.Handle("POST /api/v1/minds/{mind}/snapshots", require(auth.RoleOperator, s.snapshotCreate))
m.Handle("GET /api/v1/snapshots/{id}", require(auth.RoleObserver, s.snapshotGet))
m.Handle("GET /api/v1/snapshots/{id}/archive", require(auth.RoleOperator, s.snapshotArchive))
m.Handle("POST /api/v1/snapshots/{id}/fork", require(auth.RoleAdministrator, s.snapshotFork))
m.Handle("DELETE /api/v1/snapshots/{id}", require(auth.RoleAdministrator, s.snapshotDelete))
m.Handle("GET /api/v1/minds/{mind}/export", require(auth.RoleOperator, s.mindExport))
m.Handle("POST /api/v1/minds/import-archive", require(auth.RoleAdministrator, s.mindImportArchive))
m.Handle("GET /api/v1/minds/{mind}/compare/{other}", require(auth.RoleObserver, s.mindCompare))
m.Handle("GET /api/v1/minds/{mind}/replays", require(auth.RoleObserver, s.replaysList))
m.Handle("POST /api/v1/minds/{mind}/replays", require(auth.RoleOperator, s.replayStart))
m.Handle("GET /api/v1/replays/{id}", require(auth.RoleObserver, s.replayGet))
m.Handle("GET /api/v1/experiments", require(auth.RoleObserver, s.experimentsList))
m.Handle("POST /api/v1/experiments", require(auth.RoleOperator, s.experimentCreate))
m.Handle("GET /api/v1/experiments/{id}", require(auth.RoleObserver, s.experimentGet))
m.Handle("POST /api/v1/experiments/{id}/run", require(auth.RoleOperator, s.experimentRun))
m.Handle("GET /api/v1/experiments/{id}/export", require(auth.RoleObserver, s.experimentExport))
m.Handle("DELETE /api/v1/experiments/{id}", require(auth.RoleAdministrator, s.experimentDelete))
}
func (s *Server) snapshotsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Snapshots.List(r.Context(), m.ID, 100)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []snapshots.Snapshot{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// CreateSnapshot captures a mind; shared with the UI.
func (s *Server) CreateSnapshot(ctx context.Context, m minds.Mind, name, note, by string) (snapshots.Snapshot, error) {
sn, err := s.deps.Snapshots.Create(ctx, m, name, note, by)
if err != nil {
return sn, err
}
s.deps.Audit.Log(ctx, "snapshot.create", "snapshot", sn.ID.String(), map[string]any{"mind": m.Slug, "name": sn.Name, "event_seq": sn.EventSeq})
s.deps.Events.OperatorAction(ctx, m.ID, by, "snapshot.create", "Operator "+by+" took snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "event_seq": sn.EventSeq})
return sn, nil
}
func (s *Server) snapshotCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct{ Name, Note string }
decode(r, &in) //nolint:errcheck // both optional
sn, err := s.CreateSnapshot(r.Context(), m, in.Name, in.Note, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, sn)
}
func (s *Server) snapshotByPath(w http.ResponseWriter, r *http.Request) (snapshots.Snapshot, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad snapshot id"))
return snapshots.Snapshot{}, false
}
sn, err := s.deps.Snapshots.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return sn, false
}
return sn, true
}
func (s *Server) snapshotGet(w http.ResponseWriter, r *http.Request) {
if sn, ok := s.snapshotByPath(w, r); ok {
writeJSON(w, http.StatusOK, sn)
}
}
func (s *Server) snapshotArchive(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
a, err := s.deps.Snapshots.Archive(r.Context(), sn.ID)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "snapshot.export", "snapshot", sn.ID.String(), nil)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+a.Mind.Slug+"-"+sn.ID.String()[len(sn.ID.String())-8:]+`.noema.json"`)
json.NewEncoder(w).Encode(a) //nolint:errcheck
}
// ForkSnapshot creates a mind from a snapshot; shared with the UI.
func (s *Server) ForkSnapshot(ctx context.Context, sn snapshots.Snapshot, slug, display, by string) (minds.Mind, error) {
m, err := s.deps.Snapshots.Fork(ctx, sn.ID, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by})
if err != nil {
return m, mindErr(err)
}
s.deps.Audit.Log(ctx, "mind.fork", "mind", m.ID.String(), map[string]any{"snapshot": sn.ID, "source": sn.MindID, "slug": m.Slug})
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.fork", "Mind forked from snapshot "+sn.Name+" (history before event "+strconv.FormatInt(sn.EventSeq, 10)+" is shared with the source)", map[string]any{"snapshot_id": sn.ID, "source_mind": sn.MindID, "event_seq": sn.EventSeq})
s.deps.Events.OperatorAction(ctx, sn.MindID, by, "mind.fork", "Operator "+by+" forked this mind into "+m.Slug+" from snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "fork_mind": m.ID})
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
return m, nil
}
func (s *Server) snapshotFork(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
m, err := s.ForkSnapshot(r.Context(), sn, in.Slug, in.DisplayName, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) snapshotDelete(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
if err := s.deps.Snapshots.Delete(r.Context(), sn.ID); err != nil {
fail(w, r, ValidationError("snapshot is referenced by a replay or experiment"))
return
}
s.deps.Audit.Log(r.Context(), "snapshot.delete", "snapshot", sn.ID.String(), map[string]any{"name": sn.Name})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) mindExport(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
a, err := snapshots.Capture(r.Context(), s.deps.DB, s.deps.Events, m)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "mind.export", "mind", m.ID.String(), map[string]any{"event_seq": a.Source.EventSeq})
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+m.Slug+`.noema.json"`)
json.NewEncoder(w).Encode(a) //nolint:errcheck
}
// ImportArchive restores a hostile archive into a new mind; shared with the UI.
func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, by string) (minds.Mind, error) {
if len(raw) > snapshots.MaxArchiveBytes {
return minds.Mind{}, ValidationError("archive too large")
}
var a snapshots.Archive
dec := json.NewDecoder(bytesReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&a); err != nil {
return minds.Mind{}, ValidationError("invalid archive: " + err.Error())
}
m, err := s.deps.Snapshots.Import(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug})
if err != nil {
if errors.Is(err, snapshots.ErrInvalid) {
return m, ValidationError(err.Error())
}
return m, mindErr(err)
}
s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts()})
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", "Mind imported from archive of "+a.Source.Slug+" ("+a.Checksum+")", map[string]any{"counts": a.Counts()})
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
return m, nil
}
func (s *Server) mindImportArchive(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Archive json.RawMessage `json:"archive"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, snapshots.MaxArchiveBytes+4096))
if err := dec.Decode(&in); err != nil {
fail(w, r, ValidationError("invalid request: "+err.Error()))
return
}
m, err := s.ImportArchive(r.Context(), in.Archive, in.Slug, in.DisplayName, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) mindCompare(w http.ResponseWriter, r *http.Request) {
a, ok := s.mind(w, r)
if !ok {
return
}
b, err := s.deps.Minds.Resolve(r.Context(), r.PathValue("other"))
if err != nil {
fail(w, r, err)
return
}
c, err := s.deps.Snapshots.Compare(r.Context(), a, b)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"a": a.Slug, "b": b.Slug, "comparison": c})
}
func (s *Server) replaysList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Replay.List(r.Context(), m.ID, 50)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []replay.Replay{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// StartReplay records a replay and executes it in the background; shared with the UI.
func (s *Server) StartReplay(ctx context.Context, m minds.Mind, snapshotID ids.ID, from, to int64, by string) (replay.Replay, error) {
rp, err := s.deps.Replay.Begin(ctx, m, snapshotID, from, to, by)
if err != nil {
return rp, ValidationError(err.Error())
}
s.deps.Audit.Log(ctx, "replay.start", "replay", rp.ID.String(), map[string]any{"mind": m.Slug, "snapshot": snapshotID, "from": rp.FromSeq, "to": rp.ToSeq})
bg := context.WithoutCancel(ctx)
go func() {
if _, err := s.deps.Replay.Execute(bg, rp); err != nil {
s.deps.Log.Warn("replay failed", "replay", rp.ID, "err", err)
}
}()
return rp, nil
}
func (s *Server) replayStart(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
SnapshotID ids.ID `json:"snapshot_id"`
FromSeq int64 `json:"from_seq"`
ToSeq int64 `json:"to_seq"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
rp, err := s.StartReplay(r.Context(), m, in.SnapshotID, in.FromSeq, in.ToSeq, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, rp)
}
func (s *Server) replayGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad replay id"))
return
}
rp, err := s.deps.Replay.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, rp)
}
func (s *Server) experimentsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Experiments.List(r.Context(), 100)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []experiments.Experiment{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// ExperimentInput is the create body (API and UI).
type ExperimentInput struct {
Name string `json:"name"`
Hypothesis string `json:"hypothesis"`
SnapshotID ids.ID `json:"snapshot_id"`
Definition experiments.Definition `json:"definition"`
}
// CreateExperiment stores a draft; shared with the UI.
func (s *Server) CreateExperiment(ctx context.Context, in ExperimentInput, by string) (experiments.Experiment, error) {
e, err := s.deps.Experiments.Create(ctx, in.Name, in.Hypothesis, in.SnapshotID, in.Definition, by)
if err != nil {
return e, ValidationError(err.Error())
}
s.deps.Audit.Log(ctx, "experiment.create", "experiment", e.ID.String(), map[string]any{"name": e.Name, "snapshot": e.SnapshotID})
return e, nil
}
func (s *Server) experimentCreate(w http.ResponseWriter, r *http.Request) {
var in ExperimentInput
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
e, err := s.CreateExperiment(r.Context(), in, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) experimentByPath(w http.ResponseWriter, r *http.Request) (experiments.Experiment, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad experiment id"))
return experiments.Experiment{}, false
}
e, err := s.deps.Experiments.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return e, false
}
return e, true
}
func (s *Server) experimentGet(w http.ResponseWriter, r *http.Request) {
if e, ok := s.experimentByPath(w, r); ok {
writeJSON(w, http.StatusOK, e)
}
}
// RunExperiment starts a run in the background; shared with the UI.
func (s *Server) RunExperiment(ctx context.Context, e experiments.Experiment, by string) error {
if e.Status == "running" {
return ValidationError("experiment is already running")
}
s.deps.Audit.Log(ctx, "experiment.run", "experiment", e.ID.String(), map[string]any{"name": e.Name})
bg := context.WithoutCancel(ctx)
go func() {
if _, err := s.deps.Experiments.Run(bg, e.ID, by); err != nil {
s.deps.Log.Warn("experiment failed", "experiment", e.ID, "err", err)
}
}()
return nil
}
func (s *Server) experimentRun(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
if err := s.RunExperiment(r.Context(), e, actor(r)); err != nil {
fail(w, r, err)
return
}
e.Status = "running"
writeJSON(w, http.StatusAccepted, e)
}
func (s *Server) experimentExport(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
out, err := s.deps.Experiments.Export(r.Context(), e.ID)
if err != nil {
fail(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="experiment-`+e.ID.String()[len(e.ID.String())-8:]+`.json"`)
json.NewEncoder(w).Encode(out) //nolint:errcheck
}
func (s *Server) experimentDelete(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
if err := s.deps.Experiments.Delete(r.Context(), e.ID); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "experiment.delete", "experiment", e.ID.String(), map[string]any{"name": e.Name})
w.WriteHeader(http.StatusNoContent)
}