From d7f2c00d7b58ce52ee19a653dd94ee6fd61fdbd9 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 1 Apr 2026 04:23:51 +0200 Subject: [PATCH] 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) --- .claude/CLAUDE.md | 81 +++- .gitignore | 4 + analysis/.gitkeep | 0 apps/.gitkeep | 0 apps/docker_tui/.gitignore | 5 - apps/docker_tui/Makefile | 19 - apps/docker_tui/app.md | 28 -- apps/docker_tui/app/model.go | 175 -------- apps/docker_tui/build.sh | 14 - apps/docker_tui/config/config.go | 15 - apps/docker_tui/go.mod | 43 -- apps/docker_tui/go.sum | 84 ---- apps/docker_tui/go.work | 6 - apps/docker_tui/go.work.sum | 40 -- apps/docker_tui/main.go | 20 - apps/docker_tui/views/compose.go | 201 --------- apps/docker_tui/views/containers.go | 251 ----------- apps/docker_tui/views/docker.go | 192 -------- apps/docker_tui/views/images.go | 132 ------ apps/docker_tui/views/keys.go | 14 - apps/docker_tui/views/networks.go | 128 ------ apps/docker_tui/views/types.go | 45 -- apps/docker_tui/views/volumes.go | 128 ------ apps/metabase_registry/app.md | 104 ----- .../create_apps_dashboard.py | 219 ---------- .../create_registry_dashboard.py | 265 ----------- .../create_script_navegador_dashboard.py | 195 --------- apps/metabase_registry/main.py | 411 ------------------ apps/metabase_registry/requirements.txt | 13 - apps/pipeline_launcher/app.md | 16 - apps/pipeline_launcher/app/model.go | 181 -------- apps/pipeline_launcher/config/config.go | 27 -- apps/pipeline_launcher/go.mod | 38 -- apps/pipeline_launcher/go.sum | 51 --- apps/pipeline_launcher/main.go | 29 -- apps/pipeline_launcher/views/history.go | 219 ---------- apps/pipeline_launcher/views/keys.go | 14 - apps/pipeline_launcher/views/pipelines.go | 398 ----------------- apps/pipeline_launcher/views/runner.go | 146 ------- apps/script_navegador/.gitignore | 6 - apps/script_navegador/app.md | 100 ----- .../examples/busqueda_google.yaml | 28 -- .../examples/continue_on_error.yaml | 20 - .../examples/scrape_titulo.yaml | 16 - apps/script_navegador/examples/youtube.yaml | 6 - apps/script_navegador/go.mod | 12 - apps/script_navegador/go.sum | 6 - apps/script_navegador/id.go | 20 - apps/script_navegador/main.go | 214 --------- apps/script_navegador/ops.go | 333 -------------- apps/script_navegador/runner.go | 143 ------ apps/script_navegador/script.go | 121 ------ apps/script_navegador/wsl.go | 102 ----- bash/functions/infra/init_uv_venv.md | 36 ++ bash/functions/infra/init_uv_venv.sh | 35 ++ bash/functions/infra/uv_add_packages.md | 35 ++ bash/functions/infra/uv_add_packages.sh | 35 ++ .../infra/write_claude_jupyter_rules.md | 33 ++ .../infra/write_claude_jupyter_rules.sh | 74 ++++ .../functions/infra/write_jupyter_launcher.md | 41 ++ .../functions/infra/write_jupyter_launcher.sh | 65 +++ .../infra/write_jupyter_registry_kernel.md | 56 +++ .../infra/write_jupyter_registry_kernel.sh | 111 +++++ .../infra/write_mcp_jupyter_config.md | 33 ++ .../infra/write_mcp_jupyter_config.sh | 57 +++ .../pipelines/init_jupyter_analysis.md | 63 +++ .../pipelines/init_jupyter_analysis.sh | 123 ++++++ bash/functions/shell/exit_with_status.md | 35 ++ bash/functions/shell/exit_with_status.sh | 29 ++ bash/functions/shell/find_free_port.md | 36 ++ bash/functions/shell/find_free_port.sh | 25 ++ bash/functions/shell/report_execution_json.md | 57 +++ bash/functions/shell/report_execution_json.sh | 210 +++++++++ bash/functions/shell/run_steps.md | 72 +++ bash/functions/shell/run_steps.sh | 201 +++++++++ cmd/fn/analysis.go | 143 ++++++ cmd/fn/app.go | 159 +++++++ cmd/fn/main.go | 89 +++- docs/execution_standard.md | 384 ++++++++++++++++ docs/templates/analysis.md | 17 + functions/{infra => browser}/cdp_click.go | 2 +- functions/{infra => browser}/cdp_click.md | 4 +- functions/{infra => browser}/cdp_close.go | 2 +- functions/{infra => browser}/cdp_close.md | 2 +- functions/{infra => browser}/cdp_conn.go | 2 +- functions/{infra => browser}/cdp_connect.go | 2 +- functions/{infra => browser}/cdp_connect.md | 2 +- functions/{infra => browser}/cdp_evaluate.go | 2 +- functions/{infra => browser}/cdp_evaluate.md | 4 +- functions/{infra => browser}/cdp_get_html.go | 2 +- functions/{infra => browser}/cdp_get_html.md | 4 +- functions/{infra => browser}/cdp_navigate.go | 2 +- functions/{infra => browser}/cdp_navigate.md | 4 +- .../{infra => browser}/cdp_screenshot.go | 2 +- .../{infra => browser}/cdp_screenshot.md | 4 +- functions/{infra => browser}/cdp_type_text.go | 2 +- functions/{infra => browser}/cdp_type_text.md | 4 +- .../{infra => browser}/cdp_wait_element.go | 2 +- .../{infra => browser}/cdp_wait_element.md | 4 +- functions/{infra => browser}/cdp_wait_load.go | 2 +- functions/{infra => browser}/cdp_wait_load.md | 4 +- functions/{infra => browser}/chrome_launch.go | 2 +- functions/{infra => browser}/chrome_launch.md | 2 +- .../{infra => browser}/chrome_launch_test.go | 2 +- registry/hash.go | 20 +- registry/indexer.go | 53 ++- .../007_externalize_apps_analysis.sql | 54 +++ registry/models.go | 22 + registry/parser.go | 63 +++ registry/store.go | 163 ++++++- registry/validate.go | 38 ++ 111 files changed, 2766 insertions(+), 5043 deletions(-) create mode 100644 analysis/.gitkeep create mode 100644 apps/.gitkeep delete mode 100644 apps/docker_tui/.gitignore delete mode 100644 apps/docker_tui/Makefile delete mode 100644 apps/docker_tui/app.md delete mode 100644 apps/docker_tui/app/model.go delete mode 100755 apps/docker_tui/build.sh delete mode 100644 apps/docker_tui/config/config.go delete mode 100644 apps/docker_tui/go.mod delete mode 100644 apps/docker_tui/go.sum delete mode 100644 apps/docker_tui/go.work delete mode 100644 apps/docker_tui/go.work.sum delete mode 100644 apps/docker_tui/main.go delete mode 100644 apps/docker_tui/views/compose.go delete mode 100644 apps/docker_tui/views/containers.go delete mode 100644 apps/docker_tui/views/docker.go delete mode 100644 apps/docker_tui/views/images.go delete mode 100644 apps/docker_tui/views/keys.go delete mode 100644 apps/docker_tui/views/networks.go delete mode 100644 apps/docker_tui/views/types.go delete mode 100644 apps/docker_tui/views/volumes.go delete mode 100644 apps/metabase_registry/app.md delete mode 100644 apps/metabase_registry/create_apps_dashboard.py delete mode 100644 apps/metabase_registry/create_registry_dashboard.py delete mode 100644 apps/metabase_registry/create_script_navegador_dashboard.py delete mode 100644 apps/metabase_registry/main.py delete mode 100644 apps/metabase_registry/requirements.txt delete mode 100644 apps/pipeline_launcher/app.md delete mode 100644 apps/pipeline_launcher/app/model.go delete mode 100644 apps/pipeline_launcher/config/config.go delete mode 100644 apps/pipeline_launcher/go.mod delete mode 100644 apps/pipeline_launcher/go.sum delete mode 100644 apps/pipeline_launcher/main.go delete mode 100644 apps/pipeline_launcher/views/history.go delete mode 100644 apps/pipeline_launcher/views/keys.go delete mode 100644 apps/pipeline_launcher/views/pipelines.go delete mode 100644 apps/pipeline_launcher/views/runner.go delete mode 100644 apps/script_navegador/.gitignore delete mode 100644 apps/script_navegador/app.md delete mode 100644 apps/script_navegador/examples/busqueda_google.yaml delete mode 100644 apps/script_navegador/examples/continue_on_error.yaml delete mode 100644 apps/script_navegador/examples/scrape_titulo.yaml delete mode 100644 apps/script_navegador/examples/youtube.yaml delete mode 100644 apps/script_navegador/go.mod delete mode 100644 apps/script_navegador/go.sum delete mode 100644 apps/script_navegador/id.go delete mode 100644 apps/script_navegador/main.go delete mode 100644 apps/script_navegador/ops.go delete mode 100644 apps/script_navegador/runner.go delete mode 100644 apps/script_navegador/script.go delete mode 100644 apps/script_navegador/wsl.go create mode 100644 bash/functions/infra/init_uv_venv.md create mode 100644 bash/functions/infra/init_uv_venv.sh create mode 100644 bash/functions/infra/uv_add_packages.md create mode 100644 bash/functions/infra/uv_add_packages.sh create mode 100644 bash/functions/infra/write_claude_jupyter_rules.md create mode 100644 bash/functions/infra/write_claude_jupyter_rules.sh create mode 100644 bash/functions/infra/write_jupyter_launcher.md create mode 100644 bash/functions/infra/write_jupyter_launcher.sh create mode 100644 bash/functions/infra/write_jupyter_registry_kernel.md create mode 100644 bash/functions/infra/write_jupyter_registry_kernel.sh create mode 100644 bash/functions/infra/write_mcp_jupyter_config.md create mode 100644 bash/functions/infra/write_mcp_jupyter_config.sh create mode 100644 bash/functions/pipelines/init_jupyter_analysis.md create mode 100644 bash/functions/pipelines/init_jupyter_analysis.sh create mode 100644 bash/functions/shell/exit_with_status.md create mode 100644 bash/functions/shell/exit_with_status.sh create mode 100644 bash/functions/shell/find_free_port.md create mode 100644 bash/functions/shell/find_free_port.sh create mode 100644 bash/functions/shell/report_execution_json.md create mode 100644 bash/functions/shell/report_execution_json.sh create mode 100644 bash/functions/shell/run_steps.md create mode 100644 bash/functions/shell/run_steps.sh create mode 100644 cmd/fn/analysis.go create mode 100644 cmd/fn/app.go create mode 100644 docs/execution_standard.md create mode 100644 docs/templates/analysis.md rename functions/{infra => browser}/cdp_click.go (99%) rename functions/{infra => browser}/cdp_click.md (93%) rename functions/{infra => browser}/cdp_close.go (98%) rename functions/{infra => browser}/cdp_close.md (98%) rename functions/{infra => browser}/cdp_conn.go (99%) rename functions/{infra => browser}/cdp_connect.go (99%) rename functions/{infra => browser}/cdp_connect.md (98%) rename functions/{infra => browser}/cdp_evaluate.go (98%) rename functions/{infra => browser}/cdp_evaluate.md (95%) rename functions/{infra => browser}/cdp_get_html.go (96%) rename functions/{infra => browser}/cdp_get_html.md (93%) rename functions/{infra => browser}/cdp_navigate.go (97%) rename functions/{infra => browser}/cdp_navigate.md (94%) rename functions/{infra => browser}/cdp_screenshot.go (99%) rename functions/{infra => browser}/cdp_screenshot.md (93%) rename functions/{infra => browser}/cdp_type_text.go (98%) rename functions/{infra => browser}/cdp_type_text.md (95%) rename functions/{infra => browser}/cdp_wait_element.go (98%) rename functions/{infra => browser}/cdp_wait_element.md (93%) rename functions/{infra => browser}/cdp_wait_load.go (98%) rename functions/{infra => browser}/cdp_wait_load.md (96%) rename functions/{infra => browser}/chrome_launch.go (99%) rename functions/{infra => browser}/chrome_launch.md (98%) rename functions/{infra => browser}/chrome_launch_test.go (99%) create mode 100644 registry/migrations/007_externalize_apps_analysis.sql diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 263d0561..34105fa0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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` - 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` - Enums: `algebraic`(product|sum) @@ -80,6 +80,7 @@ fn-registry/ registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones fn_operations/ # Paquete Go: operations database (libreria) 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 docs/ # Specs de diseño 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 ### 1. CONSTRUIR diff --git a/.gitignore b/.gitignore index 604f9558..b611127e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ registry.db-wal **/*.pyo python/.venv/ +# Externalized apps and analysis (each is its own Gitea repo) +apps/*/ +analysis/*/ + # Node / pnpm **/node_modules/ diff --git a/analysis/.gitkeep b/analysis/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/.gitkeep b/apps/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/docker_tui/.gitignore b/apps/docker_tui/.gitignore deleted file mode 100644 index c8427d40..00000000 --- a/apps/docker_tui/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -build/ -*.exe -*.dll -*.so -*.dylib diff --git a/apps/docker_tui/Makefile b/apps/docker_tui/Makefile deleted file mode 100644 index caed4a3e..00000000 --- a/apps/docker_tui/Makefile +++ /dev/null @@ -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}' diff --git a/apps/docker_tui/app.md b/apps/docker_tui/app.md deleted file mode 100644 index 11f24f44..00000000 --- a/apps/docker_tui/app.md +++ /dev/null @@ -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). diff --git a/apps/docker_tui/app/model.go b/apps/docker_tui/app/model.go deleted file mode 100644 index eeb6c71e..00000000 --- a/apps/docker_tui/app/model.go +++ /dev/null @@ -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 -} diff --git a/apps/docker_tui/build.sh b/apps/docker_tui/build.sh deleted file mode 100755 index e877df85..00000000 --- a/apps/docker_tui/build.sh +++ /dev/null @@ -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" diff --git a/apps/docker_tui/config/config.go b/apps/docker_tui/config/config.go deleted file mode 100644 index a68e66a6..00000000 --- a/apps/docker_tui/config/config.go +++ /dev/null @@ -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, - } -} diff --git a/apps/docker_tui/go.mod b/apps/docker_tui/go.mod deleted file mode 100644 index 42dc1f38..00000000 --- a/apps/docker_tui/go.mod +++ /dev/null @@ -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 diff --git a/apps/docker_tui/go.sum b/apps/docker_tui/go.sum deleted file mode 100644 index e0ff28d7..00000000 --- a/apps/docker_tui/go.sum +++ /dev/null @@ -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= diff --git a/apps/docker_tui/go.work b/apps/docker_tui/go.work deleted file mode 100644 index c00c9dbe..00000000 --- a/apps/docker_tui/go.work +++ /dev/null @@ -1,6 +0,0 @@ -go 1.22.2 - -use ( - . - /home/lucas/.local_agentes/backend -) diff --git a/apps/docker_tui/go.work.sum b/apps/docker_tui/go.work.sum deleted file mode 100644 index e0b4994d..00000000 --- a/apps/docker_tui/go.work.sum +++ /dev/null @@ -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= diff --git a/apps/docker_tui/main.go b/apps/docker_tui/main.go deleted file mode 100644 index 72d81fca..00000000 --- a/apps/docker_tui/main.go +++ /dev/null @@ -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) - } -} diff --git a/apps/docker_tui/views/compose.go b/apps/docker_tui/views/compose.go deleted file mode 100644 index ca35838e..00000000 --- a/apps/docker_tui/views/compose.go +++ /dev/null @@ -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 -} diff --git a/apps/docker_tui/views/containers.go b/apps/docker_tui/views/containers.go deleted file mode 100644 index 40aa2d48..00000000 --- a/apps/docker_tui/views/containers.go +++ /dev/null @@ -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 -} diff --git a/apps/docker_tui/views/docker.go b/apps/docker_tui/views/docker.go deleted file mode 100644 index 1d8650c2..00000000 --- a/apps/docker_tui/views/docker.go +++ /dev/null @@ -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) -} diff --git a/apps/docker_tui/views/images.go b/apps/docker_tui/views/images.go deleted file mode 100644 index 478a756b..00000000 --- a/apps/docker_tui/views/images.go +++ /dev/null @@ -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 "" -} diff --git a/apps/docker_tui/views/keys.go b/apps/docker_tui/views/keys.go deleted file mode 100644 index 2ed80d08..00000000 --- a/apps/docker_tui/views/keys.go +++ /dev/null @@ -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 -} diff --git a/apps/docker_tui/views/networks.go b/apps/docker_tui/views/networks.go deleted file mode 100644 index 076603b7..00000000 --- a/apps/docker_tui/views/networks.go +++ /dev/null @@ -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 "" -} diff --git a/apps/docker_tui/views/types.go b/apps/docker_tui/views/types.go deleted file mode 100644 index c319c93e..00000000 --- a/apps/docker_tui/views/types.go +++ /dev/null @@ -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"` -} diff --git a/apps/docker_tui/views/volumes.go b/apps/docker_tui/views/volumes.go deleted file mode 100644 index 67999d8b..00000000 --- a/apps/docker_tui/views/volumes.go +++ /dev/null @@ -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 "" -} diff --git a/apps/metabase_registry/app.md b/apps/metabase_registry/app.md deleted file mode 100644 index 723fa24b..00000000 --- a/apps/metabase_registry/app.md +++ /dev/null @@ -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: \ | 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). diff --git a/apps/metabase_registry/create_apps_dashboard.py b/apps/metabase_registry/create_apps_dashboard.py deleted file mode 100644 index f9d0055d..00000000 --- a/apps/metabase_registry/create_apps_dashboard.py +++ /dev/null @@ -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() diff --git a/apps/metabase_registry/create_registry_dashboard.py b/apps/metabase_registry/create_registry_dashboard.py deleted file mode 100644 index fc2f6154..00000000 --- a/apps/metabase_registry/create_registry_dashboard.py +++ /dev/null @@ -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() diff --git a/apps/metabase_registry/create_script_navegador_dashboard.py b/apps/metabase_registry/create_script_navegador_dashboard.py deleted file mode 100644 index 4e3c6423..00000000 --- a/apps/metabase_registry/create_script_navegador_dashboard.py +++ /dev/null @@ -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() diff --git a/apps/metabase_registry/main.py b/apps/metabase_registry/main.py deleted file mode 100644 index 4caa76eb..00000000 --- a/apps/metabase_registry/main.py +++ /dev/null @@ -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() diff --git a/apps/metabase_registry/requirements.txt b/apps/metabase_registry/requirements.txt deleted file mode 100644 index 79865d30..00000000 --- a/apps/metabase_registry/requirements.txt +++ /dev/null @@ -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 diff --git a/apps/pipeline_launcher/app.md b/apps/pipeline_launcher/app.md deleted file mode 100644 index e688d471..00000000 --- a/apps/pipeline_launcher/app.md +++ /dev/null @@ -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. diff --git a/apps/pipeline_launcher/app/model.go b/apps/pipeline_launcher/app/model.go deleted file mode 100644 index 96cbb51d..00000000 --- a/apps/pipeline_launcher/app/model.go +++ /dev/null @@ -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 -} diff --git a/apps/pipeline_launcher/config/config.go b/apps/pipeline_launcher/config/config.go deleted file mode 100644 index b2d55138..00000000 --- a/apps/pipeline_launcher/config/config.go +++ /dev/null @@ -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, - } -} diff --git a/apps/pipeline_launcher/go.mod b/apps/pipeline_launcher/go.mod deleted file mode 100644 index 50d59d93..00000000 --- a/apps/pipeline_launcher/go.mod +++ /dev/null @@ -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 -) diff --git a/apps/pipeline_launcher/go.sum b/apps/pipeline_launcher/go.sum deleted file mode 100644 index 61ff79b5..00000000 --- a/apps/pipeline_launcher/go.sum +++ /dev/null @@ -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= diff --git a/apps/pipeline_launcher/main.go b/apps/pipeline_launcher/main.go deleted file mode 100644 index 18cc1890..00000000 --- a/apps/pipeline_launcher/main.go +++ /dev/null @@ -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) - } -} diff --git a/apps/pipeline_launcher/views/history.go b/apps/pipeline_launcher/views/history.go deleted file mode 100644 index 6e1d00e8..00000000 --- a/apps/pipeline_launcher/views/history.go +++ /dev/null @@ -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() -} diff --git a/apps/pipeline_launcher/views/keys.go b/apps/pipeline_launcher/views/keys.go deleted file mode 100644 index 2ed80d08..00000000 --- a/apps/pipeline_launcher/views/keys.go +++ /dev/null @@ -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 -} diff --git a/apps/pipeline_launcher/views/pipelines.go b/apps/pipeline_launcher/views/pipelines.go deleted file mode 100644 index 190e2c6a..00000000 --- a/apps/pipeline_launcher/views/pipelines.go +++ /dev/null @@ -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 -} diff --git a/apps/pipeline_launcher/views/runner.go b/apps/pipeline_launcher/views/runner.go deleted file mode 100644 index 9f41f592..00000000 --- a/apps/pipeline_launcher/views/runner.go +++ /dev/null @@ -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, - } -} diff --git a/apps/script_navegador/.gitignore b/apps/script_navegador/.gitignore deleted file mode 100644 index e58cbdf0..00000000 --- a/apps/script_navegador/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -operations.db -operations.db-wal -operations.db-shm -build/ -*.exe -script_navegador diff --git a/apps/script_navegador/app.md b/apps/script_navegador/app.md deleted file mode 100644 index b3bfb835..00000000 --- a/apps/script_navegador/app.md +++ /dev/null @@ -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` diff --git a/apps/script_navegador/examples/busqueda_google.yaml b/apps/script_navegador/examples/busqueda_google.yaml deleted file mode 100644 index ff140362..00000000 --- a/apps/script_navegador/examples/busqueda_google.yaml +++ /dev/null @@ -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 diff --git a/apps/script_navegador/examples/continue_on_error.yaml b/apps/script_navegador/examples/continue_on_error.yaml deleted file mode 100644 index 106a697f..00000000 --- a/apps/script_navegador/examples/continue_on_error.yaml +++ /dev/null @@ -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" diff --git a/apps/script_navegador/examples/scrape_titulo.yaml b/apps/script_navegador/examples/scrape_titulo.yaml deleted file mode 100644 index 9bd66cc5..00000000 --- a/apps/script_navegador/examples/scrape_titulo.yaml +++ /dev/null @@ -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" diff --git a/apps/script_navegador/examples/youtube.yaml b/apps/script_navegador/examples/youtube.yaml deleted file mode 100644 index 2a6906a2..00000000 --- a/apps/script_navegador/examples/youtube.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: "navegar_youtube" -steps: - - action: navigate - url: "https://www.youtube.com" - - action: screenshot - path: "/tmp/youtube.png" diff --git a/apps/script_navegador/go.mod b/apps/script_navegador/go.mod deleted file mode 100644 index f6206a6e..00000000 --- a/apps/script_navegador/go.mod +++ /dev/null @@ -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 diff --git a/apps/script_navegador/go.sum b/apps/script_navegador/go.sum deleted file mode 100644 index 710b3523..00000000 --- a/apps/script_navegador/go.sum +++ /dev/null @@ -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= diff --git a/apps/script_navegador/id.go b/apps/script_navegador/id.go deleted file mode 100644 index a07ce412..00000000 --- a/apps/script_navegador/id.go +++ /dev/null @@ -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]) -} diff --git a/apps/script_navegador/main.go b/apps/script_navegador/main.go deleted file mode 100644 index 94a5b282..00000000 --- a/apps/script_navegador/main.go +++ /dev/null @@ -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") - } -} diff --git a/apps/script_navegador/ops.go b/apps/script_navegador/ops.go deleted file mode 100644 index e2f2f32a..00000000 --- a/apps/script_navegador/ops.go +++ /dev/null @@ -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) -} diff --git a/apps/script_navegador/runner.go b/apps/script_navegador/runner.go deleted file mode 100644 index b6da6e69..00000000 --- a/apps/script_navegador/runner.go +++ /dev/null @@ -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) - } -} diff --git a/apps/script_navegador/script.go b/apps/script_navegador/script.go deleted file mode 100644 index d03ce55f..00000000 --- a/apps/script_navegador/script.go +++ /dev/null @@ -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 -} diff --git a/apps/script_navegador/wsl.go b/apps/script_navegador/wsl.go deleted file mode 100644 index 6ad6a58f..00000000 --- a/apps/script_navegador/wsl.go +++ /dev/null @@ -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) -} diff --git a/bash/functions/infra/init_uv_venv.md b/bash/functions/infra/init_uv_venv.md new file mode 100644 index 00000000..e32bcc93 --- /dev/null +++ b/bash/functions/infra/init_uv_venv.md @@ -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. diff --git a/bash/functions/infra/init_uv_venv.sh b/bash/functions/infra/init_uv_venv.sh new file mode 100644 index 00000000..4d003ceb --- /dev/null +++ b/bash/functions/infra/init_uv_venv.sh @@ -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" +} diff --git a/bash/functions/infra/uv_add_packages.md b/bash/functions/infra/uv_add_packages.md new file mode 100644 index 00000000..41d7b2bd --- /dev/null +++ b/bash/functions/infra/uv_add_packages.md @@ -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. diff --git a/bash/functions/infra/uv_add_packages.sh b/bash/functions/infra/uv_add_packages.sh new file mode 100644 index 00000000..a693cb7c --- /dev/null +++ b/bash/functions/infra/uv_add_packages.sh @@ -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 +} diff --git a/bash/functions/infra/write_claude_jupyter_rules.md b/bash/functions/infra/write_claude_jupyter_rules.md new file mode 100644 index 00000000..f888b075 --- /dev/null +++ b/bash/functions/infra/write_claude_jupyter_rules.md @@ -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`. diff --git a/bash/functions/infra/write_claude_jupyter_rules.sh b/bash/functions/infra/write_claude_jupyter_rules.sh new file mode 100644 index 00000000..bd50e813 --- /dev/null +++ b/bash/functions/infra/write_claude_jupyter_rules.sh @@ -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" +} diff --git a/bash/functions/infra/write_jupyter_launcher.md b/bash/functions/infra/write_jupyter_launcher.md new file mode 100644 index 00000000..13d30bda --- /dev/null +++ b/bash/functions/infra/write_jupyter_launcher.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 diff --git a/bash/functions/infra/write_jupyter_launcher.sh b/bash/functions/infra/write_jupyter_launcher.sh new file mode 100644 index 00000000..7884d317 --- /dev/null +++ b/bash/functions/infra/write_jupyter_launcher.sh @@ -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" +} diff --git a/bash/functions/infra/write_jupyter_registry_kernel.md b/bash/functions/infra/write_jupyter_registry_kernel.md new file mode 100644 index 00000000..6793f593 --- /dev/null +++ b/bash/functions/infra/write_jupyter_registry_kernel.md @@ -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`. diff --git a/bash/functions/infra/write_jupyter_registry_kernel.sh b/bash/functions/infra/write_jupyter_registry_kernel.sh new file mode 100644 index 00000000..79d46b83 --- /dev/null +++ b/bash/functions/infra/write_jupyter_registry_kernel.sh @@ -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" +} diff --git a/bash/functions/infra/write_mcp_jupyter_config.md b/bash/functions/infra/write_mcp_jupyter_config.md new file mode 100644 index 00000000..48290da4 --- /dev/null +++ b/bash/functions/infra/write_mcp_jupyter_config.md @@ -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. diff --git a/bash/functions/infra/write_mcp_jupyter_config.sh b/bash/functions/infra/write_mcp_jupyter_config.sh new file mode 100644 index 00000000..470e327f --- /dev/null +++ b/bash/functions/infra/write_mcp_jupyter_config.sh @@ -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" +} diff --git a/bash/functions/pipelines/init_jupyter_analysis.md b/bash/functions/pipelines/init_jupyter_analysis.md new file mode 100644 index 00000000..37487e2d --- /dev/null +++ b/bash/functions/pipelines/init_jupyter_analysis.md @@ -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. diff --git a/bash/functions/pipelines/init_jupyter_analysis.sh b/bash/functions/pipelines/init_jupyter_analysis.sh new file mode 100644 index 00000000..a977103b --- /dev/null +++ b/bash/functions/pipelines/init_jupyter_analysis.sh @@ -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 [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 [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 "" diff --git a/bash/functions/shell/exit_with_status.md b/bash/functions/shell/exit_with_status.md new file mode 100644 index 00000000..97738a6c --- /dev/null +++ b/bash/functions/shell/exit_with_status.md @@ -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 $?`. diff --git a/bash/functions/shell/exit_with_status.sh b/bash/functions/shell/exit_with_status.sh new file mode 100644 index 00000000..c889647f --- /dev/null +++ b/bash/functions/shell/exit_with_status.sh @@ -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 +# +# 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" +} diff --git a/bash/functions/shell/find_free_port.md b/bash/functions/shell/find_free_port.md new file mode 100644 index 00000000..a9dd0d9c --- /dev/null +++ b/bash/functions/shell/find_free_port.md @@ -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. diff --git a/bash/functions/shell/find_free_port.sh b/bash/functions/shell/find_free_port.sh new file mode 100644 index 00000000..b1183b2f --- /dev/null +++ b/bash/functions/shell/find_free_port.sh @@ -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 +} diff --git a/bash/functions/shell/report_execution_json.md b/bash/functions/shell/report_execution_json.md new file mode 100644 index 00000000..e6aee5d9 --- /dev/null +++ b/bash/functions/shell/report_execution_json.md @@ -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 `. diff --git a/bash/functions/shell/report_execution_json.sh b/bash/functions/shell/report_execution_json.sh new file mode 100644 index 00000000..b1e9eb39 --- /dev/null +++ b/bash/functions/shell/report_execution_json.sh @@ -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): +# nameactionstatuselapsed_msoutputerror +# 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 " >&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 diff --git a/bash/functions/shell/run_steps.md b/bash/functions/shell/run_steps.md new file mode 100644 index 00000000..e24f6ad2 --- /dev/null +++ b/bash/functions/shell/run_steps.md @@ -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`. diff --git a/bash/functions/shell/run_steps.sh b/bash/functions/shell/run_steps.sh new file mode 100644 index 00000000..e7005896 --- /dev/null +++ b/bash/functions/shell/run_steps.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# run_steps — ejecuta pasos de un YAML generico (action=command) + +# run_steps [--strict] +# +# Lee un YAML con la estructura: +# name: +# steps: +# - name: +# action: command +# 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: nameactionstatuselapsed_msoutputerror + 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> "$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" +} diff --git a/cmd/fn/analysis.go b/cmd/fn/analysis.go new file mode 100644 index 00000000..9aa423ab --- /dev/null +++ b/cmd/fn/analysis.go @@ -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 ") + os.Exit(1) + } + analysisClone(args[1]) + case "pull": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: fn analysis pull ") + 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 Clone analysis repo to analysis// + fn analysis pull 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.") +} diff --git a/cmd/fn/app.go b/cmd/fn/app.go new file mode 100644 index 00000000..06e50506 --- /dev/null +++ b/cmd/fn/app.go @@ -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 ") + os.Exit(1) + } + appClone(args[1]) + case "pull": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: fn app pull ") + 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 Clone app repo to apps// + fn app pull 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 +} diff --git a/cmd/fn/main.go b/cmd/fn/main.go index ab70088f..7a914f6f 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -35,6 +35,10 @@ func main() { cmdProposal(os.Args[2:]) case "run": cmdRun(os.Args[2:]) + case "app": + cmdApp(os.Args[2:]) + case "analysis": + cmdAnalysis(os.Args[2:]) case "help", "-h", "--help": printUsage() default: @@ -55,7 +59,9 @@ Usage: fn add [-k kind] Abre $EDITOR con template fn run [args...] Ejecuta funcion/pipeline (go/py/bash) fn ops Gestiona operations.db (fn ops help) - fn proposal Gestiona proposals`) + fn proposal Gestiona proposals + fn app Gestiona apps externas (Gitea) + fn analysis Gestiona analyses externas (Gitea)`) } 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 { fmt.Fprintf(os.Stderr, " INVALID: %s\n", e) } @@ -172,7 +178,13 @@ func cmdSearch(args []string) { 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.") 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) } } + 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() } @@ -249,6 +271,12 @@ func cmdList(args []string) { 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) if len(fns) > 0 { 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) } } - 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.") } w.Flush() @@ -310,6 +347,12 @@ func cmdShow(args []string) { return } + an, errAn := db.GetAnalysis(id) + if errAn == nil { + printAnalysisEntry(an) + return + } + fmt.Fprintf(os.Stderr, "not found: %s\n", id) os.Exit(1) } @@ -412,6 +455,40 @@ func printApp(a *registry.App) { 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 != "" { + 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 != "" { fmt.Printf("Framework: %s\n", a.Framework) } @@ -459,8 +536,10 @@ func cmdAdd(args []string) { templatePath = filepath.Join(r, "docs", "templates", "component.md") case "app": templatePath = filepath.Join(r, "docs", "templates", "app.md") + case "analysis": + templatePath = filepath.Join(r, "docs", "templates", "analysis.md") 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) } diff --git a/docs/execution_standard.md b/docs/execution_standard.md new file mode 100644 index 00000000..48895eeb --- /dev/null +++ b/docs/execution_standard.md @@ -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 | diff --git a/docs/templates/analysis.md b/docs/templates/analysis.md new file mode 100644 index 00000000..d9b7ee52 --- /dev/null +++ b/docs/templates/analysis.md @@ -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. diff --git a/functions/infra/cdp_click.go b/functions/browser/cdp_click.go similarity index 99% rename from functions/infra/cdp_click.go rename to functions/browser/cdp_click.go index 109dfb4c..b174d54b 100644 --- a/functions/infra/cdp_click.go +++ b/functions/browser/cdp_click.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_click.md b/functions/browser/cdp_click.md similarity index 93% rename from functions/infra/cdp_click.md rename to functions/browser/cdp_click.md index 30b13ebb..212795ec 100644 --- a/functions/infra/cdp_click.md +++ b/functions/browser/cdp_click.md @@ -2,13 +2,13 @@ name: cdp_click kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." 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: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_close.go b/functions/browser/cdp_close.go similarity index 98% rename from functions/infra/cdp_close.go rename to functions/browser/cdp_close.go index b571d01b..9fb7fe66 100644 --- a/functions/infra/cdp_close.go +++ b/functions/browser/cdp_close.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_close.md b/functions/browser/cdp_close.md similarity index 98% rename from functions/infra/cdp_close.md rename to functions/browser/cdp_close.md index 67b99683..131f6543 100644 --- a/functions/infra/cdp_close.md +++ b/functions/browser/cdp_close.md @@ -2,7 +2,7 @@ name: cdp_close kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure signature: "func CdpClose(c *CDPConn, pid int) error" diff --git a/functions/infra/cdp_conn.go b/functions/browser/cdp_conn.go similarity index 99% rename from functions/infra/cdp_conn.go rename to functions/browser/cdp_conn.go index cab7aa2e..9d0bafc2 100644 --- a/functions/infra/cdp_conn.go +++ b/functions/browser/cdp_conn.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "bufio" diff --git a/functions/infra/cdp_connect.go b/functions/browser/cdp_connect.go similarity index 99% rename from functions/infra/cdp_connect.go rename to functions/browser/cdp_connect.go index f7e4f989..e4eef455 100644 --- a/functions/infra/cdp_connect.go +++ b/functions/browser/cdp_connect.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "encoding/json" diff --git a/functions/infra/cdp_connect.md b/functions/browser/cdp_connect.md similarity index 98% rename from functions/infra/cdp_connect.md rename to functions/browser/cdp_connect.md index b9fbdc88..a8776681 100644 --- a/functions/infra/cdp_connect.md +++ b/functions/browser/cdp_connect.md @@ -2,7 +2,7 @@ name: cdp_connect kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure signature: "func CdpConnect(port int) (*CDPConn, error)" diff --git a/functions/infra/cdp_evaluate.go b/functions/browser/cdp_evaluate.go similarity index 98% rename from functions/infra/cdp_evaluate.go rename to functions/browser/cdp_evaluate.go index 76d5215c..459b08a5 100644 --- a/functions/infra/cdp_evaluate.go +++ b/functions/browser/cdp_evaluate.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_evaluate.md b/functions/browser/cdp_evaluate.md similarity index 95% rename from functions/infra/cdp_evaluate.md rename to functions/browser/cdp_evaluate.md index 0a48e15a..095ea70f 100644 --- a/functions/infra/cdp_evaluate.md +++ b/functions/browser/cdp_evaluate.md @@ -2,13 +2,13 @@ name: cdp_evaluate kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." tags: [chrome, cdp, browser, automation, javascript, devtools] -uses_functions: [cdp_connect_go_infra] +uses_functions: [cdp_connect_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_get_html.go b/functions/browser/cdp_get_html.go similarity index 96% rename from functions/infra/cdp_get_html.go rename to functions/browser/cdp_get_html.go index a676f30a..664b8313 100644 --- a/functions/infra/cdp_get_html.go +++ b/functions/browser/cdp_get_html.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_get_html.md b/functions/browser/cdp_get_html.md similarity index 93% rename from functions/infra/cdp_get_html.md rename to functions/browser/cdp_get_html.md index 3877ba07..ad272409 100644 --- a/functions/infra/cdp_get_html.md +++ b/functions/browser/cdp_get_html.md @@ -2,13 +2,13 @@ name: cdp_get_html kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." 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: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_navigate.go b/functions/browser/cdp_navigate.go similarity index 97% rename from functions/infra/cdp_navigate.go rename to functions/browser/cdp_navigate.go index 9fa7277d..d33f856b 100644 --- a/functions/infra/cdp_navigate.go +++ b/functions/browser/cdp_navigate.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_navigate.md b/functions/browser/cdp_navigate.md similarity index 94% rename from functions/infra/cdp_navigate.md rename to functions/browser/cdp_navigate.md index e275fdb8..bf1a3041 100644 --- a/functions/infra/cdp_navigate.md +++ b/functions/browser/cdp_navigate.md @@ -2,13 +2,13 @@ name: cdp_navigate kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." tags: [chrome, cdp, browser, automation, navigation, devtools] -uses_functions: [cdp_connect_go_infra] +uses_functions: [cdp_connect_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_screenshot.go b/functions/browser/cdp_screenshot.go similarity index 99% rename from functions/infra/cdp_screenshot.go rename to functions/browser/cdp_screenshot.go index 1c161211..3cca49fe 100644 --- a/functions/infra/cdp_screenshot.go +++ b/functions/browser/cdp_screenshot.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "encoding/base64" diff --git a/functions/infra/cdp_screenshot.md b/functions/browser/cdp_screenshot.md similarity index 93% rename from functions/infra/cdp_screenshot.md rename to functions/browser/cdp_screenshot.md index 909f7d4f..f08d505a 100644 --- a/functions/infra/cdp_screenshot.md +++ b/functions/browser/cdp_screenshot.md @@ -2,13 +2,13 @@ name: cdp_screenshot kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." 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: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_type_text.go b/functions/browser/cdp_type_text.go similarity index 98% rename from functions/infra/cdp_type_text.go rename to functions/browser/cdp_type_text.go index a371cae2..c17e21c7 100644 --- a/functions/infra/cdp_type_text.go +++ b/functions/browser/cdp_type_text.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_type_text.md b/functions/browser/cdp_type_text.md similarity index 95% rename from functions/infra/cdp_type_text.md rename to functions/browser/cdp_type_text.md index 4a0df8df..ee86f404 100644 --- a/functions/infra/cdp_type_text.md +++ b/functions/browser/cdp_type_text.md @@ -2,13 +2,13 @@ name: cdp_type_text kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." tags: [chrome, cdp, browser, automation, keyboard, input, devtools] -uses_functions: [cdp_connect_go_infra] +uses_functions: [cdp_connect_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_wait_element.go b/functions/browser/cdp_wait_element.go similarity index 98% rename from functions/infra/cdp_wait_element.go rename to functions/browser/cdp_wait_element.go index 0d3538ca..4326adcc 100644 --- a/functions/infra/cdp_wait_element.go +++ b/functions/browser/cdp_wait_element.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_wait_element.md b/functions/browser/cdp_wait_element.md similarity index 93% rename from functions/infra/cdp_wait_element.md rename to functions/browser/cdp_wait_element.md index eea36571..1b61c116 100644 --- a/functions/infra/cdp_wait_element.md +++ b/functions/browser/cdp_wait_element.md @@ -2,13 +2,13 @@ name: cdp_wait_element kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure 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." 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: [] returns: [] returns_optional: false diff --git a/functions/infra/cdp_wait_load.go b/functions/browser/cdp_wait_load.go similarity index 98% rename from functions/infra/cdp_wait_load.go rename to functions/browser/cdp_wait_load.go index 94753de4..4d0c0d17 100644 --- a/functions/infra/cdp_wait_load.go +++ b/functions/browser/cdp_wait_load.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/cdp_wait_load.md b/functions/browser/cdp_wait_load.md similarity index 96% rename from functions/infra/cdp_wait_load.md rename to functions/browser/cdp_wait_load.md index afac7ef9..0ae3e1ba 100644 --- a/functions/infra/cdp_wait_load.md +++ b/functions/browser/cdp_wait_load.md @@ -2,13 +2,13 @@ name: cdp_wait_load kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error" description: "Espera a que la pagina actual termine de cargar completamente. Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta que sea \"complete\", o hasta que se agote el timeout. Retorna error inmediato si CdpEvaluate falla (la conexion puede estar rota)." tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load] -uses_functions: [cdp_evaluate_go_infra] +uses_functions: [cdp_evaluate_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/infra/chrome_launch.go b/functions/browser/chrome_launch.go similarity index 99% rename from functions/infra/chrome_launch.go rename to functions/browser/chrome_launch.go index 43a5e05b..87030039 100644 --- a/functions/infra/chrome_launch.go +++ b/functions/browser/chrome_launch.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "fmt" diff --git a/functions/infra/chrome_launch.md b/functions/browser/chrome_launch.md similarity index 98% rename from functions/infra/chrome_launch.md rename to functions/browser/chrome_launch.md index fb0240f9..044b5ddd 100644 --- a/functions/infra/chrome_launch.md +++ b/functions/browser/chrome_launch.md @@ -2,7 +2,7 @@ name: chrome_launch kind: function lang: go -domain: infra +domain: browser version: "1.0.0" purity: impure signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)" diff --git a/functions/infra/chrome_launch_test.go b/functions/browser/chrome_launch_test.go similarity index 99% rename from functions/infra/chrome_launch_test.go rename to functions/browser/chrome_launch_test.go index 0f2ff51b..31238ea8 100644 --- a/functions/infra/chrome_launch_test.go +++ b/functions/browser/chrome_launch_test.go @@ -1,4 +1,4 @@ -package infra +package browser import ( "os" diff --git a/registry/hash.go b/registry/hash.go index 25c8c885..ab23fa87 100644 --- a/registry/hash.go +++ b/registry/hash.go @@ -60,13 +60,25 @@ func ComputeAppHash(a *App) string { fmt.Fprintf(h, "|%s", marshalStrings(a.Tags)) fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions)) fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes)) - fmt.Fprintf(h, "|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath) + fmt.Fprintf(h, "|%s|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.RepoURL) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// ComputeAnalysisHash computes a deterministic hash of all content fields of an Analysis. +func ComputeAnalysisHash(a *Analysis) string { + h := sha256.New() + fmt.Fprintf(h, "%s|%s|%s|%s|%s", + a.ID, a.Name, a.Lang, a.Domain, a.Description) + fmt.Fprintf(h, "|%s", marshalStrings(a.Tags)) + fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions)) + fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes)) + fmt.Fprintf(h, "|%s|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.RepoURL) return fmt.Sprintf("%x", h.Sum(nil)) } // LoadTimestamps reads existing id → {created_at, updated_at, content_hash} from all tables. // Called before Purge so we can preserve dates across reindexing. -func (db *DB) LoadTimestamps() (funcs, types, apps map[string]timestampRecord, err error) { +func (db *DB) LoadTimestamps() (funcs, types, apps, analysis map[string]timestampRecord, err error) { funcs, err = loadTable(db, "functions") if err != nil { return @@ -76,6 +88,10 @@ func (db *DB) LoadTimestamps() (funcs, types, apps map[string]timestampRecord, e return } apps, err = loadTable(db, "apps") + if err != nil { + return + } + analysis, err = loadTable(db, "analysis") return } diff --git a/registry/indexer.go b/registry/indexer.go index 030a6aad..fe7d3662 100644 --- a/registry/indexer.go +++ b/registry/indexer.go @@ -13,6 +13,7 @@ type IndexResult struct { Functions int Types int Apps int + Analysis int ValidationErrors []string Errors []string } @@ -26,15 +27,11 @@ type IndexResult struct { // directories (e.g. python/functions/, python/types/). func Index(db *DB, root string) (*IndexResult, error) { // Load existing timestamps before purging so we can preserve created_at - oldFuncs, oldTypes, oldApps, err := db.LoadTimestamps() + oldFuncs, oldTypes, oldApps, oldAnalysis, err := db.LoadTimestamps() if err != nil { return nil, fmt.Errorf("loading timestamps: %w", err) } - if err := db.Purge(); err != nil { - return nil, fmt.Errorf("purging database: %w", err) - } - result := &IndexResult{} // Pass 1: parse everything from all source directories @@ -42,7 +39,6 @@ func Index(db *DB, root string) (*IndexResult, error) { var types []*Type // Directories to scan for functions and types. - // Base dirs + language-specific dirs discovered automatically. funcDirs := []string{filepath.Join(root, "functions")} typeDirs := []string{filepath.Join(root, "types")} @@ -86,6 +82,7 @@ func Index(db *DB, root string) (*IndexResult, error) { // Parse apps from apps/*/app.md var apps []*App + localAppIDs := make(map[string]bool) appsDir := filepath.Join(root, "apps") if fi, err := os.Stat(appsDir); err == nil && fi.IsDir() { entries, _ := os.ReadDir(appsDir) @@ -103,9 +100,39 @@ func Index(db *DB, root string) (*IndexResult, error) { continue } apps = append(apps, a) + localAppIDs[a.ID] = true } } + // Parse analysis from analysis/*/analysis.md + var analyses []*Analysis + localAnalysisIDs := make(map[string]bool) + analysisDir := filepath.Join(root, "analysis") + if fi, err := os.Stat(analysisDir); err == nil && fi.IsDir() { + entries, _ := os.ReadDir(analysisDir) + for _, e := range entries { + if !e.IsDir() { + continue + } + analysisMD := filepath.Join(analysisDir, e.Name(), "analysis.md") + if _, err := os.Stat(analysisMD); err != nil { + continue + } + an, err := ParseAnalysisMD(analysisMD, root) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", analysisMD, err)) + continue + } + analyses = append(analyses, an) + localAnalysisIDs[an.ID] = true + } + } + + // Selective purge: preserve remote-only apps/analysis (have repo_url, not cloned locally) + if err := db.PurgeLocalOnly(localAppIDs, localAnalysisIDs); err != nil { + return nil, fmt.Errorf("purging database: %w", err) + } + // Build known ID sets knownFunctions := make(map[string]bool, len(functions)) for _, f := range functions { @@ -161,6 +188,20 @@ func Index(db *DB, root string) (*IndexResult, error) { result.Apps++ } + for _, an := range analyses { + if verr := ValidateAnalysis(an, knownFunctions, knownTypes); verr != nil { + result.ValidationErrors = append(result.ValidationErrors, verr.Error()) + continue + } + an.ContentHash = ComputeAnalysisHash(an) + applyTimestamps(&an.CreatedAt, &an.UpdatedAt, an.ContentHash, oldAnalysis[an.ID], now) + if err := db.InsertAnalysis(an); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", an.ID, err)) + continue + } + result.Analysis++ + } + return result, nil } diff --git a/registry/migrations/007_externalize_apps_analysis.sql b/registry/migrations/007_externalize_apps_analysis.sql new file mode 100644 index 00000000..c632b732 --- /dev/null +++ b/registry/migrations/007_externalize_apps_analysis.sql @@ -0,0 +1,54 @@ +-- Externalize apps and analysis to Gitea repositories. +-- Adds repo_url to apps, creates analysis table with FTS5. + +ALTER TABLE apps ADD COLUMN repo_url TEXT NOT NULL DEFAULT ''; + +-- Analysis table: independent Jupyter/data explorations tracked in the registry. +CREATE TABLE IF NOT EXISTS analysis ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lang TEXT NOT NULL, + domain TEXT NOT NULL, + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_functions TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + framework TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + documentation TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + repo_url TEXT NOT NULL DEFAULT '', + dir_path TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS analysis_fts USING fts5( + id, + name, + description, + tags, + domain, + documentation, + notes, + content='analysis', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS analysis_ai AFTER INSERT ON analysis BEGIN + INSERT INTO analysis_fts(rowid, id, name, description, tags, domain, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes); +END; + +CREATE TRIGGER IF NOT EXISTS analysis_ad AFTER DELETE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, id, name, description, tags, domain, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes); +END; + +CREATE TRIGGER IF NOT EXISTS analysis_au AFTER UPDATE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, id, name, description, tags, domain, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes); + INSERT INTO analysis_fts(rowid, id, name, description, tags, domain, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes); +END; diff --git a/registry/models.go b/registry/models.go index c8959ab6..031d3cb2 100644 --- a/registry/models.go +++ b/registry/models.go @@ -118,6 +118,28 @@ type App struct { Notes string `json:"notes"` DirPath string `json:"dir_path"` ContentHash string `json:"content_hash"` + RepoURL string `json:"repo_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Analysis represents an entry in the analysis table. +type Analysis struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Description string `json:"description"` + Tags []string `json:"tags"` + UsesFunctions []string `json:"uses_functions"` + UsesTypes []string `json:"uses_types"` + Framework string `json:"framework"` + EntryPoint string `json:"entry_point"` + Documentation string `json:"documentation"` + Notes string `json:"notes"` + RepoURL string `json:"repo_url"` + DirPath string `json:"dir_path"` + ContentHash string `json:"content_hash"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/registry/parser.go b/registry/parser.go index cea70bac..50a4f862 100644 --- a/registry/parser.go +++ b/registry/parser.go @@ -74,6 +74,22 @@ type rawApp struct { Framework string `yaml:"framework"` EntryPoint string `yaml:"entry_point"` DirPath string `yaml:"dir_path"` + RepoURL string `yaml:"repo_url"` +} + +// rawAnalysis mirrors the YAML frontmatter of an analysis .md file. +type rawAnalysis struct { + Name string `yaml:"name"` + Lang string `yaml:"lang"` + Domain string `yaml:"domain"` + Description string `yaml:"description"` + Tags []string `yaml:"tags"` + UsesFunctions []string `yaml:"uses_functions"` + UsesTypes []string `yaml:"uses_types"` + Framework string `yaml:"framework"` + EntryPoint string `yaml:"entry_point"` + DirPath string `yaml:"dir_path"` + RepoURL string `yaml:"repo_url"` } // extractFrontmatter splits a .md file into YAML frontmatter and body. @@ -266,11 +282,58 @@ func ParseAppMD(path string, root string) (*App, error) { Documentation: sections.documentation, Notes: sections.notes, DirPath: raw.DirPath, + RepoURL: raw.RepoURL, } return a, nil } +// ParseAnalysisMD parses an analysis .md file into an Analysis. +func ParseAnalysisMD(path string, root string) (*Analysis, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + fm, body, err := extractFrontmatter(data) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + var raw rawAnalysis + if err := yaml.Unmarshal(fm, &raw); err != nil { + return nil, fmt.Errorf("parsing YAML in %s: %w", path, err) + } + + if raw.Name == "" { + return nil, fmt.Errorf("%s: name is required", path) + } + if raw.Description == "" { + return nil, fmt.Errorf("%s: description is required", path) + } + + sections := extractSections(body) + + an := &Analysis{ + ID: GenerateID(raw.Name, raw.Lang, raw.Domain), + Name: raw.Name, + Lang: raw.Lang, + Domain: raw.Domain, + Description: raw.Description, + Tags: raw.Tags, + UsesFunctions: raw.UsesFunctions, + UsesTypes: raw.UsesTypes, + Framework: raw.Framework, + EntryPoint: raw.EntryPoint, + Documentation: sections.documentation, + Notes: sections.notes, + DirPath: raw.DirPath, + RepoURL: raw.RepoURL, + } + + return an, nil +} + // bodySections holds the extracted sections from a .md body. type bodySections struct { example string // content under ## Ejemplo diff --git a/registry/store.go b/registry/store.go index fd010f5a..d874c17a 100644 --- a/registry/store.go +++ b/registry/store.go @@ -288,11 +288,12 @@ func (db *DB) InsertApp(a *App) error { INSERT OR REPLACE INTO apps ( id, name, lang, domain, description, tags, uses_functions, uses_types, framework, entry_point, - documentation, notes, dir_path, content_hash, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags), marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), + a.RepoURL, ) return err } @@ -359,6 +360,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) &a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint, &a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash, + &a.RepoURL, ) if err != nil { return nil, fmt.Errorf("scanning app: %w", err) @@ -375,7 +377,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) return result, nil } -// Purge deletes all data from functions, types and apps. Used before re-indexing. +// Purge deletes all data from functions, types, apps and analysis. Used before re-indexing. func (db *DB) Purge() error { if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { return err @@ -383,10 +385,163 @@ func (db *DB) Purge() error { if _, err := db.conn.Exec("DELETE FROM types"); err != nil { return err } - _, err := db.conn.Exec("DELETE FROM apps") + if _, err := db.conn.Exec("DELETE FROM apps"); err != nil { + return err + } + _, err := db.conn.Exec("DELETE FROM analysis") return err } +// PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis. +// Remote-only records (repo_url set, not in localAppIDs/localAnalysisIDs) are preserved. +func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs map[string]bool) error { + if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { + return err + } + if _, err := db.conn.Exec("DELETE FROM types"); err != nil { + return err + } + // Delete local apps (those scanned from disk) + for id := range localAppIDs { + if _, err := db.conn.Exec("DELETE FROM apps WHERE id = ?", id); err != nil { + return err + } + } + // Delete apps without repo_url (legacy local-only apps not yet pushed) + if _, err := db.conn.Exec("DELETE FROM apps WHERE repo_url = '' OR repo_url IS NULL"); err != nil { + return err + } + // Same for analysis + for id := range localAnalysisIDs { + if _, err := db.conn.Exec("DELETE FROM analysis WHERE id = ?", id); err != nil { + return err + } + } + if _, err := db.conn.Exec("DELETE FROM analysis WHERE repo_url = '' OR repo_url IS NULL"); err != nil { + return err + } + return nil +} + +// --- Analysis CRUD --- + +// InsertAnalysis inserts or replaces an analysis entry. +func (db *DB) InsertAnalysis(a *Analysis) error { + now := time.Now().UTC() + if a.CreatedAt.IsZero() { + a.CreatedAt = now + } + if a.UpdatedAt.IsZero() { + a.UpdatedAt = now + } + + if a.ID == "" { + a.ID = GenerateID(a.Name, a.Lang, a.Domain) + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO analysis ( + id, name, lang, domain, description, tags, + uses_functions, uses_types, framework, entry_point, + documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags), + marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint, + a.Documentation, a.Notes, a.RepoURL, a.DirPath, a.ContentHash, + a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetAnalysis returns a single analysis by ID. +func (db *DB) GetAnalysis(id string) (*Analysis, error) { + rows, err := db.conn.Query("SELECT * FROM analysis WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + items, err := scanAnalysis(rows) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, fmt.Errorf("analysis %q not found", id) + } + return &items[0], nil +} + +// SearchAnalysis performs FTS search on analysis with optional filters. +func (db *DB) SearchAnalysis(query string, lang, domain string) ([]Analysis, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "a.id IN (SELECT id FROM analysis_fts WHERE analysis_fts MATCH ?)") + args = append(args, query) + } + if lang != "" { + where = append(where, "a.lang = ?") + args = append(args, lang) + } + if domain != "" { + where = append(where, "a.domain = ?") + args = append(args, domain) + } + + sql := "SELECT * FROM analysis a" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY a.name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search analysis: %w", err) + } + defer rows.Close() + + return scanAnalysis(rows) +} + +// ListAllAnalysis returns all analysis entries. +func (db *DB) ListAllAnalysis() ([]Analysis, error) { + return db.SearchAnalysis("", "", "") +} + +// ListAllApps returns all app entries. +func (db *DB) ListAllApps() ([]App, error) { + return db.SearchApps("", "", "") +} + +func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis, error) { + var result []Analysis + for rows.Next() { + var a Analysis + var tagsJSON, usesFnJSON, usesTypJSON string + var createdAt, updatedAt string + + err := rows.Scan( + &a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON, + &usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint, + &a.Documentation, &a.Notes, &a.RepoURL, &a.DirPath, &a.ContentHash, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning analysis: %w", err) + } + + a.Tags = unmarshalStrings(tagsJSON) + a.UsesFunctions = unmarshalStrings(usesFnJSON) + a.UsesTypes = unmarshalStrings(usesTypJSON) + a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + result = append(result, a) + } + return result, nil +} + func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) { var result []Function for rows.Next() { diff --git a/registry/validate.go b/registry/validate.go index 7d6c4c48..1115efa4 100644 --- a/registry/validate.go +++ b/registry/validate.go @@ -199,6 +199,44 @@ func ValidateApp(a *App, knownFunctions, knownTypes map[string]bool) *Validation return nil } +// ValidateAnalysis checks integrity rules for analysis entries. +func ValidateAnalysis(a *Analysis, knownFunctions, knownTypes map[string]bool) *ValidationError { + var errs []string + + if a.Name == "" { + errs = append(errs, "name is required") + } + if a.Lang == "" { + errs = append(errs, "lang is required") + } + if a.Domain == "" { + errs = append(errs, "domain is required") + } + if a.Description == "" { + errs = append(errs, "description is required") + } + + if a.DirPath != "" && strings.HasPrefix(a.DirPath, "/") { + errs = append(errs, "dir_path must be relative to registry root") + } + + for _, ref := range a.UsesFunctions { + if !knownFunctions[ref] { + errs = append(errs, fmt.Sprintf("uses_functions references unknown function: %s", ref)) + } + } + for _, ref := range a.UsesTypes { + if !knownTypes[ref] { + errs = append(errs, fmt.Sprintf("uses_types references unknown type: %s", ref)) + } + } + + if len(errs) > 0 { + return &ValidationError{ID: a.ID, Errors: errs} + } + return nil +} + // ValidateType checks integrity rules for types. func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError { var errs []string