Files
oikos/internal/httpapi/checks.go
dtoro 4e294b3630
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
0.22.0 — full MCP agent surface: 19 new tools, async run, session reliability
Stage 1 — Foundation:
- Target validation: iptables + systemctl/docker target-type gates
- Async run for long-running commands (sleep/wait/poll loops)
- audit_log.session_id plumbing (SQL, sqlcgen, 18 call sites)

Stage 2 — External agent observe (11 tools):
- get_dashboard_summary, get_ontology, list_checks, list_executions
- get_knowledge_revisions, get_knowledge_duplicates, get_knowledge_orphans
- list_knowledge_tags, list_entity_sessions, find_entities_by
- 3 resource templates: oikos://entity/{slug}, knowledge/{id}, execution/{id}

Stage 3 — Nomos reliability:
- complete_task(success) refused without verification (upgraded from warn)
- sessionHasPlan excludes replaced steps (forces propose_plan after reopen)
- Bash syntax validation in run() (rejects literal \n, flag-space typos)
- Scope gate in SOUL.md (ask before pivoting to unrelated subsystem)

Stage 4 — External agent act (9 mutation tools):
- ack_signal, resolve_signal, mute_signal, cancel_execution
- update_check, delete_knowledge, restore_knowledge
- merge_knowledge, rename_knowledge_tag
2026-08-04 23:51:55 +02:00

325 lines
8.6 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT cd.entity_id, e.slug, cd.kind,
COALESCE(te.slug, '') AS target_slug, cd.target_type,
cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled,
e.version, cd.last_health, cd.last_run_at
FROM check_defs cd
JOIN entities e ON e.id = cd.entity_id
LEFT JOIN entities te ON te.id = cd.target_id
WHERE ($1::text IS NULL OR cd.kind = $1)
AND ($2::text IS NULL OR te.slug = $2)
AND ($3::bool IS NULL OR cd.enabled = $3)
AND ($4::text IS NULL OR e.slug > $4)
ORDER BY e.slug
LIMIT $5`,
req.Params.Kind, req.Params.Target, req.Params.Enabled, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Check{}
for rows.Next() {
var c gen.Check
var targetSlug string
var configBytes []byte
// last_health is what turns a check list from configuration into an
// explanation: an entity's health is the worst of these, so this is
// the field that says which probe is responsible.
var lastHealth *string
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version,
&lastHealth, &c.LastRunAt); err != nil {
return nil, err
}
if lastHealth != nil {
h := gen.CheckLastHealth(*lastHealth)
c.LastHealth = &h
}
if targetSlug != "" {
c.Target = &targetSlug
}
var config map[string]any
if len(configBytes) > 0 && json.Unmarshal(configBytes, &config) == nil && len(config) > 0 {
c.Config = &config
}
items = append(items, c)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Check{}
}
return gen.ListChecks200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
slug := req.Body.Slug
if slug == "" {
slug = "check:" + string(req.Body.Kind) + ":" + uuid.New().String()[:8]
}
// Resolve target if provided.
var targetID *uuid.UUID
if req.Body.Target != nil && *req.Body.Target != "" {
tid, rerr := s.resolveEntityID(ctx, *req.Body.Target)
if rerr != nil {
return nil, rerr
}
targetID = &tid
}
intervalS := int32(300)
if req.Body.IntervalS != nil {
intervalS = int32(*req.Body.IntervalS)
}
timeoutS := int32(30)
if req.Body.TimeoutS != nil {
timeoutS = int32(*req.Body.TimeoutS)
}
enabled := true
if req.Body.Enabled != nil {
enabled = *req.Body.Enabled
}
configJSON := []byte("{}")
if req.Body.Config != nil {
configJSON, _ = json.Marshal(req.Body.Config)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Create the entity row (checks are entities).
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: slug,
Type: "check",
Name: slug,
Attributes: []byte("{}"),
})
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: check %q already exists", domain.ErrAlreadyExists, slug)
}
return nil, err
}
if err := q.InsertCheckDef(ctx, sqlcgen.InsertCheckDefParams{
EntityID: id,
TargetID: targetID,
TargetType: req.Body.TargetType,
Kind: string(req.Body.Kind),
Config: configJSON,
IntervalS: intervalS,
TimeoutS: timeoutS,
Zone: req.Body.Zone,
Enabled: enabled,
}); err != nil {
return nil, err
}
// Build response Check.
check := gen.Check{
Id: id,
Slug: entity.Slug,
Kind: gen.CheckKind(req.Body.Kind),
IntervalS: int(intervalS),
TimeoutS: int(timeoutS),
Enabled: enabled,
TargetType: req.Body.TargetType,
Zone: req.Body.Zone,
Version: int(entity.Version),
}
if req.Body.Config != nil {
check.Config = req.Body.Config
}
if targetID != nil && req.Body.Target != nil {
check.Target = req.Body.Target
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/checks", "",
nil,
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateCheck201JSONResponse(check), nil
}
func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, error) {
if req.Body == nil {
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
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
expectedVersion, err := parseIntIfMatch(ifMatch)
if err != nil {
return nil, err
}
_ = expectedVersion // check_defs don't track version via If-Match today, but we validate the header is present
if ifMatch == "" {
return nil, fmt.Errorf("%w: invalid If-Match header", domain.ErrInvalidInput)
}
// Get current check def
current, err := sqlcgen.New(s.pool).GetCheckDef(ctx, id)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: check %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Apply patch.
if req.Body.Config != nil {
current.Config, _ = json.Marshal(req.Body.Config)
}
if req.Body.IntervalS != nil {
current.IntervalS = int32(*req.Body.IntervalS)
}
if req.Body.TimeoutS != nil {
current.TimeoutS = int32(*req.Body.TimeoutS)
}
if req.Body.Enabled != nil {
current.Enabled = *req.Body.Enabled
}
if err := sqlcgen.New(tx).UpdateCheckDef(ctx, sqlcgen.UpdateCheckDefParams{
EntityID: id,
Kind: current.Kind,
Config: current.Config,
IntervalS: current.IntervalS,
TimeoutS: current.TimeoutS,
TargetID: current.TargetID,
TargetType: current.TargetType,
Zone: current.Zone,
Enabled: current.Enabled,
}); err != nil {
return nil, err
}
// Re-read to get updated timestamp.
updated, err := sqlcgen.New(tx).GetCheckDef(ctx, id)
if err != nil {
return nil, err
}
check := checkDefToGen(updated)
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
nil,
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchCheck200JSONResponse(check), nil
}
func checkDefToGen(cd sqlcgen.CheckDef) gen.Check {
c := gen.Check{
Id: cd.EntityID,
Kind: gen.CheckKind(cd.Kind),
IntervalS: int(cd.IntervalS),
TimeoutS: int(cd.TimeoutS),
Enabled: cd.Enabled,
TargetType: cd.TargetType,
Zone: cd.Zone,
}
var config map[string]any
if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 {
c.Config = &config
}
// Carried through so toggling a check does not blank its verdict in the
// UI — the entity window renders last_health to explain which probe is
// responsible for an entity's health, and a patch response missing it
// would erase that until the next poll.
c.LastRunAt = cd.LastRunAt
if cd.LastHealth != nil {
h := gen.CheckLastHealth(*cd.LastHealth)
c.LastHealth = &h
}
return c
}
// parseIntIfMatch parses an integer from a raw If-Match header value (with quotes stripped).
func parseIntIfMatch(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty version")
}
var v int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid version: %q", s)
}
v = v*10 + int(c-'0')
}
return v, nil
}