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:
+4
-1
@@ -31,6 +31,8 @@ func main() {
|
|||||||
cmdAdd(os.Args[2:])
|
cmdAdd(os.Args[2:])
|
||||||
case "ops":
|
case "ops":
|
||||||
cmdOps(os.Args[2:])
|
cmdOps(os.Args[2:])
|
||||||
|
case "proposal":
|
||||||
|
cmdProposal(os.Args[2:])
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
printUsage()
|
printUsage()
|
||||||
default:
|
default:
|
||||||
@@ -49,7 +51,8 @@ Usage:
|
|||||||
fn list [-k kind] [-d domain] [-l lang]
|
fn list [-k kind] [-d domain] [-l lang]
|
||||||
fn show <id> Muestra entrada completa
|
fn show <id> Muestra entrada completa
|
||||||
fn add [-k kind] Abre $EDITOR con template
|
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 {
|
func root() string {
|
||||||
|
|||||||
+677
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
ops "fn-registry/fn_operations"
|
ops "fn-registry/fn_operations"
|
||||||
"fn-registry/registry"
|
"fn-registry/registry"
|
||||||
@@ -32,6 +33,10 @@ func cmdOps(args []string) {
|
|||||||
cmdOpsGraph()
|
cmdOpsGraph()
|
||||||
case "snapshot":
|
case "snapshot":
|
||||||
cmdOpsSnapshot(args[1:])
|
cmdOpsSnapshot(args[1:])
|
||||||
|
case "execution":
|
||||||
|
cmdOpsExecution(args[1:])
|
||||||
|
case "assertion":
|
||||||
|
cmdOpsAssertion(args[1:])
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
printOpsUsage()
|
printOpsUsage()
|
||||||
default:
|
default:
|
||||||
@@ -68,7 +73,21 @@ Relation flags:
|
|||||||
--id <id> --name <name> --from <entity_id> --to <entity_id>
|
--id <id> --name <name> --from <entity_id> --to <entity_id>
|
||||||
--via <function_id> --direction <uni|bi|inverse> --status <status>
|
--via <function_id> --direction <uni|bi|inverse> --status <status>
|
||||||
--purity <pure|impure> --weight <0.0-1.0> --description <desc>
|
--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 ---
|
// --- ops init ---
|
||||||
@@ -722,6 +741,663 @@ func openOpsDB() *ops.DB {
|
|||||||
return 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 {
|
func tryOpenRegistryDB() *registry.DB {
|
||||||
// Try FN_REGISTRY_ROOT env var first
|
// Try FN_REGISTRY_ROOT env var first
|
||||||
if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" {
|
if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" {
|
||||||
|
|||||||
+2
-85
@@ -9,89 +9,6 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schemaSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS types_snapshot (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
|
||||||
lang TEXT NOT NULL,
|
|
||||||
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
|
|
||||||
definition TEXT NOT NULL DEFAULT '',
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
snapped_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type_ref TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','stale','corrupted','archived')),
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
domain TEXT NOT NULL DEFAULT '',
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}',
|
|
||||||
notes TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS relations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
from_entity TEXT NOT NULL DEFAULT '',
|
|
||||||
to_entity TEXT NOT NULL,
|
|
||||||
via TEXT NOT NULL DEFAULT '',
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
purity TEXT NOT NULL DEFAULT '' CHECK(purity IN ('','pure','impure')),
|
|
||||||
direction TEXT NOT NULL DEFAULT 'unidirectional' CHECK(direction IN ('unidirectional','bidirectional','inverse')),
|
|
||||||
weight REAL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'designed' CHECK(status IN ('designed','implemented','tested','running','deprecated')),
|
|
||||||
started_at TEXT,
|
|
||||||
ended_at TEXT,
|
|
||||||
"order" INTEGER,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
notes TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS relation_inputs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
relation_id TEXT NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
|
|
||||||
entity_id TEXT NOT NULL REFERENCES entities(id),
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
"order" INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
domain,
|
|
||||||
content='entities',
|
|
||||||
content_rowid='rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep entities FTS in sync
|
|
||||||
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
|
|
||||||
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
|
|
||||||
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
|
|
||||||
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
|
|
||||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
|
||||||
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
|
|
||||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
|
||||||
END;
|
|
||||||
`
|
|
||||||
|
|
||||||
// DB wraps a SQLite connection for an operations database.
|
// DB wraps a SQLite connection for an operations database.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
@@ -115,9 +32,9 @@ func Open(path string) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("setting WAL mode: %w", err)
|
return nil, fmt.Errorf("setting WAL mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := conn.Exec(schemaSQL); err != nil {
|
if err := migrate(conn); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, fmt.Errorf("applying schema: %w", err)
|
return nil, fmt.Errorf("running migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DB{conn: conn, path: path}, nil
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package fn_operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fieldPattern matches bare field names (word chars) that are NOT already inside
|
||||||
|
// a json_extract call, a SQL keyword, a string literal, or a number.
|
||||||
|
var fieldPattern = regexp.MustCompile(`\b([a-zA-Z_][a-zA-Z0-9_]*)\b`)
|
||||||
|
|
||||||
|
// sqlKeywords that should not be rewritten to json_extract.
|
||||||
|
var sqlKeywords = map[string]bool{
|
||||||
|
"AND": true, "OR": true, "NOT": true, "IS": true, "NULL": true,
|
||||||
|
"IN": true, "BETWEEN": true, "LIKE": true, "GLOB": true,
|
||||||
|
"TRUE": true, "FALSE": true, "CASE": true, "WHEN": true,
|
||||||
|
"THEN": true, "ELSE": true, "END": true, "SELECT": true,
|
||||||
|
"FROM": true, "WHERE": true, "AS": true, "CAST": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlFunctions that should not be rewritten.
|
||||||
|
var sqlFunctions = map[string]bool{
|
||||||
|
"json_extract": true, "datetime": true, "now": true,
|
||||||
|
"abs": true, "avg": true, "count": true, "max": true, "min": true,
|
||||||
|
"sum": true, "total": true, "length": true, "typeof": true,
|
||||||
|
"coalesce": true, "ifnull": true, "nullif": true,
|
||||||
|
"upper": true, "lower": true, "trim": true, "replace": true,
|
||||||
|
"substr": true, "instr": true, "round": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteRule transforms a rule expression into SQL that operates on entity metadata.
|
||||||
|
// Bare field names are rewritten to json_extract(metadata, '$.field').
|
||||||
|
// If the rule already uses json_extract, it is left as-is.
|
||||||
|
func rewriteRule(rule string) string {
|
||||||
|
if strings.Contains(rule, "json_extract") {
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldPattern.ReplaceAllStringFunc(rule, func(match string) string {
|
||||||
|
upper := strings.ToUpper(match)
|
||||||
|
if sqlKeywords[upper] {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if sqlFunctions[strings.ToLower(match)] {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// Skip numeric-looking tokens (shouldn't match the regex, but be safe)
|
||||||
|
if match[0] >= '0' && match[0] <= '9' {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("json_extract(metadata, '$.%s')", match)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalAssertion evaluates a single assertion against its entity's metadata.
|
||||||
|
// Returns a result with pass/fail/skip status.
|
||||||
|
func EvalAssertion(db *DB, a *Assertion, executionID string) (*AssertionResult, error) {
|
||||||
|
result := &AssertionResult{
|
||||||
|
ID: fmt.Sprintf("ar_%s_%d", a.ID, time.Now().UnixNano()),
|
||||||
|
AssertionID: a.ID,
|
||||||
|
ExecutionID: executionID,
|
||||||
|
EvaluatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten := rewriteRule(a.Rule)
|
||||||
|
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
SELECT CASE WHEN (%s) THEN 'pass' ELSE 'fail' END,
|
||||||
|
metadata
|
||||||
|
FROM entities WHERE id = ?`, rewritten)
|
||||||
|
|
||||||
|
var status, metadataJSON string
|
||||||
|
err := db.conn.QueryRow(q, a.EntityID).Scan(&status, &metadataJSON)
|
||||||
|
if err != nil {
|
||||||
|
result.Status = ResultSkip
|
||||||
|
result.Message = fmt.Sprintf("evaluation error: %v", err)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "pass" {
|
||||||
|
result.Status = ResultPass
|
||||||
|
} else {
|
||||||
|
result.Status = ResultFail
|
||||||
|
result.Value = unmarshalJSON(metadataJSON)
|
||||||
|
result.Message = fmt.Sprintf("rule failed: %s", a.Rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalEntityAssertions evaluates all active assertions for an entity.
|
||||||
|
// Returns results and persists them in the database.
|
||||||
|
func EvalEntityAssertions(db *DB, entityID, executionID string) ([]AssertionResult, error) {
|
||||||
|
active := true
|
||||||
|
assertions, err := db.ListAssertions(entityID, &active)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing assertions for entity %s: %w", entityID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []AssertionResult
|
||||||
|
for _, a := range assertions {
|
||||||
|
ar, err := EvalAssertion(db, &a, executionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluating assertion %s: %w", a.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.InsertAssertionResult(ar); err != nil {
|
||||||
|
return nil, fmt.Errorf("storing result for assertion %s: %w", a.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, *ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package fn_operations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
const migrationTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);`
|
||||||
|
|
||||||
|
// migrate applies pending migrations to the database.
|
||||||
|
func migrate(conn *sql.DB) error {
|
||||||
|
if _, err := conn.Exec(migrationTableSQL); err != nil {
|
||||||
|
return fmt.Errorf("creating schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := currentVersion(conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mf := range files {
|
||||||
|
if mf.version <= current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := migrationsFS.ReadFile(path.Join("migrations", mf.filename))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("beginning transaction for migration %d: %w", mf.version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("applying migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||||
|
mf.version, mf.filename, time.Now().UTC().Format(time.RFC3339),
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("recording migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("committing migration %s: %w", mf.filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentVersion(conn *sql.DB) (int, error) {
|
||||||
|
var v int
|
||||||
|
err := conn.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("reading current schema version: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type migrationFile struct {
|
||||||
|
version int
|
||||||
|
filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMigrations() ([]migrationFile, error) {
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading migrations directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []migrationFile
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(e.Name(), "_", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, migrationFile{version: v, filename: e.Name()})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].version < files[j].version
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Executions, assertions, and assertion_results tables.
|
||||||
|
-- Closes the autonomous improvement loop: execute -> assert -> analyze.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS executions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
pipeline_id TEXT NOT NULL,
|
||||||
|
relation_id TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('success','failure','partial')),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
records_in INTEGER,
|
||||||
|
records_out INTEGER,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
metrics TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assertions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
rule TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'warning' CHECK(severity IN ('critical','warning','info')),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assertion_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
assertion_id TEXT NOT NULL REFERENCES assertions(id),
|
||||||
|
execution_id TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pass','fail','skip')),
|
||||||
|
value TEXT NOT NULL DEFAULT '{}',
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
evaluated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS assertions_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
rule,
|
||||||
|
content='assertions',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS assertions_ai AFTER INSERT ON assertions BEGIN
|
||||||
|
INSERT INTO assertions_fts(rowid, id, name, description, rule)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.rule);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS assertions_ad AFTER DELETE ON assertions BEGIN
|
||||||
|
INSERT INTO assertions_fts(assertions_fts, rowid, id, name, description, rule)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.rule);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS assertions_au AFTER UPDATE ON assertions BEGIN
|
||||||
|
INSERT INTO assertions_fts(assertions_fts, rowid, id, name, description, rule)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.rule);
|
||||||
|
INSERT INTO assertions_fts(rowid, id, name, description, rule)
|
||||||
|
VALUES (new.rowid, new.id, new.name, new.description, new.rule);
|
||||||
|
END;
|
||||||
@@ -78,6 +78,73 @@ type RelationInput struct {
|
|||||||
Order *int `json:"order"`
|
Order *int `json:"order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecutionStatus represents the result of a pipeline execution.
|
||||||
|
type ExecutionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExecSuccess ExecutionStatus = "success"
|
||||||
|
ExecFailure ExecutionStatus = "failure"
|
||||||
|
ExecPartial ExecutionStatus = "partial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity classifies the impact of an assertion failure.
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityCritical Severity = "critical"
|
||||||
|
SeverityWarning Severity = "warning"
|
||||||
|
SeverityInfo Severity = "info"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssertionResultStatus represents the outcome of an assertion evaluation.
|
||||||
|
type AssertionResultStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResultPass AssertionResultStatus = "pass"
|
||||||
|
ResultFail AssertionResultStatus = "fail"
|
||||||
|
ResultSkip AssertionResultStatus = "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execution records a pipeline run with its metrics and outcome.
|
||||||
|
type Execution struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PipelineID string `json:"pipeline_id"`
|
||||||
|
RelationID string `json:"relation_id"`
|
||||||
|
Status ExecutionStatus `json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
EndedAt *time.Time `json:"ended_at"`
|
||||||
|
DurationMs *int64 `json:"duration_ms"`
|
||||||
|
RecordsIn *int64 `json:"records_in"`
|
||||||
|
RecordsOut *int64 `json:"records_out"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Metrics map[string]any `json:"metrics"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertion is a formal quality rule evaluated against an entity.
|
||||||
|
type Assertion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // free text: range, null, statistical, consistency, freshness, ...
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Severity Severity `json:"severity"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertionResult records one evaluation of an assertion.
|
||||||
|
type AssertionResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AssertionID string `json:"assertion_id"`
|
||||||
|
ExecutionID string `json:"execution_id"`
|
||||||
|
Status AssertionResultStatus `json:"status"`
|
||||||
|
Value map[string]any `json:"value"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
EvaluatedAt time.Time `json:"evaluated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// TypeSnapshot is an immutable copy of a registry type at point of use.
|
// TypeSnapshot is an immutable copy of a registry type at point of use.
|
||||||
type TypeSnapshot struct {
|
type TypeSnapshot struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -277,6 +277,276 @@ func UpdateSnapshot(opsDB *DB, registryDB *registry.DB, typeID string) (old, new
|
|||||||
return oldSnap, newSnap, nil
|
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 := ®istry.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) {
|
func buildEntitySet(db *DB) (map[string]bool, error) {
|
||||||
all, err := db.ListEntities("", "")
|
all, err := db.ListEntities("", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func tempDB(t *testing.T) *DB {
|
func tempDB(t *testing.T) *DB {
|
||||||
@@ -351,6 +352,479 @@ func TestValidateEntity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutionCRUD(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
started := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
|
||||||
|
ended := time.Date(2026, 3, 28, 10, 5, 0, 0, time.UTC)
|
||||||
|
rin := int64(1000)
|
||||||
|
rout := int64(60)
|
||||||
|
|
||||||
|
e := &Execution{
|
||||||
|
ID: "exec_1",
|
||||||
|
PipelineID: "tick_to_ohlcv_go_finance",
|
||||||
|
RelationID: "rel_1",
|
||||||
|
Status: ExecSuccess,
|
||||||
|
StartedAt: started,
|
||||||
|
EndedAt: &ended,
|
||||||
|
RecordsIn: &rin,
|
||||||
|
RecordsOut: &rout,
|
||||||
|
Metrics: map[string]any{"mean_close": 42000},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := InsertExecutionSafe(db, e); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-calculated duration
|
||||||
|
if e.DurationMs == nil || *e.DurationMs != 300000 {
|
||||||
|
t.Errorf("duration_ms = %v, want 300000", e.DurationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetExecution("exec_1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected execution, got nil")
|
||||||
|
}
|
||||||
|
if got.PipelineID != "tick_to_ohlcv_go_finance" {
|
||||||
|
t.Errorf("pipeline_id = %q", got.PipelineID)
|
||||||
|
}
|
||||||
|
if got.Metrics["mean_close"] != float64(42000) {
|
||||||
|
t.Errorf("metrics mean_close = %v", got.Metrics["mean_close"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// List
|
||||||
|
all, err := db.ListExecutions("", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 1 {
|
||||||
|
t.Errorf("list = %d, want 1", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
byPipeline, _ := db.ListExecutions("tick_to_ohlcv_go_finance", "", "")
|
||||||
|
if len(byPipeline) != 1 {
|
||||||
|
t.Errorf("list by pipeline = %d, want 1", len(byPipeline))
|
||||||
|
}
|
||||||
|
|
||||||
|
byStatus, _ := db.ListExecutions("", "", ExecFailure)
|
||||||
|
if len(byStatus) != 0 {
|
||||||
|
t.Errorf("list by failure status = %d, want 0", len(byStatus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssertionCRUD(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
Metadata: map[string]any{"close": 120, "high": 150, "low": 90}})
|
||||||
|
|
||||||
|
a := &Assertion{
|
||||||
|
ID: "assert_close_positive",
|
||||||
|
EntityID: "e1",
|
||||||
|
Name: "close positivo",
|
||||||
|
Kind: "range",
|
||||||
|
Rule: "close > 0",
|
||||||
|
Severity: SeverityCritical,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := InsertAssertionSafe(db, a); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetAssertion("assert_close_positive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected assertion, got nil")
|
||||||
|
}
|
||||||
|
if got.Rule != "close > 0" {
|
||||||
|
t.Errorf("rule = %q", got.Rule)
|
||||||
|
}
|
||||||
|
if !got.Active {
|
||||||
|
t.Error("expected active = true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
got.Active = false
|
||||||
|
if err := db.UpdateAssertion(got); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
updated, _ := db.GetAssertion("assert_close_positive")
|
||||||
|
if updated.Active {
|
||||||
|
t.Error("expected active = false after update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with active filter
|
||||||
|
active := true
|
||||||
|
byActive, _ := db.ListAssertions("e1", &active)
|
||||||
|
if len(byActive) != 0 {
|
||||||
|
t.Errorf("list active = %d, want 0 (we deactivated it)", len(byActive))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search FTS
|
||||||
|
updated.Active = true
|
||||||
|
db.UpdateAssertion(updated)
|
||||||
|
found, err := db.SearchAssertions("close", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Errorf("search 'close' = %d, want 1", len(found))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
if err := db.DeleteAssertion("assert_close_positive"); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
deleted, _ := db.GetAssertion("assert_close_positive")
|
||||||
|
if deleted != nil {
|
||||||
|
t.Error("expected nil after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssertionResultCRUD(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a1", EntityID: "e1", Name: "test", Kind: "range", Rule: "x > 0", Severity: SeverityWarning, Active: true})
|
||||||
|
|
||||||
|
ar := &AssertionResult{
|
||||||
|
ID: "ar_1",
|
||||||
|
AssertionID: "a1",
|
||||||
|
ExecutionID: "exec_1",
|
||||||
|
Status: ResultFail,
|
||||||
|
Value: map[string]any{"x": -5},
|
||||||
|
Message: "rule failed: x > 0",
|
||||||
|
EvaluatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.InsertAssertionResult(ar); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetAssertionResult("ar_1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected result, got nil")
|
||||||
|
}
|
||||||
|
if got.Status != ResultFail {
|
||||||
|
t.Errorf("status = %q, want fail", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List by assertion
|
||||||
|
byAssertion, _ := db.ListAssertionResults("a1", "")
|
||||||
|
if len(byAssertion) != 1 {
|
||||||
|
t.Errorf("list by assertion = %d, want 1", len(byAssertion))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List by execution
|
||||||
|
byExec, _ := db.ListAssertionResults("", "exec_1")
|
||||||
|
if len(byExec) != 1 {
|
||||||
|
t.Errorf("list by execution = %d, want 1", len(byExec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalAssertion(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
Metadata: map[string]any{"close": 120, "high": 150, "low": 90, "open": 100},
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule string
|
||||||
|
kind string
|
||||||
|
wantStatus AssertionResultStatus
|
||||||
|
}{
|
||||||
|
{"range pass", "close > 0 AND close < 1000000", "range", ResultPass},
|
||||||
|
{"range fail", "close > 200", "range", ResultFail},
|
||||||
|
{"consistency pass", "low <= close AND close <= high", "consistency", ResultPass},
|
||||||
|
{"consistency fail", "low > close", "consistency", ResultFail},
|
||||||
|
{"null pass (field exists)", "close IS NOT NULL", "null", ResultPass},
|
||||||
|
{"json_extract direct", "json_extract(metadata, '$.close') > 0", "range", ResultPass},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
a := &Assertion{
|
||||||
|
ID: "test_" + tt.name, EntityID: "e1", Name: tt.name,
|
||||||
|
Kind: tt.kind, Rule: tt.rule, Severity: SeverityWarning, Active: true,
|
||||||
|
}
|
||||||
|
result, err := EvalAssertion(db, a, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != tt.wantStatus {
|
||||||
|
t.Errorf("status = %q, want %q (message: %s)", result.Status, tt.wantStatus, result.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalEntityAssertions(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
Metadata: map[string]any{"close": 120},
|
||||||
|
})
|
||||||
|
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a1", EntityID: "e1", Name: "pass", Kind: "range", Rule: "close > 0", Severity: SeverityWarning, Active: true})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a2", EntityID: "e1", Name: "fail", Kind: "range", Rule: "close > 200", Severity: SeverityCritical, Active: true})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a3", EntityID: "e1", Name: "inactive", Kind: "range", Rule: "close > 999", Severity: SeverityInfo, Active: false})
|
||||||
|
|
||||||
|
results, err := EvalEntityAssertions(db, "e1", "exec_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only eval active assertions (a1, a2), not a3
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 results, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
pass, fail := 0, 0
|
||||||
|
for _, r := range results {
|
||||||
|
switch r.Status {
|
||||||
|
case ResultPass:
|
||||||
|
pass++
|
||||||
|
case ResultFail:
|
||||||
|
fail++
|
||||||
|
}
|
||||||
|
if r.ExecutionID != "exec_test" {
|
||||||
|
t.Errorf("execution_id = %q, want exec_test", r.ExecutionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pass != 1 || fail != 1 {
|
||||||
|
t.Errorf("pass=%d fail=%d, want 1 and 1", pass, fail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results should be persisted
|
||||||
|
stored, _ := db.ListAssertionResults("", "exec_test")
|
||||||
|
if len(stored) != 2 {
|
||||||
|
t.Errorf("stored results = %d, want 2", len(stored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReactCriticalCorruptsEntity(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
Metadata: map[string]any{"close": 120},
|
||||||
|
})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a_crit", EntityID: "e1", Name: "critical_fail", Kind: "range", Rule: "close > 200", Severity: SeverityCritical, Active: true})
|
||||||
|
|
||||||
|
// Simulate failed assertion result
|
||||||
|
results := []AssertionResult{
|
||||||
|
{ID: "ar1", AssertionID: "a_crit", Status: ResultFail, Message: "close > 200 failed", EvaluatedAt: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, err := React(db, nil, results)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("react: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rr.EntityUpdates) != 1 {
|
||||||
|
t.Fatalf("expected 1 entity update, got %d", len(rr.EntityUpdates))
|
||||||
|
}
|
||||||
|
if rr.EntityUpdates[0].NewStatus != StatusCorrupted {
|
||||||
|
t.Errorf("new status = %q, want corrupted", rr.EntityUpdates[0].NewStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entity in DB
|
||||||
|
entity, _ := db.GetEntity("e1")
|
||||||
|
if entity.Status != StatusCorrupted {
|
||||||
|
t.Errorf("entity status = %q, want corrupted", entity.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReactWarningStalesEntity(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
Metadata: map[string]any{"close": 120},
|
||||||
|
})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a_warn", EntityID: "e1", Name: "warn_fail", Kind: "range", Rule: "close > 200", Severity: SeverityWarning, Active: true})
|
||||||
|
|
||||||
|
results := []AssertionResult{
|
||||||
|
{ID: "ar1", AssertionID: "a_warn", Status: ResultFail, Message: "warning", EvaluatedAt: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, err := React(db, nil, results)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("react: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rr.EntityUpdates) != 1 {
|
||||||
|
t.Fatalf("expected 1 entity update, got %d", len(rr.EntityUpdates))
|
||||||
|
}
|
||||||
|
if rr.EntityUpdates[0].NewStatus != StatusStale {
|
||||||
|
t.Errorf("new status = %q, want stale", rr.EntityUpdates[0].NewStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReactWarningDoesNotDowngradeCorrupted(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusCorrupted, Source: "test",
|
||||||
|
})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a_warn", EntityID: "e1", Name: "warn", Kind: "range", Rule: "x > 0", Severity: SeverityWarning, Active: true})
|
||||||
|
|
||||||
|
results := []AssertionResult{
|
||||||
|
{ID: "ar1", AssertionID: "a_warn", Status: ResultFail, EvaluatedAt: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, err := React(db, nil, results)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("react: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning should NOT change corrupted entity to stale
|
||||||
|
if len(rr.EntityUpdates) != 0 {
|
||||||
|
t.Errorf("expected no entity updates, got %d", len(rr.EntityUpdates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReactInfoNoStatusChange(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{
|
||||||
|
ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test",
|
||||||
|
})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a_info", EntityID: "e1", Name: "info", Kind: "range", Rule: "x > 0", Severity: SeverityInfo, Active: true})
|
||||||
|
|
||||||
|
results := []AssertionResult{
|
||||||
|
{ID: "ar1", AssertionID: "a_info", Status: ResultFail, EvaluatedAt: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, err := React(db, nil, results)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("react: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rr.EntityUpdates) != 0 {
|
||||||
|
t.Errorf("info fail should not change status, got %d updates", len(rr.EntityUpdates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateExecution(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
e Execution
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", Execution{ID: "e1", PipelineID: "p1", Status: ExecSuccess, StartedAt: time.Now()}, false},
|
||||||
|
{"missing id", Execution{PipelineID: "p1", Status: ExecSuccess, StartedAt: time.Now()}, true},
|
||||||
|
{"missing pipeline", Execution{ID: "e1", Status: ExecSuccess, StartedAt: time.Now()}, true},
|
||||||
|
{"missing status", Execution{ID: "e1", PipelineID: "p1", StartedAt: time.Now()}, true},
|
||||||
|
{"invalid status", Execution{ID: "e1", PipelineID: "p1", Status: "invalid", StartedAt: time.Now()}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateExecution(&tt.e)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAssertion(t *testing.T) {
|
||||||
|
known := map[string]bool{"e1": true}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a Assertion
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", Assertion{ID: "a1", EntityID: "e1", Name: "test", Kind: "range", Rule: "x > 0", Severity: SeverityWarning}, false},
|
||||||
|
{"missing entity", Assertion{ID: "a1", EntityID: "unknown", Name: "test", Kind: "range", Rule: "x > 0", Severity: SeverityWarning}, true},
|
||||||
|
{"missing rule", Assertion{ID: "a1", EntityID: "e1", Name: "test", Kind: "range", Severity: SeverityWarning}, true},
|
||||||
|
{"missing kind", Assertion{ID: "a1", EntityID: "e1", Name: "test", Rule: "x > 0", Severity: SeverityWarning}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateAssertion(&tt.a, known)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrations(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
if count < 2 {
|
||||||
|
t.Errorf("expected at least 2 migrations, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all new tables exist
|
||||||
|
for _, table := range []string{"executions", "assertions", "assertion_results"} {
|
||||||
|
_, err := db.conn.Exec("SELECT 1 FROM " + table + " LIMIT 1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("table %s should exist: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordExecutionWithResults(t *testing.T) {
|
||||||
|
db := tempDB(t)
|
||||||
|
|
||||||
|
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||||
|
db.InsertEntity(&Entity{ID: "e1", Name: "e1", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||||
|
db.InsertAssertion(&Assertion{ID: "a1", EntityID: "e1", Name: "test", Kind: "range", Rule: "x > 0", Severity: SeverityWarning, Active: true})
|
||||||
|
|
||||||
|
started := time.Now().UTC()
|
||||||
|
ended := started.Add(5 * time.Second)
|
||||||
|
e := &Execution{
|
||||||
|
ID: "exec_tx", PipelineID: "p1", Status: ExecSuccess,
|
||||||
|
StartedAt: started, EndedAt: &ended,
|
||||||
|
}
|
||||||
|
results := []AssertionResult{
|
||||||
|
{ID: "ar_tx_1", AssertionID: "a1", ExecutionID: "exec_tx", Status: ResultPass, EvaluatedAt: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RecordExecutionWithResults(db, e, results); err != nil {
|
||||||
|
t.Fatalf("record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both were persisted
|
||||||
|
gotExec, _ := db.GetExecution("exec_tx")
|
||||||
|
if gotExec == nil {
|
||||||
|
t.Fatal("execution not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotResult, _ := db.GetAssertionResult("ar_tx_1")
|
||||||
|
if gotResult == nil {
|
||||||
|
t.Fatal("assertion result not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetEntityGraph(t *testing.T) {
|
func TestGetEntityGraph(t *testing.T) {
|
||||||
db := tempDB(t)
|
db := tempDB(t)
|
||||||
|
|
||||||
|
|||||||
@@ -471,3 +471,346 @@ func (db *DB) DeleteRelationInputs(relationID string) error {
|
|||||||
_, err := db.conn.Exec("DELETE FROM relation_inputs WHERE relation_id = ?", relationID)
|
_, err := db.conn.Exec("DELETE FROM relation_inputs WHERE relation_id = ?", relationID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,100 @@ func ValidateRelationInputs(inputs []RelationInput, knownEntities map[string]boo
|
|||||||
return nil
|
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.
|
// DetectCycle checks if adding a causal relation (from -> to) creates a cycle.
|
||||||
// Only considers relations where via != "" (causal/transformational).
|
// Only considers relations where via != "" (causal/transformational).
|
||||||
// Semantic relations (via == "") are exempt from cycle detection.
|
// Semantic relations (via == "") are exempt from cycle detection.
|
||||||
|
|||||||
Reference in New Issue
Block a user