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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user