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
+343
View File
@@ -471,3 +471,346 @@ func (db *DB) DeleteRelationInputs(relationID string) error {
_, err := db.conn.Exec("DELETE FROM relation_inputs WHERE relation_id = ?", relationID)
return err
}
// --- Execution CRUD ---
// InsertExecution inserts an execution record.
func (db *DB) InsertExecution(e *Execution) error {
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 := db.conn.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),
)
return err
}
// GetExecution returns an execution by ID.
func (db *DB) GetExecution(id string) (*Execution, error) {
row := db.conn.QueryRow(`
SELECT id, pipeline_id, relation_id, status, started_at, ended_at,
duration_ms, records_in, records_out, error, metrics, created_at
FROM executions WHERE id = ?`, id)
var e Execution
var metricsJSON, createdAt, startedAt string
var endedAt *string
err := row.Scan(&e.ID, &e.PipelineID, &e.RelationID, &e.Status,
&startedAt, &endedAt,
&e.DurationMs, &e.RecordsIn, &e.RecordsOut, &e.Error,
&metricsJSON, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning execution: %w", err)
}
e.Metrics = unmarshalJSON(metricsJSON)
e.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
if endedAt != nil {
t, _ := time.Parse(time.RFC3339, *endedAt)
e.EndedAt = &t
}
return &e, nil
}
// ListExecutions returns executions filtered by pipeline, relation, and/or status.
func (db *DB) ListExecutions(pipelineID, relationID string, status ExecutionStatus) ([]Execution, error) {
where := []string{}
args := []any{}
if pipelineID != "" {
where = append(where, "pipeline_id = ?")
args = append(args, pipelineID)
}
if relationID != "" {
where = append(where, "relation_id = ?")
args = append(args, relationID)
}
if status != "" {
where = append(where, "status = ?")
args = append(args, string(status))
}
q := `SELECT id, pipeline_id, relation_id, status, started_at, ended_at,
duration_ms, records_in, records_out, error, metrics, created_at
FROM executions`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY created_at DESC"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanExecutions(rows)
}
func scanExecutions(rows *sql.Rows) ([]Execution, error) {
var result []Execution
for rows.Next() {
var e Execution
var metricsJSON, createdAt, startedAt string
var endedAt *string
if err := rows.Scan(&e.ID, &e.PipelineID, &e.RelationID, &e.Status,
&startedAt, &endedAt,
&e.DurationMs, &e.RecordsIn, &e.RecordsOut, &e.Error,
&metricsJSON, &createdAt); err != nil {
return nil, fmt.Errorf("scanning execution: %w", err)
}
e.Metrics = unmarshalJSON(metricsJSON)
e.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
if endedAt != nil {
t, _ := time.Parse(time.RFC3339, *endedAt)
e.EndedAt = &t
}
result = append(result, e)
}
return result, nil
}
// --- Assertion CRUD ---
// InsertAssertion inserts or replaces an assertion.
func (db *DB) InsertAssertion(a *Assertion) error {
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
}
active := 0
if a.Active {
active = 1
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO assertions (
id, entity_id, name, kind, rule, severity, description, active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.EntityID, a.Name, a.Kind, a.Rule,
string(a.Severity), a.Description, active,
a.CreatedAt.Format(time.RFC3339),
)
return err
}
// GetAssertion returns an assertion by ID.
func (db *DB) GetAssertion(id string) (*Assertion, error) {
row := db.conn.QueryRow(`
SELECT id, entity_id, name, kind, rule, severity, description, active, created_at
FROM assertions WHERE id = ?`, id)
var a Assertion
var active int
var createdAt string
err := row.Scan(&a.ID, &a.EntityID, &a.Name, &a.Kind, &a.Rule,
&a.Severity, &a.Description, &active, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning assertion: %w", err)
}
a.Active = active == 1
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return &a, nil
}
// UpdateAssertion updates an existing assertion.
func (db *DB) UpdateAssertion(a *Assertion) error {
active := 0
if a.Active {
active = 1
}
_, err := db.conn.Exec(`
UPDATE assertions SET entity_id=?, name=?, kind=?, rule=?, severity=?,
description=?, active=?
WHERE id=?`,
a.EntityID, a.Name, a.Kind, a.Rule, string(a.Severity),
a.Description, active, a.ID,
)
return err
}
// DeleteAssertion removes an assertion by ID.
func (db *DB) DeleteAssertion(id string) error {
_, err := db.conn.Exec("DELETE FROM assertions WHERE id = ?", id)
return err
}
// ListAssertions returns assertions filtered by entity and/or active state.
func (db *DB) ListAssertions(entityID string, active *bool) ([]Assertion, error) {
where := []string{}
args := []any{}
if entityID != "" {
where = append(where, "entity_id = ?")
args = append(args, entityID)
}
if active != nil {
v := 0
if *active {
v = 1
}
where = append(where, "active = ?")
args = append(args, v)
}
q := `SELECT id, entity_id, name, kind, rule, severity, description, active, created_at
FROM assertions`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAssertions(rows)
}
// SearchAssertions performs FTS search on assertions.
func (db *DB) SearchAssertions(query, entityID string) ([]Assertion, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "a.id IN (SELECT id FROM assertions_fts WHERE assertions_fts MATCH ?)")
args = append(args, query)
}
if entityID != "" {
where = append(where, "a.entity_id = ?")
args = append(args, entityID)
}
q := `SELECT a.id, a.entity_id, a.name, a.kind, a.rule, a.severity, a.description, a.active, a.created_at
FROM assertions a`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY a.name"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAssertions(rows)
}
func scanAssertions(rows *sql.Rows) ([]Assertion, error) {
var result []Assertion
for rows.Next() {
var a Assertion
var active int
var createdAt string
if err := rows.Scan(&a.ID, &a.EntityID, &a.Name, &a.Kind, &a.Rule,
&a.Severity, &a.Description, &active, &createdAt); err != nil {
return nil, fmt.Errorf("scanning assertion: %w", err)
}
a.Active = active == 1
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
result = append(result, a)
}
return result, nil
}
// --- AssertionResult CRUD ---
// InsertAssertionResult inserts an assertion result.
func (db *DB) InsertAssertionResult(ar *AssertionResult) error {
if ar.EvaluatedAt.IsZero() {
ar.EvaluatedAt = time.Now().UTC()
}
_, err := db.conn.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),
)
return err
}
// GetAssertionResult returns an assertion result by ID.
func (db *DB) GetAssertionResult(id string) (*AssertionResult, error) {
row := db.conn.QueryRow(`
SELECT id, assertion_id, execution_id, status, value, message, evaluated_at
FROM assertion_results WHERE id = ?`, id)
var ar AssertionResult
var valueJSON, evaluatedAt string
err := row.Scan(&ar.ID, &ar.AssertionID, &ar.ExecutionID, &ar.Status,
&valueJSON, &ar.Message, &evaluatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanning assertion_result: %w", err)
}
ar.Value = unmarshalJSON(valueJSON)
ar.EvaluatedAt, _ = time.Parse(time.RFC3339, evaluatedAt)
return &ar, nil
}
// ListAssertionResults returns results filtered by assertion and/or execution.
func (db *DB) ListAssertionResults(assertionID, executionID string) ([]AssertionResult, error) {
where := []string{}
args := []any{}
if assertionID != "" {
where = append(where, "assertion_id = ?")
args = append(args, assertionID)
}
if executionID != "" {
where = append(where, "execution_id = ?")
args = append(args, executionID)
}
q := `SELECT id, assertion_id, execution_id, status, value, message, evaluated_at
FROM assertion_results`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY evaluated_at DESC"
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAssertionResults(rows)
}
func scanAssertionResults(rows *sql.Rows) ([]AssertionResult, error) {
var result []AssertionResult
for rows.Next() {
var ar AssertionResult
var valueJSON, evaluatedAt string
if err := rows.Scan(&ar.ID, &ar.AssertionID, &ar.ExecutionID, &ar.Status,
&valueJSON, &ar.Message, &evaluatedAt); err != nil {
return nil, fmt.Errorf("scanning assertion_result: %w", err)
}
ar.Value = unmarshalJSON(valueJSON)
ar.EvaluatedAt, _ = time.Parse(time.RFC3339, evaluatedAt)
result = append(result, ar)
}
return result, nil
}