feat: externalize apps/analysis to Gitea repos, add analysis table

- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:23:51 +02:00
parent 722f29b71b
commit bf1efb2099
111 changed files with 2766 additions and 5043 deletions
+80 -1
View File
@@ -53,7 +53,7 @@ sqlite3 registry.db ".schema"
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file` **functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps) - Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines - Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file` **types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum) - Enums: `algebraic`(product|sum)
@@ -80,6 +80,7 @@ fn-registry/
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
fn_operations/ # Paquete Go: operations database (libreria) fn_operations/ # Paquete Go: operations database (libreria)
apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db
analysis/ # Exploraciones Jupyter independientes — cada una con su venv, MCP y kernel conectado al registry
cmd/fn/ # CLI principal cmd/fn/ # CLI principal
docs/ # Specs de diseño docs/ # Specs de diseño
docs/templates/ # Plantillas de frontmatter docs/templates/ # Plantillas de frontmatter
@@ -193,6 +194,84 @@ Ver template en `docs/templates/`.
--- ---
## Analysis (exploraciones Jupyter)
Carpeta `analysis/` para exploraciones de datos con Jupyter + agentes Claude. Mismo patron que `apps/` — cada analisis es independiente con su propio venv, MCP y kernel.
**NO es codigo reutilizable** — son investigaciones ad-hoc. Si algo de un analisis resulta util, se extrae como funcion al registry.
### Estructura
```
analysis/
{tema}/ # Cada analisis es autonomo
.venv/ # Deps propias (gitignored)
.mcp.json # MCP jupyter apuntando a SU venv (gitignored)
.claude/CLAUDE.md # Reglas para agentes en este analisis
.ipython/profile_default/startup/ # Kernel startup con acceso al registry
00_fn_registry.py # Autocarga FN_REGISTRY_ROOT, helpers, sys.path
notebooks/ # Notebooks de exploracion
data/ # Datos locales (gitignored)
run-jupyter-lab.sh # Launcher Jupyter colaborativo
pyproject.toml # Deps gestionadas con uv
```
### Crear un analisis nuevo
```bash
# Basico
fn run init_jupyter_analysis finanzas
# Con paquetes extra
fn run init_jupyter_analysis ml scikit-learn torch
fn run init_jupyter_analysis duckdb polars duckdb
```
El pipeline `init_jupyter_analysis_bash_pipelines` compone 8 funciones atomicas del registry.
### Usar un analisis
```bash
# Terminal 1: lanzar Jupyter
cd analysis/{tema} && ./run-jupyter-lab.sh
# Terminal 2: abrir Claude con MCP jupyter
cd analysis/{tema} && claude
# Navegador: http://localhost:8888
```
### Acceso al registry desde notebooks
El kernel startup (`00_fn_registry.py`) se ejecuta automaticamente al abrir cualquier notebook y provee:
```python
# Helpers disponibles sin importar nada:
fn_search("slice") # Busca funciones y tipos por nombre/descripcion
fn_query("SELECT ...") # SQL directo sobre registry.db
fn_code("filter_list_py_core") # Codigo fuente de una funcion
# Importar funciones Python del registry directamente:
from core import filter_list, map_list, reduce_list
from finance import sma, ema, rsi
from metabase import MetabaseClient
# Variable de entorno disponible:
import os
os.environ["FN_REGISTRY_ROOT"] # Raiz del registry
```
### Reglas para agentes en analysis
Cada analisis tiene su `.claude/CLAUDE.md` con reglas especificas:
- Celdas inmutables: nunca modificar celdas existentes, solo anadir nuevas
- Programacion funcional obligatoria: funciones puras, sin mutacion
- Usar MCP jupyter para ejecutar codigo, nunca bash
- Notebooks en `notebooks/`, maximo 50 celdas por notebook
- Dependencias con `uv add`, nunca pip directo
---
## Bucle reactivo: CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR ## Bucle reactivo: CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR
### 1. CONSTRUIR ### 1. CONSTRUIR
+4
View File
@@ -34,6 +34,10 @@ registry.db-wal
**/*.pyo **/*.pyo
python/.venv/ python/.venv/
# Externalized apps and analysis (each is its own Gitea repo)
apps/*/
analysis/*/
# Node / pnpm # Node / pnpm
**/node_modules/ **/node_modules/
View File
View File
-5
View File
@@ -1,5 +0,0 @@
build/
*.exe
*.dll
*.so
*.dylib
-19
View File
@@ -1,19 +0,0 @@
.PHONY: run build clean install tidy help
run: ## Ejecuta la TUI
go run .
build: ## Compila el binario
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
clean: ## Limpia artefactos
rm -rf build/
install: build ## Instala en ~/.local/bin
cp build/docker-tui ~/.local/bin/docker-tui
tidy: ## go mod tidy
go mod tidy
help: ## Muestra esta ayuda
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
-28
View File
@@ -1,28 +0,0 @@
---
name: docker_tui
lang: go
domain: infra
description: "TUI interactiva para gestion de contenedores, imagenes, volumenes y redes Docker."
tags: [docker, tui, bubbletea, containers]
uses_functions:
- docker_pull_image_go_infra
- docker_list_containers_go_infra
- docker_remove_container_go_infra
- docker_stop_container_go_infra
- docker_start_container_go_infra
- docker_list_images_go_infra
- docker_remove_image_go_infra
- docker_remove_network_go_infra
- docker_create_network_go_infra
- docker_inspect_container_go_infra
- docker_run_container_go_infra
- docker_container_logs_go_infra
uses_types: []
framework: bubbletea
entry_point: "main.go"
dir_path: "apps/docker_tui"
---
## Notas
Aplicacion TUI con pestanas para contenedores, imagenes, volumenes, redes y compose. Construida con Bubble Tea (Charmbracelet).
-175
View File
@@ -1,175 +0,0 @@
package app
import (
"docker-tui/views"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type View int
const (
ViewContainers View = iota
ViewImages
ViewVolumes
ViewNetworks
ViewCompose
)
var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"}
type Model struct {
tui.BaseModel
activeTab int
containers views.ContainersModel
images views.ImagesModel
volumes views.VolumesModel
networks views.NetworksModel
compose views.ComposeModel
ready bool
}
func New() Model {
styles := tui.DefaultStyles()
return Model{
BaseModel: tui.NewBaseModel().WithStyles(styles),
containers: views.NewContainersModel(styles),
images: views.NewImagesModel(styles),
volumes: views.NewVolumesModel(styles),
networks: views.NewNetworksModel(styles),
compose: views.NewComposeModel(styles),
}
}
func (m Model) Init() tea.Cmd {
return m.containers.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case views.KeyQuit:
return m, tea.Quit
case "q", "0", "esc":
updated, atBase := m.handleBack()
if atBase {
return updated, tea.Quit
}
return updated, nil
case views.KeyTab:
m.activeTab = (m.activeTab + 1) % len(tabNames)
return m, m.initActiveView()
case "shift+tab":
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
return m, m.initActiveView()
}
case tea.WindowSizeMsg:
m.HandleWindowSize(msg)
m.ready = true
}
var cmd tea.Cmd
switch View(m.activeTab) {
case ViewContainers:
m.containers, cmd = m.containers.Update(msg)
case ViewImages:
m.images, cmd = m.images.Update(msg)
case ViewVolumes:
m.volumes, cmd = m.volumes.Update(msg)
case ViewNetworks:
m.networks, cmd = m.networks.Update(msg)
case ViewCompose:
m.compose, cmd = m.compose.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
// Tab bar
tabs := m.renderTabs()
// Active view content
var content string
switch View(m.activeTab) {
case ViewContainers:
content = m.containers.View()
case ViewImages:
content = m.images.View()
case ViewVolumes:
content = m.volumes.View()
case ViewNetworks:
content = m.networks.View()
case ViewCompose:
content = m.compose.View()
}
// Status bar
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
return lipgloss.JoinVertical(lipgloss.Left,
tabs,
"",
content,
"",
status,
)
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range tabNames {
if i == m.activeTab {
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
} else {
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
}
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.Styles.Header.Render("Docker TUI") + " " + row
}
// handleBack asks the active view to go back one level.
// Returns the updated model and true if the view was already at base level (app should quit).
func (m Model) handleBack() (Model, bool) {
switch View(m.activeTab) {
case ViewContainers:
atBase := m.containers.HandleBack()
return m, atBase
case ViewImages:
atBase := m.images.HandleBack()
return m, atBase
case ViewVolumes:
atBase := m.volumes.HandleBack()
return m, atBase
case ViewNetworks:
atBase := m.networks.HandleBack()
return m, atBase
case ViewCompose:
atBase := m.compose.HandleBack()
return m, atBase
}
return m, true
}
func (m Model) initActiveView() tea.Cmd {
switch View(m.activeTab) {
case ViewContainers:
return m.containers.Init()
case ViewImages:
return m.images.Init()
case ViewVolumes:
return m.volumes.Init()
case ViewNetworks:
return m.networks.Init()
case ViewCompose:
return m.compose.Init()
}
return nil
}
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Tidying modules..."
go mod tidy
echo "==> Building docker-tui..."
mkdir -p build
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
echo "==> Done: build/docker-tui ($(du -h build/docker-tui | cut -f1))"
echo " Run with: ./build/docker-tui"
-15
View File
@@ -1,15 +0,0 @@
package config
// Config holds Docker TUI configuration.
type Config struct {
ComposeFile string
RefreshInterval int // seconds, 0 = manual
}
// Default returns sensible defaults.
func Default() Config {
return Config{
ComposeFile: "docker-compose.yml",
RefreshInterval: 0,
}
}
-43
View File
@@ -1,43 +0,0 @@
module docker-tui
go 1.22.2
require (
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/lucasdataproyects/devfactory v0.0.0
)
require (
github.com/apache/arrow/go/v14 v14.0.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.18.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/marcboeker/go-duckdb v1.6.5 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
)
replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
-84
View File
@@ -1,84 +0,0 @@
github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw=
github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI=
github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o=
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-6
View File
@@ -1,6 +0,0 @@
go 1.22.2
use (
.
/home/lucas/.local_agentes/backend
)
-40
View File
@@ -1,40 +0,0 @@
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-20
View File
@@ -1,20 +0,0 @@
package main
import (
"fmt"
"os"
"docker-tui/app"
"github.com/lucasdataproyects/devfactory/tui"
)
func main() {
model := app.New()
result := tui.RunFullscreen(model)
if result.IsErr() {
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
os.Exit(1)
}
}
-201
View File
@@ -1,201 +0,0 @@
package views
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type composeState int
const (
composeLoading composeState = iota
composeList
composeAction
composeLogs
)
type composeLoadedMsg []ComposeService
type composeActionMsg struct{ output string; err error }
type composeLogsMsg struct{ output string; err error }
type ComposeModel struct {
state composeState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
services []ComposeService
output string
scrollOff int
err error
}
func NewComposeModel(styles tui.Styles) ComposeModel {
return ComposeModel{
state: composeLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading compose services..."),
styles: styles,
}
}
func (m ComposeModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadCompose)
}
func loadCompose() tea.Msg {
services, err := ComposePS()
if err != nil {
return composeLoadedMsg(nil)
}
return composeLoadedMsg(services)
}
func (m ComposeModel) Update(msg tea.Msg) (ComposeModel, tea.Cmd) {
switch msg := msg.(type) {
case composeLoadedMsg:
m.services = []ComposeService(msg)
items := make([]tui.ListItem, 0, len(m.services)+2)
// Add action items at the top
items = append(items,
tui.ListItem{Title: "▶ Compose Up", Description: "docker compose up -d", Value: "up"},
tui.ListItem{Title: "■ Compose Down", Description: "docker compose down", Value: "down"},
)
for _, s := range m.services {
stateIcon := "●"
if s.State == "running" {
stateIcon = "▶"
}
items = append(items, tui.ListItem{
Title: fmt.Sprintf("%s %s", stateIcon, s.Name),
Description: fmt.Sprintf("Service: %s — %s", s.Service, s.Status),
Value: s,
})
}
m.list.SetItems(items)
m.state = composeList
return m, nil
case composeActionMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = composeList
return m, loadCompose
case composeLogsMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = composeLogs
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case composeList:
switch msg.String() {
case "r":
m.state = composeLoading
return m, tea.Batch(m.spinner.Init(), loadCompose)
case "l":
m.state = composeAction
return m, func() tea.Msg {
output, err := ComposeLogs(100)
return composeLogsMsg{output: output, err: err}
}
case "enter":
if item := m.list.SelectedItem(); item != nil {
switch v := item.Value.(type) {
case string:
m.state = composeAction
if v == "up" {
return m, func() tea.Msg {
output, err := ComposeUp()
return composeActionMsg{output: output, err: err}
}
}
return m, func() tea.Msg {
output, err := ComposeDown()
return composeActionMsg{output: output, err: err}
}
}
}
}
case composeLogs:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
var cmd tea.Cmd
switch m.state {
case composeLoading, composeAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case composeList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *ComposeModel) HandleBack() bool {
switch m.state {
case composeLogs:
m.state = composeList
return false
default:
return true
}
}
func (m ComposeModel) View() string {
switch m.state {
case composeLoading, composeAction:
return m.spinner.View()
case composeList:
if len(m.services) == 0 {
help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.")
return m.list.View() + "\n" + help
}
help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh")
return m.list.View() + "\n" + help
case composeLogs:
return m.renderLogs()
}
return ""
}
func (m ComposeModel) renderLogs() string {
lines := strings.Split(m.output, "\n")
if len(lines) == 0 {
lines = []string{"(empty)"}
}
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Compose Logs")
content := strings.Join(visible, "\n")
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
-251
View File
@@ -1,251 +0,0 @@
package views
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type containersState int
const (
containersLoading containersState = iota
containersList
containersAction
containersLogs
)
type containersLoadedMsg []DockerContainer
type containersActionMsg struct{ output string; err error }
type containersLogsMsg struct{ output string; err error }
type ContainersModel struct {
state containersState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
containers []DockerContainer
output string
scrollOff int
err error
}
func NewContainersModel(styles tui.Styles) ContainersModel {
return ContainersModel{
state: containersLoading,
list: tui.NewFilteredList(nil, "Filter containers..."),
spinner: tui.NewSpinner("Loading containers..."),
styles: styles,
}
}
func (m ContainersModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
loadContainers,
)
}
func loadContainers() tea.Msg {
containers, err := ListContainers()
if err != nil {
return containersLoadedMsg(nil)
}
return containersLoadedMsg(containers)
}
func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) {
switch msg := msg.(type) {
case containersLoadedMsg:
m.containers = []DockerContainer(msg)
items := make([]tui.ListItem, len(m.containers))
for i, c := range m.containers {
stateIcon := "●"
if c.State == "running" {
stateIcon = "▶"
} else if c.State == "exited" {
stateIcon = "■"
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s %s", stateIcon, c.Names),
Description: fmt.Sprintf("%s — %s", c.Image, c.Status),
Value: c,
}
}
m.list.SetItems(items)
m.state = containersList
return m, nil
case containersActionMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = containersList
// Refresh after action
return m, loadContainers
case containersLogsMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = containersLogs
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case containersList:
switch msg.String() {
case "r":
m.state = containersLoading
return m, tea.Batch(m.spinner.Init(), loadContainers)
case "enter":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
if c.State == "running" {
return m, stopContainerCmd(c.ID)
}
return m, startContainerCmd(c.ID)
}
case "l":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
m.state = containersAction
return m, logsContainerCmd(c.ID)
}
case "x":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
return m, restartContainerCmd(c.ID)
}
}
case containersLogs:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case containersLoading:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case containersList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base (el caller debe salir).
func (m *ContainersModel) HandleBack() bool {
switch m.state {
case containersLogs:
m.state = containersList
return false
default:
return true
}
}
func (m ContainersModel) View() string {
switch m.state {
case containersLoading:
return m.spinner.View()
case containersList:
if len(m.containers) == 0 {
return m.styles.Muted.Render("No containers found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case containersAction:
return m.spinner.View()
case containersLogs:
return m.renderOutput()
}
return ""
}
func (m ContainersModel) renderOutput() string {
lines := splitLines(m.output)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Container Logs")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func startContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := StartContainer(id)
return containersActionMsg{output: "Started " + id, err: err}
}
}
func stopContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := StopContainer(id)
return containersActionMsg{output: "Stopped " + id, err: err}
}
}
func restartContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := RestartContainer(id)
return containersActionMsg{output: "Restarted " + id, err: err}
}
}
func logsContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
output, err := ContainerLogs(id, 100)
return containersLogsMsg{output: output, err: err}
}
}
func splitLines(s string) []string {
if s == "" {
return []string{"(empty)"}
}
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return []string{"(empty)"}
}
return lines
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
-192
View File
@@ -1,192 +0,0 @@
package views
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/lucasdataproyects/devfactory/shell"
)
const dockerTimeout = 15 * time.Second
// --- Containers ---
func ListContainers() ([]DockerContainer, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerContainer](stdout.Stdout)
}
func StartContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both()
return err
}
func StopContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both()
return err
}
func RestartContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both()
return err
}
func ContainerLogs(id string, lines int) (string, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id)
out, err := result.Both()
if err != nil {
return "", err
}
// docker logs writes to both stdout and stderr
output := out.Stdout
if out.Stderr != "" {
if output != "" {
output += "\n"
}
output += out.Stderr
}
return output, nil
}
// --- Images ---
func ListImages() ([]DockerImage, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerImage](stdout.Stdout)
}
func PullImage(name string) (string, error) {
result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name)
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout, nil
}
func RemoveImage(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both()
return err
}
// --- Volumes ---
func ListVolumes() ([]DockerVolume, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerVolume](stdout.Stdout)
}
func CreateVolume(name string) error {
args := []string{"volume", "create"}
if name != "" {
args = append(args, name)
}
_, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both()
return err
}
func RemoveVolume(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both()
return err
}
// --- Networks ---
func ListNetworks() ([]DockerNetwork, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerNetwork](stdout.Stdout)
}
func CreateNetwork(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both()
return err
}
func RemoveNetwork(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both()
return err
}
// --- Compose ---
func ComposePS() ([]ComposeService, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json")
stdout, err := result.Both()
if err != nil {
return nil, err
}
// docker compose ps --format json returns a JSON array
var services []ComposeService
if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil {
// Try line-by-line as fallback
return parseJSONLines[ComposeService](stdout.Stdout)
}
return services, nil
}
func ComposeUp() (string, error) {
result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d")
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
func ComposeDown() (string, error) {
result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down")
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
func ComposeLogs(lines int) (string, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines))
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
// --- Helpers ---
func parseJSONLines[T any](s string) ([]T, error) {
var result []T
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var item T
if err := json.Unmarshal([]byte(line), &item); err != nil {
continue
}
result = append(result, item)
}
return result, nil
}
func itoa(n int) string {
return fmt.Sprintf("%d", n)
}
-132
View File
@@ -1,132 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type imagesState int
const (
imagesLoading imagesState = iota
imagesList
imagesAction
)
type imagesLoadedMsg []DockerImage
type imagesActionMsg struct{ output string; err error }
type ImagesModel struct {
state imagesState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
images []DockerImage
err error
}
func NewImagesModel(styles tui.Styles) ImagesModel {
return ImagesModel{
state: imagesLoading,
list: tui.NewFilteredList(nil, "Filter images..."),
spinner: tui.NewSpinner("Loading images..."),
styles: styles,
}
}
func (m ImagesModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadImages)
}
func loadImages() tea.Msg {
images, err := ListImages()
if err != nil {
return imagesLoadedMsg(nil)
}
return imagesLoadedMsg(images)
}
func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) {
switch msg := msg.(type) {
case imagesLoadedMsg:
m.images = []DockerImage(msg)
items := make([]tui.ListItem, len(m.images))
for i, img := range m.images {
tag := img.Tag
if tag == "" {
tag = "latest"
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s:%s", img.Repository, tag),
Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]),
Value: img,
}
}
m.list.SetItems(items)
m.state = imagesList
return m, nil
case imagesActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = imagesList
return m, loadImages
case tea.KeyMsg:
if m.state == imagesList {
switch msg.String() {
case "r":
m.state = imagesLoading
return m, tea.Batch(m.spinner.Init(), loadImages)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
img := item.Value.(DockerImage)
m.state = imagesAction
return m, func() tea.Msg {
err := RemoveImage(img.ID)
return imagesActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case imagesLoading, imagesAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case imagesList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *ImagesModel) HandleBack() bool {
return true
}
func (m ImagesModel) View() string {
switch m.state {
case imagesLoading, imagesAction:
return m.spinner.View()
case imagesList:
if len(m.images) == 0 {
return m.styles.Muted.Render("No images found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-14
View File
@@ -1,14 +0,0 @@
package views
// Navigation key constants.
const (
KeyQuit = "ctrl+c"
KeyEsc = "esc"
KeyBack = "0"
KeyTab = "tab"
)
// IsBack returns true if the key should trigger back navigation.
func IsBack(key string) bool {
return key == KeyEsc || key == KeyBack
}
-128
View File
@@ -1,128 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type networksState int
const (
networksLoading networksState = iota
networksList
networksAction
)
type networksLoadedMsg []DockerNetwork
type networksActionMsg struct{ output string; err error }
type NetworksModel struct {
state networksState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
networks []DockerNetwork
err error
}
func NewNetworksModel(styles tui.Styles) NetworksModel {
return NetworksModel{
state: networksLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading networks..."),
styles: styles,
}
}
func (m NetworksModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadNetworks)
}
func loadNetworks() tea.Msg {
networks, err := ListNetworks()
if err != nil {
return networksLoadedMsg(nil)
}
return networksLoadedMsg(networks)
}
func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) {
switch msg := msg.(type) {
case networksLoadedMsg:
m.networks = []DockerNetwork(msg)
items := make([]tui.ListItem, len(m.networks))
for i, n := range m.networks {
items[i] = tui.ListItem{
Title: n.Name,
Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope),
Value: n,
}
}
m.list.SetItems(items)
m.state = networksList
return m, nil
case networksActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = networksList
return m, loadNetworks
case tea.KeyMsg:
if m.state == networksList {
switch msg.String() {
case "r":
m.state = networksLoading
return m, tea.Batch(m.spinner.Init(), loadNetworks)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
net := item.Value.(DockerNetwork)
m.state = networksAction
return m, func() tea.Msg {
err := RemoveNetwork(net.Name)
return networksActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case networksLoading, networksAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case networksList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *NetworksModel) HandleBack() bool {
return true
}
func (m NetworksModel) View() string {
switch m.state {
case networksLoading, networksAction:
return m.spinner.View()
case networksList:
if len(m.networks) == 0 {
return m.styles.Muted.Render("No networks found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-45
View File
@@ -1,45 +0,0 @@
package views
// DockerContainer represents a container from docker ps --format json.
type DockerContainer struct {
ID string `json:"ID"`
Names string `json:"Names"`
Image string `json:"Image"`
Status string `json:"Status"`
State string `json:"State"`
Ports string `json:"Ports"`
}
// DockerImage represents an image from docker image ls --format json.
type DockerImage struct {
ID string `json:"ID"`
Repository string `json:"Repository"`
Tag string `json:"Tag"`
Size string `json:"Size"`
CreatedAt string `json:"CreatedAt"`
}
// DockerVolume represents a volume from docker volume ls --format json.
type DockerVolume struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Mountpoint string `json:"Mountpoint"`
}
// DockerNetwork represents a network from docker network ls --format json.
type DockerNetwork struct {
ID string `json:"ID"`
Name string `json:"Name"`
Driver string `json:"Driver"`
Scope string `json:"Scope"`
}
// ComposeService represents a compose service from docker compose ps --format json.
type ComposeService struct {
ID string `json:"ID"`
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
Status string `json:"Status"`
Ports string `json:"Ports"`
}
-128
View File
@@ -1,128 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type volumesState int
const (
volumesLoading volumesState = iota
volumesList
volumesAction
)
type volumesLoadedMsg []DockerVolume
type volumesActionMsg struct{ output string; err error }
type VolumesModel struct {
state volumesState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
volumes []DockerVolume
err error
}
func NewVolumesModel(styles tui.Styles) VolumesModel {
return VolumesModel{
state: volumesLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading volumes..."),
styles: styles,
}
}
func (m VolumesModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadVolumes)
}
func loadVolumes() tea.Msg {
volumes, err := ListVolumes()
if err != nil {
return volumesLoadedMsg(nil)
}
return volumesLoadedMsg(volumes)
}
func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) {
switch msg := msg.(type) {
case volumesLoadedMsg:
m.volumes = []DockerVolume(msg)
items := make([]tui.ListItem, len(m.volumes))
for i, v := range m.volumes {
items[i] = tui.ListItem{
Title: v.Name,
Description: fmt.Sprintf("Driver: %s", v.Driver),
Value: v,
}
}
m.list.SetItems(items)
m.state = volumesList
return m, nil
case volumesActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = volumesList
return m, loadVolumes
case tea.KeyMsg:
if m.state == volumesList {
switch msg.String() {
case "r":
m.state = volumesLoading
return m, tea.Batch(m.spinner.Init(), loadVolumes)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
vol := item.Value.(DockerVolume)
m.state = volumesAction
return m, func() tea.Msg {
err := RemoveVolume(vol.Name)
return volumesActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case volumesLoading, volumesAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case volumesList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *VolumesModel) HandleBack() bool {
return true
}
func (m VolumesModel) View() string {
switch m.state {
case volumesLoading, volumesAction:
return m.spinner.View()
case volumesList:
if len(m.volumes) == 0 {
return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-104
View File
@@ -1,104 +0,0 @@
---
name: metabase_registry
lang: py
domain: analytics
description: "Setup y dashboards automaticos de Metabase para visualizar metricas del fn-registry y operations.db de cada app."
tags: [metabase, dashboard, analytics, visualization, operations]
uses_functions:
- metabase_auth_py_infra
- metabase_create_card_py_infra
- metabase_create_dashboard_py_infra
- metabase_update_dashboard_py_infra
- metabase_list_databases_py_infra
- metabase_add_database_py_infra
- metabase_list_dashboards_py_infra
- metabase_delete_dashboard_py_infra
- metabase_create_user_py_infra
uses_types: []
framework: httpx
entry_point: "main.py"
dir_path: "apps/metabase_registry"
---
## Arquitectura
Metabase corre en Docker (`fn_registry-metabase`) con Postgres como backend interno.
Las bases de datos SQLite del proyecto se montan como bind mounts RW en `/data/`:
| Database | Mount en container | Contenido |
|----------|-------------------|-----------|
| registry.db | `/data/registry/registry.db` | functions, types, proposals, apps |
| ops-docker-tui | `/data/ops-docker-tui/operations.db` | entities, relations, executions |
| ops-metabase-registry | `/data/ops-metabase-registry/operations.db` | entities, relations, executions |
| ops-pipeline-launcher | `/data/ops-pipeline-launcher/operations.db` | entities, relations, executions |
## Dashboards
| Dashboard | Contenido |
|-----------|-----------|
| fn-registry Overview | KPIs, distribucion y analisis del registry |
| fn-registry Apps | Apps por lenguaje, dominio, dependencias |
| ops: \<app-name\> | Dashboard operativo por app (entities, relations, executions, assertions) |
## Permisos SQLite en Docker
Metabase corre Java como UID 2000 (usuario `metabase`). SQLite necesita crear journal/WAL
files en el mismo directorio que la BD. Reglas:
- **NUNCA** hacer `chown` dentro del container: se propaga al host via bind mount y rompe permisos locales.
- **Usar `chmod`**: `chmod 777` en directorios, `chmod 666` en archivos `.db`.
- **Pipeline automatico**: `./fn run metabase_fix_permissions` arregla todos los permisos.
- **Ejecutar despues de**: recrear container, añadir nueva database, o ver error `SQLITE_READONLY_DIRECTORY`.
## Flujo para app nueva
```bash
# 1. Crear operations.db
./fn ops init apps/nueva_app
# 2. Recrear container con el nuevo mount
docker stop fn_registry-metabase && docker rm fn_registry-metabase
docker run -d \
--name fn_registry-metabase \
--network fn_registry-net \
-p 3000:3000 \
-e MB_DB_TYPE=postgres -e MB_DB_DBNAME=metabase \
-e MB_DB_PORT=5432 -e MB_DB_USER=metabase \
-e MB_DB_PASS=metabase -e MB_DB_HOST=fn_registry-postgres \
-v /home/lucas/fn_registry:/registry:ro \
-v /home/lucas/fn_registry/registry.db:/data/registry/registry.db \
-v /home/lucas/fn_registry/apps/docker_tui:/data/ops-docker-tui \
-v /home/lucas/fn_registry/apps/metabase_registry:/data/ops-metabase-registry \
-v /home/lucas/fn_registry/apps/pipeline_launcher:/data/ops-pipeline-launcher \
-v /home/lucas/fn_registry/apps/nueva_app:/data/ops-nueva-app \
metabase/metabase:latest
# 3. Fix permisos
./fn run metabase_fix_permissions
# 4. Registrar database en Metabase
./fn run metabase_add_ops_db nueva_app
# 5. Crear dashboard operativo
./fn run metabase_create_ops_dashboard nueva_app
```
## Scripts
| Script | Funcion |
|--------|---------|
| `main.py` | Setup inicial: datasource + cards basicas + dashboard Overview |
| `create_registry_dashboard.py` | Dashboard fn-registry Overview (18 cards) |
| `create_apps_dashboard.py` | Dashboard fn-registry Apps (10 cards) |
## Pipelines relacionados
| Pipeline | ID | Funcion |
|----------|-----|---------|
| `metabase_add_ops_db` | `metabase_add_ops_db_py_pipelines` | Registra operations.db de una app |
| `metabase_create_ops_dashboard` | `metabase_create_ops_dashboard_py_pipelines` | Crea dashboard operativo para una app |
| `metabase_fix_permissions` | `metabase_fix_permissions_py_pipelines` | Arregla SQLITE_READONLY_DIRECTORY |
## Credenciales
En `.env` local (NO commitear).
@@ -1,219 +0,0 @@
"""Crea un dashboard en Metabase con metricas de la tabla apps del fn-registry."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase.client import metabase_auth
from metabase import (
metabase_list_databases,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
metabase_list_dashboards,
)
# --- Config ---
METABASE_URL = "http://localhost:3000"
EMAIL = "admin@fnregistry.local"
PASSWORD = "FnRegistry2024!"
# --- Layout ---
# Grid de 24 unidades de ancho (estandar Metabase).
# Fila 0 (h=5): 4 scalars de 6 unidades → KPIs
# Fila 5 (h=8): 3 graficas de 8 unidades → distribucion general
# Fila 13 (h=9): 2 graficas de 12 unidades → uso de funciones + frameworks
# Fila 22 (h=8): tabla completa de apps → catalogo completo
CARDS = [
# ---- Fila 0: KPIs escalares (h=5) ----
{
"name": "Total de Apps",
"display": "scalar",
"sql": "SELECT COUNT(*) AS total FROM apps;",
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
},
{
"name": "Apps Go",
"display": "scalar",
"sql": "SELECT COUNT(*) AS apps_go FROM apps WHERE lang = 'go';",
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
},
{
"name": "Apps Python",
"display": "scalar",
"sql": "SELECT COUNT(*) AS apps_python FROM apps WHERE lang = 'py';",
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
},
{
"name": "Dominios con Apps",
"display": "scalar",
"sql": "SELECT COUNT(DISTINCT domain) AS dominios FROM apps;",
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
},
# ---- Fila 5: Distribucion general (h=8) ----
{
"name": "Apps por Lenguaje",
"display": "bar",
"sql": "SELECT lang, COUNT(*) AS cantidad FROM apps GROUP BY lang ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
},
{
"name": "Apps por Dominio",
"display": "pie",
"sql": "SELECT domain, COUNT(*) AS cantidad FROM apps GROUP BY domain ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 8, "col": 8, "row": 5,
},
{
"name": "Apps con Framework",
"display": "bar",
"sql": """
SELECT
CASE WHEN framework = '' OR framework IS NULL THEN '(sin framework)' ELSE framework END AS framework,
COUNT(*) AS cantidad
FROM apps
GROUP BY framework
ORDER BY cantidad DESC;
""",
"size_x": 8, "size_y": 8, "col": 16, "row": 5,
},
# ---- Fila 13: Analisis de dependencias (h=9) ----
{
"name": "Apps con Mas Dependencias de Funciones",
"display": "row",
"sql": """
SELECT
name || ' (' || lang || ')' AS app,
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS num_funciones_usadas
FROM apps
WHERE uses_functions != '[]' AND uses_functions != ''
ORDER BY num_funciones_usadas DESC
LIMIT 15;
""",
"size_x": 12, "size_y": 9, "col": 0, "row": 13,
},
{
"name": "Funciones del Registry Mas Usadas en Apps",
"display": "row",
"sql": """
WITH RECURSIVE split_uses(app_id, rest, val) AS (
SELECT id, uses_functions || ',', NULL
FROM apps
WHERE uses_functions != '[]' AND uses_functions != ''
UNION ALL
SELECT app_id,
SUBSTR(rest, INSTR(rest, ',') + 1),
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
FROM split_uses WHERE rest != ''
)
SELECT val AS funcion_usada, COUNT(*) AS veces_usada
FROM split_uses
WHERE val IS NOT NULL AND val != '' AND val != ']'
GROUP BY val
ORDER BY veces_usada DESC
LIMIT 15;
""",
"size_x": 12, "size_y": 9, "col": 12, "row": 13,
},
# ---- Fila 22: Catalogo completo (h=9) ----
{
"name": "Catalogo de Apps",
"display": "table",
"sql": """
SELECT
id,
name,
lang,
domain,
CASE WHEN framework = '' THEN '-' ELSE framework END AS framework,
description,
entry_point,
updated_at
FROM apps
ORDER BY domain, name;
""",
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
},
]
def main():
print("Autenticando en Metabase...")
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
# Encontrar la database registry.db
dbs = metabase_list_databases(client)
registry_db_id = None
for db in dbs:
if "registry" in db.get("name", "").lower() or (
db.get("engine") == "sqlite"
and "registry" in db.get("details", {}).get("db", "")
):
registry_db_id = db["id"]
print(f" Database encontrada: {db['name']} (id={db['id']})")
break
if not registry_db_id:
print("ERROR: No se encontro registry.db en Metabase.")
print("Databases disponibles:")
for db in dbs:
print(f" - {db['id']}: {db['name']} ({db['engine']})")
sys.exit(1)
# Verificar si ya existe un dashboard con este nombre
existing = metabase_list_dashboards(client)
for d in existing:
if d.get("name") == "fn-registry Apps":
print(f" Dashboard ya existe (id={d['id']}), recreando...")
from metabase import metabase_delete_dashboard
metabase_delete_dashboard(client, d["id"])
# Crear cards
print("Creando cards...")
created_cards = []
for i, card_def in enumerate(CARDS):
card = metabase_create_card(
client,
name=card_def["name"],
dataset_query={
"database": registry_db_id,
"type": "native",
"native": {"query": card_def["sql"]},
},
display=card_def["display"],
description=f"fn-registry apps: {card_def['name']}",
)
created_cards.append((card, card_def))
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
# Crear dashboard
print("Creando dashboard...")
dashboard = metabase_create_dashboard(
client,
name="fn-registry Apps",
description="Dashboard de apps del registry: distribucion por lenguaje, dominio, dependencias y catalogo completo.",
)
dash_id = dashboard["id"]
print(f" Dashboard creado: id={dash_id}")
# Agregar cards al dashboard con posiciones
dashcards = []
for idx, (card, card_def) in enumerate(created_cards):
dashcards.append({
"id": -(idx + 1),
"card_id": card["id"],
"size_x": card_def["size_x"],
"size_y": card_def["size_y"],
"col": card_def["col"],
"row": card_def["row"],
})
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
client.close()
if __name__ == "__main__":
main()
@@ -1,265 +0,0 @@
"""Crea un dashboard en Metabase con metricas del fn-registry."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase.client import metabase_auth
from metabase import (
metabase_list_databases,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
metabase_list_dashboards,
)
# --- Config ---
METABASE_URL = "http://localhost:3000"
EMAIL = "admin@fnregistry.local"
PASSWORD = "FnRegistry2024!"
# --- Layout ---
# Grid de 24 unidades de ancho (estandar Metabase).
# Fila 0 (h=5): 5 scalars de 4-5 unidades cada uno → fila de KPIs
# Fila 5 (h=8): 3 graficas de 8 unidades → distribucion general
# Fila 13 (h=9): 3 graficas de 8 unidades → analisis profundo
# Fila 22 (h=8): 2 tablas de 12 unidades → cobertura + x lenguaje
# Fila 30 (h=8): 2 tablas de 12 unidades → tipos + recientes
# --- SQL Queries ---
CARDS = [
# ---- Fila 0: KPIs escalares (h=5) ----
{
"name": "Total de Funciones",
"display": "scalar",
"sql": "SELECT COUNT(*) AS total FROM functions;",
"size_x": 5, "size_y": 5, "col": 0, "row": 0,
},
{
"name": "Funciones con Tests",
"display": "scalar",
"sql": "SELECT COUNT(*) AS con_tests FROM functions WHERE tested = 1;",
"size_x": 5, "size_y": 5, "col": 5, "row": 0,
},
{
"name": "Funciones sin Tests",
"display": "scalar",
"sql": "SELECT COUNT(*) AS sin_tests FROM functions WHERE tested = 0;",
"size_x": 4, "size_y": 5, "col": 10, "row": 0,
},
{
"name": "Total de Tipos",
"display": "scalar",
"sql": "SELECT COUNT(*) AS total FROM types;",
"size_x": 5, "size_y": 5, "col": 14, "row": 0,
},
{
"name": "Proposals Pendientes",
"display": "scalar",
"sql": "SELECT COUNT(*) AS pendientes FROM proposals WHERE status = 'pending';",
"size_x": 5, "size_y": 5, "col": 19, "row": 0,
},
# ---- Fila 5: Distribucion general (h=8) ----
{
"name": "Funciones por Lenguaje",
"display": "bar",
"sql": "SELECT lang, COUNT(*) AS cantidad FROM functions GROUP BY lang ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
},
{
"name": "Funciones por Dominio",
"display": "pie",
"sql": "SELECT domain, COUNT(*) AS cantidad FROM functions GROUP BY domain ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 8, "col": 8, "row": 5,
},
{
"name": "Funciones por Kind",
"display": "bar",
"sql": "SELECT kind, COUNT(*) AS cantidad FROM functions GROUP BY kind ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 8, "col": 16, "row": 5,
},
# ---- Fila 13: Analisis profundo (h=9) ----
{
"name": "Puras vs Impuras",
"display": "pie",
"sql": "SELECT purity, COUNT(*) AS cantidad FROM functions GROUP BY purity ORDER BY cantidad DESC;",
"size_x": 8, "size_y": 9, "col": 0, "row": 13,
},
{
"name": "Funciones Mas Usadas por Otras",
"display": "row",
"sql": """
WITH RECURSIVE split_uses(fn_id, rest, val) AS (
SELECT id, uses_functions || ',', NULL FROM functions WHERE uses_functions != '[]' AND uses_functions != ''
UNION ALL
SELECT fn_id,
SUBSTR(rest, INSTR(rest, ',') + 1),
TRIM(SUBSTR(rest, 1, INSTR(rest, ',') - 1), ' "[]')
FROM split_uses WHERE rest != ''
)
SELECT val AS funcion_usada, COUNT(*) AS veces_usada
FROM split_uses
WHERE val IS NOT NULL AND val != '' AND val != ']'
GROUP BY val
ORDER BY veces_usada DESC
LIMIT 15;
""",
"size_x": 8, "size_y": 9, "col": 8, "row": 13,
},
{
"name": "Funciones Mas Complejas (mas dependencias)",
"display": "row",
"sql": """
SELECT
name || ' (' || lang || ')' AS funcion,
(LENGTH(uses_functions) - LENGTH(REPLACE(uses_functions, ',', ''))
+ CASE WHEN uses_functions != '[]' AND uses_functions != '' THEN 1 ELSE 0 END) AS num_dependencias,
(LENGTH(uses_types) - LENGTH(REPLACE(uses_types, ',', ''))
+ CASE WHEN uses_types != '[]' AND uses_types != '' THEN 1 ELSE 0 END) AS num_tipos
FROM functions
WHERE uses_functions != '[]' AND uses_functions != ''
ORDER BY num_dependencias DESC
LIMIT 15;
""",
"size_x": 8, "size_y": 9, "col": 16, "row": 13,
},
# ---- Fila 22: Cobertura + cross-table (h=8) ----
{
"name": "Cobertura de Tests por Dominio",
"display": "bar",
"sql": """
SELECT
domain,
SUM(CASE WHEN tested = 1 THEN 1 ELSE 0 END) AS con_tests,
SUM(CASE WHEN tested = 0 THEN 1 ELSE 0 END) AS sin_tests
FROM functions
GROUP BY domain
ORDER BY domain;
""",
"size_x": 12, "size_y": 8, "col": 0, "row": 22,
},
{
"name": "Funciones por Lenguaje y Dominio",
"display": "table",
"sql": """
SELECT
domain,
SUM(CASE WHEN lang = 'go' THEN 1 ELSE 0 END) AS go,
SUM(CASE WHEN lang = 'py' THEN 1 ELSE 0 END) AS python,
SUM(CASE WHEN lang = 'bash' THEN 1 ELSE 0 END) AS bash,
SUM(CASE WHEN lang = 'ts' THEN 1 ELSE 0 END) AS typescript,
COUNT(*) AS total
FROM functions
GROUP BY domain
ORDER BY total DESC;
""",
"size_x": 12, "size_y": 8, "col": 12, "row": 22,
},
# ---- Fila 30: Tipos + recientes (h=8) ----
{
"name": "Tipos por Dominio y Algebraic",
"display": "table",
"sql": """
SELECT
domain,
algebraic,
COUNT(*) AS cantidad
FROM types
GROUP BY domain, algebraic
ORDER BY domain, cantidad DESC;
""",
"size_x": 12, "size_y": 8, "col": 0, "row": 30,
},
{
"name": "Funciones Recientes (ultimas 20 indexadas)",
"display": "table",
"sql": """
SELECT name, lang, domain, kind, purity, tested
FROM functions
ORDER BY created_at DESC
LIMIT 20;
""",
"size_x": 12, "size_y": 8, "col": 12, "row": 30,
},
]
def main():
print("Autenticando en Metabase...")
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
# Encontrar la database registry.db
dbs = metabase_list_databases(client)
registry_db_id = None
for db in dbs:
if "registry" in db.get("name", "").lower() or (
db.get("engine") == "sqlite"
and "registry" in db.get("details", {}).get("db", "")
):
registry_db_id = db["id"]
print(f" Database encontrada: {db['name']} (id={db['id']})")
break
if not registry_db_id:
print("ERROR: No se encontro registry.db en Metabase.")
print("Databases disponibles:")
for db in dbs:
print(f" - {db['id']}: {db['name']} ({db['engine']})")
sys.exit(1)
# Verificar si ya existe un dashboard con este nombre
existing = metabase_list_dashboards(client)
for d in existing:
if d.get("name") == "fn-registry Overview":
print(f" Dashboard ya existe (id={d['id']}), recreando...")
from metabase import metabase_delete_dashboard
metabase_delete_dashboard(client, d["id"])
# Crear cards
print("Creando cards...")
created_cards = []
for i, card_def in enumerate(CARDS):
card = metabase_create_card(
client,
name=card_def["name"],
dataset_query={
"database": registry_db_id,
"type": "native",
"native": {"query": card_def["sql"]},
},
display=card_def["display"],
description=f"fn-registry: {card_def['name']}",
)
created_cards.append((card, card_def))
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
# Crear dashboard
print("Creando dashboard...")
dashboard = metabase_create_dashboard(
client,
name="fn-registry Overview",
description="Dashboard de metricas del registry: funciones, tipos, tests, dependencias y complejidad.",
)
dash_id = dashboard["id"]
print(f" Dashboard creado: id={dash_id}")
# Agregar cards al dashboard con posiciones
dashcards = []
for idx, (card, card_def) in enumerate(created_cards):
dashcards.append({
"id": -(idx + 1),
"card_id": card["id"],
"size_x": card_def["size_x"],
"size_y": card_def["size_y"],
"col": card_def["col"],
"row": card_def["row"],
})
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
client.close()
if __name__ == "__main__":
main()
@@ -1,195 +0,0 @@
"""Crea un dashboard en Metabase para monitorear operations de script_navegador."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase.client import metabase_auth
from metabase import (
metabase_list_databases,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
metabase_list_dashboards,
)
from metabase.databases import metabase_add_database
# --- Config ---
METABASE_URL = "http://localhost:3000"
EMAIL = "admin@fnregistry.local"
PASSWORD = "FnRegistry2024!"
# Path de operations.db dentro del contenedor Docker
# Copiar con: docker exec metabase mkdir -p /data/ops-script-navegador && docker cp apps/script_navegador/operations.db metabase:/data/ops-script-navegador/operations.db
OPS_DB_PATH = "/data/ops-script-navegador/operations.db"
DB_NAME = "ops-script-navegador"
CARDS = [
# ---- Fila 0: KPIs (h=5) ----
{
"name": "Total Ejecuciones",
"display": "scalar",
"sql": "SELECT COUNT(*) AS total FROM executions;",
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
},
{
"name": "Ejecuciones Exitosas",
"display": "scalar",
"sql": "SELECT COUNT(*) AS exitosas FROM executions WHERE status = 'success';",
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
},
{
"name": "Ejecuciones Fallidas",
"display": "scalar",
"sql": "SELECT COUNT(*) AS fallidas FROM executions WHERE status = 'failure';",
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
},
{
"name": "Duracion Promedio (ms)",
"display": "scalar",
"sql": "SELECT ROUND(AVG(duration_ms)) AS avg_ms FROM executions WHERE status = 'success';",
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
},
# ---- Fila 5: Tendencias (h=8) ----
{
"name": "Ejecuciones por Estado",
"display": "pie",
"sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status;",
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
},
{
"name": "Duracion por Ejecucion (timeline)",
"display": "line",
"sql": """
SELECT
started_at,
duration_ms,
status
FROM executions
ORDER BY started_at;
""",
"size_x": 16, "size_y": 8, "col": 8, "row": 5,
},
# ---- Fila 13: Detalle de pasos (h=9) ----
{
"name": "Pasos por Script (metricas)",
"display": "table",
"sql": """
SELECT
id,
status,
records_in AS pasos_total,
records_out AS pasos_exitosos,
duration_ms,
CASE WHEN error = '' THEN '-' ELSE error END AS error,
json_extract(metrics, '$.script_name') AS script,
started_at
FROM executions
ORDER BY started_at DESC
LIMIT 20;
""",
"size_x": 24, "size_y": 9, "col": 0, "row": 13,
},
# ---- Fila 22: Logs (h=9) ----
{
"name": "Logs Recientes",
"display": "table",
"sql": """
SELECT
level,
source,
message,
json_extract(metadata, '$.action') AS action,
json_extract(metadata, '$.elapsed_ms') AS elapsed_ms,
created_at
FROM logs
ORDER BY created_at DESC
LIMIT 50;
""",
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
},
]
DASHBOARD_NAME = "script_navegador Operations"
def main():
print("Autenticando en Metabase...")
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
# Buscar si ya existe la database
dbs = metabase_list_databases(client)
ops_db_id = None
for db in dbs:
if db.get("name") == DB_NAME:
ops_db_id = db["id"]
print(f" Database ya existe: {DB_NAME} (id={ops_db_id})")
break
if not ops_db_id:
print(f"Registrando {DB_NAME} como datasource SQLite ({OPS_DB_PATH})...")
new_db = metabase_add_database(
client=client,
name=DB_NAME,
engine="sqlite",
details={"db": OPS_DB_PATH},
)
ops_db_id = new_db["id"]
print(f" Database registrada: id={ops_db_id}")
# Eliminar dashboard existente si lo hay
existing = metabase_list_dashboards(client)
for d in existing:
if d.get("name") == DASHBOARD_NAME:
print(f" Dashboard ya existe (id={d['id']}), recreando...")
from metabase import metabase_delete_dashboard
metabase_delete_dashboard(client, d["id"])
# Crear cards
print("Creando cards...")
created_cards = []
for i, card_def in enumerate(CARDS):
card = metabase_create_card(
client,
name=card_def["name"],
dataset_query={
"database": ops_db_id,
"type": "native",
"native": {"query": card_def["sql"]},
},
display=card_def["display"],
description=f"script_navegador: {card_def['name']}",
)
created_cards.append((card, card_def))
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
# Crear dashboard
print("Creando dashboard...")
dashboard = metabase_create_dashboard(
client,
name=DASHBOARD_NAME,
description="Monitoreo de ejecuciones de script_navegador: KPIs, tendencias, detalle de pasos y logs.",
)
dash_id = dashboard["id"]
print(f" Dashboard creado: id={dash_id}")
# Agregar cards al dashboard
dashcards = []
for idx, (card, card_def) in enumerate(created_cards):
dashcards.append({
"id": -(idx + 1),
"card_id": card["id"],
"size_x": card_def["size_x"],
"size_y": card_def["size_y"],
"col": card_def["col"],
"row": card_def["row"],
})
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
client.close()
if __name__ == "__main__":
main()
-411
View File
@@ -1,411 +0,0 @@
"""
apps/metabase_registry/main.py
==============================
Setup completo de fn-registry en Metabase: datasource, cards y dashboard.
USO
---
Via variables de entorno:
METABASE_URL=http://localhost:3000 \
METABASE_ADMIN_EMAIL=admin@example.com \
METABASE_ADMIN_PASSWORD=secret \
REGISTRY_DB_PATH=/data/registry/registry.db \
python main.py
Via argumentos CLI:
python main.py \
--url http://localhost:3000 \
--admin-email admin@example.com \
--admin-password secret \
--registry-db-path /registry.db
Para crear un usuario nuevo (opcional):
python main.py ... \
--new-user-email dev@example.com \
--new-user-first-name Dev \
--new-user-last-name User \
--new-user-password devpass
NOTA SOBRE registry.db EN DOCKER
----------------------------------
Metabase corre en Docker y necesita acceder a registry.db. El path que
se configura en Metabase (--registry-db-path) debe ser la ruta DENTRO
del contenedor. Usa setup_volume.sh para copiar el archivo al contenedor
antes de ejecutar este script.
./setup_volume.sh /home/lucas/fn_registry/registry.db
Tras copiar, el archivo queda en /registry.db dentro del contenedor,
que es el valor por defecto de --registry-db-path.
DEPENDENCIAS
------------
Instalar con: pip install -r requirements.txt
O con uv: uv pip install -r requirements.txt
Las funciones metabase_add_database y metabase_list_databases deben
existir en python/functions/metabase/databases.py (creadas por el
fn-constructor).
"""
import argparse
import os
import sys
import json
# Agregar el directorio de funciones Python al path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase import (
MetabaseClient,
metabase_create_user,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
)
from metabase.client import metabase_auth
from metabase.databases import metabase_add_database, metabase_list_databases
# ---------------------------------------------------------------------------
# Configuracion de cards de ejemplo
# ---------------------------------------------------------------------------
CARDS_CONFIG = [
{
"name": "Funciones por dominio",
"description": "Cuenta de funciones agrupadas por dominio del registry",
"sql": "SELECT domain, count(*) AS total FROM functions GROUP BY domain ORDER BY total DESC",
"display": "bar",
},
{
"name": "Funciones puras vs impuras",
"description": "Distribucion de pureza funcional en el registry",
"sql": "SELECT purity, count(*) AS total FROM functions GROUP BY purity ORDER BY total DESC",
"display": "pie",
},
{
"name": "Funciones por kind",
"description": "Distribucion por tipo: function, pipeline, component",
"sql": "SELECT kind, count(*) AS total FROM functions GROUP BY kind ORDER BY total DESC",
"display": "bar",
},
{
"name": "Buscar funciones",
"description": "Lista completa de funciones con id, dominio, pureza y descripcion",
"sql": (
"SELECT id, domain, kind, purity, signature, description "
"FROM functions ORDER BY domain, name"
),
"display": "table",
},
{
"name": "Tipos por dominio",
"description": "Cuenta de tipos registrados por dominio",
"sql": "SELECT domain, count(*) AS total FROM types GROUP BY domain ORDER BY total DESC",
"display": "bar",
},
{
"name": "Proposals pendientes",
"description": "Proposals del registry que estan pendientes de revision",
"sql": (
"SELECT id, kind, title, created_by, created_at "
"FROM proposals WHERE status = 'pending' ORDER BY created_at DESC"
),
"display": "table",
},
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def log(msg: str) -> None:
print(f"[metabase_registry] {msg}", flush=True)
def log_ok(msg: str) -> None:
print(f"[metabase_registry] OK {msg}", flush=True)
def log_err(msg: str) -> None:
print(f"[metabase_registry] ERR {msg}", file=sys.stderr, flush=True)
def find_existing_database(client: MetabaseClient, name: str) -> dict | None:
"""Busca una database por nombre en Metabase. Retorna None si no existe."""
try:
databases = metabase_list_databases(client)
for db in databases:
if db.get("name") == name:
return db
except Exception as e:
log(f"No se pudo listar databases: {e}")
return None
def build_dataset_query(database_id: int, sql: str) -> dict:
"""Construye el dataset_query para una card SQL nativa."""
return {
"type": "native",
"database": database_id,
"native": {
"query": sql,
"template-tags": {},
},
}
def create_cards(client: MetabaseClient, database_id: int) -> list[dict]:
"""Crea todas las cards de ejemplo. Retorna lista de cards creadas."""
created = []
for cfg in CARDS_CONFIG:
log(f"Creando card: {cfg['name']!r} ...")
try:
card = metabase_create_card(
client=client,
name=cfg["name"],
dataset_query=build_dataset_query(database_id, cfg["sql"]),
display=cfg["display"],
description=cfg["description"],
)
log_ok(f"Card creada: id={card['id']} nombre={card['name']!r}")
created.append(card)
except Exception as e:
log_err(f"No se pudo crear card {cfg['name']!r}: {e}")
return created
def create_overview_dashboard(client: MetabaseClient, cards: list[dict]) -> dict | None:
"""Crea el dashboard 'fn-registry Overview' con todas las cards."""
log("Creando dashboard 'fn-registry Overview' ...")
try:
dashboard = metabase_create_dashboard(
client=client,
name="fn-registry Overview",
description="Vista general del function registry: funciones, tipos, proposals y metricas.",
)
log_ok(f"Dashboard creado: id={dashboard['id']}")
except Exception as e:
log_err(f"No se pudo crear dashboard: {e}")
return None
if not cards:
log("Sin cards para agregar al dashboard.")
return dashboard
# Posicionar cards en una grilla de 3 columnas, 24 unidades de ancho total.
# Cada card ocupa 8 columnas x 6 filas.
cols = 3
card_w = 8
card_h = 6
dashcards = []
for idx, card in enumerate(cards):
col = (idx % cols) * card_w
row = (idx // cols) * card_h
dashcards.append({
"id": -(idx + 1), # ID negativo = nueva dashcard
"card_id": card["id"],
"size_x": card_w,
"size_y": card_h,
"col": col,
"row": row,
"parameter_mappings": [],
"visualization_settings": {},
})
try:
metabase_update_dashboard(
client=client,
dashboard_id=dashboard["id"],
dashcards=dashcards,
)
log_ok(f"Dashboard actualizado con {len(dashcards)} cards.")
except Exception as e:
log_err(f"No se pudo poblar el dashboard con cards: {e}")
return dashboard
# ---------------------------------------------------------------------------
# Flujo principal
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> int:
"""Ejecuta el setup completo. Retorna 0 en exito, 1 en fallo critico."""
# 1. Autenticar como admin
log(f"Autenticando en {args.url} como {args.admin_email} ...")
try:
client = metabase_auth(args.url, args.admin_email, args.admin_password)
log_ok("Autenticacion exitosa.")
except Exception as e:
log_err(f"Fallo de autenticacion: {e}")
return 1
with client:
# 2. Crear usuario nuevo (opcional)
if args.new_user_email:
if not args.new_user_first_name or not args.new_user_last_name:
log_err("Para crear usuario se requieren --new-user-first-name y --new-user-last-name.")
return 1
log(f"Creando usuario {args.new_user_email} ...")
try:
user = metabase_create_user(
client=client,
first_name=args.new_user_first_name,
last_name=args.new_user_last_name,
email=args.new_user_email,
password=args.new_user_password or "",
)
log_ok(f"Usuario creado: id={user['id']} email={user['email']}")
except Exception as e:
log_err(f"No se pudo crear usuario: {e}")
# No es critico, continuar
# 3. Agregar registry.db como datasource SQLite
db_name = "fn-registry"
log(f"Verificando si ya existe la database {db_name!r} en Metabase ...")
existing_db = find_existing_database(client, db_name)
if existing_db:
db_id = existing_db["id"]
log_ok(f"Database ya existe: id={db_id} nombre={db_name!r}. Se reutiliza.")
else:
log(f"Registrando registry.db ({args.registry_db_path}) como datasource SQLite ...")
try:
new_db = metabase_add_database(
client=client,
name=db_name,
engine="sqlite",
details={"db": args.registry_db_path},
)
db_id = new_db["id"]
log_ok(f"Database registrada: id={db_id} path={args.registry_db_path}")
except Exception as e:
log_err(f"No se pudo registrar la database: {e}")
log_err(
"Verifica que registry.db este accesible desde el contenedor Docker. "
"Usa setup_volume.sh para copiar el archivo."
)
return 1
# 4. Crear cards de ejemplo
log(f"Creando {len(CARDS_CONFIG)} cards con database_id={db_id} ...")
cards = create_cards(client, db_id)
log(f"Cards creadas: {len(cards)}/{len(CARDS_CONFIG)}")
# 5. Crear dashboard
dashboard = create_overview_dashboard(client, cards)
if dashboard:
log_ok(
f"Dashboard disponible en: {args.url}/dashboard/{dashboard['id']}"
)
else:
log_err("El dashboard no pudo crearse.")
summary = {
"url": args.url,
"database_id": db_id,
"cards_created": len(cards),
"dashboard_id": dashboard["id"] if dashboard else None,
"dashboard_url": f"{args.url}/dashboard/{dashboard['id']}" if dashboard else None,
}
print("\n--- Resumen ---")
print(json.dumps(summary, indent=2))
return 0
# ---------------------------------------------------------------------------
# CLI / env vars
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="metabase_registry",
description="Setup de fn-registry como datasource en Metabase.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
# Conexion Metabase
p.add_argument(
"--url",
default=os.environ.get("METABASE_URL", "http://localhost:3000"),
help="URL base de Metabase (env: METABASE_URL). Default: http://localhost:3000",
)
p.add_argument(
"--admin-email",
default=os.environ.get("METABASE_ADMIN_EMAIL", "admin@example.com"),
dest="admin_email",
help="Email del admin (env: METABASE_ADMIN_EMAIL).",
)
p.add_argument(
"--admin-password",
default=os.environ.get("METABASE_ADMIN_PASSWORD", ""),
dest="admin_password",
help="Password del admin (env: METABASE_ADMIN_PASSWORD).",
)
# Registry DB path (ruta dentro del contenedor Docker)
p.add_argument(
"--registry-db-path",
default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"),
dest="registry_db_path",
help=(
"Ruta al registry.db DENTRO del contenedor Docker "
"(env: REGISTRY_DB_PATH). Default: /registry.db"
),
)
# Nuevo usuario (todos opcionales)
p.add_argument(
"--new-user-email",
default=os.environ.get("NEW_USER_EMAIL", ""),
dest="new_user_email",
help="Email del nuevo usuario a crear (env: NEW_USER_EMAIL). Opcional.",
)
p.add_argument(
"--new-user-first-name",
default=os.environ.get("NEW_USER_FIRST_NAME", ""),
dest="new_user_first_name",
help="Nombre del nuevo usuario (env: NEW_USER_FIRST_NAME).",
)
p.add_argument(
"--new-user-last-name",
default=os.environ.get("NEW_USER_LAST_NAME", ""),
dest="new_user_last_name",
help="Apellido del nuevo usuario (env: NEW_USER_LAST_NAME).",
)
p.add_argument(
"--new-user-password",
default=os.environ.get("NEW_USER_PASSWORD", ""),
dest="new_user_password",
help="Password del nuevo usuario (env: NEW_USER_PASSWORD). Opcional.",
)
return p
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if not args.admin_password:
parser.error(
"Se requiere la password del admin. "
"Usa --admin-password o la variable de entorno METABASE_ADMIN_PASSWORD."
)
sys.exit(run(args))
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
# Dependencias de apps/metabase_registry
#
# Instalar con pip:
# pip install -r requirements.txt
#
# O con uv (recomendado, desde la raiz del repo):
# cd /home/lucas/fn_registry/python && uv pip install -r ../apps/metabase_registry/requirements.txt
#
# O directamente desde el directorio python (que ya tiene httpx):
# cd /home/lucas/fn_registry/python && uv run python ../apps/metabase_registry/main.py
# HTTP client — mismo que usa el paquete python/functions/metabase
httpx>=0.27.0
-16
View File
@@ -1,16 +0,0 @@
---
name: pipeline_launcher
lang: go
domain: tools
description: "TUI para lanzar y monitorear pipelines del fn-registry con historial de ejecuciones."
tags: [pipeline, tui, bubbletea, runner, launcher]
uses_functions: []
uses_types: []
framework: bubbletea
entry_point: "main.go"
dir_path: "apps/pipeline_launcher"
---
## Notas
Aplicacion TUI que lista pipelines con tag `launcher` del registry, permite ejecutarlos y muestra historial de ejecuciones desde operations.db.
-181
View File
@@ -1,181 +0,0 @@
package app
import (
"fmt"
ops "fn-registry/fn_operations"
"fn-registry/registry"
"pipeline-launcher/config"
"pipeline-launcher/views"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
// View identifies which tab is active.
type View int
const (
ViewPipelines View = iota
ViewHistory
)
var tabNames = []string{"Pipelines", "History"}
// Model is the top-level TUI model with two tabs.
type Model struct {
tui.BaseModel
activeTab int
pipelines views.PipelinesModel
history views.HistoryModel
ready bool
registryDB *registry.DB
opsDB *ops.DB
}
// New creates the Model, opening both databases.
func New(cfg config.Config) (Model, error) {
regDB, err := registry.Open(cfg.RegistryDB)
if err != nil {
return Model{}, fmt.Errorf("opening registry: %w", err)
}
opsDB, err := ops.Open(cfg.OperationsDB)
if err != nil {
regDB.Close()
return Model{}, fmt.Errorf("opening operations: %w", err)
}
// Build pipeline name map for history view
fns, _ := regDB.SearchFunctions("", registry.KindPipeline, "", "", "")
names := make(map[string]string, len(fns))
for _, f := range fns {
names[f.ID] = f.Name
}
styles := tui.DarkStyles()
return Model{
BaseModel: tui.NewBaseModel().WithStyles(styles),
pipelines: views.NewPipelinesModel(styles, regDB, opsDB, cfg.RegistryRoot),
history: views.NewHistoryModel(styles, opsDB, names),
registryDB: regDB,
opsDB: opsDB,
}, nil
}
// Close closes both database connections.
func (m Model) Close() {
if m.registryDB != nil {
m.registryDB.Close()
}
if m.opsDB != nil {
m.opsDB.Close()
}
}
func (m Model) Init() tea.Cmd {
return m.pipelines.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case views.KeyQuit:
return m, tea.Quit
case "q":
updated, atBase := m.handleBack()
if atBase {
return updated, tea.Quit
}
return updated, nil
case views.KeyEsc, views.KeyBack:
updated, atBase := m.handleBack()
if atBase {
return updated, nil
}
return updated, nil
case views.KeyTab:
m.activeTab = (m.activeTab + 1) % len(tabNames)
return m, m.initActiveView()
case "shift+tab":
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
return m, m.initActiveView()
}
case tea.WindowSizeMsg:
m.HandleWindowSize(msg)
m.ready = true
}
var cmd tea.Cmd
switch View(m.activeTab) {
case ViewPipelines:
m.pipelines, cmd = m.pipelines.Update(msg)
case ViewHistory:
m.history, cmd = m.history.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
tabs := m.renderTabs()
var content string
switch View(m.activeTab) {
case ViewPipelines:
content = m.pipelines.View()
case ViewHistory:
content = m.history.View()
}
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
return lipgloss.JoinVertical(lipgloss.Left,
tabs,
"",
content,
"",
status,
)
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range tabNames {
if i == m.activeTab {
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
} else {
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
}
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.Styles.Header.Render("Pipeline Launcher") + " " + row
}
func (m Model) handleBack() (Model, bool) {
switch View(m.activeTab) {
case ViewPipelines:
atBase := m.pipelines.HandleBack()
return m, atBase
case ViewHistory:
atBase := m.history.HandleBack()
return m, atBase
}
return m, true
}
func (m Model) initActiveView() tea.Cmd {
switch View(m.activeTab) {
case ViewPipelines:
return m.pipelines.Init()
case ViewHistory:
return m.history.Init()
}
return nil
}
-27
View File
@@ -1,27 +0,0 @@
package config
import (
"os"
"path/filepath"
)
// Config holds paths to databases.
type Config struct {
RegistryDB string // Path to registry.db
OperationsDB string // Path to operations.db
RegistryRoot string // Root directory of the registry (for resolving file paths)
}
// Default returns a Config resolved from environment or sensible defaults.
func Default() Config {
root := os.Getenv("FN_REGISTRY_ROOT")
if root == "" {
root = "."
}
return Config{
RegistryDB: filepath.Join(root, "registry.db"),
OperationsDB: filepath.Join(root, "apps", "pipeline_launcher", "operations.db"),
RegistryRoot: root,
}
}
-38
View File
@@ -1,38 +0,0 @@
module pipeline-launcher
go 1.22.2
require (
fn-registry v0.0.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/lucasdataproyects/devfactory v0.0.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
fn-registry => /home/lucas/fn_registry
github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
)
-51
View File
@@ -1,51 +0,0 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-29
View File
@@ -1,29 +0,0 @@
package main
import (
"fmt"
"os"
"pipeline-launcher/app"
"pipeline-launcher/config"
"github.com/lucasdataproyects/devfactory/tui"
)
func main() {
cfg := config.Default()
model, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer model.Close()
result := tui.RunFullscreen(model)
if result.IsErr() {
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
os.Exit(1)
}
}
-219
View File
@@ -1,219 +0,0 @@
package views
import (
"encoding/json"
"fmt"
"strings"
ops "fn-registry/fn_operations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type historyState int
const (
historyLoading historyState = iota
historyList
historyDetail
)
type historyLoadedMsg []ops.Execution
// HistoryModel shows execution history.
type HistoryModel struct {
state historyState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
executions []ops.Execution
detail string
scrollOff int
opsDB *ops.DB
pipelineNames map[string]string
}
// NewHistoryModel creates a new history view.
func NewHistoryModel(styles tui.Styles, opsDB *ops.DB, names map[string]string) HistoryModel {
return HistoryModel{
state: historyLoading,
list: tui.NewFilteredList(nil, "Filter executions..."),
spinner: tui.NewSpinner("Loading history..."),
styles: styles,
opsDB: opsDB,
pipelineNames: names,
}
}
func (m HistoryModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadHistory(),
)
}
func (m HistoryModel) loadHistory() tea.Cmd {
return func() tea.Msg {
execs, err := m.opsDB.ListExecutions("", "", "")
if err != nil {
return historyLoadedMsg(nil)
}
return historyLoadedMsg(execs)
}
}
func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) {
switch msg := msg.(type) {
case historyLoadedMsg:
m.executions = []ops.Execution(msg)
items := make([]tui.ListItem, len(m.executions))
for i, e := range m.executions {
icon := "●"
switch e.Status {
case ops.ExecSuccess:
icon = "✓"
case ops.ExecFailure:
icon = "✗"
case ops.ExecPartial:
icon = "~"
}
name := e.PipelineID
if n, ok := m.pipelineNames[e.PipelineID]; ok {
name = n
}
dur := ""
if e.DurationMs != nil {
dur = fmt.Sprintf("%dms", *e.DurationMs)
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s %s", icon, name),
Description: fmt.Sprintf("%s — %s — %s", string(e.Status), dur, e.StartedAt.Format("2006-01-02 15:04:05")),
Value: e,
}
}
m.list.SetItems(items)
m.state = historyList
return m, nil
case tea.KeyMsg:
switch m.state {
case historyList:
switch msg.String() {
case "r":
m.state = historyLoading
m.spinner = tui.NewSpinner("Loading history...")
return m, tea.Batch(m.spinner.Init(), m.loadHistory())
case "enter":
// Delegate enter to list first so it selects the cursor item
updated, _ := m.list.Update(msg)
m.list = updated.(tui.FilteredListModel)
if item := m.list.SelectedItem(); item != nil {
e := item.Value.(ops.Execution)
m.detail = formatExecution(e)
m.state = historyDetail
m.scrollOff = 0
}
return m, nil
}
case historyDetail:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case historyLoading:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case historyList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *HistoryModel) HandleBack() bool {
switch m.state {
case historyDetail:
m.state = historyList
return false
default:
return true
}
}
func (m HistoryModel) View() string {
switch m.state {
case historyLoading:
return m.spinner.View()
case historyList:
if len(m.executions) == 0 {
return m.styles.Muted.Render("No executions found. Launch a pipeline first.")
}
help := m.styles.Muted.Render(" Enter: details │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case historyDetail:
return m.renderDetail()
}
return ""
}
func (m HistoryModel) renderDetail() string {
lines := splitLines(m.detail)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Execution Detail")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func formatExecution(e ops.Execution) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("ID: %s\n", e.ID))
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", e.PipelineID))
sb.WriteString(fmt.Sprintf("Status: %s\n", e.Status))
sb.WriteString(fmt.Sprintf("Started: %s\n", e.StartedAt.Format("2006-01-02 15:04:05")))
if e.EndedAt != nil {
sb.WriteString(fmt.Sprintf("Ended: %s\n", e.EndedAt.Format("2006-01-02 15:04:05")))
}
if e.DurationMs != nil {
sb.WriteString(fmt.Sprintf("Duration: %dms\n", *e.DurationMs))
}
if e.RecordsIn != nil {
sb.WriteString(fmt.Sprintf("Records In: %d\n", *e.RecordsIn))
}
if e.RecordsOut != nil {
sb.WriteString(fmt.Sprintf("Records Out: %d\n", *e.RecordsOut))
}
if e.Error != "" {
sb.WriteString(fmt.Sprintf("\n--- Error ---\n%s\n", e.Error))
}
if len(e.Metrics) > 0 {
sb.WriteString("\n--- Metrics ---\n")
b, _ := json.MarshalIndent(e.Metrics, "", " ")
sb.WriteString(string(b))
sb.WriteString("\n")
}
return sb.String()
}
-14
View File
@@ -1,14 +0,0 @@
package views
// Navigation key constants.
const (
KeyQuit = "ctrl+c"
KeyEsc = "esc"
KeyBack = "0"
KeyTab = "tab"
)
// IsBack returns true if the key should trigger back navigation.
func IsBack(key string) bool {
return key == KeyEsc || key == KeyBack
}
-398
View File
@@ -1,398 +0,0 @@
package views
import (
"fmt"
"strings"
ops "fn-registry/fn_operations"
"fn-registry/registry"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type pipelinesState int
const (
pipelinesLoading pipelinesState = iota
pipelinesList
pipelinesArgs
pipelinesRunning
pipelinesOutput
)
type pipelinesLoadedMsg []registry.Function
type pipelineFinishedMsg RunResult
type pipelineFlagsMsg []PipelineFlag
// PipelinesModel lists and launches pipelines.
type PipelinesModel struct {
state pipelinesState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
pipelines []registry.Function
selectedFn *registry.Function
flags []PipelineFlag
inputs []textinput.Model
focusIdx int
output string
lastResult *RunResult
scrollOff int
err error
registryDB *registry.DB
opsDB *ops.DB
registryRoot string
}
// NewPipelinesModel creates a new pipelines view.
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
return PipelinesModel{
state: pipelinesLoading,
list: tui.NewFilteredList(nil, "Filter pipelines..."),
spinner: tui.NewSpinner("Loading pipelines..."),
styles: styles,
registryDB: regDB,
opsDB: opsDB,
registryRoot: root,
}
}
func (m PipelinesModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadPipelines(),
)
}
func (m PipelinesModel) loadPipelines() tea.Cmd {
return func() tea.Msg {
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
if err != nil {
return pipelinesLoadedMsg(nil)
}
// Only show pipelines tagged with "launcher"
var launchable []registry.Function
for _, f := range fns {
for _, t := range f.Tags {
if t == "launcher" {
launchable = append(launchable, f)
break
}
}
}
return pipelinesLoadedMsg(launchable)
}
}
// buildInputs creates a textinput for each flag, pre-filled with defaults.
func (m *PipelinesModel) buildInputs() tea.Cmd {
m.inputs = make([]textinput.Model, len(m.flags))
for i, f := range m.flags {
ti := textinput.New()
ti.CharLimit = 256
ti.Width = 40
if f.Default != "" {
ti.SetValue(f.Default)
}
if f.Required {
ti.Placeholder = "(requerido)"
}
m.inputs[i] = ti
}
m.focusIdx = 0
if len(m.inputs) > 0 {
m.inputs[0].Focus()
return textinput.Blink
}
return nil
}
func (m *PipelinesModel) focusInput(idx int) tea.Cmd {
if idx < 0 || idx >= len(m.inputs) {
return nil
}
for i := range m.inputs {
m.inputs[i].Blur()
}
m.focusIdx = idx
m.inputs[idx].Focus()
return textinput.Blink
}
// collectArgs builds CLI args from the form inputs.
func (m PipelinesModel) collectArgs() []string {
var args []string
for i, f := range m.flags {
val := strings.TrimSpace(m.inputs[i].Value())
if val != "" {
args = append(args, "--"+f.Name, val)
}
}
return args
}
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
switch msg := msg.(type) {
case pipelinesLoadedMsg:
m.pipelines = []registry.Function(msg)
items := make([]tui.ListItem, len(m.pipelines))
for i, p := range m.pipelines {
items[i] = tui.ListItem{
Title: p.Name,
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
Value: p,
}
}
m.list.SetItems(items)
m.state = pipelinesList
return m, nil
case pipelineFlagsMsg:
m.flags = []PipelineFlag(msg)
cmd := m.buildInputs()
return m, cmd
case pipelineFinishedMsg:
result := RunResult(msg)
m.lastResult = &result
var sb strings.Builder
if result.Status == ops.ExecSuccess {
sb.WriteString("[OK] ")
} else {
sb.WriteString("[FAIL] ")
}
fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID)
fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID)
fmt.Fprintf(&sb, "Duration: %dms\n", result.DurationMs)
sb.WriteString("\n--- stdout ---\n")
if result.Stdout != "" {
sb.WriteString(result.Stdout)
} else {
sb.WriteString("(empty)")
}
if result.Stderr != "" {
sb.WriteString("\n--- stderr ---\n")
sb.WriteString(result.Stderr)
}
if result.Err != nil {
fmt.Fprintf(&sb, "\n--- error ---\n%v", result.Err)
}
m.output = sb.String()
m.state = pipelinesOutput
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case pipelinesList:
switch msg.String() {
case "r":
m.state = pipelinesLoading
m.spinner = tui.NewSpinner("Loading pipelines...")
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
case "enter":
updated, _ := m.list.Update(msg)
m.list = updated.(tui.FilteredListModel)
if item := m.list.SelectedItem(); item != nil {
fn := item.Value.(registry.Function)
m.selectedFn = &fn
m.flags = nil
m.inputs = nil
m.state = pipelinesArgs
root := m.registryRoot
fnCopy := fn
return m, func() tea.Msg {
return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root))
}
}
return m, nil
}
case pipelinesArgs:
switch msg.String() {
case "tab", "down":
cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1))
return m, cmd
case "shift+tab", "up":
idx := m.focusIdx - 1
if idx < 0 {
idx = max(len(m.inputs)-1, 0)
}
cmd := m.focusInput(idx)
return m, cmd
case "ctrl+enter", "ctrl+s":
args := m.collectArgs()
m.state = pipelinesRunning
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name))
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args))
case "esc":
m.state = pipelinesList
return m, nil
}
case pipelinesOutput:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case pipelinesLoading, pipelinesRunning:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case pipelinesList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
case pipelinesArgs:
if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) {
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
}
}
return m, cmd
}
func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd {
regRoot := m.registryRoot
opsDB := m.opsDB
fnCopy := *fn
return func() tea.Msg {
result := RunPipeline(&fnCopy, regRoot, opsDB, args)
return pipelineFinishedMsg(result)
}
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *PipelinesModel) HandleBack() bool {
switch m.state {
case pipelinesArgs:
m.state = pipelinesList
return false
case pipelinesOutput:
m.state = pipelinesList
return false
default:
return true
}
}
func (m PipelinesModel) View() string {
switch m.state {
case pipelinesLoading:
return m.spinner.View()
case pipelinesList:
if len(m.pipelines) == 0 {
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case pipelinesArgs:
return m.renderArgsForm()
case pipelinesRunning:
return m.spinner.View()
case pipelinesOutput:
return m.renderOutput()
}
return ""
}
func (m PipelinesModel) renderArgsForm() string {
header := m.styles.Header.Render(m.selectedFn.Name)
var parts []string
parts = append(parts, header, "")
if len(m.flags) == 0 {
parts = append(parts, m.styles.Muted.Render(" Loading flags..."))
} else if len(m.inputs) == 0 {
parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run."))
} else {
for i, f := range m.flags {
marker := " "
if f.Required {
marker = m.styles.Error.Render("* ")
}
name := fmt.Sprintf("--%-16s", f.Name)
cursor := " "
if i == m.focusIdx {
cursor = m.styles.Info.Render("> ")
}
label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name))
input := m.inputs[i].View()
desc := f.Desc
if f.Default != "" {
desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default))
}
parts = append(parts, label+input)
parts = append(parts, " "+m.styles.Muted.Render(desc))
}
}
parts = append(parts, "")
parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m PipelinesModel) renderOutput() string {
lines := splitLines(m.output)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Pipeline Output")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func splitLines(s string) []string {
if s == "" {
return []string{"(empty)"}
}
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return []string{"(empty)"}
}
return lines
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-3] + "..."
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
-146
View File
@@ -1,146 +0,0 @@
package views
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
// PipelineFlag describes a CLI flag parsed from -help output.
type PipelineFlag struct {
Name string // e.g. "project"
Type string // e.g. "string"
Desc string // description text
Default string // default value, empty if none
Required bool // true if no default
}
var flagLineRe = regexp.MustCompile(`^\s+-(\S+)\s+(\S+)$`)
var defaultRe = regexp.MustCompile(`\(default "(.*)"\)`)
// GetPipelineFlags runs `go run . -help` and parses the flag output.
func GetPipelineFlags(fn *registry.Function, registryRoot string) []PipelineFlag {
absPath := filepath.Join(registryRoot, fn.FilePath)
dir := filepath.Dir(absPath)
cmd := exec.Command("go", "run", ".", "-help")
cmd.Dir = dir
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Run() // -help exits with code 2, ignore error
return parseFlags(stderr.String())
}
func parseFlags(output string) []PipelineFlag {
var flags []PipelineFlag
lines := strings.Split(output, "\n")
for i := 0; i < len(lines); i++ {
m := flagLineRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
f := PipelineFlag{Name: m[1], Type: m[2]}
// Next line is the description
if i+1 < len(lines) {
desc := strings.TrimSpace(lines[i+1])
if dm := defaultRe.FindStringSubmatch(desc); dm != nil {
f.Default = dm[1]
f.Desc = strings.TrimSpace(defaultRe.ReplaceAllString(desc, ""))
} else {
f.Desc = desc
}
i++
}
f.Required = f.Default == "" && !strings.Contains(strings.ToLower(f.Desc), "opcional")
flags = append(flags, f)
}
return flags
}
// RunResult holds the outcome of a pipeline execution.
type RunResult struct {
Stdout string
Stderr string
ExecID string
PipelineID string
Status ops.ExecutionStatus
DurationMs int64
Err error
}
// RunPipeline executes a pipeline as a subprocess and records the execution.
func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB, args []string) RunResult {
absPath := filepath.Join(registryRoot, fn.FilePath)
dir := filepath.Dir(absPath)
startedAt := time.Now().UTC()
cmdArgs := append([]string{"run", "."}, args...)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
endedAt := time.Now().UTC()
status := ops.ExecSuccess
var execErr string
if err != nil {
status = ops.ExecFailure
execErr = err.Error()
if stderr.Len() > 0 {
execErr = stderr.String()
}
}
execID := fmt.Sprintf("exec_%d", time.Now().UnixNano())
durationMs := endedAt.Sub(startedAt).Milliseconds()
execution := &ops.Execution{
ID: execID,
PipelineID: fn.ID,
Status: status,
StartedAt: startedAt,
EndedAt: &endedAt,
DurationMs: &durationMs,
Error: execErr,
CreatedAt: time.Now().UTC(),
}
insertErr := ops.InsertExecutionSafe(opsDB, execution)
if insertErr != nil {
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: fmt.Errorf("pipeline ran but failed to record: %w", insertErr),
}
}
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: err,
}
}
-6
View File
@@ -1,6 +0,0 @@
operations.db
operations.db-wal
operations.db-shm
build/
*.exe
script_navegador
-100
View File
@@ -1,100 +0,0 @@
---
name: script_navegador
lang: go
domain: infra
description: "Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db."
tags: [cdp, chrome, browser, automation, yaml]
uses_functions:
- chrome_launch_go_infra
- cdp_connect_go_infra
- cdp_navigate_go_infra
- cdp_click_go_infra
- cdp_type_text_go_infra
- cdp_wait_element_go_infra
- cdp_evaluate_go_infra
- cdp_get_html_go_infra
- cdp_screenshot_go_infra
- cdp_close_go_infra
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "apps/script_navegador"
---
## Descripcion
CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.
## Uso
```bash
# Conectarse a Chrome ya corriendo en puerto 9222
go run . --script examples/busqueda_google.yaml
# Lanzar Chrome nuevo (headless)
go run . --script examples/busqueda_google.yaml --launch --headless
# Puerto personalizado
go run . --script examples/busqueda_google.yaml --port 9333
```
## Formato del script YAML
```yaml
name: "nombre_del_script"
steps:
- action: navigate
url: "https://ejemplo.com"
- action: wait
selector: "#elemento"
timeout_ms: 5000 # opcional, default 10000
- action: click
selector: "#boton"
continue_on_error: true # opcional, default false
- action: type
selector: "input[name=q]" # hace click primero para enfocar
text: "texto a escribir"
- action: screenshot
path: "/tmp/captura.png"
full_page: false # opcional, default false
- action: evaluate
expr: "document.title"
- action: get_html
# sin parametros adicionales
- action: sleep
ms: 500 # pausa en milisegundos
```
## Acciones soportadas
| Accion | Parametros obligatorios | Parametros opcionales |
|-------------|-------------------------|-------------------------------|
| `navigate` | `url` | |
| `wait` | `selector` | `timeout_ms` (default 10000) |
| `click` | `selector` | `continue_on_error` |
| `type` | `selector`, `text` | `continue_on_error` |
| `screenshot`| `path` | `full_page`, `continue_on_error` |
| `evaluate` | `expr` | `continue_on_error` |
| `get_html` | — | `continue_on_error` |
| `sleep` | `ms` | |
## Registro en operations.db
- **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
- **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
- **Logs**: un log por cada paso ejecutado con nivel info/error
## Notas
- Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
- `continue_on_error: true` por paso permite continuar aunque ese paso falle
- Flag global `--abort-on-error` (default false) aborta todo el script al primer error
- Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
- operations.db se inicializa automaticamente si no existe usando `fn ops init`
@@ -1,28 +0,0 @@
name: "busqueda_google"
steps:
- action: navigate
url: "https://www.google.com"
- action: wait
selector: "textarea[name=q]"
timeout_ms: 8000
- action: type
selector: "textarea[name=q]"
text: "golang cdp automation"
- action: screenshot
path: "/tmp/busqueda_antes.png"
- action: evaluate
expr: "document.title"
- action: sleep
ms: 500
- action: evaluate
expr: "document.querySelector('textarea[name=q]').value"
- action: screenshot
path: "/tmp/busqueda_despues.png"
full_page: false
@@ -1,20 +0,0 @@
name: "demo_continue_on_error"
steps:
- action: navigate
url: "https://example.com"
- action: wait
selector: "h1"
timeout_ms: 5000
# Este paso fallara porque el selector no existe, pero el script continua
- action: click
selector: "#boton-que-no-existe"
continue_on_error: true
# Este paso se ejecuta aunque el anterior fallo
- action: evaluate
expr: "document.title"
- action: screenshot
path: "/tmp/continue_on_error.png"
@@ -1,16 +0,0 @@
name: "scrape_titulo"
steps:
- action: navigate
url: "https://example.com"
- action: wait
selector: "h1"
timeout_ms: 5000
- action: evaluate
expr: "document.querySelector('h1').textContent"
- action: get_html
- action: screenshot
path: "/tmp/example_com.png"
@@ -1,6 +0,0 @@
name: "navegar_youtube"
steps:
- action: navigate
url: "https://www.youtube.com"
- action: screenshot
path: "/tmp/youtube.png"
-12
View File
@@ -1,12 +0,0 @@
module script-navegador
go 1.24.2
require (
fn-registry v0.0.0
gopkg.in/yaml.v3 v3.0.1
)
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
replace fn-registry => /home/lucas/fn_registry
-6
View File
@@ -1,6 +0,0 @@
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-20
View File
@@ -1,20 +0,0 @@
package main
import (
"crypto/rand"
"fmt"
)
// generateID genera un UUID v4 simple sin dependencias externas.
func generateID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback con timestamp si rand falla (muy improbable)
return fmt.Sprintf("fallback-%x", b)
}
// Ajustar bits para UUID v4
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
-214
View File
@@ -1,214 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
func main() {
// Flags
scriptPath := flag.String("script", "", "Ruta al archivo YAML con el script de navegacion (obligatorio)")
port := flag.Int("port", 9222, "Puerto CDP de Chrome")
launch := flag.Bool("launch", false, "Lanzar Chrome nuevo en vez de conectarse a uno existente")
headless := flag.Bool("headless", false, "Lanzar Chrome en modo headless (requiere --launch)")
chromePath := flag.String("chrome-path", "", "Ruta al ejecutable de Chrome (ej: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe')")
userDataDir := flag.String("user-data-dir", "", "Directorio de perfil de Chrome (path WSL, se convierte a Windows automaticamente)")
keepOpen := flag.Bool("keep-open", false, "No cerrar Chrome al terminar")
abortOnError := flag.Bool("abort-on-error", false, "Abortar el script al primer error en cualquier paso")
flag.Parse()
if *scriptPath == "" {
fmt.Fprintln(os.Stderr, "error: --script es obligatorio")
flag.Usage()
os.Exit(1)
}
if err := run(*scriptPath, *port, *launch, *headless, *abortOnError, *userDataDir, *keepOpen, *chromePath); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run(scriptPath string, port int, launch, headless, abortOnError bool, userDataDir string, keepOpen bool, chromePath string) error {
// 1. Cargar y validar el script YAML
script, err := LoadScript(scriptPath)
if err != nil {
return fmt.Errorf("cargar script: %w", err)
}
fmt.Printf("[script_navegador] script: %q (%d pasos)\n", script.Name, len(script.Steps))
// 2. Inicializar operations.db
appDir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
// Fallback al directorio de trabajo
appDir, _ = os.Getwd()
}
// Si estamos corriendo con `go run .`, os.Args[0] es un tmp, usar cwd
if cwd, e := os.Getwd(); e == nil {
if _, e2 := os.Stat(filepath.Join(cwd, "app.md")); e2 == nil {
appDir = cwd
}
}
db, err := initOpsDB(appDir)
if err != nil {
// No es fatal: seguir sin operations.db, solo logear
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo inicializar operations.db: %v\n", err)
}
if db != nil {
defer db.Close()
}
// 3. Lanzar Chrome o conectarse al existente
var pid int
if launch {
// Convertir path WSL a Windows para chrome.exe
// Si empieza con / es un path Linux (WSL), convertir. Si empieza con letra:\ ya es Windows.
winDataDir := userDataDir
if winDataDir != "" && strings.HasPrefix(winDataDir, "/") {
out, err := exec.Command("wslpath", "-w", winDataDir).Output()
if err == nil {
winDataDir = strings.TrimSpace(string(out))
}
}
fmt.Printf("[chrome] lanzando Chrome en puerto %d (headless=%v, user-data-dir=%q)...\n", port, headless, winDataDir)
pid, err = infra.ChromeLaunch(infra.ChromeLaunchOpts{
Port: port,
Headless: headless,
UserDataDir: winDataDir,
ChromePath: chromePath,
})
if err != nil {
return fmt.Errorf("lanzar Chrome: %w", err)
}
fmt.Printf("[chrome] Chrome lanzado (pid=%d)\n", pid)
} else {
fmt.Printf("[chrome] conectando a Chrome en localhost:%d...\n", port)
}
// 4. Conectar CDP (con mirrored networking, localhost es compartido WSL<->Windows)
fmt.Printf("[cdp] conectando a localhost:%d...\n", port)
conn, err := infra.CdpConnect(port)
if err != nil {
// Si lanzamos Chrome, matar el proceso antes de salir
if pid > 0 {
_ = infra.CdpClose(nil, pid)
}
return fmt.Errorf("conectar CDP en localhost:%d: %w", port, err)
}
fmt.Printf("[cdp] conexion establecida\n")
// Asegurar cierre al salir (respetar --keep-open)
defer func() {
if keepOpen {
fmt.Printf("[cdp] cerrando conexion CDP (Chrome sigue abierto, pid=%d, puerto=%d)\n", pid, port)
// Solo cerrar la conexion WebSocket, no matar Chrome
if err := infra.CdpClose(conn, 0); err != nil {
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar conexion: %v\n", err)
}
} else {
fmt.Printf("[cdp] cerrando conexion y limpiando recursos...\n")
if err := infra.CdpClose(conn, pid); err != nil {
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar: %v\n", err)
}
}
}()
// 5. Registrar entities y relations en operations.db
var relationID string
if db != nil {
_, _, _, err := EnsureEntities(db, port, chromePath, userDataDir, script.Name, scriptPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear entities: %v\n", err)
} else {
fmt.Printf("[ops] entities registradas\n")
}
relationID, err = EnsureRelations(db, "chrome_instance", "cdp_session", fmt.Sprintf("script_%s", script.Name))
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear relations: %v\n", err)
} else {
fmt.Printf("[ops] relations registradas\n")
}
}
// 6. Ejecutar el script
runner := NewRunner(conn, RunnerOpts{AbortOnError: abortOnError})
startedAt := time.Now()
fmt.Printf("[run] iniciando ejecucion: %s\n", startedAt.Format(time.RFC3339))
results, runErr := runner.Run(script)
endedAt := time.Now()
// 7. Imprimir resumen de pasos
printSummary(script, results, runErr, startedAt, endedAt)
// 8. Registrar execution y actualizar relation en operations.db
if db != nil {
execID, err := RecordRun(db, script, relationID, results, runErr, startedAt, endedAt)
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar ejecucion: %v\n", err)
} else {
fmt.Printf("[ops] ejecucion registrada en operations.db (id=%s)\n", execID[:8])
}
// Registrar cada paso como log
for _, r := range results {
if logErr := LogStep(db, execID, r); logErr != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar log step[%d]: %v\n", r.Index, logErr)
}
}
// Actualizar relation status
if relationID != "" {
UpdateRelationAfterRun(db, relationID, runErr)
}
}
if runErr != nil {
return runErr
}
return nil
}
// printSummary imprime un resumen legible de la ejecucion.
func printSummary(script *Script, results []StepResult, runErr error, startedAt, endedAt time.Time) {
duration := endedAt.Sub(startedAt)
success := 0
for _, r := range results {
if r.Err == nil {
success++
}
}
fmt.Printf("\n--- Resumen: %q ---\n", script.Name)
fmt.Printf("Duracion: %v\n", duration.Round(time.Millisecond))
fmt.Printf("Pasos: %d/%d exitosos\n", success, len(results))
fmt.Println()
for _, r := range results {
status := "ok"
detail := ""
if r.Err != nil {
status = "ERROR"
detail = fmt.Sprintf(" -> %v", r.Err)
} else if r.Output != "" {
detail = fmt.Sprintf(" -> %q", r.Output)
}
fmt.Printf(" [%d] %-12s %s (%dms)%s\n",
r.Index, r.Action, status, r.Elapsed.Milliseconds(), detail)
}
if runErr != nil {
fmt.Printf("\nAbortado: %v\n", runErr)
} else {
fmt.Printf("\nScript completado.\n")
}
}
-333
View File
@@ -1,333 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
fn_operations "fn-registry/fn_operations"
)
const opsDBName = "operations.db"
// initOpsDB inicializa o abre operations.db en el directorio de la app.
func initOpsDB(appDir string) (*fn_operations.DB, error) {
dbPath := filepath.Join(appDir, opsDBName)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if err := bootstrapOpsDB(appDir, dbPath); err != nil {
return nil, fmt.Errorf("inicializar operations.db: %w", err)
}
}
db, err := fn_operations.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("abrir operations.db: %w", err)
}
return db, nil
}
// bootstrapOpsDB intenta crear operations.db usando el CLI fn o directamente.
func bootstrapOpsDB(appDir, dbPath string) error {
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
if registryRoot == "" {
registryRoot = filepath.Join(appDir, "..", "..")
}
fnBin := filepath.Join(registryRoot, "fn")
if _, err := os.Stat(fnBin); err == nil {
cmd := exec.Command(fnBin, "ops", "init", appDir)
cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+registryRoot)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("fn ops init: %w\n%s", err, out)
}
return nil
}
db, err := fn_operations.Open(dbPath)
if err != nil {
return fmt.Errorf("crear operations.db directamente: %w", err)
}
return db.Close()
}
// --- Entities ---
// EnsureEntities crea o actualiza las entities del pipeline de navegacion.
// Entities:
// - chrome_instance: la instancia de Chrome con CDP
// - cdp_session: la sesion CDP activa
// - script_file: el archivo YAML del script
func EnsureEntities(db *fn_operations.DB, port int, chromePath, userDataDir, scriptName, scriptPath string) (chromeID, cdpID, scriptID string, err error) {
now := time.Now()
chromeID = "chrome_instance"
cdpID = "cdp_session"
scriptID = fmt.Sprintf("script_%s", scriptName)
// Chrome instance
existing, _ := db.GetEntity(chromeID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: chromeID,
Name: "Chrome Windows",
TypeRef: "chrome_instance",
Status: fn_operations.StatusActive,
Description: "Instancia de Chrome con remote debugging habilitado",
Domain: "infra",
Tags: []string{"chrome", "cdp", "windows"},
Source: "script_navegador",
Metadata: map[string]any{
"port": port,
"chrome_path": chromePath,
"user_data_dir": userDataDir,
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity chrome_instance: %w", err)
}
} else if existing.Status != fn_operations.StatusActive {
existing.Status = fn_operations.StatusActive
existing.UpdatedAt = now
db.UpdateEntity(existing)
}
// CDP session
existing, _ = db.GetEntity(cdpID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: cdpID,
Name: "CDP Session",
TypeRef: "cdp_session",
Status: fn_operations.StatusActive,
Description: "Sesion CDP WebSocket activa contra Chrome",
Domain: "infra",
Tags: []string{"cdp", "websocket"},
Source: "script_navegador",
Metadata: map[string]any{
"port": port,
"protocol": "CDP 1.3",
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity cdp_session: %w", err)
}
} else if existing.Status != fn_operations.StatusActive {
existing.Status = fn_operations.StatusActive
existing.UpdatedAt = now
db.UpdateEntity(existing)
}
// Script
existing, _ = db.GetEntity(scriptID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: scriptID,
Name: scriptName,
TypeRef: "nav_script",
Status: fn_operations.StatusActive,
Description: fmt.Sprintf("Script de navegacion: %s", scriptName),
Domain: "automation",
Tags: []string{"script", "yaml", "navegacion"},
Source: scriptPath,
Metadata: map[string]any{
"script_name": scriptName,
"file_path": scriptPath,
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity script: %w", err)
}
}
return chromeID, cdpID, scriptID, nil
}
// --- Relations ---
// EnsureRelations crea las relaciones entre entities si no existen.
// Relations:
// - chrome_to_cdp: Chrome -> CDP Session (via chrome_launch + cdp_connect)
// - cdp_to_script: CDP Session -> Script (via runner)
func EnsureRelations(db *fn_operations.DB, chromeID, cdpID, scriptID string) (string, error) {
now := time.Now()
// chrome -> cdp
chromeToCDP := "chrome_to_cdp"
existing, _ := db.GetRelation(chromeToCDP)
if existing == nil {
err := db.InsertRelation(&fn_operations.Relation{
ID: chromeToCDP,
Name: "chrome_to_cdp",
FromEntity: chromeID,
ToEntity: cdpID,
Via: "cdp_connect_go_infra",
Description: "Chrome expone CDP, la app se conecta via WebSocket",
Purity: "impure",
Direction: fn_operations.DirUnidirectional,
Status: fn_operations.RelImplemented,
Tags: []string{"cdp", "websocket"},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", fmt.Errorf("insertar relation chrome_to_cdp: %w", err)
}
}
// cdp -> script execution
cdpToScript := fmt.Sprintf("cdp_to_%s", scriptID)
existing, _ = db.GetRelation(cdpToScript)
if existing == nil {
startedAt := now
err := db.InsertRelation(&fn_operations.Relation{
ID: cdpToScript,
Name: cdpToScript,
FromEntity: cdpID,
ToEntity: scriptID,
Via: "script_navegador_runner",
Description: fmt.Sprintf("CDP ejecuta pasos del script %s", scriptID),
Purity: "impure",
Direction: fn_operations.DirUnidirectional,
Status: fn_operations.RelRunning,
StartedAt: &startedAt,
Tags: []string{"automation", "pipeline"},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", fmt.Errorf("insertar relation cdp_to_script: %w", err)
}
} else {
existing.Status = fn_operations.RelRunning
existing.UpdatedAt = now
db.UpdateRelation(existing)
}
return cdpToScript, nil
}
// UpdateRelationAfterRun actualiza el status de la relation segun el resultado.
func UpdateRelationAfterRun(db *fn_operations.DB, relationID string, runErr error) {
rel, err := db.GetRelation(relationID)
if err != nil || rel == nil {
return
}
if runErr != nil {
rel.Status = fn_operations.RelImplemented
} else {
rel.Status = fn_operations.RelTested
}
now := time.Now()
rel.EndedAt = &now
rel.UpdatedAt = now
db.UpdateRelation(rel)
}
// --- Executions ---
// RecordRun registra una ejecucion completa del script en operations.db.
func RecordRun(db *fn_operations.DB, script *Script, relationID string, results []StepResult, runErr error, startedAt, endedAt time.Time) (string, error) {
totalSteps := int64(len(results))
successSteps := int64(0)
for _, r := range results {
if r.Err == nil {
successSteps++
}
}
status := fn_operations.ExecSuccess
errMsg := ""
if runErr != nil {
status = fn_operations.ExecFailure
errMsg = runErr.Error()
} else if successSteps < totalSteps {
status = fn_operations.ExecPartial
}
durationMs := endedAt.Sub(startedAt).Milliseconds()
stepSummary := make([]map[string]any, 0, len(results))
for _, r := range results {
entry := map[string]any{
"index": r.Index,
"action": r.Action,
"elapsed_ms": r.Elapsed.Milliseconds(),
"ok": r.Err == nil,
}
if r.Output != "" {
entry["output"] = r.Output
}
if r.Err != nil {
entry["error"] = r.Err.Error()
}
stepSummary = append(stepSummary, entry)
}
execID := generateID()
execution := &fn_operations.Execution{
ID: execID,
PipelineID: "script_navegador",
RelationID: relationID,
Status: status,
StartedAt: startedAt,
EndedAt: &endedAt,
DurationMs: &durationMs,
RecordsIn: &totalSteps,
RecordsOut: &successSteps,
Error: errMsg,
Metrics: map[string]any{
"script_name": script.Name,
"total_steps": totalSteps,
"success_steps": successSteps,
"steps": stepSummary,
},
}
if err := db.InsertExecution(execution); err != nil {
return "", fmt.Errorf("insertar execution: %w", err)
}
return execID, nil
}
// --- Logs ---
// LogStep registra un paso individual como log en operations.db.
func LogStep(db *fn_operations.DB, execID string, res StepResult) error {
level := fn_operations.LogInfo
msg := fmt.Sprintf("step[%d] %s: ok", res.Index, res.Action)
if res.Err != nil {
level = fn_operations.LogError
msg = fmt.Sprintf("step[%d] %s: %v", res.Index, res.Action, res.Err)
}
meta := map[string]any{
"action": res.Action,
"elapsed_ms": res.Elapsed.Milliseconds(),
}
if res.Output != "" {
meta["output"] = res.Output
}
log := &fn_operations.Log{
ID: generateID(),
Level: level,
Source: "script_navegador",
ExecutionID: execID,
Message: msg,
Metadata: meta,
}
return db.InsertLog(log)
}
-143
View File
@@ -1,143 +0,0 @@
package main
import (
"fmt"
"time"
"fn-registry/functions/infra"
)
// StepResult es el resultado de ejecutar un paso.
type StepResult struct {
Index int
Action string
Output string // resultado de evaluate/get_html, path de screenshot, etc.
Err error
Elapsed time.Duration
}
// RunnerOpts configura la ejecucion del runner.
type RunnerOpts struct {
AbortOnError bool
}
// Runner ejecuta los pasos de un Script sobre una conexion CDP activa.
type Runner struct {
conn *infra.CDPConn
opts RunnerOpts
}
// NewRunner crea un Runner con la conexion CDP dada.
func NewRunner(conn *infra.CDPConn, opts RunnerOpts) *Runner {
return &Runner{conn: conn, opts: opts}
}
// Run ejecuta todos los pasos del script y retorna los resultados de cada paso.
// Siempre retorna todos los resultados procesados hasta el momento, incluso si aborta.
func (r *Runner) Run(script *Script) ([]StepResult, error) {
results := make([]StepResult, 0, len(script.Steps))
for i, step := range script.Steps {
start := time.Now()
output, err := r.runStep(step)
elapsed := time.Since(start)
res := StepResult{
Index: i,
Action: step.Action,
Output: output,
Err: err,
Elapsed: elapsed,
}
results = append(results, res)
if err != nil {
if step.ContinueOnError {
// Continuar con el siguiente paso aunque este fallo
continue
}
if r.opts.AbortOnError {
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
}
// Por defecto: abortar si el paso fallo y no tiene continue_on_error
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
}
}
return results, nil
}
// runStep ejecuta un paso individual y retorna su output y error.
func (r *Runner) runStep(step Step) (string, error) {
switch step.Action {
case "navigate":
if err := infra.CdpNavigate(r.conn, step.URL); err != nil {
return "", err
}
// Esperar a que la página cargue completamente
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}
return "", infra.CdpWaitLoad(r.conn, timeout)
case "wait_load":
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}
return "", infra.CdpWaitLoad(r.conn, timeout)
case "wait":
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
return "", infra.CdpWaitElement(r.conn, step.Selector, timeout)
case "click":
return "", infra.CdpClick(r.conn, step.Selector)
case "type":
// Hacer click primero para enfocar el elemento
if err := infra.CdpClick(r.conn, step.Selector); err != nil {
return "", fmt.Errorf("enfocar elemento para type: %w", err)
}
return "", infra.CdpTypeText(r.conn, step.Text)
case "screenshot":
opts := infra.CdpScreenshotOpts{
FullPage: step.FullPage,
Format: "png",
}
if err := infra.CdpScreenshot(r.conn, step.Path, opts); err != nil {
return "", err
}
return step.Path, nil
case "evaluate":
result, err := infra.CdpEvaluate(r.conn, step.Expr)
if err != nil {
return "", err
}
return result, nil
case "get_html":
html, err := infra.CdpGetHTML(r.conn)
if err != nil {
return "", err
}
// Truncar para el log (el HTML puede ser muy largo)
if len(html) > 200 {
return html[:200] + "...", nil
}
return html, nil
case "sleep":
time.Sleep(time.Duration(step.Ms) * time.Millisecond)
return fmt.Sprintf("slept %dms", step.Ms), nil
default:
return "", fmt.Errorf("accion desconocida: %q", step.Action)
}
}
-121
View File
@@ -1,121 +0,0 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Script representa un archivo YAML de pasos de navegacion.
type Script struct {
Name string `yaml:"name"`
Steps []Step `yaml:"steps"`
}
// Step es un paso individual dentro del script.
type Step struct {
// Comun a todos los pasos
Action string `yaml:"action"`
ContinueOnError bool `yaml:"continue_on_error"`
// navigate
URL string `yaml:"url"`
// wait
Selector string `yaml:"selector"`
TimeoutMs int `yaml:"timeout_ms"`
// type
Text string `yaml:"text"`
// screenshot
Path string `yaml:"path"`
FullPage bool `yaml:"full_page"`
// evaluate
Expr string `yaml:"expr"`
// sleep
Ms int `yaml:"ms"`
}
// Validate comprueba que el script tiene los campos minimos correctos.
func (s *Script) Validate() error {
if s.Name == "" {
return fmt.Errorf("script: campo 'name' obligatorio")
}
if len(s.Steps) == 0 {
return fmt.Errorf("script %q: sin pasos definidos", s.Name)
}
for i, step := range s.Steps {
if err := step.Validate(i); err != nil {
return err
}
}
return nil
}
// Validate comprueba que el paso tiene los campos requeridos segun su action.
func (s *Step) Validate(idx int) error {
prefix := fmt.Sprintf("step[%d] action=%q", idx, s.Action)
switch s.Action {
case "navigate":
if s.URL == "" {
return fmt.Errorf("%s: campo 'url' obligatorio", prefix)
}
case "wait":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
case "click":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
case "type":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
if s.Text == "" {
return fmt.Errorf("%s: campo 'text' obligatorio", prefix)
}
case "screenshot":
if s.Path == "" {
return fmt.Errorf("%s: campo 'path' obligatorio", prefix)
}
case "evaluate":
if s.Expr == "" {
return fmt.Errorf("%s: campo 'expr' obligatorio", prefix)
}
case "get_html":
// sin parametros requeridos
case "wait_load":
// sin parametros requeridos (timeout_ms opcional)
case "sleep":
if s.Ms <= 0 {
return fmt.Errorf("%s: campo 'ms' debe ser mayor que 0", prefix)
}
default:
return fmt.Errorf("%s: accion desconocida (navigate|wait|wait_load|click|type|screenshot|evaluate|get_html|sleep)", prefix)
}
return nil
}
// LoadScript lee y parsea un archivo YAML de script de navegador.
func LoadScript(path string) (*Script, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("leer script %q: %w", path, err)
}
var s Script
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsear script %q: %w", path, err)
}
if err := s.Validate(); err != nil {
return nil, err
}
return &s, nil
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"fmt"
"io"
"net"
"os"
"strings"
"time"
)
// getWindowsHostIP obtiene la IP del host Windows desde WSL2.
// Lee /etc/resolv.conf que WSL2 configura con la IP del host.
func getWindowsHostIP() string {
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver ") {
ip := strings.TrimPrefix(line, "nameserver ")
ip = strings.TrimSpace(ip)
if ip != "" {
return ip
}
}
}
return ""
}
// getWindowsGatewayIP obtiene la IP del gateway (host Windows) desde la tabla de rutas.
func getWindowsGatewayIP() string {
data, err := os.ReadFile("/proc/net/route")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 3 && fields[1] == "00000000" { // default route
hexIP := fields[2]
if len(hexIP) == 8 {
// /proc/net/route stores IPs as little-endian 32-bit hex
// "011017AC" -> bytes [01,10,17,AC] -> IP 172.23.16.1 (reversed)
var a, b, c, d uint8
fmt.Sscanf(hexIP[0:2], "%02x", &a)
fmt.Sscanf(hexIP[2:4], "%02x", &b)
fmt.Sscanf(hexIP[4:6], "%02x", &c)
fmt.Sscanf(hexIP[6:8], "%02x", &d)
return fmt.Sprintf("%d.%d.%d.%d", d, c, b, a)
}
}
}
return ""
}
// waitForCDP espera a que el puerto CDP esté accesible desde WSL.
func waitForCDP(host string, port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("%s:%d", host, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("CDP %s no disponible despues de %s", addr, timeout)
}
// startCDPProxy levanta un proxy TCP local que reenvía conexiones al host Windows.
// Chrome CDP solo acepta conexiones desde localhost, así que el proxy en WSL
// conecta al host Windows vía portproxy/netsh y expone el puerto localmente.
// Retorna el puerto local del proxy y una función para cerrarlo.
func startCDPProxy(windowsHost string, remotePort, localPort int) (net.Listener, error) {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
if err != nil {
return nil, fmt.Errorf("proxy listen: %w", err)
}
go func() {
for {
client, err := ln.Accept()
if err != nil {
return // listener cerrado
}
go proxyConn(client, windowsHost, remotePort)
}
}()
return ln, nil
}
func proxyConn(client net.Conn, host string, port int) {
defer client.Close()
remote, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
if err != nil {
return
}
defer remote.Close()
go io.Copy(remote, client)
io.Copy(client, remote)
}
+36
View File
@@ -0,0 +1,36 @@
---
name: init_uv_venv
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "init_uv_venv([project_dir: string]) -> string"
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
tags: [python, venv, uv, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/init_uv_venv.sh"
---
## Ejemplo
```bash
source init_uv_venv.sh
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
echo "Venv creado en: $venv"
# Idempotente — si ya existe, retorna la ruta sin recrear
venv=$(init_uv_venv .)
```
## Notas
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
+35
View File
@@ -0,0 +1,35 @@
# init_uv_venv
# -------------
# Crea un venv con uv en el directorio especificado si no existe.
# Fallback a python -m venv si uv no esta disponible.
# Imprime la ruta del venv a stdout.
#
# USO (sourced):
# source init_uv_venv.sh
# venv_path=$(init_uv_venv /path/to/project)
init_uv_venv() {
local project_dir="${1:-.}"
local venv_path="${project_dir}/.venv"
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
echo "$venv_path"
return 0
fi
if command -v uv &>/dev/null; then
(cd "$project_dir" && uv venv) >/dev/null 2>&1
elif command -v python3 &>/dev/null; then
python3 -m venv "$venv_path"
else
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
return 1
fi
if [ ! -f "$venv_path/bin/python" ]; then
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
return 1
fi
echo "$venv_path"
}
+35
View File
@@ -0,0 +1,35 @@
---
name: uv_add_packages
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "uv_add_packages(project_dir: string, ...packages: string) -> void"
description: "Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe."
tags: [python, uv, pip, packages, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/uv_add_packages.sh"
---
## Ejemplo
```bash
source uv_add_packages.sh
uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy
# Solo un paquete
uv_add_packages . polars
```
## Notas
Requiere que el venv ya exista (usa `init_uv_venv` antes). Prefiere uv por velocidad y reproducibilidad (lockfile). Si uv no esta disponible, usa pip del venv directamente.
+35
View File
@@ -0,0 +1,35 @@
# uv_add_packages
# -----------------
# Instala paquetes Python en un proyecto con uv add.
# Inicializa pyproject.toml si no existe.
# Fallback a pip install si uv no esta disponible.
#
# USO (sourced):
# source uv_add_packages.sh
# uv_add_packages /path/to/project jupyter jupyterlab pandas
uv_add_packages() {
local project_dir="$1"
shift
local packages=("$@")
if [ ${#packages[@]} -eq 0 ]; then
echo "uv_add_packages: se requiere al menos un paquete" >&2
return 1
fi
if [ ! -d "$project_dir/.venv" ]; then
echo "uv_add_packages: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
return 1
fi
if command -v uv &>/dev/null; then
# Inicializar pyproject.toml si no existe
if [ ! -f "$project_dir/pyproject.toml" ]; then
(cd "$project_dir" && uv init 2>/dev/null) || true
fi
(cd "$project_dir" && uv add "${packages[@]}" 2>&1)
else
"$project_dir/.venv/bin/pip" install "${packages[@]}" 2>&1
fi
}
@@ -0,0 +1,33 @@
---
name: write_claude_jupyter_rules
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_claude_jupyter_rules([project_dir: string]) -> string"
description: "Genera o actualiza .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter: celdas inmutables, programacion funcional, uso de MCP, acceso al fn_registry."
tags: [claude, jupyter, rules, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_claude_jupyter_rules.sh"
---
## Ejemplo
```bash
source write_claude_jupyter_rules.sh
path=$(write_claude_jupyter_rules /home/lucas/analysis/finanzas)
echo "Reglas escritas en: $path"
```
## Notas
Idempotente: si CLAUDE.md ya contiene las reglas (detecta "JUPYTER HABILITADO"), no las duplica. Si CLAUDE.md existe sin las reglas, las prepend al contenido existente. Incluye instrucciones de acceso al fn_registry via `FN_REGISTRY_ROOT`.
@@ -0,0 +1,74 @@
# write_claude_jupyter_rules
# ----------------------------
# Genera .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter.
# Si ya existe CLAUDE.md y no tiene las reglas, las prepend.
#
# USO (sourced):
# source write_claude_jupyter_rules.sh
# write_claude_jupyter_rules /path/to/project
write_claude_jupyter_rules() {
local project_dir="${1:-.}"
local claude_dir="${project_dir}/.claude"
local claude_md="${claude_dir}/CLAUDE.md"
mkdir -p "$claude_dir"
# Si ya tiene las reglas, no hacer nada
if [ -f "$claude_md" ] && grep -q "JUPYTER HABILITADO" "$claude_md" 2>/dev/null; then
echo "$claude_md"
return 0
fi
local rules
rules='# JUPYTER HABILITADO EN ESTE ANALISIS
## Reglas OBLIGATORIAS para Claude
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
- Si hay un error en una celda, crear celda nueva con la correccion
- El historial de trabajo debe quedar intacto para trazabilidad
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
- **Composicion**: funciones pequenas que se combinan
- Preferir: `map`, `filter`, `reduce`, list comprehensions
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
- Compartimos variables y estado del kernel
- **NUNCA usar bash para ejecutar Python en este analisis**
### 4. Verificar Jupyter activo ANTES de ejecutar
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
### 5. Gestion de notebooks
- Notebooks en la carpeta `notebooks/` o subcarpetas
- Si un notebook tiene >50 celdas, crear uno nuevo
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
### 6. Gestion de Python
- **SIEMPRE usar `uv`** para gestionar dependencias
- Anadir paquetes con `uv add nombre_paquete`
### 7. Acceso al fn_registry
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
'
if [ -f "$claude_md" ]; then
local existing
existing=$(cat "$claude_md")
printf '%s\n%s' "$rules" "$existing" > "$claude_md"
else
echo "$rules" > "$claude_md"
fi
echo "$claude_md"
}
@@ -0,0 +1,41 @@
---
name: write_jupyter_launcher
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_launcher([project_dir: string]) -> string"
description: "Genera un script run-jupyter-lab.sh que lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto y token deshabilitado."
tags: [jupyter, launcher, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_launcher.sh"
---
## Ejemplo
```bash
source write_jupyter_launcher.sh
path=$(write_jupyter_launcher /home/lucas/analysis/finanzas)
echo "Launcher creado en: $path"
# Luego en otra terminal:
# ./run-jupyter-lab.sh [puerto]
```
## Notas
El launcher generado:
- Autodetecta un puerto libre (8888-8899)
- Guarda el puerto en `.jupyter-port` para que otros procesos lo lean
- Activa el venv automaticamente
- Lanza Jupyter Lab en modo `--collaborative` (requiere jupyter-collaboration)
- Token y password deshabilitados para acceso local
@@ -0,0 +1,65 @@
# write_jupyter_launcher
# -----------------------
# Genera un script run-jupyter-lab.sh en el directorio dado.
# El script lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto.
#
# USO (sourced):
# source write_jupyter_launcher.sh
# write_jupyter_launcher /path/to/project
write_jupyter_launcher() {
local project_dir="${1:-.}"
local launcher="${project_dir}/run-jupyter-lab.sh"
cat > "$launcher" << 'LAUNCHER'
#!/bin/bash
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
# Generado por write_jupyter_launcher (fn_registry)
find_free_port() {
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo $port
return
fi
done
echo 8888
}
PORT=${1:-$(find_free_port)}
cd "$(dirname "$0")"
echo $PORT > .jupyter-port
source .venv/bin/activate 2>/dev/null || true
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
echo "ERROR: jupyter-collaboration no esta instalado"
echo "Instala con: uv add jupyter-collaboration"
exit 1
fi
echo "════════════════════════════════════════════════"
echo " Jupyter Lab + Colaboracion en puerto $PORT"
echo "════════════════════════════════════════════════"
echo ""
echo " Abre: http://localhost:$PORT"
echo " Ctrl+C para detener"
echo ""
jupyter lab \
--port=$PORT \
--no-browser \
--ServerApp.token='' \
--ServerApp.password='' \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
--ServerApp.root_dir="$(pwd)" \
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
--collaborative
LAUNCHER
chmod +x "$launcher"
echo "$launcher"
}
@@ -0,0 +1,56 @@
---
name: write_jupyter_registry_kernel
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_registry_kernel([project_dir: string]) -> string"
description: "Genera un script de startup de IPython que autoconfigura FN_REGISTRY_ROOT, sys.path a python/functions del registry, y helpers fn_query/fn_search/fn_code para consultar registry.db desde notebooks."
tags: [jupyter, ipython, kernel, registry, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_registry_kernel.sh"
---
## Ejemplo
```bash
source write_jupyter_registry_kernel.sh
path=$(write_jupyter_registry_kernel /home/lucas/analysis/finanzas)
echo "Startup creado en: $path"
```
Luego en cualquier notebook del proyecto:
```python
# Ya disponible automaticamente al abrir el notebook:
# Buscar funciones
fn_search("finance")
# Consultar registry.db directamente
fn_query("SELECT id, signature FROM functions WHERE domain = ?", ("core",))
# Ver codigo de una funcion
print(fn_code("filter_list_py_core"))
# Importar funciones Python del registry directamente
from core import filter_list, map_list
from finance import sma, ema, rsi
```
## Notas
Genera `.ipython/profile_default/startup/00_fn_registry.py` que se ejecuta automaticamente al iniciar cualquier kernel IPython en el proyecto. No requiere imports manuales — las funciones `fn_query`, `fn_search` y `fn_code` estan disponibles inmediatamente en cada notebook.
El `sys.path` se configura para que cada dominio de `python/functions/` sea importable directamente (`from core import filter_list`).
Detecta `FN_REGISTRY_ROOT` buscando la raiz del repo git o subiendo directorios hasta encontrar `registry.db`.
@@ -0,0 +1,111 @@
# write_jupyter_registry_kernel
# -------------------------------
# Genera un script de startup de IPython que autoconfigura el acceso
# al fn_registry en cada notebook: FN_REGISTRY_ROOT, sys.path a
# python/functions, y un helper fn_query() para consultar registry.db.
#
# USO (sourced):
# source write_jupyter_registry_kernel.sh
# write_jupyter_registry_kernel /path/to/project
write_jupyter_registry_kernel() {
local project_dir="${1:-.}"
local startup_dir="${project_dir}/.ipython/profile_default/startup"
local registry_root
registry_root="$(cd "$project_dir" && cd "$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || echo "../..")" && pwd)"
# Fallback: si no es git, buscar registry.db subiendo directorios
if [ ! -f "$registry_root/registry.db" ] && [ -f "$project_dir/../../registry.db" ]; then
registry_root="$(cd "$project_dir/../.." && pwd)"
fi
mkdir -p "$startup_dir"
cat > "${startup_dir}/00_fn_registry.py" << STARTUP
"""
fn_registry kernel startup
Autoconfigura acceso al registry en cada notebook.
Generado por write_jupyter_registry_kernel (fn_registry).
"""
import os
import sys
import sqlite3
from pathlib import Path
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
FN_REGISTRY_ROOT = Path("${registry_root}")
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
# ── sys.path: importar funciones Python del registry ────────
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
if _domain.is_dir() and not _domain.name.startswith("_"):
_path = str(_domain)
if _path not in sys.path:
sys.path.insert(0, _path)
# Tambien el directorio padre para imports por dominio: from core import filter_list
_pf = str(_python_functions)
if _pf not in sys.path:
sys.path.insert(0, _pf)
# ── fn_query: consultar registry.db desde el notebook ───────
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
def fn_query(sql, params=()):
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
Ejemplos:
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
"""
if not _REGISTRY_DB.exists():
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
con = sqlite3.connect(str(_REGISTRY_DB))
con.row_factory = sqlite3.Row
try:
rows = con.execute(sql, params).fetchall()
return [dict(r) for r in rows]
finally:
con.close()
def fn_search(term):
"""Busca funciones y tipos en el registry por nombre o descripcion.
Ejemplo:
fn_search("slice")
fn_search("finance")
"""
fts_term = f"name:{term}* OR description:{term}*"
functions = fn_query(
"SELECT id, kind, purity, lang, description FROM functions "
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
types = fn_query(
"SELECT id, algebraic, lang, description FROM types "
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
return {"functions": functions, "types": types}
def fn_code(function_id):
"""Retorna el codigo fuente de una funcion del registry.
Ejemplo:
print(fn_code("filter_list_py_core"))
"""
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
if not rows:
raise KeyError(f"Funcion no encontrada: {function_id}")
return rows[0]["code"]
# ── Mensaje de bienvenida ───────────────────────────────────
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
print(f" Python functions: {_pf}")
print(f" Helpers: fn_query(), fn_search(), fn_code()")
STARTUP
echo "${startup_dir}/00_fn_registry.py"
}
@@ -0,0 +1,33 @@
---
name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
tags: [mcp, jupyter, config, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
---
## Ejemplo
```bash
source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
echo "Config MCP en: $path"
```
## Notas
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
@@ -0,0 +1,57 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el python del venv local con -m jupyter_mcp_server.
# Hace merge si ya existe .mcp.json (requiere jq).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
local port="${2:-8888}"
local mcp_file="${project_dir}/.mcp.json"
local abs_project
abs_project="$(cd "$project_dir" && pwd)"
local python_bin="${abs_project}/.venv/bin/python"
if [ ! -f "$python_bin" ]; then
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
return 1
fi
# Verificar que el modulo esta instalado
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${python_bin}",
"args": [
"-m", "jupyter_mcp_server",
"--runtime-url", "http://localhost:${port}",
"--start-new-runtime", "false"
]
}
}
}
EOF
)
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
else
echo "$new_config" > "$mcp_file"
fi
echo "$mcp_file"
}
@@ -0,0 +1,63 @@
---
name: init_jupyter_analysis
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_jupyter_analysis(nombre: string, [...paquetes_extra: string]) -> void"
description: "Inicializa un analisis Jupyter completo en analysis/{nombre}/ con venv, paquetes, launcher, MCP y reglas para agentes Claude. Acepta paquetes extra opcionales."
tags: [jupyter, analysis, setup, pipeline, bash, launcher]
uses_functions:
- assert_command_exists_bash_shell
- find_free_port_bash_shell
- init_uv_venv_bash_infra
- uv_add_packages_bash_infra
- write_jupyter_launcher_bash_infra
- write_mcp_jupyter_config_bash_infra
- write_claude_jupyter_rules_bash_infra
- write_jupyter_registry_kernel_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/init_jupyter_analysis.sh"
---
## Ejemplo
```bash
# Analisis basico
./init_jupyter_analysis.sh finanzas
# Con paquetes extra
./init_jupyter_analysis.sh duckdb polars duckdb
./init_jupyter_analysis.sh ml scikit-learn torch
# Via fn run
fn run init_jupyter_analysis finanzas
fn run init_jupyter_analysis ml scikit-learn torch
```
## Flujo
1. `assert_command_exists` — verifica que uv o python3 estan disponibles
2. Crea estructura `analysis/{nombre}/notebooks/` y `analysis/{nombre}/data/`
3. `init_uv_venv` — crea venv en `analysis/{nombre}/.venv/`
4. `uv_add_packages` — instala jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib + extras
5. `write_jupyter_launcher` — genera `run-jupyter-lab.sh` con modo colaborativo
6. `find_free_port` + `write_mcp_jupyter_config` — detecta puerto libre y genera `.mcp.json`
7. `write_claude_jupyter_rules` — genera `.claude/CLAUDE.md` con reglas de agente
8. `write_jupyter_registry_kernel` — genera IPython startup con `fn_query`, `fn_search`, `fn_code` y acceso a `python/functions/`
## Notas
Cada analisis es independiente (propio venv, propio Jupyter, propio MCP). Mismo patron que `apps/` pero para exploraciones no reutilizables.
El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecucion.
Paquetes base siempre incluidos: jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib. Los paquetes extra se añaden a estos.
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# init_jupyter_analysis
# ----------------------
# Inicializa un analisis Jupyter completo en analysis/{nombre}/.
# Compone: assert_command_exists + find_free_port + init_uv_venv +
# uv_add_packages + write_jupyter_launcher +
# write_mcp_jupyter_config + write_claude_jupyter_rules +
# write_jupyter_registry_kernel
#
# USO:
# ./init_jupyter_analysis.sh <nombre> [paquetes_extra...]
#
# EJEMPLOS:
# ./init_jupyter_analysis.sh finanzas
# ./init_jupyter_analysis.sh duckdb polars duckdb
# ./init_jupyter_analysis.sh ml scikit-learn torch
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Source funciones atomicas
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
source "$REGISTRY_ROOT/bash/functions/shell/find_free_port.sh"
source "$REGISTRY_ROOT/bash/functions/infra/init_uv_venv.sh"
source "$REGISTRY_ROOT/bash/functions/infra/uv_add_packages.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_launcher.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_mcp_jupyter_config.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_claude_jupyter_rules.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_registry_kernel.sh"
# ── Argumentos ──────────────────────────────────────────────
NOMBRE="${1:-}"
if [ -z "$NOMBRE" ]; then
echo "Uso: $0 <nombre> [paquetes_extra...]" >&2
echo " Ejemplo: $0 finanzas polars" >&2
exit 1
fi
shift
EXTRA_PACKAGES=("$@")
ANALYSIS_DIR="${REGISTRY_ROOT}/analysis/${NOMBRE}"
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT JUPYTER ANALYSIS: ${NOMBRE}"
echo " Directorio: ${ANALYSIS_DIR}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── 1. Verificar herramientas ───────────────────────────────
echo "[1/8] Verificando herramientas..."
assert_command_exists uv || assert_command_exists python3
echo " OK"
# ── 2. Crear estructura de carpetas ─────────────────────────
echo "[2/8] Creando estructura..."
mkdir -p "$ANALYSIS_DIR/notebooks" "$ANALYSIS_DIR/data"
echo " ${ANALYSIS_DIR}/notebooks/"
echo " ${ANALYSIS_DIR}/data/"
# ── 3. Crear venv ───────────────────────────────────────────
echo "[3/8] Inicializando venv..."
venv_path=$(init_uv_venv "$ANALYSIS_DIR")
echo " $venv_path"
# ── 4. Instalar paquetes ────────────────────────────────────
echo "[4/8] Instalando paquetes..."
BASE_PACKAGES=(jupyter jupyterlab jupyter-collaboration jupyter-mcp-server pandas numpy matplotlib)
ALL_PACKAGES=("${BASE_PACKAGES[@]}" "${EXTRA_PACKAGES[@]}")
uv_add_packages "$ANALYSIS_DIR" "${ALL_PACKAGES[@]}"
echo " Instalados: ${ALL_PACKAGES[*]}"
# ── 5. Generar launcher ─────────────────────────────────────
echo "[5/8] Generando launcher..."
launcher=$(write_jupyter_launcher "$ANALYSIS_DIR")
echo " $launcher"
# ── 6. Configurar MCP ───────────────────────────────────────
echo "[6/8] Configurando MCP..."
port=$(find_free_port 8888 8899)
mcp_config=$(write_mcp_jupyter_config "$ANALYSIS_DIR" "$port")
echo " $mcp_config (puerto: $port)"
# ── 7. Reglas para agentes ──────────────────────────────────
echo "[7/8] Escribiendo reglas Claude..."
rules=$(write_claude_jupyter_rules "$ANALYSIS_DIR")
echo " $rules"
# ── 8. Kernel startup con acceso al registry ────────────────
echo "[8/8] Configurando kernel con acceso al registry..."
kernel_startup=$(write_jupyter_registry_kernel "$ANALYSIS_DIR")
echo " $kernel_startup"
# ── Resumen ─────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════════"
echo " ANALISIS '${NOMBRE}' LISTO"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo ""
echo " 1. En otra terminal:"
echo " cd ${ANALYSIS_DIR} && ./run-jupyter-lab.sh"
echo ""
echo " 2. Abrir Claude en el analisis:"
echo " cd ${ANALYSIS_DIR} && claude"
echo ""
echo " 3. Abrir en navegador: http://localhost:${port}"
echo ""
echo " FN_REGISTRY_ROOT=${REGISTRY_ROOT}"
echo ""
+35
View File
@@ -0,0 +1,35 @@
---
name: exit_with_status
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: pure
signature: "exit_with_status(total_steps: int, ok_steps: int, failed_steps: int) -> int"
description: "Calcula el exit code estandar (0=success, 1=failure, 2=partial) a partir de contadores de pasos. Si failed_steps=0 imprime 0 y sale con 0. Si ok_steps=0 imprime 1 y sale con 1. Si hay ambos imprime 2 y sale con 2."
tags: [execution, status, exit-code, standard]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/exit_with_status.sh"
---
## Ejemplo
```bash
source bash/functions/shell/exit_with_status.sh
exit_with_status 5 5 0 # stdout: 0, exit code: 0
exit_with_status 5 0 5 # stdout: 1, exit code: 1
exit_with_status 5 3 2 # stdout: 2, exit code: 2
```
## Notas
Funcion pura: no realiza I/O de sistema, no modifica estado global, no lee variables de entorno. El argumento `total_steps` se recibe para completitud semantica pero la logica solo depende de `ok_steps` y `failed_steps`. El valor se imprime a stdout ademas de usarse como exit code, de modo que el caller puede capturarlo con `$(exit_with_status ...)` o evaluar directamente con `exit_with_status ... ; echo $?`.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# exit_with_status — calcula el exit code estandar a partir de contadores de pasos
# exit_with_status <total_steps> <ok_steps> <failed_steps>
#
# Calcula el exit code estandar:
# 0 — todos los pasos exitosos (failed_steps == 0)
# 1 — todos los pasos fallaron (ok_steps == 0)
# 2 — resultado parcial (hay ok y failed)
#
# Imprime el codigo a stdout y sale con ese codigo.
exit_with_status() {
local total_steps="$1"
local ok_steps="$2"
local failed_steps="$3"
local code
if [[ "$failed_steps" -eq 0 ]]; then
code=0
elif [[ "$ok_steps" -eq 0 ]]; then
code=1
else
code=2
fi
echo "$code"
return "$code"
}
+36
View File
@@ -0,0 +1,36 @@
---
name: find_free_port
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "find_free_port([start_port: int], [end_port: int]) -> int"
description: "Busca el primer puerto TCP libre en un rango dado usando ss y lsof. Retorna el numero de puerto a stdout."
tags: [network, port, shell, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/find_free_port.sh"
---
## Ejemplo
```bash
source find_free_port.sh
port=$(find_free_port 8888 8899)
echo "Puerto libre: $port"
# Con defaults (8888-8899)
port=$(find_free_port)
```
## Notas
Usa `ss -tln` como primer intento y `lsof` como fallback. Ambos deben confirmar que el puerto no esta en uso. Si ningun puerto esta libre en el rango, sale con exit code 1.
+25
View File
@@ -0,0 +1,25 @@
# find_free_port
# ---------------
# Busca el primer puerto libre en un rango dado.
# Imprime el puerto encontrado a stdout.
# Sale con exit code 1 si no encuentra ninguno.
#
# USO (sourced):
# source find_free_port.sh
# port=$(find_free_port 8888 8899)
find_free_port() {
local start="${1:-8888}"
local end="${2:-8899}"
for ((port=start; port<=end; port++)); do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo "$port"
return 0
fi
done
echo "find_free_port: no se encontro puerto libre en rango ${start}-${end}" >&2
return 1
}
@@ -0,0 +1,57 @@
---
name: report_execution_json
kind: function
lang: bash
domain: shell
version: "2.0.0"
purity: pure
signature: "report_execution_json(flow_name: string, status: string, exit_code: int, started_at: string, ended_at: string, duration_ms: int, steps_file: string) -> string"
description: "Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry (docs/execution_standard.md). Recibe los metadatos del flujo y un archivo TSV con resultados de pasos (columnas: name, action, status, elapsed_ms, output, error). Imprime el JSON completo a stdout. Usa jq si esta disponible, con fallback a printf. Funcion pura: sin efectos secundarios ni I/O adicional."
tags: [execution, json, report, standard, shell, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/report_execution_json.sh"
---
## Ejemplo
```bash
source bash/functions/shell/report_execution_json.sh
# Crear archivo de pasos (TSV sin cabecera, 6 columnas)
cat > /tmp/steps.tsv <<'EOF'
check_db command ok 10 exists
backup command ok 450 done
push command error 2540 remote rejected
EOF
report_execution_json \
"backup_db" "partial" 2 \
"2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 \
/tmp/steps.tsv
```
Output esperado:
```json
{"name":"backup_db","status":"partial","exit_code":2,"started_at":"2026-04-01T02:00:00Z","ended_at":"2026-04-01T02:00:03Z","duration_ms":3000,"steps_total":3,"steps_ok":2,"steps_failed":1,"steps":[{"name":"check_db","action":"command","status":"ok","elapsed_ms":10,"output":"exists"},{"name":"backup","action":"command","status":"ok","elapsed_ms":450,"output":"done"},{"name":"push","action":"command","status":"error","elapsed_ms":2540,"error":"remote rejected"}]}
```
## Notas
Funcion pura: solo lee el archivo de pasos y escribe JSON a stdout, sin efectos secundarios ni I/O adicional mas alla de leer el steps_file.
Formato TSV: 6 columnas separadas por tabulador real (sin cabecera): name, action, status (ok|error), elapsed_ms, output, error. Los campos output y error pueden estar vacios; se omiten del JSON si no tienen valor, siguiendo el estandar de output estructurado.
El caller es responsable de calcular status (success|failure|partial) y exit_code (0|1|2) segun las reglas del estandar. Ver exit_with_status_bash_shell para esa logica.
Compatibilidad dual: con jq construye el JSON de forma robusta (maneja caracteres especiales y saltos de linea en output/error). Sin jq, el fallback con printf escapa backslash, comillas dobles y caracteres de control basicos.
Puede ejecutarse directamente: `bash report_execution_json.sh <args>`.
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# report_execution_json — Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry.
#
# USO (sourced):
# source report_execution_json.sh
# report_execution_json "backup_db" "partial" 2 \
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
#
# USO (ejecutado directamente):
# bash report_execution_json.sh "backup_db" "partial" 2 \
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
#
# FORMATO steps_file (TSV sin cabecera, 6 columnas):
# name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
# Los campos output y error pueden estar vacios.
# status valido: ok | error
#
# NOTA sobre IFS y tabs: bash trata tab como whitespace en IFS y colapsa
# campos vacios consecutivos. Se usa 'cut -f N' para parsear cada columna
# de forma correcta cuando hay campos vacios entre tabs.
report_execution_json() {
local flow_name="$1"
local status="$2"
local exit_code="$3"
local started_at="$4"
local ended_at="$5"
local duration_ms="$6"
local steps_file="$7"
if [[ -z "$flow_name" || -z "$status" || -z "$exit_code" || \
-z "$started_at" || -z "$ended_at" || -z "$duration_ms" || \
-z "$steps_file" ]]; then
echo "report_execution_json: uso: report_execution_json <flow_name> <status> <exit_code> <started_at> <ended_at> <duration_ms> <steps_file>" >&2
return 1
fi
if [[ ! -f "$steps_file" ]]; then
echo "report_execution_json: archivo de pasos no encontrado: $steps_file" >&2
return 1
fi
if command -v jq >/dev/null 2>&1; then
_report_execution_json_jq \
"$flow_name" "$status" "$exit_code" \
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
else
_report_execution_json_printf \
"$flow_name" "$status" "$exit_code" \
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
fi
}
# --- Implementacion con jq ---
_report_execution_json_jq() {
local flow_name="$1" status="$2" exit_code="$3"
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
local steps_ok=0 steps_failed=0
local steps_json="[]"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local s_name s_action s_status s_elapsed s_output s_error
s_name=$(printf '%s' "$line" | cut -f1)
s_action=$(printf '%s' "$line" | cut -f2)
s_status=$(printf '%s' "$line" | cut -f3)
s_elapsed=$(printf '%s' "$line" | cut -f4)
s_output=$(printf '%s' "$line" | cut -f5)
s_error=$(printf '%s' "$line" | cut -f6)
[[ -z "$s_name" ]] && continue
local step_obj
step_obj=$(jq -n \
--arg name "$s_name" \
--arg action "$s_action" \
--arg st "$s_status" \
--argjson ms "${s_elapsed:-0}" \
--arg output "$s_output" \
--arg error "$s_error" \
'{name: $name, action: $action, status: $st, elapsed_ms: $ms}
+ (if $output != "" then {output: $output} else {} end)
+ (if $error != "" then {error: $error} else {} end)')
steps_json=$(printf '%s' "$steps_json" | jq --argjson step "$step_obj" '. + [$step]')
if [[ "$s_status" == "ok" ]]; then
((steps_ok++))
else
((steps_failed++))
fi
done < "$steps_file"
local steps_total=$(( steps_ok + steps_failed ))
jq -n \
--arg name "$flow_name" \
--arg st "$status" \
--argjson exit_code "$exit_code" \
--arg started_at "$started_at" \
--arg ended_at "$ended_at" \
--argjson duration_ms "$duration_ms" \
--argjson total "$steps_total" \
--argjson ok "$steps_ok" \
--argjson failed "$steps_failed" \
--argjson steps "$steps_json" \
'{
name: $name,
status: $st,
exit_code: $exit_code,
started_at: $started_at,
ended_at: $ended_at,
duration_ms: $duration_ms,
steps_total: $total,
steps_ok: $ok,
steps_failed: $failed,
steps: $steps
}'
}
# --- Implementacion con printf (fallback sin jq) ---
_json_escape_rj() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
_report_execution_json_printf() {
local flow_name="$1" status="$2" exit_code="$3"
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
local steps_ok=0 steps_failed=0
local steps_parts=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local s_name s_action s_status s_elapsed s_output s_error
s_name=$(printf '%s' "$line" | cut -f1)
s_action=$(printf '%s' "$line" | cut -f2)
s_status=$(printf '%s' "$line" | cut -f3)
s_elapsed=$(printf '%s' "$line" | cut -f4)
s_output=$(printf '%s' "$line" | cut -f5)
s_error=$(printf '%s' "$line" | cut -f6)
[[ -z "$s_name" ]] && continue
local part
part=$(printf '{"name":"%s","action":"%s","status":"%s","elapsed_ms":%s' \
"$(_json_escape_rj "$s_name")" \
"$(_json_escape_rj "$s_action")" \
"$(_json_escape_rj "$s_status")" \
"${s_elapsed:-0}")
if [[ -n "$s_output" ]]; then
part+=",\"output\":\"$(_json_escape_rj "$s_output")\""
fi
if [[ -n "$s_error" ]]; then
part+=",\"error\":\"$(_json_escape_rj "$s_error")\""
fi
part+="}"
steps_parts+=("$part")
if [[ "$s_status" == "ok" ]]; then
((steps_ok++))
else
((steps_failed++))
fi
done < "$steps_file"
local steps_total=$(( steps_ok + steps_failed ))
local steps_array="["
local first=1
for part in "${steps_parts[@]}"; do
if [[ $first -eq 1 ]]; then
steps_array+="$part"
first=0
else
steps_array+=",$part"
fi
done
steps_array+="]"
printf '{"name":"%s","status":"%s","exit_code":%s,"started_at":"%s","ended_at":"%s","duration_ms":%s,"steps_total":%s,"steps_ok":%s,"steps_failed":%s,"steps":%s}\n' \
"$(_json_escape_rj "$flow_name")" \
"$(_json_escape_rj "$status")" \
"$exit_code" \
"$(_json_escape_rj "$started_at")" \
"$(_json_escape_rj "$ended_at")" \
"$duration_ms" \
"$steps_total" \
"$steps_ok" \
"$steps_failed" \
"$steps_array"
}
# Permitir ejecucion directa (no solo sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
report_execution_json "$@"
fi
+72
View File
@@ -0,0 +1,72 @@
---
name: run_steps
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "run_steps(yaml_file: string, [--strict]) -> string"
description: "Ejecuta pasos de un YAML generico donde cada step tiene action=command. Lee el YAML con yq, ejecuta cada paso secuencialmente con timeout configurable, captura exit code y output, respeta continue_on_error, y al final reporta JSON estandar a stdout via report_execution_json. Sale con exit code 0 (success), 1 (failure) o 2 (partial). Con --strict mapea partial a failure."
tags: [execution, yaml, runner, standard, pipeline]
uses_functions: [exit_with_status_bash_shell, report_execution_json_bash_shell]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/run_steps.sh"
---
## Ejemplo
```yaml
# /tmp/test_flow.yaml
name: test_flow
steps:
- name: check
action: command
command: "echo hello"
- name: list
action: command
command: "ls /tmp"
continue_on_error: true
- name: slow_step
action: command
command: "sleep 2"
timeout_ms: 1000
continue_on_error: true
```
```bash
source bash/functions/shell/run_steps.sh
run_steps /tmp/test_flow.yaml
# Imprime JSON a stdout:
# {
# "name": "test_flow",
# "status": "partial",
# "exit_code": 2,
# "started_at": "2026-04-01T10:00:00Z",
# "ended_at": "2026-04-01T10:00:01Z",
# "duration_ms": 1250,
# "steps_total": 3,
# "steps_ok": 2,
# "steps_failed": 1,
# "steps": [
# {"name": "check", "action": "command", "status": "ok", "elapsed_ms": 10, "output": "hello"},
# {"name": "list", "action": "command", "status": "ok", "elapsed_ms": 15},
# {"name": "slow_step", "action": "command", "status": "error", "elapsed_ms": 1001, "error": "timeout: comando excedio 1000ms"}
# ]
# }
# Sale con exit code 2 (partial)
run_steps /tmp/test_flow.yaml --strict
# Igual pero status="failure" y exit code=1
```
## Notas
Requiere `yq` (v4+, modo expression `-e`) y `jq` (o la implementacion printf de report_execution_json). Solo soporta `action: command` — otros actions se marcan como error del paso. El campo `timeout_ms` por defecto es 30000 (30s); si el comando excede el timeout, `timeout(1)` sale con exit code 124 y se registra el error correspondiente. El output del comando (stdout+stderr combinados) se sanitiza reemplazando tabuladores y saltos de linea por espacios antes de escribir al TSV temporal. Las funciones `exit_with_status` y `report_execution_json` se cargan via `source` desde el mismo directorio que `run_steps.sh`.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# run_steps — ejecuta pasos de un YAML generico (action=command)
# run_steps <yaml_file> [--strict]
#
# Lee un YAML con la estructura:
# name: <run_name>
# steps:
# - name: <step_name>
# action: command
# command: "<shell_command>"
# continue_on_error: true|false # opcional, default false
# timeout_ms: 30000 # opcional, default 30000
#
# Para cada paso de action=command:
# - Ejecuta el command con timeout (timeout_ms ms)
# - Captura exit code, stdout+stderr y elapsed time
# - Si falla y continue_on_error=false → aborta
# - Acumula resultados en TSV temporal
#
# Al final:
# - Llama a report_execution_json para generar JSON a stdout
# - Llama a exit_with_status para determinar el exit code
#
# --strict: mapea partial (2) a failure (1) en status y exit code
#
# Requiere: yq, jq (jq opcional si report_execution_json tiene fallback printf)
run_steps() {
local yaml_file="$1"
local strict=0
if [[ "${2:-}" == "--strict" ]]; then
strict=1
fi
# --- validaciones previas ---
if [[ -z "$yaml_file" ]]; then
echo "run_steps: yaml_file requerido" >&2
return 1
fi
if [[ ! -f "$yaml_file" ]]; then
echo "run_steps: archivo '$yaml_file' no existe" >&2
return 1
fi
if ! command -v yq &>/dev/null; then
echo "run_steps: yq no encontrado en PATH" >&2
return 1
fi
# --- cargar funciones del estandar ---
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=bash/functions/shell/exit_with_status.sh
source "$script_dir/exit_with_status.sh"
# shellcheck source=bash/functions/shell/report_execution_json.sh
source "$script_dir/report_execution_json.sh"
# --- leer nombre del run ---
local run_name
run_name=$(yq e '.name // "unnamed"' "$yaml_file")
# --- contar pasos ---
local step_count
step_count=$(yq e '.steps | length' "$yaml_file")
if [[ -z "$step_count" || "$step_count" -eq 0 ]]; then
echo "run_steps: el YAML no tiene steps" >&2
return 1
fi
# --- archivo TSV temporal para acumular resultados ---
# columnas: name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
local tsv_file
tsv_file=$(mktemp /tmp/run_steps_XXXXXX.tsv)
# shellcheck disable=SC2064
trap "rm -f '$tsv_file'" EXIT
local ok_steps=0
local failed_steps=0
local started_at
started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local t_run_start
t_run_start=$(date +%s%3N)
# --- iterar sobre cada paso ---
local i
for (( i=0; i<step_count; i++ )); do
local step_name step_action step_command step_continue step_timeout_ms
step_name=$(yq e ".steps[$i].name // \"step_$i\"" "$yaml_file")
step_action=$(yq e ".steps[$i].action // \"command\"" "$yaml_file")
step_command=$(yq e ".steps[$i].command // \"\"" "$yaml_file")
step_continue=$(yq e ".steps[$i].continue_on_error // false" "$yaml_file")
step_timeout_ms=$(yq e ".steps[$i].timeout_ms // 30000" "$yaml_file")
# convertir timeout de ms a segundos enteros (timeout(1) usa segundos)
local timeout_sec
timeout_sec=$(( (step_timeout_ms + 999) / 1000 ))
# solo soportamos action=command
if [[ "$step_action" != "command" ]]; then
local err_msg="action '$step_action' no soportada (solo command)"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "$step_action" "error" "0" "" "$err_msg" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: paso '$step_name' — $err_msg — abortando" >&2
break
fi
continue
fi
if [[ -z "$step_command" ]]; then
local err_msg="campo 'command' vacio en paso '$step_name'"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "error" "0" "" "$err_msg" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: $err_msg — abortando" >&2
break
fi
continue
fi
# ejecutar con timeout y capturar output + tiempos
local t_start t_end elapsed_ms step_output step_exit
t_start=$(date +%s%3N)
step_output=$(timeout "$timeout_sec" bash -c "$step_command" 2>&1)
step_exit=$?
t_end=$(date +%s%3N)
elapsed_ms=$(( t_end - t_start ))
# timeout(1) sale con 124 cuando agota el tiempo
local step_error=""
if [[ "$step_exit" -eq 124 ]]; then
step_error="timeout: comando excedio ${step_timeout_ms}ms"
fi
# escapar tabuladores y saltos de linea en el output para el TSV
local safe_output
safe_output=$(printf '%s' "$step_output" | tr '\t' ' ' | tr '\n' ' ')
if [[ "$step_exit" -eq 0 ]]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "ok" "$elapsed_ms" "$safe_output" "" >> "$tsv_file"
ok_steps=$((ok_steps + 1))
else
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "error" "$elapsed_ms" "$safe_output" "$step_error" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: paso '$step_name' fallo (exit $step_exit) — abortando" >&2
break
fi
fi
done
local t_run_end
t_run_end=$(date +%s%3N)
local total_duration_ms=$(( t_run_end - t_run_start ))
local ended_at
ended_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local total_steps=$(( ok_steps + failed_steps ))
# --- calcular status y exit code ---
local status_code
status_code=$(exit_with_status "$total_steps" "$ok_steps" "$failed_steps")
local run_status
case "$status_code" in
0) run_status="success" ;;
1) run_status="failure" ;;
2)
if [[ "$strict" -eq 1 ]]; then
run_status="failure"
status_code=1
else
run_status="partial"
fi
;;
*) run_status="failure"; status_code=1 ;;
esac
# --- generar JSON a stdout ---
report_execution_json \
"$run_name" \
"$run_status" \
"$status_code" \
"$started_at" \
"$ended_at" \
"$total_duration_ms" \
"$tsv_file"
return "$status_code"
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"text/tabwriter"
)
func cmdAnalysis(args []string) {
if len(args) < 1 {
printAnalysisUsage()
os.Exit(1)
}
switch args[0] {
case "list":
analysisList()
case "clone":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: fn analysis clone <id>")
os.Exit(1)
}
analysisClone(args[1])
case "pull":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: fn analysis pull <id>")
os.Exit(1)
}
analysisPull(args[1])
case "help", "-h":
printAnalysisUsage()
default:
fmt.Fprintf(os.Stderr, "unknown analysis command: %s\n", args[0])
printAnalysisUsage()
os.Exit(1)
}
}
func printAnalysisUsage() {
fmt.Println(`fn analysis — manage registry analyses
Usage:
fn analysis list List all analyses (local + remote)
fn analysis clone <id> Clone analysis repo to analysis/<name>/
fn analysis pull <id> Git pull in existing clone`)
}
func analysisList() {
db := openDB()
defer db.Close()
items, err := db.ListAllAnalysis()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(items) == 0 {
fmt.Println("No analyses registered.")
return
}
r := root()
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
for _, a := range items {
status := "local"
if a.RepoURL != "" {
dirPath := filepath.Join(r, "analysis", a.Name)
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
status = "remote"
} else {
status = "cloned"
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
}
w.Flush()
}
func analysisClone(id string) {
db := openDB()
defer db.Close()
a, err := db.GetAnalysis(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if a.RepoURL == "" {
fmt.Fprintf(os.Stderr, "analysis %s has no repo_url set\n", id)
os.Exit(1)
}
r := root()
target := filepath.Join(r, "analysis", a.Name)
if _, err := os.Stat(target); err == nil {
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
os.Exit(1)
}
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
cmd := exec.Command("git", "clone", a.RepoURL, target)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Done. Run 'fn index' to re-index.")
}
func analysisPull(id string) {
db := openDB()
defer db.Close()
a, err := db.GetAnalysis(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
r := root()
dir := filepath.Join(r, "analysis", a.Name)
if _, err := os.Stat(dir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "analysis not cloned locally: %s\n", dir)
fmt.Fprintln(os.Stderr, "Run 'fn analysis clone "+id+"' first.")
os.Exit(1)
}
fmt.Printf("Pulling %s\n", dir)
cmd := exec.Command("git", "-C", dir, "pull")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Done. Run 'fn index' to re-index.")
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/tabwriter"
)
func cmdApp(args []string) {
if len(args) < 1 {
printAppUsage()
os.Exit(1)
}
switch args[0] {
case "list":
appList()
case "clone":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: fn app clone <id>")
os.Exit(1)
}
appClone(args[1])
case "pull":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: fn app pull <id>")
os.Exit(1)
}
appPull(args[1])
case "help", "-h":
printAppUsage()
default:
fmt.Fprintf(os.Stderr, "unknown app command: %s\n", args[0])
printAppUsage()
os.Exit(1)
}
}
func printAppUsage() {
fmt.Println(`fn app — manage registry apps
Usage:
fn app list List all apps (local + remote)
fn app clone <id> Clone app repo to apps/<name>/
fn app pull <id> Git pull in existing clone`)
}
func appList() {
db := openDB()
defer db.Close()
apps, err := db.ListAllApps()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(apps) == 0 {
fmt.Println("No apps registered.")
return
}
r := root()
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
for _, a := range apps {
status := "local"
if a.RepoURL != "" {
dirPath := filepath.Join(r, "apps", a.Name)
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
status = "remote"
} else {
status = "cloned"
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
}
w.Flush()
}
func appClone(id string) {
db := openDB()
defer db.Close()
a, err := db.GetApp(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if a.RepoURL == "" {
fmt.Fprintf(os.Stderr, "app %s has no repo_url set\n", id)
os.Exit(1)
}
r := root()
target := filepath.Join(r, "apps", a.Name)
if _, err := os.Stat(target); err == nil {
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
os.Exit(1)
}
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
cmd := exec.Command("git", "clone", a.RepoURL, target)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Done. Run 'fn index' to re-index.")
}
func appPull(id string) {
db := openDB()
defer db.Close()
a, err := db.GetApp(id)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
r := root()
dir := filepath.Join(r, "apps", a.Name)
if _, err := os.Stat(dir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "app not cloned locally: %s\n", dir)
fmt.Fprintln(os.Stderr, "Run 'fn app clone "+id+"' first.")
os.Exit(1)
}
fmt.Printf("Pulling %s\n", dir)
cmd := exec.Command("git", "-C", dir, "pull")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Done. Run 'fn index' to re-index.")
}
// formatRepoURL ensures the URL does not contain inline credentials for display.
func formatRepoURL(url string) string {
if strings.Contains(url, "@") && strings.Contains(url, "://") {
// Mask credentials in URLs like https://user:pass@host/...
parts := strings.SplitN(url, "://", 2)
if len(parts) == 2 {
atIdx := strings.LastIndex(parts[1], "@")
if atIdx >= 0 {
return parts[0] + "://" + parts[1][atIdx+1:]
}
}
}
return url
}
+84 -5
View File
@@ -35,6 +35,10 @@ func main() {
cmdProposal(os.Args[2:]) cmdProposal(os.Args[2:])
case "run": case "run":
cmdRun(os.Args[2:]) cmdRun(os.Args[2:])
case "app":
cmdApp(os.Args[2:])
case "analysis":
cmdAnalysis(os.Args[2:])
case "help", "-h", "--help": case "help", "-h", "--help":
printUsage() printUsage()
default: default:
@@ -55,7 +59,9 @@ Usage:
fn add [-k kind] Abre $EDITOR con template fn add [-k kind] Abre $EDITOR con template
fn run <id_or_name> [args...] Ejecuta funcion/pipeline (go/py/bash) fn run <id_or_name> [args...] Ejecuta funcion/pipeline (go/py/bash)
fn ops <subcommand> Gestiona operations.db (fn ops help) fn ops <subcommand> Gestiona operations.db (fn ops help)
fn proposal <add|list|show|update> Gestiona proposals`) fn proposal <add|list|show|update> Gestiona proposals
fn app <list|clone|pull> Gestiona apps externas (Gitea)
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)`)
} }
func root() string { func root() string {
@@ -117,7 +123,7 @@ func cmdIndex() {
} }
} }
fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps) fmt.Printf("Indexed %d functions, %d types, %d apps, %d analysis\n", result.Functions, result.Types, result.Apps, result.Analysis)
for _, e := range result.ValidationErrors { for _, e := range result.ValidationErrors {
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e) fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
} }
@@ -172,7 +178,13 @@ func cmdSearch(args []string) {
os.Exit(1) os.Exit(1)
} }
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 { analyses, err := db.SearchAnalysis(query, lang, domain)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 && len(analyses) == 0 {
fmt.Println("No results.") fmt.Println("No results.")
return return
} }
@@ -205,6 +217,16 @@ func cmdSearch(args []string) {
fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, desc) fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
} }
} }
if len(analyses) > 0 {
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDESCRIPTION")
for _, a := range analyses {
desc := truncate(a.Description, 60)
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
}
}
w.Flush() w.Flush()
} }
@@ -249,6 +271,12 @@ func cmdList(args []string) {
os.Exit(1) os.Exit(1)
} }
analyses, err := db.SearchAnalysis("", lang, domain)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
if len(fns) > 0 { if len(fns) > 0 {
fmt.Fprintln(w, "KIND\tID\tPURITY\tVERSION\tDOMAIN") fmt.Fprintln(w, "KIND\tID\tPURITY\tVERSION\tDOMAIN")
@@ -274,7 +302,16 @@ func cmdList(args []string) {
fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain) fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain)
} }
} }
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 { if len(analyses) > 0 {
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
fmt.Fprintln(w)
}
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDOMAIN")
for _, a := range analyses {
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain)
}
}
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 && len(analyses) == 0 {
fmt.Println("Registry is empty. Run 'fn index' first.") fmt.Println("Registry is empty. Run 'fn index' first.")
} }
w.Flush() w.Flush()
@@ -310,6 +347,12 @@ func cmdShow(args []string) {
return return
} }
an, errAn := db.GetAnalysis(id)
if errAn == nil {
printAnalysisEntry(an)
return
}
fmt.Fprintf(os.Stderr, "not found: %s\n", id) fmt.Fprintf(os.Stderr, "not found: %s\n", id)
os.Exit(1) os.Exit(1)
} }
@@ -412,6 +455,40 @@ func printApp(a *registry.App) {
fmt.Printf("Description: %s\n", a.Description) fmt.Printf("Description: %s\n", a.Description)
fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", ")) fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", "))
fmt.Printf("Dir: %s\n", a.DirPath) fmt.Printf("Dir: %s\n", a.DirPath)
if a.RepoURL != "" {
fmt.Printf("Repo URL: %s\n", a.RepoURL)
}
if a.Framework != "" {
fmt.Printf("Framework: %s\n", a.Framework)
}
if a.EntryPoint != "" {
fmt.Printf("Entry point: %s\n", a.EntryPoint)
}
if len(a.UsesFunctions) > 0 {
fmt.Printf("Uses fns: %s\n", strings.Join(a.UsesFunctions, ", "))
}
if len(a.UsesTypes) > 0 {
fmt.Printf("Uses types: %s\n", strings.Join(a.UsesTypes, ", "))
}
if a.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", a.Notes)
}
if a.Documentation != "" {
fmt.Printf("\nDocumentation:\n%s\n", a.Documentation)
}
}
func printAnalysisEntry(a *registry.Analysis) {
fmt.Printf("ID: %s\n", a.ID)
fmt.Printf("Name: %s\n", a.Name)
fmt.Printf("Lang: %s\n", a.Lang)
fmt.Printf("Domain: %s\n", a.Domain)
fmt.Printf("Description: %s\n", a.Description)
fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", "))
fmt.Printf("Dir: %s\n", a.DirPath)
if a.RepoURL != "" {
fmt.Printf("Repo URL: %s\n", a.RepoURL)
}
if a.Framework != "" { if a.Framework != "" {
fmt.Printf("Framework: %s\n", a.Framework) fmt.Printf("Framework: %s\n", a.Framework)
} }
@@ -459,8 +536,10 @@ func cmdAdd(args []string) {
templatePath = filepath.Join(r, "docs", "templates", "component.md") templatePath = filepath.Join(r, "docs", "templates", "component.md")
case "app": case "app":
templatePath = filepath.Join(r, "docs", "templates", "app.md") templatePath = filepath.Join(r, "docs", "templates", "app.md")
case "analysis":
templatePath = filepath.Join(r, "docs", "templates", "analysis.md")
default: default:
fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, or app)\n", kind) fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, app, or analysis)\n", kind)
os.Exit(1) os.Exit(1)
} }
+384
View File
@@ -0,0 +1,384 @@
# Estandar de Ejecucion: YAML + Exit Codes + Hooks
Contrato comun para apps, pipelines y automatizaciones del fn-registry. Define como declarar flujos en YAML, que exit codes devolver, como reportar resultados y como integrarse con Dagu y operations.db.
---
## Motivacion
Tenemos dos patrones que ya funcionan:
- **Dagu**: orquesta DAGs con `command`/`script`, usa exit codes para decidir flujo, tiene handlers globales y scheduling cron.
- **script_navegador**: ejecuta pasos tipados (CDP actions) desde YAML, registra todo en operations.db, sale con 0/1.
Ambos comparten la misma idea: **YAML declara el flujo, exit code señala el resultado, logs estructurados permiten analisis posterior**. Este documento formaliza ese contrato para que cualquier app nueva lo siga desde el inicio.
---
## 1. Estructura YAML
Toda app que ejecute un flujo debe leer un YAML con esta estructura minima:
```yaml
# Cabecera obligatoria
name: nombre_del_flujo
description: "Que hace este flujo" # opcional pero recomendado
# Variables de entorno disponibles para todos los pasos
env: # opcional
KEY: value
# Pasos del flujo
steps:
- name: paso_1 # identificador unico dentro del flujo
action: tipo_de_accion # dispatch key (navigate, command, query, etc.)
# ... parametros especificos del action
continue_on_error: false # default: false
timeout_ms: 30000 # default: 30s, 0 = sin timeout
depends: [] # IDs de pasos previos (vacio = secuencial)
# Hooks opcionales
hooks:
on_success: "comando o script" # se ejecuta si exit code = 0
on_failure: "comando o script" # se ejecuta si exit code = 1
on_partial: "comando o script" # se ejecuta si exit code = 2
```
### Campos de la cabecera
| Campo | Requerido | Descripcion |
|-------|-----------|-------------|
| `name` | si | Identificador del flujo (snake_case) |
| `description` | no | Descripcion legible |
| `env` | no | Variables de entorno (mapa key-value) |
| `steps` | si | Lista de pasos a ejecutar |
| `hooks` | no | Comandos a ejecutar segun resultado |
### Campos de cada step
| Campo | Requerido | Default | Descripcion |
|-------|-----------|---------|-------------|
| `name` | si | — | ID unico del paso dentro del flujo |
| `action` | si | — | Tipo de accion (cada app define sus propios actions) |
| `continue_on_error` | no | `false` | Si `true`, un fallo no detiene el flujo |
| `timeout_ms` | no | `30000` | Timeout del paso en ms (0 = sin timeout) |
| `depends` | no | `[]` | Pasos que deben completarse antes |
Los campos adicionales dependen del `action` y los define cada app/dominio.
### Actions por dominio
Cada app registra sus actions validos. Ejemplos:
| Dominio | Actions | App de referencia |
|---------|---------|-------------------|
| navegacion | `navigate`, `click`, `type`, `wait`, `screenshot`, `evaluate`, `get_html`, `wait_load`, `sleep` | script_navegador |
| shell | `command`, `script` | Dagu / apps genericas |
| database | `query`, `migrate`, `backup` | futuras apps |
| http | `request`, `assert_status`, `extract` | futuras apps |
---
## 2. Exit Codes
Toda app/script que siga este estandar DEBE salir con uno de estos tres codigos:
| Codigo | Constante | Significado |
|--------|-----------|-------------|
| `0` | `EXIT_SUCCESS` | Todos los pasos completaron sin error |
| `1` | `EXIT_FAILURE` | Al menos un paso fallo y el flujo aborto |
| `2` | `EXIT_PARTIAL` | Algunos pasos fallaron pero el flujo continuo (continue_on_error) |
### Reglas
- Si todos los pasos son exitosos → `0`
- Si algun paso falla y tiene `continue_on_error: false``1` (aborta)
- Si algun paso falla pero todos los fallos tenian `continue_on_error: true``2`
- Un timeout agotado cuenta como fallo del paso
### Compatibilidad con Dagu
Dagu interpreta exit code != 0 como fallo. Para que `2` (partial) no dispare el handler de failure en Dagu, el DAG que llama a la app puede usar:
```yaml
steps:
- name: run_app
command: ./mi_app --script flujo.yaml
continue_on:
failure: true # no abortar el DAG por parcial
- name: check_result
command: test $? -le 1 # falla solo si fue error fatal
depends: [run_app]
```
O alternativamente, la app puede configurarse con `--strict` para mapear partial → failure (exit 1).
---
## 3. Output Estructurado
Toda app DEBE escribir un **resumen JSON en stdout** al finalizar (despues de su output humano). El formato:
```json
{
"name": "nombre_del_flujo",
"status": "success|failure|partial",
"exit_code": 0,
"started_at": "2026-04-01T10:00:00Z",
"ended_at": "2026-04-01T10:00:05Z",
"duration_ms": 5000,
"steps_total": 5,
"steps_ok": 4,
"steps_failed": 1,
"steps": [
{
"name": "paso_1",
"action": "navigate",
"status": "ok",
"elapsed_ms": 1200,
"output": "optional output"
},
{
"name": "paso_2",
"action": "click",
"status": "error",
"elapsed_ms": 500,
"error": "elemento no encontrado: #btn-submit"
}
]
}
```
### Separacion humano / maquina
- **stderr**: logs legibles para humanos (progreso, warnings)
- **stdout**: output JSON estructurado al final (para Dagu, hooks, pipes)
- Flag `--json` o `--quiet`: suprime output humano, solo JSON en stdout
Esto permite que Dagu capture el JSON:
```yaml
steps:
- name: navegacion
command: ./script_navegador --script busqueda.yaml --json
output: RESULT
- name: analizar
command: echo "$RESULT" | jq '.steps[] | select(.status=="error")'
depends: [navegacion]
```
---
## 4. Hooks
Los hooks se ejecutan **despues** de todos los pasos, segun el exit code resultante.
### En el YAML del flujo
```yaml
hooks:
on_success: "notify-send 'Flujo completado'"
on_failure: "echo 'FALLO: {{.Name}}' >> /tmp/failures.log"
on_partial: "echo 'PARCIAL: {{.StepsFailed}} pasos fallaron' >> /tmp/warnings.log"
on_step_error: "echo 'Step {{.StepName}} fallo: {{.Error}}'" # se ejecuta por cada paso fallido
```
### Variables disponibles en hooks
| Variable | Tipo | Descripcion |
|----------|------|-------------|
| `{{.Name}}` | string | Nombre del flujo |
| `{{.Status}}` | string | success, failure, partial |
| `{{.ExitCode}}` | int | 0, 1, 2 |
| `{{.DurationMs}}` | int | Duracion total en ms |
| `{{.StepsTotal}}` | int | Total de pasos |
| `{{.StepsOk}}` | int | Pasos exitosos |
| `{{.StepsFailed}}` | int | Pasos fallidos |
| `{{.StepName}}` | string | Nombre del paso (solo en on_step_error) |
| `{{.Error}}` | string | Mensaje de error (solo en on_step_error/on_failure) |
### En Dagu (handlers globales)
Los hooks del YAML son locales al flujo. Para orquestacion, Dagu maneja sus propios handlers:
```yaml
# dags/mi_automatizacion.yaml
name: mi_automatizacion
handlers:
failure:
- name: alerta
command: echo "Fallo en mi_automatizacion" >> ~/dagu/logs/failures.log
success:
- name: registrar
command: echo "OK $(date)" >> ~/dagu/logs/success.log
steps:
- name: ejecutar
command: ./fn run mi_pipeline --script flujo.yaml
```
---
## 5. Integracion con operations.db
Las apps que siguen el bucle reactivo DEBEN registrar la ejecucion en operations.db:
### Mapping del estandar a operations.db
| Concepto estandar | Tabla operations.db | Campo |
|-------------------|---------------------|-------|
| Flujo completo | `executions` | `status`: success/failure/partial |
| Cada paso | `logs` | `level`: info/error, `message`: resultado |
| Exit code | `executions.metrics` | `{"exit_code": N}` |
| Output JSON | `executions.metrics` | `{"steps": [...]}` |
| Duracion | `executions.duration_ms` | milisegundos |
| Pasos in/out | `executions.records_in/out` | total/exitosos |
### Patron de registro (referencia: script_navegador/ops.go)
```
1. initOpsDB() → abrir/crear operations.db
2. EnsureEntities() → registrar recursos involucrados
3. EnsureRelations() → registrar como se conectan
4. [ejecutar flujo]
5. RecordRun() → insertar execution con metricas
6. LogStep() por paso → insertar log por cada paso
7. UpdateRelation() → status final de la relation
```
Este patron ya esta implementado en `script_navegador/ops.go` y sirve como referencia para nuevas apps.
---
## 6. Flujo completo: App → Dagu → Hooks
```
Dagu (scheduler)
cron / manual
┌────▼────┐
│ DAG │ dags/mi_dag.yaml
│ step │ command: ./mi_app --script flujo.yaml --json
└────┬────┘
┌────▼────────────────┐
│ App (mi_app) │
│ │
│ 1. Load YAML │
│ 2. Validate steps │
│ 3. Init ops.db │
│ 4. Execute steps │
│ 5. Run hooks │
│ 6. Record to ops │
│ 7. JSON → stdout │
│ 8. Exit code │
└────┬────────────────┘
┌──────────┼──────────┐
│ │ │
exit 0 exit 1 exit 2
success failure partial
│ │ │
▼ ▼ ▼
Dagu OK Dagu FAIL Dagu FAIL*
handler handler (configurable)
```
*Con `continue_on: failure: true` en el step de Dagu, exit 2 no aborta el DAG.
---
## 7. Implementacion: funciones del registry
### Existentes (ya en uso por script_navegador)
Todas las funciones CDP del registry (`cdp_*_go_browser`, `chrome_launch_go_browser`) ya siguen el patron de retornar error. El runner de script_navegador las compone.
### Nuevas (por construir)
| Funcion | Lang | Domain | Descripcion |
|---------|------|--------|-------------|
| `run_steps` | bash | shell | Ejecuta pasos de un YAML generico (action=command/script), reporta JSON y sale con 0/1/2 |
| `report_execution_json` | bash | shell | Genera el JSON de salida estandar dado los resultados de pasos |
| `exit_with_status` | bash | shell | Calcula exit code (0/1/2) a partir de contadores ok/fail/partial |
Estas funciones Bash permiten que cualquier script nuevo siga el estandar sin reimplementar la logica de reporte y exit codes.
---
## 8. Ejemplo completo
### Script YAML (flujo.yaml)
```yaml
name: backup_db
description: "Backup de operations.db y push a remoto"
steps:
- name: check_db
action: command
command: "test -f operations.db"
- name: backup
action: command
command: "cp operations.db backups/operations_$(date +%Y%m%d).db"
depends: [check_db]
- name: push
action: command
command: "git add backups/ && git commit -m 'backup' && git push"
depends: [backup]
continue_on_error: true
hooks:
on_failure: "echo 'Backup fallo' >> /tmp/backup_errors.log"
```
### Invocacion desde Dagu
```yaml
name: backup_diario
schedule: "0 2 * * *"
steps:
- name: backup
command: ./fn run backup_db --script flujo.yaml --json
working_dir: /home/lucas/fn_registry/apps/mi_app
handlers:
failure:
- name: alerta
command: echo "Backup fallo $(date)" >> ~/dagu/logs/failures.log
```
### Output esperado (stdout)
```json
{
"name": "backup_db",
"status": "partial",
"exit_code": 2,
"started_at": "2026-04-01T02:00:00Z",
"ended_at": "2026-04-01T02:00:03Z",
"duration_ms": 3000,
"steps_total": 3,
"steps_ok": 2,
"steps_failed": 1,
"steps": [
{"name": "check_db", "action": "command", "status": "ok", "elapsed_ms": 10},
{"name": "backup", "action": "command", "status": "ok", "elapsed_ms": 450},
{"name": "push", "action": "command", "status": "error", "elapsed_ms": 2540, "error": "remote rejected"}
]
}
```
Exit code: `2` (partial — push fallo pero tenia continue_on_error).
---
## Resumen del contrato
| Aspecto | Regla |
|---------|-------|
| Formato de flujo | YAML con `name` + `steps[]` |
| Exit codes | 0=success, 1=failure, 2=partial |
| Output maquina | JSON en stdout (con `--json`) |
| Output humano | stderr (progreso, warnings) |
| Error handling | `continue_on_error` por paso, hooks por resultado |
| Trazabilidad | operations.db (executions + logs) |
| Orquestacion | Dagu como scheduler, apps como ejecutores |
| Funciones | Reutilizar del registry, actions por dominio |
+17
View File
@@ -0,0 +1,17 @@
---
name: my_analysis
lang: py
domain: datascience
description: "Descripcion breve del analisis."
tags: []
uses_functions: []
uses_types: []
framework: "jupyterlab"
entry_point: "notebooks/main.ipynb"
dir_path: "analysis/my_analysis"
repo_url: ""
---
## Notas
Notas adicionales sobre el analisis.
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_click name: cdp_click
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpClick(c *CDPConn, selector string) error" signature: "func CdpClick(c *CDPConn, selector string) error"
description: "Hace click en el primer elemento que coincide con el selector CSS. Obtiene coordenadas del centro via getBoundingClientRect, hace scroll al elemento y despacha eventos mousedown+mouseup via Input.dispatchMouseEvent." description: "Hace click en el primer elemento que coincide con el selector CSS. Obtiene coordenadas del centro via getBoundingClientRect, hace scroll al elemento y despacha eventos mousedown+mouseup via Input.dispatchMouseEvent."
tags: [chrome, cdp, browser, automation, click, dom, devtools] tags: [chrome, cdp, browser, automation, click, dom, devtools]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra] uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,7 +2,7 @@
name: cdp_close name: cdp_close
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error" signature: "func CdpClose(c *CDPConn, pid int) error"
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"bufio" "bufio"
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"encoding/json" "encoding/json"
@@ -2,7 +2,7 @@
name: cdp_connect name: cdp_connect
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpConnect(port int) (*CDPConn, error)" signature: "func CdpConnect(port int) (*CDPConn, error)"
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_evaluate name: cdp_evaluate
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpEvaluate(c *CDPConn, expression string) (string, error)" signature: "func CdpEvaluate(c *CDPConn, expression string) (string, error)"
description: "Ejecuta una expresion JavaScript arbitraria en la pagina actual via Runtime.evaluate. Retorna el resultado serializado como string. Soporta await (awaitPromise=true). Reporta excepciones JS como error." description: "Ejecuta una expresion JavaScript arbitraria en la pagina actual via Runtime.evaluate. Retorna el resultado serializado como string. Soporta await (awaitPromise=true). Reporta excepciones JS como error."
tags: [chrome, cdp, browser, automation, javascript, devtools] tags: [chrome, cdp, browser, automation, javascript, devtools]
uses_functions: [cdp_connect_go_infra] uses_functions: [cdp_connect_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_get_html name: cdp_get_html
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpGetHTML(c *CDPConn) (string, error)" signature: "func CdpGetHTML(c *CDPConn) (string, error)"
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original." description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools] tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra] uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_navigate name: cdp_navigate
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpNavigate(c *CDPConn, targetURL string) error" signature: "func CdpNavigate(c *CDPConn, targetURL string) error"
description: "Navega a la URL indicada usando el comando Page.navigate del protocolo CDP. Verifica que no haya errorText en la respuesta. Recibe una *CDPConn obtenida de CdpConnect." description: "Navega a la URL indicada usando el comando Page.navigate del protocolo CDP. Verifica que no haya errorText en la respuesta. Recibe una *CDPConn obtenida de CdpConnect."
tags: [chrome, cdp, browser, automation, navigation, devtools] tags: [chrome, cdp, browser, automation, navigation, devtools]
uses_functions: [cdp_connect_go_infra] uses_functions: [cdp_connect_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"encoding/base64" "encoding/base64"
@@ -2,13 +2,13 @@
name: cdp_screenshot name: cdp_screenshot
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error" signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error"
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe." description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe."
tags: [chrome, cdp, browser, automation, screenshot, devtools, png] tags: [chrome, cdp, browser, automation, screenshot, devtools, png]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra] uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_type_text name: cdp_type_text
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpTypeText(c *CDPConn, text string) error" signature: "func CdpTypeText(c *CDPConn, text string) error"
description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento." description: "Escribe texto en el elemento activo de la pagina caracter por caracter via Input.dispatchKeyEvent. Envia eventos keyDown, char y keyUp por cada caracter con 10ms de pausa entre ellos. Usar CdpClick primero para enfocar el elemento."
tags: [chrome, cdp, browser, automation, keyboard, input, devtools] tags: [chrome, cdp, browser, automation, keyboard, input, devtools]
uses_functions: [cdp_connect_go_infra] uses_functions: [cdp_connect_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"
@@ -2,13 +2,13 @@
name: cdp_wait_element name: cdp_wait_element
kind: function kind: function
lang: go lang: go
domain: infra domain: browser
version: "1.0.0" version: "1.0.0"
purity: impure purity: impure
signature: "func CdpWaitElement(c *CDPConn, selector string, timeout time.Duration) error" signature: "func CdpWaitElement(c *CDPConn, selector string, timeout time.Duration) error"
description: "Espera hasta que un selector CSS exista en el DOM. Hace polling con Runtime.evaluate cada 200ms. Retorna nil cuando el elemento aparece o error si se agota el timeout. Util despues de navegacion o acciones que producen cambios dinamicos." description: "Espera hasta que un selector CSS exista en el DOM. Hace polling con Runtime.evaluate cada 200ms. Retorna nil cuando el elemento aparece o error si se agota el timeout. Util despues de navegacion o acciones que producen cambios dinamicos."
tags: [chrome, cdp, browser, automation, dom, wait, polling, devtools] tags: [chrome, cdp, browser, automation, dom, wait, polling, devtools]
uses_functions: [cdp_connect_go_infra, cdp_evaluate_go_infra] uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
@@ -1,4 +1,4 @@
package infra package browser
import ( import (
"fmt" "fmt"

Some files were not shown because too many files have changed in this diff Show More