From 615f27932373666e885262f3c0736921cecc5ff4 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 29 Mar 2026 17:31:03 +0200 Subject: [PATCH] feat: modelo Log y CRUD en fn_operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tipo Log con niveles debug/info/warn/error, source, entity_id y execution_id opcionales. Migración 003_logs.sql y funciones InsertLog, GetLog, ListLogs con filtros combinables. Co-Authored-By: Claude Opus 4.6 (1M context) --- fn_operations/migrations/003_logs.sql | 14 +++++ fn_operations/models.go | 22 +++++++ fn_operations/store.go | 89 +++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 fn_operations/migrations/003_logs.sql diff --git a/fn_operations/migrations/003_logs.sql b/fn_operations/migrations/003_logs.sql new file mode 100644 index 00000000..e62da887 --- /dev/null +++ b/fn_operations/migrations/003_logs.sql @@ -0,0 +1,14 @@ +CREATE TABLE logs ( + id TEXT PRIMARY KEY, + level TEXT NOT NULL DEFAULT 'info' CHECK(level IN ('debug','info','warn','error')), + source TEXT NOT NULL DEFAULT '', + entity_id TEXT NOT NULL DEFAULT '', + execution_id TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX idx_logs_level ON logs(level); +CREATE INDEX idx_logs_source ON logs(source); +CREATE INDEX idx_logs_created_at ON logs(created_at); diff --git a/fn_operations/models.go b/fn_operations/models.go index 3e1da45f..a951505d 100644 --- a/fn_operations/models.go +++ b/fn_operations/models.go @@ -145,6 +145,28 @@ type AssertionResult struct { EvaluatedAt time.Time `json:"evaluated_at"` } +// LogLevel represents the severity of a log entry. +type LogLevel string + +const ( + LogDebug LogLevel = "debug" + LogInfo LogLevel = "info" + LogWarn LogLevel = "warn" + LogError LogLevel = "error" +) + +// Log is a free-form operational event within a project context. +type Log struct { + ID string `json:"id"` + Level LogLevel `json:"level"` + Source string `json:"source"` // who: agent, pipeline, reactive_loop, function name... + EntityID string `json:"entity_id"` // optional context + ExecutionID string `json:"execution_id"` // optional context + Message string `json:"message"` + Metadata map[string]any `json:"metadata"` + CreatedAt time.Time `json:"created_at"` +} + // TypeSnapshot is an immutable copy of a registry type at point of use. type TypeSnapshot struct { ID string `json:"id"` diff --git a/fn_operations/store.go b/fn_operations/store.go index 592b8e7d..a2942c9f 100644 --- a/fn_operations/store.go +++ b/fn_operations/store.go @@ -814,3 +814,92 @@ func scanAssertionResults(rows *sql.Rows) ([]AssertionResult, error) { } return result, nil } + +// --- Log CRUD --- + +// InsertLog inserts a log entry. +func (db *DB) InsertLog(l *Log) error { + if l.CreatedAt.IsZero() { + l.CreatedAt = time.Now().UTC() + } + + _, err := db.conn.Exec(` + INSERT INTO logs (id, level, source, entity_id, execution_id, message, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + l.ID, string(l.Level), l.Source, l.EntityID, l.ExecutionID, + l.Message, marshalJSON(l.Metadata), l.CreatedAt.Format(time.RFC3339), + ) + return err +} + +// GetLog returns a log entry by ID. +func (db *DB) GetLog(id string) (*Log, error) { + row := db.conn.QueryRow(` + SELECT id, level, source, entity_id, execution_id, message, metadata, created_at + FROM logs WHERE id = ?`, id) + + var l Log + var metadataJSON, createdAt string + err := row.Scan(&l.ID, &l.Level, &l.Source, &l.EntityID, &l.ExecutionID, + &l.Message, &metadataJSON, &createdAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanning log: %w", err) + } + l.Metadata = unmarshalJSON(metadataJSON) + l.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return &l, nil +} + +// ListLogs returns logs filtered by level, source, entity, and/or execution. +func (db *DB) ListLogs(level LogLevel, source, entityID, executionID string, limit int) ([]Log, error) { + where := []string{} + args := []any{} + if level != "" { + where = append(where, "level = ?") + args = append(args, string(level)) + } + if source != "" { + where = append(where, "source = ?") + args = append(args, source) + } + if entityID != "" { + where = append(where, "entity_id = ?") + args = append(args, entityID) + } + if executionID != "" { + where = append(where, "execution_id = ?") + args = append(args, executionID) + } + + q := `SELECT id, level, source, entity_id, execution_id, message, metadata, created_at FROM logs` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY created_at DESC" + if limit > 0 { + q += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []Log + for rows.Next() { + var l Log + var metadataJSON, createdAt string + if err := rows.Scan(&l.ID, &l.Level, &l.Source, &l.EntityID, &l.ExecutionID, + &l.Message, &metadataJSON, &createdAt); err != nil { + return nil, fmt.Errorf("scanning log: %w", err) + } + l.Metadata = unmarshalJSON(metadataJSON) + l.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + result = append(result, l) + } + return result, nil +}