package api import ( "context" "encoding/json" "errors" "fmt" "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. The fork // receives no policies unless inheritPolicies is set explicitly. func (s *Server) ForkSnapshot(ctx context.Context, sn snapshots.Snapshot, slug, display, by string, inheritPolicies bool) (minds.Mind, error) { m, err := s.deps.Snapshots.Fork(ctx, sn.ID, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, InheritPolicies: inheritPolicies}) 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, "inherit_policies": inheritPolicies}) if !inheritPolicies { s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.fork.policies", "Capability policies were not inherited from the source mind; this mind has only global policy until an operator adds rules", map[string]any{"inherit_policies": false}) } 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"` InheritPolicies bool `json:"inherit_policies"` } 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), in.InheritPolicies) 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, omitted, err := s.deps.Snapshots.ImportReport(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(), "omitted": omitted}) summary := "Mind imported from archive of " + a.Source.Slug + " (" + a.Checksum + ")" if n := omitted["policies"]; n > 0 { summary += fmt.Sprintf("; %d policy rule(s) in the archive were not imported: policies are set by this instance's operators", n) } s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", summary, map[string]any{"counts": a.Counts(), "omitted": omitted}) 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) }