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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user