Files
noema/internal/ui/study.go
T
Andy Dixon 072422d916 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
2026-07-03 22:12:31 +01:00

294 lines
11 KiB
Go

package ui
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/api"
"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"
)
func (u *UI) studyRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/snapshots", u.require(auth.RoleObserver, u.snapshotsPage))
m.Handle("POST /minds/{mind}/snapshots", u.require(auth.RoleOperator, u.snapshotCreate))
m.Handle("POST /snapshots/{id}/fork", u.require(auth.RoleAdministrator, u.snapshotFork))
m.Handle("POST /snapshots/{id}/delete", u.require(auth.RoleAdministrator, u.snapshotDelete))
m.Handle("POST /minds/import-archive", u.require(auth.RoleAdministrator, u.importArchive))
m.Handle("GET /minds/{mind}/compare", u.require(auth.RoleObserver, u.comparePage))
m.Handle("GET /minds/{mind}/replays", u.require(auth.RoleObserver, u.replaysPage))
m.Handle("POST /minds/{mind}/replays", u.require(auth.RoleOperator, u.replayStart))
m.Handle("GET /replays/{id}", u.require(auth.RoleObserver, u.replayPage))
m.Handle("GET /experiments", u.require(auth.RoleObserver, u.experimentsPage))
m.Handle("POST /experiments", u.require(auth.RoleOperator, u.experimentCreate))
m.Handle("GET /experiments/{id}", u.require(auth.RoleObserver, u.experimentPage))
m.Handle("POST /experiments/{id}/run", u.require(auth.RoleOperator, u.experimentRun))
m.Handle("POST /experiments/{id}/delete", u.require(auth.RoleAdministrator, u.experimentDelete))
}
func (u *UI) snapshotsPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
list, _ := u.deps.Snapshots.List(r.Context(), m.ID, 100)
all, _ := u.deps.Minds.List(r.Context(), true)
var others []minds.Mind
for _, o := range all {
if o.ID != m.ID {
others = append(others, o)
}
}
var parent *minds.Mind
if m.ForkedFromMind != nil {
if p, err := u.deps.Minds.Get(r.Context(), *m.ForkedFromMind); err == nil {
parent = &p
}
}
u.render(w, r, "snapshots", m.DisplayName+" · Snapshots", map[string]any{"Mind": m, "Snapshots": list, "Others": others, "Parent": parent})
}
func (u *UI) snapshotCreate(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
sn, err := u.studyF().CreateSnapshot(r.Context(), m, r.PostFormValue("name"), r.PostFormValue("note"), username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/snapshots")
return
}
u.flash(w, r, "ok", "Snapshot "+sn.Name+" taken at event "+strconv.FormatInt(sn.EventSeq, 10)+".", "/minds/"+m.Slug+"/snapshots")
}
func (u *UI) snapshotByPath(w http.ResponseWriter, r *http.Request) (snapshots.Snapshot, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such snapshot.")
return snapshots.Snapshot{}, false
}
sn, err := u.deps.Snapshots.Get(r.Context(), id)
if err != nil {
u.errorPage(w, r, 404, "No such snapshot.")
return sn, false
}
return sn, true
}
func (u *UI) snapshotFork(w http.ResponseWriter, r *http.Request) {
sn, ok := u.snapshotByPath(w, r)
if !ok {
return
}
src, _ := u.deps.Minds.Get(r.Context(), sn.MindID)
m, err := u.studyF().ForkSnapshot(r.Context(), sn, r.PostFormValue("slug"), strings.TrimSpace(r.PostFormValue("display_name")), username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+src.Slug+"/snapshots")
return
}
u.flash(w, r, "ok", "Forked into "+m.DisplayName+". History before event "+strconv.FormatInt(sn.EventSeq, 10)+" is shared with "+src.DisplayName+".", "/minds/"+m.Slug)
}
func (u *UI) snapshotDelete(w http.ResponseWriter, r *http.Request) {
sn, ok := u.snapshotByPath(w, r)
if !ok {
return
}
src, _ := u.deps.Minds.Get(r.Context(), sn.MindID)
if err := u.deps.Snapshots.Delete(r.Context(), sn.ID); err != nil {
u.flash(w, r, "error", "Snapshot is referenced by a replay or experiment and cannot be deleted.", "/minds/"+src.Slug+"/snapshots")
return
}
u.deps.Audit.Log(r.Context(), "snapshot.delete", "snapshot", sn.ID.String(), map[string]any{"name": sn.Name})
u.flash(w, r, "ok", "Snapshot deleted.", "/minds/"+src.Slug+"/snapshots")
}
func (u *UI) importArchive(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
u.flash(w, r, "error", "Upload too large or malformed.", "/minds")
return
}
var raw []byte
if f, _, err := r.FormFile("archive"); err == nil {
defer f.Close()
raw, _ = io.ReadAll(io.LimitReader(f, snapshots.MaxArchiveBytes+1))
} else {
raw = []byte(r.PostFormValue("archive_json"))
}
m, err := u.studyF().ImportArchive(r.Context(), raw, r.PostFormValue("slug"), strings.TrimSpace(r.PostFormValue("display_name")), username(r))
if err != nil {
u.flash(w, r, "error", "Import refused: "+err.Error(), "/minds")
return
}
u.flash(w, r, "ok", "Imported "+m.DisplayName+".", "/minds/"+m.Slug)
}
func (u *UI) comparePage(w http.ResponseWriter, r *http.Request) {
a, ok := u.mind(w, r)
if !ok {
return
}
b, err := u.deps.Minds.Resolve(r.Context(), r.URL.Query().Get("with"))
if err != nil {
u.flash(w, r, "error", "Choose a mind to compare with.", "/minds/"+a.Slug+"/snapshots")
return
}
c, err := u.deps.Snapshots.Compare(r.Context(), a, b)
if err != nil {
u.errorPage(w, r, 500, err.Error())
return
}
u.render(w, r, "compare", a.DisplayName+" vs "+b.DisplayName, map[string]any{"Mind": a, "Other": b, "C": c})
}
func (u *UI) replaysPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
list, _ := u.deps.Replay.List(r.Context(), m.ID, 50)
snaps, _ := u.deps.Snapshots.List(r.Context(), m.ID, 100)
var lastSeq int64
if st, err := u.deps.Events.Stats(r.Context(), m.ID, time.Time{}); err == nil {
lastSeq = st.LastSeq
}
u.render(w, r, "replays", m.DisplayName+" · Replay", map[string]any{"Mind": m, "Replays": list, "Snapshots": snaps, "LastSeq": lastSeq})
}
func (u *UI) replayStart(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
snID, err := ids.Parse(r.PostFormValue("snapshot_id"))
if err != nil {
u.flash(w, r, "error", "Choose a snapshot.", "/minds/"+m.Slug+"/replays")
return
}
from, _ := strconv.ParseInt(r.PostFormValue("from_seq"), 10, 64)
to, _ := strconv.ParseInt(r.PostFormValue("to_seq"), 10, 64)
rp, err := u.studyF().StartReplay(r.Context(), m, snID, from, to, username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/replays")
return
}
u.flash(w, r, "ok", "Replay started; steps appear as they complete.", "/replays/"+rp.ID.String())
}
func (u *UI) replayPage(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such replay.")
return
}
rp, err := u.deps.Replay.Get(r.Context(), id)
if err != nil {
u.errorPage(w, r, 404, "No such replay.")
return
}
m, _ := u.deps.Minds.Get(r.Context(), rp.MindID)
sn, _ := u.deps.Snapshots.Get(r.Context(), rp.SnapshotID)
var sandbox *minds.Mind
if rp.ReplayMindID != nil {
if sb, err := u.deps.Minds.Get(r.Context(), *rp.ReplayMindID); err == nil {
sandbox = &sb
}
}
u.render(w, r, "replay", "Replay of "+m.DisplayName, map[string]any{"Mind": m, "Replay": rp, "Steps": replay.StepsOf(rp), "Snapshot": sn, "Sandbox": sandbox})
}
func (u *UI) experimentsPage(w http.ResponseWriter, r *http.Request) {
list, _ := u.deps.Experiments.List(r.Context(), 100)
snaps, _ := u.deps.Snapshots.List(r.Context(), ids.Nil, 200)
names, _ := u.mindNames(r)
sample, _ := json.MarshalIndent(experiments.Definition{Config: map[string]any{"workspace_capacity": 5}, Personality: map[string]any{"traits": map[string]any{"curiosity": 0.9}},
Stimuli: []experiments.Stimulus{{Text: "Deployment of the payments service finished at 09:10"}, {Text: "Latency alert on the payments service at 09:12"}},
Expected: []experiments.Expectation{{EventType: "hypothesis_created", Contains: "payments"}, {EventType: "tool_executed", Absent: true}}, Seed: "run-1", Duration: 300}, "", " ")
u.render(w, r, "experiments", "Experiments", map[string]any{"Experiments": list, "Snapshots": snaps, "MindNames": names, "Sample": string(sample)})
}
func (u *UI) experimentCreate(w http.ResponseWriter, r *http.Request) {
snID, err := ids.Parse(r.PostFormValue("snapshot_id"))
if err != nil {
u.flash(w, r, "error", "Choose a snapshot.", "/experiments")
return
}
var def experiments.Definition
dec := json.NewDecoder(strings.NewReader(r.PostFormValue("definition")))
dec.DisallowUnknownFields()
if err := dec.Decode(&def); err != nil {
u.flash(w, r, "error", "Definition is not valid JSON: "+err.Error(), "/experiments")
return
}
e, err := u.studyF().CreateExperiment(r.Context(), api.ExperimentInput{Name: r.PostFormValue("name"), Hypothesis: r.PostFormValue("hypothesis"), SnapshotID: snID, Definition: def}, username(r))
if err != nil {
u.flash(w, r, "error", err.Error(), "/experiments")
return
}
http.Redirect(w, r, "/experiments/"+e.ID.String(), http.StatusSeeOther)
}
func (u *UI) experimentByPath(w http.ResponseWriter, r *http.Request) (experiments.Experiment, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such experiment.")
return experiments.Experiment{}, false
}
e, err := u.deps.Experiments.Get(r.Context(), id)
if err != nil {
u.errorPage(w, r, 404, "No such experiment.")
return e, false
}
return e, true
}
func (u *UI) experimentPage(w http.ResponseWriter, r *http.Request) {
e, ok := u.experimentByPath(w, r)
if !ok {
return
}
sn, _ := u.deps.Snapshots.Get(r.Context(), e.SnapshotID)
base, _ := u.deps.Minds.Get(r.Context(), sn.MindID)
var subject *minds.Mind
if e.MindID != nil {
if s, err := u.deps.Minds.Get(r.Context(), *e.MindID); err == nil {
subject = &s
}
}
def, _ := json.MarshalIndent(e.Definition, "", " ")
u.render(w, r, "experiment", "Experiment "+e.Name, map[string]any{"E": e, "Results": experiments.ResultsOf(e), "Snapshot": sn, "Base": base, "Subject": subject, "DefinitionJSON": string(def)})
}
func (u *UI) experimentRun(w http.ResponseWriter, r *http.Request) {
e, ok := u.experimentByPath(w, r)
if !ok {
return
}
if err := u.studyF().RunExperiment(r.Context(), e, username(r)); err != nil {
u.flash(w, r, "error", err.Error(), "/experiments/"+e.ID.String())
return
}
u.flash(w, r, "ok", "Experiment running; results appear when the stimuli finish.", "/experiments/"+e.ID.String())
}
func (u *UI) experimentDelete(w http.ResponseWriter, r *http.Request) {
e, ok := u.experimentByPath(w, r)
if !ok {
return
}
if err := u.deps.Experiments.Delete(r.Context(), e.ID); err != nil {
u.flash(w, r, "error", err.Error(), "/experiments/"+e.ID.String())
return
}
u.deps.Audit.Log(r.Context(), "experiment.delete", "experiment", e.ID.String(), map[string]any{"name": e.Name})
u.flash(w, r, "ok", "Experiment deleted; its subject mind is archived.", "/experiments")
}