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:
@@ -0,0 +1,293 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user