A framework for testing functional properties of the architecture (Part C). None of it claims consciousness; every measure has an operational definition in research.Measures. - Ablations (attention, memory retrieval, affect, metacognition, commitments, volition, global workspace, curiosity, association, goals, narrative, notifications) and workspace overrides (force a matching candidate in or out of admission) live in a fork's cognitive configuration. Original minds refuse them on create and update; only snapshot restores may carry them, so they are part of the configuration digest. Excluded candidates are recorded with fate "excluded". - Self-model experiments override a fork's self-model (capabilities, identity, goals, knowledge gaps) without touching policy. When an action's effective policy disagrees with the self-model, cognition records a self_model_mismatch and corrects the entry; experiments report self-model accuracy and correction latency. - Counterfactual experiments replay a mind's recorded stimuli from a snapshot with chosen events omitted; the report lists what differed in beliefs, wants, intentions, memories, attention and capability decisions, in a form introspection can cite. - Experiments record a protocol (kind, independent variable, controlled variables, measures), per-variant volition, plan, downstream-by-category (events reachable from each stimulus through recorded links), introspection and self-model measures and event ranges; comparisons add want, intention and plan-capability overlap, measure ranges, a descriptive architecture share and a downstream delta; reproducibility is labelled against the previous run of the same definition. - research.Agency computes want→intention, intention→action, abandonment, commitment fulfilment and conflict, blocked intentions, replanning, outcome success and introspection accuracy; MetacognitiveCalibration relates confidence and expressed uncertainty in replies to later belief revision, with Brier scores for each half of the period. - The sandbox test harness gains volition, planning, agency and research but not the motivation hook, whose wall-clock decay would make replay comparisons timing-dependent.
534 lines
19 KiB
Go
534 lines
19 KiB
Go
package minds
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/andydixon/noema/internal/storage"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
// Status of a mind.
|
|
type Status string
|
|
|
|
// Mind statuses. Archived minds keep all history but never run.
|
|
const (
|
|
StatusActive Status = "active"
|
|
StatusDisabled Status = "disabled"
|
|
StatusArchived Status = "archived"
|
|
)
|
|
|
|
// Mind is the identity row plus its current personality and configuration.
|
|
type Mind struct {
|
|
ID ids.ID `json:"id"`
|
|
Slug string `json:"slug"`
|
|
DisplayName string `json:"display_name"`
|
|
Status Status `json:"status"`
|
|
Config CognitiveConfig `json:"config"`
|
|
Notes string `json:"notes"`
|
|
PersonalityVersion int `json:"personality_version"`
|
|
Personality Personality `json:"personality"`
|
|
TemplateID *ids.ID `json:"template_id,omitempty"`
|
|
ForkedFromMind *ids.ID `json:"forked_from_mind,omitempty"`
|
|
ForkedFromSnapshot *ids.ID `json:"forked_from_snapshot,omitempty"`
|
|
ClonedFromMind *ids.ID `json:"cloned_from_mind,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Version is one personality revision.
|
|
type Version struct {
|
|
ID ids.ID `json:"id"`
|
|
MindID ids.ID `json:"mind_id"`
|
|
Version int `json:"version"`
|
|
Personality Personality `json:"personality"`
|
|
TemplateID *ids.ID `json:"template_id,omitempty"`
|
|
Overrides map[string]any `json:"overrides"`
|
|
ChangeNote string `json:"change_note"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Template is a reusable personality.
|
|
type Template struct {
|
|
ID ids.ID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Personality Personality `json:"personality"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Store persists minds.
|
|
type Store struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore creates the store.
|
|
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
|
|
|
|
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
|
|
// ErrInvalid marks caller input problems.
|
|
var ErrInvalid = errors.New("invalid")
|
|
|
|
// CreateParams describes a new mind.
|
|
type CreateParams struct {
|
|
Slug string
|
|
DisplayName string
|
|
Personality *Personality // nil → DefaultPersonality
|
|
TemplateID *ids.ID // start from a template (Personality then acts as overrides)
|
|
Config *CognitiveConfig
|
|
Notes string
|
|
CreatedBy string
|
|
// Research allows ablations and workspace overrides; only snapshot
|
|
// restores (forks, imports of research forks) set it.
|
|
Research bool
|
|
}
|
|
|
|
// Create inserts a mind with personality version 1 and an initial self-model.
|
|
func (s *Store) Create(ctx context.Context, p CreateParams) (Mind, error) {
|
|
p.Slug = strings.ToLower(strings.TrimSpace(p.Slug))
|
|
if !slugRe.MatchString(p.Slug) {
|
|
return Mind{}, fmt.Errorf("%w: slug must be lowercase letters, digits and hyphens", ErrInvalid)
|
|
}
|
|
if p.DisplayName == "" {
|
|
p.DisplayName = p.Slug
|
|
}
|
|
pers := DefaultPersonality(p.DisplayName)
|
|
var overrides = map[string]any{}
|
|
if p.TemplateID != nil {
|
|
t, err := s.GetTemplate(ctx, *p.TemplateID)
|
|
if err != nil {
|
|
return Mind{}, fmt.Errorf("template: %w", err)
|
|
}
|
|
pers = t.Personality
|
|
pers.Identity.Name, pers.Identity.DisplayName = p.DisplayName, p.DisplayName
|
|
if p.Personality != nil {
|
|
overrides = Overrides(t.Personality, *p.Personality)
|
|
pers = ApplyOverrides(t.Personality, overrides)
|
|
} else {
|
|
overrides = Overrides(t.Personality, pers)
|
|
}
|
|
} else if p.Personality != nil {
|
|
pers = *p.Personality
|
|
}
|
|
pers.Normalise()
|
|
if err := pers.Validate(); err != nil {
|
|
return Mind{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
cfg := DefaultConfig()
|
|
if p.Config != nil {
|
|
cfg = *p.Config
|
|
}
|
|
cfg.Normalise()
|
|
if cfg.ResearchSettings() && !p.Research {
|
|
return Mind{}, fmt.Errorf("%w: ablations and workspace overrides are only allowed on research forks, never on an original mind", ErrInvalid)
|
|
}
|
|
if err := cfg.ValidateResearch(); err != nil {
|
|
return Mind{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
m := Mind{ID: ids.New(), Slug: p.Slug, DisplayName: p.DisplayName, Status: StatusActive, Config: cfg, Notes: p.Notes,
|
|
PersonalityVersion: 1, Personality: pers, TemplateID: p.TemplateID, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
|
|
cfgJSON, _ := json.Marshal(cfg)
|
|
if _, err := tx.Exec(ctx, `INSERT INTO minds (id, slug, display_name, status, config, notes, personality_version, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,1,$7,$7)`, m.ID, m.Slug, m.DisplayName, string(m.Status), cfgJSON, m.Notes, m.CreatedAt); err != nil {
|
|
return storage.Translate(err)
|
|
}
|
|
if err := insertVersion(ctx, tx, m.ID, 1, pers, p.TemplateID, overrides, "created", p.CreatedBy); err != nil {
|
|
return err
|
|
}
|
|
sm := NewSelfModel(pers)
|
|
smJSON, _ := json.Marshal(sm)
|
|
_, err := tx.Exec(ctx, `INSERT INTO self_models (mind_id, model) VALUES ($1,$2)`, m.ID, smJSON)
|
|
return err
|
|
})
|
|
return m, err
|
|
}
|
|
|
|
func insertVersion(ctx context.Context, q storage.Querier, mindID ids.ID, version int, p Personality, templateID *ids.ID, overrides map[string]any, note, by string) error {
|
|
pj, _ := json.Marshal(p)
|
|
oj, _ := json.Marshal(overrides)
|
|
if overrides == nil {
|
|
oj = []byte("{}")
|
|
}
|
|
_, err := q.Exec(ctx, `INSERT INTO personality_versions (id, mind_id, version, personality, template_id, overrides, change_note, created_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, ids.New(), mindID, version, pj, templateID, oj, note, by)
|
|
return err
|
|
}
|
|
|
|
const mindCols = `m.id, m.slug, m.display_name, m.status, m.config, m.notes, m.personality_version, m.forked_from_mind, m.forked_from_snapshot, m.cloned_from_mind, m.created_at, m.updated_at, v.personality, v.template_id`
|
|
const mindFrom = ` FROM minds m JOIN personality_versions v ON v.mind_id = m.id AND v.version = m.personality_version `
|
|
|
|
func scanMind(row pgx.Row) (Mind, error) {
|
|
var m Mind
|
|
var status string
|
|
var cfg, pers []byte
|
|
err := row.Scan(&m.ID, &m.Slug, &m.DisplayName, &status, &cfg, &m.Notes, &m.PersonalityVersion, &m.ForkedFromMind, &m.ForkedFromSnapshot, &m.ClonedFromMind, &m.CreatedAt, &m.UpdatedAt, &pers, &m.TemplateID)
|
|
if err != nil {
|
|
return Mind{}, storage.Translate(err)
|
|
}
|
|
m.Status = Status(status)
|
|
json.Unmarshal(cfg, &m.Config) //nolint:errcheck
|
|
json.Unmarshal(pers, &m.Personality) //nolint:errcheck
|
|
m.Config.Normalise()
|
|
return m, nil
|
|
}
|
|
|
|
// Get fetches a mind by id.
|
|
func (s *Store) Get(ctx context.Context, id ids.ID) (Mind, error) {
|
|
return scanMind(s.db.QueryRow(ctx, `SELECT `+mindCols+mindFrom+`WHERE m.id=$1`, id))
|
|
}
|
|
|
|
// Resolve accepts a slug or an id.
|
|
func (s *Store) Resolve(ctx context.Context, ref string) (Mind, error) {
|
|
if id, err := ids.Parse(ref); err == nil {
|
|
return s.Get(ctx, id)
|
|
}
|
|
return scanMind(s.db.QueryRow(ctx, `SELECT `+mindCols+mindFrom+`WHERE lower(m.slug)=lower($1)`, ref))
|
|
}
|
|
|
|
// List returns minds, optionally including archived ones.
|
|
func (s *Store) List(ctx context.Context, includeArchived bool) ([]Mind, error) {
|
|
rows, err := s.db.Query(ctx, `SELECT `+mindCols+mindFrom+`WHERE ($1 OR m.status <> 'archived') ORDER BY m.created_at`, includeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Mind
|
|
for rows.Next() {
|
|
m, err := scanMind(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateParams are the mutable identity fields.
|
|
type UpdateParams struct {
|
|
DisplayName *string
|
|
Notes *string
|
|
Status *Status
|
|
Config *CognitiveConfig
|
|
}
|
|
|
|
// Update changes identity fields and configuration.
|
|
func (s *Store) Update(ctx context.Context, id ids.ID, p UpdateParams) (Mind, error) {
|
|
m, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return Mind{}, err
|
|
}
|
|
if p.DisplayName != nil && strings.TrimSpace(*p.DisplayName) != "" {
|
|
m.DisplayName = strings.TrimSpace(*p.DisplayName)
|
|
}
|
|
if p.Notes != nil {
|
|
m.Notes = *p.Notes
|
|
}
|
|
if p.Status != nil {
|
|
switch *p.Status {
|
|
case StatusActive, StatusDisabled, StatusArchived:
|
|
m.Status = *p.Status
|
|
default:
|
|
return Mind{}, fmt.Errorf("%w: unknown status", ErrInvalid)
|
|
}
|
|
}
|
|
if p.Config != nil {
|
|
c := *p.Config
|
|
c.Normalise()
|
|
if c.ResearchSettings() && m.ForkedFromSnapshot == nil {
|
|
return Mind{}, fmt.Errorf("%w: ablations and workspace overrides are only allowed on forks, never on an original mind", ErrInvalid)
|
|
}
|
|
if err := c.ValidateResearch(); err != nil {
|
|
return Mind{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
m.Config = c
|
|
}
|
|
cfgJSON, _ := json.Marshal(m.Config)
|
|
_, err = s.db.Exec(ctx, `UPDATE minds SET display_name=$2, notes=$3, status=$4, config=$5, updated_at=now() WHERE id=$1`,
|
|
id, m.DisplayName, m.Notes, string(m.Status), cfgJSON)
|
|
if err != nil {
|
|
return Mind{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
// SavePersonality appends a new version. If the mind follows a template, the
|
|
// overrides relative to that template are recorded alongside.
|
|
func (s *Store) SavePersonality(ctx context.Context, id ids.ID, p Personality, note, by string) (Version, error) {
|
|
p.Normalise()
|
|
if err := p.Validate(); err != nil {
|
|
return Version{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
m, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
overrides := map[string]any{}
|
|
if m.TemplateID != nil {
|
|
if t, err := s.GetTemplate(ctx, *m.TemplateID); err == nil {
|
|
overrides = Overrides(t.Personality, p)
|
|
}
|
|
}
|
|
return s.appendVersion(ctx, m, p, m.TemplateID, overrides, note, by)
|
|
}
|
|
|
|
func (s *Store) appendVersion(ctx context.Context, m Mind, p Personality, templateID *ids.ID, overrides map[string]any, note, by string) (Version, error) {
|
|
next := m.PersonalityVersion + 1
|
|
err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
|
|
if err := insertVersion(ctx, tx, m.ID, next, p, templateID, overrides, note, by); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE minds SET personality_version=$2, display_name=$3, updated_at=now() WHERE id=$1`, m.ID, next, p.Identity.DisplayName); err != nil {
|
|
return err
|
|
}
|
|
// Keep the self-model's identity in step.
|
|
var raw []byte
|
|
if err := tx.QueryRow(ctx, `SELECT model FROM self_models WHERE mind_id=$1 FOR UPDATE`, m.ID).Scan(&raw); err == nil {
|
|
var sm SelfModel
|
|
json.Unmarshal(raw, &sm) //nolint:errcheck
|
|
sm.Refresh(p)
|
|
smJSON, _ := json.Marshal(sm)
|
|
tx.Exec(ctx, `UPDATE self_models SET model=$2, updated_at=now() WHERE mind_id=$1`, m.ID, smJSON) //nolint:errcheck
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
return s.GetVersion(ctx, m.ID, next)
|
|
}
|
|
|
|
// ApplyTemplate re-bases a mind on a template, preserving its recorded
|
|
// overrides (spec §4: assign templates without losing mind-specific overrides).
|
|
func (s *Store) ApplyTemplate(ctx context.Context, id ids.ID, templateID *ids.ID, by string) (Version, error) {
|
|
m, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
cur, err := s.GetVersion(ctx, id, m.PersonalityVersion)
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
overrides := cur.Overrides
|
|
if m.TemplateID == nil {
|
|
// Not previously templated: only identity fields are considered mind-specific.
|
|
overrides = map[string]any{"identity": toMap(m.Personality)["identity"]}
|
|
}
|
|
if templateID == nil {
|
|
return s.appendVersion(ctx, m, m.Personality, nil, map[string]any{}, "detached from template", by)
|
|
}
|
|
t, err := s.GetTemplate(ctx, *templateID)
|
|
if err != nil {
|
|
return Version{}, fmt.Errorf("template: %w", err)
|
|
}
|
|
p := ApplyOverrides(t.Personality, overrides)
|
|
if p.Identity.Name == "" {
|
|
p.Identity.Name, p.Identity.DisplayName = m.Personality.Identity.Name, m.Personality.Identity.DisplayName
|
|
}
|
|
return s.appendVersion(ctx, m, p, templateID, overrides, "applied template "+t.Name, by)
|
|
}
|
|
|
|
// Restore makes an older version current by appending a copy of it.
|
|
func (s *Store) Restore(ctx context.Context, id ids.ID, version int, by string) (Version, error) {
|
|
m, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
old, err := s.GetVersion(ctx, id, version)
|
|
if err != nil {
|
|
return Version{}, err
|
|
}
|
|
return s.appendVersion(ctx, m, old.Personality, old.TemplateID, old.Overrides, fmt.Sprintf("restored version %d", version), by)
|
|
}
|
|
|
|
// GetVersion fetches one personality version.
|
|
func (s *Store) GetVersion(ctx context.Context, id ids.ID, version int) (Version, error) {
|
|
return scanVersion(s.db.QueryRow(ctx, `SELECT id, mind_id, version, personality, template_id, overrides, change_note, created_by, created_at FROM personality_versions WHERE mind_id=$1 AND version=$2`, id, version))
|
|
}
|
|
|
|
func scanVersion(row pgx.Row) (Version, error) {
|
|
var v Version
|
|
var pers, ov []byte
|
|
if err := row.Scan(&v.ID, &v.MindID, &v.Version, &pers, &v.TemplateID, &ov, &v.ChangeNote, &v.CreatedBy, &v.CreatedAt); err != nil {
|
|
return Version{}, storage.Translate(err)
|
|
}
|
|
json.Unmarshal(pers, &v.Personality) //nolint:errcheck
|
|
json.Unmarshal(ov, &v.Overrides) //nolint:errcheck
|
|
if v.Overrides == nil {
|
|
v.Overrides = map[string]any{}
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// ListVersions returns all versions newest first.
|
|
func (s *Store) ListVersions(ctx context.Context, id ids.ID) ([]Version, error) {
|
|
rows, err := s.db.Query(ctx, `SELECT id, mind_id, version, personality, template_id, overrides, change_note, created_by, created_at FROM personality_versions WHERE mind_id=$1 ORDER BY version DESC`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Version
|
|
for rows.Next() {
|
|
v, err := scanVersion(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Clone creates a new mind with a copy of the source's current personality,
|
|
// template link and configuration. Memories and history are not copied:
|
|
// cloning is "same character, fresh experience"; forking from a snapshot
|
|
// (phase 15) copies state.
|
|
func (s *Store) Clone(ctx context.Context, sourceID ids.ID, slug, displayName, by string) (Mind, error) {
|
|
src, err := s.Get(ctx, sourceID)
|
|
if err != nil {
|
|
return Mind{}, err
|
|
}
|
|
p := src.Personality
|
|
if displayName == "" {
|
|
displayName = slug
|
|
}
|
|
p.Identity.Name, p.Identity.DisplayName = displayName, displayName
|
|
cfg := src.Config
|
|
m, err := s.Create(ctx, CreateParams{Slug: slug, DisplayName: displayName, Personality: &p, TemplateID: src.TemplateID, Config: &cfg, Notes: "cloned from " + src.Slug, CreatedBy: by})
|
|
if err != nil {
|
|
return Mind{}, err
|
|
}
|
|
if _, err := s.db.Exec(ctx, `UPDATE minds SET cloned_from_mind=$2 WHERE id=$1`, m.ID, src.ID); err != nil {
|
|
return Mind{}, err
|
|
}
|
|
return s.Get(ctx, m.ID)
|
|
}
|
|
|
|
// GetSelfModel returns the self-model.
|
|
func (s *Store) GetSelfModel(ctx context.Context, id ids.ID) (SelfModel, error) {
|
|
var raw []byte
|
|
if err := s.db.QueryRow(ctx, `SELECT model FROM self_models WHERE mind_id=$1`, id).Scan(&raw); err != nil {
|
|
return SelfModel{}, storage.Translate(err)
|
|
}
|
|
var sm SelfModel
|
|
return sm, json.Unmarshal(raw, &sm)
|
|
}
|
|
|
|
// SaveSelfModel replaces the self-model. Callers emit the self_model_updated event.
|
|
func (s *Store) SaveSelfModel(ctx context.Context, id ids.ID, sm SelfModel) error {
|
|
sm.UpdatedAt = time.Now()
|
|
raw, _ := json.Marshal(sm)
|
|
_, err := s.db.Exec(ctx, `INSERT INTO self_models (mind_id, model) VALUES ($1,$2) ON CONFLICT (mind_id) DO UPDATE SET model=excluded.model, updated_at=now()`, id, raw)
|
|
return err
|
|
}
|
|
|
|
// ---- templates ----
|
|
|
|
// CreateTemplate stores a template.
|
|
func (s *Store) CreateTemplate(ctx context.Context, name, description string, p Personality) (Template, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || len(name) > 80 {
|
|
return Template{}, fmt.Errorf("%w: name required (max 80 chars)", ErrInvalid)
|
|
}
|
|
p.Normalise()
|
|
if p.Identity.Name == "" {
|
|
p.Identity.Name, p.Identity.DisplayName = name, name
|
|
}
|
|
if err := p.Validate(); err != nil {
|
|
return Template{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
t := Template{ID: ids.New(), Name: name, Description: description, Personality: p, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
pj, _ := json.Marshal(p)
|
|
_, err := s.db.Exec(ctx, `INSERT INTO personality_templates (id, name, description, personality, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$5)`, t.ID, t.Name, t.Description, pj, t.CreatedAt)
|
|
return t, storage.Translate(err)
|
|
}
|
|
|
|
// UpdateTemplate replaces a template's content. Minds using it are not changed
|
|
// until an operator re-applies it (explicit, audited).
|
|
func (s *Store) UpdateTemplate(ctx context.Context, id ids.ID, name, description string, p Personality) (Template, error) {
|
|
p.Normalise()
|
|
if err := p.Validate(); err != nil {
|
|
return Template{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
|
}
|
|
pj, _ := json.Marshal(p)
|
|
tag, err := s.db.Exec(ctx, `UPDATE personality_templates SET name=$2, description=$3, personality=$4, updated_at=now() WHERE id=$1`, id, strings.TrimSpace(name), description, pj)
|
|
if err != nil {
|
|
return Template{}, storage.Translate(err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return Template{}, storage.ErrNotFound
|
|
}
|
|
return s.GetTemplate(ctx, id)
|
|
}
|
|
|
|
// DeleteTemplate removes a template. Versions keep a dangling reference set to
|
|
// NULL by the database; their stored effective personality is unaffected.
|
|
func (s *Store) DeleteTemplate(ctx context.Context, id ids.ID) error {
|
|
return storage.Tx(ctx, s.db, func(tx pgx.Tx) error {
|
|
// personality_versions is append-only, so the FK cannot be nulled; instead
|
|
// refuse deletion while any mind's *current* version depends on it.
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `SELECT count(*) FROM minds m JOIN personality_versions v ON v.mind_id=m.id AND v.version=m.personality_version WHERE v.template_id=$1`, id).Scan(&n); err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return fmt.Errorf("%w: template is in use by %d mind(s); detach them first", ErrInvalid, n)
|
|
}
|
|
var hist int
|
|
if err := tx.QueryRow(ctx, `SELECT count(*) FROM personality_versions WHERE template_id=$1`, id).Scan(&hist); err != nil {
|
|
return err
|
|
}
|
|
if hist > 0 {
|
|
return fmt.Errorf("%w: template is referenced by personality history and cannot be deleted; rename it instead", ErrInvalid)
|
|
}
|
|
tag, err := tx.Exec(ctx, `DELETE FROM personality_templates WHERE id=$1`, id)
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return storage.ErrNotFound
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
|
|
// GetTemplate fetches a template.
|
|
func (s *Store) GetTemplate(ctx context.Context, id ids.ID) (Template, error) {
|
|
return scanTemplate(s.db.QueryRow(ctx, `SELECT id, name, description, personality, created_at, updated_at FROM personality_templates WHERE id=$1`, id))
|
|
}
|
|
|
|
func scanTemplate(row pgx.Row) (Template, error) {
|
|
var t Template
|
|
var pj []byte
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Description, &pj, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
|
return Template{}, storage.Translate(err)
|
|
}
|
|
json.Unmarshal(pj, &t.Personality) //nolint:errcheck
|
|
return t, nil
|
|
}
|
|
|
|
// ListTemplates returns all templates by name.
|
|
func (s *Store) ListTemplates(ctx context.Context) ([]Template, error) {
|
|
rows, err := s.db.Query(ctx, `SELECT id, name, description, personality, created_at, updated_at FROM personality_templates ORDER BY lower(name)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Template
|
|
for rows.Next() {
|
|
t, err := scanTemplate(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|