Files
oikos/internal/httpapi/executions.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

322 lines
11 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Executions ────────────────────────────────────────────────────────
// ListExecutions returns executions newest-first.
//
// The target/action/correlation_id filters are declared in the OpenAPI spec and
// generated into the request struct, but were never bound — so
// `GET /executions?target=<id>` silently returned the first page of the whole
// fleet. Ordering was by target slug, which is neither useful for a history
// view nor unique enough to paginate on: several executions share a target, so
// a slug cursor could skip or repeat rows.
func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
cursorTime, cursorID, err := parseExecutionCursor(req.Params.Cursor)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE ($1::text IS NULL OR e.status = $1)
-- target accepts a slug or a uuid: the SPA passes an entity id,
-- while a human poking the API reaches for the slug.
AND ($2::text IS NULL OR te.slug = $2 OR e.target_entity_id::text = $2)
-- the run tool encodes action as "run:{json}", so match the verb too
AND ($3::text IS NULL OR e.action = $3 OR split_part(e.action, ':', 1) = $3)
AND ($4::text IS NULL OR e.correlation_id = $4)
AND ($5::timestamptz IS NULL
OR (e.created_at, e.entity_id) < ($5::timestamptz, $6::uuid))
ORDER BY e.created_at DESC, e.entity_id DESC
LIMIT $7`,
req.Params.Status, req.Params.Target, req.Params.Action, req.Params.CorrelationId,
cursorTime, cursorID, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Execution{}
for rows.Next() {
var exec gen.Execution
var resultBytes []byte
var targetSlug string
if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug); err != nil {
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
// Target is stored as UUID, but we surface the slug
exec.Slug = targetSlug
items = append(items, exec)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
cursor := formatExecutionCursor(last.CreatedAt, last.Id)
next = &cursor
}
if items == nil {
items = []gen.Execution{}
}
return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil
}
// Executions are ordered by (created_at DESC, entity_id DESC), so the cursor
// has to carry both — created_at alone is not unique, and paginating on a
// non-unique key drops or repeats rows at page boundaries.
func formatExecutionCursor(createdAt time.Time, id uuid.UUID) string {
return createdAt.UTC().Format(time.RFC3339Nano) + "," + id.String()
}
func parseExecutionCursor(cursor *string) (*time.Time, *uuid.UUID, error) {
if cursor == nil || *cursor == "" {
return nil, nil, nil
}
rawTime, rawID, ok := strings.Cut(*cursor, ",")
if !ok {
return nil, nil, domain.ErrInvalidInput
}
t, err := time.Parse(time.RFC3339Nano, rawTime)
if err != nil {
return nil, nil, domain.ErrInvalidInput
}
id, err := uuid.Parse(rawID)
if err != nil {
return nil, nil, domain.ErrInvalidInput
}
return &t, &id, nil
}
func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = s.pool.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
exec.Slug = targetSlug
return gen.GetExecution200JSONResponse(exec), nil
}
func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
if err != nil {
return nil, err
}
correlationID := uuid.New().String()
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7
// collides for real under back-to-back requests since the leading bytes
// encode a millisecond timestamp (observed live via the MCP run tool).
execSlug := "exec:" + id.String()
if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: execSlug,
Type: "execution",
Name: req.Body.Action + " on " + req.Body.Target,
Attributes: []byte("{}"),
}); err != nil {
return nil, err
}
if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
EntityID: id,
TargetEntityID: &targetID,
Action: req.Body.Action,
RiskClass: "unclassified", // will be classified by classifier
CorrelationID: correlationID,
}); err != nil {
return nil, err
}
// Re-read to get the full record.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/executions", "",
nil,
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
return nil, auditErr
}
if eventErr := observability.Event(ctx, q, "execution.requested", &id,
"info", "oikos-api", "",
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil {
return nil, eventErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.RequestExecution201JSONResponse(exec), nil
}
func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: id,
Status: "cancelled",
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
nil,
map[string]any{"status": "cancelled"}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CancelExecution200JSONResponse(exec), nil
}