commit 5d1dabb1eb3df4b6bff645d176dd967b62fa04c9 Author: fn-registry agent Date: Thu May 14 00:28:13 2026 +0200 chore: sync from fn-registry agent diff --git a/app.md b/app.md new file mode 100644 index 0000000..ff82ef6 --- /dev/null +++ b/app.md @@ -0,0 +1,123 @@ +--- +name: call_monitor +lang: go +domain: infra +description: "Telemetria de invocaciones del agente al fn_registry. Persiste eventos (calls, code_writes, test_runs, e2e_runs_fn, violations, patterns, sessions) en su propia operations.db. Vista agregada function_stats por function_id alimenta el bucle reactivo (proposals automaticas). Issue 0085." +tags: [service, telemetry, monitoring, registry, sqlite] +uses_functions: + - sqlite_open_go_infra + - sqlite_apply_migrations_go_infra + - audit_copied_code_go_infra + - generate_proposals_from_telemetry_go_infra +uses_types: [] +framework: "stdlib" +entry_point: "main.go" +dir_path: "projects/fn_monitoring/apps/call_monitor" +repo_url: "" +e2e_checks: + - id: build + cmd: "CGO_ENABLED=1 go build -tags fts5 -o call_monitor ." + timeout_s: 60 + - id: init + cmd: "./call_monitor init --db /tmp/call_monitor_e2e.db" + expect_stdout_contains: "ready" + - id: status_empty + cmd: "./call_monitor status --db /tmp/call_monitor_e2e.db" + expect_stdout_contains: "no calls recorded yet" + - id: schema_view + cmd: "sqlite3 /tmp/call_monitor_e2e.db 'SELECT COUNT(*) FROM function_stats;'" + expect_stdout_contains: "0" +--- + +## Arquitectura + +App Go bajo modulo `fn-registry` (sin `go.mod` propio). Estructura: + +``` +projects/fn_monitoring/apps/call_monitor/ + app.md + main.go # CLI: init, status + db.go # openDB + apply migrations + queries + migrations/ + 001_init.sql # 7 tablas event-log + 002_function_stats_view.sql # vista agregada por function_id + operations.db # creada al primer init (gitignored) +``` + +Reusa `infra.SQLiteOpen` + `infra.ApplyMigrations` del registry (mismo patron que `apps/kanban/backend/db.go`). + +## Tablas event-log (append-only) + +| Tabla | Captura | +|---|---| +| `sessions` | Sesion Claude Code: session_id, cwd, started_at, ended_at, health_score, mcp_ratio | +| `calls` | Cada invocacion al registry (heredoc/mcp/fn_run): function_id, tool_used, duration_ms, success, error_class, args_hash | +| `code_writes` | Edit/Write sobre archivo del registry: function_id, file_path, lines_added/removed | +| `test_runs` | Unit tests: function_id, test_id, passed, duration_ms, output_snippet | +| `e2e_runs_fn` | E2E checks de apps que dependen: function_id, app_id, check_id, passed | +| `violations` | Antipatrones (sqlite3 inline, import *, heredoc reinvento): rule_id, function_id, severity | +| `patterns` | Heredocs clusterizados por similitud: pattern_hash, occurrences, session_ids[] | +| `function_versions` | Historial de versiones por function_id. source = `index` (poblado por `call_monitor snapshot` tras `fn index`), `edit_hook` (poblado por hook PostToolUse), `copy_detected` (futura fase 0085k) | + +Datos sensibles: solo `args_hash`, NUNCA argumentos concretos. + +## Vista `function_stats` + +Rollup por `function_id` con: + +- **Uso**: calls_total, calls_24h/7d/30d/90d, last_used_at +- **Errores**: errors_total, error_rate, last_error_ts +- **Performance**: mean_duration_ms (p95 pendiente — requires window functions o sub-query) +- **Codigo**: writes_count, last_write_at +- **Tests**: tests_total, tests_failed, test_fail_rate, last_test_failed_at +- **E2E**: e2e_total, e2e_failed, e2e_fail_rate, consumer_apps_count +- **Salud**: violations_caused + +Vista O(N) sobre tablas event-log. Si performance degrada en >100k filas, materializar como TABLE refrescada por cron. + +## Uso + +```bash +# Build +cd projects/fn_monitoring/apps/call_monitor +CGO_ENABLED=1 go build -tags fts5 -o call_monitor . + +# Crear/abrir BD (aplica migraciones idempotentemente) +./call_monitor init + +# Resumen actual +./call_monitor status --top 20 + +# Snapshot versions desde registry.db (idempotente, ejecutar tras cada fn index) +./call_monitor snapshot +./call_monitor snapshot --registry /home/lucas/fn_registry/registry.db + +# BD personalizada +./call_monitor init --db /tmp/test.db +./call_monitor status --db /tmp/test.db +``` + +## Integracion con el resto + +| Componente | Como interactua | +|---|---| +| Hook `PostToolUse` (0085b) | Parsea cada Bash + cada mcp__registry__* y hace `INSERT INTO calls/...` directo sobre operations.db | +| Wrapper Python `registry_telemetry` (0085c) | Patcha imports al activar `FN_TELEMETRY=1`, registra calls del heredoc | +| `registry_dashboard` (UI) | Lee via `sqlite_api`: nuevo datasource `ops:call_monitor`. Tab "Claude usage" con top funciones, huerfanas, patrones | +| `fn-mejorador` (fase 5 bucle reactivo) | Consulta `function_stats` + `patterns` + `violations` para generar proposals con evidencia trazable | +| `fn-orquestador` (issue 0069) | Usa `sessions.health_score` como criterio de exito adicional | + +## Roadmap + +- 0085a (este paso): schema + skeleton ✓ +- 0085b: hook PostToolUse Bash que insert en `calls`/`code_writes`/`violations` +- 0085c: wrapper Python con `FN_TELEMETRY=1` +- 0085d: anadir datasource a `sqlite_api` + tabs en `registry_dashboard` +- 0085e..h: clusterizacion, proposals automaticas, gating +- p95 en mean_duration_ms via percentile calc o ext. + +## Notas + +- BD vive **junto al binario** (`/operations.db`) por defecto, no en el cwd del agente. Hook puede pasar `--db` explicito si conviene. +- `operations.db` gitignored — telemetria es local por PC, no se sincroniza. +- Sin `repo_url`: aun no se ha hecho `gitea_create_repo`. Se inicializara con `/full-git-push` cuando este la fase 0085b lista para evitar repos vacios. diff --git a/call_monitor b/call_monitor new file mode 100755 index 0000000..aeaa8ce Binary files /dev/null and b/call_monitor differ diff --git a/copied.go b/copied.go new file mode 100644 index 0000000..0184088 --- /dev/null +++ b/copied.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "time" + + "fn-registry/functions/infra" +) + +// persistCopiedCode runs infra.AuditCopiedCode and INSERT OR IGNOREs results +// into the copied_code table. Returns (newly_inserted, total_detected, error). +func persistCopiedCode(callDB *DB, registryRoot string) (int, int, error) { + entries, err := infra.AuditCopiedCode(registryRoot) + if err != nil { + return 0, 0, fmt.Errorf("audit: %w", err) + } + now := time.Now().UTC().Unix() + tx, err := callDB.conn.Begin() + if err != nil { + return 0, 0, err + } + stmt, err := tx.Prepare(`INSERT OR IGNORE INTO copied_code + (app_file, app_function, registry_id, body_hash, similarity, kind, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + _ = tx.Rollback() + return 0, 0, err + } + defer stmt.Close() + inserted := 0 + for _, e := range entries { + res, err := stmt.Exec(e.AppFile, e.AppFunction, e.RegistryID, e.BodyHash, e.Similarity, e.Kind, now) + if err != nil { + _ = tx.Rollback() + return 0, 0, err + } + if n, _ := res.RowsAffected(); n > 0 { + inserted++ + } + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + return inserted, len(entries), nil +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..f66008b --- /dev/null +++ b/db.go @@ -0,0 +1,77 @@ +package main + +import ( + "database/sql" + "embed" + "fmt" + + "fn-registry/functions/infra" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type DB struct{ conn *sql.DB } + +func openDB(path string) (*DB, error) { + conn, err := infra.SQLiteOpen(path, "") + if err != nil { + return nil, err + } + if err := infra.ApplyMigrations(conn, migrationsFS, "migrations/*.sql"); err != nil { + conn.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return &DB{conn: conn}, nil +} + +func (d *DB) Close() error { return d.conn.Close() } + +type tableCount struct { + Name string + Rows int64 +} + +func (d *DB) tableCounts() ([]tableCount, error) { + tables := []string{"sessions", "calls", "code_writes", "test_runs", "e2e_runs_fn", "violations", "patterns", "function_versions", "copied_code"} + out := make([]tableCount, 0, len(tables)) + for _, t := range tables { + var n int64 + if err := d.conn.QueryRow("SELECT COUNT(*) FROM " + t).Scan(&n); err != nil { + return nil, fmt.Errorf("count %s: %w", t, err) + } + out = append(out, tableCount{Name: t, Rows: n}) + } + return out, nil +} + +type funcStat struct { + FunctionID string + CallsTotal int64 + Calls7d int64 + ErrorsTotal int64 + ErrorRate float64 + MeanDurationMs float64 + LastUsedAt sql.NullInt64 +} + +func (d *DB) topFunctions(limit int) ([]funcStat, error) { + rows, err := d.conn.Query(` + SELECT function_id, calls_total, calls_7d, errors_total, error_rate, mean_duration_ms, last_used_at + FROM function_stats + ORDER BY calls_total DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []funcStat + for rows.Next() { + var s funcStat + if err := rows.Scan(&s.FunctionID, &s.CallsTotal, &s.Calls7d, &s.ErrorsTotal, &s.ErrorRate, &s.MeanDurationMs, &s.LastUsedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5aee331 --- /dev/null +++ b/main.go @@ -0,0 +1,235 @@ +// call_monitor: telemetria de invocaciones del agente al fn_registry. +// Issue 0085. Persiste eventos en operations.db local. Hook PostToolUse (0085b) +// y wrapper Python (0085c) escriben aqui. registry_dashboard lee via sqlite_api. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "text/tabwriter" +) + +const defaultDBName = "operations.db" + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + sub := os.Args[1] + + fs := flag.NewFlagSet(sub, flag.ExitOnError) + dbPath := fs.String("db", "", "Path to operations.db (default: ./operations.db relative to this binary's directory).") + + switch sub { + case "init": + fs.Parse(os.Args[2:]) + runInit(resolveDB(*dbPath)) + case "status": + topN := fs.Int("top", 10, "Top N functions to list.") + fs.Parse(os.Args[2:]) + runStatus(resolveDB(*dbPath), *topN) + case "snapshot": + registry := fs.String("registry", "", "Path to registry.db (default: walk up from cwd until found).") + fs.Parse(os.Args[2:]) + runSnapshot(resolveDB(*dbPath), *registry) + case "copied-code": + root := fs.String("root", "", "Path to fn_registry root (default: walk up from cwd until registry.db found).") + fs.Parse(os.Args[2:]) + runCopiedCode(resolveDB(*dbPath), *root) + case "propose": + root := fs.String("root", "", "Path to fn_registry root (default: walk up from cwd).") + dry := fs.Bool("dry-run", false, "Generate drafts without persisting to registry.db.proposals.") + fs.Parse(os.Args[2:]) + runPropose(*root, *dry) + case "-h", "--help", "help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n\n", sub) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `call_monitor — telemetria de invocaciones del agente al fn_registry + +USO: + call_monitor [flags] + +SUBCOMANDOS: + init Crea/abre operations.db y aplica migraciones (idempotente). + status Resumen: conteo de filas por tabla + top funciones por calls_total. + snapshot Lee registry.db.functions y snapshotea (function_id, content_hash) en + function_versions con source='index'. Idempotente: solo inserta nuevas tuplas. + +FLAGS GLOBALES: + --db PATH Ruta a operations.db (default: ./operations.db junto al binario). + --registry PATH (subcomando snapshot) Ruta a registry.db. Default: walk up. + +EJEMPLOS: + call_monitor init + call_monitor status --top 20 + call_monitor snapshot + call_monitor snapshot --registry /home/lucas/fn_registry/registry.db`) +} + +func resolveRegistryDB(override string) string { + if override != "" { + return override + } + exe, err := os.Executable() + if err != nil { + return "registry.db" + } + dir := filepath.Dir(exe) + for { + candidate := filepath.Join(dir, "registry.db") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "registry.db" +} + +func resolveRegistryRoot(override string) string { + if override != "" { + return override + } + exe, err := os.Executable() + if err != nil { + return "." + } + dir := filepath.Dir(exe) + for { + if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "." +} + +func runCopiedCode(callDBPath, rootOverride string) { + db, err := openDB(callDBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: open call_monitor db: %v\n", err) + os.Exit(1) + } + defer db.Close() + + root := resolveRegistryRoot(rootOverride) + if _, err := os.Stat(filepath.Join(root, "registry.db")); err != nil { + fmt.Fprintf(os.Stderr, "error: registry.db not found under %s\n", root) + os.Exit(1) + } + + inserted, total, err := persistCopiedCode(db, root) + if err != nil { + fmt.Fprintf(os.Stderr, "error: copied-code: %v\n", err) + os.Exit(1) + } + fmt.Printf("copied-code: %d total match(es) detected, %d newly inserted into copied_code.\n", total, inserted) +} + +func runSnapshot(callDBPath, registryOverride string) { + db, err := openDB(callDBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: open call_monitor db: %v\n", err) + os.Exit(1) + } + defer db.Close() + + registryPath := resolveRegistryDB(registryOverride) + if _, err := os.Stat(registryPath); err != nil { + fmt.Fprintf(os.Stderr, "error: registry.db not found at %s\n", registryPath) + os.Exit(1) + } + + inserted, seen, err := snapshotFromRegistry(db, registryPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: snapshot: %v\n", err) + os.Exit(1) + } + fmt.Printf("snapshot: %d new versions inserted (out of %d functions seen with content_hash)\n", inserted, seen) +} + +func resolveDB(override string) string { + if override != "" { + return override + } + exe, err := os.Executable() + if err == nil { + return filepath.Join(filepath.Dir(exe), defaultDBName) + } + return defaultDBName +} + +func runInit(path string) { + db, err := openDB(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer db.Close() + abs, _ := filepath.Abs(path) + fmt.Printf("call_monitor.operations.db ready: %s\n", abs) +} + +func runStatus(path string, topN int) { + db, err := openDB(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer db.Close() + + counts, err := db.tableCounts() + if err != nil { + fmt.Fprintf(os.Stderr, "table counts: %v\n", err) + os.Exit(1) + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "=== Tables ===") + fmt.Fprintln(tw, "TABLE\tROWS") + for _, c := range counts { + fmt.Fprintf(tw, "%s\t%d\n", c.Name, c.Rows) + } + tw.Flush() + + top, err := db.topFunctions(topN) + if err != nil { + fmt.Fprintf(os.Stderr, "top functions: %v\n", err) + os.Exit(1) + } + fmt.Println() + tw = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "=== Top %d functions (by calls_total) ===\n", topN) + if len(top) == 0 { + fmt.Fprintln(tw, "(no calls recorded yet)") + tw.Flush() + return + } + fmt.Fprintln(tw, "FUNCTION_ID\tCALLS\tCALLS_7D\tERRORS\tERROR_RATE\tMEAN_MS\tLAST_USED_TS") + for _, s := range top { + last := "" + if s.LastUsedAt.Valid { + last = fmt.Sprintf("%d", s.LastUsedAt.Int64) + } + fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%.2f\t%.0f\t%s\n", + s.FunctionID, s.CallsTotal, s.Calls7d, s.ErrorsTotal, s.ErrorRate, s.MeanDurationMs, last) + } + tw.Flush() +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..d17ddd3 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,113 @@ +-- call_monitor schema v1.0.0 +-- Event-log de invocaciones del agente al registry + telemetria asociada. +-- Issue 0085. Aditivo. Aplicado via embed.FS al abrir operations.db. + +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + +-- Sesiones Claude Code. Una por arranque de claude-code. +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + cwd TEXT NOT NULL DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + health_score REAL, + mcp_ratio REAL, + notes TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); + +-- Cada invocacion del agente sobre funcion del registry (heredoc/mcp/fn_run). +CREATE TABLE IF NOT EXISTS calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL DEFAULT '', + function_id TEXT NOT NULL DEFAULT '', + tool_used TEXT NOT NULL, + args_hash TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + error_class TEXT NOT NULL DEFAULT '', + error_snippet TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_calls_function ON calls(function_id); +CREATE INDEX IF NOT EXISTS idx_calls_session ON calls(session_id); +CREATE INDEX IF NOT EXISTS idx_calls_ts ON calls(ts); +CREATE INDEX IF NOT EXISTS idx_calls_tool ON calls(tool_used); +CREATE INDEX IF NOT EXISTS idx_calls_success ON calls(success); + +-- Edit/Write del agente sobre archivos del registry. +CREATE TABLE IF NOT EXISTS code_writes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL DEFAULT '', + function_id TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + lines_added INTEGER NOT NULL DEFAULT 0, + lines_removed INTEGER NOT NULL DEFAULT 0, + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_code_writes_function ON code_writes(function_id); +CREATE INDEX IF NOT EXISTS idx_code_writes_ts ON code_writes(ts); + +-- Test run de unit tests del registry (go test / pytest / etc.). +CREATE TABLE IF NOT EXISTS test_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL DEFAULT '', + function_id TEXT NOT NULL DEFAULT '', + test_id TEXT NOT NULL DEFAULT '', + passed INTEGER NOT NULL DEFAULT 1, + duration_ms INTEGER NOT NULL DEFAULT 0, + output_snippet TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_test_runs_function ON test_runs(function_id); +CREATE INDEX IF NOT EXISTS idx_test_runs_passed ON test_runs(passed); +CREATE INDEX IF NOT EXISTS idx_test_runs_ts ON test_runs(ts); + +-- E2E checks de apps que dependen de una funcion del registry. +CREATE TABLE IF NOT EXISTS e2e_runs_fn ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL DEFAULT '', + function_id TEXT NOT NULL, + app_id TEXT NOT NULL, + check_id TEXT NOT NULL, + passed INTEGER NOT NULL DEFAULT 1, + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_e2e_function ON e2e_runs_fn(function_id); +CREATE INDEX IF NOT EXISTS idx_e2e_app ON e2e_runs_fn(app_id); +CREATE INDEX IF NOT EXISTS idx_e2e_passed ON e2e_runs_fn(passed); + +-- Antipatrones detectados (sqlite3 inline, import *, heredoc reescribiendo, etc.). +CREATE TABLE IF NOT EXISTS violations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL DEFAULT '', + rule_id TEXT NOT NULL, + function_id TEXT NOT NULL DEFAULT '', + command_snippet TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT 'warning', + ts INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_violations_rule ON violations(rule_id); +CREATE INDEX IF NOT EXISTS idx_violations_function ON violations(function_id); +CREATE INDEX IF NOT EXISTS idx_violations_severity ON violations(severity); + +-- Heredocs/snippets clusterizados por similitud (alimenta proposals new_function). +CREATE TABLE IF NOT EXISTS patterns ( + pattern_hash TEXT PRIMARY KEY, + representative_snippet TEXT NOT NULL, + occurrences INTEGER NOT NULL DEFAULT 1, + session_ids_json TEXT NOT NULL DEFAULT '[]', + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + proposal_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_patterns_occurrences ON patterns(occurrences); +CREATE INDEX IF NOT EXISTS idx_patterns_last_seen ON patterns(last_seen); diff --git a/migrations/002_function_stats_view.sql b/migrations/002_function_stats_view.sql new file mode 100644 index 0000000..1da55fc --- /dev/null +++ b/migrations/002_function_stats_view.sql @@ -0,0 +1,100 @@ +-- Vista agregada por funcion del registry. Lectura O(N) sobre tablas event-log. +-- Si performance degrada, materializar como TABLE refrescada por cron. + +DROP VIEW IF EXISTS function_stats; + +CREATE VIEW function_stats AS +WITH +call_agg AS ( + SELECT + function_id, + COUNT(*) AS calls_total, + SUM(CASE WHEN ts >= CAST(strftime('%s','now','-1 day') AS INTEGER) THEN 1 ELSE 0 END) AS calls_24h, + SUM(CASE WHEN ts >= CAST(strftime('%s','now','-7 days') AS INTEGER) THEN 1 ELSE 0 END) AS calls_7d, + SUM(CASE WHEN ts >= CAST(strftime('%s','now','-30 days') AS INTEGER) THEN 1 ELSE 0 END) AS calls_30d, + SUM(CASE WHEN ts >= CAST(strftime('%s','now','-90 days') AS INTEGER) THEN 1 ELSE 0 END) AS calls_90d, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS errors_total, + AVG(duration_ms) AS mean_duration_ms, + MAX(ts) AS last_used_at, + MAX(CASE WHEN success = 0 THEN ts ELSE 0 END) AS last_error_ts + FROM calls + WHERE function_id != '' + GROUP BY function_id +), +write_agg AS ( + SELECT function_id, COUNT(*) AS writes_count, MAX(ts) AS last_write_at + FROM code_writes + WHERE function_id != '' + GROUP BY function_id +), +test_agg AS ( + SELECT + function_id, + COUNT(*) AS tests_total, + SUM(CASE WHEN passed = 0 THEN 1 ELSE 0 END) AS tests_failed, + MAX(CASE WHEN passed = 0 THEN ts ELSE 0 END) AS last_test_failed_at + FROM test_runs + WHERE function_id != '' + GROUP BY function_id +), +e2e_agg AS ( + SELECT + function_id, + COUNT(*) AS e2e_total, + SUM(CASE WHEN passed = 0 THEN 1 ELSE 0 END) AS e2e_failed, + COUNT(DISTINCT app_id) AS consumer_apps_count + FROM e2e_runs_fn + GROUP BY function_id +), +viol_agg AS ( + SELECT function_id, COUNT(*) AS violations_caused + FROM violations + WHERE function_id != '' + GROUP BY function_id +), +all_fns AS ( + SELECT function_id FROM call_agg + UNION + SELECT function_id FROM write_agg + UNION + SELECT function_id FROM test_agg + UNION + SELECT function_id FROM e2e_agg + UNION + SELECT function_id FROM viol_agg +) +SELECT + f.function_id, + COALESCE(c.calls_total, 0) AS calls_total, + COALESCE(c.calls_24h, 0) AS calls_24h, + COALESCE(c.calls_7d, 0) AS calls_7d, + COALESCE(c.calls_30d, 0) AS calls_30d, + COALESCE(c.calls_90d, 0) AS calls_90d, + COALESCE(c.errors_total, 0) AS errors_total, + CASE WHEN COALESCE(c.calls_total, 0) > 0 + THEN CAST(c.errors_total AS REAL) / c.calls_total + ELSE 0 END AS error_rate, + COALESCE(c.mean_duration_ms, 0) AS mean_duration_ms, + c.last_used_at, + CASE WHEN c.last_error_ts > 0 THEN c.last_error_ts END AS last_error_ts, + COALESCE(w.writes_count, 0) AS writes_count, + w.last_write_at, + COALESCE(t.tests_total, 0) AS tests_total, + COALESCE(t.tests_failed, 0) AS tests_failed, + CASE WHEN COALESCE(t.tests_total, 0) > 0 + THEN CAST(t.tests_failed AS REAL) / t.tests_total + ELSE 0 END AS test_fail_rate, + CASE WHEN t.last_test_failed_at > 0 THEN t.last_test_failed_at END AS last_test_failed_at, + COALESCE(e.e2e_total, 0) AS e2e_total, + COALESCE(e.e2e_failed, 0) AS e2e_failed, + CASE WHEN COALESCE(e.e2e_total, 0) > 0 + THEN CAST(e.e2e_failed AS REAL) / e.e2e_total + ELSE 0 END AS e2e_fail_rate, + COALESCE(e.consumer_apps_count, 0) AS consumer_apps_count, + COALESCE(v.violations_caused, 0) AS violations_caused +FROM all_fns f +LEFT JOIN call_agg c ON c.function_id = f.function_id +LEFT JOIN write_agg w ON w.function_id = f.function_id +LEFT JOIN test_agg t ON t.function_id = f.function_id +LEFT JOIN e2e_agg e ON e.function_id = f.function_id +LEFT JOIN viol_agg v ON v.function_id = f.function_id; diff --git a/migrations/003_function_versions.sql b/migrations/003_function_versions.sql new file mode 100644 index 0000000..515d816 --- /dev/null +++ b/migrations/003_function_versions.sql @@ -0,0 +1,23 @@ +-- function_versions: historial de versiones por function_id. +-- Fuente principal = `call_monitor snapshot` que lee registry.db.functions.content_hash +-- tras cada `fn index`. Edit-hook tambien anota cambios con sha256 del archivo. +-- copy_detected = source de la fase 0085k (fn doctor copied-code). +-- +-- PK incluye source para permitir que cada source mantenga su propio espacio de +-- hashes (el sha256 del archivo NO coincide con el content_hash canonical del +-- registry.db, que incluye metadata adicional). + +CREATE TABLE IF NOT EXISTS function_versions ( + function_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + snapped_at INTEGER NOT NULL, + source TEXT NOT NULL, + lines_added INTEGER NOT NULL DEFAULT 0, + lines_removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (function_id, content_hash, source) +); + +CREATE INDEX IF NOT EXISTS idx_fn_versions_function ON function_versions(function_id); +CREATE INDEX IF NOT EXISTS idx_fn_versions_snapped_at ON function_versions(snapped_at); +CREATE INDEX IF NOT EXISTS idx_fn_versions_source ON function_versions(source); diff --git a/migrations/004_copied_code.sql b/migrations/004_copied_code.sql new file mode 100644 index 0000000..f36df21 --- /dev/null +++ b/migrations/004_copied_code.sql @@ -0,0 +1,20 @@ +-- copied_code: matches de cuerpos de funcion del registry encontrados en apps. +-- Poblado por `call_monitor copied-code` que invoca infra.AuditCopiedCode. +-- Cada fila representa un (app_file, app_function, registry_id) sospechoso. +-- UNIQUE incluye body_hash para que solo se inserte una vez por (path,fn,id,hash). + +CREATE TABLE IF NOT EXISTS copied_code ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_file TEXT NOT NULL, + app_function TEXT NOT NULL, + registry_id TEXT NOT NULL, + body_hash TEXT NOT NULL, + similarity REAL NOT NULL DEFAULT 1.0, + kind TEXT NOT NULL, + detected_at INTEGER NOT NULL, + UNIQUE(app_file, app_function, registry_id, body_hash) +); + +CREATE INDEX IF NOT EXISTS idx_copied_registry ON copied_code(registry_id); +CREATE INDEX IF NOT EXISTS idx_copied_kind ON copied_code(kind); +CREATE INDEX IF NOT EXISTS idx_copied_detected ON copied_code(detected_at); diff --git a/migrations/005_session_capability_growth.sql b/migrations/005_session_capability_growth.sql new file mode 100644 index 0000000..36c5ae5 --- /dev/null +++ b/migrations/005_session_capability_growth.sql @@ -0,0 +1,37 @@ +-- session_capability_growth: vista que mide el ciclo "crear+usar" por sesion. +-- Una funcion se considera "creada en sesion X" si el primer code_writes +-- registrado para ese function_id (a nivel global) ocurrio dentro de la sesion X. +-- Una funcion "usada en sesion X" = >=1 fila en calls con misma sesion y ts >= created_at. +-- +-- Lectura: created_this_session = COUNT(*) WHERE session_id = ? +-- used = COUNT(*) WHERE session_id = ? AND calls_in_session > 0 +-- orphan = COUNT(*) WHERE session_id = ? AND calls_in_session = 0 +-- +-- Issue 0086. Aditivo. No reescribe schema previo. + +CREATE VIEW IF NOT EXISTS session_capability_growth AS +WITH first_write AS ( + SELECT + cw.function_id, + cw.session_id, + cw.ts AS created_at + FROM code_writes cw + WHERE cw.function_id != '' + AND cw.ts = ( + SELECT MIN(ts) FROM code_writes + WHERE function_id = cw.function_id + ) +) +SELECT + fw.session_id, + fw.function_id, + fw.created_at, + (SELECT MIN(c.ts) FROM calls c + WHERE c.function_id = fw.function_id + AND c.session_id = fw.session_id + AND c.ts >= fw.created_at) AS first_call_at, + (SELECT COUNT(*) FROM calls c + WHERE c.function_id = fw.function_id + AND c.session_id = fw.session_id + AND c.ts >= fw.created_at) AS calls_in_session +FROM first_write fw; diff --git a/operations.db b/operations.db new file mode 100644 index 0000000..98c88fc Binary files /dev/null and b/operations.db differ diff --git a/operations.db-shm b/operations.db-shm new file mode 100644 index 0000000..e942256 Binary files /dev/null and b/operations.db-shm differ diff --git a/operations.db-wal b/operations.db-wal new file mode 100644 index 0000000..9349c39 Binary files /dev/null and b/operations.db-wal differ diff --git a/propose.go b/propose.go new file mode 100644 index 0000000..b2f9caf --- /dev/null +++ b/propose.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "os" + + "fn-registry/functions/infra" +) + +// runPropose invokes the proposal generator pipeline and persists drafts +// into registry.db.proposals (INSERT OR IGNORE — never overwrites reviewed). +func runPropose(rootOverride string, dryRun bool) { + root := resolveRegistryRoot(rootOverride) + if _, err := os.Stat(rootOverride + "/registry.db"); err != nil && rootOverride != "" { + // fall back to walk-up + root = resolveRegistryRoot("") + } + if _, err := os.Stat(root + "/registry.db"); err != nil { + fmt.Fprintf(os.Stderr, "error: registry.db not found under %s\n", root) + os.Exit(1) + } + + drafts, err := infra.GenerateProposalsFromTelemetry(root) + if err != nil { + fmt.Fprintf(os.Stderr, "error: generate: %v\n", err) + os.Exit(1) + } + + if len(drafts) == 0 { + fmt.Println("propose: no proposal drafts generated (insufficient telemetry or all rules pass).") + return + } + + // Group by rule for the summary + byRule := map[string]int{} + for _, d := range drafts { + byRule[d.RuleID]++ + } + + fmt.Printf("propose: %d draft(s) generated\n", len(drafts)) + for rule, n := range byRule { + fmt.Printf(" %s: %d\n", rule, n) + } + + if dryRun { + fmt.Println("\nDRY RUN — drafts NOT persisted. Sample (first 5):") + for i, d := range drafts { + if i >= 5 { + break + } + fmt.Printf(" [%s] %s → %s\n", d.RuleID, d.ID, d.Title) + } + return + } + + inserted, total, err := infra.PersistProposalDrafts(root, drafts) + if err != nil { + fmt.Fprintf(os.Stderr, "error: persist: %v\n", err) + os.Exit(1) + } + fmt.Printf("\npersisted: %d new, %d total (idempotent — existing IDs skipped)\n", inserted, total) +} diff --git a/snapshot.go b/snapshot.go new file mode 100644 index 0000000..b64848a --- /dev/null +++ b/snapshot.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "time" + + "fn-registry/functions/infra" +) + +// snapshotFromRegistry reads registry.db.functions and inserts one row in +// function_versions per (function_id, content_hash) tuple with source='index'. +// Duplicate rows (same hash for same function from the same source) are +// silently ignored — so this can be re-run after every `fn index` to capture +// only NEW versions. +// +// Returns (inserted_rows, total_seen, error). +func snapshotFromRegistry(callDB *DB, registryPath string) (int, int, error) { + rconn, err := infra.SQLiteOpen(registryPath, "") + if err != nil { + return 0, 0, fmt.Errorf("open registry: %w", err) + } + defer rconn.Close() + + rows, err := rconn.Query("SELECT id, content_hash, version FROM functions WHERE content_hash != ''") + if err != nil { + return 0, 0, fmt.Errorf("query functions: %w", err) + } + defer rows.Close() + + now := time.Now().UTC().Unix() + tx, err := callDB.conn.Begin() + if err != nil { + return 0, 0, err + } + stmt, err := tx.Prepare(`INSERT OR IGNORE INTO function_versions + (function_id, content_hash, version, snapped_at, source, lines_added, lines_removed) + VALUES (?, ?, ?, ?, 'index', 0, 0)`) + if err != nil { + _ = tx.Rollback() + return 0, 0, err + } + defer stmt.Close() + + inserted, seen := 0, 0 + for rows.Next() { + var id, hash, version string + if err := rows.Scan(&id, &hash, &version); err != nil { + _ = tx.Rollback() + return 0, 0, err + } + seen++ + res, err := stmt.Exec(id, hash, version, now) + if err != nil { + _ = tx.Rollback() + return 0, 0, err + } + if n, _ := res.RowsAffected(); n > 0 { + inserted++ + } + } + if err := rows.Err(); err != nil { + _ = tx.Rollback() + return 0, 0, err + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + return inserted, seen, nil +} + +// versionsSummary returns aggregate stats per function_id from function_versions. +func (d *DB) versionsSummary(limit int) ([]funcVersionSummary, error) { + rows, err := d.conn.Query(` + SELECT function_id, + COUNT(DISTINCT content_hash) AS versions, + MAX(snapped_at) AS last_snapped_at + FROM function_versions + GROUP BY function_id + ORDER BY versions DESC, last_snapped_at DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []funcVersionSummary + for rows.Next() { + var s funcVersionSummary + if err := rows.Scan(&s.FunctionID, &s.Versions, &s.LastSnappedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +type funcVersionSummary struct { + FunctionID string + Versions int64 + LastSnappedAt int64 +}