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[:]) }