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
+4 -1
View File
@@ -31,6 +31,8 @@ func main() {
cmdAdd(os.Args[2:])
case "ops":
cmdOps(os.Args[2:])
case "proposal":
cmdProposal(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
@@ -49,7 +51,8 @@ Usage:
fn list [-k kind] [-d domain] [-l lang]
fn show <id> Muestra entrada completa
fn add [-k kind] Abre $EDITOR con template
fn ops <subcommand> Gestiona operations.db (fn ops help)`)
fn ops <subcommand> Gestiona operations.db (fn ops help)
fn proposal <add|list|show|update> Gestiona proposals`)
}
func root() string {
+677 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"text/tabwriter"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
@@ -32,6 +33,10 @@ func cmdOps(args []string) {
cmdOpsGraph()
case "snapshot":
cmdOpsSnapshot(args[1:])
case "execution":
cmdOpsExecution(args[1:])
case "assertion":
cmdOpsAssertion(args[1:])
case "help", "-h", "--help":
printOpsUsage()
default:
@@ -68,7 +73,21 @@ Relation flags:
--id <id> --name <name> --from <entity_id> --to <entity_id>
--via <function_id> --direction <uni|bi|inverse> --status <status>
--purity <pure|impure> --weight <0.0-1.0> --description <desc>
--tags <t1,t2> --notes <text>`)
--tags <t1,t2> --notes <text>
Execution commands:
fn ops execution add <flags> Registra ejecucion
fn ops execution list [--pipeline-id <id>] [-s status]
fn ops execution show <id> Muestra ejecucion
Assertion commands:
fn ops assertion add <flags> Añade assertion
fn ops assertion list [--entity-id <id>] Lista assertions
fn ops assertion show <id> Muestra assertion
fn ops assertion delete <id> Elimina assertion
fn ops assertion eval --entity-id <id> Evalua assertions activas
fn ops assertion result add <flags> Registra resultado manual
fn ops assertion result list [--assertion-id <id>]`)
}
// --- ops init ---
@@ -722,6 +741,663 @@ func openOpsDB() *ops.DB {
return db
}
// --- Execution subcommands ---
func cmdOpsExecution(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops execution <add|list|show>")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsExecutionAdd(args[1:])
case "list":
cmdOpsExecutionList(args[1:])
case "show":
cmdOpsExecutionShow(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown execution command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsExecutionAdd(args []string) {
var id, pipelineID, relationID, status, startedAtStr, endedAtStr, errorMsg, metricsStr string
var recordsIn, recordsOut *int64
i := 0
for i < len(args) {
switch args[i] {
case "--id":
i++
id = args[i]
case "--pipeline-id":
i++
pipelineID = args[i]
case "--relation-id":
i++
relationID = args[i]
case "--status", "-s":
i++
status = args[i]
case "--started-at":
i++
startedAtStr = args[i]
case "--ended-at":
i++
endedAtStr = args[i]
case "--records-in":
i++
v := parseInt64(args[i])
recordsIn = &v
case "--records-out":
i++
v := parseInt64(args[i])
recordsOut = &v
case "--error":
i++
errorMsg = args[i]
case "--metrics":
i++
metricsStr = args[i]
}
i++
}
if pipelineID == "" || status == "" {
fmt.Fprintln(os.Stderr, "error: --pipeline-id and --status are required")
os.Exit(1)
}
if id == "" {
id = fmt.Sprintf("exec_%d", timeNow().UnixNano())
}
var startedAt time.Time
if startedAtStr != "" {
var err error
startedAt, err = time.Parse(time.RFC3339, startedAtStr)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid started-at: %v\n", err)
os.Exit(1)
}
} else {
startedAt = timeNow()
}
var endedAt *time.Time
if endedAtStr != "" {
t, err := time.Parse(time.RFC3339, endedAtStr)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid ended-at: %v\n", err)
os.Exit(1)
}
endedAt = &t
}
var metrics map[string]any
if metricsStr != "" {
if err := json.Unmarshal([]byte(metricsStr), &metrics); err != nil {
fmt.Fprintf(os.Stderr, "error: invalid metrics JSON: %v\n", err)
os.Exit(1)
}
}
e := &ops.Execution{
ID: id,
PipelineID: pipelineID,
RelationID: relationID,
Status: ops.ExecutionStatus(status),
StartedAt: startedAt,
EndedAt: endedAt,
RecordsIn: recordsIn,
RecordsOut: recordsOut,
Error: errorMsg,
Metrics: metrics,
}
db := openOpsDB()
defer db.Close()
if err := ops.InsertExecutionSafe(db, e); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Recorded execution: %s\n", e.ID)
}
func cmdOpsExecutionList(args []string) {
var pipelineID, relationID, status string
i := 0
for i < len(args) {
switch args[i] {
case "--pipeline-id":
i++
pipelineID = args[i]
case "--relation-id":
i++
relationID = args[i]
case "-s":
i++
status = args[i]
}
i++
}
db := openOpsDB()
defer db.Close()
execs, err := db.ListExecutions(pipelineID, relationID, ops.ExecutionStatus(status))
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(execs) == 0 {
fmt.Println("No executions.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tPIPELINE\tSTATUS\tDURATION_MS\tRECORDS_IN\tRECORDS_OUT")
for _, e := range execs {
dur := "-"
if e.DurationMs != nil {
dur = fmt.Sprintf("%d", *e.DurationMs)
}
rin := "-"
if e.RecordsIn != nil {
rin = fmt.Sprintf("%d", *e.RecordsIn)
}
rout := "-"
if e.RecordsOut != nil {
rout = fmt.Sprintf("%d", *e.RecordsOut)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", e.ID, e.PipelineID, e.Status, dur, rin, rout)
}
w.Flush()
}
func cmdOpsExecutionShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops execution show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
e, err := db.GetExecution(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if e == nil {
fmt.Fprintf(os.Stderr, "execution %q not found\n", args[0])
os.Exit(1)
}
fmt.Printf("ID: %s\n", e.ID)
fmt.Printf("Pipeline: %s\n", e.PipelineID)
if e.RelationID != "" {
fmt.Printf("Relation: %s\n", e.RelationID)
}
fmt.Printf("Status: %s\n", e.Status)
fmt.Printf("Started at: %s\n", e.StartedAt.Format(time.RFC3339))
if e.EndedAt != nil {
fmt.Printf("Ended at: %s\n", e.EndedAt.Format(time.RFC3339))
}
if e.DurationMs != nil {
fmt.Printf("Duration: %d ms\n", *e.DurationMs)
}
if e.RecordsIn != nil {
fmt.Printf("Records in: %d\n", *e.RecordsIn)
}
if e.RecordsOut != nil {
fmt.Printf("Records out: %d\n", *e.RecordsOut)
}
if e.Error != "" {
fmt.Printf("Error: %s\n", e.Error)
}
if len(e.Metrics) > 0 {
m, _ := json.MarshalIndent(e.Metrics, " ", " ")
fmt.Printf("Metrics: %s\n", string(m))
}
}
// --- Assertion subcommands ---
func cmdOpsAssertion(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops assertion <add|list|show|delete|eval|result>")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsAssertionAdd(args[1:])
case "list":
cmdOpsAssertionList(args[1:])
case "show":
cmdOpsAssertionShow(args[1:])
case "delete":
cmdOpsAssertionDelete(args[1:])
case "eval":
cmdOpsAssertionEval(args[1:])
case "result":
cmdOpsAssertionResult(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown assertion command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsAssertionAdd(args []string) {
var id, entityID, name, kind, rule, severity, description string
i := 0
for i < len(args) {
switch args[i] {
case "--id":
i++
id = args[i]
case "--entity-id":
i++
entityID = args[i]
case "--name":
i++
name = args[i]
case "--kind":
i++
kind = args[i]
case "--rule":
i++
rule = args[i]
case "--severity":
i++
severity = args[i]
case "--description":
i++
description = args[i]
}
i++
}
if entityID == "" || name == "" || kind == "" || rule == "" {
fmt.Fprintln(os.Stderr, "error: --entity-id, --name, --kind, and --rule are required")
os.Exit(1)
}
if id == "" {
id = fmt.Sprintf("assert_%s_%d", name, timeNow().UnixNano())
}
if severity == "" {
severity = "warning"
}
a := &ops.Assertion{
ID: id,
EntityID: entityID,
Name: name,
Kind: kind,
Rule: rule,
Severity: ops.Severity(severity),
Description: description,
Active: true,
}
db := openOpsDB()
defer db.Close()
if err := ops.InsertAssertionSafe(db, a); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Created assertion: %s\n", a.ID)
}
func cmdOpsAssertionList(args []string) {
var entityID string
var activeFilter *bool
i := 0
for i < len(args) {
switch args[i] {
case "--entity-id":
i++
entityID = args[i]
case "--active":
v := true
activeFilter = &v
case "--inactive":
v := false
activeFilter = &v
}
i++
}
db := openOpsDB()
defer db.Close()
assertions, err := db.ListAssertions(entityID, activeFilter)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(assertions) == 0 {
fmt.Println("No assertions.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tENTITY\tNAME\tKIND\tSEVERITY\tACTIVE")
for _, a := range assertions {
active := "yes"
if !a.Active {
active = "no"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", a.ID, a.EntityID, a.Name, a.Kind, a.Severity, active)
}
w.Flush()
}
func cmdOpsAssertionShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops assertion show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
a, err := db.GetAssertion(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if a == nil {
fmt.Fprintf(os.Stderr, "assertion %q not found\n", args[0])
os.Exit(1)
}
active := "yes"
if !a.Active {
active = "no"
}
fmt.Printf("ID: %s\n", a.ID)
fmt.Printf("Entity: %s\n", a.EntityID)
fmt.Printf("Name: %s\n", a.Name)
fmt.Printf("Kind: %s\n", a.Kind)
fmt.Printf("Rule: %s\n", a.Rule)
fmt.Printf("Severity: %s\n", a.Severity)
fmt.Printf("Description: %s\n", a.Description)
fmt.Printf("Active: %s\n", active)
fmt.Printf("Created: %s\n", a.CreatedAt.Format(time.RFC3339))
// Show recent results
results, err := db.ListAssertionResults(a.ID, "")
if err == nil && len(results) > 0 {
fmt.Printf("\nRecent results:\n")
limit := 5
if len(results) < limit {
limit = len(results)
}
for _, r := range results[:limit] {
fmt.Printf(" [%s] %s — %s %s\n", r.EvaluatedAt.Format(time.RFC3339), r.Status, r.Message, formatResultValue(r.Value))
}
}
}
func cmdOpsAssertionDelete(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops assertion delete <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
if err := db.DeleteAssertion(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Deleted assertion: %s\n", args[0])
}
func cmdOpsAssertionEval(args []string) {
var entityID, executionID string
react := false
i := 0
for i < len(args) {
switch args[i] {
case "--entity-id":
i++
entityID = args[i]
case "--execution-id":
i++
executionID = args[i]
case "--react":
react = true
}
i++
}
if entityID == "" {
fmt.Fprintln(os.Stderr, "error: --entity-id is required")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
results, err := ops.EvalEntityAssertions(db, entityID, executionID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(results) == 0 {
fmt.Println("No active assertions for this entity.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ASSERTION\tSTATUS\tMESSAGE")
for _, r := range results {
fmt.Fprintf(w, "%s\t%s\t%s\n", r.AssertionID, r.Status, truncate(r.Message, 60))
}
w.Flush()
// Summary
pass, fail, skip := 0, 0, 0
for _, r := range results {
switch r.Status {
case ops.ResultPass:
pass++
case ops.ResultFail:
fail++
case ops.ResultSkip:
skip++
}
}
fmt.Printf("\nResults: %d pass, %d fail, %d skip\n", pass, fail, skip)
// Reactive loop: apply severity rules
if react && fail > 0 {
var regDB *registry.DB
regDB = tryOpenRegistryDB()
// regDB can be nil — React handles it (just won't create proposals)
rr, err := ops.React(db, regDB, results)
if regDB != nil {
defer regDB.Close()
}
if err != nil {
fmt.Fprintf(os.Stderr, "error in reactive loop: %v\n", err)
os.Exit(1)
}
if len(rr.EntityUpdates) > 0 {
fmt.Println("\nEntity status changes:")
for _, u := range rr.EntityUpdates {
fmt.Printf(" %s: %s -> %s (%s)\n", u.EntityID, u.OldStatus, u.NewStatus, u.Reason)
}
}
if len(rr.Proposals) > 0 {
fmt.Println("\nProposals created:")
for _, pid := range rr.Proposals {
fmt.Printf(" %s\n", pid)
}
}
} else if !react && fail > 0 {
fmt.Println("\nTip: use --react to apply severity rules (update entity status, create proposals)")
}
}
// --- Assertion Result subcommands ---
func cmdOpsAssertionResult(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops assertion result <add|list>")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsAssertionResultAdd(args[1:])
case "list":
cmdOpsAssertionResultList(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown assertion result command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsAssertionResultAdd(args []string) {
var id, assertionID, executionID, status, valueStr, message string
i := 0
for i < len(args) {
switch args[i] {
case "--id":
i++
id = args[i]
case "--assertion-id":
i++
assertionID = args[i]
case "--execution-id":
i++
executionID = args[i]
case "--status":
i++
status = args[i]
case "--value":
i++
valueStr = args[i]
case "--message":
i++
message = args[i]
}
i++
}
if assertionID == "" || status == "" {
fmt.Fprintln(os.Stderr, "error: --assertion-id and --status are required")
os.Exit(1)
}
if id == "" {
id = fmt.Sprintf("ar_%d", timeNow().UnixNano())
}
var value map[string]any
if valueStr != "" {
if err := json.Unmarshal([]byte(valueStr), &value); err != nil {
fmt.Fprintf(os.Stderr, "error: invalid value JSON: %v\n", err)
os.Exit(1)
}
}
ar := &ops.AssertionResult{
ID: id,
AssertionID: assertionID,
ExecutionID: executionID,
Status: ops.AssertionResultStatus(status),
Value: value,
Message: message,
EvaluatedAt: timeNow(),
}
db := openOpsDB()
defer db.Close()
if err := db.InsertAssertionResult(ar); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Recorded result: %s (%s)\n", ar.ID, ar.Status)
}
func cmdOpsAssertionResultList(args []string) {
var assertionID, executionID string
i := 0
for i < len(args) {
switch args[i] {
case "--assertion-id":
i++
assertionID = args[i]
case "--execution-id":
i++
executionID = args[i]
}
i++
}
db := openOpsDB()
defer db.Close()
results, err := db.ListAssertionResults(assertionID, executionID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(results) == 0 {
fmt.Println("No assertion results.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tASSERTION\tEXECUTION\tSTATUS\tEVALUATED_AT")
for _, r := range results {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.ID, r.AssertionID, r.ExecutionID, r.Status, r.EvaluatedAt.Format(time.RFC3339))
}
w.Flush()
}
// --- helpers for new commands ---
func timeNow() time.Time {
return time.Now().UTC()
}
func parseInt64(s string) int64 {
var v int64
fmt.Sscanf(s, "%d", &v)
return v
}
func formatResultValue(v map[string]any) string {
if len(v) == 0 {
return ""
}
b, _ := json.Marshal(v)
s := string(b)
if len(s) > 40 {
return s[:37] + "..."
}
return s
}
func tryOpenRegistryDB() *registry.DB {
// Try FN_REGISTRY_ROOT env var first
if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" {