- 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.
237 lines
8.6 KiB
Go
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), ¶ms) == 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
|
|
}
|