Phase 3. Events are the append-only record of everything a mind does (ADR-0002). pkg/events defines the closed type vocabulary, categories, evidence references and the observation payload with explicit trust levels. The store validates and appends inside the caller's transaction, publishes to a bounded fan-out bus only after commit, queries by seq cursor with type/category/source/salience/confidence/time/search filters, and walks provenance: cause, parents, children, cited evidence, derived events and correlation groups. Durable per-consumer cursors never move backwards. Server-Sent Events streams (per mind and global) subscribe before replaying history after Last-Event-ID so nothing is missed, apply the same filter vocabulary live, and heartbeat. Operators can inject observations tagged with operator trust for demos and tests. UI: live thought stream with filters and paging, forensic event page with causal chain and evidence navigation, dashboard important events. Personality and status changes now leave operator_action events in the mind's own history. CLI: event inspect/list/tail, observe.
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/andydixon/noema/internal/auth"
|
|
"github.com/andydixon/noema/internal/minds"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
func (s *Server) mindRoutes() {
|
|
m := s.mux
|
|
m.Handle("GET /api/v1/minds", require(auth.RoleObserver, s.mindsList))
|
|
m.Handle("POST /api/v1/minds", require(auth.RoleAdministrator, s.mindsCreate))
|
|
m.Handle("GET /api/v1/minds/{mind}", require(auth.RoleObserver, s.mindGet))
|
|
m.Handle("PATCH /api/v1/minds/{mind}", require(auth.RoleAdministrator, s.mindPatch))
|
|
m.Handle("POST /api/v1/minds/{mind}/clone", require(auth.RoleAdministrator, s.mindClone))
|
|
m.Handle("GET /api/v1/minds/{mind}/selfmodel", require(auth.RoleObserver, s.mindSelfModel))
|
|
m.Handle("GET /api/v1/minds/{mind}/personality", require(auth.RoleObserver, s.personalityGet))
|
|
m.Handle("PUT /api/v1/minds/{mind}/personality", require(auth.RoleAdministrator, s.personalityPut))
|
|
m.Handle("GET /api/v1/minds/{mind}/personality/brief", require(auth.RoleObserver, s.personalityBrief))
|
|
m.Handle("GET /api/v1/minds/{mind}/personality/versions", require(auth.RoleObserver, s.personalityVersions))
|
|
m.Handle("GET /api/v1/minds/{mind}/personality/versions/{n}", require(auth.RoleObserver, s.personalityVersion))
|
|
m.Handle("POST /api/v1/minds/{mind}/personality/versions/{n}/restore", require(auth.RoleAdministrator, s.personalityRestore))
|
|
m.Handle("GET /api/v1/minds/{mind}/personality/diff", require(auth.RoleObserver, s.personalityDiff))
|
|
m.Handle("POST /api/v1/minds/{mind}/personality/template", require(auth.RoleAdministrator, s.personalityTemplate))
|
|
m.Handle("GET /api/v1/personality-templates", require(auth.RoleObserver, s.templatesList))
|
|
m.Handle("POST /api/v1/personality-templates", require(auth.RoleAdministrator, s.templatesCreate))
|
|
m.Handle("GET /api/v1/personality-templates/{id}", require(auth.RoleObserver, s.templateGet))
|
|
m.Handle("PUT /api/v1/personality-templates/{id}", require(auth.RoleAdministrator, s.templatePut))
|
|
m.Handle("DELETE /api/v1/personality-templates/{id}", require(auth.RoleAdministrator, s.templateDelete))
|
|
}
|
|
|
|
// mind resolves the {mind} path value (slug or id) or writes an error.
|
|
func (s *Server) mind(w http.ResponseWriter, r *http.Request) (minds.Mind, bool) {
|
|
m, err := s.deps.Minds.Resolve(r.Context(), r.PathValue("mind"))
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return minds.Mind{}, false
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
func actor(r *http.Request) string {
|
|
if p, ok := auth.PrincipalFrom(r.Context()); ok {
|
|
return p.User.Username
|
|
}
|
|
return "system"
|
|
}
|
|
|
|
func mindErr(err error) error {
|
|
if errors.Is(err, minds.ErrInvalid) {
|
|
return ValidationError(err.Error())
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Server) mindsList(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.deps.Minds.List(r.Context(), r.URL.Query().Get("include_archived") == "true")
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
if list == nil {
|
|
list = []minds.Mind{}
|
|
}
|
|
writeJSON(w, http.StatusOK, page[minds.Mind]{Items: list})
|
|
}
|
|
|
|
func (s *Server) mindsCreate(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Slug string `json:"slug"`
|
|
DisplayName string `json:"display_name"`
|
|
Personality *minds.Personality `json:"personality"`
|
|
TemplateID *ids.ID `json:"template_id"`
|
|
Config *minds.CognitiveConfig `json:"config"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
m, err := s.deps.Minds.Create(r.Context(), minds.CreateParams{Slug: in.Slug, DisplayName: in.DisplayName, Personality: in.Personality,
|
|
TemplateID: in.TemplateID, Config: in.Config, Notes: in.Notes, CreatedBy: actor(r)})
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
|
|
writeJSON(w, http.StatusCreated, m)
|
|
}
|
|
|
|
func (s *Server) mindGet(w http.ResponseWriter, r *http.Request) {
|
|
if m, ok := s.mind(w, r); ok {
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
}
|
|
|
|
func (s *Server) mindPatch(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
DisplayName *string `json:"display_name"`
|
|
Notes *string `json:"notes"`
|
|
Status *minds.Status `json:"status"`
|
|
Config *minds.CognitiveConfig `json:"config"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
out, err := s.deps.Minds.Update(r.Context(), m.ID, minds.UpdateParams{DisplayName: in.DisplayName, Notes: in.Notes, Status: in.Status, Config: in.Config})
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "mind.update", "mind", m.ID.String(), in)
|
|
if in.Status != nil {
|
|
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "mind.status", "Operator "+actor(r)+" set status to "+string(*in.Status), map[string]any{"status": *in.Status})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) mindClone(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(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
|
|
}
|
|
c, err := s.deps.Minds.Clone(r.Context(), m.ID, in.Slug, in.DisplayName, actor(r))
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "mind.clone", "mind", c.ID.String(), map[string]any{"source": m.Slug, "slug": c.Slug})
|
|
writeJSON(w, http.StatusCreated, c)
|
|
}
|
|
|
|
func (s *Server) mindSelfModel(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
sm, err := s.deps.Minds.GetSelfModel(r.Context(), m.ID)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, sm)
|
|
}
|
|
|
|
func (s *Server) personalityGet(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
v, err := s.deps.Minds.GetVersion(r.Context(), m.ID, m.PersonalityVersion)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (s *Server) personalityBrief(w http.ResponseWriter, r *http.Request) {
|
|
if m, ok := s.mind(w, r); ok {
|
|
writeJSON(w, http.StatusOK, map[string]string{"brief": m.Personality.Brief()})
|
|
}
|
|
}
|
|
|
|
func (s *Server) personalityPut(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
Personality minds.Personality `json:"personality"`
|
|
ChangeNote string `json:"change_note"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
v, err := s.deps.Minds.SavePersonality(r.Context(), m.ID, in.Personality, in.ChangeNote, actor(r))
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "personality.update", "mind", m.ID.String(), map[string]any{"version": v.Version, "note": in.ChangeNote, "changes": minds.Diff(m.Personality, v.Personality)})
|
|
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.update", fmt.Sprintf("Operator %s updated personality to version %d: %s", actor(r), v.Version, in.ChangeNote), map[string]any{"version": v.Version, "changes": minds.Diff(m.Personality, v.Personality)})
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (s *Server) personalityVersions(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
vs, err := s.deps.Minds.ListVersions(r.Context(), m.ID)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, page[minds.Version]{Items: vs})
|
|
}
|
|
|
|
func (s *Server) personalityVersion(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("bad version"))
|
|
return
|
|
}
|
|
v, err := s.deps.Minds.GetVersion(r.Context(), m.ID, n)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (s *Server) personalityRestore(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("bad version"))
|
|
return
|
|
}
|
|
v, err := s.deps.Minds.Restore(r.Context(), m.ID, n, actor(r))
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "personality.restore", "mind", m.ID.String(), map[string]any{"restored": n, "version": v.Version})
|
|
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.restore", fmt.Sprintf("Operator %s restored personality version %d as version %d", actor(r), n, v.Version), map[string]any{"restored": n, "version": v.Version})
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (s *Server) personalityDiff(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
a, errA := strconv.Atoi(r.URL.Query().Get("a"))
|
|
b, errB := strconv.Atoi(r.URL.Query().Get("b"))
|
|
if errA != nil || errB != nil {
|
|
fail(w, r, ValidationError("a and b version numbers required"))
|
|
return
|
|
}
|
|
va, err := s.deps.Minds.GetVersion(r.Context(), m.ID, a)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
vb, err := s.deps.Minds.GetVersion(r.Context(), m.ID, b)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"a": a, "b": b, "changes": minds.Diff(va.Personality, vb.Personality)})
|
|
}
|
|
|
|
func (s *Server) personalityTemplate(w http.ResponseWriter, r *http.Request) {
|
|
m, ok := s.mind(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in struct {
|
|
TemplateID *ids.ID `json:"template_id"` // null detaches
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
v, err := s.deps.Minds.ApplyTemplate(r.Context(), m.ID, in.TemplateID, actor(r))
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "personality.template", "mind", m.ID.String(), map[string]any{"template_id": in.TemplateID, "version": v.Version})
|
|
s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "personality.template", "Operator "+actor(r)+": "+v.ChangeNote, map[string]any{"version": v.Version})
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (s *Server) templatesList(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.deps.Minds.ListTemplates(r.Context())
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
if list == nil {
|
|
list = []minds.Template{}
|
|
}
|
|
writeJSON(w, http.StatusOK, page[minds.Template]{Items: list})
|
|
}
|
|
|
|
type templateIn struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Personality minds.Personality `json:"personality"`
|
|
}
|
|
|
|
func (s *Server) templatesCreate(w http.ResponseWriter, r *http.Request) {
|
|
var in templateIn
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
t, err := s.deps.Minds.CreateTemplate(r.Context(), in.Name, in.Description, in.Personality)
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "template.create", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
|
|
writeJSON(w, http.StatusCreated, t)
|
|
}
|
|
|
|
func (s *Server) templateGet(w http.ResponseWriter, r *http.Request) {
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("bad id"))
|
|
return
|
|
}
|
|
t, err := s.deps.Minds.GetTemplate(r.Context(), id)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
func (s *Server) templatePut(w http.ResponseWriter, r *http.Request) {
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("bad id"))
|
|
return
|
|
}
|
|
var in templateIn
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
t, err := s.deps.Minds.UpdateTemplate(r.Context(), id, in.Name, in.Description, in.Personality)
|
|
if err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "template.update", "personality_template", t.ID.String(), map[string]any{"name": t.Name})
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
func (s *Server) templateDelete(w http.ResponseWriter, r *http.Request) {
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("bad id"))
|
|
return
|
|
}
|
|
if err := s.deps.Minds.DeleteTemplate(r.Context(), id); err != nil {
|
|
fail(w, r, mindErr(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "template.delete", "personality_template", id.String(), nil)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|