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:
@@ -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), ¶ms) == 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user