5b83b3f128
Subcomando ops integrado en el CLI fn. Soporta CRUD de entities con snapshot automatico de tipos, CRUD de relations con validacion de ciclos, dump ASCII del grafo, y listado de tipos snapshotted. Variable de entorno FN_REGISTRY_ROOT para acceder al registry desde cualquier directorio.
635 lines
14 KiB
Go
635 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
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 "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
|
|
|
|
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>`)
|
|
}
|
|
|
|
// --- 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 || args[0] != "list" {
|
|
fmt.Fprintln(os.Stderr, "usage: fn ops snapshot list")
|
|
os.Exit(1)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
|
|
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
|
|
}
|