feat: proposals en registry

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:13:24 +01:00
parent 093367107e
commit 8d98faccd9
9 changed files with 902 additions and 109 deletions
+265
View File
@@ -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 <kind> --title <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 := &registry.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, ", ")
}
+2 -109
View File
@@ -9,112 +9,6 @@ import (
_ "github.com/mattn/go-sqlite3" _ "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. // DB wraps a SQLite connection for the registry.
type DB struct { type DB struct {
conn *sql.DB conn *sql.DB
@@ -134,15 +28,14 @@ func Open(path string) (*DB, error) {
} }
// WAL mode: enables concurrent reads while writing. // 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 { if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
conn.Close() conn.Close()
return nil, fmt.Errorf("setting WAL mode: %w", err) return nil, fmt.Errorf("setting WAL mode: %w", err)
} }
if _, err := conn.Exec(schemaSQL); err != nil { if err := migrate(conn); err != nil {
conn.Close() 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 return &DB{conn: conn, path: path}, nil
+117
View File
@@ -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
}
+106
View File
@@ -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;
+42
View File
@@ -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;
+36
View File
@@ -87,6 +87,42 @@ type Type struct {
UpdatedAt time.Time `json:"updated_at"` 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} // GenerateID builds the canonical ID: {name}_{lang}_{domain}
func GenerateID(name, lang, domain string) string { func GenerateID(name, lang, domain string) string {
return name + "_" + lang + "_" + domain return name + "_" + lang + "_" + domain
+167
View File
@@ -24,6 +24,23 @@ func unmarshalStrings(s string) []string {
return out 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 { func marshalProps(ps []PropDef) string {
if ps == nil { if ps == nil {
ps = []PropDef{} ps = []PropDef{}
@@ -305,3 +322,153 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
} }
return result, nil 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
}
+132
View File
@@ -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) { func TestDrop(t *testing.T) {
path := filepath.Join(t.TempDir(), "drop.db") path := filepath.Join(t.TempDir(), "drop.db")
db, err := Open(path) db, err := Open(path)
+35
View File
@@ -126,6 +126,41 @@ func ValidateFunction(f *Function, knownFunctions, knownTypes map[string]bool) *
return nil 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. // ValidateType checks integrity rules for types.
func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError { func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError {
var errs []string var errs []string