Files
oikos/internal/core/app/execution.go
dtoro 7c9f4ec79f feat: Phase 4 governance/execution slice — PolicyService + ExecutionService
classifyAndGate's decision pipeline moves to core: PolicyService runs the
full gate order (classify + transport escalation, plan-first, syntax,
host-only/host-lxc, VM QGA preflight, dedup, approval-flood, window
routing) over ports.GovernanceStore; ExecutionService records and
dispatches (auto-run via ssh.CommandExecutor + TargetResolver, queue via
ExecutionRecorder) with one converged path for run/docker_exec. Gating
matrix test added (risk x window x declared risk -> outcome); pair
coverage 95.6%.

Bug fix surfaced by the matrix: the flag-space syntax regex was inverted
— it refused valid 'tail -n 3' and missed the actual 'head - n' typo.
Fixed to match dash-space-value only.

Remaining Phase 4 items tracked in the plan: ApprovalService.Decide
convergence, execlog fold, execworker poller. VERSION 0.35.0.
2026-08-16 09:48:26 +02:00

211 lines
7.5 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"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)
}
}
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
}