- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
full decide transaction: HMAC token verify, approval flip, execution un-gate,
session-scoped window keys (+session suffix matching GovernanceStore gate),
nomos session flip, audit+event on failure abort. httpapi DecideApproval now
a thin presenter delegating to the service. ListPending payload format fixed
(json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
/ ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
fixed (defer/recover per execution), correlation_id preserved via Finalize
event emission (ExecRunRepo.Finalize now emits execution.{status} with
correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
341 lines
13 KiB
Go
341 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// GovernanceRepo implements ports.GovernanceStore over the postgres
|
|
// pool. The SQL is lifted from the MCP classifyAndGate path (Phase 4
|
|
// convergence); fail-open/fail-closed semantics per gate are preserved
|
|
// and documented on the port.
|
|
type GovernanceRepo struct {
|
|
pool *Pool
|
|
}
|
|
|
|
var _ ports.GovernanceStore = (*GovernanceRepo)(nil)
|
|
|
|
// NewGovernanceRepo builds the governance read model.
|
|
func NewGovernanceRepo(pool *Pool) *GovernanceRepo { return &GovernanceRepo{pool: pool} }
|
|
|
|
// SessionHasPlan fails open (true) on query error — a transient DB issue
|
|
// must not block an otherwise-valid run.
|
|
func (g *GovernanceRepo) SessionHasPlan(ctx context.Context, sessionID string) bool {
|
|
if sessionID == "" {
|
|
return true // no session → no gate (direct MCP call from a script)
|
|
}
|
|
var count int
|
|
if err := g.pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM session_plan_steps
|
|
WHERE session_id = $1 AND status <> 'replaced'`,
|
|
sessionID).Scan(&count); err != nil {
|
|
return true
|
|
}
|
|
return count > 0
|
|
}
|
|
|
|
// AssentWindowActive checks the session-scoped assent window key set by
|
|
// chat assent and the approval-decide path.
|
|
func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool {
|
|
if agentID == "" || sessionID == "" {
|
|
return false // fail closed
|
|
}
|
|
return g.windowActive(ctx, "assent_window.agent:"+string(agentID)+".session:"+sessionID)
|
|
}
|
|
|
|
// DestructiveWindowActive checks the target+session-scoped destructive
|
|
// window key. Key format must match cmd/nomos/store.go's
|
|
// openDestructiveWindow — both processes read/write the same rows.
|
|
func (g *GovernanceRepo) DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool {
|
|
if agentID == "" || targetSlug == "" || sessionID == "" {
|
|
return false // fail closed
|
|
}
|
|
return g.windowActive(ctx,
|
|
"destructive_window.agent:"+string(agentID)+".target:"+targetSlug+".session:"+sessionID)
|
|
}
|
|
|
|
func (g *GovernanceRepo) windowActive(ctx context.Context, key string) bool {
|
|
var expiresStr string
|
|
err := g.pool.QueryRow(ctx,
|
|
"SELECT value FROM autonomy_settings WHERE key = $1", key).Scan(&expiresStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
expires, err := time.Parse(time.RFC3339, expiresStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return time.Now().UTC().Before(expires)
|
|
}
|
|
|
|
// PendingApprovalCount returns the session's executions at pending_approval.
|
|
func (g *GovernanceRepo) PendingApprovalCount(ctx context.Context, sessionID string) int {
|
|
var n int
|
|
if err := g.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM nomos_plan_executions pe
|
|
JOIN executions ex ON ex.entity_id = pe.execution_id
|
|
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
|
|
sessionID).Scan(&n); err != nil {
|
|
return 0 // fail open for the flood gate: don't block on a flake
|
|
}
|
|
return n
|
|
}
|
|
|
|
// PendingDuplicate returns the newest identical pending execution on the
|
|
// target, if any.
|
|
func (g *GovernanceRepo) PendingDuplicate(ctx context.Context, targetID domain.UUID, action string) (domain.UUID, bool) {
|
|
var existingID string
|
|
err := g.pool.QueryRow(ctx, `
|
|
SELECT e.id::text FROM entities e
|
|
JOIN executions ex ON ex.entity_id = e.id
|
|
WHERE e.type = 'execution' AND ex.target_entity_id = $1
|
|
AND ex.action = $2 AND ex.status = 'pending_approval'
|
|
ORDER BY e.created_at DESC LIMIT 1`,
|
|
mustUUID(targetID), action).Scan(&existingID)
|
|
if err != nil || existingID == "" {
|
|
return "", false
|
|
}
|
|
return domain.UUID(existingID), true
|
|
}
|
|
|
|
// EntityAttributes loads one entity's attributes map.
|
|
func (g *GovernanceRepo) EntityAttributes(ctx context.Context, targetID domain.UUID) (map[string]any, error) {
|
|
var rawAttrs []byte
|
|
if err := g.pool.QueryRow(ctx,
|
|
`SELECT attributes FROM entities WHERE id = $1`, mustUUID(targetID)).Scan(&rawAttrs); err != nil {
|
|
return nil, err
|
|
}
|
|
var attrs map[string]any
|
|
if err := json.Unmarshal(rawAttrs, &attrs); err != nil {
|
|
return nil, err
|
|
}
|
|
return attrs, nil
|
|
}
|
|
|
|
// ResolveProxmoxHostSlug resolves the Proxmox host owning a guest slug
|
|
// (refusal-hint input for host-only commands). Removed — importing
|
|
// internal/remote from the postgres adapter forms an import cycle; the
|
|
// hint is wired at composition time via PolicyService.HostHint instead.
|
|
|
|
// ExecRunRepo implements ports.ExecutionRecorder over the postgres
|
|
// pool: the write half of the run-submission lifecycle, lifted from the
|
|
// MCP classifyAndGate / autoRun / createApproval /
|
|
// markSessionAwaitingApproval paths (Phase 4 convergence).
|
|
type ExecRunRepo struct {
|
|
pool *Pool
|
|
}
|
|
|
|
var _ ports.ExecutionRecorder = (*ExecRunRepo)(nil)
|
|
|
|
// NewExecRunRepo builds the recorder.
|
|
func NewExecRunRepo(pool *Pool) *ExecRunRepo { return &ExecRunRepo{pool: pool} }
|
|
|
|
// CreateRun writes the execution entity + row, graph edges, session
|
|
// link, classification row, and audit entry.
|
|
func (r *ExecRunRepo) CreateRun(ctx context.Context, in ports.CreateRunInput) error {
|
|
id := mustUUID(in.ExecutionID)
|
|
execName := "run on " + in.TargetSlug + " (" + id.String() + ")"
|
|
execSlug := "exec:" + in.TargetSlug + ":" + id.String()
|
|
if _, err := r.pool.Exec(ctx,
|
|
`INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
|
id, execSlug, execName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx,
|
|
`INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id)
|
|
VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
|
id, mustUUID(in.TargetID), in.Action, in.RiskClass, in.CorrelationID, agentUUID(in.AgentID)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx, `
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM relationships
|
|
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
|
id, mustUUID(in.TargetID)); err != nil {
|
|
return err
|
|
}
|
|
|
|
auditSessionID := sessionUUID(in.SessionID)
|
|
if in.SessionID != "" {
|
|
if _, err := r.pool.Exec(ctx, `
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
|
FROM entities t WHERE t.slug = $2
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM relationships
|
|
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
|
id, "task:"+in.SessionID); err != nil {
|
|
return err
|
|
}
|
|
// Link execution to session for auto-continuation.
|
|
if auditSessionID != nil {
|
|
if _, err := r.pool.Exec(ctx, `
|
|
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
|
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, id, *auditSessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-classify: write the classification decision.
|
|
classID, err := uuid.NewV7()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx,
|
|
`INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'classification', $3, '{}')`,
|
|
classID, "classification:"+classID.String(), "classification for "+execSlug); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx,
|
|
`INSERT INTO classifications (entity_id, action, risk_class, route, reasoning, correlation_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
classID, in.Action, in.RiskClass, in.Route, string(in.ClassReasoning), in.CorrelationID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx,
|
|
`UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx, `
|
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
SELECT $1, $2, 'precedes', '{"by":"nomos"}'::jsonb, now()
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM relationships
|
|
WHERE source_id = $1 AND target_id = $2 AND type = 'precedes' AND valid_to IS NULL)`,
|
|
classID, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Audit the run request (every run, auto or queued).
|
|
q := sqlcgen.New(r.pool)
|
|
return observability.Audit(ctx, q, "agent", "nomos", "run",
|
|
&id, "POST", "/mcp", in.CorrelationID, auditSessionID, map[string]any{
|
|
"command": in.Command, "target": in.TargetSlug,
|
|
"risk_class": in.RiskClass, "purpose": in.Purpose,
|
|
})
|
|
}
|
|
|
|
// MarkRunning stamps status + started_at now.
|
|
func (r *ExecRunRepo) MarkRunning(ctx context.Context, id domain.UUID) error {
|
|
_, err := r.pool.Exec(ctx,
|
|
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
|
|
mustUUID(id), time.Now())
|
|
return err
|
|
}
|
|
|
|
// Finalize stamps the terminal status, result payload, duration, and
|
|
// completion time, and emits an execution.{status} event correlated to the
|
|
// session (the correlation_id is read from the row so every execution path
|
|
// — MCP auto-run, approval, worker — gets a correlated event).
|
|
func (r *ExecRunRepo) Finalize(ctx context.Context, in ports.FinalizeExecutionInput) error {
|
|
id := mustUUID(in.ExecutionID)
|
|
_, err := r.pool.Exec(ctx,
|
|
`UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4,
|
|
started_at=$5, completed_at=now()
|
|
WHERE entity_id=$1`,
|
|
id, in.Status, string(in.Result),
|
|
int(time.Since(in.StartedAt).Milliseconds()), in.StartedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Emit a correlated execution event for SSE fan-out. The correlation_id
|
|
// was set on the row at creation time (CreateRun, InsertExecution, etc.)
|
|
// and is preserved through the lifecycle.
|
|
var correlationID string
|
|
_ = r.pool.QueryRow(ctx, `SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID)
|
|
severity := "info"
|
|
if in.Status == "failed" {
|
|
severity = "warning"
|
|
}
|
|
_ = observability.Event(ctx, sqlcgen.New(r.pool), "execution."+in.Status, &id, severity, "execution", correlationID,
|
|
map[string]any{"execution_id": in.ExecutionID, "status": in.Status})
|
|
return nil
|
|
}
|
|
|
|
// QueueApproval creates the approval row, flips the execution to
|
|
// pending_approval, emits approval.created, and marks the session
|
|
// awaiting_input.
|
|
func (r *ExecRunRepo) QueueApproval(ctx context.Context, in ports.QueueApprovalInput) error {
|
|
id := mustUUID(in.ExecutionID)
|
|
payload, _ := json.Marshal(map[string]any{"action": in.Action, "params": in.Params, "execution_id": in.ExecutionID})
|
|
// approvals.entity_id is PK + FK to entities(id). Reuse the
|
|
// execution's entity so the FK is satisfied — a fresh UUID here has no
|
|
// matching entities row and the INSERT silently fails, orphaning the
|
|
// execution (one execution maps to at most one approval, so the 1:1
|
|
// identity holds).
|
|
subject := mustUUID(in.TargetID)
|
|
if err := sqlcgen.New(r.pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
|
|
EntityID: id,
|
|
SubjectEntityID: &subject,
|
|
Action: in.Action,
|
|
RiskClass: in.RiskClass,
|
|
Kind: "execution",
|
|
Payload: payload,
|
|
TokenHash: nil,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.pool.Exec(ctx,
|
|
`UPDATE executions SET approval_id = $1, status='pending_approval', risk_class=$2 WHERE entity_id = $1`,
|
|
id, in.RiskClass); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Operator-facing SSE event: a gated action now awaits a decision.
|
|
q := sqlcgen.New(r.pool)
|
|
_ = observability.Event(ctx, q, "approval.created", &id, "warning", "mcp", "",
|
|
map[string]any{"action": in.Action, "params": in.Params, "risk_class": in.RiskClass})
|
|
|
|
// Flip the session to awaiting_input so boards and the idle sweep see
|
|
// the task as blocked on the operator, not silently working. No-op for
|
|
// direct MCP calls with no nomos session.
|
|
if in.SessionID == "" || in.SessionID == "ephemeral" {
|
|
return nil
|
|
}
|
|
tag, err := r.pool.Exec(ctx, `
|
|
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
|
|
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, in.SessionID)
|
|
if err != nil || tag.RowsAffected() == 0 {
|
|
return nil
|
|
}
|
|
var taskEntID *uuid.UUID
|
|
var e uuid.UUID
|
|
if qerr := r.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, in.SessionID).Scan(&e); qerr == nil && e != uuid.Nil {
|
|
taskEntID = &e
|
|
}
|
|
return observability.Event(ctx, q, "task.status", taskEntID, "info", "nomos", in.SessionID,
|
|
map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
|
|
}
|
|
|
|
// agentUUID maps a domain agent id to pg uuid (nil-safe).
|
|
func agentUUID(id domain.UUID) any {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
return mustUUID(id)
|
|
}
|
|
|
|
// sessionUUID parses a session id for audit linkage; nil when absent or
|
|
// ephemeral.
|
|
func sessionUUID(sessionID string) *uuid.UUID {
|
|
if sessionID == "" || sessionID == "ephemeral" {
|
|
return nil
|
|
}
|
|
sid, err := uuid.Parse(sessionID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &sid
|
|
}
|