Problem: the hexagonal-architecture plan (plans/2026-08-15-hexagonal-
architecture.md) needs its foundation — an accepted ADR, the target
directory tree, and machine-checked dependency rules — before any
service extraction starts. Also folds the four outstanding review
findings (F3.1/F5/F6/F7) into the plan: ObservationService owns the
bounded probe-concurrency contract (scheduler.go:133), Phase 9 gates
ExecutionService+PolicyService ≥ 90% with a gating-matrix test,
per-phase abort criteria, and the §3.2 internal/config note.
Change:
- docs/adr/0016-hexagonal-ports-adapters.md records context, decision,
and consequences of the ports & adapters migration.
- internal/domain → internal/core/domain (mechanical import rewrite,
20 files), new internal/core/{ports,app}, internal/adapters trees
with package docs.
- .golangci.yml: depguard rules for §3.1 (core purity, no agent-client
tech in core, nomos isolation — the nomos rules self-activate when
internal/nomos exists in Phase 8). Config migrated to golangci-lint
v2 format so it loads at all (the v1 config errored under v2, masked
by CI's advisory continue-on-error). Verified depguard fires on a
planted openai-go import in internal/core/app.
- CONTRIBUTING.md layout section now shows the core/adapters tree.
Risk: import path churn is mechanical and tests pass unchanged; the
lint config migration surfaces the pre-existing 400-issue baseline
(advisory in CI, unchanged policy) — new/moved packages lint clean.
Verification: go vet ./..., make test (race, core/domain at 100%
coverage), make generate-check, golangci-lint on internal/core/... and
internal/adapters/... — 0 issues; depguard violation probe confirmed.
115 lines
3.1 KiB
Go
115 lines
3.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
defaultLimit = 50
|
|
maxLimit = 200
|
|
// graphNodeCap bounds the whole-graph view. The cognition transactional
|
|
// types (execution, task) are audit records, not topology, and previously
|
|
// crowded out every host/lxc/service; the default whole-graph view below
|
|
// excludes them so the cap is spent on the actual fleet graph. Operators
|
|
// still reach executions/tasks via list_entities.
|
|
graphNodeCap = 2000
|
|
)
|
|
|
|
// actorInfo returns the caller's (type, label) from the request context,
|
|
// falling back to operator/unknown when unset.
|
|
func actorInfo(ctx context.Context) (string, string) {
|
|
if a := GetActor(ctx); a != nil {
|
|
typ := a.Type
|
|
if typ == "" {
|
|
typ = "operator"
|
|
}
|
|
label := a.Label
|
|
if label == "" {
|
|
label = a.ID
|
|
}
|
|
return typ, label
|
|
}
|
|
return "operator", "unknown"
|
|
}
|
|
|
|
func clampLimit(l *int) int {
|
|
if l == nil {
|
|
return defaultLimit
|
|
}
|
|
if *l < 1 {
|
|
return 1
|
|
}
|
|
if *l > maxLimit {
|
|
return maxLimit
|
|
}
|
|
return *l
|
|
}
|
|
|
|
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
|
|
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
|
if id, err := uuid.Parse(idOrSlug); err == nil {
|
|
if _, ok := s.entityCache.GetSlug(id.String()); ok {
|
|
return id, nil
|
|
}
|
|
entity, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
|
}
|
|
s.entityCache.Set(entity.Slug, entity.ID.String(), "")
|
|
return entity.ID, nil
|
|
}
|
|
if cachedID, ok := s.entityCache.GetID(idOrSlug); ok {
|
|
id, parseErr := uuid.Parse(cachedID)
|
|
if parseErr == nil {
|
|
return id, nil
|
|
}
|
|
}
|
|
entity, err := sqlcgen.New(s.pool).GetEntityBySlug(ctx, idOrSlug)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
|
}
|
|
s.entityCache.Set(entity.Slug, entity.ID.String(), "")
|
|
return entity.ID, nil
|
|
}
|
|
|
|
// entityCols requires the entities table to be aliased as `e`, with
|
|
// entity_status left-joined and aliased as `st` (see withEntityStatus).
|
|
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
|
|
e.maintenance_until, e.version, e.created_at, e.updated_at,
|
|
st.health, st.last_check_at`
|
|
|
|
func scanEntity(row pgx.Row) (gen.Entity, error) {
|
|
var e gen.Entity
|
|
var state *string
|
|
var attrsJSON []byte
|
|
var maint *time.Time
|
|
var health *string
|
|
var lastCheckAt *time.Time
|
|
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
|
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
e.State = state
|
|
e.MaintenanceUntil = maint
|
|
if health != nil {
|
|
h := gen.EntityHealth(*health)
|
|
e.Health = &h
|
|
}
|
|
e.LastCheckAt = lastCheckAt
|
|
var attrs map[string]any
|
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
|
e.Attributes = &attrs
|
|
}
|
|
return e, nil
|
|
}
|