From 67401cb967d42f5e78a397e28c551ae70c1163ec Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 04:37:50 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20fn=5Foperations=20library=20?= =?UTF-8?q?=E2=80=94=20entities,=20relations,=20types=5Fsnapshot=20con=20F?= =?UTF-8?q?TS=20y=20ciclos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paquete Go completo con modelos (Entity, Relation, RelationInput, TypeSnapshot), DB SQLite con WAL + FTS5 en entities, CRUD para las 4 tablas, validacion de integridad, deteccion de ciclos solo en relaciones causales (via != ''), y operaciones de alto nivel con snapshot automatico de tipos del registry. 9 tests, todos pasan. --- fn_operations/db.go | 140 ++++++++++ fn_operations/models.go | 90 ++++++ fn_operations/operations.go | 202 ++++++++++++++ fn_operations/operations_test.go | 375 +++++++++++++++++++++++++ fn_operations/store.go | 459 +++++++++++++++++++++++++++++++ fn_operations/validate.go | 181 ++++++++++++ 6 files changed, 1447 insertions(+) create mode 100644 fn_operations/db.go create mode 100644 fn_operations/models.go create mode 100644 fn_operations/operations.go create mode 100644 fn_operations/operations_test.go create mode 100644 fn_operations/store.go create mode 100644 fn_operations/validate.go diff --git a/fn_operations/db.go b/fn_operations/db.go new file mode 100644 index 00000000..0ce57f2a --- /dev/null +++ b/fn_operations/db.go @@ -0,0 +1,140 @@ +package fn_operations + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "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. +type DB struct { + conn *sql.DB + path string +} + +// Open opens or creates an operations database at the given path. +func Open(path string) (*DB, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating db directory: %w", err) + } + + conn, err := sql.Open("sqlite3", path+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil { + conn.Close() + return nil, fmt.Errorf("setting WAL mode: %w", err) + } + + if _, err := conn.Exec(schemaSQL); err != nil { + conn.Close() + return nil, fmt.Errorf("applying schema: %w", err) + } + + return &DB{conn: conn, path: path}, nil +} + +// Conn returns the underlying sql.DB for transaction use. +func (db *DB) Conn() *sql.DB { + return db.conn +} + +// Close closes the database connection. +func (db *DB) Close() error { + return db.conn.Close() +} + +// Drop removes the database file. +func (db *DB) Drop() error { + db.Close() + return os.Remove(db.path) +} diff --git a/fn_operations/models.go b/fn_operations/models.go new file mode 100644 index 00000000..2eefeab3 --- /dev/null +++ b/fn_operations/models.go @@ -0,0 +1,90 @@ +package fn_operations + +import "time" + +// EntityStatus represents the lifecycle state of an entity. +type EntityStatus string + +const ( + StatusActive EntityStatus = "active" + StatusStale EntityStatus = "stale" + StatusCorrupted EntityStatus = "corrupted" + StatusArchived EntityStatus = "archived" +) + +// RelationStatus represents the lifecycle state of a relation. +type RelationStatus string + +const ( + RelDesigned RelationStatus = "designed" + RelImplemented RelationStatus = "implemented" + RelTested RelationStatus = "tested" + RelRunning RelationStatus = "running" + RelDeprecated RelationStatus = "deprecated" +) + +// Direction represents the directionality of a relation. +type Direction string + +const ( + DirUnidirectional Direction = "unidirectional" + DirBidirectional Direction = "bidirectional" + DirInverse Direction = "inverse" +) + +// Entity is a concrete instance of a registry type within a project context. +type Entity struct { + ID string `json:"id"` + Name string `json:"name"` + TypeRef string `json:"type_ref"` + Status EntityStatus `json:"status"` + Description string `json:"description"` + Domain string `json:"domain"` + Tags []string `json:"tags"` + Source string `json:"source"` + Metadata map[string]any `json:"metadata"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Relation describes how one entity connects to or transforms into another. +type Relation struct { + ID string `json:"id"` + Name string `json:"name"` + FromEntity string `json:"from_entity"` + ToEntity string `json:"to_entity"` + Via string `json:"via"` + Description string `json:"description"` + Purity string `json:"purity"` + Direction Direction `json:"direction"` + Weight *float64 `json:"weight"` + Status RelationStatus `json:"status"` + StartedAt *time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` + Order *int `json:"order"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RelationInput represents one input entity in a multi-input relation. +type RelationInput struct { + ID string `json:"id"` + RelationID string `json:"relation_id"` + EntityID string `json:"entity_id"` + Role string `json:"role"` + Order *int `json:"order"` +} + +// TypeSnapshot is an immutable copy of a registry type at point of use. +type TypeSnapshot struct { + ID string `json:"id"` + Version string `json:"version"` + Lang string `json:"lang"` + Algebraic string `json:"algebraic"` + Definition string `json:"definition"` + Description string `json:"description"` + SnappedAt time.Time `json:"snapped_at"` +} diff --git a/fn_operations/operations.go b/fn_operations/operations.go new file mode 100644 index 00000000..d52fc9a5 --- /dev/null +++ b/fn_operations/operations.go @@ -0,0 +1,202 @@ +package fn_operations + +import ( + "fmt" + "time" + + "fn-registry/registry" +) + +// InsertEntityWithSnapshot inserts an entity, snapshotting its type from the registry if needed. +// registryDB can be nil if the type is already snapshotted. +func InsertEntityWithSnapshot(opsDB *DB, registryDB *registry.DB, e *Entity) error { + if err := ValidateEntity(e); err != nil { + return err + } + + // Check if type is already snapshotted + snap, err := opsDB.GetTypeSnapshot(e.TypeRef) + if err != nil { + return fmt.Errorf("checking type snapshot: %w", err) + } + + if snap == nil { + // Need to fetch from registry + if registryDB == nil { + return fmt.Errorf("type %q not found in local snapshots and no registry provided", e.TypeRef) + } + if err := SnapshotType(opsDB, registryDB, e.TypeRef); err != nil { + return err + } + } + + return opsDB.InsertEntity(e) +} + +// SnapshotType fetches a type from the registry and copies it to types_snapshot. +func SnapshotType(opsDB *DB, registryDB *registry.DB, typeID string) error { + t, err := registryDB.GetType(typeID) + if err != nil { + return fmt.Errorf("fetching type %q from registry: %w", typeID, err) + } + + snap := &TypeSnapshot{ + ID: t.ID, + Version: t.Version, + Lang: t.Lang, + Algebraic: string(t.Algebraic), + Definition: t.Definition, + Description: t.Description, + SnappedAt: time.Now().UTC(), + } + + return opsDB.InsertTypeSnapshot(snap) +} + +// InsertRelationSafe validates, checks for cycles, and inserts a relation. +func InsertRelationSafe(db *DB, r *Relation) error { + entities, err := buildEntitySet(db) + if err != nil { + return err + } + + if err := ValidateRelation(r, entities); err != nil { + return err + } + + // from_entity is required when not using relation_inputs + if r.FromEntity == "" { + return fmt.Errorf("from_entity is required (use InsertRelationWithInputs for multi-input relations)") + } + + // Cycle detection only for causal relations + if r.Via != "" { + if err := DetectCycle(db, r.FromEntity, r.ToEntity); err != nil { + return err + } + } + + return db.InsertRelation(r) +} + +// InsertRelationWithInputs validates and inserts a relation with multiple inputs in a transaction. +func InsertRelationWithInputs(db *DB, r *Relation, inputs []RelationInput) error { + entities, err := buildEntitySet(db) + if err != nil { + return err + } + + if err := ValidateRelation(r, entities); err != nil { + return err + } + if err := ValidateRelationInputs(inputs, entities); err != nil { + return err + } + + // Cycle detection for each input if causal + if r.Via != "" { + for _, input := range inputs { + if err := DetectCycle(db, input.EntityID, r.ToEntity); err != nil { + return err + } + } + } + + tx, err := db.Conn().Begin() + if err != nil { + return fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback() + + // Insert relation + now := time.Now().UTC() + if r.CreatedAt.IsZero() { + r.CreatedAt = now + } + r.UpdatedAt = now + + var startedAt, endedAt *string + if r.StartedAt != nil { + s := r.StartedAt.Format(time.RFC3339) + startedAt = &s + } + if r.EndedAt != nil { + s := r.EndedAt.Format(time.RFC3339) + endedAt = &s + } + + _, err = tx.Exec(` + INSERT OR REPLACE INTO relations (id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description, + r.Purity, string(r.Direction), r.Weight, string(r.Status), + startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes, + r.CreatedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("inserting relation: %w", err) + } + + // Insert inputs + for _, ri := range inputs { + _, err = tx.Exec(` + INSERT INTO relation_inputs (id, relation_id, entity_id, role, "order") + VALUES (?, ?, ?, ?, ?)`, + ri.ID, ri.RelationID, ri.EntityID, ri.Role, ri.Order, + ) + if err != nil { + return fmt.Errorf("inserting relation_input: %w", err) + } + } + + return tx.Commit() +} + +// Graph holds the full entity-relation graph for a project. +type Graph struct { + Entities []Entity + Relations []Relation + Inputs map[string][]RelationInput +} + +// GetEntityGraph returns all entities and relations for visualization. +func GetEntityGraph(db *DB) (*Graph, error) { + entities, err := db.ListEntities("", "") + if err != nil { + return nil, fmt.Errorf("listing entities: %w", err) + } + + relations, err := db.ListRelations("") + if err != nil { + return nil, fmt.Errorf("listing relations: %w", err) + } + + inputs := map[string][]RelationInput{} + for _, r := range relations { + ri, err := db.GetRelationInputs(r.ID) + if err != nil { + return nil, fmt.Errorf("getting inputs for relation %s: %w", r.ID, err) + } + if len(ri) > 0 { + inputs[r.ID] = ri + } + } + + return &Graph{ + Entities: entities, + Relations: relations, + Inputs: inputs, + }, nil +} + +func buildEntitySet(db *DB) (map[string]bool, error) { + all, err := db.ListEntities("", "") + if err != nil { + return nil, fmt.Errorf("building entity set: %w", err) + } + set := make(map[string]bool, len(all)) + for _, e := range all { + set[e.ID] = true + } + return set, nil +} diff --git a/fn_operations/operations_test.go b/fn_operations/operations_test.go new file mode 100644 index 00000000..23b81ec7 --- /dev/null +++ b/fn_operations/operations_test.go @@ -0,0 +1,375 @@ +package fn_operations + +import ( + "os" + "path/filepath" + "testing" +) + +func tempDB(t *testing.T) *DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + db, err := Open(path) + if err != nil { + t.Fatalf("opening test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestOpenAndClose(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + db, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("db file should exist") + } +} + +func TestTypeSnapshotCRUD(t *testing.T) { + db := tempDB(t) + + ts := &TypeSnapshot{ + ID: "ohlcv_go_finance", + Version: "1.0.0", + Lang: "go", + Algebraic: "product", + Definition: "type OHLCV struct { ... }", + Description: "Vela de mercado", + } + + if err := db.InsertTypeSnapshot(ts); err != nil { + t.Fatalf("insert: %v", err) + } + + got, err := db.GetTypeSnapshot("ohlcv_go_finance") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected snapshot, got nil") + } + if got.Definition != ts.Definition { + t.Errorf("definition = %q, want %q", got.Definition, ts.Definition) + } + + // INSERT OR IGNORE: second insert should not overwrite + ts2 := &TypeSnapshot{ + ID: "ohlcv_go_finance", + Version: "2.0.0", + Lang: "go", + Algebraic: "product", + Definition: "type OHLCV struct { CHANGED }", + Description: "Changed", + } + if err := db.InsertTypeSnapshot(ts2); err != nil { + t.Fatalf("insert duplicate: %v", err) + } + got2, _ := db.GetTypeSnapshot("ohlcv_go_finance") + if got2.Version != "1.0.0" { + t.Errorf("snapshot should be immutable, got version %q", got2.Version) + } + + // Not found + missing, err := db.GetTypeSnapshot("nonexistent") + if err != nil { + t.Fatalf("get missing: %v", err) + } + if missing != nil { + t.Error("expected nil for missing snapshot") + } + + all, err := db.ListTypeSnapshots() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(all) != 1 { + t.Errorf("expected 1 snapshot, got %d", len(all)) + } +} + +func TestEntityCRUD(t *testing.T) { + db := tempDB(t) + + // Insert snapshot first (type_ref) + db.InsertTypeSnapshot(&TypeSnapshot{ + ID: "tick_go_finance", Version: "1.0.0", Lang: "go", Algebraic: "product", + }) + + e := &Entity{ + ID: "ticks_btcusdt_2024", + Name: "ticks_btcusdt_2024", + TypeRef: "tick_go_finance", + Status: StatusActive, + Source: "binance_api", + Domain: "market_data", + Tags: []string{"btc", "binance"}, + Metadata: map[string]any{ + "pair": "BTCUSDT", + "exchange": "binance", + }, + } + + if err := db.InsertEntity(e); err != nil { + t.Fatalf("insert: %v", err) + } + + got, err := db.GetEntity("ticks_btcusdt_2024") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected entity, got nil") + } + if got.Source != "binance_api" { + t.Errorf("source = %q, want binance_api", got.Source) + } + if len(got.Tags) != 2 { + t.Errorf("tags len = %d, want 2", len(got.Tags)) + } + if got.Metadata["pair"] != "BTCUSDT" { + t.Errorf("metadata pair = %v, want BTCUSDT", got.Metadata["pair"]) + } + + // Update + got.Status = StatusStale + if err := db.UpdateEntity(got); err != nil { + t.Fatalf("update: %v", err) + } + updated, _ := db.GetEntity("ticks_btcusdt_2024") + if updated.Status != StatusStale { + t.Errorf("status = %q, want stale", updated.Status) + } + + // List + all, err := db.ListEntities("market_data", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(all) != 1 { + t.Errorf("expected 1, got %d", len(all)) + } + + // Search + found, err := db.SearchEntities("btcusdt", "") + if err != nil { + t.Fatalf("search: %v", err) + } + if len(found) != 1 { + t.Errorf("search expected 1, got %d", len(found)) + } + + // Delete + if err := db.DeleteEntity("ticks_btcusdt_2024"); err != nil { + t.Fatalf("delete: %v", err) + } + deleted, _ := db.GetEntity("ticks_btcusdt_2024") + if deleted != nil { + t.Error("expected nil after delete") + } +} + +func TestRelationCRUD(t *testing.T) { + db := tempDB(t) + + // Setup entities + db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"}) + db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"}) + + r := &Relation{ + ID: "a__to__b__via__transform", + Name: "TRANSFORMA", + FromEntity: "a", + ToEntity: "b", + Direction: DirUnidirectional, + Status: RelDesigned, + } + + if err := InsertRelationSafe(db, r); err != nil { + t.Fatalf("insert relation: %v", err) + } + + got, err := db.GetRelation("a__to__b__via__transform") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected relation, got nil") + } + if got.Name != "TRANSFORMA" { + t.Errorf("name = %q, want TRANSFORMA", got.Name) + } + + // List by entity + rels, err := db.ListRelations("a") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rels) != 1 { + t.Errorf("expected 1, got %d", len(rels)) + } + + // Delete + if err := db.DeleteRelation("a__to__b__via__transform"); err != nil { + t.Fatalf("delete: %v", err) + } + deleted, _ := db.GetRelation("a__to__b__via__transform") + if deleted != nil { + t.Error("expected nil after delete") + } +} + +func TestRelationInputs(t *testing.T) { + db := tempDB(t) + + db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"}) + db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"}) + + r := &Relation{ + ID: "multi__to__c", + Name: "ENRIQUECE", + ToEntity: "c", + Direction: DirUnidirectional, + Status: RelDesigned, + } + + inputs := []RelationInput{ + {ID: "i1", RelationID: "multi__to__c", EntityID: "a", Role: "base"}, + {ID: "i2", RelationID: "multi__to__c", EntityID: "b", Role: "lookup"}, + } + + if err := InsertRelationWithInputs(db, r, inputs); err != nil { + t.Fatalf("insert with inputs: %v", err) + } + + got, err := db.GetRelationInputs("multi__to__c") + if err != nil { + t.Fatalf("get inputs: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 inputs, got %d", len(got)) + } +} + +func TestCycleDetectionCausal(t *testing.T) { + db := tempDB(t) + + db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"}) + db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"}) + + // a -> b (causal) + InsertRelationSafe(db, &Relation{ + ID: "ab", Name: "T1", FromEntity: "a", ToEntity: "b", Via: "fn1", + Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned, + }) + + // b -> c (causal) + InsertRelationSafe(db, &Relation{ + ID: "bc", Name: "T2", FromEntity: "b", ToEntity: "c", Via: "fn2", + Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned, + }) + + // c -> a (causal) should fail — creates cycle + err := InsertRelationSafe(db, &Relation{ + ID: "ca", Name: "T3", FromEntity: "c", ToEntity: "a", Via: "fn3", + Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned, + }) + if err == nil { + t.Fatal("expected cycle error, got nil") + } +} + +func TestCycleDetectionSemanticAllowed(t *testing.T) { + db := tempDB(t) + + db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"}) + db.InsertEntity(&Entity{ID: "juan", Name: "juan", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "paula", Name: "paula", TypeRef: "t1", Status: StatusActive, Source: "test"}) + + // juan -> paula (semantic, no via) + if err := InsertRelationSafe(db, &Relation{ + ID: "jp", Name: "CONOCE A", FromEntity: "juan", ToEntity: "paula", + Direction: DirBidirectional, Status: RelRunning, + }); err != nil { + t.Fatalf("insert semantic: %v", err) + } + + // paula -> juan (semantic, no via) — should succeed, no cycle check + if err := InsertRelationSafe(db, &Relation{ + ID: "pj", Name: "CONOCE A", FromEntity: "paula", ToEntity: "juan", + Direction: DirBidirectional, Status: RelRunning, + }); err != nil { + t.Fatalf("semantic cycle should be allowed: %v", err) + } +} + +func TestValidateEntity(t *testing.T) { + tests := []struct { + name string + entity Entity + wantErr bool + }{ + { + name: "valid", + entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive, Source: "test"}, + wantErr: false, + }, + { + name: "missing name", + entity: Entity{ID: "x", TypeRef: "t1", Status: StatusActive, Source: "test"}, + wantErr: true, + }, + { + name: "missing source", + entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive}, + wantErr: true, + }, + { + name: "missing type_ref", + entity: Entity{ID: "x", Name: "x", Status: StatusActive, Source: "test"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEntity(&tt.entity) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateEntity() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGetEntityGraph(t *testing.T) { + db := tempDB(t) + + db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"}) + db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"}) + db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"}) + InsertRelationSafe(db, &Relation{ + ID: "ab", Name: "FLUYE", FromEntity: "a", ToEntity: "b", + Direction: DirUnidirectional, Status: RelDesigned, + }) + + g, err := GetEntityGraph(db) + if err != nil { + t.Fatalf("graph: %v", err) + } + if len(g.Entities) != 2 { + t.Errorf("entities = %d, want 2", len(g.Entities)) + } + if len(g.Relations) != 1 { + t.Errorf("relations = %d, want 1", len(g.Relations)) + } +} diff --git a/fn_operations/store.go b/fn_operations/store.go new file mode 100644 index 00000000..52f2389a --- /dev/null +++ b/fn_operations/store.go @@ -0,0 +1,459 @@ +package fn_operations + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +func marshalStrings(ss []string) string { + if ss == nil { + ss = []string{} + } + b, _ := json.Marshal(ss) + return string(b) +} + +func unmarshalStrings(s string) []string { + var out []string + json.Unmarshal([]byte(s), &out) + if out == nil { + out = []string{} + } + return out +} + +func marshalJSON(v map[string]any) string { + if v == nil { + v = map[string]any{} + } + b, _ := json.Marshal(v) + return string(b) +} + +func unmarshalJSON(s string) map[string]any { + var out map[string]any + json.Unmarshal([]byte(s), &out) + if out == nil { + out = map[string]any{} + } + return out +} + +// --- TypeSnapshot CRUD --- + +// InsertTypeSnapshot inserts a type snapshot. +func (db *DB) InsertTypeSnapshot(ts *TypeSnapshot) error { + if ts.SnappedAt.IsZero() { + ts.SnappedAt = time.Now().UTC() + } + _, err := db.conn.Exec(` + INSERT OR IGNORE INTO types_snapshot (id, version, lang, algebraic, definition, description, snapped_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ts.ID, ts.Version, ts.Lang, ts.Algebraic, ts.Definition, ts.Description, + ts.SnappedAt.Format(time.RFC3339), + ) + return err +} + +// GetTypeSnapshot returns a type snapshot by ID. +func (db *DB) GetTypeSnapshot(id string) (*TypeSnapshot, error) { + row := db.conn.QueryRow("SELECT id, version, lang, algebraic, definition, description, snapped_at FROM types_snapshot WHERE id = ?", id) + var ts TypeSnapshot + var snappedAt string + err := row.Scan(&ts.ID, &ts.Version, &ts.Lang, &ts.Algebraic, &ts.Definition, &ts.Description, &snappedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanning type_snapshot: %w", err) + } + ts.SnappedAt, _ = time.Parse(time.RFC3339, snappedAt) + return &ts, nil +} + +// ListTypeSnapshots returns all type snapshots. +func (db *DB) ListTypeSnapshots() ([]TypeSnapshot, error) { + rows, err := db.conn.Query("SELECT id, version, lang, algebraic, definition, description, snapped_at FROM types_snapshot ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + + var result []TypeSnapshot + for rows.Next() { + var ts TypeSnapshot + var snappedAt string + if err := rows.Scan(&ts.ID, &ts.Version, &ts.Lang, &ts.Algebraic, &ts.Definition, &ts.Description, &snappedAt); err != nil { + return nil, fmt.Errorf("scanning type_snapshot: %w", err) + } + ts.SnappedAt, _ = time.Parse(time.RFC3339, snappedAt) + result = append(result, ts) + } + return result, nil +} + +// --- Entity CRUD --- + +// InsertEntity inserts or replaces an entity. +func (db *DB) InsertEntity(e *Entity) error { + now := time.Now().UTC() + if e.CreatedAt.IsZero() { + e.CreatedAt = now + } + e.UpdatedAt = now + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO entities (id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.Name, e.TypeRef, string(e.Status), e.Description, e.Domain, + marshalStrings(e.Tags), e.Source, marshalJSON(e.Metadata), e.Notes, + e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetEntity returns an entity by ID. +func (db *DB) GetEntity(id string) (*Entity, error) { + row := db.conn.QueryRow(` + SELECT id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at + FROM entities WHERE id = ?`, id) + + var e Entity + var tagsJSON, metadataJSON, createdAt, updatedAt string + err := row.Scan(&e.ID, &e.Name, &e.TypeRef, &e.Status, &e.Description, &e.Domain, + &tagsJSON, &e.Source, &metadataJSON, &e.Notes, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanning entity: %w", err) + } + e.Tags = unmarshalStrings(tagsJSON) + e.Metadata = unmarshalJSON(metadataJSON) + e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + return &e, nil +} + +// UpdateEntity updates an existing entity. +func (db *DB) UpdateEntity(e *Entity) error { + e.UpdatedAt = time.Now().UTC() + _, err := db.conn.Exec(` + UPDATE entities SET name=?, type_ref=?, status=?, description=?, domain=?, tags=?, source=?, metadata=?, notes=?, updated_at=? + WHERE id=?`, + e.Name, e.TypeRef, string(e.Status), e.Description, e.Domain, + marshalStrings(e.Tags), e.Source, marshalJSON(e.Metadata), e.Notes, + e.UpdatedAt.Format(time.RFC3339), e.ID, + ) + return err +} + +// DeleteEntity removes an entity by ID. +func (db *DB) DeleteEntity(id string) error { + _, err := db.conn.Exec("DELETE FROM entities WHERE id = ?", id) + return err +} + +// ListEntities returns entities filtered by domain and/or status. +func (db *DB) ListEntities(domain string, status EntityStatus) ([]Entity, error) { + where := []string{} + args := []any{} + if domain != "" { + where = append(where, "domain = ?") + args = append(args, domain) + } + if status != "" { + where = append(where, "status = ?") + args = append(args, string(status)) + } + + q := "SELECT id, name, type_ref, status, description, domain, tags, source, metadata, notes, created_at, updated_at FROM entities" + 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 scanEntities(rows) +} + +// SearchEntities performs FTS search on entities. +func (db *DB) SearchEntities(query, domain string) ([]Entity, error) { + where := []string{} + args := []any{} + if query != "" { + where = append(where, "e.id IN (SELECT id FROM entities_fts WHERE entities_fts MATCH ?)") + args = append(args, query) + } + if domain != "" { + where = append(where, "e.domain = ?") + args = append(args, domain) + } + + q := "SELECT e.id, e.name, e.type_ref, e.status, e.description, e.domain, e.tags, e.source, e.metadata, e.notes, e.created_at, e.updated_at FROM entities e" + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY e.name" + + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEntities(rows) +} + +func scanEntities(rows *sql.Rows) ([]Entity, error) { + var result []Entity + for rows.Next() { + var e Entity + var tagsJSON, metadataJSON, createdAt, updatedAt string + if err := rows.Scan(&e.ID, &e.Name, &e.TypeRef, &e.Status, &e.Description, &e.Domain, + &tagsJSON, &e.Source, &metadataJSON, &e.Notes, &createdAt, &updatedAt); err != nil { + return nil, fmt.Errorf("scanning entity: %w", err) + } + e.Tags = unmarshalStrings(tagsJSON) + e.Metadata = unmarshalJSON(metadataJSON) + e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + result = append(result, e) + } + return result, nil +} + +// --- Relation CRUD --- + +// InsertRelation inserts or replaces a relation. +func (db *DB) InsertRelation(r *Relation) error { + now := time.Now().UTC() + if r.CreatedAt.IsZero() { + r.CreatedAt = now + } + r.UpdatedAt = now + + var startedAt, endedAt *string + if r.StartedAt != nil { + s := r.StartedAt.Format(time.RFC3339) + startedAt = &s + } + if r.EndedAt != nil { + s := r.EndedAt.Format(time.RFC3339) + endedAt = &s + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO relations (id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description, + r.Purity, string(r.Direction), r.Weight, string(r.Status), + startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes, + r.CreatedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetRelation returns a relation by ID. +func (db *DB) GetRelation(id string) (*Relation, error) { + row := db.conn.QueryRow(` + SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at + FROM relations WHERE id = ?`, id) + return scanRelation(row) +} + +// UpdateRelation updates an existing relation. +func (db *DB) UpdateRelation(r *Relation) error { + r.UpdatedAt = time.Now().UTC() + + var startedAt, endedAt *string + if r.StartedAt != nil { + s := r.StartedAt.Format(time.RFC3339) + startedAt = &s + } + if r.EndedAt != nil { + s := r.EndedAt.Format(time.RFC3339) + endedAt = &s + } + + _, err := db.conn.Exec(` + UPDATE relations SET name=?, from_entity=?, to_entity=?, via=?, description=?, purity=?, direction=?, weight=?, status=?, started_at=?, ended_at=?, "order"=?, tags=?, notes=?, updated_at=? + WHERE id=?`, + r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description, + r.Purity, string(r.Direction), r.Weight, string(r.Status), + startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes, + r.UpdatedAt.Format(time.RFC3339), r.ID, + ) + return err +} + +// DeleteRelation removes a relation by ID (cascades to relation_inputs). +func (db *DB) DeleteRelation(id string) error { + _, err := db.conn.Exec("DELETE FROM relations WHERE id = ?", id) + return err +} + +// ListRelations returns all relations, optionally filtered by entity involvement. +func (db *DB) ListRelations(entityID string) ([]Relation, error) { + q := `SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at FROM relations` + var args []any + if entityID != "" { + q += " WHERE from_entity = ? OR to_entity = ?" + args = append(args, entityID, entityID) + } + q += " ORDER BY name" + + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []Relation + for rows.Next() { + r, err := scanRelationFromRows(rows) + if err != nil { + return nil, err + } + result = append(result, *r) + } + return result, nil +} + +// GetRelationsFrom returns all relations where from_entity matches. +func (db *DB) GetRelationsFrom(entityID string) ([]Relation, error) { + rows, err := db.conn.Query(` + SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at + FROM relations WHERE from_entity = ? ORDER BY name`, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []Relation + for rows.Next() { + r, err := scanRelationFromRows(rows) + if err != nil { + return nil, err + } + result = append(result, *r) + } + return result, nil +} + +// GetRelationsTo returns all relations where to_entity matches. +func (db *DB) GetRelationsTo(entityID string) ([]Relation, error) { + rows, err := db.conn.Query(` + SELECT id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at + FROM relations WHERE to_entity = ? ORDER BY name`, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []Relation + for rows.Next() { + r, err := scanRelationFromRows(rows) + if err != nil { + return nil, err + } + result = append(result, *r) + } + return result, nil +} + +func scanRelation(row *sql.Row) (*Relation, error) { + var r Relation + var tagsJSON, createdAt, updatedAt string + var startedAt, endedAt *string + err := row.Scan(&r.ID, &r.Name, &r.FromEntity, &r.ToEntity, &r.Via, &r.Description, + &r.Purity, &r.Direction, &r.Weight, &r.Status, + &startedAt, &endedAt, &r.Order, &tagsJSON, &r.Notes, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanning relation: %w", err) + } + r.Tags = unmarshalStrings(tagsJSON) + r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + if startedAt != nil { + t, _ := time.Parse(time.RFC3339, *startedAt) + r.StartedAt = &t + } + if endedAt != nil { + t, _ := time.Parse(time.RFC3339, *endedAt) + r.EndedAt = &t + } + return &r, nil +} + +func scanRelationFromRows(rows *sql.Rows) (*Relation, error) { + var r Relation + var tagsJSON, createdAt, updatedAt string + var startedAt, endedAt *string + err := rows.Scan(&r.ID, &r.Name, &r.FromEntity, &r.ToEntity, &r.Via, &r.Description, + &r.Purity, &r.Direction, &r.Weight, &r.Status, + &startedAt, &endedAt, &r.Order, &tagsJSON, &r.Notes, &createdAt, &updatedAt) + if err != nil { + return nil, fmt.Errorf("scanning relation: %w", err) + } + r.Tags = unmarshalStrings(tagsJSON) + r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + if startedAt != nil { + t, _ := time.Parse(time.RFC3339, *startedAt) + r.StartedAt = &t + } + if endedAt != nil { + t, _ := time.Parse(time.RFC3339, *endedAt) + r.EndedAt = &t + } + return &r, nil +} + +// --- RelationInput CRUD --- + +// InsertRelationInput inserts a relation input. +func (db *DB) InsertRelationInput(ri *RelationInput) error { + _, err := db.conn.Exec(` + INSERT INTO relation_inputs (id, relation_id, entity_id, role, "order") + VALUES (?, ?, ?, ?, ?)`, + ri.ID, ri.RelationID, ri.EntityID, ri.Role, ri.Order, + ) + return err +} + +// GetRelationInputs returns all inputs for a relation. +func (db *DB) GetRelationInputs(relationID string) ([]RelationInput, error) { + rows, err := db.conn.Query(` + SELECT id, relation_id, entity_id, role, "order" + FROM relation_inputs WHERE relation_id = ? ORDER BY "order"`, relationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []RelationInput + for rows.Next() { + var ri RelationInput + if err := rows.Scan(&ri.ID, &ri.RelationID, &ri.EntityID, &ri.Role, &ri.Order); err != nil { + return nil, fmt.Errorf("scanning relation_input: %w", err) + } + result = append(result, ri) + } + return result, nil +} + +// DeleteRelationInputs removes all inputs for a relation. +func (db *DB) DeleteRelationInputs(relationID string) error { + _, err := db.conn.Exec("DELETE FROM relation_inputs WHERE relation_id = ?", relationID) + return err +} diff --git a/fn_operations/validate.go b/fn_operations/validate.go new file mode 100644 index 00000000..5eb759a0 --- /dev/null +++ b/fn_operations/validate.go @@ -0,0 +1,181 @@ +package fn_operations + +import ( + "fmt" + "strings" +) + +// ValidationError represents one or more integrity violations. +type ValidationError struct { + ID string + Errors []string +} + +func (v *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", v.ID, strings.Join(v.Errors, "; ")) +} + +// ValidateEntity checks entity integrity rules. +func ValidateEntity(e *Entity) *ValidationError { + var errs []string + + if e.ID == "" { + errs = append(errs, "id is required") + } + if e.Name == "" { + errs = append(errs, "name is required") + } + if e.TypeRef == "" { + errs = append(errs, "type_ref is required") + } + if e.Source == "" { + errs = append(errs, "source is required") + } + + switch e.Status { + case StatusActive, StatusStale, StatusCorrupted, StatusArchived: + case "": + errs = append(errs, "status is required") + default: + errs = append(errs, fmt.Sprintf("invalid status: %s", e.Status)) + } + + if len(errs) > 0 { + return &ValidationError{ID: e.ID, Errors: errs} + } + return nil +} + +// ValidateRelation checks relation integrity rules. +// knownEntities is a set of entity IDs that exist. +func ValidateRelation(r *Relation, knownEntities map[string]bool) *ValidationError { + var errs []string + + if r.ID == "" { + errs = append(errs, "id is required") + } + if r.Name == "" { + errs = append(errs, "name is required") + } + if r.ToEntity == "" { + errs = append(errs, "to_entity is required") + } + + // from_entity or relation_inputs — validated at operation level + if r.FromEntity != "" && r.ToEntity != "" && r.FromEntity == r.ToEntity { + errs = append(errs, "from_entity and to_entity cannot be the same") + } + + if r.FromEntity != "" && !knownEntities[r.FromEntity] { + errs = append(errs, fmt.Sprintf("from_entity references unknown entity: %s", r.FromEntity)) + } + if r.ToEntity != "" && !knownEntities[r.ToEntity] { + errs = append(errs, fmt.Sprintf("to_entity references unknown entity: %s", r.ToEntity)) + } + + if r.Weight != nil { + if *r.Weight < 0.0 || *r.Weight > 1.0 { + errs = append(errs, "weight must be between 0.0 and 1.0") + } + } + + if r.StartedAt != nil && r.EndedAt != nil { + if r.StartedAt.After(*r.EndedAt) { + errs = append(errs, "started_at must be before ended_at") + } + } + + switch r.Direction { + case DirUnidirectional, DirBidirectional, DirInverse, "": + default: + errs = append(errs, fmt.Sprintf("invalid direction: %s", r.Direction)) + } + + if len(errs) > 0 { + return &ValidationError{ID: r.ID, Errors: errs} + } + return nil +} + +// ValidateRelationInputs checks relation_inputs integrity. +func ValidateRelationInputs(inputs []RelationInput, knownEntities map[string]bool) *ValidationError { + var errs []string + + if len(inputs) < 2 { + errs = append(errs, "relation_inputs must have at least 2 entries") + } + + for i, ri := range inputs { + if ri.RelationID == "" { + errs = append(errs, fmt.Sprintf("input[%d]: relation_id is required", i)) + } + if ri.EntityID == "" { + errs = append(errs, fmt.Sprintf("input[%d]: entity_id is required", i)) + } + if ri.Role == "" { + errs = append(errs, fmt.Sprintf("input[%d]: role is required", i)) + } + if ri.EntityID != "" && !knownEntities[ri.EntityID] { + errs = append(errs, fmt.Sprintf("input[%d]: entity_id references unknown entity: %s", i, ri.EntityID)) + } + } + + if len(errs) > 0 { + id := "relation_inputs" + if len(inputs) > 0 { + id = inputs[0].RelationID + } + return &ValidationError{ID: id, Errors: errs} + } + return nil +} + +// DetectCycle checks if adding a causal relation (from -> to) creates a cycle. +// Only considers relations where via != "" (causal/transformational). +// Semantic relations (via == "") are exempt from cycle detection. +func DetectCycle(db *DB, fromEntity, toEntity string) error { + if fromEntity == "" || toEntity == "" { + return nil + } + + // BFS from toEntity following only causal relations. + // If we reach fromEntity, there's a cycle. + visited := map[string]bool{} + queue := []string{toEntity} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + if visited[current] { + continue + } + visited[current] = true + + if current == fromEntity { + return fmt.Errorf("cycle detected: adding relation %s -> %s would create a causal cycle", fromEntity, toEntity) + } + + // Follow causal relations from current entity + rows, err := db.conn.Query(` + SELECT to_entity FROM relations + WHERE from_entity = ? AND via != ''`, current) + if err != nil { + return fmt.Errorf("querying relations for cycle detection: %w", err) + } + + for rows.Next() { + var next string + if err := rows.Scan(&next); err != nil { + rows.Close() + return err + } + if !visited[next] { + queue = append(queue, next) + } + } + rows.Close() + } + + return nil +} From 2d3f4b4448d0b6729ae8bb9cd68a63fa8810b23b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 04:37:54 +0100 Subject: [PATCH 2/5] feat: migracion SQL, template DB y generador para fn_operations 001_init.sql como espejo del schema, gentemplate para crear operations.db vacias con schema aplicado, y template lista para copiar con fn ops init. --- fn_operations/cmd/gentemplate/main.go | 27 +++++++ fn_operations/migrations/001_init.sql | 80 +++++++++++++++++++ fn_operations/project_template/operations.db | Bin 0 -> 53248 bytes 3 files changed, 107 insertions(+) create mode 100644 fn_operations/cmd/gentemplate/main.go create mode 100644 fn_operations/migrations/001_init.sql create mode 100644 fn_operations/project_template/operations.db diff --git a/fn_operations/cmd/gentemplate/main.go b/fn_operations/cmd/gentemplate/main.go new file mode 100644 index 00000000..0689f441 --- /dev/null +++ b/fn_operations/cmd/gentemplate/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + + ops "fn-registry/fn_operations" +) + +func main() { + path := "fn_operations/project_template/operations.db" + if len(os.Args) > 1 { + path = os.Args[1] + } + + // Remove existing template + os.Remove(path) + + db, err := ops.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer db.Close() + + fmt.Printf("Template DB created at %s\n", path) +} diff --git a/fn_operations/migrations/001_init.sql b/fn_operations/migrations/001_init.sql new file mode 100644 index 00000000..31bc8cb6 --- /dev/null +++ b/fn_operations/migrations/001_init.sql @@ -0,0 +1,80 @@ +-- fn_operations schema v1.0.0 +-- Espejo del schema en fn_operations/db.go para referencia y tooling externo. + +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + +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' +); + +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; diff --git a/fn_operations/project_template/operations.db b/fn_operations/project_template/operations.db new file mode 100644 index 0000000000000000000000000000000000000000..301fada8393ac94cc7b4e7609ea915d02ada9e68 GIT binary patch literal 53248 zcmeI(?{C{g7zgk;ZR5sm*YW})n95E=lN&8-SB<@zCXu#Y7cHb)nr;jPksDt#Yl)rN zPPY#6qTMv!u)m0Z03_Zb-t&rgyx|4X-te4%q;Z;LT@XU4z7bvQyYu4%f?CzM`>8xv;sZm046wEm;0mg-UkrZioh3rA{4N(>4nwC0!_OYvl?n zlqy@Dy6T?iE_FJglN@vGr>2o(;?L5XJjV>~)*Q3tnYNWqyaA{Am=EIP`}`??FtW zBo>n>2x9W9qL{SO#*s4bPtc3%i3u_y#DNoJgcG!(6}7RSAi_9w&IK4NCos&nVwfRD zn3M04t^aEVwCCi^wQJJhd!D}A;L$L}Dyr6OYtO9rDVmE?BEMGDnA)d>y7ZXcEf+q| zmmjh_+QS^%&$0)F%I&S43M+3tC~O?e%9-oerNj46-qx^duK7G3N?0~K1EoUIkN*0P84jGVc0MLNu%ytipQi;afPXNKI9s?{Zz*TwqtxYNwBR^765 z-S_RT&h{X5rg$dMpxy3X~zCi2A`}-)-2n$Kmj^X*qN0()3a0#PGXs7~blB zph1&&R%#V?EUKQ<&O7}ET z498a(%&#tORaj}KSY&1GmR8nE>-4!0HF7DXD9F&M zy4q8ae)Zv$oVjp8`az1hc0}D&UrYBCiXsOF4Q0=gI}>QV2*y68&5Va-}?$Mg<@4rA^#tx-U1{kFD#XDMu2DA88~HK)=keK%0erhkwX zlW3R@uZi@e{swf`w#-f|y`hF(cZz7=qTYKN<<#BSMRZeZpF1vB{kuNpX8n;D41vB$ z6)6zc)4jGkbX4SH)-7&CA%vIs_wUsC!)x#+%^0=JsUCNO1E+0Srd6jRgSTkVx+wOe zp?(}XsnR!O-+-dwl%hdFr>IX#Q$w*)1VIn<`|4|vAC^s#ge?a^`+JJRMPQv+ShSw0 zi*}3l%YJoAb~TmCXcykLwA|xG<;pMTC*;iHqV)Zf*p?bC^2yj@ic0_GTJjEBoEG|? zaEKw$=uVLUI%VoU?>GHnO-`j=8Y0JQw&S#0Vv2LB?$jR9b>VEEa+L*Je=uROP`g&y zyUf39x1CzJ$;A4jPMX})#Yf=@mg9SU``v$^L@Sf2%=+S>B#Kg?x*8o#9epXunQT^i zY5OT8X4kE{mfmt7*l-6@hv>>a@-)sr~KLaWPHm#+$>hHlk^dFyFQb$X-D zcOBiV^(J7rs~y+-yGqNkjdsnW9CF*uV6!mzo@vp~L9t`T=J7ZZ5yUC8OklP8FX~IkTAOHafKmY;|fB*y_009UI|IzJmB?KS<0SG_<0uX=z1Rwwb2tZ(51n~TSTxJS!LI45~fB*y_009U<00Izz z00c%C!1Mpn?QkUoAOHafKmY;|fB*y_009UI|IzJmB?KS<0SG_<0uX=z1Rwwb2tZ(51jO_I Date: Sat, 28 Mar 2026 04:37:59 +0100 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20CLI=20fn=20ops=20=E2=80=94=20init,?= =?UTF-8?q?=20entity,=20relation,=20graph,=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/fn/main.go | 5 +- cmd/fn/ops.go | 634 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 cmd/fn/ops.go diff --git a/cmd/fn/main.go b/cmd/fn/main.go index a3b928bf..e8415f00 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -29,6 +29,8 @@ func main() { cmdShow(os.Args[2:]) case "add": cmdAdd(os.Args[2:]) + case "ops": + cmdOps(os.Args[2:]) case "help", "-h", "--help": printUsage() default: @@ -46,7 +48,8 @@ Usage: fn search [-k kind] [-p purity] [-l lang] [-d domain] fn list [-k kind] [-d domain] [-l lang] fn show Muestra entrada completa - fn add [-k kind] Abre $EDITOR con template`) + fn add [-k kind] Abre $EDITOR con template + fn ops Gestiona operations.db (fn ops help)`) } func root() string { diff --git a/cmd/fn/ops.go b/cmd/fn/ops.go new file mode 100644 index 00000000..385edbdc --- /dev/null +++ b/cmd/fn/ops.go @@ -0,0 +1,634 @@ +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 Añade entity + fn ops entity list [-d domain] [-s status] Lista entities + fn ops entity show Muestra entity + fn ops entity delete Elimina entity + fn ops relation add Añade relation + fn ops relation list [entity_id] Lista relations + fn ops relation show Muestra relation + fn ops relation delete Elimina relation + fn ops graph Grafo ASCII de entities y relations + fn ops snapshot list Lista tipos snapshotted + +Entity flags: + --id --name --type-ref --source + --domain --status --description + --tags --metadata --notes + +Relation flags: + --id --name --from --to + --via --direction --status + --purity --weight <0.0-1.0> --description + --tags --notes `) +} + +// --- 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 ...") + 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 ") + 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 ") + 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 ...") + 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 ") + 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 ") + 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 +} From 3716c8fc6d04585be60dbed6177508f08b881de3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 04:38:04 +0100 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20Docker=20TUI=20=E2=80=94=20aplicaci?= =?UTF-8?q?on=20Bubble=20Tea=20para=20gestionar=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI fullscreen con 5 vistas: Containers (start/stop/restart/logs), Images (list/remove), Volumes (list/remove), Networks (list/remove), Compose (up/down/logs). Usa DevFactory (tui, shell, core). Navegacion por tabs, filtrado en containers/images, scroll en logs. Incluye operations.db con entities y relations del proyecto. --- fn_operations/docker_tui/.gitignore | 5 + fn_operations/docker_tui/Makefile | 19 ++ fn_operations/docker_tui/app/model.go | 146 +++++++++++ fn_operations/docker_tui/config/config.go | 15 ++ fn_operations/docker_tui/go.mod | 43 ++++ fn_operations/docker_tui/go.sum | 84 +++++++ fn_operations/docker_tui/go.work | 6 + fn_operations/docker_tui/go.work.sum | 40 +++ fn_operations/docker_tui/main.go | 20 ++ fn_operations/docker_tui/operations.db | Bin 0 -> 53248 bytes fn_operations/docker_tui/views/compose.go | 193 +++++++++++++++ fn_operations/docker_tui/views/containers.go | 243 +++++++++++++++++++ fn_operations/docker_tui/views/docker.go | 192 +++++++++++++++ fn_operations/docker_tui/views/images.go | 127 ++++++++++ fn_operations/docker_tui/views/keys.go | 14 ++ fn_operations/docker_tui/views/networks.go | 123 ++++++++++ fn_operations/docker_tui/views/types.go | 45 ++++ fn_operations/docker_tui/views/volumes.go | 123 ++++++++++ 18 files changed, 1438 insertions(+) create mode 100644 fn_operations/docker_tui/.gitignore create mode 100644 fn_operations/docker_tui/Makefile create mode 100644 fn_operations/docker_tui/app/model.go create mode 100644 fn_operations/docker_tui/config/config.go create mode 100644 fn_operations/docker_tui/go.mod create mode 100644 fn_operations/docker_tui/go.sum create mode 100644 fn_operations/docker_tui/go.work create mode 100644 fn_operations/docker_tui/go.work.sum create mode 100644 fn_operations/docker_tui/main.go create mode 100644 fn_operations/docker_tui/operations.db create mode 100644 fn_operations/docker_tui/views/compose.go create mode 100644 fn_operations/docker_tui/views/containers.go create mode 100644 fn_operations/docker_tui/views/docker.go create mode 100644 fn_operations/docker_tui/views/images.go create mode 100644 fn_operations/docker_tui/views/keys.go create mode 100644 fn_operations/docker_tui/views/networks.go create mode 100644 fn_operations/docker_tui/views/types.go create mode 100644 fn_operations/docker_tui/views/volumes.go diff --git a/fn_operations/docker_tui/.gitignore b/fn_operations/docker_tui/.gitignore new file mode 100644 index 00000000..c8427d40 --- /dev/null +++ b/fn_operations/docker_tui/.gitignore @@ -0,0 +1,5 @@ +build/ +*.exe +*.dll +*.so +*.dylib diff --git a/fn_operations/docker_tui/Makefile b/fn_operations/docker_tui/Makefile new file mode 100644 index 00000000..caed4a3e --- /dev/null +++ b/fn_operations/docker_tui/Makefile @@ -0,0 +1,19 @@ +.PHONY: run build clean install tidy help + +run: ## Ejecuta la TUI + go run . + +build: ## Compila el binario + go build -trimpath -ldflags='-s -w' -o build/docker-tui . + +clean: ## Limpia artefactos + rm -rf build/ + +install: build ## Instala en ~/.local/bin + cp build/docker-tui ~/.local/bin/docker-tui + +tidy: ## go mod tidy + go mod tidy + +help: ## Muestra esta ayuda + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/fn_operations/docker_tui/app/model.go b/fn_operations/docker_tui/app/model.go new file mode 100644 index 00000000..c0d83f08 --- /dev/null +++ b/fn_operations/docker_tui/app/model.go @@ -0,0 +1,146 @@ +package app + +import ( + "docker-tui/views" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lucasdataproyects/devfactory/tui" +) + +type View int + +const ( + ViewContainers View = iota + ViewImages + ViewVolumes + ViewNetworks + ViewCompose +) + +var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"} + +type Model struct { + tui.BaseModel + activeTab int + containers views.ContainersModel + images views.ImagesModel + volumes views.VolumesModel + networks views.NetworksModel + compose views.ComposeModel + ready bool +} + +func New() Model { + styles := tui.DefaultStyles() + return Model{ + BaseModel: tui.NewBaseModel().WithStyles(styles), + containers: views.NewContainersModel(styles), + images: views.NewImagesModel(styles), + volumes: views.NewVolumesModel(styles), + networks: views.NewNetworksModel(styles), + compose: views.NewComposeModel(styles), + } +} + +func (m Model) Init() tea.Cmd { + return m.containers.Init() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case views.KeyQuit: + return m, tea.Quit + case views.KeyTab: + m.activeTab = (m.activeTab + 1) % len(tabNames) + return m, m.initActiveView() + case "shift+tab": + m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames) + return m, m.initActiveView() + } + case tea.WindowSizeMsg: + m.HandleWindowSize(msg) + m.ready = true + } + + var cmd tea.Cmd + switch View(m.activeTab) { + case ViewContainers: + m.containers, cmd = m.containers.Update(msg) + case ViewImages: + m.images, cmd = m.images.Update(msg) + case ViewVolumes: + m.volumes, cmd = m.volumes.Update(msg) + case ViewNetworks: + m.networks, cmd = m.networks.Update(msg) + case ViewCompose: + m.compose, cmd = m.compose.Update(msg) + } + return m, cmd +} + +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + // Tab bar + tabs := m.renderTabs() + + // Active view content + var content string + switch View(m.activeTab) { + case ViewContainers: + content = m.containers.View() + case ViewImages: + content = m.images.View() + case ViewVolumes: + content = m.volumes.View() + case ViewNetworks: + content = m.networks.View() + case ViewCompose: + content = m.compose.View() + } + + // Status bar + status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh") + + return lipgloss.JoinVertical(lipgloss.Left, + tabs, + "", + content, + "", + status, + ) +} + +func (m Model) renderTabs() string { + var tabs []string + for i, name := range tabNames { + if i == m.activeTab { + tabs = append(tabs, m.Styles.Selected.Render(" "+name+" ")) + } else { + tabs = append(tabs, m.Styles.Muted.Render(" "+name+" ")) + } + } + row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + return m.Styles.Header.Render("Docker TUI") + " " + row +} + +func (m Model) initActiveView() tea.Cmd { + switch View(m.activeTab) { + case ViewContainers: + return m.containers.Init() + case ViewImages: + return m.images.Init() + case ViewVolumes: + return m.volumes.Init() + case ViewNetworks: + return m.networks.Init() + case ViewCompose: + return m.compose.Init() + } + return nil +} diff --git a/fn_operations/docker_tui/config/config.go b/fn_operations/docker_tui/config/config.go new file mode 100644 index 00000000..a68e66a6 --- /dev/null +++ b/fn_operations/docker_tui/config/config.go @@ -0,0 +1,15 @@ +package config + +// Config holds Docker TUI configuration. +type Config struct { + ComposeFile string + RefreshInterval int // seconds, 0 = manual +} + +// Default returns sensible defaults. +func Default() Config { + return Config{ + ComposeFile: "docker-compose.yml", + RefreshInterval: 0, + } +} diff --git a/fn_operations/docker_tui/go.mod b/fn_operations/docker_tui/go.mod new file mode 100644 index 00000000..42dc1f38 --- /dev/null +++ b/fn_operations/docker_tui/go.mod @@ -0,0 +1,43 @@ +module docker-tui + +go 1.22.2 + +require ( + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/lucasdataproyects/devfactory v0.0.0 +) + +require ( + github.com/apache/arrow/go/v14 v14.0.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/marcboeker/go-duckdb v1.6.5 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/rivo/uniseg v0.4.6 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/sync v0.4.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect +) + +replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend diff --git a/fn_operations/docker_tui/go.sum b/fn_operations/docker_tui/go.sum new file mode 100644 index 00000000..e0ff28d7 --- /dev/null +++ b/fn_operations/docker_tui/go.sum @@ -0,0 +1,84 @@ +github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw= +github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI= +github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= +github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/fn_operations/docker_tui/go.work b/fn_operations/docker_tui/go.work new file mode 100644 index 00000000..c00c9dbe --- /dev/null +++ b/fn_operations/docker_tui/go.work @@ -0,0 +1,6 @@ +go 1.22.2 + +use ( + . + /home/lucas/.local_agentes/backend +) diff --git a/fn_operations/docker_tui/go.work.sum b/fn_operations/docker_tui/go.work.sum new file mode 100644 index 00000000..e0b4994d --- /dev/null +++ b/fn_operations/docker_tui/go.work.sum @@ -0,0 +1,40 @@ +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/fn_operations/docker_tui/main.go b/fn_operations/docker_tui/main.go new file mode 100644 index 00000000..72d81fca --- /dev/null +++ b/fn_operations/docker_tui/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + + "docker-tui/app" + + "github.com/lucasdataproyects/devfactory/tui" +) + +func main() { + model := app.New() + result := tui.RunFullscreen(model) + + if result.IsErr() { + fmt.Fprintf(os.Stderr, "error: %v\n", result.Error()) + os.Exit(1) + } +} diff --git a/fn_operations/docker_tui/operations.db b/fn_operations/docker_tui/operations.db new file mode 100644 index 0000000000000000000000000000000000000000..5196b536e044a485e480b6b6c2b3b26731416b9e GIT binary patch literal 53248 zcmeI5OKcm*8OQf2KBU#k%7bwfH`BNV#Y(hfD{|7LMoLq(LPVwnjS!CG1b8d(4qjv6kb(>N z@^Wd71l|yX6i!#cXXES= zjj&qjhmljI<#HvRDp%G^YgJONR9E|9wN2NV?q>oQv&6Qp8;w~)-=@~qXGueM>bB8w z4a=M*uGWM&4XdpgW-gsNS6JC7t&{0&LvQJ>o}DFDt8vuNf;wq7^wZ0Vx+2+84=+m= zfvc(L4l@HW$8v*WfgR@wyS~~0mMBksNq-8 zQfa01xvwCA(X+=r7<0Q|7`KIC_%Qr7c{i=~fwq9IlE^G9@Vx`BcDbboX41Q;R=3Pe zqdCM&37SOV)JlnDhs4NEUnOVP%4Z5|uaQ?uug#LJ964XEzP!3oC2Ona%S+p1iOk|6 z-+OF#u??&47;iUMjUbeg6$8!k5-oQpwl(uvI6+;9k*t3{MFz$3kn6dJy_ z?U!3t{i<(%E|tg}Ig-4Q*=2sXK)` zpL8nR_oP#-;{i|B>p&!ERW9Unvfpg71J4?}6;5W^M>d_i@pvMUnVI2l)|pmYZ)vpC zt{G;h>pHQ^WQ0yEkT8v;|EiY0UJX|hXc(ku5Efint&+;d$_iO4oi44FDn}6_UNP2|&_ZXwg*QK{=6m`f2_aAK}_3>WmC!>D&QD z+6MMAWz)9WHIGjW%~I)fVPmCAva~U{RuCyh5W=<$Ey#VpEJ96izi6GVZMfTBh7s;i zt$`p?e7RJ7W!jHguE1A=>}(b;;k!ZBXfsESR${}j^*U{ykl!AebxormOKWBQtovTj zZ$a*{NVD0?kwj3axux5Vo@J$8*Nx^C*E0dWNv(ho$JN}fv#(Xa$7q^*Baq-HVdeM3 zn0YO|4K)TaXS1&Ecn-U3nugf~uZG?Mqc!Ty)IdHqT-4wjGGhR0bZDZUN8f8mlZJ@2 zqwqxU$?xJN+CEGRl=w0C0L}UoTc^VMT1ap|Ll)f*^q0fJ(z#<->t9Ul6+4U=s&adq|yvw|<->a~Q)X-gaH}&sWUAyjgGLifsleX?^^iSa~ zEQ7M%-nsvcXigf*6c6pyh=L|iI~KU)8yEOQCYR&iwOA9Pwd>RzQ|mZaEH`>3M4D4H z><$_g?K`|qD%NlYgpM7ZKRQn#wKTKo)mtnsglVm&e%aQHdb|Ppz1kq0^(r0PYIN%^ zv>~V4_BxA(zG;{+9Hc#Fq#g%KsE9jkWfWFt_$~dSFp^o#?Nu95g0*AOYY~RpNjlu7 z1AKY3%#DAle4;!xRt6`&kN^@u0!RP}AOR$R1dsp{xE+Dsj+}leb6~1>zPGSgx7r=c z(Q7a~cmrlnYE7#K6S-G)n~g!5mOpHu-^oeQi+k3KN*vdQ0R?$GO-Cq|(6kc-!0ZI` zF0v`?(?$y>3_{-Py1ix8y-9?Nm;8Vo$aotxSY(MQ2+AWcV@XV_ec6VkBX0m~Ik1LA z{b7E*Yio5ngxMx71BT2rz+u@NmUHy@{P7d{`Gx%P=d1GzFDyLw!m+Ph=$(~PbJYG< zt}mLpd)>0H?rV0%ixaiCyu`+3A)9;xA)h51hU#|2?z#mW1D~BWy%E?Nwz1)*+a?Z+ zJ`HonT5GP^vMy__+;EesVmePvI@wbex2#sTt?z5nITj^qQK$xY+dz#C`JS=5rrUwp zPB-w~@v=1cwWkBAELuTrn1j~d&|%S#=q+-^a@^r&eM?BsfLS*l0ke#@*4*E!GK~>6 z%CDxF{52igyy03lARe^Us@5NChkVyzDTGyZ1VGW7+bX~`n;&7At%~?hXS~f;?oNhK&F?0TNR)grf%!@sL!AUQ!r;O+a2VyL(A7;X4A66O`*I5Yrp<2%qkKVeqlCKYfu)Q zu5P<#LHHL)f=D(3n4)Rg#$IK#8~fWe%G@VL0ZHYvvDI@tRE z*e^MCWBjM%Q_2oF@r4AC01`j~NB{{S0VIF~kN^_+%o6CGkd>L4mku^F2XY6L#rW=v z*pMoAIT#{&j|l9wT>>7QCEg<~mTg!BB6qcxVQ$*m#Y^7a4tTPV?fQ6oXW0ITVSc?S zNy+8r4lZX7WT|j}c<0pE-h`o7ak;SB0oI%StsP9Gro%n}!g7epYp{^JQ1_ImWV88$ ztG+sKnCXifauJv2?_IcW@}3ZsBS&Tqp7G_e0k5xampiVlOgA-{_f6U~ujKO!2RD3a z{XGW0ygjeuG6&24_e-j`{{I1|exUwM{e$`w^~2BXZH~f_01`j~NB{{S0VIF~kN^@u z0!RP}Jj4X(Vt#DSN}d#R!CnA=N;_n(Q8$$t%~D95)3ThOF<`YH+kB zJ=WKIj@uvD)M{|3X07JvZ4FN6t}mC?tL4>7AvThVrz(0&_@oI>OvB^j8hwzyZq!@s z`T8xR!5%Je!Bf>Vq4z+x?nIBa^pBCecxl&)|M6!p4D;(%#mQ@#d{(&{u`zBAH8Qg5 z9<%VwYNZP1MUzDm6hoT5!vaj=MW8F;9nIEAhaRH`=V6+4L%UbA)`dyua6YG8RI;I} zi|X_0F1phpW>;~wQYuypORHp;bWr%$lss4!u zG%iqfM5f2rp#GhE^vg_xAaJ0QYG`~n5U>IvuLT0YCa4JDKplYq>K6H=_Xxi3&`Wqb z0xLt1=t!4?&Qn)EF<&=l^B(x19P< z82x{&{vMq8LIOwt2_OL^fCP{L5 0 { + m.scrollOff-- + } + case "esc", "q", "0": + m.state = composeList + return m, nil + } + return m, nil + } + } + + var cmd tea.Cmd + switch m.state { + case composeLoading, composeAction: + var model tea.Model + model, cmd = m.spinner.Update(msg) + m.spinner = model.(tui.SpinnerModel) + case composeList: + var model tea.Model + model, cmd = m.list.Update(msg) + m.list = model.(tui.ListModel) + } + return m, cmd +} + +func (m ComposeModel) View() string { + switch m.state { + case composeLoading, composeAction: + return m.spinner.View() + case composeList: + if len(m.services) == 0 { + help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.") + return m.list.View() + "\n" + help + } + help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh") + return m.list.View() + "\n" + help + case composeLogs: + return m.renderLogs() + } + return "" +} + +func (m ComposeModel) renderLogs() string { + lines := strings.Split(m.output, "\n") + if len(lines) == 0 { + lines = []string{"(empty)"} + } + maxLines := 20 + if m.scrollOff >= len(lines) { + m.scrollOff = max(0, len(lines)-1) + } + end := min(m.scrollOff+maxLines, len(lines)) + visible := lines[m.scrollOff:end] + + header := m.styles.Header.Render("Compose Logs") + content := strings.Join(visible, "\n") + help := m.styles.Muted.Render(" j/k: scroll │ Esc: back") + + return header + "\n" + content + "\n" + help +} diff --git a/fn_operations/docker_tui/views/containers.go b/fn_operations/docker_tui/views/containers.go new file mode 100644 index 00000000..1dcf803c --- /dev/null +++ b/fn_operations/docker_tui/views/containers.go @@ -0,0 +1,243 @@ +package views + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lucasdataproyects/devfactory/tui" +) + +type containersState int + +const ( + containersLoading containersState = iota + containersList + containersAction + containersLogs +) + +type containersLoadedMsg []DockerContainer +type containersActionMsg struct{ output string; err error } +type containersLogsMsg struct{ output string; err error } + +type ContainersModel struct { + state containersState + list tui.FilteredListModel + spinner tui.SpinnerModel + styles tui.Styles + containers []DockerContainer + output string + scrollOff int + err error +} + +func NewContainersModel(styles tui.Styles) ContainersModel { + return ContainersModel{ + state: containersLoading, + list: tui.NewFilteredList(nil, "Filter containers..."), + spinner: tui.NewSpinner("Loading containers..."), + styles: styles, + } +} + +func (m ContainersModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Init(), + loadContainers, + ) +} + +func loadContainers() tea.Msg { + containers, err := ListContainers() + if err != nil { + return containersLoadedMsg(nil) + } + return containersLoadedMsg(containers) +} + +func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) { + switch msg := msg.(type) { + case containersLoadedMsg: + m.containers = []DockerContainer(msg) + items := make([]tui.ListItem, len(m.containers)) + for i, c := range m.containers { + stateIcon := "●" + if c.State == "running" { + stateIcon = "▶" + } else if c.State == "exited" { + stateIcon = "■" + } + items[i] = tui.ListItem{ + Title: fmt.Sprintf("%s %s", stateIcon, c.Names), + Description: fmt.Sprintf("%s — %s", c.Image, c.Status), + Value: c, + } + } + m.list.SetItems(items) + m.state = containersList + return m, nil + + case containersActionMsg: + m.output = msg.output + if msg.err != nil { + m.output = fmt.Sprintf("Error: %v", msg.err) + } + m.state = containersList + // Refresh after action + return m, loadContainers + + case containersLogsMsg: + m.output = msg.output + if msg.err != nil { + m.output = fmt.Sprintf("Error: %v", msg.err) + } + m.state = containersLogs + m.scrollOff = 0 + return m, nil + + case tea.KeyMsg: + switch m.state { + case containersList: + switch msg.String() { + case "r": + m.state = containersLoading + return m, tea.Batch(m.spinner.Init(), loadContainers) + case "enter": + if item := m.list.SelectedItem(); item != nil { + c := item.Value.(DockerContainer) + if c.State == "running" { + return m, stopContainerCmd(c.ID) + } + return m, startContainerCmd(c.ID) + } + case "l": + if item := m.list.SelectedItem(); item != nil { + c := item.Value.(DockerContainer) + m.state = containersAction + return m, logsContainerCmd(c.ID) + } + case "x": + if item := m.list.SelectedItem(); item != nil { + c := item.Value.(DockerContainer) + return m, restartContainerCmd(c.ID) + } + } + case containersLogs: + switch msg.String() { + case "j", "down": + m.scrollOff++ + case "k", "up": + if m.scrollOff > 0 { + m.scrollOff-- + } + case "esc", "q", "0": + m.state = containersList + return m, nil + } + return m, nil + } + } + + // Delegate to sub-components + var cmd tea.Cmd + switch m.state { + case containersLoading: + var spinnerModel tea.Model + spinnerModel, cmd = m.spinner.Update(msg) + m.spinner = spinnerModel.(tui.SpinnerModel) + case containersList: + var listModel tea.Model + listModel, cmd = m.list.Update(msg) + m.list = listModel.(tui.FilteredListModel) + } + return m, cmd +} + +func (m ContainersModel) View() string { + switch m.state { + case containersLoading: + return m.spinner.View() + case containersList: + if len(m.containers) == 0 { + return m.styles.Muted.Render("No containers found. Press 'r' to refresh.") + } + help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter") + return m.list.View() + "\n" + help + case containersAction: + return m.spinner.View() + case containersLogs: + return m.renderOutput() + } + return "" +} + +func (m ContainersModel) renderOutput() string { + lines := splitLines(m.output) + maxLines := 20 + if m.scrollOff >= len(lines) { + m.scrollOff = max(0, len(lines)-1) + } + end := min(m.scrollOff+maxLines, len(lines)) + visible := lines[m.scrollOff:end] + + header := m.styles.Header.Render("Container Logs") + content := lipgloss.JoinVertical(lipgloss.Left, visible...) + help := m.styles.Muted.Render(" j/k: scroll │ Esc: back") + + return header + "\n" + content + "\n" + help +} + +func startContainerCmd(id string) tea.Cmd { + return func() tea.Msg { + err := StartContainer(id) + return containersActionMsg{output: "Started " + id, err: err} + } +} + +func stopContainerCmd(id string) tea.Cmd { + return func() tea.Msg { + err := StopContainer(id) + return containersActionMsg{output: "Stopped " + id, err: err} + } +} + +func restartContainerCmd(id string) tea.Cmd { + return func() tea.Msg { + err := RestartContainer(id) + return containersActionMsg{output: "Restarted " + id, err: err} + } +} + +func logsContainerCmd(id string) tea.Cmd { + return func() tea.Msg { + output, err := ContainerLogs(id, 100) + return containersLogsMsg{output: output, err: err} + } +} + +func splitLines(s string) []string { + if s == "" { + return []string{"(empty)"} + } + lines := strings.Split(s, "\n") + if len(lines) == 0 { + return []string{"(empty)"} + } + return lines +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/fn_operations/docker_tui/views/docker.go b/fn_operations/docker_tui/views/docker.go new file mode 100644 index 00000000..1d8650c2 --- /dev/null +++ b/fn_operations/docker_tui/views/docker.go @@ -0,0 +1,192 @@ +package views + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/lucasdataproyects/devfactory/shell" +) + +const dockerTimeout = 15 * time.Second + +// --- Containers --- + +func ListContainers() ([]DockerContainer, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}") + stdout, err := result.Both() + if err != nil { + return nil, err + } + return parseJSONLines[DockerContainer](stdout.Stdout) +} + +func StartContainer(id string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both() + return err +} + +func StopContainer(id string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both() + return err +} + +func RestartContainer(id string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both() + return err +} + +func ContainerLogs(id string, lines int) (string, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id) + out, err := result.Both() + if err != nil { + return "", err + } + // docker logs writes to both stdout and stderr + output := out.Stdout + if out.Stderr != "" { + if output != "" { + output += "\n" + } + output += out.Stderr + } + return output, nil +} + +// --- Images --- + +func ListImages() ([]DockerImage, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}") + stdout, err := result.Both() + if err != nil { + return nil, err + } + return parseJSONLines[DockerImage](stdout.Stdout) +} + +func PullImage(name string) (string, error) { + result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name) + out, err := result.Both() + if err != nil { + return "", err + } + return out.Stdout, nil +} + +func RemoveImage(id string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both() + return err +} + +// --- Volumes --- + +func ListVolumes() ([]DockerVolume, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}") + stdout, err := result.Both() + if err != nil { + return nil, err + } + return parseJSONLines[DockerVolume](stdout.Stdout) +} + +func CreateVolume(name string) error { + args := []string{"volume", "create"} + if name != "" { + args = append(args, name) + } + _, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both() + return err +} + +func RemoveVolume(name string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both() + return err +} + +// --- Networks --- + +func ListNetworks() ([]DockerNetwork, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}") + stdout, err := result.Both() + if err != nil { + return nil, err + } + return parseJSONLines[DockerNetwork](stdout.Stdout) +} + +func CreateNetwork(name string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both() + return err +} + +func RemoveNetwork(name string) error { + _, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both() + return err +} + +// --- Compose --- + +func ComposePS() ([]ComposeService, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json") + stdout, err := result.Both() + if err != nil { + return nil, err + } + // docker compose ps --format json returns a JSON array + var services []ComposeService + if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil { + // Try line-by-line as fallback + return parseJSONLines[ComposeService](stdout.Stdout) + } + return services, nil +} + +func ComposeUp() (string, error) { + result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d") + out, err := result.Both() + if err != nil { + return "", err + } + return out.Stdout + out.Stderr, nil +} + +func ComposeDown() (string, error) { + result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down") + out, err := result.Both() + if err != nil { + return "", err + } + return out.Stdout + out.Stderr, nil +} + +func ComposeLogs(lines int) (string, error) { + result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines)) + out, err := result.Both() + if err != nil { + return "", err + } + return out.Stdout + out.Stderr, nil +} + +// --- Helpers --- + +func parseJSONLines[T any](s string) ([]T, error) { + var result []T + for _, line := range strings.Split(strings.TrimSpace(s), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var item T + if err := json.Unmarshal([]byte(line), &item); err != nil { + continue + } + result = append(result, item) + } + return result, nil +} + +func itoa(n int) string { + return fmt.Sprintf("%d", n) +} diff --git a/fn_operations/docker_tui/views/images.go b/fn_operations/docker_tui/views/images.go new file mode 100644 index 00000000..ec989592 --- /dev/null +++ b/fn_operations/docker_tui/views/images.go @@ -0,0 +1,127 @@ +package views + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lucasdataproyects/devfactory/tui" +) + +type imagesState int + +const ( + imagesLoading imagesState = iota + imagesList + imagesAction +) + +type imagesLoadedMsg []DockerImage +type imagesActionMsg struct{ output string; err error } + +type ImagesModel struct { + state imagesState + list tui.FilteredListModel + spinner tui.SpinnerModel + styles tui.Styles + images []DockerImage + err error +} + +func NewImagesModel(styles tui.Styles) ImagesModel { + return ImagesModel{ + state: imagesLoading, + list: tui.NewFilteredList(nil, "Filter images..."), + spinner: tui.NewSpinner("Loading images..."), + styles: styles, + } +} + +func (m ImagesModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Init(), loadImages) +} + +func loadImages() tea.Msg { + images, err := ListImages() + if err != nil { + return imagesLoadedMsg(nil) + } + return imagesLoadedMsg(images) +} + +func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) { + switch msg := msg.(type) { + case imagesLoadedMsg: + m.images = []DockerImage(msg) + items := make([]tui.ListItem, len(m.images)) + for i, img := range m.images { + tag := img.Tag + if tag == "" { + tag = "latest" + } + items[i] = tui.ListItem{ + Title: fmt.Sprintf("%s:%s", img.Repository, tag), + Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]), + Value: img, + } + } + m.list.SetItems(items) + m.state = imagesList + return m, nil + + case imagesActionMsg: + if msg.err != nil { + m.err = msg.err + } + m.state = imagesList + return m, loadImages + + case tea.KeyMsg: + if m.state == imagesList { + switch msg.String() { + case "r": + m.state = imagesLoading + return m, tea.Batch(m.spinner.Init(), loadImages) + case "d", "delete": + if item := m.list.SelectedItem(); item != nil { + img := item.Value.(DockerImage) + m.state = imagesAction + return m, func() tea.Msg { + err := RemoveImage(img.ID) + return imagesActionMsg{output: "Removed", err: err} + } + } + } + } + } + + var cmd tea.Cmd + switch m.state { + case imagesLoading, imagesAction: + var model tea.Model + model, cmd = m.spinner.Update(msg) + m.spinner = model.(tui.SpinnerModel) + case imagesList: + var model tea.Model + model, cmd = m.list.Update(msg) + m.list = model.(tui.FilteredListModel) + } + return m, cmd +} + +func (m ImagesModel) View() string { + switch m.state { + case imagesLoading, imagesAction: + return m.spinner.View() + case imagesList: + if len(m.images) == 0 { + return m.styles.Muted.Render("No images found. Press 'r' to refresh.") + } + help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter") + view := m.list.View() + "\n" + help + if m.err != nil { + view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + } + return view + } + return "" +} diff --git a/fn_operations/docker_tui/views/keys.go b/fn_operations/docker_tui/views/keys.go new file mode 100644 index 00000000..2ed80d08 --- /dev/null +++ b/fn_operations/docker_tui/views/keys.go @@ -0,0 +1,14 @@ +package views + +// Navigation key constants. +const ( + KeyQuit = "ctrl+c" + KeyEsc = "esc" + KeyBack = "0" + KeyTab = "tab" +) + +// IsBack returns true if the key should trigger back navigation. +func IsBack(key string) bool { + return key == KeyEsc || key == KeyBack +} diff --git a/fn_operations/docker_tui/views/networks.go b/fn_operations/docker_tui/views/networks.go new file mode 100644 index 00000000..5c3aefac --- /dev/null +++ b/fn_operations/docker_tui/views/networks.go @@ -0,0 +1,123 @@ +package views + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lucasdataproyects/devfactory/tui" +) + +type networksState int + +const ( + networksLoading networksState = iota + networksList + networksAction +) + +type networksLoadedMsg []DockerNetwork +type networksActionMsg struct{ output string; err error } + +type NetworksModel struct { + state networksState + list tui.ListModel + spinner tui.SpinnerModel + styles tui.Styles + networks []DockerNetwork + err error +} + +func NewNetworksModel(styles tui.Styles) NetworksModel { + return NetworksModel{ + state: networksLoading, + list: tui.NewList(nil), + spinner: tui.NewSpinner("Loading networks..."), + styles: styles, + } +} + +func (m NetworksModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Init(), loadNetworks) +} + +func loadNetworks() tea.Msg { + networks, err := ListNetworks() + if err != nil { + return networksLoadedMsg(nil) + } + return networksLoadedMsg(networks) +} + +func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) { + switch msg := msg.(type) { + case networksLoadedMsg: + m.networks = []DockerNetwork(msg) + items := make([]tui.ListItem, len(m.networks)) + for i, n := range m.networks { + items[i] = tui.ListItem{ + Title: n.Name, + Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope), + Value: n, + } + } + m.list.SetItems(items) + m.state = networksList + return m, nil + + case networksActionMsg: + if msg.err != nil { + m.err = msg.err + } + m.state = networksList + return m, loadNetworks + + case tea.KeyMsg: + if m.state == networksList { + switch msg.String() { + case "r": + m.state = networksLoading + return m, tea.Batch(m.spinner.Init(), loadNetworks) + case "d", "delete": + if item := m.list.SelectedItem(); item != nil { + net := item.Value.(DockerNetwork) + m.state = networksAction + return m, func() tea.Msg { + err := RemoveNetwork(net.Name) + return networksActionMsg{output: "Removed", err: err} + } + } + } + } + } + + var cmd tea.Cmd + switch m.state { + case networksLoading, networksAction: + var model tea.Model + model, cmd = m.spinner.Update(msg) + m.spinner = model.(tui.SpinnerModel) + case networksList: + var model tea.Model + model, cmd = m.list.Update(msg) + m.list = model.(tui.ListModel) + } + return m, cmd +} + +func (m NetworksModel) View() string { + switch m.state { + case networksLoading, networksAction: + return m.spinner.View() + case networksList: + if len(m.networks) == 0 { + return m.styles.Muted.Render("No networks found. Press 'r' to refresh.") + } + help := m.styles.Muted.Render(" d: remove │ r: refresh") + view := m.list.View() + "\n" + help + if m.err != nil { + view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + } + return view + } + return "" +} diff --git a/fn_operations/docker_tui/views/types.go b/fn_operations/docker_tui/views/types.go new file mode 100644 index 00000000..c319c93e --- /dev/null +++ b/fn_operations/docker_tui/views/types.go @@ -0,0 +1,45 @@ +package views + +// DockerContainer represents a container from docker ps --format json. +type DockerContainer struct { + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + Status string `json:"Status"` + State string `json:"State"` + Ports string `json:"Ports"` +} + +// DockerImage represents an image from docker image ls --format json. +type DockerImage struct { + ID string `json:"ID"` + Repository string `json:"Repository"` + Tag string `json:"Tag"` + Size string `json:"Size"` + CreatedAt string `json:"CreatedAt"` +} + +// DockerVolume represents a volume from docker volume ls --format json. +type DockerVolume struct { + Name string `json:"Name"` + Driver string `json:"Driver"` + Mountpoint string `json:"Mountpoint"` +} + +// DockerNetwork represents a network from docker network ls --format json. +type DockerNetwork struct { + ID string `json:"ID"` + Name string `json:"Name"` + Driver string `json:"Driver"` + Scope string `json:"Scope"` +} + +// ComposeService represents a compose service from docker compose ps --format json. +type ComposeService struct { + ID string `json:"ID"` + Name string `json:"Name"` + Service string `json:"Service"` + State string `json:"State"` + Status string `json:"Status"` + Ports string `json:"Ports"` +} diff --git a/fn_operations/docker_tui/views/volumes.go b/fn_operations/docker_tui/views/volumes.go new file mode 100644 index 00000000..b55d4ec7 --- /dev/null +++ b/fn_operations/docker_tui/views/volumes.go @@ -0,0 +1,123 @@ +package views + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lucasdataproyects/devfactory/tui" +) + +type volumesState int + +const ( + volumesLoading volumesState = iota + volumesList + volumesAction +) + +type volumesLoadedMsg []DockerVolume +type volumesActionMsg struct{ output string; err error } + +type VolumesModel struct { + state volumesState + list tui.ListModel + spinner tui.SpinnerModel + styles tui.Styles + volumes []DockerVolume + err error +} + +func NewVolumesModel(styles tui.Styles) VolumesModel { + return VolumesModel{ + state: volumesLoading, + list: tui.NewList(nil), + spinner: tui.NewSpinner("Loading volumes..."), + styles: styles, + } +} + +func (m VolumesModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Init(), loadVolumes) +} + +func loadVolumes() tea.Msg { + volumes, err := ListVolumes() + if err != nil { + return volumesLoadedMsg(nil) + } + return volumesLoadedMsg(volumes) +} + +func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) { + switch msg := msg.(type) { + case volumesLoadedMsg: + m.volumes = []DockerVolume(msg) + items := make([]tui.ListItem, len(m.volumes)) + for i, v := range m.volumes { + items[i] = tui.ListItem{ + Title: v.Name, + Description: fmt.Sprintf("Driver: %s", v.Driver), + Value: v, + } + } + m.list.SetItems(items) + m.state = volumesList + return m, nil + + case volumesActionMsg: + if msg.err != nil { + m.err = msg.err + } + m.state = volumesList + return m, loadVolumes + + case tea.KeyMsg: + if m.state == volumesList { + switch msg.String() { + case "r": + m.state = volumesLoading + return m, tea.Batch(m.spinner.Init(), loadVolumes) + case "d", "delete": + if item := m.list.SelectedItem(); item != nil { + vol := item.Value.(DockerVolume) + m.state = volumesAction + return m, func() tea.Msg { + err := RemoveVolume(vol.Name) + return volumesActionMsg{output: "Removed", err: err} + } + } + } + } + } + + var cmd tea.Cmd + switch m.state { + case volumesLoading, volumesAction: + var model tea.Model + model, cmd = m.spinner.Update(msg) + m.spinner = model.(tui.SpinnerModel) + case volumesList: + var model tea.Model + model, cmd = m.list.Update(msg) + m.list = model.(tui.ListModel) + } + return m, cmd +} + +func (m VolumesModel) View() string { + switch m.state { + case volumesLoading, volumesAction: + return m.spinner.View() + case volumesList: + if len(m.volumes) == 0 { + return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.") + } + help := m.styles.Muted.Render(" d: remove │ r: refresh") + view := m.list.View() + "\n" + help + if m.err != nil { + view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + } + return view + } + return "" +} From 263791c229e8c8fec2c01892b9de5ed5c3a32472 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 04:38:08 +0100 Subject: [PATCH 5/5] =?UTF-8?q?feat:=205=20tipos=20Docker=20=E2=80=94=20co?= =?UTF-8?q?ntainer,=20image,=20volume,=20network,=20compose=5Fproject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tipos producto para modelar recursos Docker en el registry. Usados como type_ref en las entities de fn_operations. --- types/docker/compose_project.go | 8 ++++++++ types/docker/compose_project.md | 17 +++++++++++++++++ types/docker/container.go | 11 +++++++++++ types/docker/container.md | 20 ++++++++++++++++++++ types/docker/image.go | 10 ++++++++++ types/docker/image.md | 19 +++++++++++++++++++ types/docker/network.go | 9 +++++++++ types/docker/network.md | 18 ++++++++++++++++++ types/docker/volume.go | 8 ++++++++ types/docker/volume.md | 17 +++++++++++++++++ 10 files changed, 137 insertions(+) create mode 100644 types/docker/compose_project.go create mode 100644 types/docker/compose_project.md create mode 100644 types/docker/container.go create mode 100644 types/docker/container.md create mode 100644 types/docker/image.go create mode 100644 types/docker/image.md create mode 100644 types/docker/network.go create mode 100644 types/docker/network.md create mode 100644 types/docker/volume.go create mode 100644 types/docker/volume.md diff --git a/types/docker/compose_project.go b/types/docker/compose_project.go new file mode 100644 index 00000000..908ee8b8 --- /dev/null +++ b/types/docker/compose_project.go @@ -0,0 +1,8 @@ +package docker + +// ComposeProject representa un proyecto Docker Compose con sus servicios. +type ComposeProject struct { + Name string + ConfigFiles string + Services []string +} diff --git a/types/docker/compose_project.md b/types/docker/compose_project.md new file mode 100644 index 00000000..f090f0b2 --- /dev/null +++ b/types/docker/compose_project.md @@ -0,0 +1,17 @@ +--- +name: compose_project +lang: go +domain: docker +version: "1.0.0" +algebraic: product +definition: | + type ComposeProject struct { + Name string + ConfigFiles string + Services []string + } +description: "Proyecto Docker Compose con nombre, archivos de configuracion y lista de servicios." +tags: [docker, compose, infra, orchestration] +uses_types: [] +file_path: "types/docker/compose_project.go" +--- diff --git a/types/docker/container.go b/types/docker/container.go new file mode 100644 index 00000000..dd4a0e19 --- /dev/null +++ b/types/docker/container.go @@ -0,0 +1,11 @@ +package docker + +// Container representa un contenedor Docker con su estado y configuracion. +type Container struct { + ID string + Names string + Image string + Status string + State string + Ports string +} diff --git a/types/docker/container.md b/types/docker/container.md new file mode 100644 index 00000000..ab8f8227 --- /dev/null +++ b/types/docker/container.md @@ -0,0 +1,20 @@ +--- +name: container +lang: go +domain: docker +version: "1.0.0" +algebraic: product +definition: | + type Container struct { + ID string + Names string + Image string + Status string + State string + Ports string + } +description: "Contenedor Docker con ID, nombre, imagen, estado y puertos expuestos." +tags: [docker, container, infra] +uses_types: [] +file_path: "types/docker/container.go" +--- diff --git a/types/docker/image.go b/types/docker/image.go new file mode 100644 index 00000000..2ffcd991 --- /dev/null +++ b/types/docker/image.go @@ -0,0 +1,10 @@ +package docker + +// Image representa una imagen Docker con su repositorio, tag y tamaño. +type Image struct { + ID string + Repository string + Tag string + Size string + CreatedAt string +} diff --git a/types/docker/image.md b/types/docker/image.md new file mode 100644 index 00000000..accd72b7 --- /dev/null +++ b/types/docker/image.md @@ -0,0 +1,19 @@ +--- +name: image +lang: go +domain: docker +version: "1.0.0" +algebraic: product +definition: | + type Image struct { + ID string + Repository string + Tag string + Size string + CreatedAt string + } +description: "Imagen Docker con repositorio, tag, tamaño y fecha de creacion." +tags: [docker, image, infra] +uses_types: [] +file_path: "types/docker/image.go" +--- diff --git a/types/docker/network.go b/types/docker/network.go new file mode 100644 index 00000000..ea192c37 --- /dev/null +++ b/types/docker/network.go @@ -0,0 +1,9 @@ +package docker + +// Network representa una red Docker con nombre, driver y scope. +type Network struct { + ID string + Name string + Driver string + Scope string +} diff --git a/types/docker/network.md b/types/docker/network.md new file mode 100644 index 00000000..064515aa --- /dev/null +++ b/types/docker/network.md @@ -0,0 +1,18 @@ +--- +name: network +lang: go +domain: docker +version: "1.0.0" +algebraic: product +definition: | + type Network struct { + ID string + Name string + Driver string + Scope string + } +description: "Red Docker con nombre, driver y scope (local/global)." +tags: [docker, network, infra] +uses_types: [] +file_path: "types/docker/network.go" +--- diff --git a/types/docker/volume.go b/types/docker/volume.go new file mode 100644 index 00000000..de5d3443 --- /dev/null +++ b/types/docker/volume.go @@ -0,0 +1,8 @@ +package docker + +// Volume representa un volumen Docker con nombre, driver y punto de montaje. +type Volume struct { + Name string + Driver string + Mountpoint string +} diff --git a/types/docker/volume.md b/types/docker/volume.md new file mode 100644 index 00000000..a083d4a4 --- /dev/null +++ b/types/docker/volume.md @@ -0,0 +1,17 @@ +--- +name: volume +lang: go +domain: docker +version: "1.0.0" +algebraic: product +definition: | + type Volume struct { + Name string + Driver string + Mountpoint string + } +description: "Volumen Docker con nombre, driver y punto de montaje en el host." +tags: [docker, volume, storage, infra] +uses_types: [] +file_path: "types/docker/volume.go" +---