Root cause of "running for 10+ minutes without stopping": a real production execution (TypeType pct_create) was found genuinely stuck 17+ minutes into a single blocking SSH call. The container's post_install script was looping on `getent hosts deb.debian.org`, waiting on a network that could never come up — the operator's static IP config used gw:192.168.8.1, but the actual gateway on that subnet is 192.168.8.2, so every network call hung instead of failing fast (packets dropped, not rejected). Two compounding bugs made this unrecoverable without manual intervention: 1. sshExec (both internal/httpapi/phase3.go and internal/mcp/server.go) had NO execution timeout — `session.CombinedOutput()` blocks until the remote command exits, with no deadline. A hung remote process blocks the Go goroutine forever; the execution can never leave 'running', and the operator has no way to make it stop. Fixed: both now race the SSH call against a 10-minute hard timeout, closing the session/client and returning a clear "timed out after 10m0s" error if exceeded. (The mcp/server.go copy also still had the original "swallowed non-zero exit" bug from before that fix was applied to httpapi's copy only — fixed here too.) 2. provisionScript's DNS-wait loop assumed `getent hosts` fails fast on no connectivity — it doesn't; a black-holed network can make each call hang far past the resolver's nominal timeout, so the documented "~90s" budget was never real. Wrapped every attempt in `timeout 3` so the wall-clock budget is now actually enforced (~2min worst case), and the failure message now suggests checking the net0 gateway. Also fixes the matching UI-side gap (operator's literal question: "is there a way to get more details? it has been running for 10+ minutes without stopping"): - InlineApproval's track() polling loop had its own ~6min ceiling and simply STOPPED polling after that — silently going stale before the backend (now correctly capped at 10min) could ever resolve. Raised to a 14min ceiling with margin, and added a distinct 'stalled' state if that's ever exceeded (explicitly says something's wrong, rather than freezing silently). - The running-card now shows live elapsed time (ticking, from the execution's created_at), the actual command being run, and the execution ID — previously just a static "this can take a minute" with zero information. Also added command display to the destructive pending- approval card for full transparency before confirming. Verified live end-to-end in a real browser (dev server proxying to production): queued a real command via chat, approved via the button, watched the elapsed-time counter tick in real time, and saw it transition to a completed card with real output once the command finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2570 lines
81 KiB
Go
2570 lines
81 KiB
Go
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/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() {
|
|
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 <pve_id>` 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:<slug> or lxc:<slug>", 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)
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
Services []string `json:"services"` // apt packages to install after create
|
|
PostInstall string `json:"post_install"` // shell run inside the container after create
|
|
}
|
|
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, ","))
|
|
}
|
|
|
|
// 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=vmbr0,"
|
|
if cfg.IP == "" || strings.EqualFold(cfg.IP, "dhcp") {
|
|
net0 += "ip=dhcp"
|
|
} else {
|
|
net0 += "ip=" + cfg.IP
|
|
if cfg.GW != "" {
|
|
net0 += ",gw=" + cfg.GW
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// Post-create provisioning: install apt packages and run a post_install
|
|
// script inside the fresh container, so a single approved pct_create
|
|
// yields a *working service*, not just an empty container. The script
|
|
// waits for real DNS/connectivity and self-heals the resolver first —
|
|
// a static-IP container with a dead nameserver otherwise fails apt with
|
|
// "Temporary failure resolving deb.debian.org" and installs nothing.
|
|
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
|
|
script := provisionScript(sanitizePkgs(cfg.Services), cfg.PostInstall)
|
|
b64 := base64.StdEncoding.EncodeToString([]byte(script))
|
|
// sleep on the host so the container is up enough to accept pct exec.
|
|
cmd := fmt.Sprintf("sleep 4; pct exec %d -- bash -c 'echo %s | base64 -d | bash'", cfg.VMID, b64)
|
|
var provOut string
|
|
provOut, err = sshExec(ctx, host, user, cmd)
|
|
output = output + "\n--- post-install ---\n" + provOut
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// provisionScript builds the in-container bootstrap run after pct create. It
|
|
// (1) waits for DNS/connectivity and self-heals /etc/resolv.conf with a public
|
|
// resolver if the configured nameserver is dead, (2) installs apt packages with
|
|
// retries, (3) runs the operator's post_install. `set -e` after the network
|
|
// gate means any apt or post_install failure exits non-zero, so sshExec surfaces
|
|
// it and the execution is marked failed with the exact broken step in output.
|
|
func provisionScript(pkgs []string, postInstall string) string {
|
|
var b strings.Builder
|
|
b.WriteString("set -o pipefail\n")
|
|
// A fresh debian LXC has no locale set, which spams "Can't set locale"
|
|
// warnings and breaks some package post-install scripts. Pin C.UTF-8.
|
|
b.WriteString("export LANG=C.UTF-8 LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive\n")
|
|
b.WriteString("probe=deb.debian.org\n")
|
|
b.WriteString("ok=0\n")
|
|
// `timeout 3` on every getent call is load-bearing, not cosmetic: when
|
|
// the network is truly unreachable (e.g. a wrong gateway), a plain
|
|
// `getent hosts` doesn't fail fast — it can hang far longer than the
|
|
// resolver's nominal timeout because packets are just dropped, not
|
|
// rejected. Without a hard per-attempt cap, this loop's "~90s" budget
|
|
// was fiction — one run hung 17+ minutes on a bad gateway before the Go
|
|
// side finally got a hard sshExec timeout to fall back on. Capping each
|
|
// attempt makes the wall-clock budget real.
|
|
b.WriteString("for i in $(seq 1 30); do if timeout 3 getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done\n")
|
|
// Self-heal: if the assigned resolver can't resolve, fall back to public DNS.
|
|
b.WriteString("if [ \"$ok\" != 1 ]; then printf 'nameserver 1.1.1.1\\nnameserver 8.8.8.8\\n' > /etc/resolv.conf; ")
|
|
b.WriteString("for i in $(seq 1 15); do if timeout 3 getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done; fi\n")
|
|
b.WriteString("if [ \"$ok\" != 1 ]; then echo 'ERROR: container has no DNS/connectivity after ~2min — check the LXC net0 gateway/IP are correct for this subnet'; exit 1; fi\n")
|
|
b.WriteString("set -e\n")
|
|
if len(pkgs) > 0 {
|
|
b.WriteString("export DEBIAN_FRONTEND=noninteractive\n")
|
|
b.WriteString("apt-get update -o Acquire::Retries=3 -qq\n")
|
|
b.WriteString("apt-get install -y -o Acquire::Retries=3 --no-install-recommends -qq " + strings.Join(pkgs, " ") + "\n")
|
|
}
|
|
if strings.TrimSpace(postInstall) != "" {
|
|
b.WriteString("# --- operator post_install ---\n")
|
|
b.WriteString(postInstall)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
|
|
// sanitizePkgs drops anything that isn't a plausible apt package token, so a
|
|
// hallucinated package list can't inject shell into the install command.
|
|
func sanitizePkgs(pkgs []string) []string {
|
|
out := make([]string, 0, len(pkgs))
|
|
for _, p := range pkgs {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
ok := true
|
|
for _, r := range p {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '+') {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if ok {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ─── 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 string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT e.entity_id, e.target_entity_id, e.action
|
|
FROM executions e
|
|
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
|
|
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr)
|
|
if err == nil {
|
|
// Resolve target entity slug from targetID.
|
|
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
|
|
|
go 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)
|
|
|
|
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 := tx.Exec(ctx, `
|
|
UPDATE relationships
|
|
SET valid_to = now()
|
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`,
|
|
sourceID, targetID, req.Params.RelType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.RowsAffected() == 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[:])
|
|
}
|