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 }