feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- 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:
306
internal/adapters/postgres/approvals.go
Normal file
306
internal/adapters/postgres/approvals.go
Normal 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())
|
||||
}
|
||||
130
internal/adapters/postgres/execlog.go
Normal file
130
internal/adapters/postgres/execlog.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user