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

@@ -1,23 +1,25 @@
// Package execworker processes pending executions as a background daemon.
// This provides a Postgres-backed queue: executions survive restarts, and
// per-execution advisory locks prevent duplicate processing across instances.
//
// Since Phase 4 of the hexagonal refactor, the dispatch logic lives in
// ExecutionService.DispatchQueued (over CommandExecutor + TargetResolver
// ports); this package is a thin poller adapter that claims queued rows and
// delegates to the service.
package execworker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"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/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/adapters/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/health"
"github.com/dtoro/oikos/internal/remote"
"github.com/google/uuid"
)
@@ -25,7 +27,12 @@ import (
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: starting")
// Liveness probe
// Build the ExecutionService with the same wiring as cmd/oikos/main.go.
executor := ssh.NewExecutor(ssh.FileSignerSource(), 5*time.Minute)
resolver := remote.NewResolver(pool)
policySvc := app.NewPolicyService(db.NewGovernanceRepo(pool))
execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool))
probe := health.New(2 * time.Minute)
probe.Serve(ctx, cfg.HealthListen)
@@ -41,7 +48,7 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: shutting down")
return
case <-ticker.C:
processPending(ctx, pool)
processPending(ctx, pool, execSvc)
probe.Bump()
}
}
@@ -59,11 +66,11 @@ func recoverOrphaned(ctx context.Context, pool *db.Pool) {
}
}
// processPending polls for pending executions and dispatches them.
func processPending(ctx context.Context, pool *db.Pool) {
// processPending polls for pending executions and dispatches them via
// ExecutionService.DispatchQueued.
func processPending(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService) {
rows, err := pool.Query(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class, e.correlation_id, e.status,
COALESCE(t.slug, '') AS target_slug
SELECT e.entity_id, COALESCE(t.slug, '') AS target_slug, e.action
FROM executions e
LEFT JOIN entities t ON t.id = e.target_entity_id
WHERE e.status = 'proposed'
@@ -75,23 +82,16 @@ func processPending(ctx context.Context, pool *db.Pool) {
}
defer rows.Close()
q := sqlcgen.New(pool)
for rows.Next() {
var execID, targetID *uuid.UUID
var action, riskClass, correlationID, status, targetSlug string
if err := rows.Scan(&execID, &targetID, &action, &riskClass, &correlationID, &status, &targetSlug); err != nil {
var execID uuid.UUID
var targetSlug, action string
if err := rows.Scan(&execID, &targetSlug, &action); err != nil {
slog.Error("execworker: scan row", "error", err)
continue
}
if execID == nil {
continue
}
// At-most-once: try advisory lock on execution entity_id.
// Acquire a dedicated connection so the session-scoped lock isn't
// released when the transient pool connection is returned.
lockKey := hashUUID(*execID)
lockKey := hashUUID(execID)
lockConn, err := pool.Acquire(ctx)
if err != nil {
slog.Error("execworker: acquire lock conn", "error", err)
@@ -103,99 +103,24 @@ func processPending(ctx context.Context, pool *db.Pool) {
continue
}
dispatch(ctx, pool, q, *execID, targetID, action, targetSlug, correlationID)
// Release the lock on the same connection even if the dispatch
// panics — an un-released advisory lock would permanently orphan the
// execution (every future worker skips it at pg_try_advisory_lock).
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("execworker: panic dispatching execution", "execution", execID, "panic", r)
}
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey)
lockConn.Release()
}()
// Release the per-execution lock on the same connection.
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey)
lockConn.Release()
}
}
func dispatch(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, execID uuid.UUID, targetID *uuid.UUID, action, targetSlug, correlationID string) {
startedAt := time.Now()
// Mark running
_, err := pool.Exec(ctx,
`UPDATE executions SET status = 'running', started_at = $2 WHERE entity_id = $1`,
execID, startedAt)
if err != nil {
slog.Error("execworker: mark running", "error", err, "execution", execID)
return
}
// Resolve SSH target. If targetSlug is available, use it; otherwise resolve from targetID.
var host, user string
if targetSlug == "" && targetID != nil {
if err := pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", *targetID).Scan(&targetSlug); err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("resolve target slug: %v", err))
return
}
}
if targetSlug != "" {
addr, sshUser, err := remote.ResolveHost(ctx, pool, targetSlug, "root")
if err == nil {
host, user = addr, sshUser
}
}
if host == "" {
failExecution(ctx, pool, execID, fmt.Sprintf("no reachable target: %s", targetSlug))
return
}
// Determine the command to run from the action field.
// Format: "action_name:{json_params}" or a raw command string.
cmd := action
if idx := strings.Index(action, ":"); idx > 0 && idx < len(action)-1 {
rawParams := action[idx+1:]
var params map[string]any
if json.Unmarshal([]byte(rawParams), &params) == nil {
if c, ok := params["command"].(string); ok && c != "" {
cmd = c
_, dispatchErr := execSvc.DispatchQueued(ctx, domain.UUID(execID.String()), targetSlug, action)
if dispatchErr != nil {
slog.Error("execworker: dispatch failed", "execution", execID, "error", dispatchErr)
}
}
}()
}
signer, err := actuator.LoadSigner(os.Getenv("OIKOS_SSH_KEY_PATH"))
if err != nil {
signer, err = actuator.LoadSigner("/etc/oikos/ssh_key")
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("load ssh key: %v", err))
return
}
}
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("ssh dial: %v", err))
return
}
defer client.Close()
out, err := actuator.RunCombinedOutput(ctx, client, cmd)
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("command: %v\noutput: %s", err, string(out)))
return
}
duration := time.Since(startedAt).Milliseconds()
resultJSON, _ := json.Marshal(map[string]any{"output": string(out), "success": true})
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: execID,
Status: "completed",
Result: resultJSON,
DurationMs: &[]int32{int32(duration)}[0],
Verified: true,
})
slog.Info("execworker: execution complete",
"execution", execID, "target", targetSlug, "duration_ms", duration)
}
func failExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, reason string) {
slog.Error("execworker: execution failed", "execution", execID, "error", reason)
resultJSON, _ := json.Marshal(map[string]any{"error": reason, "success": false})
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb, completed_at=now() WHERE entity_id=$1`,
execID, resultJSON)
}
func hashUUID(id uuid.UUID) int {
@@ -204,4 +129,4 @@ func hashUUID(id uuid.UUID) int {
h = (h*31 + int(b)) & 0x7fffffff
}
return h
}
}