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:
@@ -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 := ®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, ", ")
|
||||
}
|
||||
+2
-109
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user