9ba1f86c34
Añade Execution, Assertion, AssertionResult al paquete fn_operations. Motor de evaluación de assertions con reescritura SQL automática. Bucle reactivo: ExecuteAndReact evalúa assertions y cambia status de entities (corrupted/stale) + auto-crea proposals en registry. CLI fn ops: assertion (add/list/show/delete/eval) y execution (add/list/show). Migración 002_executions_assertions.sql con FTS para assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1436 lines
32 KiB
Go
1436 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
ops "fn-registry/fn_operations"
|
|
"fn-registry/registry"
|
|
)
|
|
|
|
const opsDBName = "operations.db"
|
|
|
|
func cmdOps(args []string) {
|
|
if len(args) < 1 {
|
|
printOpsUsage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch args[0] {
|
|
case "init":
|
|
cmdOpsInit(args[1:])
|
|
case "entity":
|
|
cmdOpsEntity(args[1:])
|
|
case "relation":
|
|
cmdOpsRelation(args[1:])
|
|
case "graph":
|
|
cmdOpsGraph()
|
|
case "snapshot":
|
|
cmdOpsSnapshot(args[1:])
|
|
case "execution":
|
|
cmdOpsExecution(args[1:])
|
|
case "assertion":
|
|
cmdOpsAssertion(args[1:])
|
|
case "help", "-h", "--help":
|
|
printOpsUsage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown ops command: %s\n", args[0])
|
|
printOpsUsage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printOpsUsage() {
|
|
fmt.Println(`fn ops — operations CLI
|
|
|
|
Usage:
|
|
fn ops init [path] Crea operations.db en path (default: .)
|
|
fn ops entity add <flags> Añade entity
|
|
fn ops entity list [-d domain] [-s status] Lista entities
|
|
fn ops entity show <id> Muestra entity
|
|
fn ops entity delete <id> Elimina entity
|
|
fn ops relation add <flags> Añade relation
|
|
fn ops relation list [entity_id] Lista relations
|
|
fn ops relation show <id> Muestra relation
|
|
fn ops relation delete <id> Elimina relation
|
|
fn ops graph Grafo ASCII de entities y relations
|
|
fn ops snapshot list Lista tipos snapshotted
|
|
fn ops snapshot check Compara snapshots vs registry
|
|
fn ops snapshot update <id>|--all Re-snapshot desde registry
|
|
|
|
Entity flags:
|
|
--id <id> --name <name> --type-ref <type_id> --source <source>
|
|
--domain <domain> --status <status> --description <desc>
|
|
--tags <t1,t2> --metadata <json> --notes <text>
|
|
|
|
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>
|
|
|
|
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 ---
|
|
|
|
func cmdOpsInit(args []string) {
|
|
dir := "."
|
|
if len(args) > 0 {
|
|
dir = args[0]
|
|
}
|
|
|
|
path := filepath.Join(dir, opsDBName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
fmt.Fprintf(os.Stderr, "operations.db already exists at %s\n", path)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Copy from template if available, otherwise create fresh
|
|
templatePath := filepath.Join(root(), "fn_operations", "project_template", "operations.db")
|
|
if _, err := os.Stat(templatePath); err == nil {
|
|
src, err := os.Open(templatePath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error opening template: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer src.Close()
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error creating directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
dst, err := os.Create(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error creating db: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer dst.Close()
|
|
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error copying template: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("operations.db created at %s (from template)\n", path)
|
|
return
|
|
}
|
|
|
|
// Create fresh
|
|
db, err := ops.Open(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
db.Close()
|
|
fmt.Printf("operations.db created at %s\n", path)
|
|
}
|
|
|
|
// --- ops entity ---
|
|
|
|
func cmdOpsEntity(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops entity <add|list|show|delete> ...")
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
cmdOpsEntityAdd(args[1:])
|
|
case "list":
|
|
cmdOpsEntityList(args[1:])
|
|
case "show":
|
|
cmdOpsEntityShow(args[1:])
|
|
case "delete":
|
|
cmdOpsEntityDelete(args[1:])
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown entity command: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func cmdOpsEntityAdd(args []string) {
|
|
var e ops.Entity
|
|
e.Status = ops.StatusActive
|
|
var tagsStr, metadataStr string
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--id":
|
|
i++; e.ID = args[i]
|
|
case "--name":
|
|
i++; e.Name = args[i]
|
|
case "--type-ref":
|
|
i++; e.TypeRef = args[i]
|
|
case "--source":
|
|
i++; e.Source = args[i]
|
|
case "--domain":
|
|
i++; e.Domain = args[i]
|
|
case "--status":
|
|
i++; e.Status = ops.EntityStatus(args[i])
|
|
case "--description":
|
|
i++; e.Description = args[i]
|
|
case "--tags":
|
|
i++; tagsStr = args[i]
|
|
case "--metadata":
|
|
i++; metadataStr = args[i]
|
|
case "--notes":
|
|
i++; e.Notes = args[i]
|
|
}
|
|
}
|
|
|
|
if e.Name == "" || e.TypeRef == "" || e.Source == "" {
|
|
fmt.Fprintln(os.Stderr, "required: --name, --type-ref, --source")
|
|
os.Exit(1)
|
|
}
|
|
if e.ID == "" {
|
|
e.ID = e.Name
|
|
}
|
|
|
|
if tagsStr != "" {
|
|
e.Tags = strings.Split(tagsStr, ",")
|
|
}
|
|
if metadataStr != "" {
|
|
json.Unmarshal([]byte(metadataStr), &e.Metadata)
|
|
}
|
|
|
|
opsDB := openOpsDB()
|
|
defer opsDB.Close()
|
|
|
|
// Try to open registry for type snapshot
|
|
regDB := tryOpenRegistryDB()
|
|
if regDB != nil {
|
|
defer regDB.Close()
|
|
}
|
|
|
|
if err := ops.InsertEntityWithSnapshot(opsDB, regDB, &e); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Entity %s added\n", e.ID)
|
|
}
|
|
|
|
func cmdOpsEntityList(args []string) {
|
|
var domain string
|
|
var status ops.EntityStatus
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-d":
|
|
i++; domain = args[i]
|
|
case "-s":
|
|
i++; status = ops.EntityStatus(args[i])
|
|
}
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
entities, err := db.ListEntities(domain, status)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(entities) == 0 {
|
|
fmt.Println("No entities.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tTYPE_REF\tSTATUS\tSOURCE\tDOMAIN")
|
|
for _, e := range entities {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.ID, e.TypeRef, e.Status, e.Source, e.Domain)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func cmdOpsEntityShow(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops entity show <id>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
e, err := db.GetEntity(args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if e == nil {
|
|
fmt.Fprintf(os.Stderr, "entity not found: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("ID: %s\n", e.ID)
|
|
fmt.Printf("Name: %s\n", e.Name)
|
|
fmt.Printf("Type ref: %s\n", e.TypeRef)
|
|
fmt.Printf("Status: %s\n", e.Status)
|
|
fmt.Printf("Source: %s\n", e.Source)
|
|
fmt.Printf("Domain: %s\n", e.Domain)
|
|
fmt.Printf("Description: %s\n", e.Description)
|
|
fmt.Printf("Tags: %s\n", strings.Join(e.Tags, ", "))
|
|
if len(e.Metadata) > 0 {
|
|
meta, _ := json.MarshalIndent(e.Metadata, " ", " ")
|
|
fmt.Printf("Metadata: %s\n", meta)
|
|
}
|
|
if e.Notes != "" {
|
|
fmt.Printf("Notes: %s\n", e.Notes)
|
|
}
|
|
fmt.Printf("Created: %s\n", e.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
fmt.Printf("Updated: %s\n", e.UpdatedAt.Format("2006-01-02 15:04:05"))
|
|
}
|
|
|
|
func cmdOpsEntityDelete(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops entity delete <id>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
if err := db.DeleteEntity(args[0]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Entity %s deleted\n", args[0])
|
|
}
|
|
|
|
// --- ops relation ---
|
|
|
|
func cmdOpsRelation(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops relation <add|list|show|delete> ...")
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
cmdOpsRelationAdd(args[1:])
|
|
case "list":
|
|
cmdOpsRelationList(args[1:])
|
|
case "show":
|
|
cmdOpsRelationShow(args[1:])
|
|
case "delete":
|
|
cmdOpsRelationDelete(args[1:])
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown relation command: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func cmdOpsRelationAdd(args []string) {
|
|
var r ops.Relation
|
|
r.Direction = ops.DirUnidirectional
|
|
r.Status = ops.RelDesigned
|
|
var tagsStr string
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--id":
|
|
i++; r.ID = args[i]
|
|
case "--name":
|
|
i++; r.Name = args[i]
|
|
case "--from":
|
|
i++; r.FromEntity = args[i]
|
|
case "--to":
|
|
i++; r.ToEntity = args[i]
|
|
case "--via":
|
|
i++; r.Via = args[i]
|
|
case "--direction":
|
|
i++; r.Direction = ops.Direction(args[i])
|
|
case "--status":
|
|
i++; r.Status = ops.RelationStatus(args[i])
|
|
case "--purity":
|
|
i++; r.Purity = args[i]
|
|
case "--weight":
|
|
i++
|
|
var w float64
|
|
fmt.Sscanf(args[i], "%f", &w)
|
|
r.Weight = &w
|
|
case "--description":
|
|
i++; r.Description = args[i]
|
|
case "--tags":
|
|
i++; tagsStr = args[i]
|
|
case "--notes":
|
|
i++; r.Notes = args[i]
|
|
}
|
|
}
|
|
|
|
if r.Name == "" || r.ToEntity == "" {
|
|
fmt.Fprintln(os.Stderr, "required: --name, --to (and --from for simple relations)")
|
|
os.Exit(1)
|
|
}
|
|
if r.ID == "" && r.FromEntity != "" {
|
|
via := "semantic"
|
|
if r.Via != "" {
|
|
via = r.Via
|
|
}
|
|
r.ID = fmt.Sprintf("%s__to__%s__via__%s", r.FromEntity, r.ToEntity, via)
|
|
}
|
|
if tagsStr != "" {
|
|
r.Tags = strings.Split(tagsStr, ",")
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
if err := ops.InsertRelationSafe(db, &r); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Relation %s added\n", r.ID)
|
|
}
|
|
|
|
func cmdOpsRelationList(args []string) {
|
|
var entityID string
|
|
if len(args) > 0 {
|
|
entityID = args[0]
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
rels, err := db.ListRelations(entityID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(rels) == 0 {
|
|
fmt.Println("No relations.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tFROM\tTO\tVIA\tDIRECTION\tSTATUS")
|
|
for _, r := range rels {
|
|
from := r.FromEntity
|
|
if from == "" {
|
|
from = "(inputs)"
|
|
}
|
|
via := r.Via
|
|
if via == "" {
|
|
via = "-"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
r.ID, r.Name, from, r.ToEntity, via, r.Direction, r.Status)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func cmdOpsRelationShow(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops relation show <id>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
r, err := db.GetRelation(args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if r == nil {
|
|
fmt.Fprintf(os.Stderr, "relation not found: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("ID: %s\n", r.ID)
|
|
fmt.Printf("Name: %s\n", r.Name)
|
|
fmt.Printf("From: %s\n", r.FromEntity)
|
|
fmt.Printf("To: %s\n", r.ToEntity)
|
|
fmt.Printf("Via: %s\n", r.Via)
|
|
fmt.Printf("Description: %s\n", r.Description)
|
|
fmt.Printf("Purity: %s\n", r.Purity)
|
|
fmt.Printf("Direction: %s\n", r.Direction)
|
|
if r.Weight != nil {
|
|
fmt.Printf("Weight: %.2f\n", *r.Weight)
|
|
}
|
|
fmt.Printf("Status: %s\n", r.Status)
|
|
fmt.Printf("Tags: %s\n", strings.Join(r.Tags, ", "))
|
|
if r.Notes != "" {
|
|
fmt.Printf("Notes: %s\n", r.Notes)
|
|
}
|
|
|
|
// Show inputs if any
|
|
inputs, _ := db.GetRelationInputs(r.ID)
|
|
if len(inputs) > 0 {
|
|
fmt.Println("\nInputs:")
|
|
for _, ri := range inputs {
|
|
ord := ""
|
|
if ri.Order != nil {
|
|
ord = fmt.Sprintf(" (order: %d)", *ri.Order)
|
|
}
|
|
fmt.Printf(" %s [%s]%s\n", ri.EntityID, ri.Role, ord)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cmdOpsRelationDelete(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops relation delete <id>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
if err := db.DeleteRelation(args[0]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Relation %s deleted\n", args[0])
|
|
}
|
|
|
|
// --- ops graph ---
|
|
|
|
func cmdOpsGraph() {
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
g, err := ops.GetEntityGraph(db)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(g.Entities) == 0 {
|
|
fmt.Println("Empty graph.")
|
|
return
|
|
}
|
|
|
|
fmt.Println("Entities:")
|
|
for _, e := range g.Entities {
|
|
fmt.Printf(" [%s] (%s) status:%s source:%s\n", e.ID, e.TypeRef, e.Status, e.Source)
|
|
}
|
|
|
|
if len(g.Relations) > 0 {
|
|
fmt.Println("\nRelations:")
|
|
for _, r := range g.Relations {
|
|
via := ""
|
|
if r.Via != "" {
|
|
via = fmt.Sprintf(" via:%s", r.Via)
|
|
}
|
|
|
|
inputs, hasInputs := g.Inputs[r.ID]
|
|
if hasInputs {
|
|
sources := make([]string, len(inputs))
|
|
for i, ri := range inputs {
|
|
sources[i] = fmt.Sprintf("%s[%s]", ri.EntityID, ri.Role)
|
|
}
|
|
fmt.Printf(" (%s) %s → %s%s\n",
|
|
strings.Join(sources, " + "), r.Name, r.ToEntity, via)
|
|
} else {
|
|
from := r.FromEntity
|
|
if from == "" {
|
|
from = "?"
|
|
}
|
|
dir := "→"
|
|
if r.Direction == ops.DirBidirectional {
|
|
dir = "↔"
|
|
}
|
|
fmt.Printf(" %s %s %s %s%s\n", from, dir, r.Name, r.ToEntity, via)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- ops snapshot ---
|
|
|
|
func cmdOpsSnapshot(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops snapshot <list|check|update> [id|--all]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch args[0] {
|
|
case "list":
|
|
cmdOpsSnapshotList()
|
|
case "check":
|
|
cmdOpsSnapshotCheck()
|
|
case "update":
|
|
cmdOpsSnapshotUpdate(args[1:])
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown snapshot command: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func cmdOpsSnapshotList() {
|
|
db := openOpsDB()
|
|
defer db.Close()
|
|
|
|
snaps, err := db.ListTypeSnapshots()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(snaps) == 0 {
|
|
fmt.Println("No type snapshots.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tVERSION\tLANG\tALGEBRAIC\tSNAPPED_AT")
|
|
for _, s := range snaps {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
|
s.ID, s.Version, s.Lang, s.Algebraic, s.SnappedAt.Format("2006-01-02 15:04"))
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func cmdOpsSnapshotCheck() {
|
|
opsDB := openOpsDB()
|
|
defer opsDB.Close()
|
|
|
|
regDB := requireRegistryDB()
|
|
defer regDB.Close()
|
|
|
|
results, err := ops.CheckSnapshots(opsDB, regDB)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
fmt.Println("No type snapshots to check.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tLOCAL\tREGISTRY\tSTATUS")
|
|
for _, r := range results {
|
|
regVer := r.RegistryVersion
|
|
if regVer == "" {
|
|
regVer = "-"
|
|
}
|
|
status := "✓"
|
|
switch r.Status {
|
|
case ops.SnapshotOutdated:
|
|
status = "← OUTDATED"
|
|
case ops.SnapshotMissing:
|
|
status = "⚠ not in registry"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.ID, r.LocalVersion, regVer, status)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func cmdOpsSnapshotUpdate(args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops snapshot update <type_id> | --all")
|
|
os.Exit(1)
|
|
}
|
|
|
|
opsDB := openOpsDB()
|
|
defer opsDB.Close()
|
|
|
|
regDB := requireRegistryDB()
|
|
defer regDB.Close()
|
|
|
|
if args[0] == "--all" {
|
|
// Update all outdated
|
|
results, err := ops.CheckSnapshots(opsDB, regDB)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
updated := 0
|
|
for _, r := range results {
|
|
if r.Status != ops.SnapshotOutdated {
|
|
continue
|
|
}
|
|
old, new_, err := ops.UpdateSnapshot(opsDB, regDB, r.ID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " error updating %s: %v\n", r.ID, err)
|
|
continue
|
|
}
|
|
printSnapshotDiff(r.ID, old, new_)
|
|
updated++
|
|
}
|
|
if updated == 0 {
|
|
fmt.Println("All snapshots are up to date.")
|
|
} else {
|
|
fmt.Printf("\n%d snapshot(s) updated.\n", updated)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Update single
|
|
typeID := args[0]
|
|
old, new_, err := ops.UpdateSnapshot(opsDB, regDB, typeID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
printSnapshotDiff(typeID, old, new_)
|
|
}
|
|
|
|
func printSnapshotDiff(id string, old, new_ *ops.TypeSnapshot) {
|
|
fmt.Printf("Updated %s: %s → %s\n", id, old.Version, new_.Version)
|
|
if old.Definition != new_.Definition {
|
|
fmt.Println(" Definition changed:")
|
|
fmt.Printf(" - %s\n", strings.ReplaceAll(strings.TrimSpace(old.Definition), "\n", "\n - "))
|
|
fmt.Printf(" + %s\n", strings.ReplaceAll(strings.TrimSpace(new_.Definition), "\n", "\n + "))
|
|
}
|
|
if old.Description != new_.Description {
|
|
fmt.Printf(" Description: %q → %q\n", old.Description, new_.Description)
|
|
}
|
|
}
|
|
|
|
func requireRegistryDB() *registry.DB {
|
|
db := tryOpenRegistryDB()
|
|
if db == nil {
|
|
fmt.Fprintln(os.Stderr, "error: cannot find registry.db")
|
|
fmt.Fprintln(os.Stderr, "Set FN_REGISTRY_ROOT or run from the registry directory.")
|
|
os.Exit(1)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func findOpsDB() string {
|
|
dir, _ := os.Getwd()
|
|
for {
|
|
path := filepath.Join(dir, opsDBName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
return filepath.Join(".", opsDBName)
|
|
}
|
|
|
|
func openOpsDB() *ops.DB {
|
|
path := findOpsDB()
|
|
db, err := ops.Open(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error opening operations.db: %v\n", err)
|
|
fmt.Fprintln(os.Stderr, "Run 'fn ops init' first to create one.")
|
|
os.Exit(1)
|
|
}
|
|
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 != "" {
|
|
path := filepath.Join(envRoot, dbName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
db, err := registry.Open(path)
|
|
if err == nil {
|
|
return db
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try root() (finds go.mod walking up from cwd)
|
|
path := filepath.Join(root(), dbName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
db, err := registry.Open(path)
|
|
if err == nil {
|
|
return db
|
|
}
|
|
}
|
|
|
|
// Try executable's directory
|
|
if exe, err := os.Executable(); err == nil {
|
|
exeDir := filepath.Dir(exe)
|
|
path := filepath.Join(exeDir, dbName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
db, err := registry.Open(path)
|
|
if err == nil {
|
|
return db
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|