From 8d98faccd901497665083a824890b3ac28e410cc Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 17:13:24 +0100 Subject: [PATCH] feat: proposals en registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade sistema de proposals al registry: modelos (ProposalKind, ProposalStatus), CRUD completo (Insert/Get/Update/Delete/List/Search con FTS), validación, migración 002_proposals.sql y subcomando CLI fn proposal (add/list/show/update). Motor de migraciones con embed.FS reemplaza schema estático. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/fn/proposal.go | 265 ++++++++++++++++++++++++++ registry/db.go | 111 +---------- registry/migrate.go | 117 ++++++++++++ registry/migrations/001_init.sql | 106 +++++++++++ registry/migrations/002_proposals.sql | 42 ++++ registry/models.go | 36 ++++ registry/store.go | 167 ++++++++++++++++ registry/store_test.go | 132 +++++++++++++ registry/validate.go | 35 ++++ 9 files changed, 902 insertions(+), 109 deletions(-) create mode 100644 cmd/fn/proposal.go create mode 100644 registry/migrate.go create mode 100644 registry/migrations/001_init.sql create mode 100644 registry/migrations/002_proposals.sql diff --git a/cmd/fn/proposal.go b/cmd/fn/proposal.go new file mode 100644 index 00000000..da185ff6 --- /dev/null +++ b/cmd/fn/proposal.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "fn-registry/registry" +) + +func cmdProposal(args []string) { + if len(args) < 1 { + printProposalUsage() + os.Exit(1) + } + + switch args[0] { + case "add": + cmdProposalAdd(args[1:]) + case "list": + cmdProposalList(args[1:]) + case "show": + cmdProposalShow(args[1:]) + case "update": + cmdProposalUpdate(args[1:]) + case "help", "-h", "--help": + printProposalUsage() + default: + fmt.Fprintf(os.Stderr, "unknown proposal command: %s\n", args[0]) + printProposalUsage() + os.Exit(1) + } +} + +func printProposalUsage() { + fmt.Println(`fn proposal — gestiona proposals + +Usage: + fn proposal add --kind --title [options] + fn proposal list [-k kind] [-s status] + fn proposal show <id> + fn proposal update <id> --status <status> [--reviewed-by <who>] + +Kinds: new_function, new_type, improve_function, improve_type, new_pipeline +Status: pending, approved, rejected, implemented`) +} + +func cmdProposalAdd(args []string) { + var id, kind, targetID, title, description, evidenceStr, createdBy string + i := 0 + for i < len(args) { + switch args[i] { + case "--id": + i++ + id = args[i] + case "--kind": + i++ + kind = args[i] + case "--target-id": + i++ + targetID = args[i] + case "--title": + i++ + title = args[i] + case "--description": + i++ + description = args[i] + case "--evidence": + i++ + evidenceStr = args[i] + case "--created-by": + i++ + createdBy = args[i] + } + i++ + } + + if kind == "" || title == "" { + fmt.Fprintln(os.Stderr, "error: --kind and --title are required") + os.Exit(1) + } + + if id == "" { + id = fmt.Sprintf("proposal_%d", time.Now().UnixNano()) + } + + var evidence map[string]any + if evidenceStr != "" { + if err := json.Unmarshal([]byte(evidenceStr), &evidence); err != nil { + fmt.Fprintf(os.Stderr, "error: invalid evidence JSON: %v\n", err) + os.Exit(1) + } + } + + p := ®istry.Proposal{ + ID: id, + Kind: registry.ProposalKind(kind), + TargetID: targetID, + Title: title, + Description: description, + Evidence: evidence, + Status: registry.ProposalPending, + CreatedBy: createdBy, + } + + if err := registry.ValidateProposal(p); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + db := openDB() + defer db.Close() + + if err := db.InsertProposal(p); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Created proposal: %s\n", p.ID) +} + +func cmdProposalList(args []string) { + var kind, status string + i := 0 + for i < len(args) { + switch args[i] { + case "-k": + i++ + kind = args[i] + case "-s": + i++ + status = args[i] + } + i++ + } + + db := openDB() + defer db.Close() + + proposals, err := db.ListProposals(registry.ProposalKind(kind), registry.ProposalStatus(status)) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(proposals) == 0 { + fmt.Println("No proposals.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tKIND\tSTATUS\tTITLE\tCREATED_BY") + for _, p := range proposals { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Kind, p.Status, truncate(p.Title, 40), p.CreatedBy) + } + w.Flush() +} + +func cmdProposalShow(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn proposal show <id>") + os.Exit(1) + } + + db := openDB() + defer db.Close() + + p, err := db.GetProposal(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("ID: %s\n", p.ID) + fmt.Printf("Kind: %s\n", p.Kind) + fmt.Printf("Status: %s\n", p.Status) + fmt.Printf("Title: %s\n", p.Title) + fmt.Printf("Description: %s\n", p.Description) + if p.TargetID != "" { + fmt.Printf("Target ID: %s\n", p.TargetID) + } + if len(p.Evidence) > 0 { + ev, _ := json.MarshalIndent(p.Evidence, " ", " ") + fmt.Printf("Evidence: %s\n", string(ev)) + } + fmt.Printf("Created by: %s\n", p.CreatedBy) + if p.ReviewedBy != "" { + fmt.Printf("Reviewed by: %s\n", p.ReviewedBy) + } + fmt.Printf("Created: %s\n", p.CreatedAt.Format(time.RFC3339)) + fmt.Printf("Updated: %s\n", p.UpdatedAt.Format(time.RFC3339)) +} + +func cmdProposalUpdate(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn proposal update <id> --status <status> [--reviewed-by <who>]") + os.Exit(1) + } + + id := args[0] + var status, reviewedBy string + i := 1 + for i < len(args) { + switch args[i] { + case "--status": + i++ + status = args[i] + case "--reviewed-by": + i++ + reviewedBy = args[i] + } + i++ + } + + db := openDB() + defer db.Close() + + p, err := db.GetProposal(id) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if status != "" { + p.Status = registry.ProposalStatus(status) + } + if reviewedBy != "" { + p.ReviewedBy = reviewedBy + } + + // Validate updated proposal + validKinds := map[string]bool{ + "pending": true, "approved": true, "rejected": true, "implemented": true, + } + if status != "" && !validKinds[status] { + fmt.Fprintf(os.Stderr, "error: invalid status %q\n", status) + os.Exit(1) + } + + if err := db.UpdateProposal(p); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Updated proposal: %s (status: %s)\n", p.ID, p.Status) +} + +func formatEvidence(evidence map[string]any) string { + if len(evidence) == 0 { + return "{}" + } + b, _ := json.MarshalIndent(evidence, "", " ") + return string(b) +} + +// formatStrings joins a slice for display, handling nil/empty. +func formatStrings(ss []string) string { + if len(ss) == 0 { + return "" + } + return strings.Join(ss, ", ") +} diff --git a/registry/db.go b/registry/db.go index d902ec72..d7f9586f 100644 --- a/registry/db.go +++ b/registry/db.go @@ -9,112 +9,6 @@ import ( _ "github.com/mattn/go-sqlite3" ) -const schemaSQL = ` -CREATE TABLE IF NOT EXISTS functions ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')), - lang TEXT NOT NULL, - domain TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '1.0.0', - purity TEXT NOT NULL CHECK(purity IN ('pure','impure')), - signature TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL, - tags TEXT NOT NULL DEFAULT '[]', - uses_functions TEXT NOT NULL DEFAULT '[]', - uses_types TEXT NOT NULL DEFAULT '[]', - returns TEXT NOT NULL DEFAULT '[]', - returns_optional INTEGER NOT NULL DEFAULT 0, - error_type TEXT NOT NULL DEFAULT '', - imports TEXT NOT NULL DEFAULT '[]', - example TEXT NOT NULL DEFAULT '', - tested INTEGER NOT NULL DEFAULT 0, - tests TEXT NOT NULL DEFAULT '[]', - test_file_path TEXT NOT NULL DEFAULT '', - file_path TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - -- Component fields - props TEXT NOT NULL DEFAULT '[]', - emits TEXT NOT NULL DEFAULT '[]', - has_state INTEGER, - framework TEXT NOT NULL DEFAULT '', - variant TEXT NOT NULL DEFAULT '[]' -); - -CREATE TABLE IF NOT EXISTS types ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - lang TEXT NOT NULL, - domain TEXT NOT NULL, - version TEXT NOT NULL DEFAULT '1.0.0', - algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')), - definition TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL, - tags TEXT NOT NULL DEFAULT '[]', - uses_types TEXT NOT NULL DEFAULT '[]', - file_path TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5( - id, - name, - description, - tags, - signature, - domain, - content='functions', - content_rowid='rowid' -); - -CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5( - id, - name, - description, - tags, - domain, - content='types', - content_rowid='rowid' -); - --- Triggers to keep FTS in sync -CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN - INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) - VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); -END; - -CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN - INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) - VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); -END; - -CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN - INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) - VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); - INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) - VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); -END; - -CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN - INSERT INTO types_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 types_ad AFTER DELETE ON types BEGIN - INSERT INTO types_fts(types_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 types_au AFTER UPDATE ON types BEGIN - INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain) - VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); - INSERT INTO types_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 the registry. type DB struct { conn *sql.DB @@ -134,15 +28,14 @@ func Open(path string) (*DB, error) { } // WAL mode: enables concurrent reads while writing. - // Persists in the file — any client opening the DB inherits it. 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 { + if err := migrate(conn); err != nil { conn.Close() - return nil, fmt.Errorf("applying schema: %w", err) + return nil, fmt.Errorf("running migrations: %w", err) } return &DB{conn: conn, path: path}, nil diff --git a/registry/migrate.go b/registry/migrate.go new file mode 100644 index 00000000..212aa350 --- /dev/null +++ b/registry/migrate.go @@ -0,0 +1,117 @@ +package registry + +import ( + "database/sql" + "embed" + "fmt" + "path" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +const migrationTableSQL = ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +);` + +// migrate applies pending migrations to the database. +func migrate(conn *sql.DB) error { + if _, err := conn.Exec(migrationTableSQL); err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + + current, err := currentVersion(conn) + if err != nil { + return err + } + + files, err := listMigrations() + if err != nil { + return err + } + + for _, mf := range files { + if mf.version <= current { + continue + } + + content, err := migrationsFS.ReadFile(path.Join("migrations", mf.filename)) + if err != nil { + return fmt.Errorf("reading migration %s: %w", mf.filename, err) + } + + tx, err := conn.Begin() + if err != nil { + return fmt.Errorf("beginning transaction for migration %d: %w", mf.version, err) + } + + if _, err := tx.Exec(string(content)); err != nil { + tx.Rollback() + return fmt.Errorf("applying migration %s: %w", mf.filename, err) + } + + if _, err := tx.Exec( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)", + mf.version, mf.filename, time.Now().UTC().Format(time.RFC3339), + ); err != nil { + tx.Rollback() + return fmt.Errorf("recording migration %s: %w", mf.filename, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing migration %s: %w", mf.filename, err) + } + } + + return nil +} + +func currentVersion(conn *sql.DB) (int, error) { + var v int + err := conn.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&v) + if err != nil { + return 0, fmt.Errorf("reading current schema version: %w", err) + } + return v, nil +} + +type migrationFile struct { + version int + filename string +} + +func listMigrations() ([]migrationFile, error) { + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return nil, fmt.Errorf("reading migrations directory: %w", err) + } + + var files []migrationFile + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + parts := strings.SplitN(e.Name(), "_", 2) + if len(parts) < 2 { + continue + } + v, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + files = append(files, migrationFile{version: v, filename: e.Name()}) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].version < files[j].version + }) + + return files, nil +} diff --git a/registry/migrations/001_init.sql b/registry/migrations/001_init.sql new file mode 100644 index 00000000..e854dced --- /dev/null +++ b/registry/migrations/001_init.sql @@ -0,0 +1,106 @@ +-- registry schema v1.0.0 +-- Functions and types tables with FTS5 search. + +CREATE TABLE IF NOT EXISTS functions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('function','pipeline','component')), + lang TEXT NOT NULL, + domain TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + purity TEXT NOT NULL CHECK(purity IN ('pure','impure')), + signature TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_functions TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + returns TEXT NOT NULL DEFAULT '[]', + returns_optional INTEGER NOT NULL DEFAULT 0, + error_type TEXT NOT NULL DEFAULT '', + imports TEXT NOT NULL DEFAULT '[]', + example TEXT NOT NULL DEFAULT '', + tested INTEGER NOT NULL DEFAULT 0, + tests TEXT NOT NULL DEFAULT '[]', + test_file_path TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + -- Component fields + props TEXT NOT NULL DEFAULT '[]', + emits TEXT NOT NULL DEFAULT '[]', + has_state INTEGER, + framework TEXT NOT NULL DEFAULT '', + variant TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lang TEXT NOT NULL, + domain TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')), + definition TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + file_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5( + id, + name, + description, + tags, + signature, + domain, + content='functions', + content_rowid='rowid' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS types_fts USING fts5( + id, + name, + description, + tags, + domain, + content='types', + content_rowid='rowid' +); + +-- Triggers to keep FTS in sync +CREATE TRIGGER IF NOT EXISTS functions_ai AFTER INSERT ON functions BEGIN + INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); +END; + +CREATE TRIGGER IF NOT EXISTS functions_ad AFTER DELETE ON functions BEGIN + INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); +END; + +CREATE TRIGGER IF NOT EXISTS functions_au AFTER UPDATE ON functions BEGIN + INSERT INTO functions_fts(functions_fts, rowid, id, name, description, tags, signature, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.signature, old.domain); + INSERT INTO functions_fts(rowid, id, name, description, tags, signature, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.signature, new.domain); +END; + +CREATE TRIGGER IF NOT EXISTS types_ai AFTER INSERT ON types BEGIN + INSERT INTO types_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 types_ad AFTER DELETE ON types BEGIN + INSERT INTO types_fts(types_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 types_au AFTER UPDATE ON types BEGIN + INSERT INTO types_fts(types_fts, rowid, id, name, description, tags, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); + INSERT INTO types_fts(rowid, id, name, description, tags, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain); +END; diff --git a/registry/migrations/002_proposals.sql b/registry/migrations/002_proposals.sql new file mode 100644 index 00000000..7ab642ed --- /dev/null +++ b/registry/migrations/002_proposals.sql @@ -0,0 +1,42 @@ +-- Proposals table: agent-generated improvement suggestions for the registry. +-- Lives in registry (not operations) because proposals benefit all projects. + +CREATE TABLE IF NOT EXISTS proposals ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK(kind IN ('new_function','new_type','improve_function','improve_type','new_pipeline')), + target_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','implemented')), + created_by TEXT NOT NULL DEFAULT '', + reviewed_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS proposals_fts USING fts5( + id, + title, + description, + evidence, + content='proposals', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS proposals_ai AFTER INSERT ON proposals BEGIN + INSERT INTO proposals_fts(rowid, id, title, description, evidence) + VALUES (new.rowid, new.id, new.title, new.description, new.evidence); +END; + +CREATE TRIGGER IF NOT EXISTS proposals_ad AFTER DELETE ON proposals BEGIN + INSERT INTO proposals_fts(proposals_fts, rowid, id, title, description, evidence) + VALUES ('delete', old.rowid, old.id, old.title, old.description, old.evidence); +END; + +CREATE TRIGGER IF NOT EXISTS proposals_au AFTER UPDATE ON proposals BEGIN + INSERT INTO proposals_fts(proposals_fts, rowid, id, title, description, evidence) + VALUES ('delete', old.rowid, old.id, old.title, old.description, old.evidence); + INSERT INTO proposals_fts(rowid, id, title, description, evidence) + VALUES (new.rowid, new.id, new.title, new.description, new.evidence); +END; diff --git a/registry/models.go b/registry/models.go index 06346058..bf3b60a5 100644 --- a/registry/models.go +++ b/registry/models.go @@ -87,6 +87,42 @@ type Type struct { UpdatedAt time.Time `json:"updated_at"` } +// ProposalKind classifies a proposal. +type ProposalKind string + +const ( + ProposalNewFunction ProposalKind = "new_function" + ProposalNewType ProposalKind = "new_type" + ProposalImproveFunction ProposalKind = "improve_function" + ProposalImproveType ProposalKind = "improve_type" + ProposalNewPipeline ProposalKind = "new_pipeline" +) + +// ProposalStatus represents the review state of a proposal. +type ProposalStatus string + +const ( + ProposalPending ProposalStatus = "pending" + ProposalApproved ProposalStatus = "approved" + ProposalRejected ProposalStatus = "rejected" + ProposalImplemented ProposalStatus = "implemented" +) + +// Proposal represents a suggested improvement to the registry. +type Proposal struct { + ID string `json:"id"` + Kind ProposalKind `json:"kind"` + TargetID string `json:"target_id"` + Title string `json:"title"` + Description string `json:"description"` + Evidence map[string]any `json:"evidence"` + Status ProposalStatus `json:"status"` + CreatedBy string `json:"created_by"` + ReviewedBy string `json:"reviewed_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // GenerateID builds the canonical ID: {name}_{lang}_{domain} func GenerateID(name, lang, domain string) string { return name + "_" + lang + "_" + domain diff --git a/registry/store.go b/registry/store.go index c7cac284..91b75801 100644 --- a/registry/store.go +++ b/registry/store.go @@ -24,6 +24,23 @@ func unmarshalStrings(s string) []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 +} + func marshalProps(ps []PropDef) string { if ps == nil { ps = []PropDef{} @@ -305,3 +322,153 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error } return result, nil } + +// --- Proposal CRUD --- + +// InsertProposal inserts or replaces a proposal. +func (db *DB) InsertProposal(p *Proposal) error { + now := time.Now().UTC() + if p.CreatedAt.IsZero() { + p.CreatedAt = now + } + p.UpdatedAt = now + + if p.Status == "" { + p.Status = ProposalPending + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO proposals ( + id, kind, target_id, title, description, evidence, + status, created_by, reviewed_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, string(p.Kind), p.TargetID, p.Title, p.Description, + marshalJSON(p.Evidence), string(p.Status), p.CreatedBy, p.ReviewedBy, + p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetProposal returns a proposal by ID. +func (db *DB) GetProposal(id string) (*Proposal, error) { + rows, err := db.conn.Query(` + SELECT id, kind, target_id, title, description, evidence, + status, created_by, reviewed_by, created_at, updated_at + FROM proposals WHERE id = ?`, id) + if err != nil { + return nil, err + } + defer rows.Close() + + ps, err := scanProposals(rows) + if err != nil { + return nil, err + } + if len(ps) == 0 { + return nil, fmt.Errorf("proposal %q not found", id) + } + return &ps[0], nil +} + +// UpdateProposal updates an existing proposal. +func (db *DB) UpdateProposal(p *Proposal) error { + p.UpdatedAt = time.Now().UTC() + _, err := db.conn.Exec(` + UPDATE proposals SET kind=?, target_id=?, title=?, description=?, evidence=?, + status=?, created_by=?, reviewed_by=?, updated_at=? + WHERE id=?`, + string(p.Kind), p.TargetID, p.Title, p.Description, + marshalJSON(p.Evidence), string(p.Status), p.CreatedBy, p.ReviewedBy, + p.UpdatedAt.Format(time.RFC3339), p.ID, + ) + return err +} + +// DeleteProposal removes a proposal by ID. +func (db *DB) DeleteProposal(id string) error { + _, err := db.conn.Exec("DELETE FROM proposals WHERE id = ?", id) + return err +} + +// ListProposals returns proposals filtered by kind and/or status. +func (db *DB) ListProposals(kind ProposalKind, status ProposalStatus) ([]Proposal, error) { + where := []string{} + args := []any{} + if kind != "" { + where = append(where, "kind = ?") + args = append(args, string(kind)) + } + if status != "" { + where = append(where, "status = ?") + args = append(args, string(status)) + } + + q := `SELECT id, kind, target_id, title, description, evidence, + status, created_by, reviewed_by, created_at, updated_at + FROM proposals` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY created_at DESC" + + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProposals(rows) +} + +// SearchProposals performs FTS search on proposals with optional filters. +func (db *DB) SearchProposals(query string, kind ProposalKind, status ProposalStatus) ([]Proposal, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "p.id IN (SELECT id FROM proposals_fts WHERE proposals_fts MATCH ?)") + args = append(args, query) + } + if kind != "" { + where = append(where, "p.kind = ?") + args = append(args, string(kind)) + } + if status != "" { + where = append(where, "p.status = ?") + args = append(args, string(status)) + } + + q := `SELECT p.id, p.kind, p.target_id, p.title, p.description, p.evidence, + p.status, p.created_by, p.reviewed_by, p.created_at, p.updated_at + FROM proposals p` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY p.created_at DESC" + + rows, err := db.conn.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProposals(rows) +} + +func scanProposals(rows interface{ Next() bool; Scan(...any) error }) ([]Proposal, error) { + var result []Proposal + for rows.Next() { + var p Proposal + var evidenceJSON, createdAt, updatedAt string + err := rows.Scan( + &p.ID, &p.Kind, &p.TargetID, &p.Title, &p.Description, &evidenceJSON, + &p.Status, &p.CreatedBy, &p.ReviewedBy, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning proposal: %w", err) + } + p.Evidence = unmarshalJSON(evidenceJSON) + p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + result = append(result, p) + } + return result, nil +} diff --git a/registry/store_test.go b/registry/store_test.go index c3a4204c..e3831dfb 100644 --- a/registry/store_test.go +++ b/registry/store_test.go @@ -151,6 +151,138 @@ func TestPurge(t *testing.T) { } } +func TestProposalCRUD(t *testing.T) { + db := tempDB(t) + + p := &Proposal{ + ID: "proposal_test_1", + Kind: ProposalNewFunction, + Title: "Add retry with backoff", + Description: "Exponential backoff for HTTP clients", + Evidence: map[string]any{"assertion_ids": []any{"a1", "a2"}}, + CreatedBy: "agent", + } + + if err := db.InsertProposal(p); err != nil { + t.Fatalf("insert: %v", err) + } + + if p.Status != ProposalPending { + t.Errorf("default status = %q, want pending", p.Status) + } + + got, err := db.GetProposal("proposal_test_1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Title != "Add retry with backoff" { + t.Errorf("title = %q, want %q", got.Title, "Add retry with backoff") + } + if got.Evidence["assertion_ids"] == nil { + t.Error("evidence should contain assertion_ids") + } + + // Update + got.Status = ProposalApproved + got.ReviewedBy = "lucas" + if err := db.UpdateProposal(got); err != nil { + t.Fatalf("update: %v", err) + } + updated, _ := db.GetProposal("proposal_test_1") + if updated.Status != ProposalApproved { + t.Errorf("status = %q, want approved", updated.Status) + } + if updated.ReviewedBy != "lucas" { + t.Errorf("reviewed_by = %q, want lucas", updated.ReviewedBy) + } + + // List with filter + db.InsertProposal(&Proposal{ + ID: "proposal_test_2", Kind: ProposalImproveType, TargetID: "ohlcv_go_finance", + Title: "Improve OHLCV", CreatedBy: "agent", + }) + + all, err := db.ListProposals("", "") + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 2 { + t.Errorf("list all = %d, want 2", len(all)) + } + + byKind, _ := db.ListProposals(ProposalNewFunction, "") + if len(byKind) != 1 { + t.Errorf("list by kind = %d, want 1", len(byKind)) + } + + byStatus, _ := db.ListProposals("", ProposalPending) + if len(byStatus) != 1 { + t.Errorf("list by status pending = %d, want 1", len(byStatus)) + } + + // Search FTS + found, err := db.SearchProposals("backoff", "", "") + if err != nil { + t.Fatalf("search: %v", err) + } + if len(found) != 1 { + t.Errorf("search 'backoff' = %d, want 1", len(found)) + } + + // Delete + if err := db.DeleteProposal("proposal_test_1"); err != nil { + t.Fatalf("delete: %v", err) + } + _, err = db.GetProposal("proposal_test_1") + if err == nil { + t.Error("expected error after delete") + } +} + +func TestValidateProposal(t *testing.T) { + tests := []struct { + name string + p Proposal + wantErr bool + }{ + {"valid new_function", Proposal{ID: "p1", Kind: ProposalNewFunction, Title: "test"}, false}, + {"valid improve with target", Proposal{ID: "p2", Kind: ProposalImproveFunction, Title: "test", TargetID: "fn_go_core"}, false}, + {"missing title", Proposal{ID: "p3", Kind: ProposalNewFunction}, true}, + {"missing kind", Proposal{ID: "p4", Title: "test"}, true}, + {"improve without target", Proposal{ID: "p5", Kind: ProposalImproveType, Title: "test"}, true}, + {"invalid kind", Proposal{ID: "p6", Kind: "invalid", Title: "test"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateProposal(&tt.p) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateProposal() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestMigrations(t *testing.T) { + db := tempDB(t) + + // Verify schema_migrations table exists and has entries + var count int + err := db.conn.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) + if err != nil { + t.Fatalf("query schema_migrations: %v", err) + } + if count < 2 { + t.Errorf("expected at least 2 migrations, got %d", count) + } + + // Verify proposals table exists + _, err = db.conn.Exec("SELECT 1 FROM proposals LIMIT 1") + if err != nil { + t.Fatalf("proposals table should exist: %v", err) + } +} + func TestDrop(t *testing.T) { path := filepath.Join(t.TempDir(), "drop.db") db, err := Open(path) diff --git a/registry/validate.go b/registry/validate.go index 4d1b5e57..0ec04c9e 100644 --- a/registry/validate.go +++ b/registry/validate.go @@ -126,6 +126,41 @@ func ValidateFunction(f *Function, knownFunctions, knownTypes map[string]bool) * return nil } +// ValidateProposal checks integrity rules for proposals. +func ValidateProposal(p *Proposal) *ValidationError { + var errs []string + + if p.ID == "" { + errs = append(errs, "id is required") + } + if p.Title == "" { + errs = append(errs, "title is required") + } + + switch p.Kind { + case ProposalNewFunction, ProposalNewType, ProposalImproveFunction, ProposalImproveType, ProposalNewPipeline: + case "": + errs = append(errs, "kind is required") + default: + errs = append(errs, fmt.Sprintf("invalid kind: %s", p.Kind)) + } + + switch p.Status { + case ProposalPending, ProposalApproved, ProposalRejected, ProposalImplemented, "": + default: + errs = append(errs, fmt.Sprintf("invalid status: %s", p.Status)) + } + + if (p.Kind == ProposalImproveFunction || p.Kind == ProposalImproveType) && p.TargetID == "" { + errs = append(errs, "target_id is required for improve_* proposals") + } + + if len(errs) > 0 { + return &ValidationError{ID: p.ID, Errors: errs} + } + return nil +} + // ValidateType checks integrity rules for types. func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError { var errs []string