feat: registry_api + fn sync — sincronización de registry.db entre PCs

Nuevo sistema para mantener datos no regenerables (proposals, apps, projects,
analysis, vaults, pc_locations) sincronizados entre múltiples máquinas via
una API HTTP central desplegada en organic-machine.com.

- Migración 011: tabla pc_locations (mapa de ubicaciones por PC)
- registry/models.go: struct PcLocation
- registry/store.go: CRUD PcLocation + helpers de sync
- cmd/fn/sync.go: subcomando fn sync (push+pull, status, locations)
- bash/functions/infra/setup_registry_api: pipeline de deploy Docker+Traefik
- CLAUDE.md: documentación de sync y pc_locations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 02:12:38 +02:00
parent 295ab491a3
commit 28364cf212
9 changed files with 820 additions and 2 deletions
+18
View File
@@ -0,0 +1,18 @@
-- pc_locations: mapa de ubicaciones por máquina.
-- Cada PC registra dónde tiene cada app, analysis, project o vault.
CREATE TABLE IF NOT EXISTS pc_locations (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL CHECK(entity_type IN ('app', 'analysis', 'project', 'vault')),
entity_id TEXT NOT NULL,
pc_id TEXT NOT NULL,
dir_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'missing', 'archived')),
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(entity_type, entity_id, pc_id)
);
CREATE INDEX IF NOT EXISTS idx_pc_locations_pc ON pc_locations(pc_id);
CREATE INDEX IF NOT EXISTS idx_pc_locations_entity ON pc_locations(entity_type, entity_id);
+13
View File
@@ -224,6 +224,19 @@ type Vault struct {
UpdatedAt time.Time `json:"updated_at"`
}
// PcLocation maps an entity to a directory path on a specific PC.
type PcLocation struct {
ID string `json:"id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
PcID string `json:"pc_id"`
DirPath string `json:"dir_path"`
Status string `json:"status"`
Notes string `json:"notes"`
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
+113
View File
@@ -1094,3 +1094,116 @@ func scanProposals(rows interface{ Next() bool; Scan(...any) error }) ([]Proposa
}
return result, nil
}
// --- PcLocation CRUD ---
// InsertPcLocation inserts or replaces a pc_location entry.
func (db *DB) InsertPcLocation(loc *PcLocation) error {
now := time.Now().UTC()
if loc.CreatedAt.IsZero() {
loc.CreatedAt = now
}
if loc.UpdatedAt.IsZero() {
loc.UpdatedAt = now
}
if loc.ID == "" {
loc.ID = loc.EntityType + "_" + loc.EntityID + "_" + loc.PcID
}
if loc.Status == "" {
loc.Status = "active"
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO pc_locations (
id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
loc.ID, loc.EntityType, loc.EntityID, loc.PcID, loc.DirPath, loc.Status, loc.Notes,
loc.CreatedAt.Format(time.RFC3339), loc.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetPcLocationsByPC returns all locations for a given PC.
func (db *DB) GetPcLocationsByPC(pcID string) ([]PcLocation, error) {
rows, err := db.conn.Query(
"SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE pc_id = ? ORDER BY entity_type, entity_id",
pcID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPcLocations(rows)
}
// GetPcLocationsByEntity returns all PC locations for a given entity.
func (db *DB) GetPcLocationsByEntity(entityType, entityID string) ([]PcLocation, error) {
rows, err := db.conn.Query(
"SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE entity_type = ? AND entity_id = ? ORDER BY pc_id",
entityType, entityID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPcLocations(rows)
}
// ListAllPcLocations returns all pc_location entries.
func (db *DB) ListAllPcLocations() ([]PcLocation, error) {
rows, err := db.conn.Query(
"SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations ORDER BY pc_id, entity_type, entity_id",
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPcLocations(rows)
}
// DeletePcLocationsByPC removes all locations for a given PC.
func (db *DB) DeletePcLocationsByPC(pcID string) error {
_, err := db.conn.Exec("DELETE FROM pc_locations WHERE pc_id = ?", pcID)
return err
}
func scanPcLocations(rows interface{ Next() bool; Scan(...any) error }) ([]PcLocation, error) {
var result []PcLocation
for rows.Next() {
var loc PcLocation
var createdAt, updatedAt string
err := rows.Scan(
&loc.ID, &loc.EntityType, &loc.EntityID, &loc.PcID,
&loc.DirPath, &loc.Status, &loc.Notes, &createdAt, &updatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning pc_location: %w", err)
}
loc.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
loc.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, loc)
}
return result, nil
}
// --- Sync helpers ---
// AllApps returns all apps (for sync export).
func (db *DB) AllApps() ([]App, error) {
return db.SearchApps("", "", "")
}
// AllAnalysis returns all analysis entries (for sync export).
func (db *DB) AllAnalysis() ([]Analysis, error) {
return db.SearchAnalysis("", "", "")
}
// AllProposals returns all proposals (for sync export).
func (db *DB) AllProposals() ([]Proposal, error) {
return db.ListProposals("", "")
}
// AllVaults returns all vaults (for sync export).
func (db *DB) AllVaults() ([]Vault, error) {
return db.SearchVaults("", "")
}