Files
oikos/internal/execlog/execlog.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

136 lines
3.8 KiB
Go

// Package execlog persists incremental command output for an execution and
// announces it on the event stream.
//
// It exists as its own package because both SSH execution paths need it —
// internal/mcp (the agent's auto-run windows) and internal/httpapi (the
// post-approval actuator). Those two already carry near-identical copies of
// sshExec, and every bug found in this area so far has been a case of the two
// copies drifting apart; one shared sink is the cheap way not to repeat that.
package execlog
import (
"context"
"log/slog"
"sync"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
)
// eventInterval throttles execution.output events. Chunks are persisted as
// they arrive, but a chatty command (apt, a long build) can produce hundreds
// per second and the SSE broker drops events for slow subscribers — flooding
// it would push out the signal.* and approval.* events that actually need to
// arrive. The event is only a "there is more output" ping; subscribers re-read
// the rows.
const eventInterval = time.Second
// Sink receives output chunks as they arrive from a remote command.
type Sink func(stream string, chunk []byte)
// New returns a Sink that writes chunks to execution_logs and emits a
// throttled execution.output event, plus a Flush to call when the command
// finishes.
//
// The returned Sink is safe for concurrent use: stdout and stderr are written
// from separate goroutines.
func New(ctx context.Context, pool *db.Pool, execID uuid.UUID, correlationID string) (Sink, func()) {
var (
mu sync.Mutex
seq int
lastEvent time.Time
pending bool
)
emit := func() {
if err := observability.Event(ctx, sqlcgen.New(pool), "execution.output", &execID,
"info", "actuator", correlationID, map[string]any{"execution_id": execID.String()}); err != nil {
slog.Debug("execlog: emit output event", "error", err, "execution_id", execID)
}
}
sink := func(stream string, chunk []byte) {
if len(chunk) == 0 {
return
}
mu.Lock()
seq++
n := seq
mu.Unlock()
// A failed log write must never fail the command: this is observability,
// and the authoritative output still lands in executions.result at the
// end. Log and carry on.
if _, err := pool.Exec(ctx,
`INSERT INTO execution_logs (execution_id, seq, stream, chunk)
VALUES ($1, $2, $3, $4)`,
execID, n, stream, string(chunk)); err != nil {
slog.Debug("execlog: persist chunk", "error", err, "execution_id", execID)
return
}
mu.Lock()
due := time.Since(lastEvent) >= eventInterval
if due {
lastEvent = time.Now()
pending = false
} else {
pending = true
}
mu.Unlock()
if due {
emit()
}
}
// Flush emits a final event when output arrived inside the throttle window,
// so the last few lines of a short command are not left unannounced.
flush := func() {
mu.Lock()
due := pending
pending = false
mu.Unlock()
if due {
emit()
}
}
return sink, flush
}
// Read returns an execution's persisted output in order.
func Read(ctx context.Context, pool *db.Pool, execID uuid.UUID, limit int) ([]Chunk, error) {
if limit <= 0 {
limit = 1000
}
rows, err := pool.Query(ctx,
`SELECT seq, stream, chunk, ts FROM execution_logs
WHERE execution_id = $1 ORDER BY seq LIMIT $2`, execID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Chunk
for rows.Next() {
var c Chunk
if err := rows.Scan(&c.Seq, &c.Stream, &c.Chunk, &c.TS); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// Chunk is one persisted slice of command output.
type Chunk struct {
Seq int `json:"seq"`
Stream string `json:"stream"`
Chunk string `json:"chunk"`
TS time.Time `json:"ts"`
}