diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c37e3f..7900941 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hu - Checks Gitea releases every 6 hours - System tray → **Check for Updates** triggers an immediate check - Download, extract, replace the app in `/Applications`, and relaunch -- Versions are compared against the `version` const in `main.go` +- Versions are compared against the `version` var in `main.go`, injected from the repo `VERSION` file at link time (`make desktop` passes `-ldflags "-X main.version=$(cat VERSION)"`) ## Project structure diff --git a/Makefile b/Makefile index e14b7fa..ada87ab 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ .PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install -BINARY := oikos +BINARY := bin/oikos GO ?= go build: + mkdir -p bin $(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos webhook: @@ -56,7 +57,7 @@ desktop: ui ## Build the Wails desktop app for the current platform rm -rf cmd/desktop/frontend/dist mkdir -p cmd/desktop/frontend/dist cp -r web/dist/* cmd/desktop/frontend/dist/ - cd cmd/desktop && CGO_ENABLED=1 go build -o build/bin/Oikos . + cd cmd/desktop && CGO_ENABLED=1 go build -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos . desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux) @case $$(uname -s) in \ @@ -81,6 +82,7 @@ install: desktop-package ## Install to /Applications clean: rm -f $(BINARY) + rm -rf bin rm -rf cmd/desktop/build rm -rf cmd/desktop/frontend/dist $(GO) clean -testcache diff --git a/VERSION b/VERSION index e7c7d3c..972ef76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.8 +0.7.9 diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 09d1d44..20b1cab 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -36,13 +36,17 @@ var iconPNG []byte const ( keyringService = "com.hubris.oikos-desktop" keyringUser = "oikos" - version = "0.1.0" updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" pollInterval = 30 * time.Second updateInterval = 6 * time.Hour oidcCallbackPort = 18901 ) +// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)" +// (Makefile desktop target). The default keeps a non-empty fallback for +// `go build ./cmd/desktop` without ldflags. +var version = "0.1.0-dev" + type OikosConfig struct { ApiUrl string `json:"apiUrl"` Token string `json:"token,omitempty"` diff --git a/internal/httpapi/actuator.go b/internal/httpapi/actuator.go new file mode 100644 index 0000000..84dce48 --- /dev/null +++ b/internal/httpapi/actuator.go @@ -0,0 +1,676 @@ +package httpapi + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/observability" + "github.com/google/uuid" + "golang.org/x/crypto/ssh" +) + +var ( + _sshUser string + _sshKey []byte +) + +// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes"). +// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool` +// field made the approved pct_create execution fail to parse *after* the +// operator had already approved it — the container was never created and the +// operator saw "queued" with no result. This type tolerates the common shapes. +type flexBool bool + +func (b *flexBool) UnmarshalJSON(data []byte) error { + s := strings.TrimSpace(strings.Trim(string(data), `"`)) + switch strings.ToLower(s) { + case "true", "1", "yes", "on": + *b = true + case "false", "0", "no", "off", "", "null": + *b = false + default: + return fmt.Errorf("cannot parse %q as bool", s) + } + return nil +} + +func initSSH() { + if _sshUser == "" { + _sshUser = os.Getenv("OIKOS_SSH_USER") + if _sshUser == "" { + _sshUser = "root" + } + } + if len(_sshKey) == 0 { + keyPath := os.Getenv("OIKOS_SSH_KEY_PATH") + if keyPath == "" { + keyPath = "/etc/oikos/ssh_key" + } + var err error + _sshKey, err = os.ReadFile(keyPath) + if err != nil { + slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err) + } + } +} + +// sshExecTimeout bounds how long a single remote command may run. Without +// this, a hung remote command (e.g. a piped install script stuck retrying +// DNS against a misconfigured gateway) blocks the executing goroutine +// forever: the execution never leaves 'approved'/'running', the operator +// sees an unkillable spinner, and get_execution_status has nothing new to +// report. Generous enough for a real apt/docker install; not infinite. +const sshExecTimeout = 10 * time.Minute + +func sshExec(ctx context.Context, host, user, command string) (string, error) { + initSSH() + if len(_sshKey) == 0 { + return "", fmt.Errorf("no SSH key available") + } + if user == "" { + user = _sshUser + } + + addr := host + ":22" + signer, err := ssh.ParsePrivateKey(_sshKey) + if err != nil { + return "", fmt.Errorf("parse key: %w", err) + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return "", fmt.Errorf("dial %s: %w", host, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("session: %w", err) + } + defer session.Close() + + type result struct { + out []byte + err error + } + done := make(chan result, 1) + go func() { + // See internal/mcp/server.go's sshExec for why this recovers rather + // than letting a rare SSH-library panic crash the whole api process. + defer func() { + if r := recover(); r != nil { + done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)} + } + }() + out, err := session.CombinedOutput(command) + done <- result{out, err} + }() + + select { + case r := <-done: + text := strings.TrimSpace(string(r.out)) + // A non-zero exit MUST surface as an error. The previous guard only + // errored when there was no output, so a `pct create` that printed + // "CT 132 already exists" and exited non-zero was reported as + // success — the execution was marked completed though nothing was + // provisioned. + if r.err != nil { + if text != "" { + return text, fmt.Errorf("%w: %s", r.err, text) + } + return text, fmt.Errorf("exec: %w", r.err) + } + return text, nil + case <-time.After(sshExecTimeout): + // Close the session/client to hang up the remote side; the + // goroutine above will eventually exit once that unblocks + // CombinedOutput, but we don't wait for it — the caller needs an + // answer now, not an indefinite hang. + session.Close() + client.Close() + return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host) + case <-ctx.Done(): + session.Close() + client.Close() + return "", ctx.Err() + } +} + +func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) { + var attrs string + err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs) + if err != nil { + return "", "", fmt.Errorf("entity not found: %s", entitySlug) + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(attrs), &m); err != nil { + return "", "", fmt.Errorf("parse attributes: %w", err) + } + + sshUser := _sshUser + if sshUser == "" { + sshUser = "root" + } + + if ip, ok := m["lan_ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + if mesh, ok := m["mesh"].(map[string]interface{}); ok { + for _, proto := range []string{"netbird", "tailscale"} { + if p, ok := mesh[proto].(map[string]interface{}); ok { + if ip, ok := p["ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + } + } + } + return "", "", fmt.Errorf("no IP found for %s", entitySlug) +} + +// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved- +// execution side: any target slug (host: or lxc:) resolves to the SSH +// endpoint that runs the command plus a wrap function that turns a plain +// shell command into what actually needs to be sent — identity for a host, +// `pct exec ` for an LXC. Kept as a small duplicate rather than a +// cross-package import to avoid coupling httpapi to mcp for one helper. +func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) { + if strings.HasPrefix(targetSlug, "host:") { + host, user, err = resolveHostSSH(ctx, pool, targetSlug) + return host, user, func(cmd string) string { return cmd }, err + } + if strings.HasPrefix(targetSlug, "lxc:") { + var pveID, hostAttr string + // COALESCE the host column: many older LXC entities (seeded from + // inventory, not provisioned by pct_create) have pve_id but no host + // attribute at all. Scanning a SQL NULL into a plain string errors + // the whole row, wrongly reporting "missing pve_id" even when it was + // present — COALESCE avoids the NULL, "" is handled below. + if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" { + return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug) + } + hostSlug := hostAttr + if hostSlug == "" { + hostSlug = "hubris" + } + if !strings.HasPrefix(hostSlug, "host:") { + hostSlug = "host:" + hostSlug + } + host, user, err = resolveHostSSH(ctx, pool, hostSlug) + id := pveID + return host, user, func(cmd string) string { + b64 := base64.StdEncoding.EncodeToString([]byte(cmd)) + return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64) + }, err + } + return "", "", nil, fmt.Errorf("unsupported target %q: must be host: or lxc:", targetSlug) +} + +// executeApprovedAction runs a gated action after operator approval. +// Runs in a background goroutine to not block the HTTP response. +// emitExecutionEvent records an execution lifecycle event for SSE fan-out so +// the control room can watch approved actions run to completion live. +func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) { + severity := "info" + if status == "failed" { + severity = "warning" + } + _ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail) + if status == "completed" || status == "failed" || status == "cancelled" { + closePlanStepForExecution(ctx, pool, execID, status) + } +} + +// closePlanStepForExecution auto-closes a task plan step whose linked execution +// just reached a terminal state, so the task board advances even if the agent +// doesn't call update_plan_step itself (belt and suspenders — the agent links +// the step to the execution when it starts it; the api finishes it here). Emits +// plan.step.finished correlated to the step's session. No-op for the vast +// majority of executions, which aren't plan steps. +func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) { + stepStatus := "done" + if execStatus == "failed" || execStatus == "cancelled" { + stepStatus = "failed" + } + var stepID, sessionID string + var seq int + if err := pool.QueryRow(ctx, ` + UPDATE session_plan_steps SET status = $2, finished_at = now() + WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped') + RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil { + return // no matching open step + } + _ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID, + map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()}) +} + +func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) { + slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr) + + host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug) + if err != nil { + slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("%s", err.Error())) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) + return + } + + idx := strings.Index(actionStr, ":") + if idx < 0 { + slog.Error("httpapi: malformed action string (no colon)", "action", actionStr) + return + } + action, params := actionStr[:idx], actionStr[idx+1:] + + startedAt := time.Now() + var output, cmd string + + switch action { + case "systemctl": + svc := strings.TrimPrefix(targetSlug, "lxc:") + switch { + case strings.HasPrefix(params, "enable:"): + svc = strings.TrimPrefix(params, "enable:") + cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) + case strings.HasPrefix(params, "disable:"): + svc = strings.TrimPrefix(params, "disable:") + cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) + default: + cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc) + } + output, err = sshExec(ctx, host, user, cmd) + + case "apt_upgrade": + svc := strings.TrimPrefix(targetSlug, "lxc:") + cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc) + output, err = sshExec(ctx, host, user, cmd) + + case "pct_create": + var cfg struct { + VMID int `json:"vmid"` + Hostname string `json:"hostname"` + Cores int `json:"cores"` + Memory int `json:"memory"` + DiskGB int `json:"disk_gb"` + IP string `json:"ip"` + GW string `json:"gw"` + Bridge string `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0 + Storage string `json:"storage"` + Template string `json:"template"` + Privileged flexBool `json:"privileged"` + Nesting flexBool `json:"nesting"` + Mounts []string `json:"mounts"` + Nameserver string `json:"nameserver"` + Searchdomain string `json:"searchdomain"` + // No services/post_install here anymore — pct_create is atomic + // (create + start + register only). Installing packages and + // running setup scripts is the agent's job via follow-up `run` + // calls against lxc:, so each step is individually + // observable and recoverable instead of one opaque multi-minute + // black box. See the comment above the removed post-create block. + } + if err := json.Unmarshal([]byte(params), &cfg); err != nil { + slog.Error("httpapi: pct_create parse params", "error", err, "params", params) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("invalid pct_create params: %v", err)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) + return + } + // Only hostname is required. vmid is optional — when 0 (or later found + // to collide) the VMID guard below assigns a free cluster id. + if cfg.Hostname == "" { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, `{"error":"pct_create: hostname is required"}`) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"}) + return + } + if cfg.Cores == 0 { + cfg.Cores = 1 + } + if cfg.Memory == 0 { + cfg.Memory = 512 + } + if cfg.DiskGB == 0 { + cfg.DiskGB = 8 + } + if cfg.Storage == "" { + cfg.Storage = "local-lvm" + } + if cfg.GW == "" { + cfg.GW = "192.168.8.2" + } + if cfg.Nameserver == "" { + cfg.Nameserver = "192.168.8.2" + } + if cfg.Searchdomain == "" { + cfg.Searchdomain = "hubris.network" + } + // Template pre-flight: resolve against what the host actually has + // cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw + // `pct` error when that exact file isn't present. List the cache, then + // either validate the requested template or auto-pick the newest + // debian one; on miss, fail early with the available list so the + // operator/agent can retry with a real name. + cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true") + available := []string{} + for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") { + if l = strings.TrimSpace(l); l != "" { + available = append(available, l) + } + } + if tplErr != nil { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error())) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()}) + return + } + cfg.Template = resolveTemplate(cfg.Template, available) + if cfg.Template == "" { + msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("%s", msg)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) + return + } + + // VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's + // guess (e.g. 132) can collide with a container on another node — pct + // create then fails with "CT N already exists on node X". Fetch the set + // of in-use VMIDs across the cluster; if the requested id is taken (or + // absent), fall back to the cluster's next free id so provisioning + // still succeeds instead of dead-ending on the operator's approval. + usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`) + used := map[int]bool{} + for _, l := range strings.Fields(usedRaw) { + if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil { + used[n] = true + } + } + if cfg.VMID == 0 || used[cfg.VMID] { + nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`) + nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw)) + if nerr != nil || cerr != nil || nextID == 0 { + msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("%s", msg)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) + return + } + slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID) + cfg.VMID = nextID + } + + privFlag := "--unprivileged 1" + if cfg.Privileged { + privFlag = "--unprivileged 0" + } + + nestingFlag := "" + features := []string{} + if cfg.Nesting { + features = append(features, "nesting=1") + } + if cfg.Privileged { + features = append(features, "keyctl=1") + } + if len(features) > 0 { + nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ",")) + } + + if cfg.Bridge == "" { + cfg.Bridge = "vmbr0" + } + + // net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox + // rejects a gateway alongside ip=dhcp, so only add gw for a static IP. + net0 := "name=eth0,bridge=" + cfg.Bridge + "," + isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp") + if !isStatic { + net0 += "ip=dhcp" + } else { + net0 += "ip=" + cfg.IP + if cfg.GW != "" { + net0 += ",gw=" + cfg.GW + } + } + + // Pre-flight: for a static config, ping the gateway from the target + // HOST, on the SPECIFIC BRIDGE being requested, before spending 5+ + // minutes creating the container. This is the check that would have + // caught the real TypeType failure immediately instead of after a + // full provision attempt. + // + // Binding to the bridge (`ping -I `) matters and was found + // live: a plain unqualified `ping ` from the host can succeed via + // the host's own routing table (multiple routes, possibly through an + // upstream router) even when the *container* — which only gets a + // naive on-link default route via its bridge's veth — can never ARP + // that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2` + // succeeded (via the host's default route), but a container actually + // attached to vmbr0 showed 100% packet loss trying to reach the same + // address, because vmbr0 doesn't carry that subnet's L2 segment. + // Binding to the bridge interface reproduces what the container will + // actually experience, not what the host's broader routing table can + // reach. + if isStatic && cfg.GW != "" { + pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW)) + if pingErr != nil || !gatewayPreflightPassed(pingOut) { + msg := fmt.Sprintf( + "gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+ + "Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.", + cfg.GW, targetSlug, cfg.Bridge) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("%s", msg)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) + return + } + } + + templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template) + createCmd := fmt.Sprintf( + "pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1", + cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory, + cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag) + + if cfg.Nameserver != "" { + createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver) + } + if cfg.Searchdomain != "" { + createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain) + } + + // Add mount points + for i, mp := range cfg.Mounts { + if i < 10 { // pct supports up to mp9 + createCmd += fmt.Sprintf(" --mp%d %s", i, mp) + } + } + + slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd) + output, err = sshExec(ctx, host, user, createCmd) + + // pct_create is now DELIBERATELY ATOMIC: create + start + register, + // nothing else. It used to also run apt installs and a post_install + // script inline as one black-box multi-minute SSH call — the agent + // got back a single opaque success/fail for the whole thing with no + // way to see (or fix) which step actually broke. That's the opposite + // of what makes an agent able to recover from errors. + // + // Installing packages, running post_install, and verifying the + // service now happen as the agent's OWN follow-up `run` calls against + // the new lxc: target — each one is synchronous (in an + // active assent window) or individually gated, so the agent observes + // every step's real output and can diagnose + retry the exact thing + // that failed instead of re-doing the whole container. See SOUL.md + // "After pct_create: you drive the install" and provisionScript's + // surviving role (DNS self-heal) is now something the agent invokes + // itself via `run`, not something baked into this handler. + // + // cfg.Services/cfg.PostInstall are intentionally no longer read here. + + // On success, register the entity in the DB with proper relationships + if err == nil { + slug := "lxc:" + cfg.Hostname + var lxcID uuid.UUID + lxcID, _ = uuid.NewV7() + attrs := map[string]any{ + "pve_id": fmt.Sprintf("%d", cfg.VMID), + "host": strings.TrimPrefix(targetSlug, "host:"), + "ip": cfg.IP, + } + attrsJSON, _ := json.Marshal(attrs) + _, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at) + VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON) + if insErr != nil { + slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug) + } + + // Create hosts relationship: Proxmox host → LXC + var hostID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil { + _, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID) + if relErr != nil { + slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug) + } + } + + // Create entity_status row for health tracking + pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at) + VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID) + + emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{ + "lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug, + }) + + slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug) + } + + case "run": + // The general gated primitive: arbitrary shell against any host or + // LXC, approved and classified by internal/policy.ClassifyCommand at + // request time (see mcp/server.go's "run" tool). No fixed action + // enum — new capability doesn't require new Go code here. + var cfg struct { + Command string `json:"command"` + Purpose string `json:"purpose"` + } + if perr := json.Unmarshal([]byte(params), &cfg); perr != nil { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("invalid run params: %v", perr)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()}) + return + } + cmd = wrap(cfg.Command) + output, err = sshExec(ctx, host, user, cmd) + + default: + slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, jsonErr("unknown action: %s", action)) + return + } + + durationMs := int(time.Since(startedAt).Milliseconds()) + status := "completed" + verified := true + // Build result via json.Marshal, not string interpolation. Command output + // (apt/pct) contains quotes, backslashes and control chars; the old + // fmt.Sprintf only escaped "\n", producing invalid JSON that failed the + // ::jsonb cast — so this UPDATE was silently discarded and the execution + // was stuck at "approved" forever even though provisioning succeeded. + resMap := map[string]any{"output": output} + if err != nil { + resMap["error"] = err.Error() + status = "failed" + verified = false + } + resultJSON, _ := json.Marshal(resMap) + + if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`, + execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil { + slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status) + } + + emitExecutionEvent(ctx, pool, execID, status, map[string]any{ + "action": action, "target": targetSlug, "duration_ms": durationMs, + }) + + slog.Info("httpapi: approved action executed", + "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) +} + +// jsonErr builds a valid {"error": "..."} JSON payload for an execution's +// result column. Always use this instead of fmt.Sprintf'ing JSON by hand — +// error text and command output routinely contain quotes/backslashes that +// break a hand-built string and fail the ::jsonb cast. +func jsonErr(format string, args ...any) []byte { + b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)}) + return b +} + +// resolveTemplate maps a requested template name to one actually present in +// the host's template cache. Exact match wins; a bare distro hint (e.g. +// "debian-13" or "debian") matches by prefix; empty picks the newest debian +// (falling back to any) template available. Returns "" when nothing fits. +// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers +// from the pct_create gateway pre-flight check. Pulled out as its own +// function (rather than an inline strings.Contains at the call site) so it's +// unit-testable: a prior version checked for "REACHABLE", which is a +// substring of "UNREACHABLE" — the check could never actually fail, and it +// took a live deployment to notice. Exact-match markers plus a test make +// that specific bug class structurally unable to recur silently. +func gatewayPreflightPassed(out string) bool { + return strings.TrimSpace(out) == "PREFLIGHT_OK" +} + +func resolveTemplate(requested string, available []string) string { + if len(available) == 0 { + return "" + } + if requested != "" { + for _, a := range available { + if a == requested { + return a + } + } + for _, a := range available { + if strings.HasPrefix(a, requested) { + return a + } + } + } + // Auto-pick: prefer debian, then the lexically-greatest (newest version). + best := "" + for _, a := range available { + if strings.Contains(a, "debian") && a > best { + best = a + } + } + if best != "" { + return best + } + for _, a := range available { + if a > best { + best = a + } + } + return best +} diff --git a/internal/httpapi/agent_activity.go b/internal/httpapi/agent_activity.go new file mode 100644 index 0000000..df7e8a2 --- /dev/null +++ b/internal/httpapi/agent_activity.go @@ -0,0 +1,90 @@ +package httpapi + +import ( + "context" + "fmt" + "time" + + "github.com/dtoro/oikos/internal/httpapi/gen" +) + +// ─── Agent Activity (stub) ───────────────────────────────────────────── + +func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) { + limit := clampLimit(request.Params.Limit) + from := time.Now().Add(-24 * time.Hour) + if request.Params.From != nil { + from = *request.Params.From + } + to := time.Now() + if request.Params.To != nil { + to = *request.Params.To + } + + var agentID *string + if request.Params.AgentId != nil { + a := *request.Params.AgentId + agentID = &a + } + var activityType *string + if request.Params.ActivityType != nil { + a := string(*request.Params.ActivityType) + activityType = &a + } + var entityID *string + if request.Params.EntityId != nil { + a := *request.Params.EntityId + entityID = &a + } + var cursorID *int + if request.Params.Cursor != nil && *request.Params.Cursor != "" { + if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 { + cursorID = &id + } + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, + entity_id::text, input_summary, output_summary, + duration_ms, token_count, success, correlation_id + FROM agent_activity + WHERE ts >= $1 AND ts <= $2 + AND ($3::text IS NULL OR agent_id::text = $3) + AND ($4::text IS NULL OR activity_type = $4) + AND ($5::text IS NULL OR entity_id::text = $5) + AND ($6::bigint IS NULL OR id < $6::bigint) + ORDER BY id DESC + LIMIT $7`, + from, to, agentID, activityType, entityID, cursorID, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.AgentActivity{} + for rows.Next() { + var a gen.AgentActivity + if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId, + &a.ActivityType, &a.ToolName, &a.EntityId, + &a.InputSummary, &a.OutputSummary, + &a.DurationMs, &a.TokenCount, &a.Success, + &a.CorrelationId); err != nil { + return nil, err + } + items = append(items, a) + } + if rows.Err() != nil { + return nil, rows.Err() + } + + var next *string + if len(items) > limit { + items = items[:limit] + lastID := fmt.Sprintf("%d", items[len(items)-1].Id) + next = &lastID + } + if items == nil { + items = []gen.AgentActivity{} + } + return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil +} diff --git a/internal/httpapi/approval_rules.go b/internal/httpapi/approval_rules.go new file mode 100644 index 0000000..11f8814 --- /dev/null +++ b/internal/httpapi/approval_rules.go @@ -0,0 +1,160 @@ +package httpapi + +import ( + "context" + "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" +) + +// ─── Approval Rules (Policy) ─────────────────────────────────────────── + +func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, entity_type, action, risk_class, autonomy_level, + COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''), + version, updated_at + FROM approval_rules ORDER BY entity_type, action`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.ApprovalRule{} + for rows.Next() { + var rule gen.ApprovalRule + var scopeSlug string + if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action, + &rule.RiskClass, &rule.AutonomyLevel, &scopeSlug, + &rule.Version); err != nil { + return nil, err + } + if scopeSlug != "" { + rule.ScopeEntity = &scopeSlug + } + items = append(items, rule) + } + if rows.Err() != nil { + return nil, rows.Err() + } + if items == nil { + items = []gen.ApprovalRule{} + } + return gen.ListApprovalRules200JSONResponse{Items: items}, nil +} + +func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, 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 + } + + var scopeEntity *uuid.UUID + if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" { + se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity) + if rerr != nil { + return nil, rerr + } + scopeEntity = &se + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity) + VALUES ($1, $2, $3, $4, $5, $6)`, + id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass, + string(req.Body.AutonomyLevel), scopeEntity) + if err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { + return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists, + coalesceStr(req.Body.EntityType, "*"), req.Body.Action) + } + return nil, err + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", + &id, "POST", "/api/v1/policy/approval-rules", "", + map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + // Return 202 pending approval (dual-control). + return gen.CreateApprovalRule202JSONResponse{}, nil +} + +func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, 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 + } + + var scopeEntity *uuid.UUID + if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" { + se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity) + if rerr != nil { + return nil, rerr + } + scopeEntity = &se + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + result, err := tx.Exec(ctx, ` + UPDATE approval_rules + SET entity_type = COALESCE($2, entity_type), + action = COALESCE($3, action), + risk_class = COALESCE($4, risk_class), + autonomy_level = COALESCE($5, autonomy_level), + scope_entity = COALESCE($6, scope_entity), + version = version + 1, + updated_at = now() + WHERE id = $1`, + id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass, + string(req.Body.AutonomyLevel), scopeEntity) + if err != nil { + return nil, err + } + if result.RowsAffected() == 0 { + return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id) + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", + &id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "", + map[string]any{"action": req.Body.Action}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.PatchApprovalRule202JSONResponse{}, nil +} diff --git a/internal/httpapi/approvals.go b/internal/httpapi/approvals.go new file mode 100644 index 0000000..3de1ed1 --- /dev/null +++ b/internal/httpapi/approvals.go @@ -0,0 +1,259 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "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/dtoro/oikos/internal/safego" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ─── Approvals ───────────────────────────────────────────────────────── + +func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) { + limit := clampLimit(req.Params.Limit) + var status *string + if req.Params.Status != nil { + s := string(*req.Params.Status) + status = &s + } + var kind *string + if req.Params.Kind != nil { + k := string(*req.Params.Kind) + kind = &k + } + rows, err := s.pool.Query(ctx, ` + SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload, + a.status, a.expires_at, a.decided_at, a.decided_by::text, + a.created_at, e.slug + FROM approvals a + JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id) + WHERE ($1::text IS NULL OR a.status = $1) + AND ($2::text IS NULL OR a.kind = $2) + AND ($3::text IS NULL OR e.slug > $3) + ORDER BY e.slug + LIMIT $4`, + status, kind, req.Params.Cursor, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Approval{} + for rows.Next() { + var a gen.Approval + var payloadBytes []byte + var decidedBy *string + if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes, + &a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy, + &a.CreatedAt, &a.Slug); err != nil { + return nil, err + } + a.DecidedBy = decidedBy + var payload map[string]any + if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil { + a.Payload = &payload + } + items = append(items, a) + } + 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.Approval{} + } + return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil +} + +func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, 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 + } + + actorType, actor := actorInfo(ctx) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + q := sqlcgen.New(tx) + + // Verify HMAC token if provided (single-use, S5). + if req.Body.Token != nil && *req.Body.Token != "" { + var tokenHash *string + var apprStatus string + var expiresAt time.Time + err := tx.QueryRow(ctx, + "SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1", + id).Scan(&tokenHash, &apprStatus, &expiresAt) + if err != nil || tokenHash == nil { + return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound) + } + if apprStatus != "pending" { + return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition) + } + if expiresAt.Before(time.Now()) { + return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition) + } + if *tokenHash != hashToken(*req.Body.Token) { + return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput) + } + } + + // Map decision to status. + var status string + switch req.Body.Decision { + case gen.Approve: + status = "approved" + case gen.Deny: + status = "denied" + case gen.Revoke: + status = "revoked" + default: + return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision) + } + + if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{ + EntityID: id, + Status: status, + }); err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id) + } + return nil, err + } + + // Re-read approval. + app, err := q.GetApprovalByID(ctx, id) + if err != nil { + return nil, err + } + + approval := approvalToGen(app) + + if auditErr := observability.Audit(ctx, q, actorType, actor, "decide", + &id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "", + map[string]any{"decision": status}); auditErr != nil { + return nil, auditErr + } + + // Emit for SSE fan-out (in-tx; NOTIFY fires post-commit). + if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "", + map[string]any{"decision": status, "actor": actor}); evErr != nil { + return nil, evErr + } + + // On approve: execute the linked gated command. + if status == "approved" { + var execID, targetID uuid.UUID + var actionStr, targetSlug, riskClass string + err := tx.QueryRow(ctx, ` + SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class + FROM executions e + WHERE e.approval_id = $1 AND e.status = 'pending_approval' + LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass) + if err == nil { + // Resolve target entity slug from targetID. + _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) + + safego.Go("httpapi:executeApprovedAction", func() { + executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr) + }) + // Status only — risk_class was set correctly at request time + // (e.g. by policy.ClassifyCommand for `run`); overwriting it to + // a hardcoded 'config_mutation' here corrupted the audit ledger + // for every other risk class, including destructive. + _, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID) + + // Approving a plan step — by ANY route (this endpoint backs both + // the chat Approve button and chat-assent) — opens/extends the + // agent's assent window. This is the scope gate the Nomos + // auto-continuation worker checks: with the window open, the + // finished execution's result is fed back to the agent so it runs + // the plan to completion. Without opening it here, approving via + // the button (instead of typing "go ahead") would silently not + // auto-continue. + var agentID *uuid.UUID + if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil { + expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339) + _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires) + + // Approving a DESTRUCTIVE step via the button is exactly as + // explicit as a typed "I confirm" — the operator affirmatively + // clicked Approve on a card that said DESTRUCTIVE. Open the + // same short, target-scoped destructive window chat-assent's + // typed-confirm path opens, for parity: a multi-step + // destructive recovery (stop, then destroy) shouldn't need a + // fresh confirmation per click any more than it needs one per + // typed phrase. + if riskClass == "destructive" && targetSlug != "" { + dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339) + _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, + "destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires) + } + } + + slog.Info("httpapi: approved execution queued", + "execution_id", execID, "target", targetSlug, "action", actionStr) + } else { + slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err) + } + } else { + // Denied/revoked: reflect it on the linked execution too. Previously + // only the approvals row changed, so the execution stayed + // 'pending_approval' forever — any UI/poller reading execution + // status (not approval status) never saw the decision. + _, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.DecideApproval200JSONResponse(approval), nil +} + +func approvalToGen(a sqlcgen.Approval) gen.Approval { + app := gen.Approval{ + Id: a.EntityID, + Action: a.Action, + RiskClass: a.RiskClass, + Kind: gen.ApprovalKind(a.Kind), + Status: gen.ApprovalStatus(a.Status), + ExpiresAt: a.ExpiresAt, + DecidedAt: a.DecidedAt, + CreatedAt: a.CreatedAt, + } + if a.DecidedBy != nil { + s := a.DecidedBy.String() + app.DecidedBy = &s + } + var payload map[string]any + if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 { + app.Payload = &payload + } + return app +} diff --git a/internal/httpapi/autonomy.go b/internal/httpapi/autonomy.go new file mode 100644 index 0000000..cb35655 --- /dev/null +++ b/internal/httpapi/autonomy.go @@ -0,0 +1,102 @@ +package httpapi + +import ( + "context" + "fmt" + + "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" +) + +// ─── Autonomy Settings ───────────────────────────────────────────────── + +func (s *Server) GetAutonomySettings(ctx context.Context, req gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) { + rows, err := s.pool.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.AutonomySetting{} + for rows.Next() { + var as gen.AutonomySetting + if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil { + return nil, err + } + items = append(items, as) + } + if rows.Err() != nil { + return nil, rows.Err() + } + if items == nil { + items = []gen.AutonomySetting{} + } + return gen.GetAutonomySettings200JSONResponse{Items: items}, nil +} + +func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) { + if req.Body == nil { + return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + for key, value := range *req.Body { + _, err := tx.Exec(ctx, ` + INSERT INTO autonomy_settings (key, value, version, updated_at) + VALUES ($1, $2, 1, now()) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, version = autonomy_settings.version + 1, updated_at = now()`, + key, value) + if err != nil { + return nil, err + } + } + + // Re-read all settings. + rows, err := tx.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.AutonomySetting{} + for rows.Next() { + var as gen.AutonomySetting + if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil { + return nil, err + } + items = append(items, as) + } + if rows.Err() != nil { + return nil, rows.Err() + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", + nil, "PATCH", "/api/v1/policy/autonomy", "", + map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.PatchAutonomySettings200JSONResponse{Items: items}, nil +} + +// keysOfMap returns the keys of a map[string]string. +func keysOfMap(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/internal/httpapi/checks.go b/internal/httpapi/checks.go new file mode 100644 index 0000000..c11fd50 --- /dev/null +++ b/internal/httpapi/checks.go @@ -0,0 +1,304 @@ +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 + 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 + if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType, + &configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil { + return nil, err + } + 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", "", + 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, "", + 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 + } + 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 +} diff --git a/internal/httpapi/classifications.go b/internal/httpapi/classifications.go new file mode 100644 index 0000000..78d5c6d --- /dev/null +++ b/internal/httpapi/classifications.go @@ -0,0 +1,85 @@ +package httpapi + +import ( + "context" + "encoding/json" + + "github.com/dtoro/oikos/internal/httpapi/gen" + "github.com/google/uuid" +) + +// ─── Classifications ─────────────────────────────────────────────────── + +func (s *Server) ListClassifications(ctx context.Context, req gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) { + limit := clampLimit(req.Params.Limit) + var route *string + if req.Params.Route != nil { + r := string(*req.Params.Route) + route = &r + } + rows, err := s.pool.Query(ctx, ` + SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action, + c.recommended_action, c.risk_class, c.route, c.blast_radius, + c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning, + c.correlation_id, c.created_at, + e.slug, COALESCE(se.slug, '') AS signal_slug, COALESCE(te.slug, '') AS target_slug + FROM classifications c + LEFT JOIN entities e ON e.id = c.entity_id + LEFT JOIN entities se ON se.id = c.signal_entity_id + LEFT JOIN entities te ON te.id = c.target_entity_id + WHERE ($1::text IS NULL OR c.route = $1) + AND ($2::text IS NULL OR e.slug > $2) + ORDER BY e.slug + LIMIT $3`, + route, req.Params.Cursor, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Classification{} + for rows.Next() { + var cls gen.Classification + var recActionJSON []byte + var reasoningJSON []byte + var blastRadius []uuid.UUID + var signalSlug, targetSlug string + if err := rows.Scan(&cls.Id, &cls.SignalId, &targetSlug, &cls.Action, + &recActionJSON, &cls.RiskClass, &cls.Route, &blastRadius, + &cls.PatternConfidence, &cls.SkillId, &cls.AutonomyCheck, &reasoningJSON, + &cls.CorrelationId, &cls.CreatedAt, + &cls.Target, &signalSlug, &targetSlug); err != nil { + return nil, err + } + if targetSlug != "" { + cls.Target = &targetSlug + } + var reasoning map[string]any + if json.Unmarshal(reasoningJSON, &reasoning) == nil { + cls.Reasoning = reasoning + } + if len(blastRadius) > 0 { + br := make([]string, len(blastRadius)) + for i, id := range blastRadius { + br[i] = id.String() + } + cls.BlastRadius = &br + } + items = append(items, cls) + } + if rows.Err() != nil { + return nil, rows.Err() + } + + var next *string + if len(items) > limit { + items = items[:limit] + if items[len(items)-1].Target != nil { + next = items[len(items)-1].Target + } + } + if items == nil { + items = []gen.Classification{} + } + return gen.ListClassifications200JSONResponse{Items: items, NextCursor: next}, nil +} diff --git a/internal/httpapi/entity_types.go b/internal/httpapi/entity_types.go new file mode 100644 index 0000000..6d0a8e4 --- /dev/null +++ b/internal/httpapi/entity_types.go @@ -0,0 +1,160 @@ +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" +) + +// ─── Entity Types (Ontology) ─────────────────────────────────────────── + +func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) { + if req.Body == nil { + return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) + } + + isAbstract := false + if req.Body.IsAbstract != nil { + isAbstract = *req.Body.IsAbstract + } + + attrsSchemaJSON := []byte("null") + if req.Body.AttributeSchema != nil { + attrsSchemaJSON, _ = json.Marshal(req.Body.AttributeSchema) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`, + req.Body.Name, req.Body.ParentType, isAbstract, req.Body.Domain, + string(req.Body.Layer), req.Body.Description, req.Body.LifecycleId, attrsSchemaJSON) + if err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { + return nil, fmt.Errorf("%w: entity type %q already exists", domain.ErrAlreadyExists, req.Body.Name) + } + return nil, err + } + + // Re-read. + var et gen.EntityType + var schemaBytes []byte + err = tx.QueryRow(ctx, ` + SELECT name, parent_type, is_abstract, domain, layer, description, + lifecycle_id, attribute_schema, schema_version, status + FROM entity_types WHERE name = $1`, req.Body.Name). + Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer, + &et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status) + if err != nil { + return nil, err + } + var schema map[string]any + if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil { + et.AttributeSchema = &schema + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", + nil, "POST", "/api/v1/ontology/entity-types", "", + map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.CreateEntityType201JSONResponse(et), nil +} + +func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) { + if req.Body == nil { + return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // Build dynamic update. + sets := []string{} + args := []any{} + argIdx := 2 + + if req.Body.Description != nil { + sets = append(sets, fmt.Sprintf("description = $%d", argIdx)) + args = append(args, *req.Body.Description) + argIdx++ + } + if req.Body.Status != nil { + sets = append(sets, fmt.Sprintf("status = $%d", argIdx)) + args = append(args, string(*req.Body.Status)) + argIdx++ + } + if req.Body.AttributeSchema != nil { + schemaJSON, _ := json.Marshal(req.Body.AttributeSchema) + sets = append(sets, fmt.Sprintf("attribute_schema = $%d", argIdx)) + args = append(args, schemaJSON) + argIdx++ + } + + if len(sets) == 0 { + return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput) + } + + sets = append(sets, "schema_version = schema_version + 1, updated_at = now()") + + query := fmt.Sprintf(`UPDATE entity_types SET %s WHERE name = $1`, strings.Join(sets, ", ")) + finalArgs := append([]any{req.Name}, args...) + + result, err := tx.Exec(ctx, query, finalArgs...) + if err != nil { + return nil, err + } + if result.RowsAffected() == 0 { + return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Name) + } + + // Re-read. + var et gen.EntityType + var schemaBytes []byte + err = tx.QueryRow(ctx, ` + SELECT name, parent_type, is_abstract, domain, layer, description, + lifecycle_id, attribute_schema, schema_version, status + FROM entity_types WHERE name = $1`, req.Name). + Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer, + &et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status) + if err != nil { + return nil, err + } + var schema map[string]any + if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil { + et.AttributeSchema = &schema + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", + nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "", + map[string]any{"status": req.Body.Status}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.PatchEntityType200JSONResponse(et), nil +} diff --git a/internal/httpapi/executions.go b/internal/httpapi/executions.go new file mode 100644 index 0000000..ae6aa22 --- /dev/null +++ b/internal/httpapi/executions.go @@ -0,0 +1,267 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + + "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" +) + +// ─── Executions ──────────────────────────────────────────────────────── + +func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) { + limit := clampLimit(req.Params.Limit) + rows, err := s.pool.Query(ctx, ` + SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, + e.target_entity_id, e.action, e.risk_class, + e.approval_id::text, e.agent_id::text, e.skill_id::text, + e.skill_version, e.status, e.result, e.duration_ms, + e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, + te.slug + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + WHERE ($1::text IS NULL OR e.status = $1) + AND ($2::text IS NULL OR te.slug > $2) + ORDER BY te.slug + LIMIT $3`, + req.Params.Status, req.Params.Cursor, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Execution{} + for rows.Next() { + var exec gen.Execution + var resultBytes []byte + var targetSlug string + if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, + &exec.Target, &exec.Action, &exec.RiskClass, + &exec.ApprovalId, &exec.AgentId, &exec.SkillId, + &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, + &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, + &exec.CreatedAt, &targetSlug); err != nil { + return nil, err + } + var result map[string]any + if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil { + exec.Result = &result + } + // Target is stored as UUID, but we surface the slug + exec.Slug = targetSlug + items = append(items, exec) + } + 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.Execution{} + } + return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil +} + +func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) { + id, err := s.resolveEntityID(ctx, req.Id) + if err != nil { + return nil, err + } + + var exec gen.Execution + var resultBytes []byte + var targetSlug string + err = s.pool.QueryRow(ctx, ` + SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, + e.target_entity_id, e.action, e.risk_class, + e.approval_id::text, e.agent_id::text, e.skill_id::text, + e.skill_version, e.status, e.result, e.duration_ms, + e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, + te.slug + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + WHERE e.entity_id = $1`, id). + Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, + &exec.Target, &exec.Action, &exec.RiskClass, + &exec.ApprovalId, &exec.AgentId, &exec.SkillId, + &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, + &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, + &exec.CreatedAt, &targetSlug) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + var result map[string]any + if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil { + exec.Result = &result + } + exec.Slug = targetSlug + return gen.GetExecution200JSONResponse(exec), nil +} + +func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, 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 + } + + targetID, err := s.resolveEntityID(ctx, req.Body.Target) + if err != nil { + return nil, err + } + + correlationID := uuid.New().String() + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + q := sqlcgen.New(tx) + + // Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7 + // collides for real under back-to-back requests since the leading bytes + // encode a millisecond timestamp (observed live via the MCP run tool). + execSlug := "exec:" + id.String() + if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{ + ID: id, + Slug: execSlug, + Type: "execution", + Name: req.Body.Action + " on " + req.Body.Target, + Attributes: []byte("{}"), + }); err != nil { + return nil, err + } + + if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{ + EntityID: id, + TargetEntityID: &targetID, + Action: req.Body.Action, + RiskClass: "unclassified", // will be classified by classifier + CorrelationID: correlationID, + }); err != nil { + return nil, err + } + + // Re-read to get the full record. + var exec gen.Execution + var resultBytes []byte + var targetSlug string + err = tx.QueryRow(ctx, ` + SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, + e.target_entity_id, e.action, e.risk_class, + e.approval_id::text, e.agent_id::text, e.skill_id::text, + e.skill_version, e.status, e.result, e.duration_ms, + e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, + te.slug + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + WHERE e.entity_id = $1`, id). + Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, + &exec.Target, &exec.Action, &exec.RiskClass, + &exec.ApprovalId, &exec.AgentId, &exec.SkillId, + &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, + &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, + &exec.CreatedAt, &targetSlug) + if err != nil { + return nil, err + } + exec.Slug = targetSlug + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, q, actorType, actor, "create", + &id, "POST", "/api/v1/executions", "", + map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil { + return nil, auditErr + } + if eventErr := observability.Event(ctx, q, "execution.requested", &id, + "info", "oikos-api", "", + map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil { + return nil, eventErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.RequestExecution201JSONResponse(exec), nil +} + +func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) { + id, err := s.resolveEntityID(ctx, req.Id) + if err != nil { + return nil, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + q := sqlcgen.New(tx) + if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{ + EntityID: id, + Status: "cancelled", + }); err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + + // Re-read. + var exec gen.Execution + var resultBytes []byte + var targetSlug string + err = tx.QueryRow(ctx, ` + SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, + e.target_entity_id, e.action, e.risk_class, + e.approval_id::text, e.agent_id::text, e.skill_id::text, + e.skill_version, e.status, e.result, e.duration_ms, + e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, + te.slug + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + WHERE e.entity_id = $1`, id). + Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, + &exec.Target, &exec.Action, &exec.RiskClass, + &exec.ApprovalId, &exec.AgentId, &exec.SkillId, + &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, + &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, + &exec.CreatedAt, &targetSlug) + if err != nil { + return nil, err + } + exec.Slug = targetSlug + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel", + &id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "", + map[string]any{"status": "cancelled"}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.CancelExecution200JSONResponse(exec), nil +} diff --git a/internal/httpapi/helpers.go b/internal/httpapi/helpers.go new file mode 100644 index 0000000..5f62d19 --- /dev/null +++ b/internal/httpapi/helpers.go @@ -0,0 +1,32 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// ─── Helpers ─────────────────────────────────────────────────────────── + +func coalesceStr(s *string, def string) string { + if s == nil || *s == "" { + return def + } + return *s +} + +func parseIntOrZero(s string) (int, error) { + var n int + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("invalid integer: %q", s) + } + n = n*10 + int(c-'0') + } + return n, nil +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/httpapi/metrics.go b/internal/httpapi/metrics.go new file mode 100644 index 0000000..51fbd4f --- /dev/null +++ b/internal/httpapi/metrics.go @@ -0,0 +1,180 @@ +package httpapi + +import ( + "context" + "fmt" + "time" + + "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/httpapi/gen" + "github.com/jackc/pgx/v5/pgtype" +) + +// ─── Metrics ─────────────────────────────────────────────────────────── + +func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) { + if req.Params.EntityId == nil || *req.Params.EntityId == "" { + return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput) + } + entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId) + if err != nil { + return nil, err + } + + from := time.Now().Add(-24 * time.Hour) + if req.Params.From != nil { + from = *req.Params.From + } + to := time.Now() + if req.Params.To != nil { + to = *req.Params.To + } + + var metricNames []string + if req.Params.Metric != nil && len(*req.Params.Metric) > 0 { + metricNames = *req.Params.Metric + } else { + // metric omitted: report every metric recorded for this entity in range. + rows, err := s.pool.Query(ctx, ` + SELECT DISTINCT metric FROM metric_samples + WHERE entity_id = $1 AND ts >= $2 AND ts <= $3 + ORDER BY metric`, entityID, from, to) + if err != nil { + return nil, err + } + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return nil, err + } + metricNames = append(metricNames, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + } + + items := []gen.MetricSeries{} + for _, metricName := range metricNames { + series := gen.MetricSeries{ + EntityId: entityID.String(), + Metric: metricName, + Rollup: gen.MetricSeriesRollupRaw, + } + + rows, err := s.pool.Query(ctx, ` + SELECT ts, value + FROM metric_samples + WHERE entity_id = $1 AND metric = $2 + AND ts >= $3 AND ts <= $4 + ORDER BY ts ASC`, + entityID, metricName, from, to) + if err != nil { + return nil, err + } + + samples := []struct { + Avg *float32 `json:"avg"` + Count *int `json:"count"` + Max *float32 `json:"max"` + Min *float32 `json:"min"` + Ts time.Time `json:"ts"` + Value *float32 `json:"value"` + }{} + + for rows.Next() { + var ts time.Time + var val float64 + if err := rows.Scan(&ts, &val); err != nil { + rows.Close() + return nil, err + } + f := float32(val) + samples = append(samples, struct { + Avg *float32 `json:"avg"` + Count *int `json:"count"` + Max *float32 `json:"max"` + Min *float32 `json:"min"` + Ts time.Time `json:"ts"` + Value *float32 `json:"value"` + }{Value: &f, Ts: ts}) + } + rows.Close() + if rows.Err() != nil { + return nil, rows.Err() + } + + series.Samples = samples + items = append(items, series) + } + + if items == nil { + items = []gen.MetricSeries{} + } + return gen.QueryMetrics200JSONResponse{Items: items}, nil +} + +func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) { + entityID, err := s.resolveEntityID(ctx, req.EntityId) + if err != nil { + return nil, err + } + + from := time.Now().Add(-7 * 24 * time.Hour) + if req.Params.From != nil { + from = *req.Params.From + } + + rows, err := s.pool.Query(ctx, ` + SELECT metric, + ROUND(avg(value)::numeric, 2) AS avg_val, + ROUND(stddev(value)::numeric, 2) AS std_val, + count(*) AS sample_count, + ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope + FROM metric_samples + WHERE entity_id = $1 AND ts >= $2 + GROUP BY metric + ORDER BY metric`, entityID, from) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Trend{} + for rows.Next() { + var t gen.Trend + var avgVal, stdVal, slopeNum pgtype.Numeric + var sampleCount int + if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil { + return nil, err + } + + // Determine direction. + if slopeNum.Valid { + f, _ := slopeNum.Float64Value() + t.Slope = float32Ptr(float32(f.Float64)) + if f.Float64 > 0.01 { + t.Direction = gen.Improving + } else if f.Float64 < -0.01 { + t.Direction = gen.Degrading + } else { + t.Direction = gen.Stable + } + } else { + t.Direction = gen.Unknown + } + items = append(items, t) + } + if rows.Err() != nil { + return nil, rows.Err() + } + if items == nil { + items = []gen.Trend{} + } + return gen.GetTrends200JSONResponse{Items: items}, nil +} + +func float32Ptr(f float32) *float32 { + return &f +} diff --git a/internal/httpapi/patterns.go b/internal/httpapi/patterns.go new file mode 100644 index 0000000..1300809 --- /dev/null +++ b/internal/httpapi/patterns.go @@ -0,0 +1,123 @@ +package httpapi + +import ( + "context" + "fmt" + + "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/jackc/pgx/v5" +) + +// ─── Patterns ────────────────────────────────────────────────────────── + +func (s *Server) ListPatterns(ctx context.Context, req gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) { + limit := clampLimit(req.Params.Limit) + rows, err := s.pool.Query(ctx, ` + SELECT p.entity_id, e.slug, p.applies_type, p.action, p.pattern, p.confidence, + p.evidence_count, p.success_count, p.failure_count, p.status, + p.quarantined, p.version, p.last_validated_at + FROM patterns p + JOIN entities e ON e.id = p.entity_id + WHERE ($1::text IS NULL OR p.status = $1) + AND ($2::text IS NULL OR p.applies_type = $2) + AND ($3::text IS NULL OR p.action = $3) + ORDER BY p.applies_type, p.action + LIMIT $4`, + req.Params.Status, req.Params.EntityType, req.Params.Action, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Pattern{} + for rows.Next() { + var p gen.Pattern + if err := rows.Scan(&p.Id, &p.Slug, &p.AppliesType, &p.Action, &p.Pattern, + &p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount, + &p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt); err != nil { + return nil, err + } + items = append(items, p) + } + if rows.Err() != nil { + return nil, rows.Err() + } + + if items == nil { + items = []gen.Pattern{} + } + return gen.ListPatterns200JSONResponse{Items: items}, nil +} + +func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, 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 + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + q := sqlcgen.New(tx) + + if req.Body.Status != nil { + status := string(*req.Body.Status) + if err := q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{ + EntityID: id, + Status: status, + }); err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + } + + if req.Body.Quarantined != nil { + if err := q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{ + EntityID: id, + Quarantined: *req.Body.Quarantined, + }); err != nil { + return nil, err + } + } + + // Re-read. + var p gen.Pattern + err = tx.QueryRow(ctx, ` + SELECT entity_id, applies_type, action, pattern, confidence, + evidence_count, success_count, failure_count, status, + quarantined, version, last_validated_at + FROM patterns WHERE entity_id = $1`, id). + Scan(&p.Id, &p.AppliesType, &p.Action, &p.Pattern, + &p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount, + &p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", + &id, "PATCH", "/api/v1/patterns/"+req.Id, "", + map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.PatchPattern200JSONResponse(p), nil +} diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go deleted file mode 100644 index 8af7f52..0000000 --- a/internal/httpapi/phase3.go +++ /dev/null @@ -1,2627 +0,0 @@ -package httpapi - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "os" - "strconv" - "strings" - "time" - - "github.com/dtoro/oikos/internal/db" - "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/dtoro/oikos/internal/safego" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "golang.org/x/crypto/ssh" -) - -var ( - _sshUser string - _sshKey []byte -) - -// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes"). -// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool` -// field made the approved pct_create execution fail to parse *after* the -// operator had already approved it — the container was never created and the -// operator saw "queued" with no result. This type tolerates the common shapes. -type flexBool bool - -func (b *flexBool) UnmarshalJSON(data []byte) error { - s := strings.TrimSpace(strings.Trim(string(data), `"`)) - switch strings.ToLower(s) { - case "true", "1", "yes", "on": - *b = true - case "false", "0", "no", "off", "", "null": - *b = false - default: - return fmt.Errorf("cannot parse %q as bool", s) - } - return nil -} - -func initSSH() { - if _sshUser == "" { - _sshUser = os.Getenv("OIKOS_SSH_USER") - if _sshUser == "" { - _sshUser = "root" - } - } - if len(_sshKey) == 0 { - keyPath := os.Getenv("OIKOS_SSH_KEY_PATH") - if keyPath == "" { - keyPath = "/etc/oikos/ssh_key" - } - var err error - _sshKey, err = os.ReadFile(keyPath) - if err != nil { - slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err) - } - } -} - -// sshExecTimeout bounds how long a single remote command may run. Without -// this, a hung remote command (e.g. a piped install script stuck retrying -// DNS against a misconfigured gateway) blocks the executing goroutine -// forever: the execution never leaves 'approved'/'running', the operator -// sees an unkillable spinner, and get_execution_status has nothing new to -// report. Generous enough for a real apt/docker install; not infinite. -const sshExecTimeout = 10 * time.Minute - -func sshExec(ctx context.Context, host, user, command string) (string, error) { - initSSH() - if len(_sshKey) == 0 { - return "", fmt.Errorf("no SSH key available") - } - if user == "" { - user = _sshUser - } - - addr := host + ":22" - signer, err := ssh.ParsePrivateKey(_sshKey) - if err != nil { - return "", fmt.Errorf("parse key: %w", err) - } - - cfg := &ssh.ClientConfig{ - User: user, - Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, - } - - client, err := ssh.Dial("tcp", addr, cfg) - if err != nil { - return "", fmt.Errorf("dial %s: %w", host, err) - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", fmt.Errorf("session: %w", err) - } - defer session.Close() - - type result struct { - out []byte - err error - } - done := make(chan result, 1) - go func() { - // See internal/mcp/server.go's sshExec for why this recovers rather - // than letting a rare SSH-library panic crash the whole api process. - defer func() { - if r := recover(); r != nil { - done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)} - } - }() - out, err := session.CombinedOutput(command) - done <- result{out, err} - }() - - select { - case r := <-done: - text := strings.TrimSpace(string(r.out)) - // A non-zero exit MUST surface as an error. The previous guard only - // errored when there was no output, so a `pct create` that printed - // "CT 132 already exists" and exited non-zero was reported as - // success — the execution was marked completed though nothing was - // provisioned. - if r.err != nil { - if text != "" { - return text, fmt.Errorf("%w: %s", r.err, text) - } - return text, fmt.Errorf("exec: %w", r.err) - } - return text, nil - case <-time.After(sshExecTimeout): - // Close the session/client to hang up the remote side; the - // goroutine above will eventually exit once that unblocks - // CombinedOutput, but we don't wait for it — the caller needs an - // answer now, not an indefinite hang. - session.Close() - client.Close() - return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host) - case <-ctx.Done(): - session.Close() - client.Close() - return "", ctx.Err() - } -} - -func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) { - var attrs string - err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs) - if err != nil { - return "", "", fmt.Errorf("entity not found: %s", entitySlug) - } - - var m map[string]interface{} - if err := json.Unmarshal([]byte(attrs), &m); err != nil { - return "", "", fmt.Errorf("parse attributes: %w", err) - } - - sshUser := _sshUser - if sshUser == "" { - sshUser = "root" - } - - if ip, ok := m["lan_ip"].(string); ok && ip != "" { - return ip, sshUser, nil - } - if mesh, ok := m["mesh"].(map[string]interface{}); ok { - for _, proto := range []string{"netbird", "tailscale"} { - if p, ok := mesh[proto].(map[string]interface{}); ok { - if ip, ok := p["ip"].(string); ok && ip != "" { - return ip, sshUser, nil - } - } - } - } - return "", "", fmt.Errorf("no IP found for %s", entitySlug) -} - -// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved- -// execution side: any target slug (host: or lxc:) resolves to the SSH -// endpoint that runs the command plus a wrap function that turns a plain -// shell command into what actually needs to be sent — identity for a host, -// `pct exec ` for an LXC. Kept as a small duplicate rather than a -// cross-package import to avoid coupling httpapi to mcp for one helper. -func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) { - if strings.HasPrefix(targetSlug, "host:") { - host, user, err = resolveHostSSH(ctx, pool, targetSlug) - return host, user, func(cmd string) string { return cmd }, err - } - if strings.HasPrefix(targetSlug, "lxc:") { - var pveID, hostAttr string - // COALESCE the host column: many older LXC entities (seeded from - // inventory, not provisioned by pct_create) have pve_id but no host - // attribute at all. Scanning a SQL NULL into a plain string errors - // the whole row, wrongly reporting "missing pve_id" even when it was - // present — COALESCE avoids the NULL, "" is handled below. - if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" { - return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug) - } - hostSlug := hostAttr - if hostSlug == "" { - hostSlug = "hubris" - } - if !strings.HasPrefix(hostSlug, "host:") { - hostSlug = "host:" + hostSlug - } - host, user, err = resolveHostSSH(ctx, pool, hostSlug) - id := pveID - return host, user, func(cmd string) string { - b64 := base64.StdEncoding.EncodeToString([]byte(cmd)) - return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64) - }, err - } - return "", "", nil, fmt.Errorf("unsupported target %q: must be host: or lxc:", targetSlug) -} - -// executeApprovedAction runs a gated action after operator approval. -// Runs in a background goroutine to not block the HTTP response. -// emitExecutionEvent records an execution lifecycle event for SSE fan-out so -// the control room can watch approved actions run to completion live. -func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) { - severity := "info" - if status == "failed" { - severity = "warning" - } - _ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail) - if status == "completed" || status == "failed" || status == "cancelled" { - closePlanStepForExecution(ctx, pool, execID, status) - } -} - -// closePlanStepForExecution auto-closes a task plan step whose linked execution -// just reached a terminal state, so the task board advances even if the agent -// doesn't call update_plan_step itself (belt and suspenders — the agent links -// the step to the execution when it starts it; the api finishes it here). Emits -// plan.step.finished correlated to the step's session. No-op for the vast -// majority of executions, which aren't plan steps. -func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) { - stepStatus := "done" - if execStatus == "failed" || execStatus == "cancelled" { - stepStatus = "failed" - } - var stepID, sessionID string - var seq int - if err := pool.QueryRow(ctx, ` - UPDATE session_plan_steps SET status = $2, finished_at = now() - WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped') - RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil { - return // no matching open step - } - _ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID, - map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()}) -} - -func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) { - slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr) - - host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug) - if err != nil { - slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("%s", err.Error())) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) - return - } - - idx := strings.Index(actionStr, ":") - if idx < 0 { - slog.Error("httpapi: malformed action string (no colon)", "action", actionStr) - return - } - action, params := actionStr[:idx], actionStr[idx+1:] - - startedAt := time.Now() - var output, cmd string - - switch action { - case "systemctl": - svc := strings.TrimPrefix(targetSlug, "lxc:") - switch { - case strings.HasPrefix(params, "enable:"): - svc = strings.TrimPrefix(params, "enable:") - cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) - case strings.HasPrefix(params, "disable:"): - svc = strings.TrimPrefix(params, "disable:") - cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) - default: - cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc) - } - output, err = sshExec(ctx, host, user, cmd) - - case "apt_upgrade": - svc := strings.TrimPrefix(targetSlug, "lxc:") - cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc) - output, err = sshExec(ctx, host, user, cmd) - - case "pct_create": - var cfg struct { - VMID int `json:"vmid"` - Hostname string `json:"hostname"` - Cores int `json:"cores"` - Memory int `json:"memory"` - DiskGB int `json:"disk_gb"` - IP string `json:"ip"` - GW string `json:"gw"` - Bridge string `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0 - Storage string `json:"storage"` - Template string `json:"template"` - Privileged flexBool `json:"privileged"` - Nesting flexBool `json:"nesting"` - Mounts []string `json:"mounts"` - Nameserver string `json:"nameserver"` - Searchdomain string `json:"searchdomain"` - // No services/post_install here anymore — pct_create is atomic - // (create + start + register only). Installing packages and - // running setup scripts is the agent's job via follow-up `run` - // calls against lxc:, so each step is individually - // observable and recoverable instead of one opaque multi-minute - // black box. See the comment above the removed post-create block. - } - if err := json.Unmarshal([]byte(params), &cfg); err != nil { - slog.Error("httpapi: pct_create parse params", "error", err, "params", params) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("invalid pct_create params: %v", err)) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) - return - } - // Only hostname is required. vmid is optional — when 0 (or later found - // to collide) the VMID guard below assigns a free cluster id. - if cfg.Hostname == "" { - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, `{"error":"pct_create: hostname is required"}`) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"}) - return - } - if cfg.Cores == 0 { - cfg.Cores = 1 - } - if cfg.Memory == 0 { - cfg.Memory = 512 - } - if cfg.DiskGB == 0 { - cfg.DiskGB = 8 - } - if cfg.Storage == "" { - cfg.Storage = "local-lvm" - } - if cfg.GW == "" { - cfg.GW = "192.168.8.2" - } - if cfg.Nameserver == "" { - cfg.Nameserver = "192.168.8.2" - } - if cfg.Searchdomain == "" { - cfg.Searchdomain = "hubris.network" - } - // Template pre-flight: resolve against what the host actually has - // cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw - // `pct` error when that exact file isn't present. List the cache, then - // either validate the requested template or auto-pick the newest - // debian one; on miss, fail early with the available list so the - // operator/agent can retry with a real name. - cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true") - available := []string{} - for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") { - if l = strings.TrimSpace(l); l != "" { - available = append(available, l) - } - } - if tplErr != nil { - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error())) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()}) - return - } - cfg.Template = resolveTemplate(cfg.Template, available) - if cfg.Template == "" { - msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("%s", msg)) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) - return - } - - // VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's - // guess (e.g. 132) can collide with a container on another node — pct - // create then fails with "CT N already exists on node X". Fetch the set - // of in-use VMIDs across the cluster; if the requested id is taken (or - // absent), fall back to the cluster's next free id so provisioning - // still succeeds instead of dead-ending on the operator's approval. - usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`) - used := map[int]bool{} - for _, l := range strings.Fields(usedRaw) { - if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil { - used[n] = true - } - } - if cfg.VMID == 0 || used[cfg.VMID] { - nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`) - nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw)) - if nerr != nil || cerr != nil || nextID == 0 { - msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("%s", msg)) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) - return - } - slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID) - cfg.VMID = nextID - } - - privFlag := "--unprivileged 1" - if cfg.Privileged { - privFlag = "--unprivileged 0" - } - - nestingFlag := "" - features := []string{} - if cfg.Nesting { - features = append(features, "nesting=1") - } - if cfg.Privileged { - features = append(features, "keyctl=1") - } - if len(features) > 0 { - nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ",")) - } - - if cfg.Bridge == "" { - cfg.Bridge = "vmbr0" - } - - // net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox - // rejects a gateway alongside ip=dhcp, so only add gw for a static IP. - net0 := "name=eth0,bridge=" + cfg.Bridge + "," - isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp") - if !isStatic { - net0 += "ip=dhcp" - } else { - net0 += "ip=" + cfg.IP - if cfg.GW != "" { - net0 += ",gw=" + cfg.GW - } - } - - // Pre-flight: for a static config, ping the gateway from the target - // HOST, on the SPECIFIC BRIDGE being requested, before spending 5+ - // minutes creating the container. This is the check that would have - // caught the real TypeType failure immediately instead of after a - // full provision attempt. - // - // Binding to the bridge (`ping -I `) matters and was found - // live: a plain unqualified `ping ` from the host can succeed via - // the host's own routing table (multiple routes, possibly through an - // upstream router) even when the *container* — which only gets a - // naive on-link default route via its bridge's veth — can never ARP - // that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2` - // succeeded (via the host's default route), but a container actually - // attached to vmbr0 showed 100% packet loss trying to reach the same - // address, because vmbr0 doesn't carry that subnet's L2 segment. - // Binding to the bridge interface reproduces what the container will - // actually experience, not what the host's broader routing table can - // reach. - if isStatic && cfg.GW != "" { - pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW)) - if pingErr != nil || !gatewayPreflightPassed(pingOut) { - msg := fmt.Sprintf( - "gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+ - "Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.", - cfg.GW, targetSlug, cfg.Bridge) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("%s", msg)) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg}) - return - } - } - - templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template) - createCmd := fmt.Sprintf( - "pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1", - cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory, - cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag) - - if cfg.Nameserver != "" { - createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver) - } - if cfg.Searchdomain != "" { - createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain) - } - - // Add mount points - for i, mp := range cfg.Mounts { - if i < 10 { // pct supports up to mp9 - createCmd += fmt.Sprintf(" --mp%d %s", i, mp) - } - } - - slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd) - output, err = sshExec(ctx, host, user, createCmd) - - // pct_create is now DELIBERATELY ATOMIC: create + start + register, - // nothing else. It used to also run apt installs and a post_install - // script inline as one black-box multi-minute SSH call — the agent - // got back a single opaque success/fail for the whole thing with no - // way to see (or fix) which step actually broke. That's the opposite - // of what makes an agent able to recover from errors. - // - // Installing packages, running post_install, and verifying the - // service now happen as the agent's OWN follow-up `run` calls against - // the new lxc: target — each one is synchronous (in an - // active assent window) or individually gated, so the agent observes - // every step's real output and can diagnose + retry the exact thing - // that failed instead of re-doing the whole container. See SOUL.md - // "After pct_create: you drive the install" and provisionScript's - // surviving role (DNS self-heal) is now something the agent invokes - // itself via `run`, not something baked into this handler. - // - // cfg.Services/cfg.PostInstall are intentionally no longer read here. - - // On success, register the entity in the DB with proper relationships - if err == nil { - slug := "lxc:" + cfg.Hostname - var lxcID uuid.UUID - lxcID, _ = uuid.NewV7() - attrs := map[string]any{ - "pve_id": fmt.Sprintf("%d", cfg.VMID), - "host": strings.TrimPrefix(targetSlug, "host:"), - "ip": cfg.IP, - } - attrsJSON, _ := json.Marshal(attrs) - _, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at) - VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON) - if insErr != nil { - slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug) - } - - // Create hosts relationship: Proxmox host → LXC - var hostID uuid.UUID - if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil { - _, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID) - if relErr != nil { - slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug) - } - } - - // Create entity_status row for health tracking - pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at) - VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID) - - emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{ - "lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug, - }) - - slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug) - } - - case "run": - // The general gated primitive: arbitrary shell against any host or - // LXC, approved and classified by internal/policy.ClassifyCommand at - // request time (see mcp/server.go's "run" tool). No fixed action - // enum — new capability doesn't require new Go code here. - var cfg struct { - Command string `json:"command"` - Purpose string `json:"purpose"` - } - if perr := json.Unmarshal([]byte(params), &cfg); perr != nil { - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("invalid run params: %v", perr)) - emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()}) - return - } - cmd = wrap(cfg.Command) - output, err = sshExec(ctx, host, user, cmd) - - default: - slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID) - pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, - execID, jsonErr("unknown action: %s", action)) - return - } - - durationMs := int(time.Since(startedAt).Milliseconds()) - status := "completed" - verified := true - // Build result via json.Marshal, not string interpolation. Command output - // (apt/pct) contains quotes, backslashes and control chars; the old - // fmt.Sprintf only escaped "\n", producing invalid JSON that failed the - // ::jsonb cast — so this UPDATE was silently discarded and the execution - // was stuck at "approved" forever even though provisioning succeeded. - resMap := map[string]any{"output": output} - if err != nil { - resMap["error"] = err.Error() - status = "failed" - verified = false - } - resultJSON, _ := json.Marshal(resMap) - - if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`, - execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil { - slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status) - } - - emitExecutionEvent(ctx, pool, execID, status, map[string]any{ - "action": action, "target": targetSlug, "duration_ms": durationMs, - }) - - slog.Info("httpapi: approved action executed", - "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) -} - -// jsonErr builds a valid {"error": "..."} JSON payload for an execution's -// result column. Always use this instead of fmt.Sprintf'ing JSON by hand — -// error text and command output routinely contain quotes/backslashes that -// break a hand-built string and fail the ::jsonb cast. -func jsonErr(format string, args ...any) []byte { - b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)}) - return b -} - -// resolveTemplate maps a requested template name to one actually present in -// the host's template cache. Exact match wins; a bare distro hint (e.g. -// "debian-13" or "debian") matches by prefix; empty picks the newest debian -// (falling back to any) template available. Returns "" when nothing fits. -// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers -// from the pct_create gateway pre-flight check. Pulled out as its own -// function (rather than an inline strings.Contains at the call site) so it's -// unit-testable: a prior version checked for "REACHABLE", which is a -// substring of "UNREACHABLE" — the check could never actually fail, and it -// took a live deployment to notice. Exact-match markers plus a test make -// that specific bug class structurally unable to recur silently. -func gatewayPreflightPassed(out string) bool { - return strings.TrimSpace(out) == "PREFLIGHT_OK" -} - -func resolveTemplate(requested string, available []string) string { - if len(available) == 0 { - return "" - } - if requested != "" { - for _, a := range available { - if a == requested { - return a - } - } - for _, a := range available { - if strings.HasPrefix(a, requested) { - return a - } - } - } - // Auto-pick: prefer debian, then the lexically-greatest (newest version). - best := "" - for _, a := range available { - if strings.Contains(a, "debian") && a > best { - best = a - } - } - if best != "" { - return best - } - for _, a := range available { - if a > best { - best = a - } - } - return best -} - -// ─── 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 - 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 - if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType, - &configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil { - return nil, err - } - 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", "", - 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, "", - 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 - } - 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 -} - -// ─── Classifications ─────────────────────────────────────────────────── - -func (s *Server) ListClassifications(ctx context.Context, req gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) { - limit := clampLimit(req.Params.Limit) - var route *string - if req.Params.Route != nil { - r := string(*req.Params.Route) - route = &r - } - rows, err := s.pool.Query(ctx, ` - SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action, - c.recommended_action, c.risk_class, c.route, c.blast_radius, - c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning, - c.correlation_id, c.created_at, - e.slug, COALESCE(se.slug, '') AS signal_slug, COALESCE(te.slug, '') AS target_slug - FROM classifications c - LEFT JOIN entities e ON e.id = c.entity_id - LEFT JOIN entities se ON se.id = c.signal_entity_id - LEFT JOIN entities te ON te.id = c.target_entity_id - WHERE ($1::text IS NULL OR c.route = $1) - AND ($2::text IS NULL OR e.slug > $2) - ORDER BY e.slug - LIMIT $3`, - route, req.Params.Cursor, limit+1) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Classification{} - for rows.Next() { - var cls gen.Classification - var recActionJSON []byte - var reasoningJSON []byte - var blastRadius []uuid.UUID - var signalSlug, targetSlug string - if err := rows.Scan(&cls.Id, &cls.SignalId, &targetSlug, &cls.Action, - &recActionJSON, &cls.RiskClass, &cls.Route, &blastRadius, - &cls.PatternConfidence, &cls.SkillId, &cls.AutonomyCheck, &reasoningJSON, - &cls.CorrelationId, &cls.CreatedAt, - &cls.Target, &signalSlug, &targetSlug); err != nil { - return nil, err - } - if targetSlug != "" { - cls.Target = &targetSlug - } - var reasoning map[string]any - if json.Unmarshal(reasoningJSON, &reasoning) == nil { - cls.Reasoning = reasoning - } - if len(blastRadius) > 0 { - br := make([]string, len(blastRadius)) - for i, id := range blastRadius { - br[i] = id.String() - } - cls.BlastRadius = &br - } - items = append(items, cls) - } - if rows.Err() != nil { - return nil, rows.Err() - } - - var next *string - if len(items) > limit { - items = items[:limit] - if items[len(items)-1].Target != nil { - next = items[len(items)-1].Target - } - } - if items == nil { - items = []gen.Classification{} - } - return gen.ListClassifications200JSONResponse{Items: items, NextCursor: next}, nil -} - -// ─── Executions ──────────────────────────────────────────────────────── - -func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) { - limit := clampLimit(req.Params.Limit) - rows, err := s.pool.Query(ctx, ` - SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, - e.target_entity_id, e.action, e.risk_class, - e.approval_id::text, e.agent_id::text, e.skill_id::text, - e.skill_version, e.status, e.result, e.duration_ms, - e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, - te.slug - FROM executions e - JOIN entities te ON te.id = e.target_entity_id - WHERE ($1::text IS NULL OR e.status = $1) - AND ($2::text IS NULL OR te.slug > $2) - ORDER BY te.slug - LIMIT $3`, - req.Params.Status, req.Params.Cursor, limit+1) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Execution{} - for rows.Next() { - var exec gen.Execution - var resultBytes []byte - var targetSlug string - if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, - &exec.Target, &exec.Action, &exec.RiskClass, - &exec.ApprovalId, &exec.AgentId, &exec.SkillId, - &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, - &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, - &exec.CreatedAt, &targetSlug); err != nil { - return nil, err - } - var result map[string]any - if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil { - exec.Result = &result - } - // Target is stored as UUID, but we surface the slug - exec.Slug = targetSlug - items = append(items, exec) - } - 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.Execution{} - } - return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil -} - -func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) - if err != nil { - return nil, err - } - - var exec gen.Execution - var resultBytes []byte - var targetSlug string - err = s.pool.QueryRow(ctx, ` - SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, - e.target_entity_id, e.action, e.risk_class, - e.approval_id::text, e.agent_id::text, e.skill_id::text, - e.skill_version, e.status, e.result, e.duration_ms, - e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, - te.slug - FROM executions e - JOIN entities te ON te.id = e.target_entity_id - WHERE e.entity_id = $1`, id). - Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, - &exec.Target, &exec.Action, &exec.RiskClass, - &exec.ApprovalId, &exec.AgentId, &exec.SkillId, - &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, - &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, - &exec.CreatedAt, &targetSlug) - if err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - var result map[string]any - if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil { - exec.Result = &result - } - exec.Slug = targetSlug - return gen.GetExecution200JSONResponse(exec), nil -} - -func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, 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 - } - - targetID, err := s.resolveEntityID(ctx, req.Body.Target) - if err != nil { - return nil, err - } - - correlationID := uuid.New().String() - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - q := sqlcgen.New(tx) - - // Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7 - // collides for real under back-to-back requests since the leading bytes - // encode a millisecond timestamp (observed live via the MCP run tool). - execSlug := "exec:" + id.String() - if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{ - ID: id, - Slug: execSlug, - Type: "execution", - Name: req.Body.Action + " on " + req.Body.Target, - Attributes: []byte("{}"), - }); err != nil { - return nil, err - } - - if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{ - EntityID: id, - TargetEntityID: &targetID, - Action: req.Body.Action, - RiskClass: "unclassified", // will be classified by classifier - CorrelationID: correlationID, - }); err != nil { - return nil, err - } - - // Re-read to get the full record. - var exec gen.Execution - var resultBytes []byte - var targetSlug string - err = tx.QueryRow(ctx, ` - SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, - e.target_entity_id, e.action, e.risk_class, - e.approval_id::text, e.agent_id::text, e.skill_id::text, - e.skill_version, e.status, e.result, e.duration_ms, - e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, - te.slug - FROM executions e - JOIN entities te ON te.id = e.target_entity_id - WHERE e.entity_id = $1`, id). - Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, - &exec.Target, &exec.Action, &exec.RiskClass, - &exec.ApprovalId, &exec.AgentId, &exec.SkillId, - &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, - &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, - &exec.CreatedAt, &targetSlug) - if err != nil { - return nil, err - } - exec.Slug = targetSlug - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, q, actorType, actor, "create", - &id, "POST", "/api/v1/executions", "", - map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil { - return nil, auditErr - } - if eventErr := observability.Event(ctx, q, "execution.requested", &id, - "info", "oikos-api", "", - map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil { - return nil, eventErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.RequestExecution201JSONResponse(exec), nil -} - -func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) - if err != nil { - return nil, err - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - q := sqlcgen.New(tx) - if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{ - EntityID: id, - Status: "cancelled", - }); err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - - // Re-read. - var exec gen.Execution - var resultBytes []byte - var targetSlug string - err = tx.QueryRow(ctx, ` - SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text, - e.target_entity_id, e.action, e.risk_class, - e.approval_id::text, e.agent_id::text, e.skill_id::text, - e.skill_version, e.status, e.result, e.duration_ms, - e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at, - te.slug - FROM executions e - JOIN entities te ON te.id = e.target_entity_id - WHERE e.entity_id = $1`, id). - Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId, - &exec.Target, &exec.Action, &exec.RiskClass, - &exec.ApprovalId, &exec.AgentId, &exec.SkillId, - &exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs, - &exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt, - &exec.CreatedAt, &targetSlug) - if err != nil { - return nil, err - } - exec.Slug = targetSlug - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel", - &id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "", - map[string]any{"status": "cancelled"}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.CancelExecution200JSONResponse(exec), nil -} - -// ─── Approvals ───────────────────────────────────────────────────────── - -func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) { - limit := clampLimit(req.Params.Limit) - var status *string - if req.Params.Status != nil { - s := string(*req.Params.Status) - status = &s - } - var kind *string - if req.Params.Kind != nil { - k := string(*req.Params.Kind) - kind = &k - } - rows, err := s.pool.Query(ctx, ` - SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload, - a.status, a.expires_at, a.decided_at, a.decided_by::text, - a.created_at, e.slug - FROM approvals a - JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id) - WHERE ($1::text IS NULL OR a.status = $1) - AND ($2::text IS NULL OR a.kind = $2) - AND ($3::text IS NULL OR e.slug > $3) - ORDER BY e.slug - LIMIT $4`, - status, kind, req.Params.Cursor, limit+1) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Approval{} - for rows.Next() { - var a gen.Approval - var payloadBytes []byte - var decidedBy *string - if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes, - &a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy, - &a.CreatedAt, &a.Slug); err != nil { - return nil, err - } - a.DecidedBy = decidedBy - var payload map[string]any - if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil { - a.Payload = &payload - } - items = append(items, a) - } - 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.Approval{} - } - return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil -} - -func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, 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 - } - - actorType, actor := actorInfo(ctx) - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - q := sqlcgen.New(tx) - - // Verify HMAC token if provided (single-use, S5). - if req.Body.Token != nil && *req.Body.Token != "" { - var tokenHash *string - var apprStatus string - var expiresAt time.Time - err := tx.QueryRow(ctx, - "SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1", - id).Scan(&tokenHash, &apprStatus, &expiresAt) - if err != nil || tokenHash == nil { - return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound) - } - if apprStatus != "pending" { - return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition) - } - if expiresAt.Before(time.Now()) { - return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition) - } - if *tokenHash != hashToken(*req.Body.Token) { - return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput) - } - } - - // Map decision to status. - var status string - switch req.Body.Decision { - case gen.Approve: - status = "approved" - case gen.Deny: - status = "denied" - case gen.Revoke: - status = "revoked" - default: - return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision) - } - - if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{ - EntityID: id, - Status: status, - }); err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id) - } - return nil, err - } - - // Re-read approval. - app, err := q.GetApprovalByID(ctx, id) - if err != nil { - return nil, err - } - - approval := approvalToGen(app) - - if auditErr := observability.Audit(ctx, q, actorType, actor, "decide", - &id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "", - map[string]any{"decision": status}); auditErr != nil { - return nil, auditErr - } - - // Emit for SSE fan-out (in-tx; NOTIFY fires post-commit). - if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "", - map[string]any{"decision": status, "actor": actor}); evErr != nil { - return nil, evErr - } - - // On approve: execute the linked gated command. - if status == "approved" { - var execID, targetID uuid.UUID - var actionStr, targetSlug, riskClass string - err := tx.QueryRow(ctx, ` - SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class - FROM executions e - WHERE e.approval_id = $1 AND e.status = 'pending_approval' - LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass) - if err == nil { - // Resolve target entity slug from targetID. - _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) - - safego.Go("httpapi:executeApprovedAction", func() { - executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr) - }) - // Status only — risk_class was set correctly at request time - // (e.g. by policy.ClassifyCommand for `run`); overwriting it to - // a hardcoded 'config_mutation' here corrupted the audit ledger - // for every other risk class, including destructive. - _, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID) - - // Approving a plan step — by ANY route (this endpoint backs both - // the chat Approve button and chat-assent) — opens/extends the - // agent's assent window. This is the scope gate the Nomos - // auto-continuation worker checks: with the window open, the - // finished execution's result is fed back to the agent so it runs - // the plan to completion. Without opening it here, approving via - // the button (instead of typing "go ahead") would silently not - // auto-continue. - var agentID *uuid.UUID - if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil { - expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339) - _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires) - - // Approving a DESTRUCTIVE step via the button is exactly as - // explicit as a typed "I confirm" — the operator affirmatively - // clicked Approve on a card that said DESTRUCTIVE. Open the - // same short, target-scoped destructive window chat-assent's - // typed-confirm path opens, for parity: a multi-step - // destructive recovery (stop, then destroy) shouldn't need a - // fresh confirmation per click any more than it needs one per - // typed phrase. - if riskClass == "destructive" && targetSlug != "" { - dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339) - _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2`, - "destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires) - } - } - - slog.Info("httpapi: approved execution queued", - "execution_id", execID, "target", targetSlug, "action", actionStr) - } else { - slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err) - } - } else { - // Denied/revoked: reflect it on the linked execution too. Previously - // only the approvals row changed, so the execution stayed - // 'pending_approval' forever — any UI/poller reading execution - // status (not approval status) never saw the decision. - _, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status) - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.DecideApproval200JSONResponse(approval), nil -} - -func approvalToGen(a sqlcgen.Approval) gen.Approval { - app := gen.Approval{ - Id: a.EntityID, - Action: a.Action, - RiskClass: a.RiskClass, - Kind: gen.ApprovalKind(a.Kind), - Status: gen.ApprovalStatus(a.Status), - ExpiresAt: a.ExpiresAt, - DecidedAt: a.DecidedAt, - CreatedAt: a.CreatedAt, - } - if a.DecidedBy != nil { - s := a.DecidedBy.String() - app.DecidedBy = &s - } - var payload map[string]any - if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 { - app.Payload = &payload - } - return app -} - -// ─── Patterns ────────────────────────────────────────────────────────── - -func (s *Server) ListPatterns(ctx context.Context, req gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) { - limit := clampLimit(req.Params.Limit) - rows, err := s.pool.Query(ctx, ` - SELECT p.entity_id, e.slug, p.applies_type, p.action, p.pattern, p.confidence, - p.evidence_count, p.success_count, p.failure_count, p.status, - p.quarantined, p.version, p.last_validated_at - FROM patterns p - JOIN entities e ON e.id = p.entity_id - WHERE ($1::text IS NULL OR p.status = $1) - AND ($2::text IS NULL OR p.applies_type = $2) - AND ($3::text IS NULL OR p.action = $3) - ORDER BY p.applies_type, p.action - LIMIT $4`, - req.Params.Status, req.Params.EntityType, req.Params.Action, limit+1) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Pattern{} - for rows.Next() { - var p gen.Pattern - if err := rows.Scan(&p.Id, &p.Slug, &p.AppliesType, &p.Action, &p.Pattern, - &p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount, - &p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt); err != nil { - return nil, err - } - items = append(items, p) - } - if rows.Err() != nil { - return nil, rows.Err() - } - - if items == nil { - items = []gen.Pattern{} - } - return gen.ListPatterns200JSONResponse{Items: items}, nil -} - -func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, 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 - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - q := sqlcgen.New(tx) - - if req.Body.Status != nil { - status := string(*req.Body.Status) - if err := q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{ - EntityID: id, - Status: status, - }); err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - } - - if req.Body.Quarantined != nil { - if err := q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{ - EntityID: id, - Quarantined: *req.Body.Quarantined, - }); err != nil { - return nil, err - } - } - - // Re-read. - var p gen.Pattern - err = tx.QueryRow(ctx, ` - SELECT entity_id, applies_type, action, pattern, confidence, - evidence_count, success_count, failure_count, status, - quarantined, version, last_validated_at - FROM patterns WHERE entity_id = $1`, id). - Scan(&p.Id, &p.AppliesType, &p.Action, &p.Pattern, - &p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount, - &p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt) - if err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", - &id, "PATCH", "/api/v1/patterns/"+req.Id, "", - map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.PatchPattern200JSONResponse(p), nil -} - -// ─── Skills ──────────────────────────────────────────────────────────── - -func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) { - limit := clampLimit(req.Params.Limit) - rows, err := s.pool.Query(ctx, ` - SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type, - s.action, s.pattern_ids, s.status, s.success_rate, - s.changed_by::text, s.change_reason, s.last_used_at - FROM skills s - WHERE ($1::text IS NULL OR s.status = $1) - AND ($2::text IS NULL OR s.applies_type = $2) - AND ($3::text IS NULL OR s.action = $3) - ORDER BY s.name, s.version DESC`, - req.Params.Status, req.Params.AppliesTo, req.Params.Action) - if err != nil { - return nil, err - } - defer rows.Close() - - // Deduplicate to latest version per skill (the ORDER BY name, version DESC - // means the first row per name is the latest). - seen := map[string]bool{} - items := []gen.Skill{} - for rows.Next() { - var s gen.Skill - var procBytes []byte - var patternIDs []uuid.UUID - if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType, - &s.Action, &patternIDs, &s.Status, &s.SuccessRate, - &s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil { - return nil, err - } - if seen[s.Id.String()] { - continue - } - seen[s.Id.String()] = true - if err := json.Unmarshal(procBytes, &s.Procedure); err != nil { - slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err) - } - if len(patternIDs) > 0 { - pids := make([]string, len(patternIDs)) - for i, pid := range patternIDs { - pids[i] = pid.String() - } - s.PatternIds = &pids - } - items = append(items, s) - if len(items) > limit { - break - } - } - if rows.Err() != nil { - return nil, rows.Err() - } - - if items == nil { - items = []gen.Skill{} - } - return gen.ListSkills200JSONResponse{Items: items}, nil -} - -func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, 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 - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - q := sqlcgen.New(tx) - - if req.Body.Status != nil { - if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{ - EntityID: id, - Status: string(*req.Body.Status), - }); err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - } - - // Re-read skill. - var skill gen.Skill - var procBytes []byte - var patternIDs []uuid.UUID - err = tx.QueryRow(ctx, ` - SELECT entity_id, version, name, procedure, applies_type, action, - pattern_ids, status, success_rate, changed_by::text, - change_reason, last_used_at - FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id). - Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, - &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, - &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt) - if err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) - } - return nil, err - } - if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { - slog.Warn("phase3: unmarshal skill proc", "error", err) - } - if len(patternIDs) > 0 { - pids := make([]string, len(patternIDs)) - for i, pid := range patternIDs { - pids[i] = pid.String() - } - skill.PatternIds = &pids - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", - &id, "PATCH", "/api/v1/skills/"+req.Id, "", - map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.PatchSkill200JSONResponse(skill), nil -} - -func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) { - id, err := s.resolveEntityID(ctx, req.Id) - if err != nil { - return nil, err - } - - rows, err := s.pool.Query(ctx, ` - SELECT entity_id, version, name, procedure, applies_type, action, - pattern_ids, status, success_rate, changed_by::text, - change_reason, last_used_at - FROM skills WHERE entity_id = $1 - ORDER BY version DESC`, id) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Skill{} - for rows.Next() { - var skill gen.Skill - var procBytes []byte - var patternIDs []uuid.UUID - if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, - &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, - &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil { - return nil, err - } - if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { - slog.Warn("phase3: unmarshal skill proc", "error", err) - } - if len(patternIDs) > 0 { - pids := make([]string, len(patternIDs)) - for i, pid := range patternIDs { - pids[i] = pid.String() - } - skill.PatternIds = &pids - } - items = append(items, skill) - } - if rows.Err() != nil { - return nil, rows.Err() - } - if items == nil { - items = []gen.Skill{} - } - return gen.ListSkillVersions200JSONResponse{Items: items}, nil -} - -// ─── Approval Rules (Policy) ─────────────────────────────────────────── - -func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, entity_type, action, risk_class, autonomy_level, - COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''), - version, updated_at - FROM approval_rules ORDER BY entity_type, action`) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.ApprovalRule{} - for rows.Next() { - var rule gen.ApprovalRule - var scopeSlug string - if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action, - &rule.RiskClass, &rule.AutonomyLevel, &scopeSlug, - &rule.Version); err != nil { - return nil, err - } - if scopeSlug != "" { - rule.ScopeEntity = &scopeSlug - } - items = append(items, rule) - } - if rows.Err() != nil { - return nil, rows.Err() - } - if items == nil { - items = []gen.ApprovalRule{} - } - return gen.ListApprovalRules200JSONResponse{Items: items}, nil -} - -func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, 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 - } - - var scopeEntity *uuid.UUID - if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" { - se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity) - if rerr != nil { - return nil, rerr - } - scopeEntity = &se - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - _, err = tx.Exec(ctx, ` - INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity) - VALUES ($1, $2, $3, $4, $5, $6)`, - id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass, - string(req.Body.AutonomyLevel), scopeEntity) - if err != nil { - if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { - return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists, - coalesceStr(req.Body.EntityType, "*"), req.Body.Action) - } - return nil, err - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", - &id, "POST", "/api/v1/policy/approval-rules", "", - map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - // Return 202 pending approval (dual-control). - return gen.CreateApprovalRule202JSONResponse{}, nil -} - -func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, 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 - } - - var scopeEntity *uuid.UUID - if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" { - se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity) - if rerr != nil { - return nil, rerr - } - scopeEntity = &se - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - result, err := tx.Exec(ctx, ` - UPDATE approval_rules - SET entity_type = COALESCE($2, entity_type), - action = COALESCE($3, action), - risk_class = COALESCE($4, risk_class), - autonomy_level = COALESCE($5, autonomy_level), - scope_entity = COALESCE($6, scope_entity), - version = version + 1, - updated_at = now() - WHERE id = $1`, - id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass, - string(req.Body.AutonomyLevel), scopeEntity) - if err != nil { - return nil, err - } - if result.RowsAffected() == 0 { - return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id) - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", - &id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "", - map[string]any{"action": req.Body.Action}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.PatchApprovalRule202JSONResponse{}, nil -} - -// ─── Autonomy Settings ───────────────────────────────────────────────── - -func (s *Server) GetAutonomySettings(ctx context.Context, req gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) { - rows, err := s.pool.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.AutonomySetting{} - for rows.Next() { - var as gen.AutonomySetting - if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil { - return nil, err - } - items = append(items, as) - } - if rows.Err() != nil { - return nil, rows.Err() - } - if items == nil { - items = []gen.AutonomySetting{} - } - return gen.GetAutonomySettings200JSONResponse{Items: items}, nil -} - -func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) { - if req.Body == nil { - return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - for key, value := range *req.Body { - _, err := tx.Exec(ctx, ` - INSERT INTO autonomy_settings (key, value, version, updated_at) - VALUES ($1, $2, 1, now()) - ON CONFLICT (key) - DO UPDATE SET value = EXCLUDED.value, version = autonomy_settings.version + 1, updated_at = now()`, - key, value) - if err != nil { - return nil, err - } - } - - // Re-read all settings. - rows, err := tx.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.AutonomySetting{} - for rows.Next() { - var as gen.AutonomySetting - if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil { - return nil, err - } - items = append(items, as) - } - if rows.Err() != nil { - return nil, rows.Err() - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", - nil, "PATCH", "/api/v1/policy/autonomy", "", - map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.PatchAutonomySettings200JSONResponse{Items: items}, nil -} - -// keysOfMap returns the keys of a map[string]string. -func keysOfMap(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} - -// ─── Risk Classes ────────────────────────────────────────────────────── - -func (s *Server) ListRiskClasses(ctx context.Context, req gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) { - rows, err := s.pool.Query(ctx, `SELECT name, description, approval_required, autonomy_allowed FROM risk_classes ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.RiskClass{} - for rows.Next() { - var rc gen.RiskClass - if err := rows.Scan(&rc.Name, &rc.Description, &rc.ApprovalRequired, &rc.AutonomyAllowed); err != nil { - return nil, err - } - items = append(items, rc) - } - if rows.Err() != nil { - return nil, rows.Err() - } - if items == nil { - items = []gen.RiskClass{} - } - return gen.ListRiskClasses200JSONResponse{Items: items}, nil -} - -// ─── Relationships ───────────────────────────────────────────────────── - -func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) { - if req.Body == nil { - return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) - } - - sourceID, err := s.resolveEntityID(ctx, req.Body.Source) - if err != nil { - return nil, err - } - targetID, err := s.resolveEntityID(ctx, req.Body.Target) - if err != nil { - return nil, err - } - - attrsJSON := []byte("{}") - if req.Body.Attributes != nil { - attrsJSON, _ = json.Marshal(req.Body.Attributes) - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - _, err = tx.Exec(ctx, ` - INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - VALUES ($1, $2, $3, $4, now())`, - sourceID, targetID, req.Body.Type, attrsJSON) - if err != nil { - if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { - return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists", - domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target) - } - return nil, err - } - - rel := gen.Relationship{ - Source: req.Body.Source, - Target: req.Body.Target, - Type: req.Body.Type, - ValidFrom: time.Now(), - } - if req.Body.Attributes != nil { - rel.Attributes = req.Body.Attributes - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", - nil, "POST", "/api/v1/relationships", "", - map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.CreateRelationship201JSONResponse(rel), nil -} - -func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) { - sourceID, err := s.resolveEntityID(ctx, req.Params.Source) - if err != nil { - return nil, err - } - targetID, err := s.resolveEntityID(ctx, req.Params.Target) - if err != nil { - return nil, err - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{ - SourceID: sourceID, - TargetID: targetID, - Type: req.Params.RelType, - }) - if err != nil { - return nil, err - } - if result == 0 { - return nil, fmt.Errorf("%w: active relationship %s:%s:%s", - domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target) - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete", - nil, "DELETE", "/api/v1/relationships", "", - map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.EndRelationship204Response{}, nil -} - -// ─── Entity Types (Ontology) ─────────────────────────────────────────── - -func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) { - if req.Body == nil { - return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) - } - - isAbstract := false - if req.Body.IsAbstract != nil { - isAbstract = *req.Body.IsAbstract - } - - attrsSchemaJSON := []byte("null") - if req.Body.AttributeSchema != nil { - attrsSchemaJSON, _ = json.Marshal(req.Body.AttributeSchema) - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - _, err = tx.Exec(ctx, ` - INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`, - req.Body.Name, req.Body.ParentType, isAbstract, req.Body.Domain, - string(req.Body.Layer), req.Body.Description, req.Body.LifecycleId, attrsSchemaJSON) - if err != nil { - if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { - return nil, fmt.Errorf("%w: entity type %q already exists", domain.ErrAlreadyExists, req.Body.Name) - } - return nil, err - } - - // Re-read. - var et gen.EntityType - var schemaBytes []byte - err = tx.QueryRow(ctx, ` - SELECT name, parent_type, is_abstract, domain, layer, description, - lifecycle_id, attribute_schema, schema_version, status - FROM entity_types WHERE name = $1`, req.Body.Name). - Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer, - &et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status) - if err != nil { - return nil, err - } - var schema map[string]any - if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil { - et.AttributeSchema = &schema - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", - nil, "POST", "/api/v1/ontology/entity-types", "", - map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.CreateEntityType201JSONResponse(et), nil -} - -func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) { - if req.Body == nil { - return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) - } - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - // Build dynamic update. - sets := []string{} - args := []any{} - argIdx := 2 - - if req.Body.Description != nil { - sets = append(sets, fmt.Sprintf("description = $%d", argIdx)) - args = append(args, *req.Body.Description) - argIdx++ - } - if req.Body.Status != nil { - sets = append(sets, fmt.Sprintf("status = $%d", argIdx)) - args = append(args, string(*req.Body.Status)) - argIdx++ - } - if req.Body.AttributeSchema != nil { - schemaJSON, _ := json.Marshal(req.Body.AttributeSchema) - sets = append(sets, fmt.Sprintf("attribute_schema = $%d", argIdx)) - args = append(args, schemaJSON) - argIdx++ - } - - if len(sets) == 0 { - return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput) - } - - sets = append(sets, "schema_version = schema_version + 1, updated_at = now()") - - query := fmt.Sprintf(`UPDATE entity_types SET %s WHERE name = $1`, strings.Join(sets, ", ")) - finalArgs := append([]any{req.Name}, args...) - - result, err := tx.Exec(ctx, query, finalArgs...) - if err != nil { - return nil, err - } - if result.RowsAffected() == 0 { - return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Name) - } - - // Re-read. - var et gen.EntityType - var schemaBytes []byte - err = tx.QueryRow(ctx, ` - SELECT name, parent_type, is_abstract, domain, layer, description, - lifecycle_id, attribute_schema, schema_version, status - FROM entity_types WHERE name = $1`, req.Name). - Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer, - &et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status) - if err != nil { - return nil, err - } - var schema map[string]any - if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil { - et.AttributeSchema = &schema - } - - actorType, actor := actorInfo(ctx) - if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", - nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "", - map[string]any{"status": req.Body.Status}); auditErr != nil { - return nil, auditErr - } - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return gen.PatchEntityType200JSONResponse(et), nil -} - -// ─── Metrics ─────────────────────────────────────────────────────────── - -func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) { - if req.Params.EntityId == nil || *req.Params.EntityId == "" { - return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput) - } - entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId) - if err != nil { - return nil, err - } - - from := time.Now().Add(-24 * time.Hour) - if req.Params.From != nil { - from = *req.Params.From - } - to := time.Now() - if req.Params.To != nil { - to = *req.Params.To - } - - var metricNames []string - if req.Params.Metric != nil && len(*req.Params.Metric) > 0 { - metricNames = *req.Params.Metric - } else { - // metric omitted: report every metric recorded for this entity in range. - rows, err := s.pool.Query(ctx, ` - SELECT DISTINCT metric FROM metric_samples - WHERE entity_id = $1 AND ts >= $2 AND ts <= $3 - ORDER BY metric`, entityID, from, to) - if err != nil { - return nil, err - } - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - rows.Close() - return nil, err - } - metricNames = append(metricNames, name) - } - if err := rows.Err(); err != nil { - return nil, err - } - } - - items := []gen.MetricSeries{} - for _, metricName := range metricNames { - series := gen.MetricSeries{ - EntityId: entityID.String(), - Metric: metricName, - Rollup: gen.MetricSeriesRollupRaw, - } - - rows, err := s.pool.Query(ctx, ` - SELECT ts, value - FROM metric_samples - WHERE entity_id = $1 AND metric = $2 - AND ts >= $3 AND ts <= $4 - ORDER BY ts ASC`, - entityID, metricName, from, to) - if err != nil { - return nil, err - } - - samples := []struct { - Avg *float32 `json:"avg"` - Count *int `json:"count"` - Max *float32 `json:"max"` - Min *float32 `json:"min"` - Ts time.Time `json:"ts"` - Value *float32 `json:"value"` - }{} - - for rows.Next() { - var ts time.Time - var val float64 - if err := rows.Scan(&ts, &val); err != nil { - rows.Close() - return nil, err - } - f := float32(val) - samples = append(samples, struct { - Avg *float32 `json:"avg"` - Count *int `json:"count"` - Max *float32 `json:"max"` - Min *float32 `json:"min"` - Ts time.Time `json:"ts"` - Value *float32 `json:"value"` - }{Value: &f, Ts: ts}) - } - rows.Close() - if rows.Err() != nil { - return nil, rows.Err() - } - - series.Samples = samples - items = append(items, series) - } - - if items == nil { - items = []gen.MetricSeries{} - } - return gen.QueryMetrics200JSONResponse{Items: items}, nil -} - -func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) { - entityID, err := s.resolveEntityID(ctx, req.EntityId) - if err != nil { - return nil, err - } - - from := time.Now().Add(-7 * 24 * time.Hour) - if req.Params.From != nil { - from = *req.Params.From - } - - rows, err := s.pool.Query(ctx, ` - SELECT metric, - ROUND(avg(value)::numeric, 2) AS avg_val, - ROUND(stddev(value)::numeric, 2) AS std_val, - count(*) AS sample_count, - ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope - FROM metric_samples - WHERE entity_id = $1 AND ts >= $2 - GROUP BY metric - ORDER BY metric`, entityID, from) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.Trend{} - for rows.Next() { - var t gen.Trend - var avgVal, stdVal, slopeNum pgtype.Numeric - var sampleCount int - if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil { - return nil, err - } - - // Determine direction. - if slopeNum.Valid { - f, _ := slopeNum.Float64Value() - t.Slope = float32Ptr(float32(f.Float64)) - if f.Float64 > 0.01 { - t.Direction = gen.Improving - } else if f.Float64 < -0.01 { - t.Direction = gen.Degrading - } else { - t.Direction = gen.Stable - } - } else { - t.Direction = gen.Unknown - } - items = append(items, t) - } - if rows.Err() != nil { - return nil, rows.Err() - } - if items == nil { - items = []gen.Trend{} - } - return gen.GetTrends200JSONResponse{Items: items}, nil -} - -func float32Ptr(f float32) *float32 { - return &f -} - -// ─── Agent Activity (stub) ───────────────────────────────────────────── - -func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) { - limit := clampLimit(request.Params.Limit) - from := time.Now().Add(-24 * time.Hour) - if request.Params.From != nil { - from = *request.Params.From - } - to := time.Now() - if request.Params.To != nil { - to = *request.Params.To - } - - var agentID *string - if request.Params.AgentId != nil { - a := *request.Params.AgentId - agentID = &a - } - var activityType *string - if request.Params.ActivityType != nil { - a := string(*request.Params.ActivityType) - activityType = &a - } - var entityID *string - if request.Params.EntityId != nil { - a := *request.Params.EntityId - entityID = &a - } - var cursorID *int - if request.Params.Cursor != nil && *request.Params.Cursor != "" { - if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 { - cursorID = &id - } - } - - rows, err := s.pool.Query(ctx, ` - SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, - entity_id::text, input_summary, output_summary, - duration_ms, token_count, success, correlation_id - FROM agent_activity - WHERE ts >= $1 AND ts <= $2 - AND ($3::text IS NULL OR agent_id::text = $3) - AND ($4::text IS NULL OR activity_type = $4) - AND ($5::text IS NULL OR entity_id::text = $5) - AND ($6::bigint IS NULL OR id < $6::bigint) - ORDER BY id DESC - LIMIT $7`, - from, to, agentID, activityType, entityID, cursorID, limit+1) - if err != nil { - return nil, err - } - defer rows.Close() - - items := []gen.AgentActivity{} - for rows.Next() { - var a gen.AgentActivity - if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId, - &a.ActivityType, &a.ToolName, &a.EntityId, - &a.InputSummary, &a.OutputSummary, - &a.DurationMs, &a.TokenCount, &a.Success, - &a.CorrelationId); err != nil { - return nil, err - } - items = append(items, a) - } - if rows.Err() != nil { - return nil, rows.Err() - } - - var next *string - if len(items) > limit { - items = items[:limit] - lastID := fmt.Sprintf("%d", items[len(items)-1].Id) - next = &lastID - } - if items == nil { - items = []gen.AgentActivity{} - } - return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil -} - -// ─── Helpers ─────────────────────────────────────────────────────────── - -func coalesceStr(s *string, def string) string { - if s == nil || *s == "" { - return def - } - return *s -} - -func parseIntOrZero(s string) (int, error) { - var n int - for _, c := range s { - if c < '0' || c > '9' { - return 0, fmt.Errorf("invalid integer: %q", s) - } - n = n*10 + int(c-'0') - } - return n, nil -} - -func hashToken(token string) string { - h := sha256.Sum256([]byte(token)) - return hex.EncodeToString(h[:]) -} diff --git a/internal/httpapi/relationships.go b/internal/httpapi/relationships.go new file mode 100644 index 0000000..1080263 --- /dev/null +++ b/internal/httpapi/relationships.go @@ -0,0 +1,120 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "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" +) + +// ─── Relationships ───────────────────────────────────────────────────── + +func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) { + if req.Body == nil { + return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) + } + + sourceID, err := s.resolveEntityID(ctx, req.Body.Source) + if err != nil { + return nil, err + } + targetID, err := s.resolveEntityID(ctx, req.Body.Target) + if err != nil { + return nil, err + } + + attrsJSON := []byte("{}") + if req.Body.Attributes != nil { + attrsJSON, _ = json.Marshal(req.Body.Attributes) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + VALUES ($1, $2, $3, $4, now())`, + sourceID, targetID, req.Body.Type, attrsJSON) + if err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { + return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists", + domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target) + } + return nil, err + } + + rel := gen.Relationship{ + Source: req.Body.Source, + Target: req.Body.Target, + Type: req.Body.Type, + ValidFrom: time.Now(), + } + if req.Body.Attributes != nil { + rel.Attributes = req.Body.Attributes + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", + nil, "POST", "/api/v1/relationships", "", + map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.CreateRelationship201JSONResponse(rel), nil +} + +func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) { + sourceID, err := s.resolveEntityID(ctx, req.Params.Source) + if err != nil { + return nil, err + } + targetID, err := s.resolveEntityID(ctx, req.Params.Target) + if err != nil { + return nil, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{ + SourceID: sourceID, + TargetID: targetID, + Type: req.Params.RelType, + }) + if err != nil { + return nil, err + } + if result == 0 { + return nil, fmt.Errorf("%w: active relationship %s:%s:%s", + domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target) + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete", + nil, "DELETE", "/api/v1/relationships", "", + map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.EndRelationship204Response{}, nil +} diff --git a/internal/httpapi/risk_classes.go b/internal/httpapi/risk_classes.go new file mode 100644 index 0000000..9274d35 --- /dev/null +++ b/internal/httpapi/risk_classes.go @@ -0,0 +1,33 @@ +package httpapi + +import ( + "context" + + "github.com/dtoro/oikos/internal/httpapi/gen" +) + +// ─── Risk Classes ────────────────────────────────────────────────────── + +func (s *Server) ListRiskClasses(ctx context.Context, req gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) { + rows, err := s.pool.Query(ctx, `SELECT name, description, approval_required, autonomy_allowed FROM risk_classes ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.RiskClass{} + for rows.Next() { + var rc gen.RiskClass + if err := rows.Scan(&rc.Name, &rc.Description, &rc.ApprovalRequired, &rc.AutonomyAllowed); err != nil { + return nil, err + } + items = append(items, rc) + } + if rows.Err() != nil { + return nil, rows.Err() + } + if items == nil { + items = []gen.RiskClass{} + } + return gen.ListRiskClasses200JSONResponse{Items: items}, nil +} diff --git a/internal/httpapi/skills.go b/internal/httpapi/skills.go new file mode 100644 index 0000000..71d2352 --- /dev/null +++ b/internal/httpapi/skills.go @@ -0,0 +1,196 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "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" +) + +// ─── Skills ──────────────────────────────────────────────────────────── + +func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) { + limit := clampLimit(req.Params.Limit) + rows, err := s.pool.Query(ctx, ` + SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type, + s.action, s.pattern_ids, s.status, s.success_rate, + s.changed_by::text, s.change_reason, s.last_used_at + FROM skills s + WHERE ($1::text IS NULL OR s.status = $1) + AND ($2::text IS NULL OR s.applies_type = $2) + AND ($3::text IS NULL OR s.action = $3) + ORDER BY s.name, s.version DESC`, + req.Params.Status, req.Params.AppliesTo, req.Params.Action) + if err != nil { + return nil, err + } + defer rows.Close() + + // Deduplicate to latest version per skill (the ORDER BY name, version DESC + // means the first row per name is the latest). + seen := map[string]bool{} + items := []gen.Skill{} + for rows.Next() { + var s gen.Skill + var procBytes []byte + var patternIDs []uuid.UUID + if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType, + &s.Action, &patternIDs, &s.Status, &s.SuccessRate, + &s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil { + return nil, err + } + if seen[s.Id.String()] { + continue + } + seen[s.Id.String()] = true + if err := json.Unmarshal(procBytes, &s.Procedure); err != nil { + slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err) + } + if len(patternIDs) > 0 { + pids := make([]string, len(patternIDs)) + for i, pid := range patternIDs { + pids[i] = pid.String() + } + s.PatternIds = &pids + } + items = append(items, s) + if len(items) > limit { + break + } + } + if rows.Err() != nil { + return nil, rows.Err() + } + + if items == nil { + items = []gen.Skill{} + } + return gen.ListSkills200JSONResponse{Items: items}, nil +} + +func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, 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 + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + q := sqlcgen.New(tx) + + if req.Body.Status != nil { + if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{ + EntityID: id, + Status: string(*req.Body.Status), + }); err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + } + + // Re-read skill. + var skill gen.Skill + var procBytes []byte + var patternIDs []uuid.UUID + err = tx.QueryRow(ctx, ` + SELECT entity_id, version, name, procedure, applies_type, action, + pattern_ids, status, success_rate, changed_by::text, + change_reason, last_used_at + FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id). + Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, + &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, + &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) + } + return nil, err + } + if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { + slog.Warn("phase3: unmarshal skill proc", "error", err) + } + if len(patternIDs) > 0 { + pids := make([]string, len(patternIDs)) + for i, pid := range patternIDs { + pids[i] = pid.String() + } + skill.PatternIds = &pids + } + + actorType, actor := actorInfo(ctx) + if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", + &id, "PATCH", "/api/v1/skills/"+req.Id, "", + map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil { + return nil, auditErr + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return gen.PatchSkill200JSONResponse(skill), nil +} + +func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) { + id, err := s.resolveEntityID(ctx, req.Id) + if err != nil { + return nil, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT entity_id, version, name, procedure, applies_type, action, + pattern_ids, status, success_rate, changed_by::text, + change_reason, last_used_at + FROM skills WHERE entity_id = $1 + ORDER BY version DESC`, id) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []gen.Skill{} + for rows.Next() { + var skill gen.Skill + var procBytes []byte + var patternIDs []uuid.UUID + if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, + &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, + &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil { + return nil, err + } + if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { + slog.Warn("phase3: unmarshal skill proc", "error", err) + } + if len(patternIDs) > 0 { + pids := make([]string, len(patternIDs)) + for i, pid := range patternIDs { + pids[i] = pid.String() + } + skill.PatternIds = &pids + } + items = append(items, skill) + } + if rows.Err() != nil { + return nil, rows.Err() + } + if items == nil { + items = []gen.Skill{} + } + return gen.ListSkillVersions200JSONResponse{Items: items}, nil +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 66cf5b9..f73fd7e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -68,708 +68,9 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ Logger: slog.Default(), }) - - register := func(tool *mcp.Tool, handler toolHandler) { - s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler)) + for _, t := range allTools(pool, agentID) { + s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler)) } - - register(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID", - InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - idOrSlug, _ := args["slug_or_id"].(string) - return queryEntity(ctx, pool, idOrSlug), nil - }) - - register(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search", - InputSchema: objSchema( - prop{"type", "string", "Filter by entity type"}, - prop{"state", "string", "Filter by lifecycle state"}, - prop{"q", "string", "Substring match on slug or name"}, - prop{"limit", "integer", "Max rows (default 50)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - limit := int(getFloat(args, "limit", 50)) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at - FROM entities e - WHERE ($1::text IS NULL OR e.type = $1) - AND ($2::text IS NULL OR e.state = $2) - AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') - ORDER BY e.slug LIMIT $4`, - nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil - }) - - register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", - InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["entity_id"].(string) - return queryRows(ctx, pool, ` - SELECT r.type, src.slug AS source, tgt.slug AS target - FROM relationships r - JOIN entities src ON src.id = r.source_id - JOIN entities tgt ON tgt.id = r.target_id - WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL - ORDER BY r.type`, slug), nil - }) - - register(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down", - InputSchema: objSchema( - prop{"entity_id", "string", "Entity slug"}, - prop{"depth", "integer", "Traversal depth (default 3)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["entity_id"].(string) - depth := int(getFloat(args, "depth", 3)) - return queryRows(ctx, pool, - "SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id", - slug, depth), nil - }) - - register(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary", - InputSchema: objSchema(), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return queryRows(ctx, pool, ` - SELECT e.slug, e.type, st.health, st.last_check_at - FROM entity_status st JOIN entities e ON e.id = st.entity_id - WHERE e.type <> 'check' - ORDER BY e.slug`), nil - }) - - register(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log", - InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - return queryRows(ctx, pool, ` - SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id - FROM audit_log - WHERE ($1::text IS NULL OR entity_id::text = $1) - ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil - }) - - register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.", - InputSchema: objSchema(prop{"query", "string", "Search terms"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - q := nStr(args["query"]) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT ke.title, e.slug, - ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, - ts_headline('english', ke.content, plainto_tsquery('english', $1), - 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, - FragmentDelimiter=" ... "') AS snippet, - ke.source, ke.tags - FROM knowledge_entities ke - JOIN entities e ON e.id = ke.entity_id - WHERE ke.search @@ plainto_tsquery('english', $1) - ORDER BY rank DESC - LIMIT 20`, q), "knowledge_results"), nil - }) - - register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.", - InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["entity_slug"].(string) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT ke.title, ke.source, e.type AS kind, e.slug, - ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline - FROM knowledge_entities ke - JOIN entities e ON e.id = ke.entity_id - JOIN relationships r ON r.source_id = ke.entity_id - JOIN entities target ON target.id = r.target_id - WHERE target.slug = $1 - AND r.valid_to IS NULL - AND r.type IN ('documents', 'about') - UNION - SELECT ke.title, ke.source, e.type AS kind, e.slug, - ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline - FROM knowledge_entities ke - JOIN entities e ON e.id = ke.entity_id - JOIN relationships r ON r.source_id = ke.entity_id - JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1) - JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 - WHERE r.valid_to IS NULL - AND r.type = 'procedure-for' - ORDER BY 1`, slug), "knowledge_results"), nil - }) - - register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.", - InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["slug"].(string) - return queryRows(ctx, pool, ` - SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text - FROM knowledge_entities ke - JOIN entities e ON e.id = ke.entity_id - WHERE e.slug = $1`, slug), nil - }) - - register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.", - InputSchema: objSchema( - prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."}, - prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."}, - prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."}, - prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."}, - prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - return upsertKnowledge(ctx, pool, args) - }) - - register(&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.", - InputSchema: objSchema( - prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."}, - prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["slug"].(string) - attrsStr, _ := args["attributes"].(string) - if slug == "" || attrsStr == "" { - return textResult("error: slug and attributes are required"), nil - } - var attrs map[string]any - if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil { - return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil - } - attrsJSON, _ := json.Marshal(attrs) - ct, err := pool.Exec(ctx, ` - UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() - WHERE slug = $1`, slug, string(attrsJSON)) - if err != nil { - return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil - } - if ct.RowsAffected() == 0 { - return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil - } - return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil - }) - - register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.", - InputSchema: objSchema( - prop{"source", "string", "Source entity slug."}, - prop{"target", "string", "Target entity slug."}, - prop{"type", "string", "Relationship type name (must already exist in the ontology)."}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - source, _ := args["source"].(string) - target, _ := args["target"].(string) - relType, _ := args["type"].(string) - if source == "" || target == "" || relType == "" { - return textResult("error: source, target, and type are required"), nil - } - var sourceID, targetID uuid.UUID - if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil { - return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil - } - if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil { - return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil - } - _, err := pool.Exec(ctx, ` - INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now() - WHERE NOT EXISTS ( - SELECT 1 FROM relationships - WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`, - sourceID, targetID, relType) - if err != nil { - return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil - } - return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil - }) - - register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics", - InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - hours := int(getFloat(args, "hours", 24)) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT time_bucket('1 hour', ts) AS bucket, - entity_id::text, metric, - ROUND(avg(value)::numeric, 2) AS avg, - ROUND(min(value)::numeric, 2) AS min, - ROUND(max(value)::numeric, 2) AS max - FROM metric_samples - WHERE ts > now() - make_interval(hours => $1) - GROUP BY bucket, entity_id, metric - ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil - }) - - // ─── Phase 4: new tools ────────────────────────────────────────── - - register(&mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals", - InputSchema: objSchema( - prop{"entity_slug", "string", "Filter by target entity slug"}, - prop{"state", "string", "Filter by signal state (raised, resolved)"}, - prop{"limit", "integer", "Max rows (default 50)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - limit := int(getFloat(args, "limit", 50)) - return queryRows(ctx, pool, ` - SELECT s.entity_id::text, s.kind, s.severity, s.state, - s.occurrence_count, e.slug AS target_slug, - s.first_seen_at, s.last_seen_at - FROM signals s - LEFT JOIN entities e ON e.id = s.target_entity_id - WHERE ($1::text IS NULL OR e.slug = $1) - AND ($2::text IS NULL OR s.state = $2) - ORDER BY s.last_seen_at DESC LIMIT $3`, - nStr(args["entity_slug"]), nStr(args["state"]), limit), nil - }) - - register(&mcp.Tool{Name: "get_patterns", Description: "List learned action patterns", - InputSchema: objSchema( - prop{"status", "string", "Filter by status (hypothesized, validated, active)"}, - prop{"entity_type", "string", "Filter by applies_type"}, - prop{"action", "string", "Filter by action"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - return queryRows(ctx, pool, ` - SELECT p.entity_id::text, p.applies_type, p.action, p.pattern, - p.confidence, p.evidence_count, p.success_count, p.failure_count, - p.status, p.quarantined, p.version, p.last_validated_at - FROM patterns p - WHERE ($1::text IS NULL OR p.status = $1) - AND ($2::text IS NULL OR p.applies_type = $2) - AND ($3::text IS NULL OR p.action = $3) - ORDER BY p.applies_type, p.action`, - nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil - }) - - register(&mcp.Tool{Name: "get_skills", Description: "List available automation skills", - InputSchema: objSchema( - prop{"status", "string", "Filter by status (active, inactive, deprecated)"}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - return queryRows(ctx, pool, ` - SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview, - s.applies_type, s.action, s.status, s.success_rate, - s.changed_by::text, s.change_reason, s.last_used_at - FROM skills s - WHERE ($1::text IS NULL OR s.status = $1) - ORDER BY s.name, s.version DESC`, - nStr(args["status"])), nil - }) - - // ── request_execution (legacy fixed enum) retired 2026-07-14 ── - // All mutations now route through `run`. The handler functions - // (runRexecRestart, runRexecSystemctl, etc.) are kept as reference - // for future runbook extraction — especially pct_create DNS/VMID logic. - // DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md. - - register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.", - InputSchema: objSchema( - prop{"target", "string", "Target entity slug: host: (e.g. host:strong) or lxc: (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."}, - prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."}, - prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."}, - prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - targetSlug, _ := args["target"].(string) - command, _ := args["command"].(string) - purpose, _ := args["purpose"].(string) - declaredRisk, _ := args["declared_risk"].(string) - sessionID, _ := args["_session_id"].(string) - if targetSlug == "" || command == "" { - return textResult("error: target and command are required"), nil - } - - var targetID uuid.UUID - if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { - return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil - } - - return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil - }) - - register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.", - InputSchema: objSchema( - prop{"url", "string", "Absolute http(s) URL to fetch"}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - rawURL, _ := args["url"].(string) - return httpGet(ctx, rawURL), nil - }) - - register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution", - InputSchema: objSchema( - prop{"execution_id", "string", "Execution UUID (from request_execution output)"}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - execID, _ := args["execution_id"].(string) - if execID == "" { - return textResult("execution_id required"), nil - } - eid, err := uuid.Parse(execID) - if err != nil { - // Try finding by exec slug prefix - var found uuid.UUID - err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found) - if err2 != nil { - return textResult(fmt.Sprintf("execution not found: %s", execID)), nil - } - eid = found - } - return queryRows(ctx, pool, ` - SELECT e.entity_id::text, e.action, e.risk_class, e.status, - e.result::text, e.duration_ms, e.started_at::text, - e.completed_at::text, e.correlation_id - FROM executions e - WHERE e.entity_id = $1`, eid), nil - }) - - register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity", - InputSchema: objSchema( - prop{"entity_id", "string", "Entity slug"}, - prop{"days", "integer", "Look-back window in days (default 7)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["entity_id"].(string) - days := int(getFloat(args, "days", 7)) - return queryRows(ctx, pool, ` - SELECT metric, - ROUND(avg(value)::numeric, 2) AS avg_val, - ROUND(stddev(value)::numeric, 2) AS std_val, - count(*) AS sample_count, - ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope - FROM metric_samples ms - JOIN entities e ON e.id = ms.entity_id - WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2) - GROUP BY metric - ORDER BY metric`, slug, days), nil - }) - - register(&mcp.Tool{Name: "get_event_timeline", Description: "Get recent events", - InputSchema: objSchema( - prop{"severity", "string", "Filter by severity (info, warn, error)"}, - prop{"entity_slug", "string", "Filter by entity slug"}, - prop{"limit", "integer", "Max rows (default 50)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - limit := int(getFloat(args, "limit", 50)) - return queryRows(ctx, pool, ` - SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug, - ev.data::text AS message, ev.correlation_id - FROM events ev - LEFT JOIN entities e ON e.id = ev.entity_id - WHERE ($1::text IS NULL OR ev.severity = $1) - AND ($2::text IS NULL OR e.slug = $2) - ORDER BY ev.ts DESC LIMIT $3`, - nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil - }) - - register(&mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log", - InputSchema: objSchema( - prop{"limit", "integer", "Max rows (default 50)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - limit := int(getFloat(args, "limit", 50)) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, - entity_id::text, left(input_summary, 200) AS input_summary, - left(output_summary, 200) AS output_summary, - duration_ms, token_count, success, correlation_id - FROM agent_activity - WHERE agent_id = $1 - ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil - }) - - // ─── Phase 5: operational MCP tools ────────────────────────────── - - register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.", - InputSchema: objSchema( - prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"}, - ), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - state, _ := argsMap(req)["state"].(string) - var statePtr *string - if state != "" { - statePtr = &state - } - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, - e.attributes->>'lan_ip' AS lan_ip, - e.state, - st.health, st.last_check_at, - (SELECT MAX(k.created_at) - FROM relationships r - JOIN knowledge_entities k ON k.entity_id = r.source_id - WHERE r.target_id = e.id - AND r.type = 'about' - AND r.valid_to IS NULL - AND (k.tags @> ARRAY['audit']::text[] - OR k.tags @> ARRAY['update']::text[] - OR k.title ILIKE '%audit%' - OR k.title ILIKE '%update%') - ) AS last_audited_at - FROM entities e - LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.type = 'lxc' - AND ($1::text IS NULL OR e.state = $1) - ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END, - (e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil - }) - - register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", - InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["service_slug"].(string) - if slug == "" { - return textResult("service_slug is required"), nil - } - rows, err := pool.Query(ctx, ` - SELECT st.health, st.last_check_at, e.attributes->>'url' AS url - FROM entity_status st - JOIN entities e ON e.id = st.entity_id - WHERE e.slug = $1`, slug) - if err != nil { - return textResult(fmt.Sprintf("query error: %v", err)), nil - } - defer rows.Close() - if !rows.Next() { - return textResult(fmt.Sprintf("service not found: %s", slug)), nil - } - var health, lastCheck, url string - rows.Scan(&health, &lastCheck, &url) - if url == "" { - url = "(no URL in entity attributes)" - } - return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil - }) - - register(&mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl", - InputSchema: objSchema( - prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}, - prop{"lines", "integer", "Number of lines (default 50)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["service_slug"].(string) - n := int(getFloat(args, "lines", 50)) - if slug == "" { - return textResult("service_slug is required"), nil - } - host, user, err := resolveHost(ctx, pool, slug) - if err != nil { - return textResult(fmt.Sprintf("resolve host: %v", err)), nil - } - svc := strings.TrimPrefix(slug, "lxc:") - out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)) - if err != nil { - return textResult(fmt.Sprintf("ssh: %v", err)), nil - } - return textResult(out), nil - }) - - register(&mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host", - InputSchema: objSchema( - prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["service_slug"].(string) - if slug == "" { - return textResult("service_slug is required"), nil - } - host, user, err := resolveHost(ctx, pool, slug) - if err != nil { - return textResult(fmt.Sprintf("resolve host: %v", err)), nil - } - svc := strings.TrimPrefix(slug, "lxc:") - out, err := sshExec(ctx, host, user, - fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc)) - if err != nil { - return textResult(fmt.Sprintf("ssh: %v", err)), nil - } - return textResult(out), nil - }) - - register(&mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host", - InputSchema: objSchema( - prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["lxc_slug"].(string) - if slug == "" { - return textResult("lxc_slug is required"), nil - } - var pveID string - err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID) - if err != nil || pveID == "" { - return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil - } - // Resolve the Proxmox host — find the host that runs this LXC - var hostID uuid.UUID - err = pool.QueryRow(ctx, ` - SELECT t.id FROM entities t - JOIN relationships r ON r.source_id = t.id - JOIN entities s ON s.id = r.target_id - WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL - LIMIT 1`, slug).Scan(&hostID) - if err != nil { - // Fallback: use the inventory host attribute if no relationship - var hostSlug string - err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug) - if err != nil || hostSlug == "" { - return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil - } - var host, user string - host, user, err = resolveHost(ctx, pool, "host:"+hostSlug) - if err != nil { - return textResult(fmt.Sprintf("resolve: %v", err)), nil - } - out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) - if err2 != nil { - return textResult(fmt.Sprintf("ssh: %v", err2)), nil - } - return textResult(out), nil - } - var hostSlug string - pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug) - host, user, err := resolveHost(ctx, pool, hostSlug) - if err != nil { - return textResult(fmt.Sprintf("resolve host: %v", err)), nil - } - out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) - if err != nil { - return textResult(fmt.Sprintf("ssh: %v", err)), nil - } - return textResult(out), nil - }) - - // ─── Client introspection tools (plan: client-lifecycle Phase 3) ── - - register(&mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host", - InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - hostname, _ := args["hostname"].(string) - if hostname == "" { - return textResult("error: hostname required"), nil - } - slug := "ws:" + hostname - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT e.slug, e.type, e.name, e.state, - COALESCE(st.health, 'unknown') AS health, - COALESCE(st.last_check_at::text, '') AS last_check, - e.attributes->>'mesh_ip' AS mesh_ip, - e.attributes->>'age_pubkey' AS age_pubkey, - e.enrolled_at - FROM entities e - LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.slug = $1 - ORDER BY e.slug`, slug), "entity_card"), nil - }) - - register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", - InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["service_slug"].(string) - if slug == "" { - return textResult("error: service_slug required"), nil - } - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT e.slug, e.type, e.name, e.state, - COALESCE(st.health, 'unknown') AS health, - COALESCE(st.last_check_at::text, '') AS last_check, - e.version, e.updated_at, - COALESCE(e.attributes::text, '{}') AS attrs - FROM entities e - LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.slug = $1`, slug), "entity_card"), nil - }) - - register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", - InputSchema: objSchema( - prop{"service_slug", "string", "Entity slug"}, - prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["service_slug"].(string) - action, _ := args["action"].(string) - if slug == "" || action == "" { - return textResult("error: service_slug and action required"), nil - } - return queryRows(ctx, pool, ` - SELECT e.slug, e.type, e.state, - CASE - WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low' - WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation' - WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive' - ELSE 'read_only' - END AS risk_class, - CASE - WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act' - WHEN $2 = 'config_mutation' THEN 'operator-approval' - ELSE 'operator-approval+confirmation' - END AS approval - FROM entities e WHERE e.slug = $1`, slug, action), nil - }) - - register(&mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity", - InputSchema: objSchema( - prop{"entity_slug", "string", "Entity slug"}, - prop{"limit", "integer", "Max entries (default 20)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - slug, _ := args["entity_slug"].(string) - limit := int(getFloat(args, "limit", 20)) - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, - al.action, al.method, al.path, - al.detail::text AS details - FROM audit_log al - JOIN entities e ON e.id = al.entity_id - WHERE e.slug = $1 - ORDER BY al.ts DESC - LIMIT $2`, slug, limit), "change_log"), nil - }) - - register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", - InputSchema: objSchema(), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return annotateJSONResult(queryRows(ctx, pool, ` - SELECT e.slug, e.type, e.state, - COALESCE(st.health, 'unknown') AS health, - COALESCE(st.last_check_at::text, '') AS last_check - FROM entities e - LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.state IS NOT NULL - OR st.health IS NOT NULL - ORDER BY st.health, e.slug - LIMIT 200 - `), "fleet_snapshot"), nil - }) - - register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", - InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}), - }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - args := argsMap(req) - pubkey, _ := args["caller_pubkey"].(string) - // Match entities where age_pubkey attribute contains the caller's key. - query := ` - SELECT e.slug, e.type, e.name, - e.attributes->>'age_pubkey' AS age_pubkey - FROM entities e - WHERE e.attributes->>'age_pubkey' IS NOT NULL` - var dbArgs []any - if pubkey != "" { - query += ` AND e.attributes->>'age_pubkey' = $1` - dbArgs = append(dbArgs, pubkey) - } - query += ` ORDER BY e.slug LIMIT 100` - return queryRows(ctx, pool, query, dbArgs...), nil - }) - return s } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..cf7092b --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,724 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/dtoro/oikos/internal/db" + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// toolReg pairs a tool definition with its handler. allTools returns a slice +// of these; newServer iterates it and registers each one wrapped with +// withActivityLogging. +type toolReg struct { + tool *mcp.Tool + handler toolHandler +} + +// allTools returns every MCP tool registration. Tool definitions, schemas, +// descriptions, and handler bodies are kept verbatim from the former inline +// newServer registrations. +func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { + return []toolReg{ + {tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID", + InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + idOrSlug, _ := args["slug_or_id"].(string) + return queryEntity(ctx, pool, idOrSlug), nil + }}, + + {tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search", + InputSchema: objSchema( + prop{"type", "string", "Filter by entity type"}, + prop{"state", "string", "Filter by lifecycle state"}, + prop{"q", "string", "Substring match on slug or name"}, + prop{"limit", "integer", "Max rows (default 50)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 50)) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at + FROM entities e + WHERE ($1::text IS NULL OR e.type = $1) + AND ($2::text IS NULL OR e.state = $2) + AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') + ORDER BY e.slug LIMIT $4`, + nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil + }}, + + {tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", + InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_id"].(string) + return queryRows(ctx, pool, ` + SELECT r.type, src.slug AS source, tgt.slug AS target + FROM relationships r + JOIN entities src ON src.id = r.source_id + JOIN entities tgt ON tgt.id = r.target_id + WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL + ORDER BY r.type`, slug), nil + }}, + + {tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down", + InputSchema: objSchema( + prop{"entity_id", "string", "Entity slug"}, + prop{"depth", "integer", "Traversal depth (default 3)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_id"].(string) + depth := int(getFloat(args, "depth", 3)) + return queryRows(ctx, pool, + "SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id", + slug, depth), nil + }}, + + {tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, st.health, st.last_check_at + FROM entity_status st JOIN entities e ON e.id = st.entity_id + WHERE e.type <> 'check' + ORDER BY e.slug`), nil + }}, + + {tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log", + InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return queryRows(ctx, pool, ` + SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id + FROM audit_log + WHERE ($1::text IS NULL OR entity_id::text = $1) + ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil + }}, + + {tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.", + InputSchema: objSchema(prop{"query", "string", "Search terms"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + q := nStr(args["query"]) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT ke.title, e.slug, + ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, + ts_headline('english', ke.content, plainto_tsquery('english', $1), + 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, + FragmentDelimiter=" ... "') AS snippet, + ke.source, ke.tags + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE ke.search @@ plainto_tsquery('english', $1) + ORDER BY rank DESC + LIMIT 20`, q), "knowledge_results"), nil + }}, + + {tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.", + InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_slug"].(string) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT ke.title, ke.source, e.type AS kind, e.slug, + ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + JOIN relationships r ON r.source_id = ke.entity_id + JOIN entities target ON target.id = r.target_id + WHERE target.slug = $1 + AND r.valid_to IS NULL + AND r.type IN ('documents', 'about') + UNION + SELECT ke.title, ke.source, e.type AS kind, e.slug, + ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + JOIN relationships r ON r.source_id = ke.entity_id + JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1) + JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 + WHERE r.valid_to IS NULL + AND r.type = 'procedure-for' + ORDER BY 1`, slug), "knowledge_results"), nil + }}, + + {tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.", + InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["slug"].(string) + return queryRows(ctx, pool, ` + SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE e.slug = $1`, slug), nil + }}, + + {tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.", + InputSchema: objSchema( + prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."}, + prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."}, + prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."}, + prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."}, + prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return upsertKnowledge(ctx, pool, args) + }}, + + {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.", + InputSchema: objSchema( + prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."}, + prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["slug"].(string) + attrsStr, _ := args["attributes"].(string) + if slug == "" || attrsStr == "" { + return textResult("error: slug and attributes are required"), nil + } + var attrs map[string]any + if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil { + return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil + } + attrsJSON, _ := json.Marshal(attrs) + ct, err := pool.Exec(ctx, ` + UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() + WHERE slug = $1`, slug, string(attrsJSON)) + if err != nil { + return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil + } + if ct.RowsAffected() == 0 { + return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil + } + return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil + }}, + + {tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.", + InputSchema: objSchema( + prop{"source", "string", "Source entity slug."}, + prop{"target", "string", "Target entity slug."}, + prop{"type", "string", "Relationship type name (must already exist in the ontology)."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + source, _ := args["source"].(string) + target, _ := args["target"].(string) + relType, _ := args["type"].(string) + if source == "" || target == "" || relType == "" { + return textResult("error: source, target, and type are required"), nil + } + var sourceID, targetID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil { + return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil + } + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil { + return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil + } + _, err := pool.Exec(ctx, ` + INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now() + WHERE NOT EXISTS ( + SELECT 1 FROM relationships + WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`, + sourceID, targetID, relType) + if err != nil { + return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil + } + return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil + }}, + + {tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics", + InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + hours := int(getFloat(args, "hours", 24)) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT time_bucket('1 hour', ts) AS bucket, + entity_id::text, metric, + ROUND(avg(value)::numeric, 2) AS avg, + ROUND(min(value)::numeric, 2) AS min, + ROUND(max(value)::numeric, 2) AS max + FROM metric_samples + WHERE ts > now() - make_interval(hours => $1) + GROUP BY bucket, entity_id, metric + ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil + }}, + + // ─── Phase 4: new tools ────────────────────────────────────────── + + {tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals", + InputSchema: objSchema( + prop{"entity_slug", "string", "Filter by target entity slug"}, + prop{"state", "string", "Filter by signal state (raised, resolved)"}, + prop{"limit", "integer", "Max rows (default 50)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 50)) + return queryRows(ctx, pool, ` + SELECT s.entity_id::text, s.kind, s.severity, s.state, + s.occurrence_count, e.slug AS target_slug, + s.first_seen_at, s.last_seen_at + FROM signals s + LEFT JOIN entities e ON e.id = s.target_entity_id + WHERE ($1::text IS NULL OR e.slug = $1) + AND ($2::text IS NULL OR s.state = $2) + ORDER BY s.last_seen_at DESC LIMIT $3`, + nStr(args["entity_slug"]), nStr(args["state"]), limit), nil + }}, + + {tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns", + InputSchema: objSchema( + prop{"status", "string", "Filter by status (hypothesized, validated, active)"}, + prop{"entity_type", "string", "Filter by applies_type"}, + prop{"action", "string", "Filter by action"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return queryRows(ctx, pool, ` + SELECT p.entity_id::text, p.applies_type, p.action, p.pattern, + p.confidence, p.evidence_count, p.success_count, p.failure_count, + p.status, p.quarantined, p.version, p.last_validated_at + FROM patterns p + WHERE ($1::text IS NULL OR p.status = $1) + AND ($2::text IS NULL OR p.applies_type = $2) + AND ($3::text IS NULL OR p.action = $3) + ORDER BY p.applies_type, p.action`, + nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil + }}, + + {tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills", + InputSchema: objSchema( + prop{"status", "string", "Filter by status (active, inactive, deprecated)"}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return queryRows(ctx, pool, ` + SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview, + s.applies_type, s.action, s.status, s.success_rate, + s.changed_by::text, s.change_reason, s.last_used_at + FROM skills s + WHERE ($1::text IS NULL OR s.status = $1) + ORDER BY s.name, s.version DESC`, + nStr(args["status"])), nil + }}, + + // ── request_execution (legacy fixed enum) retired 2026-07-14 ── + // All mutations now route through `run`. The handler functions + // (runRexecRestart, runRexecSystemctl, etc.) are kept as reference + // for future runbook extraction — especially pct_create DNS/VMID logic. + // DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md. + + {tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.", + InputSchema: objSchema( + prop{"target", "string", "Target entity slug: host: (e.g. host:strong) or lxc: (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."}, + prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."}, + prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."}, + prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + targetSlug, _ := args["target"].(string) + command, _ := args["command"].(string) + purpose, _ := args["purpose"].(string) + declaredRisk, _ := args["declared_risk"].(string) + sessionID, _ := args["_session_id"].(string) + if targetSlug == "" || command == "" { + return textResult("error: target and command are required"), nil + } + + var targetID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { + return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil + } + + return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil + }}, + + {tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.", + InputSchema: objSchema( + prop{"url", "string", "Absolute http(s) URL to fetch"}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + rawURL, _ := args["url"].(string) + return httpGet(ctx, rawURL), nil + }}, + + {tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution", + InputSchema: objSchema( + prop{"execution_id", "string", "Execution UUID (from request_execution output)"}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + execID, _ := args["execution_id"].(string) + if execID == "" { + return textResult("execution_id required"), nil + } + eid, err := uuid.Parse(execID) + if err != nil { + // Try finding by exec slug prefix + var found uuid.UUID + err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found) + if err2 != nil { + return textResult(fmt.Sprintf("execution not found: %s", execID)), nil + } + eid = found + } + return queryRows(ctx, pool, ` + SELECT e.entity_id::text, e.action, e.risk_class, e.status, + e.result::text, e.duration_ms, e.started_at::text, + e.completed_at::text, e.correlation_id + FROM executions e + WHERE e.entity_id = $1`, eid), nil + }}, + + {tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity", + InputSchema: objSchema( + prop{"entity_id", "string", "Entity slug"}, + prop{"days", "integer", "Look-back window in days (default 7)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_id"].(string) + days := int(getFloat(args, "days", 7)) + return queryRows(ctx, pool, ` + SELECT metric, + ROUND(avg(value)::numeric, 2) AS avg_val, + ROUND(stddev(value)::numeric, 2) AS std_val, + count(*) AS sample_count, + ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope + FROM metric_samples ms + JOIN entities e ON e.id = ms.entity_id + WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2) + GROUP BY metric + ORDER BY metric`, slug, days), nil + }}, + + {tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events", + InputSchema: objSchema( + prop{"severity", "string", "Filter by severity (info, warn, error)"}, + prop{"entity_slug", "string", "Filter by entity slug"}, + prop{"limit", "integer", "Max rows (default 50)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 50)) + return queryRows(ctx, pool, ` + SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug, + ev.data::text AS message, ev.correlation_id + FROM events ev + LEFT JOIN entities e ON e.id = ev.entity_id + WHERE ($1::text IS NULL OR ev.severity = $1) + AND ($2::text IS NULL OR e.slug = $2) + ORDER BY ev.ts DESC LIMIT $3`, + nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil + }}, + + {tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log", + InputSchema: objSchema( + prop{"limit", "integer", "Max rows (default 50)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 50)) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, + entity_id::text, left(input_summary, 200) AS input_summary, + left(output_summary, 200) AS output_summary, + duration_ms, token_count, success, correlation_id + FROM agent_activity + WHERE agent_id = $1 + ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil + }}, + + // ─── Phase 5: operational MCP tools ────────────────────────────── + + {tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.", + InputSchema: objSchema( + prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + state, _ := argsMap(req)["state"].(string) + var statePtr *string + if state != "" { + statePtr = &state + } + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, + e.attributes->>'lan_ip' AS lan_ip, + e.state, + st.health, st.last_check_at, + (SELECT MAX(k.created_at) + FROM relationships r + JOIN knowledge_entities k ON k.entity_id = r.source_id + WHERE r.target_id = e.id + AND r.type = 'about' + AND r.valid_to IS NULL + AND (k.tags @> ARRAY['audit']::text[] + OR k.tags @> ARRAY['update']::text[] + OR k.title ILIKE '%audit%' + OR k.title ILIKE '%update%') + ) AS last_audited_at + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.type = 'lxc' + AND ($1::text IS NULL OR e.state = $1) + ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END, + (e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil + }}, + + {tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", + InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("service_slug is required"), nil + } + rows, err := pool.Query(ctx, ` + SELECT st.health, st.last_check_at, e.attributes->>'url' AS url + FROM entity_status st + JOIN entities e ON e.id = st.entity_id + WHERE e.slug = $1`, slug) + if err != nil { + return textResult(fmt.Sprintf("query error: %v", err)), nil + } + defer rows.Close() + if !rows.Next() { + return textResult(fmt.Sprintf("service not found: %s", slug)), nil + } + var health, lastCheck, url string + rows.Scan(&health, &lastCheck, &url) + if url == "" { + url = "(no URL in entity attributes)" + } + return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil + }}, + + {tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl", + InputSchema: objSchema( + prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}, + prop{"lines", "integer", "Number of lines (default 50)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + n := int(getFloat(args, "lines", 50)) + if slug == "" { + return textResult("service_slug is required"), nil + } + host, user, err := resolveHost(ctx, pool, slug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + svc := strings.TrimPrefix(slug, "lxc:") + out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }}, + + {tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host", + InputSchema: objSchema( + prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("service_slug is required"), nil + } + host, user, err := resolveHost(ctx, pool, slug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + svc := strings.TrimPrefix(slug, "lxc:") + out, err := sshExec(ctx, host, user, + fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }}, + + {tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host", + InputSchema: objSchema( + prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["lxc_slug"].(string) + if slug == "" { + return textResult("lxc_slug is required"), nil + } + var pveID string + err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID) + if err != nil || pveID == "" { + return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil + } + // Resolve the Proxmox host — find the host that runs this LXC + var hostID uuid.UUID + err = pool.QueryRow(ctx, ` + SELECT t.id FROM entities t + JOIN relationships r ON r.source_id = t.id + JOIN entities s ON s.id = r.target_id + WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL + LIMIT 1`, slug).Scan(&hostID) + if err != nil { + // Fallback: use the inventory host attribute if no relationship + var hostSlug string + err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug) + if err != nil || hostSlug == "" { + return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil + } + var host, user string + host, user, err = resolveHost(ctx, pool, "host:"+hostSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve: %v", err)), nil + } + out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) + if err2 != nil { + return textResult(fmt.Sprintf("ssh: %v", err2)), nil + } + return textResult(out), nil + } + var hostSlug string + pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug) + host, user, err := resolveHost(ctx, pool, hostSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }}, + + // ─── Client introspection tools (plan: client-lifecycle Phase 3) ── + + {tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host", + InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + hostname, _ := args["hostname"].(string) + if hostname == "" { + return textResult("error: hostname required"), nil + } + slug := "ws:" + hostname + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check, + e.attributes->>'mesh_ip' AS mesh_ip, + e.attributes->>'age_pubkey' AS age_pubkey, + e.enrolled_at + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.slug = $1 + ORDER BY e.slug`, slug), "entity_card"), nil + }}, + + {tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", + InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("error: service_slug required"), nil + } + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check, + e.version, e.updated_at, + COALESCE(e.attributes::text, '{}') AS attrs + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.slug = $1`, slug), "entity_card"), nil + }}, + + {tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", + InputSchema: objSchema( + prop{"service_slug", "string", "Entity slug"}, + prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + action, _ := args["action"].(string) + if slug == "" || action == "" { + return textResult("error: service_slug and action required"), nil + } + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.state, + CASE + WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low' + WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation' + WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive' + ELSE 'read_only' + END AS risk_class, + CASE + WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act' + WHEN $2 = 'config_mutation' THEN 'operator-approval' + ELSE 'operator-approval+confirmation' + END AS approval + FROM entities e WHERE e.slug = $1`, slug, action), nil + }}, + + {tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity", + InputSchema: objSchema( + prop{"entity_slug", "string", "Entity slug"}, + prop{"limit", "integer", "Max entries (default 20)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_slug"].(string) + limit := int(getFloat(args, "limit", 20)) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, + al.action, al.method, al.path, + al.detail::text AS details + FROM audit_log al + JOIN entities e ON e.id = al.entity_id + WHERE e.slug = $1 + ORDER BY al.ts DESC + LIMIT $2`, slug, limit), "change_log"), nil + }}, + + {tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.state, + COALESCE(st.health, 'unknown') AS health, + COALESCE(st.last_check_at::text, '') AS last_check + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.state IS NOT NULL + OR st.health IS NOT NULL + ORDER BY st.health, e.slug + LIMIT 200 + `), "fleet_snapshot"), nil + }}, + + {tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", + InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + pubkey, _ := args["caller_pubkey"].(string) + // Match entities where age_pubkey attribute contains the caller's key. + query := ` + SELECT e.slug, e.type, e.name, + e.attributes->>'age_pubkey' AS age_pubkey + FROM entities e + WHERE e.attributes->>'age_pubkey' IS NOT NULL` + var dbArgs []any + if pubkey != "" { + query += ` AND e.attributes->>'age_pubkey' = $1` + dbArgs = append(dbArgs, pubkey) + } + query += ` ORDER BY e.slug LIMIT 100` + return queryRows(ctx, pool, query, dbArgs...), nil + }}, + } +} diff --git a/plans/2026-07-17-codebase-review-and-cleanup.md b/plans/2026-07-17-codebase-review-and-cleanup.md index 9030d14..ceb7483 100644 --- a/plans/2026-07-17-codebase-review-and-cleanup.md +++ b/plans/2026-07-17-codebase-review-and-cleanup.md @@ -388,14 +388,20 @@ qualified as `archive/knowledge/` history. installing golangci-lint + staticcheck + govulncheck** in CI (none are installed locally; CI config at `.gitea/workflows/ci.yml` should be checked). -### E.2 Desktop version hardcode — NOT fixed (behavior change) +### E.2 Desktop version hardcode — fixed in R6 -`cmd/desktop/main.go:39` `version = "0.1.0"` while repo is `0.7.6`. Per -`CONTRIBUTING.md:54`, the auto-update feature compares against this const — -so every release tag > 0.1.0 triggers a spurious update prompt, or the -comparison is meaningless. **Fix: inject from `VERSION` at link time** (e.g. -`-ldflags "-X main.version=$(cat VERSION)"`). Deferred — touches auto-update -behavior; tracked as R6. +`cmd/desktop/main.go:39` had `version = "0.1.0"` as a const while the repo is at `0.7.8`. Per +`CONTRIBUTING.md:54`, the auto-update feature compares against this value — so every release tag > +0.1.0 triggered a spurious update prompt. **Fixed:** `version` is now a `var` (default +`"0.1.0-dev"` fallback for bare `go build`), injected from the `VERSION` file at link time via +`make desktop` (`-ldflags "-X main.version=$(cat VERSION)"`). `CONTRIBUTING.md` updated to match. + +### E.3 `Makefile` `BINARY` collision — fixed in R6 + +`BINARY := oikos` wrote to `./oikos`, which collided with the `oikos/` directory (Go's `-o oikos` +into a directory named `oikos` created `oikos/oikos`). **Fixed:** `BINARY := bin/oikos` (matches +the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `bin/`. Stale +`oikos/` cruft directory removed. ## F. Recommendations (actionable, ordered) @@ -404,9 +410,9 @@ behavior; tracked as R6. | R1 | Delete dead Go: `notifier.VerifyApprovalToken`, `httpapi/stubs.go`; unexport 4 `checkdefaults` symbols | S | Low | ✅ done (c3973e7) | | R2 | Delete dead web: 21-file tool-renderer registry, 5 dead components, 2 dead store exports, 2 dead npm deps | S | Low | ✅ done (c3973e7+1) | | R3 | Decide sqlc vs raw SQL: delete 17 dead queries OR migrate inline SQL to use them | M | Medium | ✅ done (hybrid: 8 deleted, 9 migrated) | -| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | +| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | ✅ done | | R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low | ✅ done | -| R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | +| R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | ✅ done | | R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | | R8 | Add `eslint`+`prettier`+`vitest` to `web/`; wire `svelte-check`+`tsc` into CI; add `web/` CI job | M | Low | | R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low |