feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

- 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.
This commit is contained in:
2026-08-16 12:29:59 +02:00
parent 986937799a
commit b98d7c24bf
27 changed files with 1161 additions and 600 deletions

View File

@@ -0,0 +1,306 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"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"
"github.com/jackc/pgx/v5"
)
const (
assentWindowDuration = 30 * time.Minute
destructiveWindowDuration = 15 * time.Minute
)
// ApprovalRepo implements ports.ApprovalRepository over the postgres pool.
// Decide verifies the HMAC token (check-then-act), flips the approval,
// un-gates the linked execution, opens assent/destructive windows, flips
// the nomos session out of awaiting_input, and appends audit + event — one
// transaction (ADR 0016 §3.6).
type ApprovalRepo struct {
pool *Pool
}
var _ ports.ApprovalRepository = (*ApprovalRepo)(nil)
// NewApprovalRepo builds the approval repository.
func NewApprovalRepo(pool *Pool) *ApprovalRepo { return &ApprovalRepo{pool: pool} }
// ListPending returns pending approvals for the given entity (or all pending
// when entityID is empty), newest first.
func (r *ApprovalRepo) ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := r.pool.Query(ctx, `
SELECT a.entity_id, COALESCE(a.subject_entity_id::text, ''), a.action, a.risk_class, a.kind,
a.payload, a.status, COALESCE(a.token_hash, ''), a.expires_at, a.decided_at,
COALESCE(a.decided_by::text, ''), a.created_at
FROM approvals a
WHERE ($1::uuid IS NULL OR a.entity_id = $1)
AND a.status = 'pending'
ORDER BY a.created_at DESC
LIMIT $2`, uuidOrNil(entityID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Approval
for rows.Next() {
var a domain.Approval
var subjectStr, tokenHash, decidedBy string
var payload []byte
if err := rows.Scan(&a.EntityID, &subjectStr, &a.Action, &a.RiskClass, &a.Kind,
&payload, &a.Status, &tokenHash, &a.ExpiresAt, &a.DecidedAt,
&decidedBy, &a.CreatedAt); err != nil {
return nil, err
}
a.TokenHash = tokenHash
if subjectStr != "" {
a.SubjectEntityID = domain.UUID(subjectStr)
}
if decidedBy != "" {
a.DecidedBy = domain.UUID(decidedBy)
}
if len(payload) > 0 {
var m map[string]any
if json.Unmarshal(payload, &m) == nil {
a.Payload = m
}
}
out = append(out, a)
}
return out, rows.Err()
}
// Decide runs the full decide transaction. Returns the updated approval and,
// on approve with a linked execution, the Resume info for the driving adapter
// to dispatch the SSH work after commit.
func (r *ApprovalRepo) Decide(ctx context.Context, in ports.ApprovalDecideInput) (ports.ApprovalDecideResult, error) {
id := mustUUID(in.ApprovalID)
tx, err := r.pool.Begin(ctx)
if err != nil {
return ports.ApprovalDecideResult{}, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if in.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 ports.ApprovalDecideResult{}, domain.ErrNotFound
}
if apprStatus != "pending" {
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
}
if expiresAt.Before(time.Now()) {
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
}
if *tokenHash != hashApprovalToken(in.Token) {
return ports.ApprovalDecideResult{}, domain.ErrInvalidInput
}
}
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: id,
Status: in.Status,
}); err != nil {
return ports.ApprovalDecideResult{}, domain.ErrNotFound
}
// Re-read approval.
app, err := q.GetApprovalByID(ctx, id)
if err != nil {
return ports.ApprovalDecideResult{}, err
}
approval := domain.Approval{
EntityID: domain.UUID(app.EntityID.String()),
Action: app.Action,
RiskClass: app.RiskClass,
Kind: app.Kind,
Status: app.Status,
TokenHash: stringPtrOr(app.TokenHash),
ExpiresAt: app.ExpiresAt,
DecidedAt: app.DecidedAt,
DecidedBy: uuidPtrOr(app.DecidedBy),
CreatedAt: app.CreatedAt,
}
if app.SubjectEntityID != nil {
approval.SubjectEntityID = domain.UUID(app.SubjectEntityID.String())
}
// Audit + event (in-tx; NOTIFY fires post-commit). An audit failure must
// abort the decision — leaving no trace of who decided would violate the
// append-only audit contract (ADR 0016 §3.6).
actorLabel := in.Actor
if actorLabel == "" {
actorLabel = "unknown"
}
if auditErr := observability.Audit(ctx, q, "api", actorLabel, "decide",
&id, "POST", "/api/v1/approvals/"+id.String()+"/decision", "",
nil,
map[string]any{"decision": in.Status}); auditErr != nil {
return ports.ApprovalDecideResult{}, auditErr
}
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
map[string]any{"decision": in.Status, "actor": actorLabel}); evErr != nil {
return ports.ApprovalDecideResult{}, evErr
}
result := ports.ApprovalDecideResult{Approval: approval}
// Approve: un-gate the linked execution and open windows.
if in.Status == "approved" {
resume, aerr := r.approveSideEffects(ctx, tx, id)
if aerr != nil {
return result, aerr
}
result.Resume = resume
} else {
// Denied/revoked: reflect on the linked execution.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, in.Status)
}
// Flip any nomos session out of awaiting_input — the operator answered.
flipAwaitingSession(ctx, tx, id, in.Status)
if err := tx.Commit(ctx); err != nil {
return result, err
}
return result, nil
}
// approveSideEffects finds the linked pending execution, marks it approved,
// opens the assent window (+ destructive window for destructive risk), and
// returns the Resume info for the adapter to dispatch the SSH work.
func (r *ApprovalRepo) approveSideEffects(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID) (*ports.ApprovalResume, error) {
var execID, targetID uuid.UUID
var actionStr, targetSlug, riskClass, sessionID string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class,
COALESCE((SELECT pe.session_id FROM nomos_plan_executions pe
WHERE pe.execution_id = e.entity_id LIMIT 1)::text, '')
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, approvalID).Scan(&execID, &targetID, &actionStr, &riskClass, &sessionID)
if err != nil {
slog.Warn("postgres: no pending execution found for approval", "approval_id", approvalID, "error", err)
return nil, nil
}
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
// 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)
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 {
// Approving a plan step — by ANY route (the UI Approve button and
// chat-assent) — opens/extends the agent's assent window. The key is
// session-scoped so the window only auto-runs THIS task's plan steps
// (governance.go AssentWindowActive + session store's AssentWindowKey
// use the same "assent_window.agent:<id>.session:<sid>" format).
expires := time.Now().Add(assentWindowDuration).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()+".session:"+sessionID, expires)
// Approving a DESTRUCTIVE step via the button is exactly as explicit
// as a typed "I confirm" — open the same short, target+session-scoped
// destructive window chat-assent's typed-confirm path opens.
if riskClass == "destructive" && targetSlug != "" {
dExpires := time.Now().Add(destructiveWindowDuration).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+".session:"+sessionID, dExpires)
}
}
slog.Info("postgres: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
return &ports.ApprovalResume{
ExecutionID: domain.UUID(execID.String()),
TargetSlug: targetSlug,
Action: actionStr,
}, nil
}
// flipAwaitingSession marks any nomos session linked to this approval as
// executing again (awaiting_input → executing), mirroring
// classifyAndGate's awaiting_input flip in reverse.
func flipAwaitingSession(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID, status string) {
var awaitingSessionID string
_ = tx.QueryRow(ctx, `
SELECT pe.session_id FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE ex.approval_id = $1
LIMIT 1`, approvalID).Scan(&awaitingSessionID)
if awaitingSessionID == "" {
return
}
rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID)
if rerr != nil || rtag.RowsAffected() == 0 {
return
}
var taskEntID *uuid.UUID
var e uuid.UUID
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
taskEntID = &e
}
_ = observability.Event(ctx, sqlcgen.New(tx), "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
// hashApprovalToken computes a SHA-256 hex-encoded hash of the token, used
// to verify single-use approval tokens (S5) without storing the plaintext.
func hashApprovalToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func uuidOrNil(id domain.UUID) any {
if id == "" {
return nil
}
return mustUUID(id)
}
func stringPtrOr(s *string) string {
if s == nil {
return ""
}
return *s
}
func uuidPtrOr(u *uuid.UUID) domain.UUID {
if u == nil {
return ""
}
return domain.UUID(u.String())
}

View File

@@ -0,0 +1,130 @@
package db
import (
"context"
"log/slog"
"sync"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
)
// Execution log persistence + throttled event emission (folded from the
// internal/execlog package during the hex refactor — the execution-log
// repository lives with the other execution persistence in this adapter).
// eventInterval throttles execution.output events. Chunks are persisted as
// they arrive, but a chatty command (apt, a long build) can produce hundreds
// per second and the SSE broker drops events for slow subscribers — flooding
// it would push out the signal.* and approval.* events that actually need to
// arrive. The event is only a "there is more output" ping; subscribers re-read
// the rows.
const eventInterval = time.Second
// ExecLogSink receives output chunks as they arrive from a remote command.
type ExecLogSink func(stream string, chunk []byte)
// NewExecutionLog returns a Sink that writes chunks to execution_logs and
// emits a throttled execution.output event, plus a Flush to call when the
// command finishes.
//
// The returned Sink is safe for concurrent use: stdout and stderr are written
// from separate goroutines.
func NewExecutionLog(ctx context.Context, pool *Pool, execID uuid.UUID, correlationID string) (ExecLogSink, func()) {
var (
mu sync.Mutex
seq int
lastEvent time.Time
pending bool
)
emit := func() {
if err := observability.Event(ctx, sqlcgen.New(pool), "execution.output", &execID,
"info", "actuator", correlationID, map[string]any{"execution_id": execID.String()}); err != nil {
slog.Debug("execlog: emit output event", "error", err, "execution_id", execID)
}
}
sink := func(stream string, chunk []byte) {
if len(chunk) == 0 {
return
}
mu.Lock()
seq++
n := seq
mu.Unlock()
// A failed log write must never fail the command: this is observability,
// and the authoritative output still lands in executions.result at the
// end. Log and carry on.
if _, err := pool.Exec(ctx,
`INSERT INTO execution_logs (execution_id, seq, stream, chunk)
VALUES ($1, $2, $3, $4)`,
execID, n, stream, string(chunk)); err != nil {
slog.Debug("execlog: persist chunk", "error", err, "execution_id", execID)
return
}
mu.Lock()
due := time.Since(lastEvent) >= eventInterval
if due {
lastEvent = time.Now()
pending = false
} else {
pending = true
}
mu.Unlock()
if due {
emit()
}
}
// Flush emits a final event when output arrived inside the throttle window,
// so the last few lines of a short command are not left unannounced.
flush := func() {
mu.Lock()
due := pending
pending = false
mu.Unlock()
if due {
emit()
}
}
return sink, flush
}
// ExecLogChunk is one persisted slice of command output.
type ExecLogChunk struct {
Seq int `json:"seq"`
Stream string `json:"stream"`
Chunk string `json:"chunk"`
TS time.Time `json:"ts"`
}
// ReadExecutionLog returns an execution's persisted output in order.
func ReadExecutionLog(ctx context.Context, pool *Pool, execID uuid.UUID, limit int) ([]ExecLogChunk, error) {
if limit <= 0 {
limit = 1000
}
rows, err := pool.Query(ctx,
`SELECT seq, stream, chunk, ts FROM execution_logs
WHERE execution_id = $1 ORDER BY seq LIMIT $2`, execID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExecLogChunk
for rows.Next() {
var c ExecLogChunk
if err := rows.Scan(&c.Seq, &c.Stream, &c.Chunk, &c.TS); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}

View File

@@ -234,15 +234,32 @@ func (r *ExecRunRepo) MarkRunning(ctx context.Context, id domain.UUID) error {
}
// Finalize stamps the terminal status, result payload, duration, and
// completion time.
// 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`,
mustUUID(in.ExecutionID), in.Status, string(in.Result),
id, in.Status, string(in.Result),
int(time.Since(in.StartedAt).Milliseconds()), in.StartedAt)
return err
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