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.
This commit is contained in:
2026-08-16 09:48:26 +02:00
parent 60c0432d8b
commit 7c9f4ec79f
19 changed files with 1748 additions and 725 deletions

View File

@@ -11,11 +11,14 @@ import (
"strings"
"syscall"
"github.com/google/uuid"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/remote"
intremote "github.com/dtoro/oikos/internal/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/execworker"
"github.com/dtoro/oikos/internal/httpapi"
@@ -114,7 +117,7 @@ func main() {
slog.Info("all: starting api with scheduler + execution-worker in background")
svc := buildAPIServices(pool)
if err := httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds); err != nil {
if err := httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc); err != nil {
slog.Error("api failed", "error", err)
os.Exit(1)
}
@@ -207,7 +210,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
}
svc := buildAPIServices(pool)
err = httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds)
err = httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc)
if err == http.ErrServerClosed {
return nil
}
@@ -224,6 +227,7 @@ type apiServices struct {
relService *app.RelationshipService
provisioning *app.ProvisioningService
seeds *app.SeedService
execSvc *app.ExecutionService
}
func buildAPIServices(pool *db.Pool) apiServices {
@@ -231,15 +235,28 @@ func buildAPIServices(pool *db.Pool) apiServices {
onto := db.NewOntologyRepo(pool, time.Minute)
readModels := db.NewEntityReader(pool)
entities := app.NewEntityService(entityRepo, onto)
relService := app.NewRelationshipService(db.NewRelRepo(pool), onto)
relRepo := db.NewRelRepo(pool)
relService := app.NewRelationshipService(relRepo, onto)
executor := ssh.NewExecutor(ssh.FileSignerSource(), 5*time.Minute)
provisioner := ssh.NewProvisioner(executor)
resolver := remote.NewResolver(pool)
provisioning := app.NewProvisioningService(provisioner, resolver, entityRepo, db.NewRelRepo(pool))
provisioning := app.NewProvisioningService(provisioner, resolver, entityRepo, relRepo)
seeds := app.NewSeedService(db.NewSeedRepo(pool))
// Governance/execution slice (Phase 4): the MCP run/docker_exec tools
// gate and dispatch through PolicyService + ExecutionService.
policySvc := app.NewPolicyService(db.NewGovernanceRepo(pool))
policySvc.HostHint = func(ctx context.Context, targetSlug string) string {
var id uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&id); err != nil {
id = uuid.Nil
}
return intremote.ResolveProxmoxHostSlug(ctx, pool, id, "")
}
execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool))
return apiServices{
entities: entities,
entityRepo: entityRepo,
@@ -247,6 +264,7 @@ func buildAPIServices(pool *db.Pool) apiServices {
relService: relService,
provisioning: provisioning,
seeds: seeds,
execSvc: execSvc,
}
}