Files
oikos/internal/core/app/execution.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
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.
2026-08-16 12:29:59 +02:00

237 lines
8.6 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
// ExecutionService is the run-submission use-case: gate via PolicyService,
// record the submission, then either execute immediately over the
// CommandExecutor port or queue an operator approval. Both entry paths
// (MCP `run`/`docker_exec` tools, and any future REST surface) converge
// here — one policy, one audit trail (plan §4.A).
type ExecutionService struct {
policy *PolicyService
exec ports.CommandExecutor
resolver ports.TargetResolver
recorder ports.ExecutionRecorder
}
// NewExecutionService wires the service.
func NewExecutionService(
policy *PolicyService,
exec ports.CommandExecutor,
resolver ports.TargetResolver,
recorder ports.ExecutionRecorder,
) *ExecutionService {
return &ExecutionService{policy: policy, exec: exec, resolver: resolver, recorder: recorder}
}
// ExecutionSubmitCmd is one `run` submission.
type ExecutionSubmitCmd struct {
AgentID domain.UUID
TargetID domain.UUID
TargetSlug string
Command string
Purpose string
DeclaredRisk string
SessionID string
// Async, when true and the decision is auto-run, dispatches the
// execution in a background goroutine and returns immediately with
// AsyncStarted — for long-running commands that would exceed the
// caller's round-trip timeout. Set it via PolicyService's
// IsLongRunningCommand at the caller, or directly.
Async bool
// SinkFactory builds the output stream sink for the execution once
// its id and correlation id are known (the adapter wires its log
// streaming here; nil means no streaming).
SinkFactory func(ctx context.Context, executionID domain.UUID, correlationID string) (sink func(stream string, chunk []byte), flush func())
}
// ExecutionSubmitResult is the submission outcome for the calling
// adapter to render.
type ExecutionSubmitResult struct {
Decision PolicyDecision
// ExecutionID is set for every non-refused submission (auto and
// queue paths) — the ledger id to poll with get_execution_status.
ExecutionID domain.UUID
// Output/Error carry the command result when it ran synchronously.
Output string
Err error
// AsyncStarted reports the execution was dispatched in the
// background.
AsyncStarted bool
}
// Submit gates, records, and dispatches one command. Refusals return the
// agent-facing message without recording anything; auto-run paths record
// the submission then execute; queue paths record and create the
// approval ask.
func (s *ExecutionService) Submit(ctx context.Context, cmd ExecutionSubmitCmd) ExecutionSubmitResult {
decision := s.policy.Decide(ctx, PolicySubmitInput{
AgentID: cmd.AgentID,
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
Command: cmd.Command,
Purpose: cmd.Purpose,
DeclaredRisk: cmd.DeclaredRisk,
SessionID: cmd.SessionID,
})
if decision.Action == DecisionRefuse {
return ExecutionSubmitResult{Decision: decision}
}
execID, err := uuid.NewV7()
if err != nil {
return ExecutionSubmitResult{Decision: decision, Err: err}
}
// Correlate the execution to the chat session that asked for it —
// execution events carry correlation_id, so the UI can match them to
// the session on screen. Fresh random id when there is no session.
correlationID := cmd.SessionID
if correlationID == "" || correlationID == "ephemeral" {
correlationID = uuid.New().String()
}
reason, _ := json.Marshal(map[string]string{
"command": cmd.Command, "purpose": cmd.Purpose,
"target": cmd.TargetSlug, "declared_risk": cmd.DeclaredRisk,
})
if err := s.recorder.CreateRun(ctx, ports.CreateRunInput{
ExecutionID: domain.UUID(execID.String()),
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
AgentID: cmd.AgentID,
SessionID: cmd.SessionID,
Command: cmd.Command,
Purpose: cmd.Purpose,
Action: RunActionParams(cmd.Command, cmd.Purpose),
RiskClass: decision.RiskClass,
Route: decision.Route,
ClassReasoning: reason,
CorrelationID: correlationID,
}); err != nil {
return ExecutionSubmitResult{Decision: decision, Err: fmt.Errorf("record execution: %w", err)}
}
execUUID := domain.UUID(execID.String())
// Build the streaming sink now that the execution id is known.
var sink func(string, []byte)
var flush func()
if cmd.SinkFactory != nil {
sink, flush = cmd.SinkFactory(ctx, execUUID, correlationID)
}
switch decision.Action {
case DecisionQueue:
params, _ := json.Marshal(map[string]string{"command": cmd.Command, "purpose": cmd.Purpose})
if err := s.recorder.QueueApproval(ctx, ports.QueueApprovalInput{
ExecutionID: execUUID,
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
Action: "run",
Params: string(params),
RiskClass: decision.RiskClass,
SessionID: cmd.SessionID,
}); err != nil {
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Err: fmt.Errorf("queue approval: %w", err)}
}
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID}
case DecisionAuto:
if cmd.Async {
safego.Go("execution:async-run", func() {
// Result surfaces via the ledger (get_execution_status);
// the handler already returned "started".
_, _ = s.dispatch(context.Background(), execUUID, cmd.TargetSlug, cmd.Command, sink, flush)
})
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, AsyncStarted: true}
}
out, xerr := s.dispatch(ctx, execUUID, cmd.TargetSlug, cmd.Command, sink, flush)
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Output: out, Err: xerr}
}
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Err: fmt.Errorf("unknown decision action %q", decision.Action)}
}
// dispatch resolves the target, marks the execution running, executes
// with streaming, and finalizes with full timing. Shared by the sync and
// async auto-run paths.
func (s *ExecutionService) dispatch(ctx context.Context, execID domain.UUID, targetSlug, command string, sink func(string, []byte), flush func()) (string, error) {
startedAt := time.Now()
if err := s.recorder.MarkRunning(ctx, execID); err != nil {
slog.Error("execution: mark running", "error", err, "execution_id", execID)
}
target, err := s.resolver.ResolveExecTarget(ctx, targetSlug)
if err != nil {
s.finalize(ctx, execID, "failed", jsonErrBytes("%s", err.Error()), startedAt)
return "", err
}
res := s.exec.Run(ctx, target, command, ports.ExecOpts{Sink: sink})
if flush != nil {
flush()
}
if res.Err != nil {
s.finalize(ctx, execID, "failed", jsonErrBytes("%s: %s", res.Err.Error(), res.Output), startedAt)
return res.Output, res.Err
}
s.finalize(ctx, execID, "completed", jsonOutBytes(res.Output), startedAt)
return res.Output, nil
}
func (s *ExecutionService) finalize(ctx context.Context, execID domain.UUID, status string, result []byte, startedAt time.Time) {
if err := s.recorder.Finalize(ctx, ports.FinalizeExecutionInput{
ExecutionID: execID, Status: status, Result: result, StartedAt: startedAt,
}); err != nil {
slog.Error("execution: finalize", "error", err, "execution_id", execID)
}
}
// DispatchQueued runs a previously-recorded queued execution (status
// 'proposed' or 'approved') over the CommandExecutor port. The command is
// derived from the execution's action column the same way the legacy worker
// did: "action_name:{json_params}" extracts params["command"], else the
// action string is the raw command. Used by the execworker poller adapter.
func (s *ExecutionService) DispatchQueued(ctx context.Context, execID domain.UUID, targetSlug, action string) (string, error) {
return s.dispatch(ctx, execID, targetSlug, queuedCommand(action), nil, nil)
}
// queuedCommand extracts the actual shell command from an execution's action
// column. Format: "action_name:{json_params}" → params["command"]; otherwise
// the action string is itself the raw command.
func queuedCommand(action string) string {
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 != "" {
return c
}
}
}
return action
}
func jsonOutBytes(out string) []byte {
b, _ := json.Marshal(map[string]any{"output": out})
return b
}
func jsonErrBytes(format string, args ...any) []byte {
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
return b
}