9ba1f86c34
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>
276 lines
6.7 KiB
Go
276 lines
6.7 KiB
Go
package fn_operations
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ValidationError represents one or more integrity violations.
|
|
type ValidationError struct {
|
|
ID string
|
|
Errors []string
|
|
}
|
|
|
|
func (v *ValidationError) Error() string {
|
|
return fmt.Sprintf("%s: %s", v.ID, strings.Join(v.Errors, "; "))
|
|
}
|
|
|
|
// ValidateEntity checks entity integrity rules.
|
|
func ValidateEntity(e *Entity) *ValidationError {
|
|
var errs []string
|
|
|
|
if e.ID == "" {
|
|
errs = append(errs, "id is required")
|
|
}
|
|
if e.Name == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if e.TypeRef == "" {
|
|
errs = append(errs, "type_ref is required")
|
|
}
|
|
if e.Source == "" {
|
|
errs = append(errs, "source is required")
|
|
}
|
|
|
|
switch e.Status {
|
|
case StatusActive, StatusStale, StatusCorrupted, StatusArchived:
|
|
case "":
|
|
errs = append(errs, "status is required")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("invalid status: %s", e.Status))
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return &ValidationError{ID: e.ID, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateRelation checks relation integrity rules.
|
|
// knownEntities is a set of entity IDs that exist.
|
|
func ValidateRelation(r *Relation, knownEntities map[string]bool) *ValidationError {
|
|
var errs []string
|
|
|
|
if r.ID == "" {
|
|
errs = append(errs, "id is required")
|
|
}
|
|
if r.Name == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if r.ToEntity == "" {
|
|
errs = append(errs, "to_entity is required")
|
|
}
|
|
|
|
// from_entity or relation_inputs — validated at operation level
|
|
if r.FromEntity != "" && r.ToEntity != "" && r.FromEntity == r.ToEntity {
|
|
errs = append(errs, "from_entity and to_entity cannot be the same")
|
|
}
|
|
|
|
if r.FromEntity != "" && !knownEntities[r.FromEntity] {
|
|
errs = append(errs, fmt.Sprintf("from_entity references unknown entity: %s", r.FromEntity))
|
|
}
|
|
if r.ToEntity != "" && !knownEntities[r.ToEntity] {
|
|
errs = append(errs, fmt.Sprintf("to_entity references unknown entity: %s", r.ToEntity))
|
|
}
|
|
|
|
if r.Weight != nil {
|
|
if *r.Weight < 0.0 || *r.Weight > 1.0 {
|
|
errs = append(errs, "weight must be between 0.0 and 1.0")
|
|
}
|
|
}
|
|
|
|
if r.StartedAt != nil && r.EndedAt != nil {
|
|
if r.StartedAt.After(*r.EndedAt) {
|
|
errs = append(errs, "started_at must be before ended_at")
|
|
}
|
|
}
|
|
|
|
switch r.Direction {
|
|
case DirUnidirectional, DirBidirectional, DirInverse, "":
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("invalid direction: %s", r.Direction))
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return &ValidationError{ID: r.ID, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateRelationInputs checks relation_inputs integrity.
|
|
func ValidateRelationInputs(inputs []RelationInput, knownEntities map[string]bool) *ValidationError {
|
|
var errs []string
|
|
|
|
if len(inputs) < 2 {
|
|
errs = append(errs, "relation_inputs must have at least 2 entries")
|
|
}
|
|
|
|
for i, ri := range inputs {
|
|
if ri.RelationID == "" {
|
|
errs = append(errs, fmt.Sprintf("input[%d]: relation_id is required", i))
|
|
}
|
|
if ri.EntityID == "" {
|
|
errs = append(errs, fmt.Sprintf("input[%d]: entity_id is required", i))
|
|
}
|
|
if ri.Role == "" {
|
|
errs = append(errs, fmt.Sprintf("input[%d]: role is required", i))
|
|
}
|
|
if ri.EntityID != "" && !knownEntities[ri.EntityID] {
|
|
errs = append(errs, fmt.Sprintf("input[%d]: entity_id references unknown entity: %s", i, ri.EntityID))
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
id := "relation_inputs"
|
|
if len(inputs) > 0 {
|
|
id = inputs[0].RelationID
|
|
}
|
|
return &ValidationError{ID: id, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateExecution checks execution integrity rules.
|
|
func ValidateExecution(e *Execution) *ValidationError {
|
|
var errs []string
|
|
|
|
if e.ID == "" {
|
|
errs = append(errs, "id is required")
|
|
}
|
|
if e.PipelineID == "" {
|
|
errs = append(errs, "pipeline_id is required")
|
|
}
|
|
|
|
switch e.Status {
|
|
case ExecSuccess, ExecFailure, ExecPartial:
|
|
case "":
|
|
errs = append(errs, "status is required")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("invalid status: %s", e.Status))
|
|
}
|
|
|
|
if e.StartedAt.IsZero() {
|
|
errs = append(errs, "started_at is required")
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return &ValidationError{ID: e.ID, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAssertion checks assertion integrity rules.
|
|
func ValidateAssertion(a *Assertion, knownEntities map[string]bool) *ValidationError {
|
|
var errs []string
|
|
|
|
if a.ID == "" {
|
|
errs = append(errs, "id is required")
|
|
}
|
|
if a.EntityID == "" {
|
|
errs = append(errs, "entity_id is required")
|
|
} else if knownEntities != nil && !knownEntities[a.EntityID] {
|
|
errs = append(errs, fmt.Sprintf("entity_id references unknown entity: %s", a.EntityID))
|
|
}
|
|
if a.Name == "" {
|
|
errs = append(errs, "name is required")
|
|
}
|
|
if a.Kind == "" {
|
|
errs = append(errs, "kind is required")
|
|
}
|
|
if a.Rule == "" {
|
|
errs = append(errs, "rule is required")
|
|
}
|
|
|
|
switch a.Severity {
|
|
case SeverityCritical, SeverityWarning, SeverityInfo:
|
|
case "":
|
|
errs = append(errs, "severity is required")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("invalid severity: %s", a.Severity))
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return &ValidationError{ID: a.ID, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAssertionResult checks assertion result integrity.
|
|
func ValidateAssertionResult(ar *AssertionResult) *ValidationError {
|
|
var errs []string
|
|
|
|
if ar.ID == "" {
|
|
errs = append(errs, "id is required")
|
|
}
|
|
if ar.AssertionID == "" {
|
|
errs = append(errs, "assertion_id is required")
|
|
}
|
|
|
|
switch ar.Status {
|
|
case ResultPass, ResultFail, ResultSkip:
|
|
case "":
|
|
errs = append(errs, "status is required")
|
|
default:
|
|
errs = append(errs, fmt.Sprintf("invalid status: %s", ar.Status))
|
|
}
|
|
|
|
if ar.EvaluatedAt.IsZero() {
|
|
errs = append(errs, "evaluated_at is required")
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return &ValidationError{ID: ar.ID, Errors: errs}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DetectCycle checks if adding a causal relation (from -> to) creates a cycle.
|
|
// Only considers relations where via != "" (causal/transformational).
|
|
// Semantic relations (via == "") are exempt from cycle detection.
|
|
func DetectCycle(db *DB, fromEntity, toEntity string) error {
|
|
if fromEntity == "" || toEntity == "" {
|
|
return nil
|
|
}
|
|
|
|
// BFS from toEntity following only causal relations.
|
|
// If we reach fromEntity, there's a cycle.
|
|
visited := map[string]bool{}
|
|
queue := []string{toEntity}
|
|
|
|
for len(queue) > 0 {
|
|
current := queue[0]
|
|
queue = queue[1:]
|
|
|
|
if visited[current] {
|
|
continue
|
|
}
|
|
visited[current] = true
|
|
|
|
if current == fromEntity {
|
|
return fmt.Errorf("cycle detected: adding relation %s -> %s would create a causal cycle", fromEntity, toEntity)
|
|
}
|
|
|
|
// Follow causal relations from current entity
|
|
rows, err := db.conn.Query(`
|
|
SELECT to_entity FROM relations
|
|
WHERE from_entity = ? AND via != ''`, current)
|
|
if err != nil {
|
|
return fmt.Errorf("querying relations for cycle detection: %w", err)
|
|
}
|
|
|
|
for rows.Next() {
|
|
var next string
|
|
if err := rows.Scan(&next); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if !visited[next] {
|
|
queue = append(queue, next)
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|