feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run

- 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.
This commit is contained in:
2026-08-16 12:29:59 +02:00
parent 986937799a
commit b98d7c24bf
27 changed files with 1161 additions and 600 deletions

View File

@@ -1 +1 @@
0.35.1 0.36.0

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -153,6 +154,40 @@ gated mutations through run. Be concise. Prefer tools over guessing.`
// explicit typed confirmation regardless of the window. // explicit typed confirmation regardless of the window.
const assentWindowDuration = 30 * time.Minute const assentWindowDuration = 30 * time.Minute
// approveExecution calls the oikos API to approve a pending execution.
// Returns true + new status on success, false on any error.
func (a *agent) approveExecution(ctx context.Context, execID string) (bool, string, error) {
if a.apiBase == "" || a.apiToken == "" {
return false, "", fmt.Errorf("nomos: apiBase or apiToken not configured")
}
body, _ := json.Marshal(map[string]string{"status": "approved"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
a.apiBase+"/api/v1/approvals/"+execID+"/decision", bytes.NewReader(body))
if err != nil {
return false, "", fmt.Errorf("nomos: create approval request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+a.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return false, "", fmt.Errorf("nomos: approval request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return false, "", fmt.Errorf("nomos: approval %s not found", execID)
}
if resp.StatusCode != http.StatusOK {
return false, "", fmt.Errorf("nomos: approval %s returned %d", execID, resp.StatusCode)
}
var result struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, "", fmt.Errorf("nomos: decode approval response: %w", err)
}
return true, result.Status, nil
}
// openAssentWindow records an active assent window in autonomy_settings so // openAssentWindow records an active assent window in autonomy_settings so
// the MCP run tool (separate process) can check it before requiring approval // the MCP run tool (separate process) can check it before requiring approval
// for config_mutation commands. Key is scoped to this agent's UUID AND this // for config_mutation commands. Key is scoped to this agent's UUID AND this

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"testing" "testing"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/nomos/turngate"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -48,14 +50,14 @@ func TestExtractExecutionIDs(t *testing.T) {
// return false, body never executed — when a turn is already active for the // return false, body never executed — when a turn is already active for the
// session. continueSession relies on this so it only marks a continuation // session. continueSession relies on this so it only marks a continuation
// "continued" after a turn really ran (otherwise the result is lost: marked // "continued" after a turn really ran (otherwise the result is lost: marked
// continued, never re-queued by pendingContinuations). // continued, never re-queued by PendingContinuations).
// //
// A minimal agent with only a gate is enough: if the body ever ran, chatWith // A minimal agent with only a gate is enough: if the body ever ran, chatWith
// would dereference the nil provider and panic. Returning false cleanly proves // would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped. // the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) { func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: turngate.New()} a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) { if !a.gate.Acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session") t.Fatal("precondition: initial acquire should succeed on a free session")
} }
ran := a.resumeSession(context.Background(), "sess", "note") ran := a.resumeSession(context.Background(), "sess", "note")
@@ -70,9 +72,9 @@ func TestResumeSession_SkipsWhenBusy(t *testing.T) {
// without reaching resumeSession's body (nil provider → panic) or markContinued. // without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) { func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: turngate.New()} a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) { if !a.gate.Acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session") t.Fatal("precondition: initial acquire should succeed on a free session")
} }
p := pendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"} p := session.PendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"}
a.continueSession(context.Background(), p) // must not panic; must not run/mark a.continueSession(context.Background(), p) // must not panic; must not run/mark
} }

View File

@@ -17,6 +17,7 @@ import (
"github.com/dtoro/oikos/internal/nomos/session" "github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
) )
func main() { func main() {
@@ -425,7 +426,7 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *session.Stor
limit = n limit = n
} }
} }
sessions, err := st.ListSessionsFiltered(r.Context(), ListFilter{ sessions, err := st.ListSessionsFiltered(r.Context(), session.ListFilter{
Outcome: q.Get("outcome"), Outcome: q.Get("outcome"),
Status: q.Get("status"), Status: q.Get("status"),
EntityID: q.Get("entity_id"), EntityID: q.Get("entity_id"),

View File

@@ -8,6 +8,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/dtoro/oikos/internal/nomos/session"
) )
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the // Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
@@ -222,7 +224,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
case "propose_plan": case "propose_plan":
raw, _ := args["steps"].([]any) raw, _ := args["steps"].([]any)
var steps []PlanStepInput var steps []session.PlanStepInput
for _, r := range raw { for _, r := range raw {
m, ok := r.(map[string]any) m, ok := r.(map[string]any)
if !ok { if !ok {
@@ -234,7 +236,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
} }
detail, _ := m["detail"].(string) detail, _ := m["detail"].(string)
target, _ := m["target_slug"].(string) target, _ := m["target_slug"].(string)
steps = append(steps, PlanStepInput{Title: title, Detail: detail, TargetSlug: target}) steps = append(steps, session.PlanStepInput{Title: title, Detail: detail, TargetSlug: target})
} }
if len(steps) == 0 { if len(steps) == 0 {
return "error: propose_plan needs at least one step with a title", true return "error: propose_plan needs at least one step with a title", true
@@ -263,7 +265,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
} }
appendedNote := "" appendedNote := ""
if !hasWritebackStep { if !hasWritebackStep {
steps = append(steps, PlanStepInput{ steps = append(steps, session.PlanStepInput{
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge", Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).", Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
}) })

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"time"
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -10,19 +9,20 @@ import (
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports" "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/execworker"
"github.com/dtoro/oikos/internal/httpapi" "github.com/dtoro/oikos/internal/httpapi"
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
intremote "github.com/dtoro/oikos/internal/remote"
"github.com/dtoro/oikos/internal/scheduler" "github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/secrets"
) )
@@ -117,7 +117,7 @@ func main() {
slog.Info("all: starting api with scheduler + execution-worker in background") slog.Info("all: starting api with scheduler + execution-worker in background")
svc := buildAPIServices(pool) svc := buildAPIServices(pool)
if err := httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc); err != nil { if err := httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc, svc.approvalSvc); err != nil {
slog.Error("api failed", "error", err) slog.Error("api failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -210,7 +210,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
} }
svc := buildAPIServices(pool) svc := buildAPIServices(pool)
err = httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc) err = httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds, svc.execSvc, svc.approvalSvc)
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
return nil return nil
} }
@@ -228,6 +228,7 @@ type apiServices struct {
provisioning *app.ProvisioningService provisioning *app.ProvisioningService
seeds *app.SeedService seeds *app.SeedService
execSvc *app.ExecutionService execSvc *app.ExecutionService
approvalSvc *app.ApprovalService
} }
func buildAPIServices(pool *db.Pool) apiServices { func buildAPIServices(pool *db.Pool) apiServices {
@@ -256,6 +257,7 @@ func buildAPIServices(pool *db.Pool) apiServices {
return intremote.ResolveProxmoxHostSlug(ctx, pool, id, "") return intremote.ResolveProxmoxHostSlug(ctx, pool, id, "")
} }
execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool)) execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool))
approvalSvc := app.NewApprovalService(db.NewApprovalRepo(pool))
return apiServices{ return apiServices{
entities: entities, entities: entities,
@@ -265,6 +267,7 @@ func buildAPIServices(pool *db.Pool) apiServices {
provisioning: provisioning, provisioning: provisioning,
seeds: seeds, seeds: seeds,
execSvc: execSvc, execSvc: execSvc,
approvalSvc: approvalSvc,
} }
} }

View File

@@ -0,0 +1,306 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
assentWindowDuration = 30 * time.Minute
destructiveWindowDuration = 15 * time.Minute
)
// ApprovalRepo implements ports.ApprovalRepository over the postgres pool.
// Decide verifies the HMAC token (check-then-act), flips the approval,
// un-gates the linked execution, opens assent/destructive windows, flips
// the nomos session out of awaiting_input, and appends audit + event — one
// transaction (ADR 0016 §3.6).
type ApprovalRepo struct {
pool *Pool
}
var _ ports.ApprovalRepository = (*ApprovalRepo)(nil)
// NewApprovalRepo builds the approval repository.
func NewApprovalRepo(pool *Pool) *ApprovalRepo { return &ApprovalRepo{pool: pool} }
// ListPending returns pending approvals for the given entity (or all pending
// when entityID is empty), newest first.
func (r *ApprovalRepo) ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := r.pool.Query(ctx, `
SELECT a.entity_id, COALESCE(a.subject_entity_id::text, ''), a.action, a.risk_class, a.kind,
a.payload, a.status, COALESCE(a.token_hash, ''), a.expires_at, a.decided_at,
COALESCE(a.decided_by::text, ''), a.created_at
FROM approvals a
WHERE ($1::uuid IS NULL OR a.entity_id = $1)
AND a.status = 'pending'
ORDER BY a.created_at DESC
LIMIT $2`, uuidOrNil(entityID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Approval
for rows.Next() {
var a domain.Approval
var subjectStr, tokenHash, decidedBy string
var payload []byte
if err := rows.Scan(&a.EntityID, &subjectStr, &a.Action, &a.RiskClass, &a.Kind,
&payload, &a.Status, &tokenHash, &a.ExpiresAt, &a.DecidedAt,
&decidedBy, &a.CreatedAt); err != nil {
return nil, err
}
a.TokenHash = tokenHash
if subjectStr != "" {
a.SubjectEntityID = domain.UUID(subjectStr)
}
if decidedBy != "" {
a.DecidedBy = domain.UUID(decidedBy)
}
if len(payload) > 0 {
var m map[string]any
if json.Unmarshal(payload, &m) == nil {
a.Payload = m
}
}
out = append(out, a)
}
return out, rows.Err()
}
// Decide runs the full decide transaction. Returns the updated approval and,
// on approve with a linked execution, the Resume info for the driving adapter
// to dispatch the SSH work after commit.
func (r *ApprovalRepo) Decide(ctx context.Context, in ports.ApprovalDecideInput) (ports.ApprovalDecideResult, error) {
id := mustUUID(in.ApprovalID)
tx, err := r.pool.Begin(ctx)
if err != nil {
return ports.ApprovalDecideResult{}, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if in.Token != "" {
var tokenHash *string
var apprStatus string
var expiresAt time.Time
err := tx.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
id).Scan(&tokenHash, &apprStatus, &expiresAt)
if err != nil || tokenHash == nil {
return ports.ApprovalDecideResult{}, domain.ErrNotFound
}
if apprStatus != "pending" {
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
}
if expiresAt.Before(time.Now()) {
return ports.ApprovalDecideResult{}, domain.ErrInvalidTransition
}
if *tokenHash != hashApprovalToken(in.Token) {
return ports.ApprovalDecideResult{}, domain.ErrInvalidInput
}
}
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: id,
Status: in.Status,
}); err != nil {
return ports.ApprovalDecideResult{}, domain.ErrNotFound
}
// Re-read approval.
app, err := q.GetApprovalByID(ctx, id)
if err != nil {
return ports.ApprovalDecideResult{}, err
}
approval := domain.Approval{
EntityID: domain.UUID(app.EntityID.String()),
Action: app.Action,
RiskClass: app.RiskClass,
Kind: app.Kind,
Status: app.Status,
TokenHash: stringPtrOr(app.TokenHash),
ExpiresAt: app.ExpiresAt,
DecidedAt: app.DecidedAt,
DecidedBy: uuidPtrOr(app.DecidedBy),
CreatedAt: app.CreatedAt,
}
if app.SubjectEntityID != nil {
approval.SubjectEntityID = domain.UUID(app.SubjectEntityID.String())
}
// Audit + event (in-tx; NOTIFY fires post-commit). An audit failure must
// abort the decision — leaving no trace of who decided would violate the
// append-only audit contract (ADR 0016 §3.6).
actorLabel := in.Actor
if actorLabel == "" {
actorLabel = "unknown"
}
if auditErr := observability.Audit(ctx, q, "api", actorLabel, "decide",
&id, "POST", "/api/v1/approvals/"+id.String()+"/decision", "",
nil,
map[string]any{"decision": in.Status}); auditErr != nil {
return ports.ApprovalDecideResult{}, auditErr
}
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
map[string]any{"decision": in.Status, "actor": actorLabel}); evErr != nil {
return ports.ApprovalDecideResult{}, evErr
}
result := ports.ApprovalDecideResult{Approval: approval}
// Approve: un-gate the linked execution and open windows.
if in.Status == "approved" {
resume, aerr := r.approveSideEffects(ctx, tx, id)
if aerr != nil {
return result, aerr
}
result.Resume = resume
} else {
// Denied/revoked: reflect on the linked execution.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, in.Status)
}
// Flip any nomos session out of awaiting_input — the operator answered.
flipAwaitingSession(ctx, tx, id, in.Status)
if err := tx.Commit(ctx); err != nil {
return result, err
}
return result, nil
}
// approveSideEffects finds the linked pending execution, marks it approved,
// opens the assent window (+ destructive window for destructive risk), and
// returns the Resume info for the adapter to dispatch the SSH work.
func (r *ApprovalRepo) approveSideEffects(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID) (*ports.ApprovalResume, error) {
var execID, targetID uuid.UUID
var actionStr, targetSlug, riskClass, sessionID string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class,
COALESCE((SELECT pe.session_id FROM nomos_plan_executions pe
WHERE pe.execution_id = e.entity_id LIMIT 1)::text, '')
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, approvalID).Scan(&execID, &targetID, &actionStr, &riskClass, &sessionID)
if err != nil {
slog.Warn("postgres: no pending execution found for approval", "approval_id", approvalID, "error", err)
return nil, nil
}
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
// Status only — risk_class was set correctly at request time (e.g. by
// policy.ClassifyCommand for `run`); overwriting it to a hardcoded
// 'config_mutation' here corrupted the audit ledger for every other risk
// class, including destructive.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)
var agentID *uuid.UUID
if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
// Approving a plan step — by ANY route (the UI Approve button and
// chat-assent) — opens/extends the agent's assent window. The key is
// session-scoped so the window only auto-runs THIS task's plan steps
// (governance.go AssentWindowActive + session store's AssentWindowKey
// use the same "assent_window.agent:<id>.session:<sid>" format).
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`,
"assent_window.agent:"+agentID.String()+".session:"+sessionID, expires)
// Approving a DESTRUCTIVE step via the button is exactly as explicit
// as a typed "I confirm" — open the same short, target+session-scoped
// destructive window chat-assent's typed-confirm path opens.
if riskClass == "destructive" && targetSlug != "" {
dExpires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`,
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID, dExpires)
}
}
slog.Info("postgres: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
return &ports.ApprovalResume{
ExecutionID: domain.UUID(execID.String()),
TargetSlug: targetSlug,
Action: actionStr,
}, nil
}
// flipAwaitingSession marks any nomos session linked to this approval as
// executing again (awaiting_input → executing), mirroring
// classifyAndGate's awaiting_input flip in reverse.
func flipAwaitingSession(ctx context.Context, tx pgx.Tx, approvalID uuid.UUID, status string) {
var awaitingSessionID string
_ = tx.QueryRow(ctx, `
SELECT pe.session_id FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE ex.approval_id = $1
LIMIT 1`, approvalID).Scan(&awaitingSessionID)
if awaitingSessionID == "" {
return
}
rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID)
if rerr != nil || rtag.RowsAffected() == 0 {
return
}
var taskEntID *uuid.UUID
var e uuid.UUID
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
taskEntID = &e
}
_ = observability.Event(ctx, sqlcgen.New(tx), "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
// hashApprovalToken computes a SHA-256 hex-encoded hash of the token, used
// to verify single-use approval tokens (S5) without storing the plaintext.
func hashApprovalToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func uuidOrNil(id domain.UUID) any {
if id == "" {
return nil
}
return mustUUID(id)
}
func stringPtrOr(s *string) string {
if s == nil {
return ""
}
return *s
}
func uuidPtrOr(u *uuid.UUID) domain.UUID {
if u == nil {
return ""
}
return domain.UUID(u.String())
}

View File

@@ -1,12 +1,4 @@
// Package execlog persists incremental command output for an execution and package db
// 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 ( import (
"context" "context"
@@ -14,12 +6,15 @@ import (
"sync" "sync"
"time" "time"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid" "github.com/google/uuid"
) )
// Execution log persistence + throttled event emission (folded from the
// internal/execlog package during the hex refactor — the execution-log
// repository lives with the other execution persistence in this adapter).
// eventInterval throttles execution.output events. Chunks are persisted as // eventInterval throttles execution.output events. Chunks are persisted as
// they arrive, but a chatty command (apt, a long build) can produce hundreds // 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 // per second and the SSE broker drops events for slow subscribers — flooding
@@ -28,16 +23,16 @@ import (
// the rows. // the rows.
const eventInterval = time.Second const eventInterval = time.Second
// Sink receives output chunks as they arrive from a remote command. // ExecLogSink receives output chunks as they arrive from a remote command.
type Sink func(stream string, chunk []byte) type ExecLogSink func(stream string, chunk []byte)
// New returns a Sink that writes chunks to execution_logs and emits a // NewExecutionLog returns a Sink that writes chunks to execution_logs and
// throttled execution.output event, plus a Flush to call when the command // emits a throttled execution.output event, plus a Flush to call when the
// finishes. // command finishes.
// //
// The returned Sink is safe for concurrent use: stdout and stderr are written // The returned Sink is safe for concurrent use: stdout and stderr are written
// from separate goroutines. // from separate goroutines.
func New(ctx context.Context, pool *db.Pool, execID uuid.UUID, correlationID string) (Sink, func()) { func NewExecutionLog(ctx context.Context, pool *Pool, execID uuid.UUID, correlationID string) (ExecLogSink, func()) {
var ( var (
mu sync.Mutex mu sync.Mutex
seq int seq int
@@ -102,8 +97,16 @@ func New(ctx context.Context, pool *db.Pool, execID uuid.UUID, correlationID str
return sink, flush return sink, flush
} }
// Read returns an execution's persisted output in order. // ExecLogChunk is one persisted slice of command output.
func Read(ctx context.Context, pool *db.Pool, execID uuid.UUID, limit int) ([]Chunk, error) { type ExecLogChunk struct {
Seq int `json:"seq"`
Stream string `json:"stream"`
Chunk string `json:"chunk"`
TS time.Time `json:"ts"`
}
// ReadExecutionLog returns an execution's persisted output in order.
func ReadExecutionLog(ctx context.Context, pool *Pool, execID uuid.UUID, limit int) ([]ExecLogChunk, error) {
if limit <= 0 { if limit <= 0 {
limit = 1000 limit = 1000
} }
@@ -115,9 +118,9 @@ func Read(ctx context.Context, pool *db.Pool, execID uuid.UUID, limit int) ([]Ch
} }
defer rows.Close() defer rows.Close()
var out []Chunk var out []ExecLogChunk
for rows.Next() { for rows.Next() {
var c Chunk var c ExecLogChunk
if err := rows.Scan(&c.Seq, &c.Stream, &c.Chunk, &c.TS); err != nil { if err := rows.Scan(&c.Seq, &c.Stream, &c.Chunk, &c.TS); err != nil {
return nil, err return nil, err
} }
@@ -125,11 +128,3 @@ func Read(ctx context.Context, pool *db.Pool, execID uuid.UUID, limit int) ([]Ch
} }
return out, rows.Err() 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"`
}

View File

@@ -234,15 +234,32 @@ func (r *ExecRunRepo) MarkRunning(ctx context.Context, id domain.UUID) error {
} }
// Finalize stamps the terminal status, result payload, duration, and // Finalize stamps the terminal status, result payload, duration, and
// completion time. // completion time, and emits an execution.{status} event correlated to the
// session (the correlation_id is read from the row so every execution path
// — MCP auto-run, approval, worker — gets a correlated event).
func (r *ExecRunRepo) Finalize(ctx context.Context, in ports.FinalizeExecutionInput) error { func (r *ExecRunRepo) Finalize(ctx context.Context, in ports.FinalizeExecutionInput) error {
id := mustUUID(in.ExecutionID)
_, err := r.pool.Exec(ctx, _, err := r.pool.Exec(ctx,
`UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4,
started_at=$5, completed_at=now() started_at=$5, completed_at=now()
WHERE entity_id=$1`, WHERE entity_id=$1`,
mustUUID(in.ExecutionID), in.Status, string(in.Result), id, in.Status, string(in.Result),
int(time.Since(in.StartedAt).Milliseconds()), in.StartedAt) int(time.Since(in.StartedAt).Milliseconds()), in.StartedAt)
if err != nil {
return err return err
}
// Emit a correlated execution event for SSE fan-out. The correlation_id
// was set on the row at creation time (CreateRun, InsertExecution, etc.)
// and is preserved through the lifecycle.
var correlationID string
_ = r.pool.QueryRow(ctx, `SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID)
severity := "info"
if in.Status == "failed" {
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(r.pool), "execution."+in.Status, &id, severity, "execution", correlationID,
map[string]any{"execution_id": in.ExecutionID, "status": in.Status})
return nil
} }
// QueueApproval creates the approval row, flips the execution to // QueueApproval creates the approval row, flips the execution to

View File

@@ -0,0 +1,65 @@
package app
import (
"context"
"fmt"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
)
// ApprovalService is the approval-decision use-case: list pending approvals
// and decide (approve/deny/revoke), which un-gates the linked execution and
// persists the audit trail. The driving adapter dispatches the SSH work for
// approved executions after Decide returns.
type ApprovalService struct {
approvals ports.ApprovalRepository
}
// NewApprovalService wires the service.
func NewApprovalService(approvals ports.ApprovalRepository) *ApprovalService {
return &ApprovalService{approvals: approvals}
}
// List returns pending approvals for the given entity.
func (s *ApprovalService) List(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) {
return s.approvals.ListPending(ctx, entityID, limit)
}
// ApprovalDecideCmd is one decide-approval request.
type ApprovalDecideCmd struct {
ApprovalID domain.UUID
Token string
Decision string // "approve" | "deny" | "revoke"
Actor string
}
// Decide validates the decision, maps to status, and delegates the
// transactional flip + execution un-gate + audit + event to the repository.
// Returns the updated approval and, on approve, the execution to resume.
func (s *ApprovalService) Decide(ctx context.Context, cmd ApprovalDecideCmd) (ports.ApprovalDecideResult, error) {
var status string
switch cmd.Decision {
case "approve":
status = "approved"
case "deny":
status = "denied"
case "revoke":
status = "revoked"
default:
return ports.ApprovalDecideResult{}, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, cmd.Decision)
}
aid, err := uuid.Parse(string(cmd.ApprovalID))
if err != nil {
return ports.ApprovalDecideResult{}, fmt.Errorf("parse approval id: %w", err)
}
return s.approvals.Decide(ctx, ports.ApprovalDecideInput{
ApprovalID: domain.UUID(aid.String()),
Token: cmd.Token,
Status: status,
Actor: cmd.Actor,
})
}

View File

@@ -0,0 +1,114 @@
package app
import (
"context"
"errors"
"testing"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/core/ports/portstest"
"github.com/google/uuid"
)
func TestApprovalDecideValidDecisions(t *testing.T) {
for _, tc := range []struct {
decision string
want string
}{
{"approve", "approved"},
{"deny", "denied"},
{"revoke", "revoked"},
} {
t.Run(tc.decision, func(t *testing.T) {
repo := portstest.NewApprovalRepo()
repo.Result = ports.ApprovalDecideResult{
Approval: domain.Approval{EntityID: "some-id", Status: tc.want},
}
svc := NewApprovalService(repo)
aid := uuid.New()
got, err := svc.Decide(context.Background(), ApprovalDecideCmd{
ApprovalID: domain.UUID(aid.String()),
Decision: tc.decision,
Actor: "operator",
})
if err != nil {
t.Fatalf("Decide(%s): %v", tc.decision, err)
}
if len(repo.Decided) != 1 {
t.Fatalf("Decide called %d times, want 1", len(repo.Decided))
}
in := repo.Decided[0]
if in.Status != tc.want {
t.Errorf("repo status = %q, want %q", in.Status, tc.want)
}
if in.Actor != "operator" || string(in.ApprovalID) != aid.String() {
t.Errorf("repo input = %+v", in)
}
if got.Approval.Status != tc.want {
t.Errorf("result approval status = %q, want %q", got.Approval.Status, tc.want)
}
})
}
}
func TestApprovalDecideInvalidDecision(t *testing.T) {
repo := portstest.NewApprovalRepo()
svc := NewApprovalService(repo)
_, err := svc.Decide(context.Background(), ApprovalDecideCmd{
ApprovalID: domain.UUID(uuid.New().String()),
Decision: "maybe",
Actor: "operator",
})
if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput", err)
}
if len(repo.Decided) != 0 {
t.Error("repo must not be called on an invalid decision")
}
}
func TestApprovalDecideRepoErrorPropagates(t *testing.T) {
repo := portstest.NewApprovalRepo()
repo.ErrStub = domain.ErrNotFound
svc := NewApprovalService(repo)
_, err := svc.Decide(context.Background(), ApprovalDecideCmd{
ApprovalID: domain.UUID(uuid.New().String()),
Decision: "approve",
})
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestApprovalList(t *testing.T) {
repo := portstest.NewApprovalRepo()
repo.Pending = []domain.Approval{{EntityID: "a", Status: "pending"}}
svc := NewApprovalService(repo)
got, err := svc.List(context.Background(), "e1", 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].Status != "pending" {
t.Fatalf("got %+v", got)
}
}
func TestApprovalDecideTokenForwarded(t *testing.T) {
repo := portstest.NewApprovalRepo()
svc := NewApprovalService(repo)
aid := uuid.New()
_, err := svc.Decide(context.Background(), ApprovalDecideCmd{
ApprovalID: domain.UUID(aid.String()),
Token: "hmac-token",
Decision: "approve",
Actor: "operator",
})
if err != nil {
t.Fatalf("Decide: %v", err)
}
if repo.Decided[0].Token != "hmac-token" {
t.Errorf("token = %q, want forwarded", repo.Decided[0].Token)
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/domain"
@@ -199,6 +200,31 @@ func (s *ExecutionService) finalize(ctx context.Context, execID domain.UUID, sta
} }
} }
// 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), &params) == nil {
if c, ok := params["command"].(string); ok && c != "" {
return c
}
}
}
return action
}
func jsonOutBytes(out string) []byte { func jsonOutBytes(out string) []byte {
b, _ := json.Marshal(map[string]any{"output": out}) b, _ := json.Marshal(map[string]any{"output": out})
return b return b

View File

@@ -281,3 +281,135 @@ func TestExecutionSubmitHostHint(t *testing.T) {
t.Errorf("default hint missing: %s", d.Message) t.Errorf("default hint missing: %s", d.Message)
} }
} }
func TestQueuedCommandExtraction(t *testing.T) {
cases := map[string]string{
`run:{"command":"df -h","purpose":"check"}`: "df -h",
`systemctl restart caddy`: "systemctl restart caddy",
`pct:{"command":"pct list"}`: "pct list",
`apt_upgrade:not-json`: "apt_upgrade:not-json",
`plain command`: "plain command",
}
for in, want := range cases {
if got := queuedCommand(in); got != want {
t.Errorf("queuedCommand(%q) = %q, want %q", in, got, want)
}
}
}
func TestExecutionDispatchQueued(t *testing.T) {
rec, exec, _ := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "uptime output"}}
res, err := svcFor(t, rec, exec).DispatchQueued(context.Background(), targetID, "lxc:x", `run:{"command":"uptime"}`)
if err != nil {
t.Fatalf("DispatchQueued: %v", err)
}
if res != "uptime output" {
t.Errorf("result = %q", res)
}
if len(rec.Running) != 1 || len(rec.Finalized) != 1 {
t.Fatalf("running=%d finalized=%d", len(rec.Running), len(rec.Finalized))
}
if rec.Finalized[0].Status != "completed" {
t.Errorf("finalized = %+v", rec.Finalized[0])
}
}
func TestExecutionDispatchQueuedFailure(t *testing.T) {
rec, exec, _ := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "boom", Err: errors.New("exit 2")}}
_, err := svcFor(t, rec, exec).DispatchQueued(context.Background(), targetID, "lxc:x", "uptime")
if err == nil {
t.Fatal("expected dispatch error")
}
if rec.Finalized[0].Status != "failed" {
t.Errorf("finalized = %+v, want failed", rec.Finalized[0])
}
}
func TestExecutionDispatchQueuedResolverFailure(t *testing.T) {
rec, exec, _ := newExecDeps(t)
failing := &portstest.FakeResolver{Err: errors.New("no route")}
svc := NewExecutionService(NewPolicyService(portstest.NewGovernanceStore()), exec, failing, rec)
_, err := svc.DispatchQueued(context.Background(), targetID, "lxc:x", "uptime")
if err == nil {
t.Fatal("expected resolver error")
}
if len(exec.Calls) != 0 {
t.Error("no execution on resolve failure")
}
if rec.Finalized[0].Status != "failed" {
t.Errorf("finalized = %+v, want failed", rec.Finalized[0])
}
}
func TestExecutionSubmitNoSessionGetsCorrelationID(t *testing.T) {
rec, exec, svc := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "ok"}}
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", // auto-run, no session
})
if res.Err != nil {
t.Fatalf("submit: %v", res.Err)
}
if len(rec.Created) != 1 || rec.Created[0].CorrelationID == "" {
t.Errorf("created = %+v, want a correlation id", rec.Created)
}
}
func TestExecutionSubmitAsyncStarted(t *testing.T) {
rec, exec, _ := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "sleeping"}}
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
store.Assent[string(agentID)+"/"+sessionOK] = true
svc := NewExecutionService(NewPolicyService(store), exec, &portstest.FakeResolver{Addr: "10.0.0.1", User: "root"}, rec)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "sleep 60", SessionID: sessionOK, Async: true,
})
if !res.AsyncStarted {
t.Fatalf("result = %+v, want async started", res)
}
if res.ExecutionID == "" {
t.Fatal("async submission must return the execution id synchronously")
}
}
func TestExecutionSubmitAutoRunQueueResume(t *testing.T) {
rec, exec, _ := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "done"}}
// A gated command queues; the same command with a live assent window
// auto-runs instead — the window route switch in Submit.
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
store.Assent[string(agentID)+"/"+sessionOK] = true
svc := NewExecutionService(NewPolicyService(store), exec, &portstest.FakeResolver{Addr: "10.0.0.1", User: "root"}, rec)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "systemctl enable caddy", SessionID: sessionOK,
})
if res.Decision.Action != DecisionAuto {
t.Fatalf("decision = %+v, want auto (via assent window)", res.Decision)
}
if len(rec.Queued) != 0 {
t.Errorf("window route must not queue, got %+v", rec.Queued)
}
}
// svcFor builds a service over the given recorder/executor with a plan-backed
// governance store.
func svcFor(t *testing.T, rec *portstest.ExecutionRecorder, exec *portstest.RecordingExecutor) *ExecutionService {
t.Helper()
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
return NewExecutionService(NewPolicyService(store), exec, &portstest.FakeResolver{Addr: "10.1.1.1", User: "root"}, rec)
}

View File

@@ -83,17 +83,32 @@ type ExecutionRepository interface {
type ApprovalDecideInput struct { type ApprovalDecideInput struct {
ApprovalID domain.UUID ApprovalID domain.UUID
Token string Token string
Approved bool Status string // "approved" | "denied" | "revoked"
Actor string Actor string
Audit []AuditEntry Audit []AuditEntry
Event *Event Event *Event
} }
// ApprovalResume identifies an un-gated execution the driving adapter should
// dispatch after an approved decision (SSH work is adapter-specific).
type ApprovalResume struct {
ExecutionID domain.UUID
TargetSlug string
Action string
}
// ApprovalDecideResult carries the updated approval and, on approve, the
// linked execution to resume.
type ApprovalDecideResult struct {
Approval domain.Approval
Resume *ApprovalResume
}
// ApprovalRepository is the approval aggregate. // ApprovalRepository is the approval aggregate.
type ApprovalRepository interface { type ApprovalRepository interface {
ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error)
Decide(ctx context.Context, input ApprovalDecideInput) (domain.Approval, error) Decide(ctx context.Context, input ApprovalDecideInput) (ApprovalDecideResult, error)
} }
// GovernanceStore is the policy read model: the facts the gating decision // GovernanceStore is the policy read model: the facts the gating decision

View File

@@ -627,3 +627,38 @@ func (r *ExecutionRecorder) MuLock() { r.mu.Lock() }
// MuUnlock releases the internal mutex. // MuUnlock releases the internal mutex.
func (r *ExecutionRecorder) MuUnlock() { r.mu.Unlock() } func (r *ExecutionRecorder) MuUnlock() { r.mu.Unlock() }
// ApprovalRepo is an in-memory ports.ApprovalRepository. ListPending returns
// the canned pending list; Decide records the input and replies with a canned
// result or the ErrStub.
type ApprovalRepo struct {
mu sync.Mutex
// Pending is returned by ListPending.
Pending []domain.Approval
// Decided records every Decide input for assertion.
Decided []ports.ApprovalDecideInput
// Result is returned by Decide (default: empty approval).
Result ports.ApprovalDecideResult
ErrStub error
}
// NewApprovalRepo builds an empty in-memory approval repository.
func NewApprovalRepo() *ApprovalRepo { return &ApprovalRepo{} }
// ListPending returns the canned pending list.
func (r *ApprovalRepo) ListPending(_ context.Context, _ domain.UUID, _ int) ([]domain.Approval, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.Pending, r.ErrStub
}
// Decide records the input and replies with the canned result.
func (r *ApprovalRepo) Decide(_ context.Context, in ports.ApprovalDecideInput) (ports.ApprovalDecideResult, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.Decided = append(r.Decided, in)
if r.ErrStub != nil {
return ports.ApprovalDecideResult{}, r.ErrStub
}
return r.Result, nil
}

View File

@@ -1,23 +1,25 @@
// Package execworker processes pending executions as a background daemon. // Package execworker processes pending executions as a background daemon.
// This provides a Postgres-backed queue: executions survive restarts, and // This provides a Postgres-backed queue: executions survive restarts, and
// per-execution advisory locks prevent duplicate processing across instances. // per-execution advisory locks prevent duplicate processing across instances.
//
// Since Phase 4 of the hexagonal refactor, the dispatch logic lives in
// ExecutionService.DispatchQueued (over CommandExecutor + TargetResolver
// ports); this package is a thin poller adapter that claims queued rows and
// delegates to the service.
package execworker package execworker
import ( import (
"context" "context"
"encoding/json"
"fmt"
"log/slog" "log/slog"
"os"
"strings"
"time" "time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/adapters/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/health" "github.com/dtoro/oikos/internal/health"
"github.com/dtoro/oikos/internal/remote"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -25,7 +27,12 @@ import (
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) { func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: starting") slog.Info("execworker: starting")
// Liveness probe // Build the ExecutionService with the same wiring as cmd/oikos/main.go.
executor := ssh.NewExecutor(ssh.FileSignerSource(), 5*time.Minute)
resolver := remote.NewResolver(pool)
policySvc := app.NewPolicyService(db.NewGovernanceRepo(pool))
execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool))
probe := health.New(2 * time.Minute) probe := health.New(2 * time.Minute)
probe.Serve(ctx, cfg.HealthListen) probe.Serve(ctx, cfg.HealthListen)
@@ -41,7 +48,7 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: shutting down") slog.Info("execworker: shutting down")
return return
case <-ticker.C: case <-ticker.C:
processPending(ctx, pool) processPending(ctx, pool, execSvc)
probe.Bump() probe.Bump()
} }
} }
@@ -59,11 +66,11 @@ func recoverOrphaned(ctx context.Context, pool *db.Pool) {
} }
} }
// processPending polls for pending executions and dispatches them. // processPending polls for pending executions and dispatches them via
func processPending(ctx context.Context, pool *db.Pool) { // ExecutionService.DispatchQueued.
func processPending(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService) {
rows, err := pool.Query(ctx, ` rows, err := pool.Query(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class, e.correlation_id, e.status, SELECT e.entity_id, COALESCE(t.slug, '') AS target_slug, e.action
COALESCE(t.slug, '') AS target_slug
FROM executions e FROM executions e
LEFT JOIN entities t ON t.id = e.target_entity_id LEFT JOIN entities t ON t.id = e.target_entity_id
WHERE e.status = 'proposed' WHERE e.status = 'proposed'
@@ -75,23 +82,16 @@ func processPending(ctx context.Context, pool *db.Pool) {
} }
defer rows.Close() defer rows.Close()
q := sqlcgen.New(pool)
for rows.Next() { for rows.Next() {
var execID, targetID *uuid.UUID var execID uuid.UUID
var action, riskClass, correlationID, status, targetSlug string var targetSlug, action string
if err := rows.Scan(&execID, &targetID, &action, &riskClass, &correlationID, &status, &targetSlug); err != nil { if err := rows.Scan(&execID, &targetSlug, &action); err != nil {
slog.Error("execworker: scan row", "error", err) slog.Error("execworker: scan row", "error", err)
continue continue
} }
if execID == nil {
continue
}
// At-most-once: try advisory lock on execution entity_id. // At-most-once: try advisory lock on execution entity_id.
// Acquire a dedicated connection so the session-scoped lock isn't lockKey := hashUUID(execID)
// released when the transient pool connection is returned.
lockKey := hashUUID(*execID)
lockConn, err := pool.Acquire(ctx) lockConn, err := pool.Acquire(ctx)
if err != nil { if err != nil {
slog.Error("execworker: acquire lock conn", "error", err) slog.Error("execworker: acquire lock conn", "error", err)
@@ -103,99 +103,24 @@ func processPending(ctx context.Context, pool *db.Pool) {
continue continue
} }
dispatch(ctx, pool, q, *execID, targetID, action, targetSlug, correlationID) // Release the lock on the same connection even if the dispatch
// panics — an un-released advisory lock would permanently orphan the
// Release the per-execution lock on the same connection. // execution (every future worker skips it at pg_try_advisory_lock).
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("execworker: panic dispatching execution", "execution", execID, "panic", r)
}
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey) lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey)
lockConn.Release() lockConn.Release()
} }()
}
func dispatch(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, execID uuid.UUID, targetID *uuid.UUID, action, targetSlug, correlationID string) { _, dispatchErr := execSvc.DispatchQueued(ctx, domain.UUID(execID.String()), targetSlug, action)
startedAt := time.Now() if dispatchErr != nil {
slog.Error("execworker: dispatch failed", "execution", execID, "error", dispatchErr)
// Mark running
_, err := pool.Exec(ctx,
`UPDATE executions SET status = 'running', started_at = $2 WHERE entity_id = $1`,
execID, startedAt)
if err != nil {
slog.Error("execworker: mark running", "error", err, "execution", execID)
return
} }
}()
// Resolve SSH target. If targetSlug is available, use it; otherwise resolve from targetID.
var host, user string
if targetSlug == "" && targetID != nil {
if err := pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", *targetID).Scan(&targetSlug); err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("resolve target slug: %v", err))
return
} }
}
if targetSlug != "" {
addr, sshUser, err := remote.ResolveHost(ctx, pool, targetSlug, "root")
if err == nil {
host, user = addr, sshUser
}
}
if host == "" {
failExecution(ctx, pool, execID, fmt.Sprintf("no reachable target: %s", targetSlug))
return
}
// Determine the command to run from the action field.
// Format: "action_name:{json_params}" or a raw command string.
cmd := action
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), &params) == nil {
if c, ok := params["command"].(string); ok && c != "" {
cmd = c
}
}
}
signer, err := actuator.LoadSigner(os.Getenv("OIKOS_SSH_KEY_PATH"))
if err != nil {
signer, err = actuator.LoadSigner("/etc/oikos/ssh_key")
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("load ssh key: %v", err))
return
}
}
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("ssh dial: %v", err))
return
}
defer client.Close()
out, err := actuator.RunCombinedOutput(ctx, client, cmd)
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("command: %v\noutput: %s", err, string(out)))
return
}
duration := time.Since(startedAt).Milliseconds()
resultJSON, _ := json.Marshal(map[string]any{"output": string(out), "success": true})
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: execID,
Status: "completed",
Result: resultJSON,
DurationMs: &[]int32{int32(duration)}[0],
Verified: true,
})
slog.Info("execworker: execution complete",
"execution", execID, "target", targetSlug, "duration_ms", duration)
}
func failExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, reason string) {
slog.Error("execworker: execution failed", "execution", execID, "error", reason)
resultJSON, _ := json.Marshal(map[string]any{"error": reason, "success": false})
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb, completed_at=now() WHERE entity_id=$1`,
execID, resultJSON)
} }
func hashUUID(id uuid.UUID) int { func hashUUID(id uuid.UUID) int {

View File

@@ -14,7 +14,6 @@ import (
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/execlog"
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -80,7 +79,7 @@ const sshExecTimeout = 10 * time.Minute
// sshExecStream runs a command and reports its combined output, forwarding // sshExecStream runs a command and reports its combined output, forwarding
// each chunk to sink as it arrives. A nil sink behaves exactly as before. // each chunk to sink as it arrives. A nil sink behaves exactly as before.
func sshExecStream(ctx context.Context, host, user, command string, sink execlog.Sink) (string, error) { func sshExecStream(ctx context.Context, host, user, command string, sink db.ExecLogSink) (string, error) {
initSSH() initSSH()
if len(_sshKey) == 0 { if len(_sshKey) == 0 {
return "", fmt.Errorf("no SSH key available") return "", fmt.Errorf("no SSH key available")
@@ -260,7 +259,7 @@ func (s *Server) executeApprovedAction(ctx context.Context, pool *db.Pool, execI
`SELECT correlation_id FROM executions WHERE entity_id = $1`, execID).Scan(&correlationID); qerr != nil { `SELECT correlation_id FROM executions WHERE entity_id = $1`, execID).Scan(&correlationID); qerr != nil {
correlationID = "" correlationID = ""
} }
sink, flushLogs := execlog.New(ctx, pool, execID, correlationID) sink, flushLogs := db.NewExecutionLog(ctx, pool, execID, correlationID)
defer flushLogs() defer flushLogs()
var output, cmd string var output, cmd string

View File

@@ -16,8 +16,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports/portstest" "github.com/dtoro/oikos/internal/core/ports/portstest"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -109,7 +109,8 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
app.NewPolicyService(portstest.NewGovernanceStore()), app.NewPolicyService(portstest.NewGovernanceStore()),
&portstest.RecordingExecutor{}, &portstest.FakeResolver{Addr: "127.0.0.1"}, &portstest.RecordingExecutor{}, &portstest.FakeResolver{Addr: "127.0.0.1"},
portstest.NewExecutionRecorder()) portstest.NewExecutionRecorder())
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc) approvalSvc := app.NewApprovalService(db.NewApprovalRepo(pool))
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc, approvalSvc)
} }
// testAuthToken is the static bearer token devConfig() configures. There is // testAuthToken is the static bearer token devConfig() configures. There is

View File

@@ -5,15 +5,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
) )
// ─── Approvals ───────────────────────────────────────────────────────── // ─── Approvals ─────────────────────────────────────────────────────────
@@ -22,8 +19,8 @@ func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequest
limit := clampLimit(req.Params.Limit) limit := clampLimit(req.Params.Limit)
var status *string var status *string
if req.Params.Status != nil { if req.Params.Status != nil {
s := string(*req.Params.Status) st := string(*req.Params.Status)
status = &s status = &st
} }
var kind *string var kind *string
if req.Params.Kind != nil { if req.Params.Kind != nil {
@@ -79,6 +76,10 @@ func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequest
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
} }
// DecideApproval is the thin presenter over ApprovalService.Decide: it maps
// the request onto the service command, then dispatches the SSH work for an
// approved execution in a background goroutine. The HMAC/window/execution
// un-gating logic lives in the service + repository (Phase 9 convergence).
func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) { func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
if req.Body == nil { if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
@@ -89,184 +90,43 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
return nil, err return nil, err
} }
actorType, actor := actorInfo(ctx) _, actor := actorInfo(ctx)
tx, err := s.pool.Begin(ctx) var token string
if err != nil { if req.Body.Token != nil {
return nil, err token = *req.Body.Token
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if req.Body.Token != nil && *req.Body.Token != "" {
var tokenHash *string
var apprStatus string
var expiresAt time.Time
err := tx.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
id).Scan(&tokenHash, &apprStatus, &expiresAt)
if err != nil || tokenHash == nil {
return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
}
if apprStatus != "pending" {
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
}
if expiresAt.Before(time.Now()) {
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
}
if *tokenHash != hashToken(*req.Body.Token) {
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
}
} }
// Map decision to status. result, err := s.approvalSvc.Decide(ctx, app.ApprovalDecideCmd{
var status string ApprovalID: domain.UUID(id.String()),
switch req.Body.Decision { Token: token,
case gen.Approve: Decision: string(req.Body.Decision),
status = "approved" Actor: actor,
case gen.Deny:
status = "denied"
case gen.Revoke:
status = "revoked"
default:
return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
}
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: id,
Status: status,
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read approval.
app, err := q.GetApprovalByID(ctx, id)
if err != nil {
return nil, err
}
approval := approvalToGen(app)
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
nil,
map[string]any{"decision": status}); auditErr != nil {
return nil, auditErr
}
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
map[string]any{"decision": status, "actor": actor}); evErr != nil {
return nil, evErr
}
// On approve: execute the linked gated command.
if status == "approved" {
var execID, targetID uuid.UUID
var actionStr, targetSlug, riskClass string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
if err == nil {
// Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
safego.Go("httpapi:executeApprovedAction", func() {
s.executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
}) })
// Status only — risk_class was set correctly at request time if err != nil {
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
// a hardcoded 'config_mutation' here corrupted the audit ledger
// for every other risk class, including destructive.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)
// Approving a plan step — by ANY route (this endpoint backs both
// the chat Approve button and chat-assent) — opens/extends the
// agent's assent window. This is the scope gate the Nomos
// auto-continuation worker checks: with the window open, the
// finished execution's result is fed back to the agent so it runs
// the plan to completion. Without opening it here, approving via
// the button (instead of typing "go ahead") would silently not
// auto-continue.
var agentID *uuid.UUID
if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires)
// Approving a DESTRUCTIVE step via the button is exactly as
// explicit as a typed "I confirm" — the operator affirmatively
// clicked Approve on a card that said DESTRUCTIVE. Open the
// same short, target-scoped destructive window chat-assent's
// typed-confirm path opens, for parity: a multi-step
// destructive recovery (stop, then destroy) shouldn't need a
// fresh confirmation per click any more than it needs one per
// typed phrase.
if riskClass == "destructive" && targetSlug != "" {
dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`,
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires)
}
}
slog.Info("httpapi: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
} else {
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
}
} else {
// Denied/revoked: reflect it on the linked execution too. Previously
// only the approvals row changed, so the execution stayed
// 'pending_approval' forever — any UI/poller reading execution
// status (not approval status) never saw the decision.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
}
// If this execution belongs to a nomos session, flip it out of
// awaiting_input — the counterpart to classifyAndGate flipping it IN
// the moment the approval was created (internal/mcp/server.go's
// markSessionAwaitingApproval). Runs for all three decisions (approve/
// deny/revoke): each one is an operator answer to "what do I do about
// this?", same as answerQuestion's unconditional resume-to-executing
// (cmd/nomos/store.go) for a session_questions answer.
var awaitingSessionID string
_ = tx.QueryRow(ctx, `
SELECT pe.session_id FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE ex.approval_id = $1
LIMIT 1`, id).Scan(&awaitingSessionID)
if awaitingSessionID != "" {
if rtag, rerr := tx.Exec(ctx, `
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
var taskEntID *uuid.UUID
var e uuid.UUID
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
taskEntID = &e
}
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err return nil, err
} }
return gen.DecideApproval200JSONResponse(approval), nil // Approve: dispatch the SSH work the repo just un-gated, in the
// background so the HTTP response is not blocked by the execution.
if result.Resume != nil {
res := result.Resume
safego.Go("httpapi:executeApprovedAction", func() {
s.executeApprovedAction(context.Background(), s.pool, uuid.MustParse(string(res.ExecutionID)), res.TargetSlug, res.Action)
})
slog.Info("httpapi: approved execution queued",
"execution_id", res.ExecutionID, "target", res.TargetSlug, "action", res.Action)
}
return gen.DecideApproval200JSONResponse(approvalToGenApproval(result.Approval)), nil
} }
func approvalToGen(a sqlcgen.Approval) gen.Approval { // approvalToGenApproval maps the domain approval onto the wire shape. The
// subject slug is not carried on the domain type, so it is left empty on the
// decide path.
func approvalToGenApproval(a domain.Approval) gen.Approval {
app := gen.Approval{ app := gen.Approval{
Id: a.EntityID, Id: uuid.MustParse(string(a.EntityID)),
Action: a.Action, Action: a.Action,
RiskClass: a.RiskClass, RiskClass: a.RiskClass,
Kind: gen.ApprovalKind(a.Kind), Kind: gen.ApprovalKind(a.Kind),
@@ -275,12 +135,13 @@ func approvalToGen(a sqlcgen.Approval) gen.Approval {
DecidedAt: a.DecidedAt, DecidedAt: a.DecidedAt,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
} }
if a.DecidedBy != nil { if a.DecidedBy != "" {
s := a.DecidedBy.String() u := uuid.MustParse(string(a.DecidedBy))
app.DecidedBy = &s str := u.String()
app.DecidedBy = &str
} }
var payload map[string]any if len(a.Payload) > 0 {
if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 { payload := map[string]any(a.Payload)
app.Payload = &payload app.Payload = &payload
} }
return app return app

View File

@@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/dtoro/oikos/internal/execlog" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -34,7 +34,7 @@ func (s *Server) serveExecutionLogs(w http.ResponseWriter, req *http.Request) {
} }
} }
chunks, err := execlog.Read(ctx, s.pool, execID, limit) chunks, err := db.ReadExecutionLog(ctx, s.pool, execID, limit)
if err != nil { if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return return

View File

@@ -24,8 +24,8 @@ import (
"time" "time"
"github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
@@ -77,6 +77,10 @@ type Server struct {
// execSvc is the run-submission use-case (Phase 4): PolicyService // execSvc is the run-submission use-case (Phase 4): PolicyService
// gating + auto-run/queue dispatch, shared by the MCP tools. // gating + auto-run/queue dispatch, shared by the MCP tools.
execSvc *app.ExecutionService execSvc *app.ExecutionService
// approvalSvc is the approval-decision use-case (Phase 9): token
// verification + execution un-gating, shared by the REST decision
// endpoint and the MCP decide_approval path.
approvalSvc *app.ApprovalService
} }
// NewHandler builds the full HTTP handler: /healthz (unauthenticated, // NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -86,7 +90,7 @@ type Server struct {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases // before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks. // and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) http.Handler { func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService, approvalSvc *app.ApprovalService) http.Handler {
s := &Server{ s := &Server{
pool: pool, pool: pool,
cfg: cfg, cfg: cfg,
@@ -100,6 +104,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
provisioning: provisioning, provisioning: provisioning,
seeds: seeds, seeds: seeds,
execSvc: execSvc, execSvc: execSvc,
approvalSvc: approvalSvc,
} }
// Wire secrets backend: Infisical primary with SOPS DR fallback. // Wire secrets backend: Infisical primary with SOPS DR fallback.
@@ -948,10 +953,10 @@ main();
// ListenAndServe runs the API server with graceful shutdown on ctx cancel // ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit. // (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) error { func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService, approvalSvc *app.ApprovalService) error {
srv := &http.Server{ srv := &http.Server{
Addr: cfg.APIListen, Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc), Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc, approvalSvc),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }

View File

@@ -6,7 +6,6 @@ import (
"strings" "strings"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/execlog"
"github.com/dtoro/oikos/internal/remote" "github.com/dtoro/oikos/internal/remote"
) )
@@ -65,7 +64,7 @@ func discoverInfraDrift(ctx context.Context, pool *db.Pool) any {
} }
for _, cmd := range []string{"pct list", "qm list"} { for _, cmd := range []string{"pct list", "qm list"} {
out, eerr := sshExecStream(ctx, et.Host, et.User, et.Wrap(cmd), out, eerr := sshExecStream(ctx, et.Host, et.User, et.Wrap(cmd),
execlog.Sink(func(string, []byte) {})) db.ExecLogSink(func(string, []byte) {}))
if eerr != nil { if eerr != nil {
hostErrors[hs+" "+cmd] = eerr.Error() hostErrors[hs+" "+cmd] = eerr.Error()
continue continue

View File

@@ -20,12 +20,11 @@ import (
"time" "time"
"github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/execlog"
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/policy" "github.com/dtoro/oikos/internal/policy"
"github.com/dtoro/oikos/internal/remote" "github.com/dtoro/oikos/internal/remote"
@@ -471,7 +470,7 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
// sshExecStream runs a command and reports its combined output, forwarding // sshExecStream runs a command and reports its combined output, forwarding
// each chunk to sink as it arrives. A nil sink behaves exactly as before. // each chunk to sink as it arrives. A nil sink behaves exactly as before.
func sshExecStream(ctx context.Context, host, user, command string, sink execlog.Sink) (string, error) { func sshExecStream(ctx context.Context, host, user, command string, sink db.ExecLogSink) (string, error) {
initSSH() initSSH()
if len(sshKey) == 0 { if len(sshKey) == 0 {
return "", fmt.Errorf("no SSH key available") return "", fmt.Errorf("no SSH key available")
@@ -619,7 +618,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionS
SessionID: sessionID, SessionID: sessionID,
Async: app.IsLongRunningCommand(command), Async: app.IsLongRunningCommand(command),
SinkFactory: func(ctx context.Context, execID domain.UUID, correlationID string) (func(string, []byte), func()) { SinkFactory: func(ctx context.Context, execID domain.UUID, correlationID string) (func(string, []byte), func()) {
return execlog.New(ctx, pool, uuid.MustParse(string(execID)), correlationID) return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
}, },
}) })
return renderSubmit(targetSlug, command, res) return renderSubmit(targetSlug, command, res)

View File

@@ -11,8 +11,8 @@ func TestIsAssent_Positive(t *testing.T) {
"ok go ahead and run it", "ok go ahead and run it",
} }
for _, c := range cases { for _, c := range cases {
if !isAssent(c) { if !IsAssent(c) {
t.Errorf("isAssent(%q) = false, want true", c) t.Errorf("IsAssent(%q) = false, want true", c)
} }
} }
} }
@@ -24,8 +24,8 @@ func TestIsAssent_Negative(t *testing.T) {
"maybe later", "", "maybe later", "",
} }
for _, c := range cases { for _, c := range cases {
if isAssent(c) { if IsAssent(c) {
t.Errorf("isAssent(%q) = true, want false", c) t.Errorf("IsAssent(%q) = true, want false", c)
} }
} }
} }
@@ -38,8 +38,8 @@ func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
"wait, not yet please", "wait, not yet please",
} }
for _, c := range cases { for _, c := range cases {
if isAssent(c) { if IsAssent(c) {
t.Errorf("isAssent(%q) = true, want false (negation should block)", c) t.Errorf("IsAssent(%q) = true, want false (negation should block)", c)
} }
} }
} }
@@ -47,7 +47,7 @@ func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found // TestIsAssent_WholeWordBoundary regression-tests a real false positive found
// live: the old substring check matched "yes" inside "yesterday" (and would // live: the old substring check matched "yes" inside "yesterday" (and would
// equally match "confirm" inside "confirmed"/"unconfirmed" for // equally match "confirm" inside "confirmed"/"unconfirmed" for
// isTypedConfirmation below) because only negation used a word-boundary // IsTypedConfirmation below) because only negation used a word-boundary
// check — assent/confirm words used a bare strings.Contains. Confirmed via a // check — assent/confirm words used a bare strings.Contains. Confirmed via a
// throwaway probe before being fixed; kept here permanently so a future // throwaway probe before being fixed; kept here permanently so a future
// change can't silently reintroduce it. // change can't silently reintroduce it.
@@ -57,14 +57,14 @@ func TestIsAssent_WholeWordBoundary(t *testing.T) {
"my eyesight isn't great, what does that say", "my eyesight isn't great, what does that say",
} }
for _, c := range cases { for _, c := range cases {
if isAssent(c) { if IsAssent(c) {
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c) t.Errorf("IsAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
} }
} }
} }
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real // TestIsTypedConfirmation_ContractedNegation regression-tests the other real
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and // false positive: IsTypedConfirmation gates DESTRUCTIVE actions, and
// "confirm" matching inside "confirmed" combined with contracted negatives // "confirm" matching inside "confirmed" combined with contracted negatives
// ("haven't") not being in negationWords meant a message that explicitly // ("haven't") not being in negationWords meant a message that explicitly
// says the operator has NOT confirmed something could read as confirming it. // says the operator has NOT confirmed something could read as confirming it.
@@ -75,8 +75,8 @@ func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
"we can't confirm that until tomorrow", "we can't confirm that until tomorrow",
} }
for _, c := range cases { for _, c := range cases {
if isTypedConfirmation(c) { if IsTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c) t.Errorf("IsTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
} }
} }
} }
@@ -89,8 +89,8 @@ func TestIsTypedConfirmation(t *testing.T) {
"yes I confirm", "yes I confirm",
} }
for _, c := range positive { for _, c := range positive {
if !isTypedConfirmation(c) { if !IsTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = false, want true", c) t.Errorf("IsTypedConfirmation(%q) = false, want true", c)
} }
} }
negative := []string{ negative := []string{
@@ -98,8 +98,8 @@ func TestIsTypedConfirmation(t *testing.T) {
"no, don't confirm yet", "wait", "", "no, don't confirm yet", "wait", "",
} }
for _, c := range negative { for _, c := range negative {
if isTypedConfirmation(c) { if IsTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (only explicit confirm should pass)", c) t.Errorf("IsTypedConfirmation(%q) = true, want false (only explicit confirm should pass)", c)
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package main package session
import ( import (
"context" "context"
@@ -14,30 +14,31 @@ import (
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const maxToolResultSize = 4096 const maxToolResultSize = 4096
// errPlanInFlight is returned by proposePlan when called again after a step // ErrPlanInFlight is returned by proposePlan when called again after a step
// has already started. The agent must advance the existing plan with // has already started. The agent must advance the existing plan with
// update_plan_step + run instead of re-proposing — re-proposing was the // update_plan_step + run instead of re-proposing — re-proposing was the
// source of duplicate plans in the sidebar (operator-reported 2026-07-14). // source of duplicate plans in the sidebar (operator-reported 2026-07-14).
// The caller translates this into a directive tool result. // The caller translates this into a directive tool result.
var errPlanInFlight = errors.New("plan already in flight") var ErrPlanInFlight = errors.New("plan already in flight")
// errPlanStepNotFound is returned by updatePlanStep when no step matches the // ErrPlanStepNotFound is returned by updatePlanStep when no step matches the
// given seq in the CURRENT (MAX) generation — either the seq is out of range, // given seq in the CURRENT (MAX) generation — either the seq is out of range,
// or (after a re-plan) the model addressed a stale 1-based number. seq is // or (after a re-plan) the model addressed a stale 1-based number. seq is
// generation-relative, so this never resurrects a superseded generation's row. // generation-relative, so this never resurrects a superseded generation's row.
// The caller translates it into a directive tool result (P0.1). // The caller translates it into a directive tool result (P0.1).
var errPlanStepNotFound = errors.New("plan step not found in current generation") var ErrPlanStepNotFound = errors.New("plan step not found in current generation")
type store struct { type Store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
func newStore(ctx context.Context, databaseURL string) (*store, error) { func New(ctx context.Context, databaseURL string) (*Store, error) {
if databaseURL == "" { if databaseURL == "" {
return nil, nil return nil, nil
} }
@@ -49,8 +50,8 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
pool.Close() pool.Close()
return nil, fmt.Errorf("ping db: %w", err) return nil, fmt.Errorf("ping db: %w", err)
} }
s := &store{pool: pool} s := &Store{pool: pool}
s.cleanupStaleExecutions(ctx, time.Hour) s.CleanupStaleExecutions(ctx, time.Hour)
return s, nil return s, nil
} }
@@ -62,7 +63,7 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
// `running` and `pending_approval` executions pile up in the DB and pollute // `running` and `pending_approval` executions pile up in the DB and pollute
// the Operations page + session rail badges. Called at startup (maxAge=1h) // the Operations page + session rail badges. Called at startup (maxAge=1h)
// and periodically (maxAge=10m) by the sweep worker. // and periodically (maxAge=10m) by the sweep worker.
func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int { func (s *Store) CleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int {
if s == nil { if s == nil {
return 0 return 0
} }
@@ -85,12 +86,21 @@ func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration
return n return n
} }
func (s *store) close() { func (s *Store) Close() {
if s.pool != nil { if s.pool != nil {
s.pool.Close() s.pool.Close()
} }
} }
// Exec runs a raw SQL query against the store's pool. Used by the agent to
// write autonomy_settings rows directly.
func (s *Store) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if s == nil {
return pgconn.CommandTag{}, nil
}
return s.pool.Exec(ctx, sql, args...)
}
// session is a chat session elevated to a task: goal-structured work with a // session is a chat session elevated to a task: goal-structured work with a
// lifecycle status and an outcome (see migration 018 / the task-board plan). // lifecycle status and an outcome (see migration 018 / the task-board plan).
// Outcome/Summary/EntityID are empty until set, hence omitempty. // Outcome/Summary/EntityID are empty until set, hence omitempty.
@@ -104,7 +114,7 @@ func (s *store) close() {
// Blocker is a short structured reason: approval_timeout, // Blocker is a short structured reason: approval_timeout,
// classifier_overreach, user_abandoned, tool_error, etc. See // classifier_overreach, user_abandoned, tool_error, etc. See
// plans/2026-07-20-session-review-ten-sessions.md P1.5. // plans/2026-07-20-session-review-ten-sessions.md P1.5.
type session struct { type Session struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Actor string `json:"actor"` Actor string `json:"actor"`
@@ -129,7 +139,7 @@ type session struct {
DurationSeconds int `json:"duration_seconds,omitempty"` DurationSeconds int `json:"duration_seconds,omitempty"`
} }
type message struct { type Message struct {
ID string `json:"id"` ID string `json:"id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Role string `json:"role"` Role string `json:"role"`
@@ -137,9 +147,9 @@ type message struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func (s *store) createSession(ctx context.Context, title string) (*session, error) { func (s *Store) CreateSession(ctx context.Context, title string) (*Session, error) {
if s == nil { if s == nil {
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil return &Session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
} }
var id string var id string
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
@@ -152,7 +162,7 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
// off the existing relationships graph. Best-effort: a failure here must not // off the existing relationships graph. Best-effort: a failure here must not
// block the chat — the session is usable without a graph anchor. // block the chat — the session is usable without a graph anchor.
entityID := s.createTaskEntity(ctx, id, title) entityID := s.createTaskEntity(ctx, id, title)
return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active", return &Session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
} }
@@ -160,7 +170,7 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
// anchors this task's knowledge and involved-entity relationships, and records // anchors this task's knowledge and involved-entity relationships, and records
// it on the session. Returns the entity id, or "" on failure — non-fatal, see // it on the session. Returns the entity id, or "" on failure — non-fatal, see
// caller. Requires the 'task' entity type (seeds/ontology.yaml). // caller. Requires the 'task' entity type (seeds/ontology.yaml).
func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string { func (s *Store) createTaskEntity(ctx context.Context, sessionID, title string) string {
entityID, _ := uuid.NewV7() entityID, _ := uuid.NewV7()
slug := "task:" + sessionID slug := "task:" + sessionID
// name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the // name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the
@@ -191,7 +201,7 @@ func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) s
return entityID.String() return entityID.String()
} }
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error { func (s *Store) SaveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
if s == nil { if s == nil {
return nil return nil
} }
@@ -208,7 +218,7 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
// the worker inserts a placeholder immediately and updates the SAME row as // the worker inserts a placeholder immediately and updates the SAME row as
// each tool call completes, so a poller sees individual steps land, not just // each tool call completes, so a poller sees individual steps land, not just
// a final rolled-up summary. // a final rolled-up summary.
func (s *store) insertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) { func (s *Store) InsertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) {
if s == nil { if s == nil {
return uuid.Nil, nil return uuid.Nil, nil
} }
@@ -219,7 +229,7 @@ func (s *store) insertMessageReturningID(ctx context.Context, sessionID, role st
return id, err return id, err
} }
func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error { func (s *Store) UpdateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error {
if s == nil || id == uuid.Nil { if s == nil || id == uuid.Nil {
return nil return nil
} }
@@ -234,19 +244,19 @@ func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.Ra
// retries failed), the placeholder row is deleted instead of persisting an // retries failed), the placeholder row is deleted instead of persisting an
// empty assistant bubble — the error was already streamed to the frontend // empty assistant bubble — the error was already streamed to the frontend
// via the 'done with error=true' event, so the operator sees it inline. // via the 'done with error=true' event, so the operator sees it inline.
func (s *store) deleteMessage(ctx context.Context, id uuid.UUID) { func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) {
if s == nil || id == uuid.Nil { if s == nil || id == uuid.Nil {
return return
} }
s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE id = $1`, id) s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE id = $1`, id)
} }
// lastUserMessage returns the most recent user message text for a session, // lastUserMessage returns the most recent user message text for a Session,
// or "" if none. Used to build a context-rich reconnect/resume note: instead // or "" if none. Used to build a context-rich reconnect/resume note: instead
// of a generic "report your state," the note can say "the operator's last // of a generic "report your state," the note can say "the operator's last
// message was X — advance the plan" so the agent doesn't re-propose or // message was X — advance the plan" so the agent doesn't re-propose or
// re-execute on a reconnect (the operator-reported 2026-07-14 divergence). // re-execute on a reconnect (the operator-reported 2026-07-14 divergence).
func (s *store) lastUserMessage(ctx context.Context, sessionID string) string { func (s *Store) lastUserMessage(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return "" return ""
} }
@@ -272,7 +282,7 @@ func (s *store) lastUserMessage(ctx context.Context, sessionID string) string {
// the plan with update_plan_step + run" instead of the generic "report // the plan with update_plan_step + run" instead of the generic "report
// your state" (which caused the agent to re-propose and duplicate the plan // your state" (which caused the agent to re-propose and duplicate the plan
// in the sidebar — operator-reported 2026-07-14). // in the sidebar — operator-reported 2026-07-14).
func (s *store) hasPlanInFlight(ctx context.Context, sessionID string) bool { func (s *Store) HasPlanInFlight(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false return false
} }
@@ -292,12 +302,12 @@ func (s *store) hasPlanInFlight(ctx context.Context, sessionID string) bool {
// duplicate the plan on a reconnect (operator-reported 2026-07-14); this // duplicate the plan on a reconnect (operator-reported 2026-07-14); this
// enrichment gives the agent enough context to do the right thing even // enrichment gives the agent enough context to do the right thing even
// through the reconnect path. // through the reconnect path.
func (s *store) enrichResumeNote(ctx context.Context, sessionID, base string) string { func (s *Store) EnrichResumeNote(ctx context.Context, sessionID, base string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return base return base
} }
last := s.lastUserMessage(ctx, sessionID) last := s.lastUserMessage(ctx, sessionID)
inFlight := s.hasPlanInFlight(ctx, sessionID) inFlight := s.HasPlanInFlight(ctx, sessionID)
if last == "" && !inFlight { if last == "" && !inFlight {
return base return base
} }
@@ -346,21 +356,21 @@ func truncateToolResults(content json.RawMessage) json.RawMessage {
return out return out
} }
func (s *store) touchSession(ctx context.Context, id string) { func (s *Store) TouchSession(ctx context.Context, id string) {
if s != nil { if s != nil {
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
} }
} }
func (s *store) listSessions(ctx context.Context) ([]session, error) { func (s *Store) ListSessions(ctx context.Context) ([]Session, error) {
return s.listSessionsFiltered(ctx, listFilter{Limit: 50}) return s.ListSessionsFiltered(ctx, ListFilter{Limit: 50})
} }
// listFilter carries the optional WHERE/ORDER clauses added by P2.8 // listFilter carries the optional WHERE/ORDER clauses added by P2.8
// (filtering & pagination). All fields optional; empty values are no-ops. // (filtering & pagination). All fields optional; empty values are no-ops.
// The handler in main.go parses query params into this struct so the SQL // The handler in main.go parses query params into this struct so the SQL
// builder here is the single source of truth for what filters exist. // builder here is the single source of truth for what filters exist.
type listFilter struct { type ListFilter struct {
Outcome string // exact match on outcome (success/partial/failure) Outcome string // exact match on outcome (success/partial/failure)
Status string // exact match on status (active/done/failed/executing) Status string // exact match on status (active/done/failed/executing)
EntityID string // exact match on entity_id (UUID) EntityID string // exact match on entity_id (UUID)
@@ -370,7 +380,7 @@ type listFilter struct {
Limit int // default 50, clamped by the handler Limit int // default 50, clamped by the handler
} }
func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) { func (s *Store) ListSessionsFiltered(ctx context.Context, f ListFilter) ([]Session, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
@@ -474,9 +484,9 @@ func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]sessi
} }
defer rows.Close() defer rows.Close()
var out []session var out []Session
for rows.Next() { for rows.Next() {
var sess session var sess Session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
@@ -488,11 +498,11 @@ func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]sessi
return out, rows.Err() return out, rows.Err()
} }
func (s *store) getSession(ctx context.Context, id string) (*session, error) { func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
var sess session var sess Session
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, `SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''), COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''),
@@ -533,7 +543,7 @@ func (s *store) getSession(ctx context.Context, id string) (*session, error) {
// prior session's goal + summary at set_goal time lets the agent pick up the // prior session's goal + summary at set_goal time lets the agent pick up the
// thread instead of rediscovering it. See // thread instead of rediscovering it. See
// plans/2026-07-20-session-review-ten-sessions.md P1.3. // plans/2026-07-20-session-review-ten-sessions.md P1.3.
func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]session, error) { func (s *Store) RecentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]Session, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
@@ -576,9 +586,9 @@ func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID stri
} }
defer rows.Close() defer rows.Close()
var out []session var out []Session
for rows.Next() { for rows.Next() {
var sess session var sess Session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
@@ -596,7 +606,7 @@ func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID stri
// it's run. For LLM replay, see getRecentMessages: sending the operator's // it's run. For LLM replay, see getRecentMessages: sending the operator's
// full transcript is fine; sending the model's full transcript on every // full transcript is fine; sending the model's full transcript on every
// single turn is not (see getRecentMessages's doc comment). // single turn is not (see getRecentMessages's doc comment).
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) { func (s *Store) GetMessages(ctx context.Context, sessionID string) ([]Message, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
@@ -608,9 +618,9 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
} }
defer rows.Close() defer rows.Close()
var out []message var out []Message
for rows.Next() { for rows.Next() {
var m message var m Message
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err return nil, err
} }
@@ -644,11 +654,11 @@ type SessionToolCall struct {
// it a single SQL + Go walk on the server. Each tool_use/tool_result pair // it a single SQL + Go walk on the server. Each tool_use/tool_result pair
// is emitted as two rows (same id, different Type), preserving the // is emitted as two rows (same id, different Type), preserving the
// persisted shape — clients that want the merged shape can group by ID. // persisted shape — clients that want the merged shape can group by ID.
func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) { func (s *Store) GetSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
msgs, err := s.getMessages(ctx, sessionID) msgs, err := s.GetMessages(ctx, sessionID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -699,7 +709,7 @@ func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]Se
// heavily-autonomous tasks (many auto-continuation cycles) this system is // heavily-autonomous tasks (many auto-continuation cycles) this system is
// built to run longest. Fetches limit+1 rows to detect "there's more" // built to run longest. Fetches limit+1 rows to detect "there's more"
// without a separate COUNT query. // without a separate COUNT query.
func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []message, truncated bool, err error) { func (s *Store) GetRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []Message, truncated bool, err error) {
if s == nil { if s == nil {
return nil, false, nil return nil, false, nil
} }
@@ -712,9 +722,9 @@ func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit i
} }
defer rows.Close() defer rows.Close()
var out []message var out []Message
for rows.Next() { for rows.Next() {
var m message var m Message
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, false, err return nil, false, err
} }
@@ -736,7 +746,7 @@ func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit i
return out, truncated, nil return out, truncated, nil
} }
func (s *store) deleteSession(ctx context.Context, id string) error { func (s *Store) DeleteSession(ctx context.Context, id string) error {
if s == nil { if s == nil {
return nil return nil
} }
@@ -763,9 +773,9 @@ func (s *store) deleteSession(ctx context.Context, id string) error {
return nil return nil
} }
// taskEntityPtr returns the task entity id for a session, or nil — used as the // taskEntityPtr returns the task entity id for a Session, or nil — used as the
// entity_id on task-scoped events so they anchor to the task in the graph. // entity_id on task-scoped events so they anchor to the task in the graph.
func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID { func (s *Store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID {
var id uuid.UUID var id uuid.UUID
if err := s.pool.QueryRow(ctx, if err := s.pool.QueryRow(ctx,
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil { `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
@@ -795,7 +805,7 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
// sessions.md P1.4 (session 55927f0a had two set_goal calls with the first // sessions.md P1.4 (session 55927f0a had two set_goal calls with the first
// implicitly abandoned when the operator said "lets just keep ludo-library // implicitly abandoned when the operator said "lets just keep ludo-library
// then"). // then").
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error { func (s *Store) SetGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil return nil
} }
@@ -845,7 +855,7 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
// //
// Returns true if the session was actually reopened (was terminal), false if // Returns true if the session was actually reopened (was terminal), false if
// it was already active (no-op). // it was already active (no-op).
func (s *store) reopenSession(ctx context.Context, sessionID string) bool { func (s *Store) ReopenSession(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false return false
} }
@@ -874,7 +884,7 @@ func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
} }
// planStepInput is one step as the agent proposes it. // planStepInput is one step as the agent proposes it.
type planStepInput struct { type PlanStepInput struct {
Title string Title string
Detail string Detail string
TargetSlug string TargetSlug string
@@ -895,7 +905,7 @@ type planStepInput struct {
// Refusing is the correct default — the tool result tells the agent how // Refusing is the correct default — the tool result tells the agent how
// to advance, and the generation column tracks revisions if a genuine // to advance, and the generation column tracks revisions if a genuine
// re-plan is ever allowed. // re-plan is ever allowed.
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) { func (s *Store) ProposePlan(ctx context.Context, sessionID string, steps []PlanStepInput) ([]map[string]any, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil, nil return nil, nil
} }
@@ -920,7 +930,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// A plan is already in flight (a step is running/done/failed/...). // A plan is already in flight (a step is running/done/failed/...).
// Refuse the re-proposal — the agent must advance with // Refuse the re-proposal — the agent must advance with
// update_plan_step + run. The caller surfaces a directive. // update_plan_step + run. The caller surfaces a directive.
return nil, errPlanInFlight return nil, ErrPlanInFlight
} }
// Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE). // Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE).
// The rows are kept for the generation counter (MAX(generation)+1 below) // The rows are kept for the generation counter (MAX(generation)+1 below)
@@ -992,7 +1002,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// be marked complete while an earlier step is still pending, preventing the // be marked complete while an earlier step is still pending, preventing the
// agent from marking step 5 done before step 4 (observed in production: the // agent from marking step 5 done before step 4 (observed in production: the
// agent rushed to close all steps in a final turn, in reverse order). // agent rushed to close all steps in a final turn, in reverse order).
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error { func (s *Store) UpdatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil return nil
} }
@@ -1011,7 +1021,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
return err return err
} }
if curGen == 0 { if curGen == 0 {
return errPlanStepNotFound return ErrPlanStepNotFound
} }
stamp := "" stamp := ""
switch status { switch status {
@@ -1044,7 +1054,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
// stamp is a fixed literal from the switch above — never user input. // stamp is a fixed literal from the switch above — never user input.
// status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a // status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a
// replaced row, but if it ever could, this refuses the write instead of // replaced row, but if it ever could, this refuses the write instead of
// resurrecting it. No matching row → errPlanStepNotFound (stale/out-of-range seq). // resurrecting it. No matching row → ErrPlanStepNotFound (stale/out-of-range seq).
if status == "replaced" && replacedReason != "" { if status == "replaced" && replacedReason != "" {
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps UPDATE session_plan_steps
@@ -1053,7 +1063,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug) RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound return ErrPlanStepNotFound
} }
return err return err
} }
@@ -1065,7 +1075,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug) RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound return ErrPlanStepNotFound
} }
return err return err
} }
@@ -1090,21 +1100,21 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
return nil return nil
} }
// errTaskAlreadyComplete is returned by completeTask when the session is // ErrTaskAlreadyComplete is returned by completeTask when the session is
// already in a terminal state (done/failed/partial). The agent sometimes // already in a terminal state (done/failed/partial). The agent sometimes
// re-calls complete_task after a UI clarification (operator-reported // re-calls complete_task after a UI clarification (operator-reported
// 2026-07-14) — without this guard, the re-completion produces duplicate // 2026-07-14) — without this guard, the re-completion produces duplicate
// knowledge entries and erodes audit-log clarity. The caller translates this // knowledge entries and erodes audit-log clarity. The caller translates this
// into a directive tool result. // into a directive tool result.
var errTaskAlreadyComplete = errors.New("task already complete") var ErrTaskAlreadyComplete = errors.New("task already complete")
// completeTask sets a task's terminal state, outcome, and one-line summary, // completeTask sets a task's terminal state, outcome, and one-line summary,
// mirrors the outcome onto the task entity's attributes (so the board/graph // mirrors the outcome onto the task entity's attributes (so the board/graph
// show it), and publishes task.status for the live context panel. outcome is // show it), and publishes task.status for the live context panel. outcome is
// success|failure|partial; status is derived (failure → failed, else done). // success|failure|partial; status is derived (failure → failed, else done).
// Returns errTaskAlreadyComplete if the session is already terminal — the // Returns ErrTaskAlreadyComplete if the session is already terminal — the
// agent must not re-complete a finished task. // agent must not re-complete a finished task.
func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary string) error { func (s *Store) CompleteTask(ctx context.Context, sessionID, outcome, summary string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil return nil
} }
@@ -1119,7 +1129,7 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
// Session doesn't exist or query failed — let the rest of the // Session doesn't exist or query failed — let the rest of the
// function proceed; it'll fail safely on the UPDATE below. // function proceed; it'll fail safely on the UPDATE below.
} else if currentStatus == "done" || currentStatus == "failed" { } else if currentStatus == "done" || currentStatus == "failed" {
return errTaskAlreadyComplete return ErrTaskAlreadyComplete
} }
// Auto-cancel any executions still in pending_approval/approved/queued // Auto-cancel any executions still in pending_approval/approved/queued
@@ -1271,12 +1281,12 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
return nil return nil
} }
// autoUpsertKnowledge creates a knowledge entry for a completed session, // autoUpsertKnowledge creates a knowledge entry for a completed Session,
// capturing what was done and linking it to the entities involved. Called // capturing what was done and linking it to the entities involved. Called
// automatically from completeTask so every session leaves a trace, even if // automatically from completeTask so every session leaves a trace, even if
// the agent forgot to call upsert_knowledge. Only fired for success/partial // the agent forgot to call upsert_knowledge. Only fired for success/partial
// outcomes (failures don't have actionable discoveries). // outcomes (failures don't have actionable discoveries).
func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summary string) { func autoUpsertKnowledge(ctx context.Context, s *Store, sessionID, outcome, summary string) {
var goal string var goal string
if err := s.pool.QueryRow(ctx, if err := s.pool.QueryRow(ctx,
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
@@ -1335,7 +1345,7 @@ func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summ
// writePlanCompletionRate computes the step completion rate for the current // writePlanCompletionRate computes the step completion rate for the current
// plan generation and writes it as a task entity attribute so the trend can // plan generation and writes it as a task entity attribute so the trend can
// be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done). // be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done).
func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) { func writePlanCompletionRate(ctx context.Context, s *Store, sessionID string) {
var total, completed int var total, completed int
s.pool.QueryRow(ctx, ` s.pool.QueryRow(ctx, `
SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0) SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
@@ -1359,7 +1369,7 @@ func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) {
// last execution, feeding the pattern-extraction pipeline that has been empty // last execution, feeding the pattern-extraction pipeline that has been empty
// since launch. Only created for success/partial outcomes (failures don't // since launch. Only created for success/partial outcomes (failures don't
// have a specific execution to tie to). // have a specific execution to tie to).
func autoFeedback(ctx context.Context, s *store, sessionID, outcome, summary string) { func autoFeedback(ctx context.Context, s *Store, sessionID, outcome, summary string) {
// Find the last execution linked to this session. // Find the last execution linked to this session.
var execID uuid.UUID var execID uuid.UUID
if err := s.pool.QueryRow(ctx, ` if err := s.pool.QueryRow(ctx, `
@@ -1418,7 +1428,7 @@ var blockerPatterns = []struct {
// "uncategorized" when outcome is partial/failed but no signature matched — // "uncategorized" when outcome is partial/failed but no signature matched —
// better than "" because the audit needs to know this WAS blocked, just for // better than "" because the audit needs to know this WAS blocked, just for
// an unknown reason. Returns "" for success outcomes (caller checks first). // an unknown reason. Returns "" for success outcomes (caller checks first).
func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) string { func deriveBlocker(ctx context.Context, s *Store, sessionID, summary string) string {
// Pull the last assistant text — that's where the agent's parting // Pull the last assistant text — that's where the agent's parting
// words explain why it didn't finish. // words explain why it didn't finish.
var lastText string var lastText string
@@ -1438,7 +1448,7 @@ func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) str
// hadEntityWriteback checks whether this session called update_entity_attributes // hadEntityWriteback checks whether this session called update_entity_attributes
// or create_relationship — used by complete_task to warn the agent when it // or create_relationship — used by complete_task to warn the agent when it
// forgot to persist entity facts (the #1 cause of knowledge graph drift). // forgot to persist entity facts (the #1 cause of knowledge graph drift).
func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool { func (s *Store) HadEntityWriteback(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" { if s == nil || sessionID == "" {
return true // fail safe: don't warn when we can't check return true // fail safe: don't warn when we can't check
} }
@@ -1463,7 +1473,7 @@ func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool {
// only calls get_entity is a degenerate case (SOUL.md: "Don't invent // only calls get_entity is a degenerate case (SOUL.md: "Don't invent
// attributes that don't exist") and must NOT be blocked. Only sessions that // attributes that don't exist") and must NOT be blocked. Only sessions that
// actually executed against a live target get the writeback gate. // actually executed against a live target get the writeback gate.
func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool { func (s *Store) HadDiscovery(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" { if s == nil || sessionID == "" {
return false // fail safe: don't block when we can't check return false // fail safe: don't block when we can't check
} }
@@ -1479,7 +1489,7 @@ func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
// sessionGoal returns the session's goal text, empty string if not found. // sessionGoal returns the session's goal text, empty string if not found.
// Used by complete_task to check whether the goal involved a reachability // Used by complete_task to check whether the goal involved a reachability
// verification before marking success. // verification before marking success.
func (s *store) sessionGoal(ctx context.Context, sessionID string) string { func (s *Store) SessionGoal(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" { if s == nil || sessionID == "" {
return "" return ""
} }
@@ -1494,7 +1504,7 @@ func (s *store) sessionGoal(ctx context.Context, sessionID string) string {
// reachability in recent turns — ping_service, or a run with curl/wget that // reachability in recent turns — ping_service, or a run with curl/wget that
// returned successfully. Used by complete_task as a soft warning when the // returned successfully. Used by complete_task as a soft warning when the
// goal involved a reachability check but no recent verification occurred. // goal involved a reachability check but no recent verification occurred.
func (s *store) hadRecentVerification(ctx context.Context, sessionID string) bool { func (s *Store) HadRecentVerification(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" { if s == nil || sessionID == "" {
return true // fail safe: don't warn when we can't check return true // fail safe: don't warn when we can't check
} }
@@ -1537,7 +1547,7 @@ type staleGoalSession struct {
// alone) but have sat non-terminal past idleThreshold. completion_nudges // alone) but have sat non-terminal past idleThreshold. completion_nudges
// tells the caller whether to nudge (0) or give up and auto-close (>=1) — // tells the caller whether to nudge (0) or give up and auto-close (>=1) —
// see processIdleSweep in continue.go. // see processIdleSweep in continue.go.
func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession { func (s *Store) StaleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession {
if s == nil { if s == nil {
return nil return nil
} }
@@ -1563,11 +1573,11 @@ func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Durati
return out return out
} }
// bumpCompletionNudge records that the idle sweep nudged a stalled session, // bumpCompletionNudge records that the idle sweep nudged a stalled Session,
// stamping last_active_at so it isn't picked up again until it's genuinely // stamping last_active_at so it isn't picked up again until it's genuinely
// idle again (a fresh nudge shouldn't fire every tick while the model is // idle again (a fresh nudge shouldn't fire every tick while the model is
// mid-response to the previous one). // mid-response to the previous one).
func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error { func (s *Store) BumpCompletionNudge(ctx context.Context, sessionID string) error {
if s == nil { if s == nil {
return nil return nil
} }
@@ -1582,7 +1592,7 @@ func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error
// still pending or running. Used by autoCompleteIfPlanDone to auto-close a // still pending or running. Used by autoCompleteIfPlanDone to auto-close a
// task when the agent did all the work but forgot to call complete_task. // task when the agent did all the work but forgot to call complete_task.
// Returns false if there are no plan steps at all (no plan was proposed). // Returns false if there are no plan steps at all (no plan was proposed).
func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool { func (s *Store) AllPlanStepsTerminal(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false return false
} }
@@ -1600,7 +1610,7 @@ func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool
// pending_approval state. Used by autoCompleteIfPlanDone to avoid closing a // pending_approval state. Used by autoCompleteIfPlanDone to avoid closing a
// session that's blocked waiting for operator approval — the agent hit the // session that's blocked waiting for operator approval — the agent hit the
// P5 gate and can't continue until the operator responds. // P5 gate and can't continue until the operator responds.
func (s *store) hasPendingApprovals(ctx context.Context, sessionID string) bool { func (s *Store) HasPendingApprovals(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false return false
} }
@@ -1634,7 +1644,7 @@ type planStep struct {
// live plan, not an archaeological record of every superseded generation. Pass // live plan, not an archaeological record of every superseded generation. Pass
// all=true for the audit/eval view that needs every generation (the // all=true for the audit/eval view that needs every generation (the
// plan_generations assertion counts distinct generations across the full set). // plan_generations assertion counts distinct generations across the full set).
func (s *store) getPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) { func (s *Store) GetPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
@@ -1678,7 +1688,7 @@ type sessionQuestion struct {
// getQuestions returns a task's questions (open and answered) newest-first — // getQuestions returns a task's questions (open and answered) newest-first —
// REST hydration for the context panel's pinned question card and history. // REST hydration for the context panel's pinned question card and history.
func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) { func (s *Store) GetQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
@@ -1707,7 +1717,7 @@ func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQu
// askOperator records a structured decision the agent needs from the operator, // askOperator records a structured decision the agent needs from the operator,
// moves the task to awaiting_input, and emits question.raised so the context // moves the task to awaiting_input, and emits question.raised so the context
// panel pins it. qctx carries {why, options, entities}. Returns the question id. // panel pins it. qctx carries {why, options, entities}. Returns the question id.
func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) { func (s *Store) AskOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return "", nil return "", nil
} }
@@ -1730,7 +1740,7 @@ func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx
// openQuestionID returns the id of the session's open question, or "". Used to // openQuestionID returns the id of the session's open question, or "". Used to
// auto-close a pending question when the operator answers via a plain chat reply. // auto-close a pending question when the operator answers via a plain chat reply.
func (s *store) openQuestionID(ctx context.Context, sessionID string) string { func (s *Store) OpenQuestionID(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return "" return ""
} }
@@ -1742,7 +1752,7 @@ func (s *store) openQuestionID(ctx context.Context, sessionID string) string {
// getQuestion returns a question's prompt, answer, and session — used to build // getQuestion returns a question's prompt, answer, and session — used to build
// the resume note when the operator answers via the panel. // the resume note when the operator answers via the panel.
func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) { func (s *Store) GetQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) {
if s == nil || questionID == "" { if s == nil || questionID == "" {
return "", "", "" return "", "", ""
} }
@@ -1759,7 +1769,7 @@ func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, ans
// and emits question.answered. It does NOT itself resume the agent — the caller // and emits question.answered. It does NOT itself resume the agent — the caller
// decides: a chat reply IS the resuming turn, while a panel answer triggers a // decides: a chat reply IS the resuming turn, while a panel answer triggers a
// continuation. // continuation.
func (s *store) answerQuestion(ctx context.Context, sessionID, questionID, answer string) error { func (s *Store) AnswerQuestion(ctx context.Context, sessionID, questionID, answer string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" { if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" {
return nil return nil
} }
@@ -1789,7 +1799,7 @@ var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
// the entity it's "about" by upsert_knowledge itself — that about-link is the // the entity it's "about" by upsert_knowledge itself — that about-link is the
// retrieval path future tasks use (get_entity_knowledge); this task-link is for // retrieval path future tasks use (get_entity_knowledge); this task-link is for
// the task's own outcome/knowledge view. // the task's own outcome/knowledge view.
func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText string) { func (s *Store) LinkKnowledgeToTask(ctx context.Context, sessionID, resultText string) {
if s == nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || sessionID == "" || sessionID == "ephemeral" {
return return
} }
@@ -1817,7 +1827,7 @@ func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText s
map[string]any{"slug": slug}) map[string]any{"slug": slug})
} }
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error { func (s *Store) UpdateSessionTitle(ctx context.Context, id, title string) error {
if s == nil { if s == nil {
return nil return nil
} }
@@ -1827,7 +1837,7 @@ func (s *store) updateSessionTitle(ctx context.Context, id, title string) error
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). // resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
// Returns uuid.Nil if the store is absent or the slug is unknown. // Returns uuid.Nil if the store is absent or the slug is unknown.
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { func (s *Store) ResolveAgentID(ctx context.Context, slug string) uuid.UUID {
if s == nil { if s == nil {
return uuid.Nil return uuid.Nil
} }
@@ -1839,10 +1849,10 @@ func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
} }
// linkExecution records that a gated execution was initiated by a chat // linkExecution records that a gated execution was initiated by a chat
// session, so the auto-continuation worker can feed its result back to that // Session, so the auto-continuation worker can feed its result back to that
// session when it finishes. Idempotent — the same execution may appear in // session when it finishes. Idempotent — the same execution may appear in
// several tool results across a turn. // several tool results across a turn.
func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID string) { func (s *Store) LinkExecution(ctx context.Context, execID uuid.UUID, sessionID string) {
if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" { if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" {
return return
} }
@@ -1868,7 +1878,7 @@ var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task
// or fails the tool call. Only args are inspected — what the agent chose to act // or fails the tool call. Only args are inspected — what the agent chose to act
// on — never results, since a single bulk query result would otherwise pull the // on — never results, since a single bulk query result would otherwise pull the
// whole fleet into the task's graph. // whole fleet into the task's graph.
func (s *store) recordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) { func (s *Store) RecordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) {
if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 { if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 {
return return
} }
@@ -1953,7 +1963,7 @@ func collectTaskSlugs(v any, out map[string]struct{}) {
// pendingContinuation is one finished execution whose result hasn't yet been // pendingContinuation is one finished execution whose result hasn't yet been
// fed back to its originating session. // fed back to its originating session.
type pendingContinuation struct { type PendingContinuation struct {
ExecID uuid.UUID ExecID uuid.UUID
SessionID string SessionID string
Status string Status string
@@ -1964,7 +1974,7 @@ type pendingContinuation struct {
// pendingContinuations returns executions that have reached a terminal state // pendingContinuations returns executions that have reached a terminal state
// but haven't been continued yet — the worker's work list. Bounded so one // but haven't been continued yet — the worker's work list. Bounded so one
// tick can't fan out unboundedly. // tick can't fan out unboundedly.
func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingContinuation { func (s *Store) PendingContinuations(ctx context.Context, limit int) []PendingContinuation {
if s == nil { if s == nil {
return nil return nil
} }
@@ -1981,9 +1991,9 @@ func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingCo
return nil return nil
} }
defer rows.Close() defer rows.Close()
var out []pendingContinuation var out []PendingContinuation
for rows.Next() { for rows.Next() {
var p pendingContinuation var p PendingContinuation
if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil { if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil {
out = append(out, p) out = append(out, p)
} }
@@ -1993,7 +2003,7 @@ func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingCo
// markContinued stamps an execution as fed-back so the worker won't process it // markContinued stamps an execution as fed-back so the worker won't process it
// again (prevents an auto-continuation loop). // again (prevents an auto-continuation loop).
func (s *store) markContinued(ctx context.Context, execID uuid.UUID) { func (s *Store) MarkContinued(ctx context.Context, execID uuid.UUID) {
if s == nil { if s == nil {
return return
} }
@@ -2001,29 +2011,29 @@ func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
} }
// assentWindowActive reports whether THIS TASK currently has an open assent // assentWindowActive reports whether THIS TASK currently has an open assent
// window — the scope gate for auto-continuation. Scoped by session, not just // window — the scope gate for auto-continuation. Scoped by Session, not just
// agent: with a single agent:nomos entity serving every concurrent task, an // agent: with a single agent:nomos entity serving every concurrent task, an
// agent-only key would let approving Task A's plan silently auto-run // agent-only key would let approving Task A's plan silently auto-run
// unapproved config-mutation actions in a concurrently-running Task B. We // unapproved config-mutation actions in a concurrently-running Task B. We
// only auto-continue executions that are part of THIS session's approved // only auto-continue executions that are part of THIS session's approved
// plan, never a stray action from another task riding the same window. // plan, never a stray action from another task riding the same window.
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool { func (s *Store) AssentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool {
if s == nil || agentID == uuid.Nil || sessionID == "" { if s == nil || agentID == uuid.Nil || sessionID == "" {
return false // fail closed: no session to scope to means no window return false // fail closed: no session to scope to means no window
} }
var expires time.Time var expires time.Time
key := assentWindowKey(agentID, sessionID) key := AssentWindowKey(agentID, sessionID)
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil { if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
return false return false
} }
return time.Now().Before(expires) return time.Now().Before(expires)
} }
// assentWindowKey scopes the grant to one agent AND one session/task — see // AssentWindowKey scopes the grant to one agent AND one session/task — see
// assentWindowActive. Must match internal/mcp/server.go's copy (mirrored // assentWindowActive. Must match internal/mcp/server.go's copy (mirrored
// there, not shared, since the two are separate Go packages/binaries reading // there, not shared, since the two are separate Go packages/binaries reading
// the same autonomy_settings row). // the same autonomy_settings row).
func assentWindowKey(agentID uuid.UUID, sessionID string) string { func AssentWindowKey(agentID uuid.UUID, sessionID string) string {
return "assent_window.agent:" + agentID.String() + ".session:" + sessionID return "assent_window.agent:" + agentID.String() + ".session:" + sessionID
} }
@@ -2050,7 +2060,7 @@ func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) strin
// was gated independently. One explicit confirmation on a target should // was gated independently. One explicit confirmation on a target should
// cover the short follow-up sequence needed to finish what was just // cover the short follow-up sequence needed to finish what was just
// confirmed — but only within the task that got the confirmation. // confirmed — but only within the task that got the confirmation.
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) { func (s *Store) OpenDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return return
} }
@@ -2061,7 +2071,7 @@ func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, ta
// destructiveWindowActive reports whether target has a live, explicitly- // destructiveWindowActive reports whether target has a live, explicitly-
// confirmed destructive grant for this agent within this session/task. // confirmed destructive grant for this agent within this session/task.
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool { func (s *Store) DestructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return false return false
} }
@@ -2076,7 +2086,7 @@ func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID,
// executionTarget resolves the target entity slug for an execution — used to // executionTarget resolves the target entity slug for an execution — used to
// scope the destructive window to the right entity when a chat-assent typed // scope the destructive window to the right entity when a chat-assent typed
// confirmation grants a destructive execution. // confirmation grants a destructive execution.
func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string { func (s *Store) ExecutionTarget(ctx context.Context, execID uuid.UUID) string {
if s == nil { if s == nil {
return "" return ""
} }
@@ -2101,7 +2111,7 @@ var entityArgKeys = []string{
// resolveArgEntityID best-effort resolves the entity a tool call acted on // resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no // from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity. // key is present or none resolves to a known entity.
func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID { func (s *Store) ResolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID {
if s == nil { if s == nil {
return uuid.Nil return uuid.Nil
} }
@@ -2126,11 +2136,11 @@ func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uui
// The (nullable) session_id column carries the conversation id. args is the // The (nullable) session_id column carries the conversation id. args is the
// tool call's own arguments, used to best-effort tag the row with the // tool call's own arguments, used to best-effort tag the row with the
// entity it acted on (see resolveArgEntityID). // entity it acted on (see resolveArgEntityID).
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) { func (s *Store) LogActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) {
if s == nil || agentID == uuid.Nil { if s == nil || agentID == uuid.Nil {
return return
} }
entityID := s.resolveArgEntityID(ctx, args) entityID := s.ResolveArgEntityID(ctx, args)
var entityIDArg any var entityIDArg any
if entityID != uuid.Nil { if entityID != uuid.Nil {
entityIDArg = entityID entityIDArg = entityID

View File

@@ -1,4 +1,4 @@
package main package session
// Integration tests against a real Postgres, mirroring // Integration tests against a real Postgres, mirroring
// internal/db/integration_test.go's pattern: guarded by // internal/db/integration_test.go's pattern: guarded by
@@ -10,6 +10,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
@@ -98,7 +99,7 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "history window test") sess, err := s.CreateSession(ctx, "history window test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
@@ -111,12 +112,12 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
role = "assistant" role = "assistant"
} }
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i) body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil { if err := s.SaveMessage(ctx, sess.ID, role, body); err != nil {
t.Fatalf("saveMessage %d: %v", i, err) t.Fatalf("saveMessage %d: %v", i, err)
} }
} }
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit) msgs, truncated, err := s.GetRecentMessages(ctx, sess.ID, limit)
if err != nil { if err != nil {
t.Fatalf("getRecentMessages: %v", err) t.Fatalf("getRecentMessages: %v", err)
} }
@@ -131,25 +132,25 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
// the last should be the most recently saved (msg-34). // the last should be the most recently saved (msg-34).
wantFirst := fmt.Sprintf("msg-%d", total-limit) wantFirst := fmt.Sprintf("msg-%d", total-limit)
wantLast := fmt.Sprintf("msg-%d", total-1) wantLast := fmt.Sprintf("msg-%d", total-1)
if got := extractText(msgs[0].Content); got != wantFirst { if got := sessionText(msgs[0].Content); got != wantFirst {
t.Errorf("first retained message = %q, want %q", got, wantFirst) t.Errorf("first retained message = %q, want %q", got, wantFirst)
} }
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast { if got := sessionText(msgs[len(msgs)-1].Content); got != wantLast {
t.Errorf("last retained message = %q, want %q", got, wantLast) t.Errorf("last retained message = %q, want %q", got, wantLast)
} }
// Under the limit: nothing dropped. // Under the limit: nothing dropped.
sess2, err := s.createSession(ctx, "small session") sess2, err := s.CreateSession(ctx, "small session")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i) body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil { if err := s.SaveMessage(ctx, sess2.ID, "user", body); err != nil {
t.Fatalf("saveMessage: %v", err) t.Fatalf("saveMessage: %v", err)
} }
} }
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit) msgs2, truncated2, err := s.GetRecentMessages(ctx, sess2.ID, limit)
if err != nil { if err != nil {
t.Fatalf("getRecentMessages (small): %v", err) t.Fatalf("getRecentMessages (small): %v", err)
} }
@@ -173,14 +174,14 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "plan refuse test") sess, err := s.CreateSession(ctx, "plan refuse test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
// First call: no steps exist yet — must persist as-is (replace mode, // First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace). // trivially: nothing to replace).
out1, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}}) out1, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}})
if err != nil { if err != nil {
t.Fatalf("proposePlan #1: %v", err) t.Fatalf("proposePlan #1: %v", err)
} }
@@ -192,20 +193,20 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
} }
// Mark step 1 as started. // Mark step 1 as started.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err) t.Fatalf("updatePlanStep: %v", err)
} }
// Second call, simulating a model that re-proposes mid-flight (the // Second call, simulating a model that re-proposes mid-flight (the
// operator-reported "proceed" bug): since step 1 has left 'pending', // operator-reported "proceed" bug): since step 1 has left 'pending',
// this MUST refuse with errPlanInFlight, not append or replace. // this MUST refuse with ErrPlanInFlight, not append or replace.
_, err = s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}}) _, err = s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}})
if !errors.Is(err, errPlanInFlight) { if !errors.Is(err, ErrPlanInFlight) {
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err) t.Fatalf("proposePlan #2: err = %v, want ErrPlanInFlight (refuse mid-flight re-proposal)", err)
} }
// The original step 1 must be untouched — not erased, not appended to. // The original step 1 must be untouched — not erased, not appended to.
steps, err := s.getPlanSteps(ctx, sess.ID, false) steps, err := s.GetPlanSteps(ctx, sess.ID, false)
if err != nil { if err != nil {
t.Fatalf("getPlanSteps: %v", err) t.Fatalf("getPlanSteps: %v", err)
} }
@@ -219,18 +220,18 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
// Third call BEFORE anything runs on a fresh session: every step is // Third call BEFORE anything runs on a fresh session: every step is
// still pending, so this must REPLACE (mark the prior plan `replaced`), // still pending, so this must REPLACE (mark the prior plan `replaced`),
// not refuse. The new plan becomes generation 2. // not refuse. The new plan becomes generation 2.
sess2, err := s.createSession(ctx, "plan replace test") sess2, err := s.CreateSession(ctx, "plan replace test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
if _, err := s.proposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil { if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil {
t.Fatalf("proposePlan (initial): %v", err) t.Fatalf("proposePlan (initial): %v", err)
} }
if _, err := s.proposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil { if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil {
t.Fatalf("proposePlan (revise before execution): %v", err) t.Fatalf("proposePlan (revise before execution): %v", err)
} }
// Default (current generation) view: only the revised step. // Default (current generation) view: only the revised step.
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID, false) revisedSteps, err := s.GetPlanSteps(ctx, sess2.ID, false)
if err != nil { if err != nil {
t.Fatalf("getPlanSteps: %v", err) t.Fatalf("getPlanSteps: %v", err)
} }
@@ -244,7 +245,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation) t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation)
} }
// all=true audit view: both generations, the original marked `replaced`. // all=true audit view: both generations, the original marked `replaced`.
allSteps, err := s.getPlanSteps(ctx, sess2.ID, true) allSteps, err := s.GetPlanSteps(ctx, sess2.ID, true)
if err != nil { if err != nil {
t.Fatalf("getPlanSteps(all): %v", err) t.Fatalf("getPlanSteps(all): %v", err)
} }
@@ -270,32 +271,32 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "gen-relative seq test") sess, err := s.CreateSession(ctx, "gen-relative seq test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
// Generation 1: two steps. // Generation 1: two steps.
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil { if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan #1: %v", err) t.Fatalf("proposePlan #1: %v", err)
} }
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2. // Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2.
if err := s.setGoal(ctx, sess.ID, "follow-up sub-task"); err != nil { if err := s.SetGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
t.Fatalf("setGoal: %v", err) t.Fatalf("setGoal: %v", err)
} }
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil { if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
t.Fatalf("proposePlan #2: %v", err) t.Fatalf("proposePlan #2: %v", err)
} }
// The model addresses the new plan with 1-based seq. seq=1 must hit // The model addresses the new plan with 1-based seq. seq=1 must hit
// gen-2 "C", leaving gen-1 "A" (replaced) untouched. // gen-2 "C", leaving gen-1 "A" (replaced) untouched.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, running): %v", err) t.Fatalf("updatePlanStep(seq=1, running): %v", err)
} }
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil { if err := s.UpdatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, done): %v", err) t.Fatalf("updatePlanStep(seq=1, done): %v", err)
} }
all, err := s.getPlanSteps(ctx, sess.ID, true) all, err := s.GetPlanSteps(ctx, sess.ID, true)
if err != nil { if err != nil {
t.Fatalf("getPlanSteps(all): %v", err) t.Fatalf("getPlanSteps(all): %v", err)
} }
@@ -319,8 +320,8 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
} }
// Out-of-range seq must be refused (no current-gen step there). // Out-of-range seq must be refused (no current-gen step there).
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) { if err := s.UpdatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, ErrPlanStepNotFound) {
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err) t.Fatalf("updatePlanStep(seq=99) err = %v, want ErrPlanStepNotFound", err)
} }
} }
@@ -333,24 +334,24 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "auto-close events test") sess, err := s.CreateSession(ctx, "auto-close events test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil { if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan: %v", err) t.Fatalf("proposePlan: %v", err)
} }
// A is running, B still pending at completion time. // A is running, B still pending at completion time.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(1, running): %v", err) t.Fatalf("updatePlanStep(1, running): %v", err)
} }
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil { if err := s.CompleteTask(ctx, sess.ID, "success", "done"); err != nil {
t.Fatalf("completeTask: %v", err) t.Fatalf("completeTask: %v", err)
} }
// Every auto-closed step should now carry both a started_at and a // Every auto-closed step should now carry both a started_at and a
// finished_at (no NULL-started `done` step). // finished_at (no NULL-started `done` step).
steps, err := s.getPlanSteps(ctx, sess.ID, true) steps, err := s.GetPlanSteps(ctx, sess.ID, true)
if err != nil { if err != nil {
t.Fatalf("getPlanSteps: %v", err) t.Fatalf("getPlanSteps: %v", err)
} }
@@ -382,64 +383,64 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "discovery test") sess, err := s.CreateSession(ctx, "discovery test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
// Before any tool calls: no discovery, no writeback. // Before any tool calls: no discovery, no writeback.
if s.hadDiscovery(ctx, sess.ID) { if s.HadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = true before any tool calls, want false") t.Fatal("hadDiscovery = true before any tool calls, want false")
} }
if s.hadEntityWriteback(ctx, sess.ID) { if s.HadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true before any tool calls, want false") t.Fatal("hadEntityWriteback = true before any tool calls, want false")
} }
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback. // A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
agentID := uuid.New() agentID := uuid.New()
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0) s.LogActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
if !s.hadDiscovery(ctx, sess.ID) { if !s.HadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = false after a successful run call, want true") t.Fatal("hadDiscovery = false after a successful run call, want true")
} }
if s.hadEntityWriteback(ctx, sess.ID) { if s.HadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true after only a run call, want false") t.Fatal("hadEntityWriteback = true after only a run call, want false")
} }
// A failed run call should NOT count as discovery (no facts learned). // A failed run call should NOT count as discovery (no facts learned).
sess2, err := s.createSession(ctx, "failed discovery test") sess2, err := s.CreateSession(ctx, "failed discovery test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0) s.LogActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
if s.hadDiscovery(ctx, sess2.ID) { if s.HadDiscovery(ctx, sess2.ID) {
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)") t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
} }
// A get_entity call should NOT count as discovery (DB lookup, not live state). // A get_entity call should NOT count as discovery (DB lookup, not live state).
sess3, err := s.createSession(ctx, "lookup test") sess3, err := s.CreateSession(ctx, "lookup test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0) s.LogActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
if s.hadDiscovery(ctx, sess3.ID) { if s.HadDiscovery(ctx, sess3.ID) {
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)") t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
} }
// update_entity_attributes sets hadEntityWriteback. // update_entity_attributes sets hadEntityWriteback.
sess4, err := s.createSession(ctx, "writeback test") sess4, err := s.CreateSession(ctx, "writeback test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0) s.LogActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
if !s.hadEntityWriteback(ctx, sess4.ID) { if !s.HadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true") t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
} }
// And the discovery+writeback combination (the conv3 scenario). // And the discovery+writeback combination (the conv3 scenario).
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0) s.LogActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
if !s.hadDiscovery(ctx, sess4.ID) { if !s.HadDiscovery(ctx, sess4.ID) {
t.Fatal("hadDiscovery = false after run+writeback, want true") t.Fatal("hadDiscovery = false after run+writeback, want true")
} }
if !s.hadEntityWriteback(ctx, sess4.ID) { if !s.HadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after run+writeback, want true") t.Fatal("hadEntityWriteback = false after run+writeback, want true")
} }
} }
@@ -459,13 +460,13 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()
sess, err := s.createSession(ctx, "goal pivot test") sess, err := s.CreateSession(ctx, "goal pivot test")
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
// First set_goal — no prior, no supersession event expected. // First set_goal — no prior, no supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil { if err := s.SetGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
t.Fatalf("setGoal #1: %v", err) t.Fatalf("setGoal #1: %v", err)
} }
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 { if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
@@ -473,7 +474,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
} }
// Second set_goal with a DIFFERENT goal — supersession event expected. // Second set_goal with a DIFFERENT goal — supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil { if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #2: %v", err) t.Fatalf("setGoal #2: %v", err)
} }
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 { if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
@@ -482,7 +483,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
// Third set_goal with the SAME goal as the second — no new supersession // Third set_goal with the SAME goal as the second — no new supersession
// event (idempotent: same goal is a no-op, not a pivot). // event (idempotent: same goal is a no-op, not a pivot).
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil { if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #3: %v", err) t.Fatalf("setGoal #3: %v", err)
} }
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 { if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
@@ -490,7 +491,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
} }
// The session's current goal must be the latest one set. // The session's current goal must be the latest one set.
got, err := s.getSession(ctx, sess.ID) got, err := s.GetSession(ctx, sess.ID)
if err != nil { if err != nil {
t.Fatalf("getSession: %v", err) t.Fatalf("getSession: %v", err)
} }
@@ -509,3 +510,14 @@ func countEvents(ctx context.Context, s *Store, sessionID, eventType string) int
sessionID, eventType).Scan(&n) sessionID, eventType).Scan(&n)
return n return n
} }
// sessionText pulls the "text" field from a persisted message's JSONB content.
func sessionText(content json.RawMessage) string {
var m struct {
Text string `json:"text"`
}
if err := json.Unmarshal(content, &m); err != nil {
return ""
}
return m.Text
}

View File

@@ -6,7 +6,9 @@ went sideways, open an investigation.
## Active ## Active
All plans reviewed and moved to `done/` — see the [Done table](#done) below. | Date | Title |
| ---- | ----- |
| 2026-08-16 | [dsh-as-agent: replace nomos with DeepSeek Harness](2026-08-16-dsh-as-agent-replace-nomos.md) |
## Done ## Done