feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run

- 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

@@ -14,7 +14,6 @@ import (
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/execlog"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
)
@@ -80,7 +79,7 @@ const sshExecTimeout = 10 * time.Minute
// sshExecStream runs a command and reports its combined output, forwarding
// each chunk to sink as it arrives. A nil sink behaves exactly as before.
func sshExecStream(ctx context.Context, host, user, command string, sink execlog.Sink) (string, error) {
func sshExecStream(ctx context.Context, host, user, command string, sink db.ExecLogSink) (string, error) {
initSSH()
if len(_sshKey) == 0 {
return "", fmt.Errorf("no SSH key available")
@@ -260,7 +259,7 @@ func (s *Server) executeApprovedAction(ctx context.Context, pool *db.Pool, execI
`SELECT correlation_id FROM executions WHERE entity_id = $1`, execID).Scan(&correlationID); qerr != nil {
correlationID = ""
}
sink, flushLogs := execlog.New(ctx, pool, execID, correlationID)
sink, flushLogs := db.NewExecutionLog(ctx, pool, execID, correlationID)
defer flushLogs()
var output, cmd string

View File

@@ -16,8 +16,8 @@ import (
"testing"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports/portstest"
"github.com/jackc/pgx/v5"
@@ -109,7 +109,8 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
app.NewPolicyService(portstest.NewGovernanceStore()),
&portstest.RecordingExecutor{}, &portstest.FakeResolver{Addr: "127.0.0.1"},
portstest.NewExecutionRecorder())
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc)
approvalSvc := app.NewApprovalService(db.NewApprovalRepo(pool))
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc, approvalSvc)
}
// testAuthToken is the static bearer token devConfig() configures. There is

View File

@@ -5,15 +5,12 @@ import (
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/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"
)
// ─── Approvals ─────────────────────────────────────────────────────────
@@ -22,8 +19,8 @@ func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequest
limit := clampLimit(req.Params.Limit)
var status *string
if req.Params.Status != nil {
s := string(*req.Params.Status)
status = &s
st := string(*req.Params.Status)
status = &st
}
var kind *string
if req.Params.Kind != nil {
@@ -79,6 +76,10 @@ func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequest
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}
// DecideApproval is the thin presenter over ApprovalService.Decide: it maps
// the request onto the service command, then dispatches the SSH work for an
// approved execution in a background goroutine. The HMAC/window/execution
// un-gating logic lives in the service + repository (Phase 9 convergence).
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)
@@ -89,184 +90,43 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
return nil, err
}
actorType, actor := actorInfo(ctx)
_, 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)
}
var token string
if req.Body.Token != nil {
token = *req.Body.Token
}
// 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)
result, err := s.approvalSvc.Decide(ctx, app.ApprovalDecideCmd{
ApprovalID: domain.UUID(id.String()),
Token: token,
Decision: string(req.Body.Decision),
Actor: actor,
})
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", "",
nil,
map[string]any{"decision": status}); auditErr != nil {
return nil, auditErr
// Approve: dispatch the SSH work the repo just un-gated, in the
// background so the HTTP response is not blocked by the execution.
if result.Resume != nil {
res := result.Resume
safego.Go("httpapi:executeApprovedAction", func() {
s.executeApprovedAction(context.Background(), s.pool, uuid.MustParse(string(res.ExecutionID)), res.TargetSlug, res.Action)
})
slog.Info("httpapi: approved execution queued",
"execution_id", res.ExecutionID, "target", res.TargetSlug, "action", res.Action)
}
// 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() {
s.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 this execution belongs to a nomos session, flip it out of
// awaiting_input — the counterpart to classifyAndGate flipping it IN
// the moment the approval was created (internal/mcp/server.go's
// markSessionAwaitingApproval). Runs for all three decisions (approve/
// deny/revoke): each one is an operator answer to "what do I do about
// this?", same as answerQuestion's unconditional resume-to-executing
// (cmd/nomos/store.go) for a session_questions answer.
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`, id).Scan(&awaitingSessionID)
if awaitingSessionID != "" {
if rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
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, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.DecideApproval200JSONResponse(approval), nil
return gen.DecideApproval200JSONResponse(approvalToGenApproval(result.Approval)), nil
}
func approvalToGen(a sqlcgen.Approval) gen.Approval {
// approvalToGenApproval maps the domain approval onto the wire shape. The
// subject slug is not carried on the domain type, so it is left empty on the
// decide path.
func approvalToGenApproval(a domain.Approval) gen.Approval {
app := gen.Approval{
Id: a.EntityID,
Id: uuid.MustParse(string(a.EntityID)),
Action: a.Action,
RiskClass: a.RiskClass,
Kind: gen.ApprovalKind(a.Kind),
@@ -275,12 +135,13 @@ func approvalToGen(a sqlcgen.Approval) gen.Approval {
DecidedAt: a.DecidedAt,
CreatedAt: a.CreatedAt,
}
if a.DecidedBy != nil {
s := a.DecidedBy.String()
app.DecidedBy = &s
if a.DecidedBy != "" {
u := uuid.MustParse(string(a.DecidedBy))
str := u.String()
app.DecidedBy = &str
}
var payload map[string]any
if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
if len(a.Payload) > 0 {
payload := map[string]any(a.Payload)
app.Payload = &payload
}
return app

View File

@@ -6,7 +6,7 @@ import (
"strconv"
"strings"
"github.com/dtoro/oikos/internal/execlog"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
@@ -34,7 +34,7 @@ func (s *Server) serveExecutionLogs(w http.ResponseWriter, req *http.Request) {
}
}
chunks, err := execlog.Read(ctx, s.pool, execID, limit)
chunks, err := db.ReadExecutionLog(ctx, s.pool, execID, limit)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return

View File

@@ -24,8 +24,8 @@ import (
"time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/httpapi/gen"
@@ -65,10 +65,10 @@ type Server struct {
// entities is the entity-aggregate use-case service (Phase 3 of the
// hexagonal refactor); entityRepo exposes the idempotency reads the
// replay path needs. Wired here until main becomes the composition root.
entities *app.EntityService
entityRepo *db.EntityRepo
readModels ports.ReadModels
relService *app.RelationshipService
entities *app.EntityService
entityRepo *db.EntityRepo
readModels ports.ReadModels
relService *app.RelationshipService
// provisioning owns the pct_create flow (Phase 7): spec defaults,
// provisioner dispatch, guest registration in the graph.
provisioning *app.ProvisioningService
@@ -77,6 +77,10 @@ type Server struct {
// execSvc is the run-submission use-case (Phase 4): PolicyService
// gating + auto-run/queue dispatch, shared by the MCP tools.
execSvc *app.ExecutionService
// approvalSvc is the approval-decision use-case (Phase 9): token
// verification + execution un-gating, shared by the REST decision
// endpoint and the MCP decide_approval path.
approvalSvc *app.ApprovalService
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -86,7 +90,7 @@ type Server struct {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) http.Handler {
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService, approvalSvc *app.ApprovalService) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
@@ -100,6 +104,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
provisioning: provisioning,
seeds: seeds,
execSvc: execSvc,
approvalSvc: approvalSvc,
}
// Wire secrets backend: Infisical primary with SOPS DR fallback.
@@ -948,10 +953,10 @@ main();
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) error {
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService, approvalSvc *app.ApprovalService) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc),
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc, approvalSvc),
ReadHeaderTimeout: 10 * time.Second,
}