feat: Phase 3b — EntityService, postgres EntityRepository, converged mutations
Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.
Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
hash + cached-body renderer so the replay record commits in the
create's transaction), IdempotentResponse + GetIdempotent read,
AuditEntry gains Method/Path/CorrelationID, Event gains
CorrelationID; EntityUpdateInput carries ExpectedVersion +
RederiveChecks (derivation for updates runs repo-side: the graph
host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
Update/SetState/reads/idempotency) preserving the load-bearing
check-then-act invariants in-tx: version WHERE-clause, declared
transitions + preconditions (ValidateTransition), duplicate-slug
mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
(type exists, concrete, state declared — the stricter MCP rule now
governs both surfaces), default-state resolution, id generation,
derivation for creates, audit/event construction, idempotency
pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
regenerates derived checks (the A2 parity gap). MCP create/update/
set-state tools call the same service — and now write audit + event
rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
depth (../../../seeds).
Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.
Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
This commit is contained in:
@@ -23,7 +23,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func seedsDir() string { return "../../seeds" }
|
func seedsDir() string { return "../../../seeds" }
|
||||||
|
|
||||||
// newTestPool creates a throwaway database (dropped on cleanup), runs all
|
// newTestPool creates a throwaway database (dropped on cleanup), runs all
|
||||||
// migrations, and returns a pool connected to it.
|
// migrations, and returns a pool connected to it.
|
||||||
|
|||||||
506
internal/adapters/postgres/repositories.go
Normal file
506
internal/adapters/postgres/repositories.go
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityRepo implements ports.EntityRepository over the postgres pool.
|
||||||
|
// Command methods run everything in their input inside one transaction
|
||||||
|
// (ADR 0016 §3.6): entity write, derived checks, idempotency record, audit,
|
||||||
|
// event.
|
||||||
|
type EntityRepo struct {
|
||||||
|
pool *Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.EntityRepository = (*EntityRepo)(nil)
|
||||||
|
|
||||||
|
// NewEntityRepo builds the entity repository.
|
||||||
|
func NewEntityRepo(pool *Pool) *EntityRepo { return &EntityRepo{pool: pool} }
|
||||||
|
|
||||||
|
|
||||||
|
// mustUUID converts a domain.UUID (string alias) to uuid.UUID. The domain
|
||||||
|
// layer guarantees UUID-shaped strings; parse failures are programming
|
||||||
|
// errors and panic loudly rather than half-failing a transaction.
|
||||||
|
func mustUUID(id domain.UUID) uuid.UUID {
|
||||||
|
u, err := uuid.Parse(string(id))
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("invalid entity UUID %q", string(id)))
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityFullCols = `id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at`
|
||||||
|
|
||||||
|
type rowScanner interface{ Scan(dest ...any) error }
|
||||||
|
|
||||||
|
func scanDomainEntity(row rowScanner) (domain.Entity, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
var slug, typ, name string
|
||||||
|
var state *string
|
||||||
|
var attrs []byte
|
||||||
|
var maint *time.Time
|
||||||
|
var version int32
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
if err := row.Scan(&id, &slug, &typ, &name, &state, &attrs, &maint, &version, &createdAt, &updatedAt); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
d := domain.Entity{
|
||||||
|
ID: domain.UUID(id.String()),
|
||||||
|
Slug: slug,
|
||||||
|
Type: typ,
|
||||||
|
Name: name,
|
||||||
|
Version: int(version),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
MaintenanceUntil: maint,
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
d.State = *state
|
||||||
|
}
|
||||||
|
if len(attrs) > 0 {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(attrs, &m) == nil {
|
||||||
|
d.Attributes = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRowErr(err error) error {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the entity by ID.
|
||||||
|
func (r *EntityRepo) Get(ctx context.Context, id domain.UUID) (domain.Entity, error) {
|
||||||
|
uid, err := uuid.Parse(string(id))
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
e, err := scanDomainEntity(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+entityFullCols+` FROM entities WHERE id = $1`, uid))
|
||||||
|
return e, mapRowErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BySlug returns the entity by slug.
|
||||||
|
func (r *EntityRepo) BySlug(ctx context.Context, slug string) (domain.Entity, error) {
|
||||||
|
e, err := scanDomainEntity(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, slug))
|
||||||
|
return e, mapRowErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns entities filtered by type (including descendant types),
|
||||||
|
// state, domain, layer, and a slug/name substring, keyset-paginated by
|
||||||
|
// slug. The second return is the next cursor ("" when exhausted).
|
||||||
|
func (r *EntityRepo) List(ctx context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
|
||||||
|
limit := f.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
// NULL-able filter args: an absent filter must bind SQL NULL, not "".
|
||||||
|
nullable := func(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type filter includes descendants via the parent hierarchy (R3-1).
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
WITH RECURSIVE tt AS (
|
||||||
|
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
|
||||||
|
UNION
|
||||||
|
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||||
|
WHERE $1::text IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT `+entityFullCols+`
|
||||||
|
FROM entities e
|
||||||
|
JOIN entity_types et ON et.name = e.type
|
||||||
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
|
AND ($3::text IS NULL OR et.domain = $3)
|
||||||
|
AND ($4::text IS NULL OR et.layer = $4)
|
||||||
|
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
|
||||||
|
AND ($6::text IS NULL OR e.slug > $6)
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT $7`,
|
||||||
|
nullable(f.Type), nullable(f.State), nullable(f.Domain), nullable(f.Layer),
|
||||||
|
nullable(f.Q), nullable(f.Cursor), limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []domain.Entity{}
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanDomainEntity(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, "", rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
next := ""
|
||||||
|
if len(items) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
next = items[len(items)-1].Slug
|
||||||
|
}
|
||||||
|
return items, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches slug/name substrings.
|
||||||
|
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
|
||||||
|
items, _, err := r.List(ctx, ports.EntityFilters{Q: q, Limit: limit})
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSideEffects writes audit entries and the event inside the open
|
||||||
|
// transaction.
|
||||||
|
func writeSideEffects(ctx context.Context, tx pgx.Tx, entityID domain.UUID, audits []ports.AuditEntry, event *ports.Event) error {
|
||||||
|
q := sqlcgen.New(tx)
|
||||||
|
eid := mustUUID(entityID)
|
||||||
|
for _, a := range audits {
|
||||||
|
if err := observability.Audit(ctx, q, a.ActorType, a.ActorLabel, a.Action, &eid,
|
||||||
|
a.Method, a.Path, a.CorrelationID, nil, a.Details); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if event != nil {
|
||||||
|
if err := observability.Event(ctx, q, event.Type, &eid, event.Severity, event.Source,
|
||||||
|
event.CorrelationID, event.Data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appTargetOf(e domain.Entity) app.CheckTarget {
|
||||||
|
attrs, _ := json.Marshal(e.Attributes)
|
||||||
|
return app.CheckTarget{ID: string(e.ID), Slug: e.Slug, Type: e.Type, Name: e.Name, Attrs: attrs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDerivedChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, checks []ports.DerivedCheck) (int, error) {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_status (entity_id, health, updated_at)
|
||||||
|
VALUES ($1, 'unknown', now())
|
||||||
|
ON CONFLICT (entity_id) DO NOTHING`, mustUUID(e.ID)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
created := 0
|
||||||
|
t := appTargetOf(e)
|
||||||
|
for i, dc := range checks {
|
||||||
|
ok, err := writeCheck(ctx, tx, t, i, app.CheckDef{Kind: dc.Kind, Config: dc.Config, IntervalS: dc.IntervalS})
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rederiveChecks re-runs derivation for an entity inside the open tx, with
|
||||||
|
// the graph host fallback active (a service inherits its container's
|
||||||
|
// address once the hosting edge exists).
|
||||||
|
func rederiveChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, tree *ontology.TypeTree) (int, error) {
|
||||||
|
t := appTargetOf(e)
|
||||||
|
defs, _ := app.Derive(tree, t, func() map[string]any {
|
||||||
|
attrs, err := hostViaGraph(ctx, tx, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
})
|
||||||
|
derived := make([]ports.DerivedCheck, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
derived[i] = ports.DerivedCheck{Kind: d.Kind, Config: d.Config, IntervalS: d.IntervalS}
|
||||||
|
}
|
||||||
|
return writeDerivedChecks(ctx, tx, e, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts the entity with its derived checks, idempotency record,
|
||||||
|
// audit, and event — one transaction.
|
||||||
|
func (r *EntityRepo) Create(ctx context.Context, in ports.EntityCreateInput) (domain.Entity, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
e := in.Entity
|
||||||
|
attrsJSON, _ := json.Marshal(e.Attributes)
|
||||||
|
if e.Attributes == nil {
|
||||||
|
attrsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
var state *string
|
||||||
|
if e.State != "" {
|
||||||
|
state = &e.State
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := scanDomainEntity(tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING `+entityFullCols,
|
||||||
|
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON))
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||||
|
return domain.Entity{}, errors.Join(domain.ErrAlreadyExists, err)
|
||||||
|
}
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Idempotency != nil {
|
||||||
|
var body []byte
|
||||||
|
if in.Idempotency.RenderBody != nil {
|
||||||
|
body = in.Idempotency.RenderBody(created)
|
||||||
|
}
|
||||||
|
code := int32(201)
|
||||||
|
if err := sqlcgen.New(tx).PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
|
||||||
|
Actor: in.Idempotency.Actor, Key: in.Idempotency.Key,
|
||||||
|
RequestHash: in.Idempotency.RequestHash, ResponseCode: &code, ResponseBody: body,
|
||||||
|
}); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := writeDerivedChecks(ctx, tx, created, in.DerivedChecks); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if err := writeSideEffects(ctx, tx, created.ID, in.Audit, in.Event); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies name/state/attributes/maintenance atomically with an
|
||||||
|
// optimistic-version check, re-deriving default checks when asked. The
|
||||||
|
// declared-transition + precondition validation runs inside the
|
||||||
|
// transaction (check-then-act, plan §3.6).
|
||||||
|
func (r *EntityRepo) Update(ctx context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
current, err := scanDomainEntity(tx.QueryRow(ctx,
|
||||||
|
`SELECT `+entityFullCols+` FROM entities WHERE id = $1`, mustUUID(in.Entity.ID)))
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, mapRowErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
|
||||||
|
return domain.Entity{}, domain.ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
e := in.Entity
|
||||||
|
// Fields the caller left zero keep their current values.
|
||||||
|
if e.Name == "" {
|
||||||
|
e.Name = current.Name
|
||||||
|
}
|
||||||
|
if e.Slug == "" {
|
||||||
|
e.Slug = current.Slug
|
||||||
|
}
|
||||||
|
if e.Type == "" {
|
||||||
|
e.Type = current.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle validation when state changes (declared transition +
|
||||||
|
// preconditions, in-tx).
|
||||||
|
if e.State != "" && e.State != current.State {
|
||||||
|
if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, current.State, e.State); err != nil {
|
||||||
|
if errors.Is(err, ErrTransitionInvalid) {
|
||||||
|
return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err)
|
||||||
|
}
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
} else if e.State == "" {
|
||||||
|
e.State = current.State
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Attributes == nil {
|
||||||
|
e.Attributes = current.Attributes
|
||||||
|
}
|
||||||
|
setMaintenance := e.MaintenanceUntil != nil
|
||||||
|
maint := e.MaintenanceUntil
|
||||||
|
if !setMaintenance {
|
||||||
|
maint = current.MaintenanceUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
attrsJSON, _ := json.Marshal(e.Attributes)
|
||||||
|
if e.Attributes == nil {
|
||||||
|
attrsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
var state *string
|
||||||
|
if e.State != "" {
|
||||||
|
state = &e.State
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := scanDomainEntity(tx.QueryRow(ctx, `
|
||||||
|
UPDATE entities
|
||||||
|
SET name = $2, state = $3, attributes = $4, maintenance_until = $5, version = version + 1, updated_at = now()
|
||||||
|
WHERE id = $1 AND version = $6
|
||||||
|
RETURNING `+entityFullCols,
|
||||||
|
mustUUID(e.ID), e.Name, state, attrsJSON, maint, current.Version))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.Entity{}, domain.ErrConflict
|
||||||
|
}
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.RederiveChecks {
|
||||||
|
tree, err := LoadTypeTree(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if _, err := rederiveChecks(ctx, tx, updated, tree); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeSideEffects(ctx, tx, updated.ID, in.Audit, in.Event); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState transitions an entity's lifecycle state. Declared-transition
|
||||||
|
// and precondition validation run in-tx; a stale From is refused.
|
||||||
|
func (r *EntityRepo) SetState(ctx context.Context, in ports.EntityTransitionInput) (domain.Entity, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
current, err := scanDomainEntity(tx.QueryRow(ctx,
|
||||||
|
`SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, in.Slug))
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, mapRowErr(err)
|
||||||
|
}
|
||||||
|
if current.State != in.From {
|
||||||
|
return domain.Entity{}, domain.ErrConflict
|
||||||
|
}
|
||||||
|
if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, in.From, in.To); err != nil {
|
||||||
|
if errors.Is(err, ErrTransitionInvalid) {
|
||||||
|
return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err)
|
||||||
|
}
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE entities SET state = $2, version = version + 1, updated_at = now() WHERE id = $1`,
|
||||||
|
mustUUID(current.ID), in.To); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
after := current
|
||||||
|
after.State = in.To
|
||||||
|
if err := writeSideEffects(ctx, tx, after.ID, in.Audit, in.Event); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
return after, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdempotent returns the cached response for (actor, key) or
|
||||||
|
// domain.ErrNotFound.
|
||||||
|
func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (ports.IdempotentResponse, error) {
|
||||||
|
cached, err := sqlcgen.New(r.pool).GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{Actor: actor, Key: key})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ports.IdempotentResponse{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return ports.IdempotentResponse{}, err
|
||||||
|
}
|
||||||
|
code := 0
|
||||||
|
if cached.ResponseCode != nil {
|
||||||
|
code = int(*cached.ResponseCode)
|
||||||
|
}
|
||||||
|
return ports.IdempotentResponse{
|
||||||
|
RequestHash: cached.RequestHash,
|
||||||
|
ResponseCode: code,
|
||||||
|
ResponseBody: cached.ResponseBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OntologyRepo implements ports.OntologyStore with a TTL cache — entity
|
||||||
|
// types change at seed time, not per request, so a short cache trades a
|
||||||
|
// little staleness for avoiding the meta-schema load on every mutation.
|
||||||
|
type OntologyRepo struct {
|
||||||
|
pool *Pool
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
loaded time.Time
|
||||||
|
tree *ontology.TypeTree
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.OntologyStore = (*OntologyRepo)(nil)
|
||||||
|
|
||||||
|
// NewOntologyRepo builds the ontology store with the given cache TTL
|
||||||
|
// (values <= 0 disable caching).
|
||||||
|
func NewOntologyRepo(pool *Pool, ttl time.Duration) *OntologyRepo {
|
||||||
|
return &OntologyRepo{pool: pool, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTypeTree returns the (possibly cached) ontology tree.
|
||||||
|
func (o *OntologyRepo) LoadTypeTree(ctx context.Context) (ports.TypeTree, error) {
|
||||||
|
if o.ttl <= 0 {
|
||||||
|
return o.load(ctx)
|
||||||
|
}
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
if o.tree != nil && time.Since(o.loaded) < o.ttl {
|
||||||
|
return o.tree, nil
|
||||||
|
}
|
||||||
|
tree, err := o.load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.tree = tree
|
||||||
|
o.loaded = time.Now()
|
||||||
|
return tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OntologyRepo) load(ctx context.Context) (ports.TypeTree, error) {
|
||||||
|
tx, err := o.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
tree, err := LoadTypeTree(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ports.TypeTree(tree), nil
|
||||||
|
}
|
||||||
262
internal/core/app/entities.go
Normal file
262
internal/core/app/entities.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityService owns the entity aggregate's use-cases: create with derived
|
||||||
|
// checks, update with optimistic versioning and check regeneration,
|
||||||
|
// lifecycle transitions. Validation against the (cached) ontology happens
|
||||||
|
// here, in the pure half; transactional check-then-act invariants
|
||||||
|
// (version, declared transitions, preconditions) run in the repository
|
||||||
|
// (plan §3.6).
|
||||||
|
type EntityService struct {
|
||||||
|
entities ports.EntityRepository
|
||||||
|
onto ports.OntologyStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntityService wires the service.
|
||||||
|
func NewEntityService(entities ports.EntityRepository, onto ports.OntologyStore) *EntityService {
|
||||||
|
return &EntityService{entities: entities, onto: onto}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntityCmd is one entity creation. Actor identifies the calling
|
||||||
|
// surface for the audit trail ("operator:<label>", "agent:mcp").
|
||||||
|
type CreateEntityCmd struct {
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
State string // "" → lifecycle default
|
||||||
|
Attributes map[string]any
|
||||||
|
ActorType string
|
||||||
|
Actor string
|
||||||
|
Method string // audit context, e.g. "POST" / "TOOL"
|
||||||
|
Path string // audit context, e.g. "/api/v1/entities" / "create_entity"
|
||||||
|
// Idempotency, when set, replays-protects the create. The adapter owns
|
||||||
|
// the request hash and the cached-body renderer (its wire shape); the
|
||||||
|
// repository stores the record in the create's transaction.
|
||||||
|
Idempotency *ports.Idempotency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create validates the type (exists, concrete, state declared), derives
|
||||||
|
// default checks, and commits entity + checks + audit + event in one
|
||||||
|
// transaction.
|
||||||
|
func (s *EntityService) Create(ctx context.Context, cmd CreateEntityCmd) (domain.Entity, DeriveResult, error) {
|
||||||
|
var res DeriveResult
|
||||||
|
|
||||||
|
tree, err := s.onto.LoadTypeTree(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := cmd.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = cmd.Type + ":" + cmd.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller-supplied states are validated against the lifecycle's declared
|
||||||
|
// states — a create bypass of lifecycle guardrails would let an agent
|
||||||
|
// create in a terminal state without satisfying the preconditions that
|
||||||
|
// SetState enforces for the same transition. Both surfaces (REST, MCP)
|
||||||
|
// now share this rule.
|
||||||
|
state := cmd.State
|
||||||
|
if state == "" {
|
||||||
|
state = tree.DefaultState(cmd.Type)
|
||||||
|
}
|
||||||
|
if err := tree.ValidateEntity(cmd.Type, state); err != nil {
|
||||||
|
return domain.Entity{}, res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, res, err
|
||||||
|
}
|
||||||
|
e := domain.Entity{
|
||||||
|
ID: domain.UUID(id.String()),
|
||||||
|
Slug: slug,
|
||||||
|
Type: cmd.Type,
|
||||||
|
Name: cmd.Name,
|
||||||
|
State: state,
|
||||||
|
Attributes: cmd.Attributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derivation for a create has no graph fallback available — a new entity
|
||||||
|
// has no edges yet. A type whose address comes from its host (a service)
|
||||||
|
// produces no checks on this pass; the gap is deliberate and visible
|
||||||
|
// (coverage sweep), and the next mutation or ingest fills it once the
|
||||||
|
// hosting edge exists.
|
||||||
|
attrsJSON, _ := json.Marshal(cmd.Attributes)
|
||||||
|
defs, dres := Derive(tree, CheckTarget{
|
||||||
|
ID: string(e.ID), Slug: slug, Type: cmd.Type, Name: cmd.Name, Attrs: attrsJSON,
|
||||||
|
}, nil)
|
||||||
|
// Derive is pure and cannot count writes; every derived def is ensured
|
||||||
|
// by the repository in the create's transaction (a write failure aborts
|
||||||
|
// the whole create), so the ensured count is the derived count.
|
||||||
|
dres.Created = len(defs)
|
||||||
|
res = dres
|
||||||
|
derived := make([]ports.DerivedCheck, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
derived[i] = ports.DerivedCheck{Kind: d.Kind, Config: d.Config, IntervalS: d.IntervalS}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ports.EntityCreateInput{
|
||||||
|
Entity: e,
|
||||||
|
DerivedChecks: derived,
|
||||||
|
Audit: []ports.AuditEntry{{
|
||||||
|
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "create",
|
||||||
|
EntityID: e.ID, Method: cmd.Method, Path: cmd.Path,
|
||||||
|
Details: map[string]any{"type": cmd.Type, "slug": slug},
|
||||||
|
}},
|
||||||
|
Event: &ports.Event{
|
||||||
|
Type: "entity.created", Severity: "info", Source: "oikos-api", EntityID: e.ID,
|
||||||
|
Data: map[string]any{"slug": slug, "type": cmd.Type},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
input.Idempotency = cmd.Idempotency
|
||||||
|
|
||||||
|
created, err := s.entities.Create(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, res, err
|
||||||
|
}
|
||||||
|
return created, res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEntityCmd mutates an entity. Attributes are REPLACED when
|
||||||
|
// AttrsReplace is true and shallow-merged otherwise. SetState semantics
|
||||||
|
// ride along: a State change is lifecycle-validated.
|
||||||
|
type UpdateEntityCmd struct {
|
||||||
|
SlugOrID string
|
||||||
|
ExpectedVer int
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
Attributes map[string]any
|
||||||
|
AttrsReplace bool
|
||||||
|
Maintenance *time.Time
|
||||||
|
SetMaint bool
|
||||||
|
// RederiveChecks regenerates default checks from the post-update
|
||||||
|
// attributes (a `monitoring` attribute change re-wires probes).
|
||||||
|
RederiveChecks bool
|
||||||
|
ActorType string
|
||||||
|
Actor string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loads the current entity, validates the target state against the
|
||||||
|
// ontology, and commits the mutation atomically. The optimistic-version and
|
||||||
|
// transition-precondition checks run inside the repository's transaction.
|
||||||
|
// When RederiveChecks is set the returned DeriveResult summarizes the
|
||||||
|
// derivation the transaction applied (for surface messages).
|
||||||
|
func (s *EntityService) Update(ctx context.Context, cmd UpdateEntityCmd) (domain.Entity, DeriveResult, error) {
|
||||||
|
var dres DeriveResult
|
||||||
|
current, err := s.resolve(ctx, cmd.SlugOrID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, dres, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := s.onto.LoadTypeTree(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, dres, err
|
||||||
|
}
|
||||||
|
if cmd.State != "" && cmd.State != current.State {
|
||||||
|
if err := tree.ValidateEntity(current.Type, cmd.State); err != nil {
|
||||||
|
return domain.Entity{}, dres, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e := domain.Entity{
|
||||||
|
ID: current.ID,
|
||||||
|
Slug: current.Slug,
|
||||||
|
Type: current.Type,
|
||||||
|
Name: cmd.Name,
|
||||||
|
State: cmd.State,
|
||||||
|
Version: current.Version,
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cmd.Attributes == nil:
|
||||||
|
e.Attributes = nil // keep current
|
||||||
|
case cmd.AttrsReplace:
|
||||||
|
e.Attributes = cmd.Attributes
|
||||||
|
default:
|
||||||
|
merged := map[string]any{}
|
||||||
|
for k, v := range current.Attributes {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range cmd.Attributes {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
e.Attributes = merged
|
||||||
|
}
|
||||||
|
if cmd.SetMaint {
|
||||||
|
e.MaintenanceUntil = cmd.Maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.entities.Update(ctx, ports.EntityUpdateInput{
|
||||||
|
Entity: e,
|
||||||
|
ExpectedVersion: cmd.ExpectedVer,
|
||||||
|
RederiveChecks: cmd.RederiveChecks,
|
||||||
|
Audit: []ports.AuditEntry{{
|
||||||
|
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "patch",
|
||||||
|
EntityID: current.ID, Method: cmd.Method, Path: cmd.Path,
|
||||||
|
Details: map[string]any{"version": current.Version},
|
||||||
|
}},
|
||||||
|
Event: &ports.Event{
|
||||||
|
Type: "entity.updated", Severity: "info", Source: "oikos-api", EntityID: current.ID,
|
||||||
|
Data: map[string]any{"slug": current.Slug, "type": current.Type},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, dres, err
|
||||||
|
}
|
||||||
|
if cmd.RederiveChecks {
|
||||||
|
attrsJSON, _ := json.Marshal(updated.Attributes)
|
||||||
|
defs, d := Derive(tree, CheckTarget{
|
||||||
|
ID: string(updated.ID), Slug: updated.Slug, Type: updated.Type,
|
||||||
|
Name: updated.Name, Attrs: attrsJSON,
|
||||||
|
}, nil)
|
||||||
|
d.Created = len(defs)
|
||||||
|
dres = d
|
||||||
|
}
|
||||||
|
return updated, dres, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState transitions an entity's lifecycle. Declared-transition and
|
||||||
|
// precondition enforcement happens in the repository's transaction; a
|
||||||
|
// stale FromState is refused there (check-then-act).
|
||||||
|
func (s *EntityService) SetState(ctx context.Context, slug, fromState, toState, actorType, actor, method, path string) (domain.Entity, error) {
|
||||||
|
current, err := s.entities.BySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
after, err := s.entities.SetState(ctx, ports.EntityTransitionInput{
|
||||||
|
Slug: slug,
|
||||||
|
From: fromState,
|
||||||
|
To: toState,
|
||||||
|
Audit: []ports.AuditEntry{{
|
||||||
|
ActorType: actorType, ActorLabel: actor, Action: "state",
|
||||||
|
EntityID: current.ID, Method: method, Path: path,
|
||||||
|
Details: map[string]any{"from": fromState, "to": toState},
|
||||||
|
}},
|
||||||
|
Event: &ports.Event{
|
||||||
|
Type: "entity.state.changed", Severity: "info", Source: "oikos-api", EntityID: current.ID,
|
||||||
|
Data: map[string]any{"slug": slug, "from": fromState, "to": toState},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.Entity{}, err
|
||||||
|
}
|
||||||
|
return after, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntityService) resolve(ctx context.Context, slugOrID string) (domain.Entity, error) {
|
||||||
|
if _, err := uuid.Parse(slugOrID); err == nil {
|
||||||
|
return s.entities.Get(ctx, domain.UUID(slugOrID))
|
||||||
|
}
|
||||||
|
return s.entities.BySlug(ctx, slugOrID)
|
||||||
|
}
|
||||||
@@ -7,38 +7,78 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/ontology"
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TypeTree aliases the ontology tree: entity types, relationship types,
|
// TypeTree is the loaded ontology: entity types, relationship types,
|
||||||
// lifecycle definitions. ontology is pure over domain; it moves under
|
// lifecycle definitions. It is ontology's pure tree behind an interface so
|
||||||
// core/ when checkdefaults is absorbed (Phase 3).
|
// ports does not alias a concrete struct into the contract.
|
||||||
type TypeTree = ontology.TypeTree
|
type TypeTree = *ontology.TypeTree
|
||||||
|
|
||||||
// EntityFilters bounds entity list/search reads.
|
// EntityFilters bounds entity list/search reads.
|
||||||
type EntityFilters struct {
|
type EntityFilters struct {
|
||||||
Type string
|
Type string
|
||||||
State string
|
State string
|
||||||
Q string
|
Q string
|
||||||
|
Domain string
|
||||||
|
Layer string
|
||||||
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DerivedCheck is one concrete check derived from an entity's type
|
||||||
|
// monitoring spec, to be written in the same transaction as the entity
|
||||||
|
// mutation that produced it.
|
||||||
|
type DerivedCheck struct {
|
||||||
|
Kind string
|
||||||
|
Config map[string]any
|
||||||
|
IntervalS int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency replays-protects one command: the adapter stores the cached
|
||||||
|
// response inside the same transaction as the mutation, so a crash between
|
||||||
|
// the two cannot let a replay re-execute. RenderBody is a pure presenter
|
||||||
|
// closure that serializes the committed entity into the caller's wire
|
||||||
|
// shape; the repository never inspects it.
|
||||||
|
type Idempotency struct {
|
||||||
|
Actor string
|
||||||
|
Key string
|
||||||
|
RequestHash string
|
||||||
|
RenderBody func(domain.Entity) []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdempotentResponse is a previously cached response for (actor, key).
|
||||||
|
type IdempotentResponse struct {
|
||||||
|
RequestHash string
|
||||||
|
ResponseCode int
|
||||||
|
ResponseBody []byte
|
||||||
|
}
|
||||||
|
|
||||||
// EntityCreateInput is one transaction: the entity, its derived check
|
// EntityCreateInput is one transaction: the entity, its derived check
|
||||||
// definitions, and the audit/event side-effects of the creation.
|
// definitions, and the audit/event side-effects of the creation.
|
||||||
type EntityCreateInput struct {
|
type EntityCreateInput struct {
|
||||||
Entity domain.Entity
|
Entity domain.Entity
|
||||||
DerivedChecks []CheckDef
|
DerivedChecks []DerivedCheck
|
||||||
Audit []AuditEntry
|
Audit []AuditEntry
|
||||||
Event *Event
|
Event *Event
|
||||||
|
Idempotency *Idempotency
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntityUpdateInput mirrors EntityCreateInput for updates.
|
// EntityUpdateInput mutates an entity atomically. ExpectedVersion is the
|
||||||
|
// optimistic-concurrency check (0 disables it). When RederiveChecks is set,
|
||||||
|
// the repository re-derives default checks inside the transaction — the
|
||||||
|
// graph host fallback (a service inherits its container's address) reads
|
||||||
|
// relationships through the open transaction, so derivation cannot happen
|
||||||
|
// in the service for updates.
|
||||||
type EntityUpdateInput struct {
|
type EntityUpdateInput struct {
|
||||||
Entity domain.Entity
|
Entity domain.Entity
|
||||||
DerivedChecks []CheckDef
|
ExpectedVersion int
|
||||||
|
RederiveChecks bool
|
||||||
Audit []AuditEntry
|
Audit []AuditEntry
|
||||||
Event *Event
|
Event *Event
|
||||||
|
Idempotency *Idempotency
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntityTransitionInput is a lifecycle state change: the check-then-act
|
// EntityTransitionInput is a lifecycle state change: the declared-transition
|
||||||
// precondition (current state) is validated inside the transaction.
|
// check and preconditions are validated inside the transaction
|
||||||
|
// (check-then-act), not against the possibly-stale From.
|
||||||
type EntityTransitionInput struct {
|
type EntityTransitionInput struct {
|
||||||
Slug string
|
Slug string
|
||||||
From string
|
From string
|
||||||
@@ -52,12 +92,16 @@ type EntityTransitionInput struct {
|
|||||||
type EntityRepository interface {
|
type EntityRepository interface {
|
||||||
Get(ctx context.Context, id domain.UUID) (domain.Entity, error)
|
Get(ctx context.Context, id domain.UUID) (domain.Entity, error)
|
||||||
BySlug(ctx context.Context, slug string) (domain.Entity, error)
|
BySlug(ctx context.Context, slug string) (domain.Entity, error)
|
||||||
List(ctx context.Context, filters EntityFilters) ([]domain.Entity, error)
|
List(ctx context.Context, filters EntityFilters) ([]domain.Entity, string, error)
|
||||||
Search(ctx context.Context, q string, limit int) ([]domain.Entity, error)
|
Search(ctx context.Context, q string, limit int) ([]domain.Entity, error)
|
||||||
|
|
||||||
Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error)
|
Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error)
|
||||||
Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error)
|
Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error)
|
||||||
SetState(ctx context.Context, input EntityTransitionInput) (domain.Entity, error)
|
SetState(ctx context.Context, input EntityTransitionInput) (domain.Entity, error)
|
||||||
|
|
||||||
|
// GetIdempotent returns the cached response for (actor, key), or
|
||||||
|
// domain.ErrNotFound when none exists.
|
||||||
|
GetIdempotent(ctx context.Context, actor, key string) (IdempotentResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelationshipCreateInput validates endpoints against the ontology in core
|
// RelationshipCreateInput validates endpoints against the ontology in core
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Event struct {
|
|||||||
Severity string // "info", "warning", "critical"
|
Severity string // "info", "warning", "critical"
|
||||||
Source string // "api", "mcp", "scheduler", "webhook"
|
Source string // "api", "mcp", "scheduler", "webhook"
|
||||||
EntityID domain.UUID
|
EntityID domain.UUID
|
||||||
|
CorrelationID string
|
||||||
Data map[string]any
|
Data map[string]any
|
||||||
Ts time.Time
|
Ts time.Time
|
||||||
}
|
}
|
||||||
@@ -25,12 +26,17 @@ type EventPublisher interface {
|
|||||||
Publish(ctx context.Context, event Event) error
|
Publish(ctx context.Context, event Event) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEntry is an append-only audit-log record.
|
// AuditEntry is an append-only audit-log record. Method/Path carry the
|
||||||
|
// surface context ("POST", "/api/v1/entities" for REST; "TOOL",
|
||||||
|
// "create_entity" for MCP).
|
||||||
type AuditEntry struct {
|
type AuditEntry struct {
|
||||||
ActorType string // "agent", "operator", "system"
|
ActorType string // "agent", "operator", "system"
|
||||||
ActorLabel string
|
ActorLabel string
|
||||||
Action string
|
Action string
|
||||||
EntityID domain.UUID
|
EntityID domain.UUID
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
CorrelationID string
|
||||||
Details map[string]any
|
Details map[string]any
|
||||||
Ts time.Time
|
Ts time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type EntityRepo struct {
|
|||||||
Audits []ports.AuditEntry
|
Audits []ports.AuditEntry
|
||||||
Events []ports.Event
|
Events []ports.Event
|
||||||
Checks map[domain.UUID][]ports.CheckDef
|
Checks map[domain.UUID][]ports.CheckDef
|
||||||
|
Idempotent map[string]ports.IdempotentResponse
|
||||||
|
Rederived []domain.UUID
|
||||||
ErrStub error // returned by every command when set
|
ErrStub error // returned by every command when set
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ func NewEntityRepo() *EntityRepo {
|
|||||||
byID: make(map[domain.UUID]domain.Entity),
|
byID: make(map[domain.UUID]domain.Entity),
|
||||||
bySlug: make(map[string]domain.UUID),
|
bySlug: make(map[string]domain.UUID),
|
||||||
Checks: make(map[domain.UUID][]ports.CheckDef),
|
Checks: make(map[domain.UUID][]ports.CheckDef),
|
||||||
|
Idempotent: make(map[string]ports.IdempotentResponse),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +61,9 @@ func (r *EntityRepo) BySlug(_ context.Context, slug string) (domain.Entity, erro
|
|||||||
return r.byID[id], nil
|
return r.byID[id], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns entities filtered by type/state, bounded by limit.
|
// List returns entities filtered by type/state, bounded by limit, with the
|
||||||
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, error) {
|
// next cursor ("" when exhausted).
|
||||||
|
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
var out []domain.Entity
|
var out []domain.Entity
|
||||||
@@ -76,13 +80,17 @@ func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.En
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
next := ""
|
||||||
|
if len(out) == f.Limit && f.Limit > 0 {
|
||||||
|
next = "cursor-more"
|
||||||
|
}
|
||||||
|
return out, next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search matches name/slug substrings.
|
// Search matches name/slug substrings.
|
||||||
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
|
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
|
||||||
// Substring over name/slug is enough for service tests.
|
// Substring over name/slug is enough for service tests.
|
||||||
all, err := r.List(ctx, ports.EntityFilters{Limit: limit})
|
all, _, err := r.List(ctx, ports.EntityFilters{Limit: limit})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -114,27 +122,50 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma
|
|||||||
if in.Event != nil {
|
if in.Event != nil {
|
||||||
r.Events = append(r.Events, *in.Event)
|
r.Events = append(r.Events, *in.Event)
|
||||||
}
|
}
|
||||||
r.Checks[in.Entity.ID] = in.DerivedChecks
|
if in.Idempotency != nil {
|
||||||
|
var body []byte
|
||||||
|
if in.Idempotency.RenderBody != nil {
|
||||||
|
body = in.Idempotency.RenderBody(in.Entity)
|
||||||
|
}
|
||||||
|
r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
|
||||||
|
RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
return in.Entity, nil
|
return in.Entity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update replaces a stored entity and records its input side-effects.
|
// Update replaces a stored entity and records its input side-effects.
|
||||||
|
// The optimistic-version check mirrors the postgres WHERE version clause.
|
||||||
func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
|
func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
|
||||||
if r.ErrStub != nil {
|
if r.ErrStub != nil {
|
||||||
return domain.Entity{}, r.ErrStub
|
return domain.Entity{}, r.ErrStub
|
||||||
}
|
}
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if _, ok := r.byID[in.Entity.ID]; !ok {
|
current, ok := r.byID[in.Entity.ID]
|
||||||
|
if !ok {
|
||||||
return domain.Entity{}, domain.ErrNotFound
|
return domain.Entity{}, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
delete(r.bySlug, r.byID[in.Entity.ID].Slug)
|
if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
|
||||||
r.store(in.Entity)
|
return domain.Entity{}, domain.ErrConflict
|
||||||
|
}
|
||||||
|
after := in.Entity
|
||||||
|
after.Version = current.Version + 1
|
||||||
|
delete(r.bySlug, current.Slug)
|
||||||
|
r.store(after)
|
||||||
r.Audits = append(r.Audits, in.Audit...)
|
r.Audits = append(r.Audits, in.Audit...)
|
||||||
if in.Event != nil {
|
if in.Event != nil {
|
||||||
r.Events = append(r.Events, *in.Event)
|
r.Events = append(r.Events, *in.Event)
|
||||||
}
|
}
|
||||||
r.Checks[in.Entity.ID] = in.DerivedChecks
|
if in.Idempotency != nil {
|
||||||
|
var body []byte
|
||||||
|
if in.Idempotency.RenderBody != nil {
|
||||||
|
body = in.Idempotency.RenderBody(in.Entity)
|
||||||
|
}
|
||||||
|
r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
|
||||||
|
RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
return in.Entity, nil
|
return in.Entity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +199,17 @@ func (r *EntityRepo) store(e domain.Entity) {
|
|||||||
r.order = append(r.order, e.ID)
|
r.order = append(r.order, e.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIdempotent returns the cached response for (actor, key).
|
||||||
|
func (r *EntityRepo) GetIdempotent(_ context.Context, actor, key string) (ports.IdempotentResponse, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cached, ok := r.Idempotent[actor+"\x00"+key]
|
||||||
|
if !ok {
|
||||||
|
return ports.IdempotentResponse{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FindBySlug is a test helper bypassing the port interface.
|
// FindBySlug is a test helper bypassing the port interface.
|
||||||
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
|
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
||||||
"github.com/dtoro/oikos/internal/core/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
|
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
|
||||||
@@ -25,25 +21,21 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
|||||||
|
|
||||||
// Check idempotency if a key was provided. The idempotency scope is the
|
// Check idempotency if a key was provided. The idempotency scope is the
|
||||||
// calling actor, so replays are per-caller.
|
// calling actor, so replays are per-caller.
|
||||||
actorType, actorLabel := actorInfo(ctx)
|
actorType, actor := actorInfo(ctx)
|
||||||
actor := actorLabel
|
var idem *ports.Idempotency
|
||||||
var bodyHash string
|
|
||||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||||
|
bodyJSON, _ := json.Marshal(req.Body)
|
||||||
|
hash := fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
||||||
key := *req.Params.IdempotencyKey
|
key := *req.Params.IdempotencyKey
|
||||||
q := sqlcgen.New(s.pool)
|
|
||||||
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{
|
cached, err := s.entityRepo.GetIdempotent(ctx, actor, key)
|
||||||
Actor: actor,
|
|
||||||
Key: key,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Verify the request body hasn't changed.
|
// Verify the request body hasn't changed.
|
||||||
bodyJSON, _ := json.Marshal(req.Body)
|
if cached.RequestHash != hash {
|
||||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
|
||||||
if cached.RequestHash != bodyHash {
|
|
||||||
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
||||||
}
|
}
|
||||||
// Replay the cached response.
|
// Replay the cached response.
|
||||||
if cached.ResponseCode != nil && *cached.ResponseCode == 201 {
|
if cached.ResponseCode == 201 {
|
||||||
var entity gen.Entity
|
var entity gen.Entity
|
||||||
if len(cached.ResponseBody) > 0 {
|
if len(cached.ResponseBody) > 0 {
|
||||||
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
|
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
|
||||||
@@ -57,123 +49,38 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
|||||||
}
|
}
|
||||||
// Forward cached error response.
|
// Forward cached error response.
|
||||||
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
|
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
|
||||||
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"},
|
Body: gen.Problem{Status: cached.ResponseCode, Title: "replayed error"},
|
||||||
StatusCode: int(*cached.ResponseCode),
|
StatusCode: cached.ResponseCode,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
idem = &ports.Idempotency{
|
||||||
|
Actor: actor, Key: key, RequestHash: hash,
|
||||||
|
// RenderBody serializes the adapter's wire shape inside the
|
||||||
|
// create's transaction, so a replay returns the original
|
||||||
|
// response atomically with the insert.
|
||||||
|
RenderBody: func(e domain.Entity) []byte {
|
||||||
|
b, _ := json.Marshal(domainToGen(e))
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := uuid.NewV7()
|
created, _, err := s.entities.Create(ctx, app.CreateEntityCmd{
|
||||||
if err != nil {
|
Slug: req.Body.Slug,
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
slug := req.Body.Slug
|
|
||||||
if slug == "" {
|
|
||||||
slug = req.Body.Type + ":" + req.Body.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
q := sqlcgen.New(tx)
|
|
||||||
|
|
||||||
// Validate type exists and is NOT abstract.
|
|
||||||
var isAbstract bool
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil {
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if isAbstract {
|
|
||||||
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get default state from lifecycle.
|
|
||||||
var defaultState *string
|
|
||||||
var lcDefault string
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld
|
|
||||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
|
||||||
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil {
|
|
||||||
defaultState = &lcDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
state := req.Body.State
|
|
||||||
if state == nil && defaultState != nil {
|
|
||||||
state = defaultState
|
|
||||||
}
|
|
||||||
|
|
||||||
// attributes is NOT NULL; the column default only applies when omitted,
|
|
||||||
// not when an explicit NULL is bound — so default to an empty object.
|
|
||||||
attrsJSON := []byte("{}")
|
|
||||||
if req.Body.Attributes != nil {
|
|
||||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the entity.
|
|
||||||
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
|
||||||
ID: id,
|
|
||||||
Slug: slug,
|
|
||||||
Type: req.Body.Type,
|
Type: req.Body.Type,
|
||||||
Name: req.Body.Name,
|
Name: req.Body.Name,
|
||||||
State: state,
|
State: derefStr(req.Body.State),
|
||||||
Attributes: attrsJSON,
|
Attributes: derefAttrs(req.Body.Attributes),
|
||||||
|
ActorType: actorType,
|
||||||
|
Actor: actor,
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/entities",
|
||||||
|
Idempotency: idem,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Duplicate slug.
|
|
||||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
|
||||||
return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
entity := domainToGen(created)
|
||||||
// Convert sqlcgen.Entity → gen.Entity.
|
|
||||||
entity := sqlcEntityToGen(inserted)
|
|
||||||
|
|
||||||
// Cache idempotent response.
|
|
||||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
|
||||||
respBody, _ := json.Marshal(entity)
|
|
||||||
code := int32(201)
|
|
||||||
if bodyHash == "" {
|
|
||||||
bodyJSON, _ := json.Marshal(req.Body)
|
|
||||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
|
||||||
}
|
|
||||||
if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
|
|
||||||
Actor: actor,
|
|
||||||
Key: *req.Params.IdempotencyKey,
|
|
||||||
RequestHash: bodyHash,
|
|
||||||
ResponseCode: &code,
|
|
||||||
ResponseBody: respBody,
|
|
||||||
}); putErr != nil {
|
|
||||||
return nil, putErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit.
|
|
||||||
entityID := inserted.ID
|
|
||||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
|
||||||
&entityID, "POST", "/api/v1/entities", "",
|
|
||||||
nil,
|
|
||||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
|
||||||
return nil, auditErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if eventErr := observability.Event(ctx, q, "entity.created", &entityID,
|
|
||||||
"info", "oikos-api", "",
|
|
||||||
map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil {
|
|
||||||
return nil, eventErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, inserted.Name, attrsJSON); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return gen.CreateEntity201JSONResponse{
|
return gen.CreateEntity201JSONResponse{
|
||||||
Body: entity,
|
Body: entity,
|
||||||
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||||
@@ -185,102 +92,37 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
|||||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := s.resolveEntityID(ctx, req.Id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse If-Match header (quoted version string).
|
// Parse If-Match header (quoted version string).
|
||||||
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
|
ifMatch := trimQuotes(req.Params.IfMatch)
|
||||||
expectedVersion, err := strconv.Atoi(ifMatch)
|
expectedVersion, err := strconv.Atoi(ifMatch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
|
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
patchActorType, patchActor := actorInfo(ctx)
|
||||||
if err != nil {
|
updated, _, err := s.entities.Update(ctx, app.UpdateEntityCmd{
|
||||||
return nil, err
|
SlugOrID: req.Id,
|
||||||
}
|
ExpectedVer: expectedVersion,
|
||||||
defer tx.Rollback(ctx)
|
Name: derefStr(req.Body.Name),
|
||||||
|
State: derefStr(req.Body.State),
|
||||||
// Get current entity for version check + lifecycle validation.
|
Attributes: derefAttrs(req.Body.Attributes),
|
||||||
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id)
|
AttrsReplace: true,
|
||||||
if err != nil {
|
Maintenance: req.Body.MaintenanceUntil,
|
||||||
if err == pgx.ErrNoRows {
|
SetMaint: req.Body.MaintenanceUntil != nil,
|
||||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id)
|
// Attribute changes propagate to derived checks (the A2 parity fix:
|
||||||
}
|
// previously only the MCP surface regenerated checks on attribute
|
||||||
return nil, err
|
// changes).
|
||||||
}
|
RederiveChecks: true,
|
||||||
|
ActorType: patchActorType,
|
||||||
if int(current.Version) != expectedVersion {
|
Actor: patchActor,
|
||||||
return nil, fmt.Errorf("%w: expected version %d, current version %d",
|
Method: "PATCH",
|
||||||
domain.ErrConflict, expectedVersion, current.Version)
|
Path: "/api/v1/entities/" + req.Id,
|
||||||
}
|
|
||||||
|
|
||||||
// Validate lifecycle transition if state is being changed.
|
|
||||||
if req.Body.State != nil && *req.Body.State != "" {
|
|
||||||
fromState := ""
|
|
||||||
if current.State != nil {
|
|
||||||
fromState = *current.State
|
|
||||||
}
|
|
||||||
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
|
|
||||||
if errors.Is(err, db.ErrTransitionInvalid) {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH,
|
|
||||||
// but we handle it if the generated code ever adds it).
|
|
||||||
// For now, no idempotency check on PATCH.
|
|
||||||
|
|
||||||
// Marshal attributes if provided.
|
|
||||||
var attrsJSON []byte
|
|
||||||
if req.Body.Attributes != nil {
|
|
||||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the update via sqlcgen.
|
|
||||||
q := sqlcgen.New(tx)
|
|
||||||
updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
|
||||||
Name: req.Body.Name,
|
|
||||||
State: req.Body.State,
|
|
||||||
Attributes: attrsJSON,
|
|
||||||
SetMaintenance: req.Body.MaintenanceUntil != nil,
|
|
||||||
MaintenanceUntil: req.Body.MaintenanceUntil,
|
|
||||||
ID: id,
|
|
||||||
Version: int32(expectedVersion),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
// Version mismatch or entity not found.
|
|
||||||
return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entity := sqlcEntityToGen(updated)
|
|
||||||
|
|
||||||
// Audit.
|
|
||||||
patchActorType, patchActor := actorInfo(ctx)
|
|
||||||
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
|
||||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
|
||||||
nil,
|
|
||||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
|
||||||
return nil, auditErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if eventErr := observability.Event(ctx, q, "entity.updated", &id,
|
|
||||||
"info", "oikos-api", "",
|
|
||||||
map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil {
|
|
||||||
return nil, eventErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entity := domainToGen(updated)
|
||||||
s.entityCache.Invalidate(entity.Slug, entity.Id.String())
|
s.entityCache.Invalidate(entity.Slug, entity.Id.String())
|
||||||
|
|
||||||
return gen.PatchEntity200JSONResponse{
|
return gen.PatchEntity200JSONResponse{
|
||||||
@@ -288,3 +130,49 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
|||||||
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefStr(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefAttrs(p *map[string]any) map[string]any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimQuotes(s string) string {
|
||||||
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainToGen converts a domain entity to the wire shape.
|
||||||
|
func domainToGen(e domain.Entity) gen.Entity {
|
||||||
|
id, _ := uuid.Parse(string(e.ID))
|
||||||
|
out := gen.Entity{
|
||||||
|
Id: id,
|
||||||
|
Slug: e.Slug,
|
||||||
|
Type: e.Type,
|
||||||
|
Name: e.Name,
|
||||||
|
Version: e.Version,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
}
|
||||||
|
if e.State != "" {
|
||||||
|
out.State = &e.State
|
||||||
|
}
|
||||||
|
if e.MaintenanceUntil != nil {
|
||||||
|
out.MaintenanceUntil = e.MaintenanceUntil
|
||||||
|
}
|
||||||
|
if len(e.Attributes) > 0 {
|
||||||
|
attrs := e.Attributes
|
||||||
|
out.Attributes = &attrs
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/actuator"
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
@@ -59,6 +60,12 @@ type Server struct {
|
|||||||
sseBroker *sseBroker
|
sseBroker *sseBroker
|
||||||
sseSubs map[*sseSubscriber]struct{}
|
sseSubs map[*sseSubscriber]struct{}
|
||||||
sseMu sync.Mutex
|
sseMu sync.Mutex
|
||||||
|
|
||||||
|
// entities is the entity-aggregate use-case service (Phase 3 of the
|
||||||
|
// hexagonal refactor); entityRepo exposes the idempotency reads the
|
||||||
|
// replay path needs. Wired here until main becomes the composition root.
|
||||||
|
entities *app.EntityService
|
||||||
|
entityRepo *db.EntityRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||||
@@ -76,6 +83,8 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
sseBroker: newSSEBroker(10000),
|
sseBroker: newSSEBroker(10000),
|
||||||
sseSubs: make(map[*sseSubscriber]struct{}),
|
sseSubs: make(map[*sseSubscriber]struct{}),
|
||||||
}
|
}
|
||||||
|
s.entityRepo = db.NewEntityRepo(pool)
|
||||||
|
s.entities = app.NewEntityService(s.entityRepo, db.NewOntologyRepo(pool, time.Minute))
|
||||||
|
|
||||||
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
||||||
if cfg.InfisicalSiteURL != "" {
|
if cfg.InfisicalSiteURL != "" {
|
||||||
@@ -318,7 +327,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
|
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
|
||||||
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
|
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
|
||||||
}
|
}
|
||||||
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager))
|
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities))
|
||||||
|
|
||||||
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
||||||
target, _ := url.Parse(nomosURL)
|
target, _ := url.Parse(nomosURL)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package mcp
|
|||||||
// (see internal/db/integration_test.go); run via `make test-db`.
|
// (see internal/db/integration_test.go); run via `make test-db`.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -97,7 +98,8 @@ func newTestPool(t *testing.T) *db.Pool {
|
|||||||
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
|
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var handler toolHandler
|
var handler toolHandler
|
||||||
for _, r := range allTools(pool, uuid.Nil, nil) {
|
entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
|
||||||
|
for _, r := range allTools(pool, uuid.Nil, nil, entities) {
|
||||||
if r.tool.Name == name {
|
if r.tool.Name == name {
|
||||||
handler = r.handler
|
handler = r.handler
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ package mcp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/audit"
|
"github.com/dtoro/oikos/internal/audit"
|
||||||
"github.com/dtoro/oikos/internal/core/ports"
|
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
|
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
|
||||||
return []toolReg{
|
return []toolReg{
|
||||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||||
InputSchema: objSchema(),
|
InputSchema: objSchema(),
|
||||||
@@ -109,80 +112,38 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
|
|||||||
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
attrsJSON, _ := json.Marshal(attrs)
|
|
||||||
stateStr, _ := args["state"].(string)
|
stateStr, _ := args["state"].(string)
|
||||||
|
|
||||||
tx, err := pool.Begin(ctx)
|
// One service path with the HTTP surface (ADR 0016 Phase 3):
|
||||||
|
// ontology validation, lifecycle-state guardrails, check
|
||||||
|
// derivation, and audit/event recording converge here.
|
||||||
|
_, res, err := entities.Create(ctx, app.CreateEntityCmd{
|
||||||
|
Slug: slug,
|
||||||
|
Type: entityType,
|
||||||
|
Name: name,
|
||||||
|
State: stateStr,
|
||||||
|
Attributes: attrs,
|
||||||
|
ActorType: "agent",
|
||||||
|
Actor: "mcp",
|
||||||
|
Method: "TOOL",
|
||||||
|
Path: "create_entity",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
if errors.Is(err, domain.ErrAlreadyExists) {
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
|
|
||||||
var isAbstract bool
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
|
|
||||||
}
|
|
||||||
if isAbstract {
|
|
||||||
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default state from the type's lifecycle unless the caller
|
|
||||||
// supplied one. Caller-supplied states are validated against
|
|
||||||
// the lifecycle's declared states — a create_entity bypass of
|
|
||||||
// lifecycle guardrails would let an agent create in a terminal
|
|
||||||
// state (destroyed) without satisfying the preconditions that
|
|
||||||
// set_entity_state enforces for the same transition.
|
|
||||||
var state *string
|
|
||||||
var lsDefault, statesRaw string
|
|
||||||
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
|
|
||||||
FROM lifecycle_defs ld
|
|
||||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
|
||||||
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
|
|
||||||
var validStates []string
|
|
||||||
json.Unmarshal([]byte(statesRaw), &validStates)
|
|
||||||
if stateStr != "" {
|
|
||||||
found := false
|
|
||||||
for _, s := range validStates {
|
|
||||||
if s == stateStr {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found && len(validStates) > 0 {
|
|
||||||
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
|
|
||||||
}
|
|
||||||
state = &stateStr
|
|
||||||
} else if lsDefault != "" {
|
|
||||||
state = &lsDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := uuid.NewV7()
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var createdName string
|
|
||||||
if err := tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING name`,
|
|
||||||
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
|
||||||
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, domain.ErrAbstractType) {
|
||||||
|
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, domain.ErrInvalidTransition) {
|
||||||
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||||
|
}
|
||||||
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
|
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
|
|
||||||
if derr != nil {
|
|
||||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
|
|
||||||
}
|
|
||||||
if cerr := tx.Commit(ctx); cerr != nil {
|
|
||||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return textResult(formatCreateResult(slug, entityType, res)), nil
|
return textResult(formatCreateResult(slug, entityType, res)), nil
|
||||||
}},
|
}},
|
||||||
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||||
@@ -221,41 +182,23 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
|
|||||||
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
attrsJSON, _ := json.Marshal(attrs)
|
// One service path with the HTTP surface (ADR 0016 Phase 3): merge +
|
||||||
|
// check regeneration + audit/event in one transaction.
|
||||||
// Run the merge + check regeneration in one transaction so the
|
_, res, err := entities.Update(ctx, app.UpdateEntityCmd{
|
||||||
// derived checks always see the post-merge attributes. Mirrors
|
SlugOrID: slug,
|
||||||
// httpapi.PatchEntity; without this, setting an entity's
|
Attributes: attrs,
|
||||||
// `monitoring` attribute via MCP silently produced no checks.
|
AttrsReplace: false,
|
||||||
tx, err := pool.Begin(ctx)
|
RederiveChecks: true,
|
||||||
|
ActorType: "agent",
|
||||||
|
Actor: "mcp",
|
||||||
|
Method: "TOOL",
|
||||||
|
Path: "update_entity_attributes",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
ra, err := sqlcgen.New(tx).MergeEntityAttributes(ctx, sqlcgen.MergeEntityAttributesParams{Slug: slug, Patch: attrsJSON})
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
|
||||||
}
|
|
||||||
if ra == 0 {
|
|
||||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||||
}
|
}
|
||||||
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||||
merged, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
|
|
||||||
}
|
|
||||||
id := merged.ID
|
|
||||||
entityType := merged.Type
|
|
||||||
name := merged.Name
|
|
||||||
mergedAttrs := merged.Attributes
|
|
||||||
|
|
||||||
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
|
|
||||||
if cerr != nil {
|
|
||||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
|
|
||||||
}
|
|
||||||
if cerr := tx.Commit(ctx); cerr != nil {
|
|
||||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
|
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (m *mockSecretBackend) Name() string { return "mock" }
|
|||||||
// findToolHandler locates a tool's handler from allTools by name.
|
// findToolHandler locates a tool's handler from allTools by name.
|
||||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, r := range allTools(nil, uuid.Nil, sec) {
|
for _, r := range allTools(nil, uuid.Nil, sec, nil) {
|
||||||
if r.tool.Name == name {
|
if r.tool.Name == name {
|
||||||
return r.handler
|
return r.handler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/actuator"
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/dtoro/oikos/internal/core/ports"
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
@@ -51,8 +52,8 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
|||||||
|
|
||||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets) http.Handler {
|
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) http.Handler {
|
||||||
s := newServer(pool, agentID, sec)
|
s := newServer(pool, agentID, sec, entities)
|
||||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||||
if token != "" {
|
if token != "" {
|
||||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||||
@@ -67,11 +68,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
|
|||||||
// toolHandler is the function signature registered via AddTool.
|
// toolHandler is the function signature registered via AddTool.
|
||||||
type toolHandler = mcp.ToolHandler
|
type toolHandler = mcp.ToolHandler
|
||||||
|
|
||||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) *mcp.Server {
|
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) *mcp.Server {
|
||||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
})
|
})
|
||||||
for _, t := range allTools(pool, agentID, sec) {
|
for _, t := range allTools(pool, agentID, sec, entities) {
|
||||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
// pool is only used inside tool handlers (invoked per-call), not at
|
// pool is only used inside tool handlers (invoked per-call), not at
|
||||||
// registration time, so a nil pool is safe for this construction test.
|
// registration time, so a nil pool is safe for this construction test.
|
||||||
s := newServer(nil, uuid.Nil, nil)
|
s := newServer(nil, uuid.Nil, nil, nil)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
t.Fatal("newServer returned nil")
|
t.Fatal("newServer returned nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ type toolReg struct {
|
|||||||
handler toolHandler
|
handler toolHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
|
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
|
||||||
return append(append(append(append(
|
return append(append(append(append(
|
||||||
[]toolReg{},
|
[]toolReg{},
|
||||||
EntityTools(pool, agentID, sec)...),
|
EntityTools(pool, agentID, sec, entities)...),
|
||||||
OpsTools(pool, agentID, sec)...),
|
OpsTools(pool, agentID, sec)...),
|
||||||
KnowledgeTools(pool, agentID, sec)...),
|
KnowledgeTools(pool, agentID, sec)...),
|
||||||
AnalysisTools(pool, agentID, sec)...)
|
AnalysisTools(pool, agentID, sec)...)
|
||||||
|
|||||||
Reference in New Issue
Block a user