chore: sync from fn-registry agent
This commit is contained in:
@@ -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** (`<exe_dir>/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.
|
||||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 <subcomando> [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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+62
@@ -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)
|
||||||
|
}
|
||||||
+100
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user