feat: executions, assertions y bucle reactivo en fn_operations

Añade Execution, Assertion, AssertionResult al paquete fn_operations.
Motor de evaluación de assertions con reescritura SQL automática.
Bucle reactivo: ExecuteAndReact evalúa assertions y cambia status de
entities (corrupted/stale) + auto-crea proposals en registry.
CLI fn ops: assertion (add/list/show/delete/eval) y execution (add/list/show).
Migración 002_executions_assertions.sql con FTS para assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:13:37 +01:00
parent 8d98faccd9
commit 9ba1f86c34
11 changed files with 2230 additions and 87 deletions
+270
View File
@@ -277,6 +277,276 @@ func UpdateSnapshot(opsDB *DB, registryDB *registry.DB, typeID string) (old, new
return oldSnap, newSnap, nil
}
// InsertExecutionSafe validates and inserts an execution.
// Auto-calculates duration_ms if both started_at and ended_at are set.
func InsertExecutionSafe(db *DB, e *Execution) error {
if err := ValidateExecution(e); err != nil {
return err
}
// Auto-calculate duration
if e.EndedAt != nil && !e.StartedAt.IsZero() && e.DurationMs == nil {
ms := e.EndedAt.Sub(e.StartedAt).Milliseconds()
e.DurationMs = &ms
}
return db.InsertExecution(e)
}
// InsertAssertionSafe validates that the entity exists, then inserts the assertion.
func InsertAssertionSafe(db *DB, a *Assertion) error {
entities, err := buildEntitySet(db)
if err != nil {
return err
}
if err := ValidateAssertion(a, entities); err != nil {
return err
}
return db.InsertAssertion(a)
}
// RecordExecutionWithResults inserts an execution and its assertion results in a transaction.
func RecordExecutionWithResults(db *DB, e *Execution, results []AssertionResult) error {
if err := ValidateExecution(e); err != nil {
return err
}
// Auto-calculate duration
if e.EndedAt != nil && !e.StartedAt.IsZero() && e.DurationMs == nil {
ms := e.EndedAt.Sub(e.StartedAt).Milliseconds()
e.DurationMs = &ms
}
tx, err := db.Conn().Begin()
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback()
// Insert execution
if e.CreatedAt.IsZero() {
e.CreatedAt = time.Now().UTC()
}
var endedAt *string
if e.EndedAt != nil {
s := e.EndedAt.Format(time.RFC3339)
endedAt = &s
}
_, err = tx.Exec(`
INSERT OR REPLACE INTO executions (
id, pipeline_id, relation_id, status, started_at, ended_at,
duration_ms, records_in, records_out, error, metrics, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.PipelineID, e.RelationID, string(e.Status),
e.StartedAt.Format(time.RFC3339), endedAt,
e.DurationMs, e.RecordsIn, e.RecordsOut, e.Error,
marshalJSON(e.Metrics), e.CreatedAt.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("inserting execution: %w", err)
}
// Insert assertion results
for _, ar := range results {
if ar.EvaluatedAt.IsZero() {
ar.EvaluatedAt = time.Now().UTC()
}
_, err = tx.Exec(`
INSERT OR REPLACE INTO assertion_results (
id, assertion_id, execution_id, status, value, message, evaluated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
ar.ID, ar.AssertionID, ar.ExecutionID, string(ar.Status),
marshalJSON(ar.Value), ar.Message, ar.EvaluatedAt.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("inserting assertion_result: %w", err)
}
}
return tx.Commit()
}
// --- Reactive loop ---
// ReactiveResult holds what the reactive loop did after analyzing assertion results.
type ReactiveResult struct {
EntityUpdates []EntityStatusChange
Proposals []string // IDs of proposals created
}
// EntityStatusChange records a status transition triggered by assertions.
type EntityStatusChange struct {
EntityID string
OldStatus EntityStatus
NewStatus EntityStatus
Reason string
}
// React analyzes assertion results and applies the severity rules:
// - critical fail → entity.status = corrupted
// - warning fail → entity.status = stale (only if currently active)
// - info fail → no status change
//
// If registryDB is provided and critical failures exist, a proposal is auto-created.
// Returns what changed so the caller can log or display it.
func React(opsDB *DB, registryDB *registry.DB, results []AssertionResult) (*ReactiveResult, error) {
rr := &ReactiveResult{}
// Group failures by entity, tracking worst severity per entity.
type entityImpact struct {
worstSeverity Severity
failures []AssertionResult
assertionIDs []string
}
impacts := map[string]*entityImpact{}
for _, ar := range results {
if ar.Status != ResultFail {
continue
}
// Look up the assertion to get entity_id and severity
a, err := opsDB.GetAssertion(ar.AssertionID)
if err != nil || a == nil {
continue
}
imp, ok := impacts[a.EntityID]
if !ok {
imp = &entityImpact{}
impacts[a.EntityID] = imp
}
imp.failures = append(imp.failures, ar)
imp.assertionIDs = append(imp.assertionIDs, ar.AssertionID)
// Track worst severity (critical > warning > info)
if severityRank(a.Severity) > severityRank(imp.worstSeverity) {
imp.worstSeverity = a.Severity
}
}
// Apply status changes per entity
for entityID, imp := range impacts {
entity, err := opsDB.GetEntity(entityID)
if err != nil || entity == nil {
continue
}
var newStatus EntityStatus
switch imp.worstSeverity {
case SeverityCritical:
newStatus = StatusCorrupted
case SeverityWarning:
// Only degrade active → stale, don't touch corrupted/archived
if entity.Status == StatusActive {
newStatus = StatusStale
}
default:
continue // info: no status change
}
if newStatus == "" || newStatus == entity.Status {
continue
}
oldStatus := entity.Status
entity.Status = newStatus
if err := opsDB.UpdateEntity(entity); err != nil {
return nil, fmt.Errorf("updating entity %s status: %w", entityID, err)
}
reason := fmt.Sprintf("%s assertion(s) failed: %v", imp.worstSeverity, imp.assertionIDs)
rr.EntityUpdates = append(rr.EntityUpdates, EntityStatusChange{
EntityID: entityID,
OldStatus: oldStatus,
NewStatus: newStatus,
Reason: reason,
})
}
// Create proposals in registry for critical failures
if registryDB != nil {
for entityID, imp := range impacts {
if imp.worstSeverity != SeverityCritical {
continue
}
// Build evidence from failures
failureDetails := make([]map[string]any, 0, len(imp.failures))
for _, f := range imp.failures {
failureDetails = append(failureDetails, map[string]any{
"assertion_id": f.AssertionID,
"execution_id": f.ExecutionID,
"message": f.Message,
"value": f.Value,
})
}
p := &registry.Proposal{
ID: fmt.Sprintf("proposal_react_%s_%d", entityID, time.Now().UnixNano()),
Kind: registry.ProposalImproveFunction,
Title: fmt.Sprintf("Critical assertion failures on entity %s", entityID),
Description: fmt.Sprintf("%d critical assertion(s) failed. Entity marked as corrupted.", len(imp.failures)),
Evidence: map[string]any{
"entity_id": entityID,
"assertion_ids": imp.assertionIDs,
"failures": failureDetails,
},
Status: registry.ProposalPending,
CreatedBy: "reactive_loop",
}
if err := registryDB.InsertProposal(p); err != nil {
return nil, fmt.Errorf("creating proposal for entity %s: %w", entityID, err)
}
rr.Proposals = append(rr.Proposals, p.ID)
}
}
return rr, nil
}
// ExecuteAndReact is the full autonomous loop step:
// 1. Record the execution
// 2. Evaluate all active assertions on affected entities
// 3. React to failures (update entity status, create proposals)
func ExecuteAndReact(opsDB *DB, registryDB *registry.DB, e *Execution, entityIDs []string) (*ReactiveResult, error) {
// 1. Record execution
if err := InsertExecutionSafe(opsDB, e); err != nil {
return nil, fmt.Errorf("recording execution: %w", err)
}
// 2. Evaluate assertions for each affected entity
var allResults []AssertionResult
for _, entityID := range entityIDs {
results, err := EvalEntityAssertions(opsDB, entityID, e.ID)
if err != nil {
return nil, fmt.Errorf("evaluating assertions for entity %s: %w", entityID, err)
}
allResults = append(allResults, results...)
}
// 3. React
return React(opsDB, registryDB, allResults)
}
func severityRank(s Severity) int {
switch s {
case SeverityCritical:
return 3
case SeverityWarning:
return 2
case SeverityInfo:
return 1
default:
return 0
}
}
func buildEntitySet(db *DB) (map[string]bool, error) {
all, err := db.ListEntities("", "")
if err != nil {