Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together, since the right granularity for B1 in the auto-continuation worker turned out to require B2's restructuring anyway (see below). B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned nothing before this — every explicitly-spawned goroutine (continuation worker, resumed chat turns, async execution dispatch, the SSE listener, two duplicate sshExec implementations' output-collector goroutines) crashed the whole process on an unhandled panic, not just that one goroutine. More consequential post-concurrency: more simultaneous unattended background work means more surface area for one bad input to end every running task. New internal/safego package: Go(label, fn) launches fn in a goroutine with a recover-and-log wrapper. Applied at every bare `go` spawn site across the three packages. Two sites needed bespoke handling instead of the generic helper because their callers block on a channel and a silent recover would just make them hang until timeout: sshExec's output-collector goroutine (two near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go) and httpapi's ListenAndServe goroutine — both now recover AND send a synthetic error result so the waiting select unblocks immediately instead of waiting out the full timeout. httpapi's sseListener got extra treatment: its per-notification handling was extracted into handleNotification with its own recover, so a panic decoding ONE malformed pg_notify payload can't kill the listener goroutine for every connected SSE client — the outer goroutine spawn only needs to guard the connection setup/reconnect code around it. B2: cmd/nomos/continue.go's processContinuations used to run every pending continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the ticker — meaning (a) task B's continuation waited for task A's full (up to 10-minute) resumed turn to finish first, undercutting this session's earlier concurrency work on exactly the path autonomous tasks depend on most, and (b) an unrecovered panic anywhere in that call chain didn't just crash the process (B1) — even WITH B1's recovery wrapped only at the top-level worker spawn, the panic would still unwind the ENTIRE ticker-loop goroutine, silently ending auto-continuation for every task until nomos restarted. Fixed by spawning each pending item via safego.Go individually: real parallelism, and a bad item can now only ever take down its own goroutine. Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete proof — a deliberate panic inside Go() that would otherwise crash the whole test binary; reaching the assertion after it IS the evidence recovery works. Verified live against the rebuilt containers: full chat turn round-tripped correctly (hostname lookup, 2 iterations, normal completion) — no regression from threading safego.Go through the tool-dispatch/continuation paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2628 lines
85 KiB
Go
2628 lines
85 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/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 <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)
|
|
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:<hostname>, 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 <bridge>`) matters and was found
|
|
// live: a plain unqualified `ping <gw>` 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:<hostname> 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 := 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[:])
|
|
}
|