201 Commits

Author SHA1 Message Date
egutierrez dcefa13d2d merge: quick/temp-workspace — documentar workspace efimero temp/ 2026-04-16 23:42:06 +02:00
egutierrez f8aa5e8072 chore: regenerar registry.db tras fn sync
Actualiza registry.db con los datos sincronizados desde el servidor
registry_api (https://registry.organic-machine.com).

Sync realizado desde home-wsl: 44 items enviados, 20 actualizados en
el servidor, 44 recibidos, 18 locations importadas localmente.

Impacto: la BD queda alineada con el estado compartido entre PCs.
No toca schema ni codigo fuente; solo es un snapshot binario.
2026-04-16 23:41:52 +02:00
egutierrez bb15b142bf docs: añadir workspace efimero temp/
Documenta la carpeta temp/ como workspace desechable para pruebas rapidas
(APIs, scripts exploratorios, prototipos) y la añade a .gitignore.

Cambios:
- .claude/CLAUDE.md: incluye temp/ en el arbol de estructura del proyecto
  con la nota de que es efimero, gitignored y no indexado.
- .claude/rules/apps_vs_functions.md: nueva seccion "temp/ — workspace
  efimero" con las reglas de uso (no es codigo del registry, estructura
  libre, se extrae al registry si algo resulta util, se puede borrar).
- .gitignore: añade temp/ para que su contenido nunca se versione.

Impacto: los agentes y el humano tienen ahora un lugar claro donde
probar cosas sin contaminar el registry ni preocuparse por limpieza.
No toca codigo existente ni la estructura de apps/ o functions/.
2026-04-16 23:41:47 +02:00
egutierrez 28364cf212 feat: registry_api + fn sync — sincronización de registry.db entre PCs
Nuevo sistema para mantener datos no regenerables (proposals, apps, projects,
analysis, vaults, pc_locations) sincronizados entre múltiples máquinas via
una API HTTP central desplegada en organic-machine.com.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:12:38 +02:00
egutierrez 295ab491a3 merge: quick/metabase-expansion-v2 — expansion Metabase: snippets, notifications, filters, export, ProseMirror 2026-04-14 19:03:56 +02:00
egutierrez debbdb86be chore: actualizar exports __init__.py y regenerar registry.db
Nuevos exports: snippets, notifications, dashboard_filters, export_card,
create_model, prosemirror_card_embed. registry.db regenerado con todas las
funciones nuevas indexadas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:27 +02:00
egutierrez 58539f45c9 feat(metabase): expansion cards y documents — export, model, ProseMirror validation, copy nativo
Cards: export_card (CSV/XLSX/JSON), create_model (type=model para fuentes MBQL).
Documents: prosemirror_card_embed helper (resizeNode envolviendo cardEmbed),
validacion automatica contra whitelist TipTap antes de enviar, copy_document
refactorizado al endpoint nativo POST /api/document/:id/copy.
Docs: dataset_query legacy vs MBQL5, template-tags, whitelist de nodos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:19 +02:00
egutierrez 4299482b75 feat(metabase): nuevos modulos — snippets, notifications, dashboard_filters
Tres modulos nuevos con funciones CRUD completas:
- snippets: list, get, create, update, archive (SQL reutilizable)
- notifications: list, create_card_alert, create_dashboard_subscription, update, delete
- dashboard_filters: add_dashboard_filter (parameter_mappings sobre cards existentes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:08 +02:00
egutierrez 7081c3b4d1 merge: quick/metabase-expansion-and-issues — expansion Metabase + issues tracker 2026-04-13 23:32:22 +02:00
egutierrez cb25bf6d1b chore: regenerar registry.db con las nuevas funciones Metabase
Resultado de `fn index` tras la expansion del paquete metabase. Indexa las nuevas funciones y tipos para que queden buscables via FTS5.
2026-04-13 23:31:51 +02:00
egutierrez e5c17f89d7 docs(issues): nuevos issues de backlog y limpieza de tracker
- Añade issues nuevos al backlog: 0010 auth-system, 0011 websocket-sse, 0014 file-upload, 0016 rate-limiting, 0019 structured-logging, 0021 crud-generator, 0022 init-pipelines, 0024 dashboard-yaml-split-por-tab.
- Elimina 0008-sqlite-api-web.md (ya no aplica).
- Actualiza dev/issues/README.md con el estado del tracker.
2026-04-13 23:31:47 +02:00
egutierrez 9a28d08e38 feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
Añade un conjunto amplio de funciones al paquete python/functions/metabase:
- Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test).
- Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones.
- Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio.
- .md por funcion con frontmatter para que fn index los registre.
- Actualiza pyproject.toml y uv.lock con las dependencias resultantes.

Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
2026-04-13 23:31:42 +02:00
egutierrez baa72e211e chore: untrack apps/auto_metabase (lives in its own repo dataforge/auto_metabase) 2026-04-13 14:28:26 +02:00
egutierrez 58fab5ad34 feat(auto_metabase): push-all + describe/sql + auto-inject de dashcards
- push_all(): pushea todos los YAMLs de un proyecto (cards primero,
  dashboards despues), solo CREATE/UPDATE, resiliente a fallos por item
- explore.py: comandos describe (schema de DB) y sql (query ad-hoc con
  limite, cap 5MB, bloqueo de escrituras destructivas)
- payload.py: auto-inyecta id:-N, visualization_settings:{} y
  parameter_mappings:[] en dashcards nuevas para evitar 500 en push
- test_local: 11 cards + 3 dashboards sobre Sample Database de Metabase
- registry.db regenerado con auto_metabase_py_analytics indexada

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:14:05 +02:00
egutierrez 854f42ed6b docs: actualizar README de issues — marcar 8 issues de Wave 1 como completados
Wave 1 completada e integrada a master:
- 0009 HTTP Server Foundation
- 0012 Email & SMTP
- 0013 Background Job Queue
- 0015 Database Migrations
- 0017 Frontend Data Hooks
- 0018 Config & Env Management
- 0020 PDF Generation
- 0023 Testing Utilities

Pendientes (Wave 2 descartada, Wave 3 sin iniciar):
0010, 0011, 0014, 0016, 0019, 0021, 0022

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:26:05 +02:00
egutierrez 1f59b5b4c3 fix: rename openTestDB to openMigrationTestDB to avoid redeclaration with job_queue_test.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:27 +02:00
egutierrez e74ed2e7d3 merge: issue/0023-testing-utils — Go testing utilities
# Conflicts:
#	registry.db
2026-04-13 02:06:03 +02:00
egutierrez 93ae1bd497 merge: issue/0020-pdf-generation — PDF generation Python+Go
# Conflicts:
#	registry.db
2026-04-13 02:05:55 +02:00
egutierrez b0038aab43 merge: issue/0018-config-env — Config & env management
# Conflicts:
#	registry.db
2026-04-13 02:05:47 +02:00
egutierrez 3bb0c7c6f2 merge: issue/0017-frontend-hooks — React HTTP hooks
# Conflicts:
#	registry.db
2026-04-13 02:05:39 +02:00
egutierrez fb9a598aa9 merge: issue/0015-db-migrations — SQL migration system
# Conflicts:
#	registry.db
2026-04-13 02:05:31 +02:00
egutierrez aed8d5b308 merge: issue/0013-background-jobs — SQLite job queue
# Conflicts:
#	registry.db
2026-04-13 02:05:20 +02:00
egutierrez 6aacdb0323 merge: issue/0012-email-smtp — Email SMTP functions 2026-04-13 02:05:03 +02:00
egutierrez 116bbb5e87 merge: issue/0009-http-server — implementación paralela 2026-04-13 02:04:50 +02:00
egutierrez 2fd6eeb95b docs: cerrar issue 0012 — email SMTP implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:04:11 +02:00
egutierrez cdad1b5832 chore: reindexar registry con funciones email SMTP
683 funciones, 110 tipos, 800 unit_tests indexados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:34 +02:00
egutierrez 9747069182 docs: cerrar issue 0020 — PDF Generation implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:28 +02:00
egutierrez a97dd9d9f5 docs: cerrar issue 0015 — sistema de migraciones SQL implementado
6 funciones (migration_parse, migration_validate, migration_create, migration_up,
migration_down, migration_status) + 2 tipos (Migration, MigrationStatus) indexados
en registry.db. 26 tests, todos pasan. Build limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:19 +02:00
egutierrez 38ac24a0ed chore: añadir fpdf2 y pypdf a python/pyproject.toml y uv.lock
Dependencias necesarias para pdf_create_py_infra, pdf_add_table_py_infra
y pdf_merge_py_infra. Instaladas previamente via uv add en el repo principal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:16 +02:00
egutierrez 851732ce7d docs: cerrar issue 0017 — frontend hooks implementados 2026-04-13 02:03:15 +02:00
egutierrez ff7da29638 feat: funciones email SMTP en Python (infra)
smtp_send: conecta+envia+cierra en un paso via smtplib (TLS/STARTTLS/plain).
email_build_html: construye EmailMessagePy frozen dataclass con cuerpo HTML.
Solo stdlib Python: smtplib, email.mime. Tests con mock SMTP server threading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:12 +02:00
egutierrez 94be3b62e7 feat: agregar hooks React HTTP genericos — issue 0017
4 tipos en frontend/types/core/: FetchState, MutationState, FormState, APIClientConfig.
9 funciones en frontend/functions/core/: api_client, http_cache, use_fetch,
use_mutation, use_infinite_scroll, use_form, use_debounced_search, use_sse, use_websocket.
Zero dependencias externas — solo React + fetch nativo. Cache in-memory con SWR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:10 +02:00
egutierrez df424f2de0 feat: issue/0020 — generacion de PDFs en Python y Go
Añade 3 tipos Python (PDFDoc, PDFPage, PDFStyle) y 10 funciones Python
para construir PDFs con fpdf2 (builder fluent), fusionar PDFs con pypdf
y convertir HTML/Markdown a PDF via weasyprint (stub si no disponible).
Añade pdf_simple_report en Go como stub hasta que go-pdf/fpdf se integre.

- python/types/infra/: pdf_doc, pdf_page, pdf_style
- python/functions/infra/: pdf_create, pdf_add_page, pdf_add_text,
  pdf_add_table, pdf_add_image, pdf_add_header_footer, pdf_from_html,
  pdf_from_markdown, pdf_merge, pdf_save
- functions/infra/pdf_simple_report.go: stub Go con ReportSection/ReportTable
- 17 tests Python pasando (pytest)
- fpdf2 y pypdf añadidos via uv al venv Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:51 +02:00
egutierrez 7670b671f2 docs: cerrar issue 0018 — Config & Env Management implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:28 +02:00
egutierrez 4ef5c6e5b8 fix: corregir IDs de tipos en uses_types/returns a formato PascalCase
Los IDs de tipos Go usan PascalCase: Migration_go_infra, MigrationStatus_go_infra.
Actualizar los .md de todas las funciones migration para referenciar los IDs correctos.
Re-indexar: 681 funciones, 109 tipos, 0 errores de validacion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:27 +02:00
egutierrez af9ad48c9b feat(infra): funciones Python config_from_env y dotenv_load con tests
- config_from_env_py_infra: dataclass + field metadata, tipos str/int/float/bool/list
- dotenv_load_py_infra: parser .env con semantica de no-sobreescritura
- 15 tests unitarios Python, todos PASS
- registry.db actualizado con fn index (685 funciones, 109 tipos)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:56 +02:00
egutierrez 327937124f feat(infra): funciones impuras Go para carga de env/config (dotenv, env_require, config_from_env, config_from_file)
- dotenv_load: parser .env con no-sobreescritura y soporte de comillas
- env_require: os.Getenv con error descriptivo fail-fast
- env_require_all: verifica multiples vars y lista todas las faltantes
- config_from_env: reflection sobre struct tags env/default/required/secret, 5 tipos soportados
- config_from_file: JSON via stdlib, YAML stub con not-implemented
- 25 tests unitarios, todos PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:50 +02:00
egutierrez 3d515aa441 feat(infra): tipos ConfigError y ConfigValidation + funciones puras Go (validate, merge, dump)
- ConfigError y ConfigValidation como tipos producto con sus .md en types/infra/
- config_validate: validacion con tags required/format/min/max/oneof via reflection
- config_merge: merge no-mutante de map[string]string con precedencia de override
- config_dump: serializacion de structs a map con mascara *** para campos secret
- 17 tests unitarios, todos PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:43 +02:00
egutierrez 9b0e1f836d feat: funciones impuras migration_create, migration_up, migration_down, migration_status
Fase 2 del issue 0015. MigrationCreate (crea archivo .sql template con version
auto-calculada), MigrationUp (aplica migraciones pendientes en transacciones
individuales), MigrationDown (revierte ultimas N via down_sql de _migrations),
MigrationGetStatus (cruza disco con BD, detecta orphaned). Tests de integracion:
ciclo completo create->up->status->down->status. 26 tests, todos pasan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:40 +02:00
egutierrez ca15655268 feat: tipos Migration/MigrationStatus y funciones puras migration_parse + migration_validate
Fase 1 del issue 0015. Tipos Go en functions/infra/migration.go con metadata en
types/infra/. Funciones puras: MigrationParse (parsea filename NNN_name.sql +
bloques -- +up/-- +down) y MigrationValidate (verifica secuencia, huecos,
duplicados, bloques vacios). 16 tests, todos pasan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:34 +02:00
egutierrez ab868bcea7 test: tests Go para funciones email SMTP
Tests para builders puros (build_html, build_text, with_attachment),
template_render, smtp_connect y smtp_send con mock TCP server.
Todos los tests pasan: 18 casos cubriendo pureza, inmutabilidad y envio SMTP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:18 +02:00
egutierrez cdcdb04d01 docs: cerrar issue 0023 — testing utilities implementadas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:16 +02:00
egutierrez 53deb8e9a8 feat: tipos y funciones email SMTP en Go (infra)
Tipos: EmailAttachment, EmailMessage, SMTPConfig.
Puras: email_build_html, email_build_text, email_with_attachment, email_template_render.
Impuras: smtp_connect (TLS/STARTTLS/plain), smtp_send (MIME multipart con adjuntos).
Solo stdlib: net/smtp, crypto/tls, text/template, mime/multipart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:13 +02:00
egutierrez 38fbb222bf docs: cerrar issue 0013
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:03 +02:00
egutierrez f7a4f26cf0 feat: cola de jobs asincrona basada en SQLite (issue 0013)
Implementa el subsistema completo de background jobs para apps Go en el
dominio infra. 9 funciones + 3 tipos + 17 tests, todos pasando.

- Tipos: Job (product), JobQueue (product), JobStatus (sum) con
  JobHandler, EnqueueOption y WorkerOption usando functional options pattern
- job_queue_create: CREATE TABLE + indices + WAL mode
- job_enqueue: INSERT con UUID (github.com/google/uuid), WithPriority/WithScheduledAt/WithMaxAttempts
- job_dequeue: SELECT+UPDATE atomico en transaccion exclusiva, filtro por jobTypes
- job_complete / job_fail: transiciones de estado; fail → dead cuando attempts >= max_attempts
- job_status_summary: pura, formatea conteo de jobs por estado
- job_worker: poll loop bloqueante, context-cancelable, graceful shutdown
- job_worker_pool: N workers con golang.org/x/sync/errgroup
- job_cleanup: DELETE jobs terminales mas viejos que olderThan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:44 +02:00
egutierrez 59eea5d0f1 chore: re-indexar registry.db con testing-utils (8 funciones, 2 tipos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:39 +02:00
egutierrez de64da7bbc feat: funciones impuras de testing (http server, db setup/seed, fixtures, env, logs) con tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:19 +02:00
egutierrez bb9c3d1bc3 feat: funciones puras assert_json_equal y assert_contains_all con tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:14 +02:00
egutierrez 97512e9a48 feat: tipos TestServer y TestDB para utilidades de testing en Go core
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:11 +02:00
egutierrez 8c1315b9d2 docs: cerrar issue 0009 — HTTP server functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:58:15 +02:00
egutierrez 02226d61f6 feat: funciones impuras HTTP — response, parse, logger, router, serve
Seis funciones de servidor HTTP con tests usando httptest:
- HTTPJSONResponse: escribe JSON con Content-Type y status code
- HTTPErrorResponse: escribe HTTPError como JSON estructurado
- HTTPParseBody: decode JSON con limite de bytes y campos estrictos
- HTTPLoggerMiddleware: loguea method/path/status/duration a io.Writer
- HTTPRouter: crea ServeMux con rutas Go 1.22+ (METHOD /path)
- HTTPServe: ListenAndServe con graceful shutdown por contexto

23 tests pasando, solo stdlib net/http.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:47 +02:00
egutierrez fd19cd222a feat: funciones puras HTTP — HTTPMiddlewareChain y HTTPCORSMiddleware
- HTTPMiddlewareChain: compone N middlewares preservando el orden (el primero es el mas externo)
- HTTPCORSMiddleware: genera Middleware con headers CORS configurables, maneja OPTIONS preflight con 204

Ambas son puras (sin I/O) y testeadas con httptest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:39 +02:00
egutierrez d8d72bb8d6 feat: tipos HTTP — Middleware, Route, HTTPError
Tres tipos Go en el paquete infra para construir servidores HTTP:
- Middleware: funcion que envuelve http.Handler (patron decorator)
- Route: struct con Method, Path y Handler para registrar rutas
- HTTPError: struct con Status, Code y Message para respuestas JSON de error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:34 +02:00
egutierrez 092f14eff0 merge: quick/cmake-path-and-start-script — CMake path fix, sqlite_api start.sh 2026-04-13 01:45:50 +02:00
egutierrez adfd5f63bb chore: actualizar path CMake a nueva ubicacion del dashboard + start.sh
cpp/CMakeLists.txt ahora referencia projects/fn_monitoring/apps/
en vez de apps/. Se añade start.sh para lanzar sqlite_api en
background con health check automatico.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:45:47 +02:00
egutierrez 74c9e39b58 merge: quick/fn-monitoring-project — Proyecto fn_monitoring, mover sqlite_api 2026-04-13 01:36:39 +02:00
egutierrez ccd123e062 feat: proyecto fn_monitoring — agrupa sqlite_api y registry_dashboard
Crea projects/fn_monitoring/ con project.md. Mueve sqlite_api de apps/
a projects/fn_monitoring/apps/. registry_dashboard (repo externo) tambien
se asocia al proyecto via dir_path actualizado. fn index detecta el
project_id automaticamente.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:36:32 +02:00
egutierrez 6877fcc70a merge: issue/0008-sqlite-api-web — API REST HTTP read-only para registry.db y operations.db 2026-04-13 01:29:03 +02:00
egutierrez 3ebda4fcca docs: cerrar issue 0008 — SQLite API Web completado
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:24:24 +02:00
egutierrez f9c1280964 feat: sqlite_api — API REST HTTP read-only para registry.db y operations.db
App service que expone las bases de datos SQLite del registry como endpoints
HTTP. Solo queries SELECT/PRAGMA, apertura read-only (?mode=ro), timeout 5s,
auto-discovery de operations.db, busqueda FTS5 directa, CORS habilitado.
Puerto default 8484.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:24:21 +02:00
egutierrez d2cbbdf600 merge: quick/projects-and-docker-deploy — Docker compose deploy, jupyter fixes, projects structure 2026-04-13 01:17:37 +02:00
egutierrez b717337b7b docs: regla projects, estructura projects/vaults, registry.db
Nueva regla projects.md que documenta como agrupar apps, analyses y vaults bajo
un tema comun en projects/{nombre}/. Actualiza INDEX.md con la entrada #15.
Crea directorios projects/ y vaults/ con .gitkeep (contenido real gitignored).
registry.db regenerado con los cambios del indice.
2026-04-13 01:17:25 +02:00
egutierrez 5b375cb822 fix: mejoras en jupyter launcher y kernel startup
write_jupyter_launcher ahora exporta IPYTHONDIR al directorio local .ipython/
para que el kernel cargue el startup correcto cuando se ejecuta desde projects/.
write_jupyter_registry_kernel usa descubrimiento inteligente de FN_REGISTRY_ROOT:
prioriza env var, luego path hardcoded, luego sube desde CWD buscando registry.db.
Esto permite que analyses dentro de projects/ encuentren el registry automaticamente.
2026-04-13 01:17:19 +02:00
egutierrez ee4e86ee2e feat: funcion docker_compose_remote_deploy bash
Funcion bash que despliega un stack Docker Compose en un host remoto via SSH.
Flujo: verificar SSH → git pull → docker-compose pull → docker-compose up -d → listar containers.
Soporta compose files adicionales y retorna JSON con status, containers y duracion.
2026-04-13 01:17:14 +02:00
egutierrez 5f8b71b528 merge: quick/deploy-infra-projects — Deploy infra, projects y vaults
Sistema de deploy SSH+systemd+rsync con funciones Go y Bash.
Soporte completo de projects y vaults en registry (schema, parser,
store, CLI). Regla deploy para agentes.
2026-04-12 17:30:45 +02:00
egutierrez 1e2582b068 docs: regla deploy, template project, gitignore projects/vaults
Añade regla deploy.md con arquitectura SSH+systemd+rsync, workflow
completo para agentes, y referencia a todas las funciones involucradas.
Actualiza INDEX.md con la nueva regla. Añade template project.md para
fn add -k project. Gitignore projects/*/ y vaults/*/ (contenido local,
solo manifests se versionan).
2026-04-12 17:30:01 +02:00
egutierrez ae33d02e75 feat: funciones bash de deploy — rsync_deploy y gitea_create_webhook
rsync_deploy sincroniza directorio local a remoto via SSH con
exclusiones estándar (.git, node_modules, *.db, etc.).
gitea_create_webhook crea webhook de push en un repo Gitea para
auto-deploy en cada commit.
2026-04-12 17:29:56 +02:00
egutierrez a06946e410 feat: funciones Go de deploy — systemd, VPS setup, deploy remoto
Nuevas funciones infra para deploy sin Docker: generación de units
systemd (pura), instalación/restart/status de servicios remotos via
SSH, setup inicial de VPS (crear dirs, usuario, permisos), y pipelines
de deploy completo (setup_vps_app, deploy_app_remote). Incluye tipo
DeployConfig con la configuración de deploy por app.
2026-04-12 17:29:52 +02:00
egutierrez 6f6bc714a9 feat: subcomando project en CLI con busqueda y listado integrado
Añade cmd/fn/project.go con subcomandos init, list, show y status
para gestionar proyectos desde la CLI. Integra projects en fn search,
fn list y fn show para que aparezcan junto a functions, types y apps.
También añade soporte para vaults en fn show y template project en
fn add -k project.
2026-04-12 17:29:46 +02:00
egutierrez 54e62ecb91 feat: soporte projects y vaults en registry
Añade tablas projects y vaults a registry.db con FTS5, modelos Go,
parser de project.md y vault.yaml, CRUD completo en store, hashing
determinista, validación, y soporte en el indexer para escanear
projects/{name}/ con sus apps, analysis y vaults anidados.
Migration 010 crea las tablas, triggers FTS5, y columna project_id
en apps/analysis. El indexer preserva records remotos (repo_url) al
reindexar, igual que apps/analysis.
2026-04-12 17:29:41 +02:00
egutierrez 1a3e77b0d5 merge: quick/new-bash-go-functions — Nuevas funciones Bash y Go multi-dominio
Batch de funciones nuevas para el registry:
- 12 funciones Bash shell (utilidades de scripting y git)
- 10 funciones Bash infra (instaladores y diagnostico)
- 12 funciones Bash cybersecurity (auditoria y hardening)
- 2 pipelines Bash (inicializacion de proyectos Go)
- 5 funciones Go core (strings y versiones)
- 7 funciones Go infra (gestion SSH config) + tipo SshConfigEntry
- 1 funcion Go shell (extract_script_description)
- 7 funciones Go tui (renderizado y terminal helpers)
2026-04-12 13:55:34 +02:00
egutierrez 8bc721d53b feat: add Go TUI rendering and terminal helper functions
7 funciones Go del dominio tui: apply_gradient (gradiente de color en texto),
draw_box y draw_separator (renderizado de cajas y separadores con box_chars),
load_ascii_art (carga de arte ASCII desde archivos), normalize_terminal_output
y strip_ansi (limpieza de salida de terminal), read_dir_autocomplete
(autocompletado de rutas de directorio). Incluye box_chars.go como helper
de caracteres Unicode para bordes.
2026-04-12 13:54:54 +02:00
egutierrez 6d73e1b4be feat: add Go extract_script_description function
Funcion Go pura del dominio shell que extrae la descripcion de un script Bash
parseando el header del archivo. Busca comentarios con formato estandar y
retorna la primera linea de descripcion encontrada. Util para indexar scripts
automaticamente.
2026-04-12 13:54:48 +02:00
egutierrez f2753e6fff feat: add Go SSH config management functions and type
7 funciones Go del dominio infra para gestion programatica de ~/.ssh/config:
ssh_config_parse (parser de bloques Host/Match), ssh_config_read (lectura del
archivo), ssh_config_find (busqueda por host), ssh_config_add_entry y
ssh_config_remove_entry (CRUD), ssh_config_render (serializacion a texto),
ssh_config_write (escritura atomica). Incluye tipo SshConfigEntry (product type)
y tests unitarios del parser.
2026-04-12 13:54:43 +02:00
egutierrez 773bb3a523 feat: add Go core string and version utility functions
5 funciones Go puras del dominio core: parse_version y compare_versions para
parsing y comparacion semantica de versiones, longest_common_prefix para
encontrar el prefijo comun mas largo entre strings, rel_or_full para devolver
rutas relativas cuando es posible, y split_command_and_arg para separar
comandos de sus argumentos. Todas sin dependencias externas.
2026-04-12 13:54:36 +02:00
egutierrez ae1c69eee0 feat: add bash pipeline functions for Go project initialization
2 pipelines Bash: init_go_module (inicializa un modulo Go con go mod init y
estructura de directorios estandar) e init_go_project (scaffolding completo
de proyecto Go con cmd/, internal/, configs y Makefile). Componen funciones
shell existentes del registry.
2026-04-12 13:54:30 +02:00
egutierrez e76a5e5ab1 feat: add bash cybersecurity audit and hardening functions
12 funciones Bash del dominio cybersecurity: auditoria de red y servicios
(analyze_dns, audit_http_headers, inspect_ssl_cert, list_active_connections,
enumerate_subdomains, geolocate_ip), auditoria de sistema (audit_ssh_config,
check_firewall, detect_suspicious_users), y utilidades crypto (encrypt_file,
generate_password, verify_file_hash). Dominio nuevo en bash/functions/.
2026-04-12 13:54:25 +02:00
egutierrez 94efefc7bf feat: add bash infra installer and diagnostic functions
10 funciones Bash del dominio infra: instaladores de herramientas de desarrollo
(install_go, install_nodejs, install_pnpm, install_python312, install_uv,
install_volta, install_wails), diagnostico del sistema (analyze_disk_space,
detect_wsl, list_listening_ports). Automatizan la configuracion del entorno
de desarrollo en Linux/WSL.
2026-04-12 13:54:21 +02:00
egutierrez 8f45b40528 feat: add bash shell utility functions
12 funciones Bash del dominio shell: utilidades de scripting (bash_log,
bash_colors, bash_check_deps, bash_confirm, bash_handle_error, bash_safe_run),
manipulacion de texto (convert_text_case), estructura de proyectos
(create_project_structure), y operaciones git (git_clean_branches,
git_log_visual, git_push_all_remotes, git_repo_status). Cada una con su
.sh y .md de frontmatter.
2026-04-12 13:54:15 +02:00
egutierrez ac9965220d merge: issue/0007-dag-engine — Motor de DAGs con CLI, web frontend y SQLite
Reemplaza Dagu con implementacion propia compatible con formato YAML existente.
Incluye parser, validador, topo sort, process manager, execution store SQLite,
scheduler cron, CLI (run/list/status/validate/server) y frontend React/Mantine.
2026-04-12 13:08:26 +02:00
egutierrez 1344e557e5 chore: update gitignore files
Agrega prompts/ al gitignore raiz. Actualiza dag_engine/.gitignore
con patrones estandar para Go, frontend y editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:08:13 +02:00
egutierrez 2721b9cc8f chore: close issues 0007a-e, update feature flag and sources manifest
Enable dag-engine feature flag, document dagu as analyzed (GPL-3.0,
no code extracted), move all 0007 issues to completed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:06:17 +02:00
egutierrez d9414e4cba feat: add dag_engine app — CLI + web frontend for DAG execution (0007e)
Full DAG engine app with CLI subcommands (run, list, status, validate, server)
and React/Mantine web frontend. Uses net/http + embedded Vite build. SQLite
store for run history. Scheduler with cron_ticker for automated execution.
Compatible with existing dagu YAML format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:36 +02:00
egutierrez 7aa7790931 feat: add process manager and execution store types (0007b, 0007c)
Process spawn/wait/kill functions for subprocess management with output
capture, timeout, and process group cleanup. DagRun and DagStepResult
types for SQLite execution persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:13 +02:00
egutierrez c3dfc9315f feat: add DAG core functions — parse, validate, topo sort, resolve env, cron match (0007a, 0007d)
Pure functions for parsing dagu-compatible YAML, validating DAG structure,
topological sorting with parallel levels (Kahn's algorithm), and env variable
resolution. Also adds cron_match for schedule matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:05 +02:00
egutierrez cb96e85b69 merge: quick/issue-0008-sqlite-api-web — SQLite API Web service 2026-04-08 01:29:11 +02:00
egutierrez 0bdb3d72d7 feat: add issue 0008 — SQLite API Web service
App Go que expone registry.db y operations.db de cada app como API REST
read-only en localhost:8484, para acceso programático sin SQLite directo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:27:54 +02:00
egutierrez 4e8bbb0a88 merge: quick/registry-dashboard-and-rules — Dashboard ImGui, viewport multi-ventana, reglas tags y issues DAG engine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:36 +02:00
egutierrez ffbcafa52d chore: update registry.db with fullscreen_window function and registry_dashboard app
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:22 +02:00
egutierrez d9b448a07b feat: add DAG engine issues (0007a-e) and feature flag
Desglose del sistema de orquestacion propio para reemplazar Dagu:
- 0007a: core puro (parse, validate, topo sort)
- 0007b: process manager (spawn, wait, kill)
- 0007c: execution store (SQLite)
- 0007d: scheduler (cron parser, ticker)
- 0007e: app CLI que compone todo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:19 +02:00
egutierrez 5c712bb974 docs: update /app and /create_functions skills with service tag and Gitea rules
- /app: Gitea publicacion obligatoria, tag service para daemons, flujo C++ e ImGui,
  prefijo service: para crear services directamente
- /create_functions: reglas de tags launcher y service en la seccion de reglas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:13 +02:00
egutierrez 29dee49a36 docs: rename tag_launcher to function_tags, add service tag convention
Renombra la regla y documenta el tag service para apps de larga duracion
(APIs, daemons, watchers). Un service es una app con tag service, no una
tipologia separada.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:06 +02:00
egutierrez f0d9ffa2bb chore: remove leftover sqlite3 tarball from vendor
Solo se necesitan sqlite3.c, sqlite3.h y sqlite3ext.h para la amalgamation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:18:00 +02:00
egutierrez 132a7d3240 feat: add multi-viewport support and SQLite amalgamation to C++ framework
- AppConfig.viewports flag para ventanas OS reales fuera del main window
- Multi-viewport render loop en app_base.cpp (UpdatePlatformWindows)
- SQLite amalgamation vendoreada para Windows cross-compile
- LANGUAGES C CXX en CMakeLists para compilar sqlite3.c
- Fix pie_chart.cpp para nueva API de ImPlot (PlotPieChart sin flags arg)
- imgui.ini añadido a gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:17:50 +02:00
egutierrez dcd1843609 feat: add fullscreen_window C++ function for ImGui apps
Componente que crea una ventana ImGui fullscreen sin decoraciones, eliminando
la necesidad de usar el sistema de ventanas interno. Usado por registry_dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 01:17:44 +02:00
egutierrez d2ae672a23 merge: quick/cpp-notebook-commands — Funciones C++ ImGui, mejoras notebook, agentes Claude 2026-04-08 00:10:43 +02:00
egutierrez 76a607cf6f chore: update registry.db with C++ functions and notebook enhancements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:10:31 +02:00
egutierrez a1b7e5e143 chore: add claude agent definitions and command templates
Agentes especializados (fn-constructor, fn-executor, fn-recopilador)
y comandos de usuario (analysis, app, create_functions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:10:27 +02:00
egutierrez fc8062bade feat: enhance jupyter notebook functions with auto-init and kernel management
Auto-create notebooks y sesiones en jupyter_exec (append y cell).
Auto-create en jupyter_write (append_code, append_markdown, batch).
Nuevos subcomandos cleanup y shutdown-all en jupyter_kernel.
README.md renombrado a README.txt para evitar error de parseo del indexer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:10:23 +02:00
egutierrez 7eef2544ab feat: add C++ ImGui functions for core UI and visualization
Funciones C++/ImGui para dashboards (grid, panel, docking, sidebar, tabs),
visualizaciones (candlestick, gauge, histogram, pie, sparkline, heatmap,
scatter, line, bar, surface3d, kpi, table), grafos (force layout, renderer,
viewport, spatial hash, types) y utilidades (time series buffer, tracy zones,
memory/fps overlay, plot theme).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:10:18 +02:00
egutierrez 5aef738bc8 merge: quick/bigquery-functions — Funciones BigQuery Python, tipo BQClient, comando meta_bigq 2026-04-07 18:45:38 +02:00
egutierrez 126a20ce07 chore: update registry.db with BigQuery functions index
Reindexado con las nuevas funciones BigQuery y tipo BQClient.
2026-04-07 18:45:15 +02:00
egutierrez f3e62e8303 feat: add meta_bigq command for Metabase + BigQuery operations
Comando Claude con referencia completa de funciones Metabase y BigQuery,
flujos tipicos y ejemplos de uso combinado.
2026-04-07 18:45:11 +02:00
egutierrez 5965997c9e chore: add google-cloud-bigquery dependencies
Dependencias del SDK oficial de BigQuery para las funciones Python del registry.
2026-04-07 18:45:06 +02:00
egutierrez 690e68a542 feat: add BigQuery Python functions and BQClient type
Funciones CRUD completas para BigQuery: auth, datasets, tables, queries,
jobs, routines, load/export. Tipo BQClient como wrapper del SDK oficial.
2026-04-07 18:45:02 +02:00
egutierrez c311623a76 merge: quick/mantine-cpp-new-functions — Mantine v9, C++, OSINT refactor, nuevas funciones 2026-04-06 23:47:41 +02:00
egutierrez b1016ec845 chore: add Kotlin directory structure, update registry.db and gitignore
Añade estructura inicial kotlin/functions/, actualiza registry.db con todos
los cambios indexados, y ajusta .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:47:27 +02:00
egutierrez cbc4c5eafa feat: add Python core and infra functions — PWA, geocoding, POI matching
Nuevas funciones Python: build_guide_prompt, generate_pwa_manifest,
generate_service_worker, match_pois_to_interests (core), nominatim_reverse_geocode,
ollama_chat, overpass_nearby_pois (infra). Incluye tests unitarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:47:19 +02:00
egutierrez 89e443ab18 feat: add bash infra functions — Gitea, Android SDK, Mantine, Capacitor
Nuevas funciones bash: gestión Gitea (create_repo, list_repos, add_collaborator,
push_directory), install_android_sdk, install_mantine, frontend_doctor.
Pipelines: capacitor_build_apk y gitea_init_app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:47:10 +02:00
egutierrez f4932ce64c refactor: reorganize OSINT types — genéricos a core, específicos en cybersecurity
Mueve tipos genéricos (Person, Organization, Location, Email, Phone, Document,
Domain, Event, SocialMedia) de cybersecurity a core. Mantiene en cybersecurity
solo los específicos de seguridad (CryptoWallet, IPAddress, Malware, Vulnerability).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:47:01 +02:00
egutierrez 2d108c295a refactor: migrate frontend from shadcn/Tailwind to Mantine v9
Reescribe todos los componentes UI para usar Mantine v9 en lugar de shadcn/Tailwind.
Elimina cn(), CVA, components.json, theme_provider custom y globals.css con Tailwind.
Añade 25+ componentes nuevos (AppShell, AuthForm, DatePickerInput, Dropzone, etc.)
y MantineProvider como wrapper estándar del sistema de temas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:46:44 +02:00
egutierrez 73a4c3a148 feat: add C++ support with ImGui/ImPlot framework and vendor submodules
Añade soporte C++ al registry: vendor submodules (glfw, imgui, implot, tracy),
sistema de build con CMake y toolchains cross-platform, runner C++ en fn CLI,
parser de tests Google Test, y funciones bash para build Linux/Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:46:36 +02:00
egutierrez 356dbcdadd feat: include registry.db in repo and ignore broken_paths.txt
Share the SQLite registry database so apps/analysis repos can consume
it without needing the full function tree to rebuild.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:55:19 +02:00
egutierrez 1233efb31d merge: quick/docs-params-schema — documentación de params_schema 2026-04-06 00:35:49 +02:00
egutierrez 513c2fb4a7 docs: documenta params_schema en CLAUDE.md y templates
Actualiza schema rápido, ejemplo FTS5, sección de añadir funciones y los tres
templates (function, pipeline, component) con los campos params/output obligatorios.
2026-04-06 00:35:40 +02:00
egutierrez 9b5c430f7f merge: quick/params-schema-composability — params_schema para composabilidad de funciones 2026-04-05 18:45:27 +02:00
egutierrez 5f4f1f7508 docs: params/output semántico en 506 funciones para composabilidad
Añade campos params y output al frontmatter YAML de las 506 funciones del registry.
Cada parámetro tiene descripción semántica (qué representa, unidades, rango típico)
y cada función describe qué produce su output. Permite a agentes razonar sobre
cadenas de composición (ej: prices → log_return → sharpe_ratio) sin leer código.
2026-04-05 18:45:16 +02:00
egutierrez 9b4bb3aabc feat: fn check params y fn show muestra params_schema
Nuevo subcomando 'fn check params' lista funciones sin params_schema documentado.
'fn show' ahora muestra el campo Params con el JSON semántico de inputs/outputs.
2026-04-05 18:45:05 +02:00
egutierrez 34ecadf5a4 feat: add params_schema column for function composability
Nueva columna params_schema en functions con migración 009. Almacena JSON
con descripción semántica de inputs/outputs para que agentes razonen sobre
composabilidad de funciones. Incluye: campo en modelo Go, parsing de params/output
del frontmatter YAML, serialización a JSON, FTS5 rebuild con nueva columna,
hash de contenido actualizado, y warning en indexer cuando faltan params.
2026-04-05 18:45:01 +02:00
egutierrez b55f120a00 merge: quick/unit-tests-e2e-tables — tablas unit_tests y e2e_tests, parser de tests, docs 2026-04-05 18:19:48 +02:00
egutierrez 89730911c2 chore: añade directorio dev/ con issues y funciones implementadas
Tracking de issues completados (jupyter tools) y funciones implementadas (specs de diseño ya resueltas).
2026-04-05 18:19:36 +02:00
egutierrez 3a3a8fd9a9 docs: convenciones de testing y schema unit_tests/e2e_tests
Nuevo docs/testing.md con convenciones de test por lenguaje (Go, Python, Bash con 3 opciones), tablas unit_tests y e2e_tests, consultas FTS5 de ejemplo. Actualiza functions.md y CLAUDE.md con referencia a unit_tests.
2026-04-05 18:19:26 +02:00
egutierrez 29b1c4cd8b feat: fn index extrae unit_tests automáticamente
El indexer lee test_file_path de funciones testeadas, parsea los test cases y los inserta en unit_tests. El output de fn index ahora muestra el conteo de unit_tests extraídos.
2026-04-05 18:19:21 +02:00
egutierrez 131f860a94 feat: parser automático de test files Go/Python/Bash
Extrae test cases individuales con su código desde archivos _test. Go detecta func TestXxx, Python detecta def test_xxx, Bash soporta tres convenciones: test_xxx(){}, secciones === nombre ===, y comentarios # Test:.
2026-04-05 18:19:17 +02:00
egutierrez 9660a1c432 feat: modelos y CRUD para unit_tests y e2e_tests
UnitTest en registry con Insert, GetByFunction, Search FTS5, Purge. E2ETest en fn_operations con Insert, Get, List, UpdateResult, Delete. Ambos con scan helpers y serialización JSON.
2026-04-05 18:19:10 +02:00
egutierrez 256e038cbe feat: tablas unit_tests y e2e_tests
Migración 008 en registry.db para unit_tests con FTS5 (tests individuales extraídos de archivos de test). Migración 004 en operations.db para e2e_tests con FTS5 (tests de integración entre funciones dentro de apps).
2026-04-05 18:19:05 +02:00
egutierrez b406b29074 merge: quick/bulk-functions-sources-notebook — funciones Go/Python/Bash, tipos, notebook, sources 2026-04-05 17:13:13 +02:00
egutierrez 834e910bcf fix: pivot_test comparaciones de tipo — sum retorna float64, no int
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:13:03 +02:00
egutierrez 7605a5760a chore: dependencias Python, sources manifest, reglas de extracción y comando extract-source
Actualiza pyproject.toml con nuevas dependencias (pdfplumber, python-docx, ebooklib, openpyxl, etc.).
Actualiza sources.yaml con funciones extraídas de repos externos.
Mejora reglas de extracción en sources.md.
Añade comando Claude extract-source para workflow de extracción.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:12:05 +02:00
egutierrez 9f4ac6de32 feat: funciones Bash — install_nbconvert, notebook_to_pdf, export_analysis_pdfs
Infra: install_nbconvert (instala nbconvert+deps), notebook_to_pdf (convierte .ipynb a PDF).
Pipeline: export_analysis_pdfs (exporta todos los notebooks de analysis/ a PDF).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:57 +02:00
egutierrez a9f2c60e3d feat: mejoras notebook functions — discover multi-servidor, write batch ops
jupyter_discover: soporte multi-servidor, detección de modo colaborativo mejorada.
jupyter_write: operaciones batch (insert, edit, delete), manejo robusto de Y.js.
jupyter_exec: mejoras en ejecución directa al kernel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:50 +02:00
egutierrez 9fd0ca9cac feat: funciones Python infra y tipos Python (core, datascience, infra)
Infra: cache_to_file, cache_to_sqlite, http_download_file, http_get_json,
http_post_json, read_file_with_encoding, safe_extract_zip, scan_directory,
setup_logger, normalize_zip_filenames.
Tipos: 30+ tipos core (agent_action, context, task, message, parse_result...),
6 tipos datascience (entity_candidate, extraction_result...), 2 tipos infra.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:43 +02:00
egutierrez 63a9cb5273 feat: funciones Python datascience, finance, cybersecurity y pipelines
Datascience: aggregate_by_group, deduplicate_entities/relations, detect_drift,
diff_entities/relations, extract_entities/relations_llm, hotness_score, melt,
merge_graphs, pivot, build_entity/relation_schema_prompt.
Finance: avellaneda_stoikov_quotes, generate_gbm_prices, generate_taker_order,
hawkes_intensity + módulo finance.py.
Cybersecurity: envelope_encrypt/decrypt + módulo cybersecurity.py.
Pipelines: extraction_pipeline, monte_carlo_market, run_market_sim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:32 +02:00
egutierrez 25a392df48 feat: funciones Python core — parsers, formatters, retry, serialización, LLM utils y más
178 archivos: módulo core.py actualizado + ~80 funciones nuevas con tests.
Incluye: parse_llm_json, extract_text_from_file, retry_with_backoff, circuit_breaker,
from_csv/to_csv, from_jsonl/to_jsonl, html_to_markdown, pdf_to_markdown, docx/epub/excel
converters, cache_decorator, react_loop, task_manager, template rendering, entre otros.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:21 +02:00
egutierrez 9c0d24d3ef feat: funciones Go — core (cron, join_by_key, validate_struct), datascience (pivot, diff_entities), infra (http, cache, cron_ticker)
Nuevas funciones Go con tests en tres dominios:
- core: parse_cron_expr, next_cron_time, join_by_key, validate_struct_fields + tipo CronSchedule
- datascience: pivot (tabla dinámica), diff_entities (comparación de entidades)
- infra: http_get_json, http_post_json, http_download_file, cache_to_sqlite, cron_ticker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:11:12 +02:00
egutierrez bee3b0d946 merge: quick/native-select-search-graph — native select, SearchBar, GraphContainer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:02:49 +02:00
egutierrez af039f6023 feat: componente GraphContainer con sigma.js y graphology
Visualizacion interactiva de grafos con WebGL via sigma.js, estructura de
datos graphology, y layout ForceAtlas2 adaptativo. Soporta grafos dirigidos
multi-edge, leyenda de tipos de nodo, y eventos click/double-click.
Nuevas deps: graphology, sigma, recharts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:02:34 +02:00
egutierrez f168795bda feat: componente SearchBar con debounce y clear
Input de busqueda con icono, debounce configurable y boton de limpiar.
Exportado desde index.ts del barrel de UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:02:29 +02:00
egutierrez bbd2cbff3e refactor: migrar Select y SimpleSelect a native HTML select
Select reescrito de @base-ui/react primitives a <select> nativo con wrapper
para mantener la misma API visual (ChevronDown, estilos tema). SimpleSelect
actualizado para usar <select>/<optgroup> directamente sin intermediarios.
Checkbox corregido: import CheckboxIndicator separado reemplazado por
CheckboxPrimitive.Indicator para consistencia.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:02:24 +02:00
egutierrez 056ce6679c merge: quick/frontmatter-fixes-new-components-osint — frontmatter fixes, UI components, OSINT types, indexer warnings 2026-04-03 03:24:20 +02:00
egutierrez eb9476503f chore: regla frontend_theming y comandos claude
Nueva regla para usar componentes @fn_library y sistema de temas CSS variables en todos los frontends. Añade directorio de comandos claude.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:52 +02:00
egutierrez e7a00e221e feat: funciones bash audit_registry_paths y validate_registry_paths
Pipeline para auditar file_path del registry contra disco y función shell para validar paths individuales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:47 +02:00
egutierrez f61a4c4b18 feat: tipos OSINT para dominio cybersecurity
13 tipos product (person, email, domain, IP, phone, social_media, organization, location, vulnerability, malware, crypto_wallet, document, event) para modelar entidades de inteligencia de fuentes abiertas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:43 +02:00
egutierrez 40d6db312d feat: funciones core frontend — generate_theme_css, get_computed_color, get_theme_tokens
Utilidades TypeScript puras para generación de CSS desde tema, resolución de colores computados y extracción de tokens del sistema de temas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:38 +02:00
egutierrez c5bb64160f feat: nuevos componentes UI — accordion, avatar, breadcrumb, checkbox, command, dropdown, pagination, popover, radio, sheet, select, switch, textarea, toast
Componentes React accesibles basados en Radix UI con soporte de temas via CSS variables. Incluye barrel export en index.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:32 +02:00
egutierrez e89b78cc45 feat: warnings en indexer para file_path inexistentes en disco
Valida post-insert que file_path y test_file_path de funciones y tipos apunten a archivos reales. Reporta warnings sin bloquear el indexado.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:25 +02:00
egutierrez e33b306225 fix: corregir lang y file_path en frontmatter de funciones existentes
Normaliza lang: typescript → ts en funciones frontend y corrige file_path de functions/infra/ → functions/browser/ en funciones CDP. Actualiza referencias cn_typescript_core → cn_ts_core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 03:23:20 +02:00
egutierrez 9c859e96d8 merge: quick/ssh-pass-stubs-embedding-datascience — SSH, pass, stubs, embedding, datascience, jupyter fix 2026-04-02 22:04:29 +02:00
egutierrez 10d17f9362 chore: gitignore .local
Ignora directorio .local para archivos locales de desarrollo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:04:00 +02:00
egutierrez 974f704214 fix: jupyter_exec usa run_in_executor para execute_cell
Evita bloquear el event loop asyncio ejecutando execute_cell (operación
síncrona con websocket) en un thread executor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:59 +02:00
egutierrez 0fa16a033c feat: módulo embedding — encode, model CRUD, stores sqlvec y usearch
Funciones Python para embeddings: carga/guardado de modelos, encoding de
texto, y almacenamiento/búsqueda vectorial con sqlite-vec y usearch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:57 +02:00
egutierrez f851988d6f feat: funciones datascience — ops_to_rdf_triples, ops_to_sigma_json, render_sigma_html
Conversión de operations.db a triples RDF y formato sigma.js, más
renderizado HTML standalone con dark theme y ForceAtlas2 layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:51 +02:00
egutierrez e9a8cbf20f feat: build tags y stubs para clickhouse y duckdb
Añade build tags noclickhouse/noduckdb a las implementaciones reales y
crea stubs que devuelven error para compilar sin las dependencias CGO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:49 +02:00
egutierrez 846012c087 feat: funciones pass para gestión de secretos — get, set, list, delete, generate, sync
Wrappers Bash sobre pass (password-store) para CRUD de secretos, generación
de contraseñas y sincronización con git. Incluye script de test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:44 +02:00
egutierrez 6d0d63cb23 feat: funciones SSH para infra — conn, check, exec, download, upload, tunnel
Conjunto completo de funciones SSH para operaciones remotas: conexión,
verificación de host, ejecución de comandos, transferencia de archivos
(upload/download) y gestión de túneles. Incluye tipo SSHConn y tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:03:41 +02:00
egutierrez b220f8c0be merge: quick/frontend-ui-components-sqlite-open — componentes UI nuevos y mejorados, sqlite_open basePath 2026-04-02 15:32:52 +02:00
egutierrez 4c52b41b7b feat: sqlite_open basePath — resuelve paths relativos desde directorio de config
Nuevo parámetro basePath en SQLiteOpen para resolver paths relativos
contra un directorio base (ej: filepath.Dir del archivo YAML de config)
en lugar del cwd del proceso. basePath vacío mantiene comportamiento anterior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:32:40 +02:00
egutierrez aea2131dcb feat: mejoras componentes UI — card variants, kpi_card slots, sparkline colors, bar_chart horizontal radius
- card: variantes default/borderless/ghost con ring condicional
- kpi_card: props unit, action, chart y delta con label/suffix personalizable
- sparkline: prop colors para colores por barra en variant bar
- bar_chart: radius condicional según orientación horizontal/vertical

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:32:35 +02:00
egutierrez 1aaeec5090 feat: componentes data_table y pie_chart — tabla con sorting/pagination y gráfico circular Recharts
Nuevos componentes React/TS en frontend/functions/ui/:
- data_table: tabla de datos con columnas tipadas, sorting, paginación y formato personalizable
- pie_chart: gráfico circular Recharts con tooltips, leyenda y paleta configurable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:32:28 +02:00
egutierrez 7e3599e3ac merge: quick/nordvpn-db-wails-frontend-notebook — NordVPN, DB multi-engine, Wails, frontend React/TS, Jupyter notebook, lorenz_step 2026-04-01 20:56:18 +02:00
egutierrez 29c8046d4e chore: actualizar deps Go, sources.yaml y funciones infra modificadas
Nuevas dependencias para ClickHouse, DuckDB, Postgres drivers.
Actualizar sources.yaml con funciones extraídas.
Ajustes menores en write_jupyter_launcher, write_mcp_jupyter_config y docker_run_container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:55 +02:00
egutierrez 125ef74358 docs: regla notebook_collaboration y actualización INDEX
Nueva regla para colaboración en notebooks Jupyter via funciones del registry.
Documenta el flujo discover → read → write → exec y las reglas de uso.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:48 +02:00
egutierrez 960f310bcf feat: lorenz_step datascience — paso del atractor de Lorenz
Función pura Go que calcula un paso del sistema de ecuaciones de Lorenz.
Útil para simulaciones de sistemas dinámicos y visualizaciones caóticas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:44 +02:00
egutierrez 268a76602a feat: funciones Jupyter notebook Python — discover, read, write, exec, kernel
Funciones Python para interactuar con Jupyter Lab programáticamente:
descubrir instancias, leer/escribir celdas, ejecutar código y gestionar kernels.
Reemplazan MCP jupyter con API REST + WebSocket directa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:39 +02:00
egutierrez dc78d8fea3 feat: funciones frontend React/TS — componentes UI, hooks Wails, charts y tipos
Componentes React reutilizables: card, dialog, tabs, select, alert, badge, button, input, label,
skeleton, tooltip, progress_bar, page_header, form_field, settings_page, crud_page, analytics_page,
dashboard_layout. Charts: area, bar, line, sparkline, kpi_card, chart_container.
Hooks Wails: use_wails_query, use_wails_mutation, use_wails_stream, use_wails_event, use_animated_canvas.
Funciones core: cn, format_compact, chart_colors, get_series_color, wails_cache, theme_config_to_colors.
Tipos: chart_series, wails_ipc, theme_config, component_variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:34 +02:00
egutierrez e02a950ee0 feat: funciones Wails — scaffold, CRUD bindings, build, eventos y streaming
Funciones Go para crear apps Wails: scaffold estructura, bind CRUD genérico,
build multiplataforma, emit eventos y stream de datos al frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:24 +02:00
egutierrez a75170cbc6 feat: abstracción DB multi-engine — CRUD genérico y openers para SQLite, Postgres, ClickHouse, DuckDB
Funciones Go con interfaz unificada para operaciones DB: open, close, create_table, exec, query, insert_row, insert_batch.
Openers específicos por engine. Tipo DBConfig para configuración común.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:17 +02:00
egutierrez c33e907fef feat: funciones NordVPN bash y Go — CLI, contenedor Docker y parser de estado
Funciones bash para instalar, conectar, desconectar, estado, IP, ciudades, países y protocolo.
Funciones Go para gestionar contenedor NordVPN (run/start/stop) y parsear estado.
Incluye tipo NordVPNStatus y tests para el parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:55:08 +02:00
egutierrez d7f2c00d7b feat: externalize apps/analysis to Gitea repos, add analysis table
- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:23:51 +02:00
egutierrez 8f24157096 merge: quick/content-hash-sources-infra-functions — content hash, sources, funciones infra/core/PowerShell y app navegador 2026-03-30 14:25:18 +02:00
egutierrez 3b88857999 chore: schema rápido en CLAUDE.md, sync Metabase en CLI, fix main.py
Agrega documentación de schema rápido en CLAUDE.md, regla sources en INDEX.
CLI fn index sincroniza registry.db a directorio Metabase si existe.
fn show muestra campos source_*. Fix import en metabase_registry/main.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:54 +02:00
egutierrez 4d6ea9a910 feat: funciones PowerShell infra — firewall y portproxy
Funciones PowerShell para gestión de red en Windows: win_firewall_add_rule,
win_firewall_remove_rule, win_portproxy_add y win_portproxy_remove.
Útiles para configurar acceso de red en entornos WSL2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:45 +02:00
egutierrez bb38eedfd1 feat: app script_navegador y dashboard Metabase
App Go para ejecutar scripts de navegación automatizada usando las
funciones CDP del registry. Incluye script de creación de dashboard
en Metabase para monitoreo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:39 +02:00
egutierrez 9d3bfd2cd2 feat: cdp_wait_load y mejoras en CDP connect/launch
Nueva función cdp_wait_load para esperar carga completa de página.
CdpConnect ahora soporta host remoto via CdpConnectHost (útil para
WSL2 donde Chrome Windows escucha en IP distinta). Mejoras en
chrome_launch para configuración más flexible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:21 +02:00
egutierrez 90693fb32f feat: funciones infra — Docker, deploy, build y health check
Funciones impuras para gestión de contenedores: docker_build_image,
docker_compose_up/down, docker_volume_create/list/remove,
generate_dockerfile, write_dockerfile, go_build_binary, health_check_http,
deploy_app y stop_app. Todas con tests unitarios donde aplica.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:12 +02:00
egutierrez b5a6711c64 feat: funciones core — detect_cycle, generate_id, rewrite_rule
Tres funciones puras para el dominio core: detección de ciclos en grafos
dirigidos (DFS), generación de IDs determinísticos, y reescritura de
reglas con pattern matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:00 +02:00
egutierrez c72ae15429 feat: source attribution para funciones externas
Sistema de extracción de funciones desde repos externos. Agrega campos
source_repo, source_license y source_file en functions y types (migración 006).
Incluye manifest sources/sources.yaml, regla sources.md, parser con campos
de atribución, y template actualizado con los nuevos campos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:23:53 +02:00
egutierrez e3bb9c3b38 feat: content hash y timestamps inteligentes en registry
Agrega content_hash a functions, types y apps para detectar cambios reales
entre reindexaciones. Los timestamps created_at se preservan si el contenido
no cambió, y updated_at solo se actualiza cuando hay cambios efectivos.
Incluye migración 005, hash.go con SHA256 determinístico, y ajustes en
store/indexer/models para el nuevo flujo de timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:23:45 +02:00
egutierrez 48caec5665 merge: quick/chrome-cdp-and-ops-logs — funciones Chrome CDP y logs en operations 2026-03-29 17:31:16 +02:00
egutierrez 169cb0853b feat: modelo Log y CRUD en fn_operations
Tipo Log con niveles debug/info/warn/error, source, entity_id y execution_id
opcionales. Migración 003_logs.sql y funciones InsertLog, GetLog, ListLogs
con filtros combinables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:31:03 +02:00
egutierrez add09c2faa feat: funciones Chrome CDP para automatización de navegador
10 funciones Go en infra/ para controlar Chrome via Chrome DevTools Protocol:
chrome_launch, cdp_connect, cdp_navigate, cdp_evaluate, cdp_screenshot,
cdp_click, cdp_type_text, cdp_wait_element, cdp_get_html, cdp_close.
WebSocket RFC 6455 implementado sin dependencias externas.
Incluye tests de integración con Chrome real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:30:56 +02:00
egutierrez f748256c1d fix: findOpsDB falla con error en vez de crear operations.db en la raíz
Antes, si no encontraba operations.db subiendo directorios, hacía
fallback silencioso a ./operations.db — lo que creaba la BD en la raíz
violando la regla de db_locations. Ahora retorna error explícito
indicando que se debe ejecutar fn ops init en el directorio correcto.

También elimina operations.db espuria de la raíz (2 executions de
metabase_registry creadas por el fallback).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:29:47 +02:00
egutierrez 4b2240fbce merge: quick/metabase-ops-pipelines — pipelines operativos Metabase y fix permisos SQLite 2026-03-29 00:54:38 +01:00
egutierrez c2528c6ea4 docs: documentación completa de metabase_registry
Arquitectura de mounts Docker, tabla de databases, permisos SQLite
(nunca chown, siempre chmod), flujo para app nueva paso a paso,
y referencia a los 3 pipelines relacionados.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:54:28 +01:00
egutierrez dd324b7785 feat: pipelines Metabase — add ops db, create ops dashboard, fix permissions
Tres pipelines Python para gestionar operations.db en Metabase:
- metabase_add_ops_db: registra la operations.db de una app como database SQLite
- metabase_create_ops_dashboard: genera dashboard operativo con 14 cards (KPIs,
  distribución, executions, assertions) para cualquier app
- metabase_fix_permissions: arregla SQLITE_READONLY_DIRECTORY haciendo chmod
  777/666 sin chown (que se propaga al host via bind mount)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:54:24 +01:00
egutierrez 9095fe8c65 feat: dashboard apps y mejora layout del dashboard Overview
Dashboard fn-registry Apps con 10 cards: KPIs por lenguaje, dominio,
framework, dependencias y catálogo completo. Cards del Overview
ampliadas a grid de 24 columnas con tamaños más legibles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:54:18 +01:00
egutierrez d6240022a4 merge: quick/apps-table-metabase-dashboard — tabla apps, dashboard Metabase, funciones Python 2026-03-29 00:14:21 +01:00
egutierrez 405be396c8 feat: dashboard Metabase del registry + regla apps vs functions
Script Python que crea un dashboard en Metabase con 15 cards: KPIs
escalares, distribucion por lenguaje/dominio/kind/pureza, ranking de
funciones mas usadas y complejas, cobertura de tests y tabla cruzada.
Agrega regla apps_vs_functions que establece que codigo reutilizable va
en functions/ y codigo especifico/hardcodeado va en apps/.
2026-03-29 00:14:07 +01:00
egutierrez 2c15a0b5e9 feat: tabla apps en registry — modelo, parser, indexer y CLI
Agrega soporte completo para indexar aplicaciones del directorio apps/.
Cada app tiene un descriptor app.md con frontmatter YAML que el indexer
recoge automaticamente. Incluye migracion 004, modelo App, ParseAppMD,
ValidateApp, store CRUD con FTS5, y soporte en fn list/search/show.
Crea descriptores app.md para docker_tui, pipeline_launcher y metabase_registry.
2026-03-29 00:13:57 +01:00
egutierrez eaed99e52c feat: funciones Python para core, cybersecurity, datascience y finance
Agrega funciones Python reutilizables organizadas por dominio:
- core: composicion funcional (pipe, compose, map, filter, reduce, etc.)
- cybersecurity: analisis de amenazas y puertos
- datascience: estadisticas y deteccion de outliers
- finance: indicadores tecnicos y analisis financiero
2026-03-29 00:13:50 +01:00
egutierrez ac71d4b079 feat: pyrunner mejorado para fn run Python
Refactoriza la ejecucion de funciones Python en fn run. Extrae la logica
a pyrunner.go con soporte para importar dependencias del registry y
ejecutar con el venv del proyecto. Agrega WalCheckpoint en db.go para
que lectores externos vean datos actualizados tras fn index.
2026-03-29 00:13:46 +01:00
egutierrez f11f60d121 chore: gitignore completo — __pycache__, .env, venv, node_modules
Añadidos patrones recursivos para Python (__pycache__, .pyc, .venv),
Node (node_modules), y secrets (.env, .env.*) en cualquier subdirectorio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:25:55 +01:00
egutierrez e0573302af merge: quick/fn-run-types-metabase — fn run multi-lenguaje, tipos Go unificados, metabase setup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:45 +01:00
egutierrez 54be36dd63 docs: CLAUDE.md actualizado con fn run, tipos Go en functions/, bash functions
Documentación de fn run para todos los lenguajes, nueva ubicación de tipos Go,
sección de uso por agentes. Añadidas funciones Bash del registry (shell, infra,
core, pipelines).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:30 +01:00
egutierrez 72c572e1ea feat: metabase_setup Python, fix list_databases, volumen Docker en init_metabase
Nueva función metabase_setup para setup inicial via API. Fix list_databases
que no extraía data del response wrapper. Pipeline init_metabase soporta
--mb-volumes para montar SQLite como volumen con fix de permisos automático.
Añadido .env a gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:20 +01:00
egutierrez 2bae07d1f5 feat: fn run — ejecución multi-lenguaje de funciones y pipelines desde CLI
Nuevo comando que despacha automáticamente según lenguaje: Go pipelines con
go run, Go functions con go test/vet, Python con venv y -m para imports
relativos, Bash directo, TypeScript con tsx del frontend. Resolución por
nombre con desambiguación. Añadido GetFunctionsByName al store y tsx al frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:12 +01:00
egutierrez 9abaefeb00 fix: stubs shell y tui usan tipos nativos en firmas en vez de Result/Option de core
Result[T] y Option[T] de core no son accesibles desde otros paquetes sin import.
Cambiado a (T, error) y (T, bool) siguiendo la regla de tipos nativos en firmas.
Añadidas dependencias bubbletea/bubbles/lipgloss al go.mod raíz para que tui compile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:06 +01:00
egutierrez 05444f74d3 refactor: mover .go de tipos Go a functions/{domain}/ para compilación unificada
Los archivos .go de tipos ahora viven junto a las funciones en functions/{domain}/
(mismo paquete Go), resolviendo errores de compilación por tipos no encontrados
(Option, Pair, Result, etc.). Los .md de metadata permanecen en types/{domain}/
con file_path actualizado a functions/. Se elimina types.go duplicado de infra.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:23:00 +01:00
egutierrez 528a16cd5a merge: quick/frontend-setup-metabase-docs — frontend setup, metabase Go/Py, documentación en registry 2026-03-28 20:33:20 +01:00
egutierrez d549aa0314 chore: gitignore para node_modules, dist y __pycache__
Añade .gitignore en frontend/ y python/ para excluir artefactos
generados. Elimina node_modules/, dist/ y __pycache__/ del tracking.
2026-03-28 20:33:04 +01:00
egutierrez 3798e2d959 feat: setup frontend con pnpm, vite, react, tailwind y shadcn
Inicializa directorio frontend/ con stack React moderno:
pnpm + vite 8 + react 19 + tailwind v4 + shadcn v4 (base-nova).
Estructura functions/core (TS puro) y functions/ui (componentes React).
El indexer descubre frontend/functions/ y frontend/types/ automáticamente.
Elimina functions/components/ (legacy) y actualiza referencias en
CLAUDE.md y template de componentes.
2026-03-28 20:32:40 +01:00
1991 changed files with 401844 additions and 2782 deletions
+196 -12
View File
@@ -3,20 +3,35 @@
Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes. Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes.
**Dos bases de datos SQLite:** **Dos bases de datos SQLite:**
- **registry.db** (raiz) — funciones, tipos, proposals. Regenerable con `fn index` (excepto proposals). - **registry.db** (raiz) — funciones, tipos, proposals, apps, projects, analysis, vaults, pc_locations. Regenerable con `fn index` (excepto proposals y pc_locations).
- **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos. - **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos.
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
**Reglas y convenciones:** ver `.claude/rules/INDEX.md` **Reglas y convenciones:** ver `.claude/rules/INDEX.md`
--- ---
## Explorar el registry (USAR SIEMPRE) ## Explorar el registry (OBLIGATORIO)
Antes de escribir codigo, SIEMPRE consulta registry.db para evitar duplicados y descubrir funciones reutilizables. **SIEMPRE** consulta registry.db antes de escribir codigo, crear funciones, o responder sobre el registry. No uses grep/glob sobre archivos .go/.md — la BD es la fuente de verdad.
**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Estos campos tambien estan indexados en FTS5, asi que puedes buscar dentro del codigo y la documentacion directamente. Para leer el codigo de una funcion: `SELECT code FROM functions WHERE id = '...'`. Para leer su documentacion: `SELECT documentation FROM functions WHERE id = '...'`.
**Busquedas FTS5 obligatorias:** Usa SIEMPRE la tabla FTS5 para buscar tanto por `name` como por `description`. Esto encuentra coincidencias parciales y similares que una busqueda exacta perderia. Usa operadores FTS5: `OR` para ampliar, `*` para prefijos, `NEAR` para proximidad.
```bash ```bash
# FTS5 # Busqueda FTS5 por nombre Y descripcion (USAR SIEMPRE ESTE PATRON)
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'slice') ORDER BY name;" sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slice OR description:slice') ORDER BY name;"
# FTS5 con prefijo (encuentra slice, slicing, sliced...)
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slic* OR description:slic*') ORDER BY name;"
# FTS5 en tipos
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:result OR description:result') ORDER BY name;"
# FTS5 por semantica de params (composabilidad)
sqlite3 registry.db "SELECT id, json_extract(params_schema, '$.output') FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'params_schema:retornos');"
# Por dominio # Por dominio
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;" sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
@@ -24,7 +39,7 @@ sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain =
# Puras de un dominio # Puras de un dominio
sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;" sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;"
# Tipos # Tipos por dominio
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';" sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';"
# Dependencias # Dependencias
@@ -37,22 +52,57 @@ sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status
sqlite3 registry.db ".schema" sqlite3 registry.db ".schema"
``` ```
**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero.
### Schema rapido
**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, params_schema`
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- 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)
**unit_tests** — columnas: `id, function_id, name, code, file_path, lang, created_at, updated_at`
- Extraidos automaticamente por `fn index` desde los archivos de test
- FK: `function_id``functions.id`
**pc_locations** — columnas: `id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at`
- Mapa de ubicaciones por PC: donde esta cada app/analysis/project/vault en cada maquina
- `entity_type`: app, analysis, project, vault
- `status`: active, missing, archived
- Se puebla con `fn sync`, NO con `fn index`
- Consultas: `SELECT * FROM pc_locations WHERE pc_id = 'home-wsl'`
**FTS5 (columnas buscables):**
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code, params_schema
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
- `unit_tests_fts`: id, name, code, function_id, lang
--- ---
## Estructura ## Estructura
``` ```
fn-registry/ fn-registry/
functions/{domain}/ # .go + .md por funcion (core, finance, datascience, cybersecurity) functions/{domain}/ # .go + .md por funcion Y tipo Go (core, finance, datascience, cybersecurity)
functions/pipelines/ # Composiciones, siempre impuras functions/pipelines/ # Composiciones, siempre impuras
functions/components/ # React (.tsx) types/{domain}/ # Solo .md de tipos (los .go viven en functions/{domain}/)
types/{domain}/ # .go + .md por tipo python/functions/ # .py + .md por funcion Python
python/types/ # .py + .md por tipo Python
bash/functions/ # .sh + .md por funcion Bash (core, infra, io, shell)
frontend/ # pnpm + vite + react + mantine
frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React)
frontend/types/ # .ts + .md por tipo
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
fn_operations/ # Paquete Go: operations database (libreria) fn_operations/ # Paquete Go: operations database (libreria)
apps/ # Apps ejecutables (TUIs, CLIs) — modulos Go independientes, cada una con su operations.db apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db
analysis/ # Exploraciones Jupyter independientes — cada una con su venv, MCP y kernel conectado al registry
cmd/fn/ # CLI principal cmd/fn/ # CLI principal
docs/ # Specs de diseño docs/ # Specs de diseño
docs/templates/ # Plantillas de frontmatter docs/templates/ # Plantillas de frontmatter
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
``` ```
--- ---
@@ -76,6 +126,24 @@ fn search -k function -p pure -d core "slice"
fn list [-d domain] [-k kind] fn list [-d domain] [-k kind]
fn show <id> fn show <id>
fn add -k function # Template fn add -k function # Template
fn check params # Lista funciones sin params_schema
# Ejecutar funciones y pipelines (fn run)
fn run <id_or_name> [args...] # Ejecuta por ID o nombre
fn run init_metabase --project test # Go pipeline (go run .)
fn run setup_metabase_volume # Bash pipeline (bash <file>)
fn run metabase_setup_py_infra # Python (python/.venv/bin/python3 <file>)
fn run my_component_ts_core # TypeScript (frontend/node_modules/.bin/tsx <file>)
fn run filter_slice_go_core # Go function con tests (go test -v)
fn run docker_pull_image_go_infra # Go function sin tests (go vet)
# Despacho por lenguaje:
# go (con main.go en dir) → go run .
# go (con tests) → go test -v -count=1 -tags fts5 ./pkg/
# go (sin tests) → go vet -tags fts5 ./pkg/
# py → python/.venv/bin/python3 <file>
# bash → bash <file>
# ts → frontend/node_modules/.bin/tsx <file>
# Si el nombre es ambiguo, muestra los IDs para desambiguar.
# Proposals # Proposals
fn proposal add --kind new_function --title "..." --created-by agent [--target-id <id>] fn proposal add --kind new_function --title "..." --created-by agent [--target-id <id>]
@@ -83,6 +151,13 @@ fn proposal list [-k kind] [-s status]
fn proposal show <id> fn proposal show <id>
fn proposal update <id> --status approved [--reviewed-by lucas] fn proposal update <id> --status approved [--reviewed-by lucas]
# Sync entre PCs
fn sync # Push+pull completo contra el servidor
fn sync status # Estado local: PC, API, conteos
fn sync locations # Mapa de ubicaciones en todos los PCs
# Config: ~/.fn_pc (identidad PC), FN_REGISTRY_API (URL), REGISTRY_API_TOKEN (token)
# URL con basicAuth: export FN_REGISTRY_API="https://user:pass@registry.organic-machine.com"
# Operations (desde directorio con operations.db) # Operations (desde directorio con operations.db)
fn ops init [path] fn ops init [path]
fn ops entity add|list|show|delete fn ops entity add|list|show|delete
@@ -96,16 +171,41 @@ fn ops assertion result add|list
`FN_REGISTRY_ROOT` env var permite que `fn ops` acceda a registry.db desde cualquier directorio. `FN_REGISTRY_ROOT` env var permite que `fn ops` acceda a registry.db desde cualquier directorio.
### Uso de fn run por agentes
`fn run` permite ejecutar directamente funciones y pipelines del registry desde la terminal. Usar para:
- Lanzar pipelines con sus argumentos: `./fn run init_metabase --project fn_registry`
- Correr tests de funciones Go: `./fn run filter_slice_go_core`
- Ejecutar scripts Python/Bash del registry sin montar paths manualmente
- Verificar que funciones Go compilan correctamente (go vet)
Entornos usados automaticamente:
- Python: `python/.venv/bin/python3` (venv del proyecto)
- TypeScript: `frontend/node_modules/.bin/tsx` (node del proyecto)
- Go: `go run .` / `go test` / `go vet` con `CGO_ENABLED=1 -tags fts5`
- Bash: `bash` del sistema
--- ---
## Añadir funciones ## Añadir funciones
1. Consulta la BD para verificar que no existe algo similar 1. Consulta la BD para verificar que no existe algo similar
2. Crea dos archivos: `functions/{domain}/{name}.go` + `functions/{domain}/{name}.md` 2. Crea dos archivos segun el lenguaje:
- Go: `functions/{domain}/{name}.go` + `.md`
- Python: `python/functions/{domain}/{name}.py` + `.md`
- Bash: `bash/functions/{domain}/{name}.sh` + `.md`
- TypeScript: `frontend/functions/{domain}/{name}.ts` + `.md`
3. Ejecuta `./fn index` y verifica con `./fn show {id}` 3. Ejecuta `./fn index` y verifica con `./fn show {id}`
Frontmatter del .md — ver template completo en `docs/templates/` o con `fn add -k function`. Frontmatter del .md — ver template completo en `docs/templates/` o con `fn add -k function`.
Campos `params` y `output` (obligatorios en frontmatter):
- `params`: lista de `{name, desc}` con descripción semántica de cada parámetro (qué representa, unidades, rango)
- `output`: descripción semántica de lo que retorna la función
- Para componentes: solo `output` (ya tienen `props`)
- Se indexan como JSON en `params_schema` y son buscables via FTS5
- `fn check params` lista funciones sin documentar
Reglas de integridad (el indexer las valida): Reglas de integridad (el indexer las valida):
- Pipeline → siempre impuro + uses_functions no vacio - Pipeline → siempre impuro + uses_functions no vacio
- Pure → returns_optional: false + error_type: "" - Pure → returns_optional: false + error_type: ""
@@ -118,7 +218,91 @@ Reglas de integridad (el indexer las valida):
## Añadir tipos ## Añadir tipos
Dos archivos: `types/{domain}/{name}.go` + `types/{domain}/{name}.md`. Ver template en `docs/templates/`. Dos archivos en directorios separados:
- **Codigo Go:** `functions/{domain}/{name}.go` (junto a las funciones, mismo paquete Go)
- **Metadata .md:** `types/{domain}/{name}.md` con `file_path` apuntando a `functions/{domain}/{name}.go`
Los `.go` de tipos viven en `functions/{domain}/` para que Go los compile en el mismo paquete que las funciones que los usan. Los `.md` se mantienen en `types/{domain}/` para que el indexer los identifique como tipos.
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
--- ---
+828
View File
@@ -0,0 +1,828 @@
---
name: fn-constructor
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
model: sonnet
tools: Read, Write, Bash, Glob, Grep, Edit
---
# Agente Constructor — Fase 1 del Ciclo Reactivo
Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y tipos de calidad que se integren perfectamente en el registry. Trabajas en 4 lenguajes: **Go**, **Python**, **TypeScript** y **Bash**.
## REGLA FUNDAMENTAL: Consultar registry.db ANTES de escribir
**SIEMPRE** consulta la base de datos antes de crear cualquier cosa. La BD es la fuente de verdad.
```bash
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Buscar tipos existentes
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Ver funciones de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
# Ver tipos de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
# Verificar que un ID referenciado existe
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
```
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
### Reutilizar funciones existentes
Antes de implementar logica desde cero, busca funciones del registry que puedas **componer** para resolver el problema. El registry crece por composicion, no por duplicacion.
```bash
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
# Ver que retorna y que tipos usa una funcion candidata
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
# Buscar funciones puras del mismo dominio (las mas componibles)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
```
**Criterios de reutilizacion:**
- Si una funcion pura existente cubre parte de la logica, **usala** (importala y referenciala en `uses_functions`)
- Si un tipo existente modela los datos que necesitas, **usalo** (referencialo en `uses_types`)
- Compara `returns` de funciones existentes con los inputs que necesitas — si encajan, componer es mejor que reimplementar
- Prioriza funciones **puras y testeadas** (`purity = 'pure' AND tested = 1`) como bloques de construccion
Esto acelera la construccion y fortalece el grafo de dependencias del registry.
---
## REGLA CRITICA: Cada lenguaje tiene su carpeta raiz
**NUNCA** pongas archivos de un lenguaje en la carpeta de otro. El directorio raiz depende SOLO del lenguaje:
| Lang | Carpeta raiz funciones | Carpeta raiz tipos | Extension |
|------|------------------------|--------------------|-----------|
| `go` | `functions/` | `types/` | `.go` |
| `py` | `python/functions/` | `python/types/` | `.py` |
| `bash` | `bash/functions/` | *(no tiene tipos)* | `.sh` |
| `typescript` | `frontend/functions/` | `frontend/types/` | `.ts`/`.tsx` |
**Patron de file_path por lenguaje** (campo `file_path` del .md, relativo a la raiz del registry):
| Lang | file_path funcion | file_path pipeline | file_path tipo |
|------|-------------------|--------------------|----------------|
| `go` | `functions/{domain}/{name}.go` | `functions/pipelines/{name}.go` | `functions/{domain}/{name}.go` (codigo) + `types/{domain}/{name}.md` (metadata) |
| `py` | `python/functions/{domain}/{name}.py` | `python/functions/pipelines/{name}.py` | `python/types/{domain}/{name}.py` |
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md.
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md`
- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh`
### Estructura detallada
**Go** (carpeta raiz: `functions/` y `types/`)
- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md`
- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go`
- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md`
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
**Python** (carpeta raiz: `python/`)
- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md`
- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py`
- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md`
- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md`
**Bash** (carpeta raiz: `bash/`)
- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh`
- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
**TypeScript** (carpeta raiz: `frontend/`)
- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md`
- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
---
## Convenciones de IDs y nombres
- **ID**: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`, `metabase_list_users_py_infra`, `assert_file_exists_bash_shell`)
- **Nombres**: snake_case para funciones, PascalCase para tipos Go y componentes React
- **Lang valores**: `go`, `py`, `typescript`, `bash`
- **file_path**: siempre relativo a la raiz del registry, con el prefijo de lenguaje correcto segun la tabla de arriba
---
## Reglas de pureza (CRITICAS)
- **Puras en el centro, impuras en los bordes**
- Una funcion pura NUNCA depende de una impura
- `purity: pure` -> `returns_optional: false` + `error_type: ""`
- `purity: impure` -> `error_type` obligatorio (usar `error_go_core`)
- `kind: pipeline` -> siempre `purity: impure` + `uses_functions` no vacio
---
## Reglas de integridad (el indexer las valida)
1. Pipeline -> impuro + uses_functions no vacio
2. Pure -> returns_optional: false + error_type: ""
3. Impure (no component) -> error_type obligatorio
4. tested: true -> test_file_path y tests obligatorios
5. tested: false -> tests vacio y test_file_path vacio
6. uses_functions, uses_types, returns, error_type -> IDs que EXISTEN en la BD
7. Component -> framework obligatorio, returns vacio (usar emits)
8. file_path siempre relativa, nunca absoluta
9. returns solo para IDs del registry, NO tipos nativos del lenguaje
10. Tipos nativos (float64, []float64, string, dict) van en la firma, no en returns
---
## Firmas: tipos nativos, no del registry
Usar tipos nativos del lenguaje en las firmas para evitar imports circulares:
- Go: `float64`, `[]float64`, `string`, `[]byte`, `map[string]any`
- Python: `float`, `list[float]`, `str`, `dict`
- TypeScript: `number`, `number[]`, `string`, `Record<string, unknown>`
- Bash: `string`, `int`, `array` (descriptivos — bash no tiene tipos reales)
Los tipos del registry se documentan en `uses_types` y `returns` del .md, no en la firma.
---
## Templates por tipo de entidad
### Funcion Go pura
**{name}.go:**
```go
package {domain}
// {PascalName} {description corta}.
func {PascalName}[T any](params) returnType {
// implementacion
}
```
**{name}.md:**
```yaml
---
name: {name}
kind: function
lang: go
domain: {domain}
version: "1.0.0"
purity: pure
signature: "func {PascalName}(...) ..."
description: "{descripcion}"
tags: [{tags}]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["{test1}", "{test2}"]
test_file_path: "functions/{domain}/{name}_test.go"
file_path: "functions/{domain}/{name}.go"
---
## Ejemplo
```go
// ejemplo de uso
```
## Notas
{notas sobre la implementacion}
```
### Funcion Go impura
**{name}.md** — diferencias con pura:
```yaml
purity: impure
error_type: "error_go_core"
returns_optional: false # o true si aplica
```
**{name}.go** — siempre retorna `(T, error)`:
```go
func {PascalName}(params) (returnType, error) {
// implementacion con manejo de errores
}
```
### Test Go
**{name}_test.go:**
```go
package {domain}
import "testing"
func Test{PascalName}(t *testing.T) {
t.Run("{nombre del test}", func(t *testing.T) {
got := {PascalName}(input)
// assertions
if got != expected {
t.Errorf("got %v, want %v", got, expected)
}
})
}
```
Los nombres de los subtests t.Run() deben coincidir EXACTAMENTE con el array `tests` del .md.
### Pipeline Go
**{name}.md:**
```yaml
kind: pipeline
purity: impure
uses_functions: [{id1}, {id2}] # IDs existentes en BD
error_type: "error_go_core"
file_path: "functions/pipelines/{name}.go"
```
### Funcion Python
**{name}.py:**
```python
"""Descripcion del modulo."""
def {name}(params) -> return_type:
"""Descripcion.
Args:
param: descripcion.
Returns:
descripcion del retorno.
"""
# implementacion
```
**{name}.md** — misma estructura que Go pero:
```yaml
lang: py
file_path: "python/functions/{domain}/{name}.py"
test_file_path: "python/functions/{domain}/{name}_test.py"
```
### Test Python
**{name}_test.py:**
```python
"""Tests para {name}."""
def test_{caso}():
result = {name}(input)
assert result == expected
```
### Funcion TypeScript pura
**{name}.ts:**
```typescript
/**
* {Descripcion}.
*/
export function {camelName}<T>(params: types): ReturnType {
// implementacion
}
```
**{name}.md:**
```yaml
lang: typescript
domain: core
file_path: "frontend/functions/core/{name}.ts"
test_file_path: "frontend/functions/core/{name}.test.ts"
```
### Componente React (TypeScript)
**{name}.tsx:**
```tsx
import { type FC } from "react";
interface {PascalName}Props {
// props
}
export const {PascalName}: FC<{PascalName}Props> = ({ ...props }) => {
return (/* JSX */);
};
```
**{name}.md:**
```yaml
kind: component
lang: typescript
domain: core # o ui
framework: react
props:
- name: propName
type: "string"
required: true
description: "..."
emits: [onEvent]
has_state: false # true si usa useState/useReducer
file_path: "frontend/functions/ui/{name}.tsx"
```
### Tipo Go
**IMPORTANTE:** Los `.go` de tipos Go van en `functions/{domain}/` (mismo directorio que las funciones, mismo paquete Go). Los `.md` van en `types/{domain}/` con `file_path` apuntando a `functions/{domain}/{name}.go`. Esto permite que Go compile tipos y funciones juntos en el mismo paquete.
**functions/{domain}/{name}.go:** (el codigo)
```go
package {domain}
// {PascalName} {descripcion corta}.
type {PascalName} struct {
Field1 Type1
Field2 Type2
}
```
**types/{domain}/{name}.md:** (la metadata, file_path apunta a functions/)
```yaml
---
name: {name}
lang: go
domain: {domain}
version: "1.0.0"
algebraic: product # o sum
definition: |
type {PascalName} struct {
Field1 Type1
Field2 Type2
}
description: "{descripcion}"
tags: [{tags}]
uses_types: []
file_path: "functions/{domain}/{name}.go"
---
## Notas
{notas}
```
### Tipo TypeScript
**{name}.ts:**
```typescript
/** {Descripcion}. */
export interface {PascalName} {
field1: type1;
field2: type2;
}
```
**{name}.md:**
```yaml
lang: typescript
file_path: "frontend/types/{domain}/{name}.ts"
```
### Tipo Python
**{name}.py:**
```python
"""Descripcion."""
from dataclasses import dataclass
@dataclass(frozen=True)
class {PascalName}:
field1: type1
field2: type2
```
**{name}.md:**
```yaml
lang: py
file_path: "python/types/{domain}/{name}.py"
```
### Funcion Bash pura
**{name}.sh:**
```bash
#!/usr/bin/env bash
# {name} — {descripcion corta}
{name}() {
local input="$1"
# implementacion pura (sin efectos secundarios, sin I/O)
echo "$result"
}
```
**{name}.md:**
```yaml
---
name: {name}
kind: function
lang: bash
domain: {domain}
version: "1.0.0"
purity: pure
signature: "{name}(input: string) -> string"
description: "{descripcion}"
tags: [{tags}]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["{test1}", "{test2}"]
test_file_path: "bash/functions/{domain}/{name}_test.sh"
file_path: "bash/functions/{domain}/{name}.sh"
---
## Ejemplo
```bash
result=$({name} "input")
```
## Notas
{notas sobre la implementacion}
```
### Funcion Bash impura
**{name}.md** — diferencias con pura:
```yaml
purity: impure
error_type: "error_go_core"
```
**{name}.sh** — retorna exit code != 0 en error:
```bash
#!/usr/bin/env bash
# {name} — {descripcion corta}
{name}() {
local param="$1"
# implementacion con I/O, red, filesystem, etc.
local result
result=$(curl -sf "$param") || return 1
echo "$result"
}
```
### Test Bash
**{name}_test.sh:**
```bash
#!/usr/bin/env bash
# Tests para {name}
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/{name}.sh"
PASS=0
FAIL=0
assert_eq() {
local test_name="$1" expected="$2" got="$3"
if [[ "$expected" == "$got" ]]; then
echo "PASS: $test_name"
((PASS++))
else
echo "FAIL: $test_name — expected '$expected', got '$got'"
((FAIL++))
fi
}
# Test: {nombre del test}
assert_eq "{nombre del test}" "expected" "$({name} "input")"
# Test: {otro test}
assert_eq "{otro test}" "expected2" "$({name} "input2")"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
```
Los nombres de los tests en assert_eq deben coincidir EXACTAMENTE con el array `tests` del .md.
### Pipeline Bash
**{name}.md:**
```yaml
kind: pipeline
lang: bash
purity: impure
uses_functions: [{id1}, {id2}] # IDs existentes en BD
error_type: "error_go_core"
file_path: "bash/functions/pipelines/{name}.sh"
```
**{name}.sh:**
```bash
#!/usr/bin/env bash
# Pipeline: {name} — {descripcion}
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../{domain1}/{func1}.sh"
source "$SCRIPT_DIR/../{domain2}/{func2}.sh"
main() {
local input="$1"
local step1
step1=$({func1} "$input")
{func2} "$step1"
}
main "$@"
```
---
## Stubs para dependencias externas
Si la implementacion necesita dependencias externas no disponibles:
Go:
```go
func FetchSomething(url string) ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}
```
Bash:
```bash
fetch_something() {
echo "not implemented" >&2
return 1
}
```
Documentar completamente el .md igualmente.
---
## Flujo de trabajo del constructor
### Al recibir una peticion de crear funcion/tipo:
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index`
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
6. Si hay errores de validacion, corregirlos y re-indexar
### Al recibir una peticion de crear tests:
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
2. **CREAR** el archivo de test
3. **EJECUTAR** los tests:
- Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
- Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
- Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
5. **RE-INDEXAR** y verificar
### Al recibir una peticion batch (multiples funciones):
1. Buscar todas en FTS5 primero
2. Crear todas las funciones
3. Un solo `fn index` al final
4. Verificar todas con `fn show`
---
## Compilacion, tests y ejecucion
```bash
# Compilar CLI (necesario si se modifico codigo del CLI)
cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
# Indexar registry
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
# Tests Go de un dominio
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
# Tests Go de todo el registry
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
# Mostrar funcion indexada
cd /home/lucas/fn_registry && ./fn show {id}
```
### fn run — Ejecutar funciones y pipelines directamente
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
```bash
cd /home/lucas/fn_registry
# Go pipeline (go run . en su directorio)
./fn run init_metabase --project test
# Go function con tests (go test -v)
./fn run filter_slice_go_core
# Go function sin tests (go vet — verifica compilacion)
./fn run docker_pull_image_go_infra
# Python function (usa python/.venv/bin/python3, imports relativos funcionan)
./fn run metabase_list_databases_py_infra
# Bash pipeline/function
./fn run setup_metabase_volume
# TypeScript (usa frontend/node_modules/.bin/tsx)
./fn run my_function_ts_core
# Por nombre (si es unico) o por ID completo
./fn run init_metabase # resuelve a init_metabase_go_infra
./fn run metabase_auth # error: ambiguo (go + py), usar ID completo
```
**Despacho por lenguaje:**
- **Go pipeline** (dir con main.go) → `go run .`
- **Go function con tests** → `go test -v -count=1 -tags fts5 ./pkg/`
- **Go function sin tests** → `go vet -tags fts5 ./pkg/`
- **Python** → `python/.venv/bin/python3 -m package.module` (PYTHONPATH=python/functions/)
- **Bash** → `bash <file>`
- **TypeScript** → `frontend/node_modules/.bin/tsx <file>`
**Usar fn run para verificar** que lo que construiste funciona antes de reportar al usuario.
---
## Dominios existentes
### Go
- **core** — funciones genericas (slice, string, math)
- **finance** — indicadores tecnicos, mercado
- **datascience** — estadistica, ML, analisis
- **cybersecurity** — seguridad, hashing, crypto
- **infra** — infraestructura, APIs, servicios
- **io** — entrada/salida de archivos y red
- **shell** — comandos del sistema
- **tui** — interfaces de terminal (Bubble Tea)
- **pipelines** — composiciones orquestadas (siempre impuro)
### Python
- **infra** — wrappers de APIs (Metabase, etc.)
- (extensible a cualquier dominio)
### Bash
- **core** — funciones puras de texto/strings/arrays
- **infra** — automatizacion de infraestructura, APIs con curl
- **io** — lectura/escritura de archivos, parseo
- **shell** — wrappers de comandos del sistema
- (extensible a cualquier dominio)
### TypeScript
- **core** — funciones puras TS (sin React)
- **ui** — componentes React
---
## Errores comunes a evitar
1. **Archivo en carpeta de otro lenguaje** -> un .sh en `functions/` (Go) en vez de `bash/functions/`, un .py en `functions/` en vez de `python/functions/`. SIEMPRE usar la carpeta raiz del lenguaje correspondiente (ver tabla de REGLA CRITICA)
2. **No consultar la BD** antes de crear -> puede duplicar funciones
3. **Poner tipos del registry en la firma** -> causa imports circulares en Go
4. **Olvidar error_type en impuras** -> falla validacion
5. **tests array no coincide con t.Run()** -> inconsistencia
6. **file_path absoluto** -> falla validacion
7. **file_path no coincide con la carpeta raiz del lenguaje** -> el file_path del .md debe empezar con `bash/` para bash, `python/` para py, `frontend/` para typescript, `functions/` o `types/` para Go
8. **returns con tipos nativos** -> returns solo acepta IDs del registry
9. **Pipeline sin uses_functions** -> falla validacion
10. **Pura con error_type** -> falla validacion
11. **No re-indexar** despues de crear archivos
---
## Ejemplo completo: crear funcion Go pura con tests
Peticion: "Crea una funcion que calcule la media de un slice de float64"
### Paso 1: Buscar en BD
```bash
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
```
### Paso 2: Crear archivos
**functions/core/mean.go:**
```go
package core
// Mean returns the arithmetic mean of a float64 slice.
// Returns 0 for an empty slice.
func Mean(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
var sum float64
for _, x := range xs {
sum += x
}
return sum / float64(len(xs))
}
```
**functions/core/mean.md:**
```yaml
---
name: mean
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func Mean(xs []float64) float64"
description: "Calcula la media aritmetica de un slice de float64. Retorna 0 para slice vacio."
tags: [math, statistics, mean, average]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["media de valores positivos", "slice vacio retorna cero", "un solo elemento retorna ese elemento"]
test_file_path: "functions/core/mean_test.go"
file_path: "functions/core/mean.go"
---
## Ejemplo
```go
avg := Mean([]float64{1.0, 2.0, 3.0, 4.0})
// avg = 2.5
```
## Notas
Funcion pura. No maneja NaN ni Inf — asume valores finitos.
```
**functions/core/mean_test.go:**
```go
package core
import (
"math"
"testing"
)
func TestMean(t *testing.T) {
t.Run("media de valores positivos", func(t *testing.T) {
got := Mean([]float64{1, 2, 3, 4})
if math.Abs(got-2.5) > 1e-9 {
t.Errorf("got %v, want 2.5", got)
}
})
t.Run("slice vacio retorna cero", func(t *testing.T) {
got := Mean([]float64{})
if got != 0 {
t.Errorf("got %v, want 0", got)
}
})
t.Run("un solo elemento retorna ese elemento", func(t *testing.T) {
got := Mean([]float64{42.0})
if got != 42.0 {
t.Errorf("got %v, want 42", got)
}
})
}
```
### Paso 3: Indexar y verificar
```bash
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
./fn show mean_go_core
```
+899
View File
@@ -0,0 +1,899 @@
---
name: fn-executor
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
model: sonnet
tools: Read, Write, Bash, Glob, Grep, Edit
---
# Agente Ejecutor — Fase 2 del Ciclo Reactivo
Eres el agente ejecutor del fn_registry. Tu rol es **preparar entornos de ejecucion** (apps con operations.db), **ejecutar funciones y pipelines** (Go, Python y Bash), y **registrar cada ejecucion** con sus metricas y resultados en operations.db.
Trabajas despues del fn-constructor: el toma las decisiones de diseño, tu las ejecutas y registras.
Ademas, **detectas oportunidades de mejora**: si al ejecutar una app identificas logica reutilizable que deberia ser un pipeline o funcion del registry, creas una proposal.
---
## REGLA FUNDAMENTAL: Todo se registra en operations.db
Cada ejecucion debe quedar trazada. operations.db es la fuente de verdad operativa.
- **operations.db** solo existe dentro de apps (`apps/*/operations.db`), NUNCA en la raiz
- **registry.db** solo existe en la raiz del repo, NUNCA en apps
- Si no existe operations.db en la app, inicializalo primero
---
## Paso 0: Consultar registry.db para entender que ejecutar
Antes de ejecutar, consulta el registry para obtener contexto completo: funciones, apps, y sus dependencias.
### Consultar apps registradas
Las apps estan indexadas en registry.db con toda la metadata necesaria para ejecutarlas. **Consulta siempre la tabla apps antes de ejecutar una app.**
```bash
# Ver todas las apps disponibles
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
# Ver app completa con dependencias y framework
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Apps de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
# Apps que usan una funcion especifica
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
# Ver documentacion completa de una app
sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
```
**Campos clave de apps para ejecucion:**
- `entry_point` — archivo de entrada (main.go, main.py, main.sh)
- `dir_path` — directorio de la app relativo a la raiz (apps/nombre)
- `lang` — lenguaje (go, py, bash, ts)
- `framework` — framework usado (bubbletea, httpx, etc.)
- `uses_functions` — JSON array con IDs de funciones del registry que usa
- `uses_types` — JSON array con IDs de tipos del registry que usa
### Consultar funciones y pipelines
```bash
# Ver pipeline/funcion completa
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
# Ver codigo de la funcion
sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
# Pipelines disponibles (con tag launcher para TUI)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
# Funciones impuras ejecutables directamente
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
# Buscar por FTS
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
```
### Usar contexto de apps para ejecucion inteligente
Cuando te pidan ejecutar una app, sigue este flujo:
1. **Consulta la app en registry.db** para obtener `entry_point`, `dir_path`, `lang`, `framework`
2. **Revisa `uses_functions`** para entender las dependencias — si alguna funcion fallo antes, anticipa el problema
3. **Lee `documentation` y `notes`** si necesitas contexto sobre como ejecutar o configurar la app
4. **Despacha segun `lang`**: Go → `go run .`, Python → `python3 main.py`, Bash → `bash main.sh`
5. **Verifica que `dir_path` existe** y tiene operations.db antes de ejecutar
---
## Paso 1: Preparar la app
### Inicializar operations.db
```bash
# Desde la raiz del registry
cd /home/lucas/fn_registry
# Opcion A: Usar el CLI
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
# Opcion B: Copiar template directamente
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
```
### Estructura obligatoria de una app
Toda app DEBE tener estos archivos:
```
apps/{app_name}/
app.md # Metadata OBLIGATORIA (frontmatter + documentacion)
operations.db # BD operativa OBLIGATORIA (creada con fn ops init)
.gitignore # Excluir operations.db, binarios, __pycache__
```
#### app.md — frontmatter obligatorio
```yaml
---
name: {app_name}
lang: go|py|bash|ts
domain: infra|analytics|tools|finance|...
description: "Descripcion corta de la app"
tags: [tag1, tag2]
uses_functions:
- funcion_id_1
- funcion_id_2
uses_types: []
framework: bubbletea|httpx|... # o vacio si no aplica
entry_point: "main.go|main.py|main.sh"
dir_path: "apps/{app_name}"
---
## Notas / Arquitectura / etc.
(documentacion libre)
```
**Reglas del frontmatter:**
- `uses_functions` debe listar TODOS los IDs de funciones del registry que la app importa
- `entry_point` debe ser el archivo que se ejecuta (main.go, main.py, main.sh)
- `dir_path` siempre relativo a la raiz del repo
- `framework` es el framework principal (bubbletea, httpx, etc.)
#### Estructura por lenguaje
**Go (TUI o CLI):**
```
apps/{app_name}/
app.md
main.go # Entry point
go.mod / go.sum
operations.db
.gitignore
app/
model.go # Modelo principal (tea.Model si es Bubbletea)
config/
config.go # Configuracion y paths
views/
*.go # Vistas/componentes de la UI
```
**Python:**
```
apps/{app_name}/
app.md
main.py # Entry point
requirements.txt # Dependencias (si tiene extras)
operations.db
.gitignore
*.py # Modulos adicionales
```
**Bash:**
```
apps/{app_name}/
app.md
main.sh # Entry point (chmod +x)
operations.db
.gitignore
```
#### .gitignore recomendado
```
operations.db
operations.db-wal
operations.db-shm
__pycache__/
build/
*.exe
```
#### Checklist al crear o validar una app
1. [ ] `app.md` existe con frontmatter completo
2. [ ] `operations.db` inicializada con `fn ops init`
3. [ ] `uses_functions` en app.md lista todas las funciones del registry usadas
4. [ ] `entry_point` apunta al archivo correcto
5. [ ] `dir_path` es `apps/{app_name}`
6. [ ] `.gitignore` excluye operations.db y artefactos
7. [ ] La app esta indexada en registry.db (`fn index` y verificar con `SELECT * FROM apps WHERE name = '...'`)
### Verificar que operations.db existe y tiene schema
```bash
sqlite3 apps/{app_name}/operations.db ".tables"
# Debe mostrar: assertion_results assertions assertions_fts entities entities_fts executions relation_inputs relations schema_migrations types_snapshot
```
---
## Paso 2: Configurar entities y relations antes de ejecutar
Las entities representan los datos concretos del proyecto. Las relations documentan como se transforman.
### Crear entities (datos que el pipeline consume o produce)
```bash
cd /home/lucas/fn_registry
# Entity de entrada
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
--db apps/{app_name}/operations.db \
--name "btc_ticks" \
--type-ref "tick_go_finance" \
--domain "finance" \
--source "binance_api" \
--status "active" \
--tags '["btc","ticks","live"]' \
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
# Entity de salida
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
--db apps/{app_name}/operations.db \
--name "btc_ohlcv_5m" \
--type-ref "ohlcv_go_finance" \
--domain "finance" \
--source "pipeline:tick_to_ohlcv" \
--status "designed" \
--tags '["btc","ohlcv","5min"]' \
--metadata '{"pair":"BTCUSDT","interval":"5m"}'
```
### Crear relations (como se conectan entities)
```bash
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
--db apps/{app_name}/operations.db \
--name "ticks_to_ohlcv" \
--from-entity "{entity_id}" \
--to-entity "{entity_id}" \
--via "tick_to_ohlcv_go_finance" \
--status "designed"
```
### Consultar estado actual
```bash
# Listar entities
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
# Listar relations
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
# Ver grafo ASCII
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
```
---
## Paso 3: Ejecutar
### fn run — Metodo preferido (todos los lenguajes)
`fn run` despacha automaticamente segun el lenguaje y tipo:
```bash
cd /home/lucas/fn_registry
# Go pipeline (go run . en su directorio)
./fn run init_metabase --project test
# Go function con tests (go test -v)
./fn run filter_slice_go_core
# Go function sin tests (go vet — verifica compilacion)
./fn run docker_pull_image_go_infra
# Python (usa python/.venv/bin/python3, imports relativos funcionan)
./fn run metabase_list_databases_py_infra
# Bash pipeline/function
./fn run setup_metabase_volume
# TypeScript (usa frontend/node_modules/.bin/tsx)
./fn run my_function_ts_core
# Por nombre (si es unico) o por ID completo
./fn run init_metabase # resuelve a init_metabase_go_infra
```
**Despacho automatico:**
- **Go pipeline** (dir con main.go) → `go run .` con CGO_ENABLED=1
- **Go function con tests** → `go test -v -count=1 -tags fts5 ./pkg/`
- **Go function sin tests** → `go vet -tags fts5 ./pkg/`
- **Python** → `python/.venv/bin/python3 -m package.module` (PYTHONPATH=python/functions/)
- **Bash** → `bash <file>`
- **TypeScript** → `frontend/node_modules/.bin/tsx <file>`
### Ejecucion directa (cuando fn run no aplica)
Para apps con su propio main.go/main.py/main.sh:
```bash
# Go app
cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
# Python app
cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args]
# Bash app
cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args]
```
### Capturar metricas de ejecucion
Al ejecutar, siempre captura:
- **Tiempo de inicio y fin** (ISO 8601)
- **Duration en ms**
- **records_in / records_out** (si aplica)
- **stdout / stderr**
- **Status**: success, failure, partial
- **Error message** si fallo
```bash
# Ejemplo: ejecutar con captura de tiempo
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
EXIT_CODE=$?
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ $EXIT_CODE -eq 0 ]; then
STATUS="success"
ERROR=""
else
STATUS="failure"
ERROR="$OUTPUT"
fi
echo "Status: $STATUS | Start: $START | End: $END"
```
---
## Paso 4: Registrar la ejecucion en operations.db
### Via CLI
```bash
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
--db apps/{app_name}/operations.db \
--pipeline-id "tick_to_ohlcv_go_finance" \
--relation-id "{relation_id}" \
--status "success" \
--started-at "$START" \
--ended-at "$END" \
--records-in 1000 \
--records-out 200 \
--metrics '{"avg_latency_ms":45,"rows_filtered":800}'
```
### Via SQLite directamente (cuando el CLI no esta disponible)
```bash
sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id, relation_id, status, started_at, ended_at, duration_ms, records_in, records_out, error, metrics) VALUES (
'$(uuidgen | tr '[:upper:]' '[:lower:]')',
'pipeline_id_aqui',
'relation_id_o_vacio',
'success',
'$START',
'$END',
$DURATION_MS,
1000,
200,
'',
'{\"metric1\": 42}'
);"
```
### Consultar ejecuciones
```bash
# Listar todas
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
# Por pipeline
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
# Por status
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
# Detalle de una ejecucion
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
```
---
## Paso 5: Actualizar estado de entities y relations
Despues de ejecutar, actualiza los estados para reflejar la realidad.
### Actualizar relation status
```bash
# Antes de ejecutar: designed -> implemented -> tested
# Al ejecutar: -> running
# Si se retira: -> deprecated
sqlite3 apps/{app_name}/operations.db "UPDATE relations SET status = 'running', started_at = datetime('now') WHERE id = 'RELATION_ID';"
```
### Actualizar entity status
```bash
# La entity de salida pasa a active tras ejecucion exitosa
sqlite3 apps/{app_name}/operations.db "UPDATE entities SET status = 'active', updated_at = datetime('now') WHERE id = 'ENTITY_ID';"
# Si la ejecucion fallo
sqlite3 apps/{app_name}/operations.db "UPDATE entities SET status = 'stale', updated_at = datetime('now') WHERE id = 'ENTITY_ID';"
```
---
## Paso 6 (Opcional): Evaluar assertions y reaccionar
Si hay assertions definidas sobre las entities afectadas, evaluarlas para verificar calidad.
```bash
# Evaluar assertions de una entity
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
--db apps/{app_name}/operations.db \
--entity-id "ENTITY_ID"
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
--db apps/{app_name}/operations.db \
--entity-id "ENTITY_ID" \
--react
```
### Reglas de reaccion (automaticas con --react):
- **critical fail** -> entity.status = corrupted + proposal creada en registry.db
- **warning fail** -> entity.status = stale (si estaba active)
- **info fail** -> solo se registra, sin cambio de status
---
## Crear una app nueva desde cero
Cuando el usuario pide ejecutar algo que aun no tiene app:
### App Go
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: go
domain: {domain}
description: "{descripcion}"
tags: [{tags}]
uses_functions: []
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "apps/{app_name}"
---
## Notas
{documentacion}
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
build/
*.exe
GIEOF
# 4. Inicializar modulo Go
cd /home/lucas/fn_registry/apps/{app_name}
go mod init fn_registry/apps/{app_name}
# 5. Crear main.go minimo
cat > main.go << 'GOEOF'
package main
import (
"fmt"
"os"
"time"
)
func main() {
start := time.Now()
// TODO: implementar logica del pipeline
duration := time.Since(start)
fmt.Fprintf(os.Stderr, "duration_ms=%d\n", duration.Milliseconds())
}
GOEOF
# 6. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
# 7. Indexar en registry.db
./fn index
```
### App Python
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: py
domain: {domain}
description: "{descripcion}"
tags: [{tags}]
uses_functions: []
uses_types: []
framework: ""
entry_point: "main.py"
dir_path: "apps/{app_name}"
---
## Notas
{documentacion}
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
__pycache__/
GIEOF
# 4. Crear main.py
cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF'
"""Pipeline executor."""
import sys
import time
import json
def main():
start = time.time()
# TODO: implementar logica
duration_ms = int((time.time() - start) * 1000)
print(json.dumps({"status": "success", "duration_ms": duration_ms}))
if __name__ == "__main__":
main()
PYEOF
# 5. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
# 6. Indexar en registry.db
./fn index
```
### App Bash
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: bash
domain: {domain}
description: "{descripcion}"
tags: [{tags}]
uses_functions: []
uses_types: []
framework: ""
entry_point: "main.sh"
dir_path: "apps/{app_name}"
---
## Notas
{documentacion}
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
GIEOF
# 4. Crear main.sh
cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
#!/usr/bin/env bash
# Pipeline executor: {app_name}
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
main() {
local start_ts
start_ts=$(date +%s%N)
# TODO: implementar logica
# source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
# result=$({func} "$@")
local end_ts duration_ms
end_ts=$(date +%s%N)
duration_ms=$(( (end_ts - start_ts) / 1000000 ))
echo "{\"status\": \"success\", \"duration_ms\": $duration_ms}" >&2
}
main "$@"
SHEOF
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
# 5. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
# 6. Indexar en registry.db
./fn index
```
---
## Ejecucion con captura completa (patron recomendado)
Este patron captura todo lo necesario para registrar la ejecucion:
### Go
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
PIPELINE_ID="{pipeline_id}"
RELATION_ID="{relation_id}" # vacio si no aplica
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STDOUT_FILE=$(mktemp)
STDERR_FILE=$(mktemp)
cd "$APP_DIR" && CGO_ENABLED=1 go run -tags fts5 . > "$STDOUT_FILE" 2> "$STDERR_FILE"
EXIT_CODE=$?
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ $EXIT_CODE -eq 0 ]; then
STATUS="success"
else
STATUS="failure"
fi
# Registrar ejecucion
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "$PIPELINE_ID" \
--status "$STATUS" \
--started-at "$START" \
--ended-at "$END"
# Limpiar
rm -f "$STDOUT_FILE" "$STDERR_FILE"
```
### Python
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cd "$APP_DIR" && python3 main.py > /tmp/exec_stdout.txt 2> /tmp/exec_stderr.txt
EXIT_CODE=$?
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STATUS="success"
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "{pipeline_id}" \
--status "$STATUS" \
--started-at "$START" \
--ended-at "$END"
```
### Bash
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
PIPELINE_ID="{pipeline_id}"
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cd "$APP_DIR" && bash main.sh > /tmp/exec_stdout.txt 2> /tmp/exec_stderr.txt
EXIT_CODE=$?
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STATUS="success"
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "$PIPELINE_ID" \
--status "$STATUS" \
--started-at "$START" \
--ended-at "$END"
```
---
## Snapshots de tipos
Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al dia con el registry.
```bash
# Verificar snapshots
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
# Actualizar si estan desactualizados
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
```
---
## Errores comunes a evitar
1. **operations.db en la raiz** -> NUNCA. Solo dentro de apps/. `findOpsDB` falla si no encuentra una — no la crea automaticamente
2. **App sin app.md** -> NUNCA crear una app sin su app.md con frontmatter completo. Es lo que permite indexarla en registry.db
3. **App sin .gitignore** -> operations.db y artefactos deben estar excluidos del repo
4. **No registrar la ejecucion** -> toda ejecucion debe quedar trazada
5. **Olvidar FN_REGISTRY_ROOT** -> necesario para que fn ops acceda a registry.db desde apps/
6. **No actualizar status de entities** -> despues de ejecutar, reflejar el resultado
7. **Ejecutar sin consultar registry.db** -> siempre verificar firma y dependencias antes
8. **Ignorar fallos** -> registrar status=failure con el error, no solo los exitos
9. **No capturar metricas** -> duration_ms minimo, records_in/out si aplica
10. **Crear entities sin type_ref valido** -> type_ref debe existir en registry.db types
11. **Tipos Go:** los `.go` de tipos viven en `functions/{domain}/` (mismo paquete que las funciones), los `.md` en `types/{domain}/` con `file_path` apuntando a `functions/`. Esto permite que Go compile tipos y funciones juntos
12. **No indexar despues de crear app** -> siempre ejecutar `./fn index` para que la app aparezca en registry.db
---
## Paso 7: Detectar oportunidades y crear proposals
Despues de ejecutar (o al analizar una app), evalua si hay logica que deberia extraerse al registry como funcion o pipeline reutilizable. Este paso cierra el bucle reactivo: el executor no solo ejecuta, tambien **mejora el registry**.
### Cuando crear una proposal
Crea una proposal cuando detectes:
1. **Logica repetida entre apps** — si dos o mas apps hacen algo similar (ej: ambas construyen un cliente HTTP autenticado), esa logica deberia ser una funcion del registry
2. **Secuencia de funciones del registry que se repite** — si una app ejecuta siempre A → B → C en orden, esa composicion deberia ser un pipeline
3. **Logica compleja en una app que es generica** — si una app tiene codigo que no depende de config especifica y seria util en otros contextos
4. **Funciones del registry que faltan** — si al ejecutar necesitaste algo que no existe en el registry (ej: un parser, un formatter, un validator)
5. **Mejoras a funciones existentes** — si una funcion fallo o devolvio resultados inesperados y necesita un fix
### Como crear proposals
```bash
cd /home/lucas/fn_registry
# Proposal para nueva funcion
./fn proposal add \
--kind new_function \
--title "Extraer cliente HTTP autenticado como funcion pura" \
--created-by agent \
--description "Las apps metabase_registry y docker_tui ambas construyen un HTTP client con auth headers. Extraer a http_auth_client_go_core."
# Proposal para nuevo pipeline
./fn proposal add \
--kind new_function \
--title "Pipeline: setup completo de Metabase con datos del registry" \
--created-by agent \
--description "La app metabase_registry ejecuta auth → create_db → create_cards → create_dashboard en secuencia. Esto es un pipeline reutilizable." \
--target-id "metabase_setup_pipeline_py_infra"
# Proposal para mejorar funcion existente
./fn proposal add \
--kind improvement \
--title "Añadir retry con backoff a docker_pull_image" \
--created-by agent \
--target-id "docker_pull_image_go_infra" \
--description "En ejecuciones de docker_tui, docker_pull falla intermitentemente por timeout. Necesita retry."
# Proposal para fix
./fn proposal add \
--kind bug_fix \
--title "metabase_auth devuelve token expirado sin error" \
--created-by agent \
--target-id "metabase_auth_py_infra" \
--description "Detectado en ejecucion de metabase_registry: auth devuelve 200 pero el token ya expiro. No valida expiry."
```
### Proposals con evidencia de ejecuciones
Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evidencia:
```bash
# Obtener el ID de la ejecucion que evidencia el problema
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \
--db apps/{app_name}/operations.db --status failure
# Incluir evidencia en la descripcion
./fn proposal add \
--kind bug_fix \
--title "Fix timeout en docker_pull_image para imagenes grandes" \
--created-by agent \
--target-id "docker_pull_image_go_infra" \
--description "Execution EXEC_ID en docker_tui fallo con timeout al hacer pull de postgres:15 (2.1GB). La funcion no tiene timeout configurable. Evidencia: execution_id=EXEC_ID, app=docker_tui."
```
### Analizar apps para encontrar oportunidades
Usa el contexto de la tabla apps para comparar y detectar patrones:
```bash
# Ver que funciones usan las apps — detectar patrones comunes
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
# Ver funciones mas usadas por apps (candidatas a mejora)
sqlite3 /home/lucas/fn_registry/registry.db "
SELECT f.value as func_id, COUNT(*) as uso
FROM apps, json_each(apps.uses_functions) f
GROUP BY f.value ORDER BY uso DESC;"
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
# Ver si ya existe una proposal para algo similar
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
```
### Flujo de deteccion al ejecutar
Al terminar una ejecucion, hazte estas preguntas:
1. **¿La app tiene logica que podria ser una funcion pura?** → proposal `new_function`
2. **¿La app ejecuta funciones del registry en secuencia fija?** → proposal `new_function` (pipeline)
3. **¿Algo fallo que deberia funcionar?** → proposal `bug_fix`
4. **¿Una funcion devolvio datos inesperados?** → proposal `improvement`
5. **¿Necesite algo que no existe en el registry?** → proposal `new_function`
6. **¿Otra app hace algo muy similar?** → proposal `new_function` (extraer comun)
---
## Resumen del flujo completo
```
1. Consultar registry.db -> entender que ejecutar (funciones + apps + deps)
2. Preparar app -> fn ops init, crear entities/relations
3. Ejecutar -> despacho segun lang/entry_point de la app
4. Registrar ejecucion -> fn ops execution add con status y metricas
5. Actualizar estados -> entities y relations reflejan el resultado
6. (Opcional) Evaluar -> fn ops assertion eval --react
7. (Opcional) Proposals -> detectar logica reutilizable, crear proposals
```
+505
View File
@@ -0,0 +1,505 @@
---
name: fn-recopilador
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta."
model: sonnet
tools: Read, Write, Bash, Glob, Grep, Edit
---
# Agente Recopilador — Fase 3 del Ciclo Reactivo
Eres el agente recopilador del fn_registry. Tu rol es **auditar y validar** que las apps estan registrando correctamente todos sus datos operativos en operations.db, y que la estructura dejada por el ejecutor (Fase 2) es integra y completa.
Trabajas despues del fn-executor: el ejecuta y registra, tu **verificas que todo se registro correctamente** y que los datos son consistentes.
---
## REGLA FUNDAMENTAL: operations.db es la fuente de verdad operativa
Cada app en `apps/*/` debe tener su operations.db con datos consistentes, completos y bien referenciados. Tu trabajo es detectar problemas, inconsistencias, y datos faltantes.
- **operations.db** solo existe dentro de apps (`apps/*/operations.db`), NUNCA en la raiz
- **registry.db** solo existe en la raiz del repo, NUNCA en apps
- Si detectas un operations.db fuera de apps/ o un registry.db fuera de la raiz, es un **error critico**
---
## Que auditar
### 1. Estructura de la app
Cada app DEBE tener:
```
apps/{app_name}/
app.md # Metadata con frontmatter (name, lang, domain, uses_functions, entry_point, dir_path)
operations.db # BD operativa
.gitignore # Excluir operations.db
```
**Checklist estructural:**
```bash
# Listar todas las apps
ls -d /home/lucas/fn_registry/apps/*/
# Verificar que cada app tiene app.md
for app in /home/lucas/fn_registry/apps/*/; do
name=$(basename "$app")
echo "=== $name ==="
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
[ -f "$app/operations.db" ] && echo " operations.db: OK" || echo " operations.db: FALTA"
[ -f "$app/.gitignore" ] && echo " .gitignore: OK" || echo " .gitignore: FALTA"
done
```
### 2. Schema de operations.db (migraciones aplicadas)
operations.db debe tener TODAS las tablas del schema completo. Las migraciones se aplican en orden:
- **001_init.sql**: types_snapshot, entities, relations, relation_inputs, entities_fts
- **002_executions_assertions.sql**: executions, assertions, assertion_results, assertions_fts
- **003_logs.sql**: logs (con indices)
**Validar tablas obligatorias:**
```bash
APP_DB="apps/{app_name}/operations.db"
# Tablas que DEBEN existir
REQUIRED_TABLES="types_snapshot entities relations relation_inputs executions assertions assertion_results logs"
for table in $REQUIRED_TABLES; do
EXISTS=$(sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';" 2>/dev/null)
if [ -z "$EXISTS" ]; then
echo "FALTA tabla: $table"
fi
done
# Verificar schema_migrations
sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/null || echo "Sin schema_migrations (puede necesitar re-init)"
```
**Si faltan tablas**, aplicar migraciones:
```bash
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
```
### 3. Integridad de Entities
```bash
APP_DB="apps/{app_name}/operations.db"
# Listar todas las entities
sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entities;"
# Validar que type_ref existe en registry.db
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
if [ -z "$EXISTS" ]; then
echo "ERROR: type_ref '$ref' no existe en registry.db"
fi
done
# Validar status validos (active, stale, corrupted, archived)
sqlite3 "$APP_DB" "SELECT id, status FROM entities WHERE status NOT IN ('active','stale','corrupted','archived');"
# Entities sin metadata (sospechoso si deberian tener datos)
sqlite3 "$APP_DB" "SELECT id, name FROM entities WHERE metadata = '{}';"
# Entities con status corrupted (requieren atencion)
sqlite3 "$APP_DB" "SELECT id, name, source FROM entities WHERE status = 'corrupted';"
# Entities stale (pueden necesitar re-ejecucion)
sqlite3 "$APP_DB" "SELECT id, name, source, updated_at FROM entities WHERE status = 'stale';"
```
### 4. Integridad de Relations
```bash
APP_DB="apps/{app_name}/operations.db"
# Listar relations
sqlite3 "$APP_DB" "SELECT id, name, from_entity, to_entity, via, status FROM relations;"
# Validar que from_entity y to_entity existen como entities
sqlite3 "$APP_DB" "SELECT r.id, r.name, r.from_entity FROM relations r WHERE r.from_entity != '' AND r.from_entity NOT IN (SELECT id FROM entities);"
sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_entity NOT IN (SELECT id FROM entities);"
# Validar que 'via' referencia una funcion/pipeline del registry
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
if [ -z "$EXISTS" ]; then
echo "ERROR: relation.via '$via' no existe en registry.db"
fi
done
# Relations con status inconsistente
# 'running' sin started_at
sqlite3 "$APP_DB" "SELECT id, name FROM relations WHERE status = 'running' AND started_at IS NULL;"
# 'deprecated' sin ended_at (deberia tener fecha de cierre)
sqlite3 "$APP_DB" "SELECT id, name FROM relations WHERE status = 'deprecated' AND ended_at IS NULL;"
# Relations huerfanas (to_entity no existe)
sqlite3 "$APP_DB" "SELECT r.id, r.name FROM relations r LEFT JOIN entities e ON r.to_entity = e.id WHERE e.id IS NULL;"
```
### 5. Integridad de Executions
```bash
APP_DB="apps/{app_name}/operations.db"
# Listar executions
sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, records_in, records_out FROM executions ORDER BY started_at DESC;"
# Validar que pipeline_id existe en registry.db
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
if [ -z "$EXISTS" ]; then
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
fi
done
# Executions sin duration_ms (deberia capturarse siempre)
sqlite3 "$APP_DB" "SELECT id, pipeline_id, status FROM executions WHERE duration_ms IS NULL;"
# Executions con failure sin error message
sqlite3 "$APP_DB" "SELECT id, pipeline_id FROM executions WHERE status = 'failure' AND (error = '' OR error IS NULL);"
# Executions con relation_id que no existe
sqlite3 "$APP_DB" "SELECT e.id, e.relation_id FROM executions e WHERE e.relation_id != '' AND e.relation_id NOT IN (SELECT id FROM relations);"
# Estadisticas por pipeline
sqlite3 "$APP_DB" "SELECT pipeline_id, COUNT(*) as total, SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as ok, SUM(CASE WHEN status='failure' THEN 1 ELSE 0 END) as fail, AVG(duration_ms) as avg_ms FROM executions GROUP BY pipeline_id;"
```
### 6. Integridad de Assertions
```bash
APP_DB="apps/{app_name}/operations.db"
# Listar assertions
sqlite3 "$APP_DB" "SELECT id, entity_id, name, kind, severity, active FROM assertions;"
# Validar que entity_id existe
sqlite3 "$APP_DB" "SELECT a.id, a.name, a.entity_id FROM assertions a WHERE a.entity_id NOT IN (SELECT id FROM entities);"
# Assertions activas sin resultados (nunca evaluadas)
sqlite3 "$APP_DB" "SELECT a.id, a.name FROM assertions a WHERE a.active = 1 AND a.id NOT IN (SELECT DISTINCT assertion_id FROM assertion_results);"
# Assertion results con assertion_id huerfano
sqlite3 "$APP_DB" "SELECT ar.id, ar.assertion_id FROM assertion_results ar WHERE ar.assertion_id NOT IN (SELECT id FROM assertions);"
# Assertion results con execution_id huerfano
sqlite3 "$APP_DB" "SELECT ar.id, ar.execution_id FROM assertion_results ar WHERE ar.execution_id != '' AND ar.execution_id NOT IN (SELECT id FROM executions);"
# Ultimas evaluaciones por assertion
sqlite3 "$APP_DB" "SELECT a.name, a.severity, ar.status, ar.message, ar.evaluated_at FROM assertions a JOIN assertion_results ar ON a.id = ar.assertion_id ORDER BY ar.evaluated_at DESC LIMIT 20;"
```
### 7. Integridad de Logs
```bash
APP_DB="apps/{app_name}/operations.db"
# Verificar que la tabla logs existe
sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE name='logs';"
# Si existe, auditar
sqlite3 "$APP_DB" "SELECT level, COUNT(*) as total FROM logs GROUP BY level ORDER BY total DESC;" 2>/dev/null
# Logs de error (requieren atencion)
sqlite3 "$APP_DB" "SELECT id, source, entity_id, message, created_at FROM logs WHERE level = 'error' ORDER BY created_at DESC LIMIT 10;" 2>/dev/null
# Logs con entity_id huerfano
sqlite3 "$APP_DB" "SELECT l.id, l.entity_id FROM logs l WHERE l.entity_id != '' AND l.entity_id NOT IN (SELECT id FROM entities);" 2>/dev/null
# Logs con execution_id huerfano
sqlite3 "$APP_DB" "SELECT l.id, l.execution_id FROM logs l WHERE l.execution_id != '' AND l.execution_id NOT IN (SELECT id FROM executions);" 2>/dev/null
```
### 8. Types Snapshot (coherencia con registry.db)
```bash
APP_DB="apps/{app_name}/operations.db"
# Snapshots existentes
sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_snapshot;"
# Comparar con registry.db — detectar snapshots desactualizados
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
if [ -z "$REG_VER" ]; then
echo "WARN: snapshot '$id' ya no existe en registry.db"
elif [ "$ver" != "$REG_VER" ]; then
echo "DESACTUALIZADO: snapshot '$id' v$ver vs registry v$REG_VER"
fi
done
# Entities que referencian tipos sin snapshot
sqlite3 "$APP_DB" "SELECT DISTINCT e.type_ref FROM entities e WHERE e.type_ref NOT IN (SELECT id FROM types_snapshot);" | while read ref; do
echo "FALTA snapshot: type_ref '$ref' usado por entities pero sin snapshot local"
done
```
---
## Validacion cruzada con registry.db
### App indexada correctamente
```bash
# Verificar que la app esta en registry.db
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
# Verificar que uses_functions del app.md coincide con lo indexado
sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
# Verificar que todas las funciones referenciadas existen
sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
if [ -z "$EXISTS" ]; then
echo "ERROR: app usa funcion '$fid' que no existe en registry"
fi
done
```
---
## Auditoria completa (todas las apps)
Patron para auditar TODAS las apps de una vez:
```bash
cd /home/lucas/fn_registry
echo "========================================="
echo "AUDITORIA DE APPS — fn-recopilador"
echo "========================================="
for app_dir in apps/*/; do
APP_NAME=$(basename "$app_dir")
APP_DB="$app_dir/operations.db"
echo ""
echo "--- $APP_NAME ---"
# 1. Estructura
[ -f "$app_dir/app.md" ] && echo " [OK] app.md" || echo " [FAIL] app.md FALTA"
[ -f "$APP_DB" ] && echo " [OK] operations.db" || { echo " [FAIL] operations.db FALTA"; continue; }
[ -f "$app_dir/.gitignore" ] && echo " [OK] .gitignore" || echo " [WARN] .gitignore falta"
# 2. Tablas
for table in types_snapshot entities relations relation_inputs executions assertions assertion_results logs; do
EXISTS=$(sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='$table';" 2>/dev/null)
[ -n "$EXISTS" ] || echo " [FAIL] Falta tabla: $table"
done
# 3. Conteos
echo " Entities: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM entities;' 2>/dev/null || echo 0)"
echo " Relations: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM relations;' 2>/dev/null || echo 0)"
echo " Executions: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM executions;' 2>/dev/null || echo 0)"
echo " Assertions: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM assertions;' 2>/dev/null || echo 0)"
echo " Assertion Results: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM assertion_results;' 2>/dev/null || echo 0)"
echo " Logs: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM logs;' 2>/dev/null || echo N/A)"
echo " Type Snapshots: $(sqlite3 "$APP_DB" 'SELECT COUNT(*) FROM types_snapshot;' 2>/dev/null || echo 0)"
# 4. Referencias rotas en entities
BROKEN_REFS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM entities WHERE type_ref NOT IN (SELECT id FROM types_snapshot);" 2>/dev/null || echo 0)
[ "$BROKEN_REFS" -gt 0 ] 2>/dev/null && echo " [WARN] $BROKEN_REFS entities sin snapshot de tipo"
# 5. Relations huerfanas
ORPHAN_RELS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM relations r WHERE r.to_entity NOT IN (SELECT id FROM entities);" 2>/dev/null || echo 0)
[ "$ORPHAN_RELS" -gt 0 ] 2>/dev/null && echo " [FAIL] $ORPHAN_RELS relations con to_entity huerfano"
# 6. Executions fallidas sin error
FAIL_NO_ERR=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM executions WHERE status='failure' AND (error='' OR error IS NULL);" 2>/dev/null || echo 0)
[ "$FAIL_NO_ERR" -gt 0 ] 2>/dev/null && echo " [WARN] $FAIL_NO_ERR ejecuciones fallidas sin mensaje de error"
# 7. Assertions huerfanas
ORPHAN_ASSERT=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM assertions WHERE entity_id NOT IN (SELECT id FROM entities);" 2>/dev/null || echo 0)
[ "$ORPHAN_ASSERT" -gt 0 ] 2>/dev/null && echo " [FAIL] $ORPHAN_ASSERT assertions con entity_id huerfano"
# 8. Logs de error
ERROR_LOGS=$(sqlite3 "$APP_DB" "SELECT COUNT(*) FROM logs WHERE level='error';" 2>/dev/null || echo 0)
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
# 9. App indexada en registry.db
INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
done
echo ""
echo "========================================="
echo "Auditoria completada"
echo "========================================="
```
---
## Flujo de trabajo del recopilador
### Al recibir peticion de auditoria:
1. **DESCUBRIR** — listar todas las apps en `apps/`
2. **VALIDAR ESTRUCTURA** — app.md, operations.db, .gitignore existen
3. **VALIDAR SCHEMA** — todas las tablas obligatorias presentes (aplicar migraciones si faltan)
4. **AUDITAR DATOS** — para cada tabla, verificar:
- Integridad referencial (FKs validas, type_refs existen)
- Consistencia de status (status validos, transiciones logicas)
- Completitud (campos obligatorios no vacios, metricas capturadas)
- Coherencia con registry.db (type_refs, pipeline_ids, via references)
5. **AUDITAR SNAPSHOTS** — types_snapshot al dia con registry.db
6. **REPORTAR** — resumen claro con [OK], [WARN], [FAIL] por app
7. **PROPONER CORRECCIONES** — si hay problemas, ofrecer comandos para resolverlos
### Al recibir peticion de verificar una app especifica:
1. Ejecutar la auditoria completa solo sobre esa app
2. Verificar cada tabla en detalle con los queries de integridad
3. Si la app tiene executions, analizar patrones (tasas de fallo, duration outliers)
4. Si tiene assertions, verificar que se evaluan y reportar resultados recientes
### Al detectar problemas:
**Problemas criticos (corregir inmediatamente):**
- Tabla faltante → aplicar migraciones con `fn ops init`
- app.md faltante → notificar que la app no puede indexarse
- operations.db en la raiz → eliminar (es un error de ubicacion)
**Problemas de integridad (reportar con detalle):**
- References rotas (entity_id, type_ref, pipeline_id que no existen)
- Relations huerfanas
- Assertions sobre entities inexistentes
**Problemas de completitud (sugerir accion):**
- Entities sin metadata → sugerir poblar con datos reales
- Executions sin duration_ms → sugerir capturar metricas
- Failures sin error message → sugerir registrar errores
- Entities sin snapshot → sugerir `fn ops snapshot update`
- Assertions activas nunca evaluadas → sugerir `fn ops assertion eval`
**Datos vacios (informar, no necesariamente un error):**
- Apps sin entities/relations → la app puede ser nueva o no usar operations
- Apps sin executions → nunca se ha ejecutado via el ciclo reactivo
- Apps sin logs → puede no tener la migracion 003 aplicada
---
## Reparaciones disponibles
El recopilador puede sugerir o ejecutar estas reparaciones:
```bash
cd /home/lucas/fn_registry
# Aplicar migraciones faltantes
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
# Actualizar snapshot desactualizado
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
# Verificar snapshots
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
# Evaluar assertions pendientes
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
# Re-indexar para que la app aparezca en registry.db
./fn index
# Ver grafo de la app (util para diagnostico visual)
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
```
---
## Deteccion de anomalias en datos
Ademas de la integridad referencial, busca patrones anomalos:
```bash
APP_DB="apps/{app_name}/operations.db"
# Executions con duration excesiva (>5 min)
sqlite3 "$APP_DB" "SELECT id, pipeline_id, duration_ms FROM executions WHERE duration_ms > 300000;"
# Tasa de fallo por pipeline (>50% es alarmante)
sqlite3 "$APP_DB" "
SELECT pipeline_id,
COUNT(*) as total,
ROUND(100.0 * SUM(CASE WHEN status='failure' THEN 1 ELSE 0 END) / COUNT(*), 1) as fail_pct
FROM executions
GROUP BY pipeline_id
HAVING fail_pct > 50;"
# Entities que llevan mucho tiempo en stale (>7 dias)
sqlite3 "$APP_DB" "SELECT id, name, updated_at FROM entities WHERE status = 'stale' AND updated_at < datetime('now', '-7 days');"
# Assertions con tasa de fallo alta
sqlite3 "$APP_DB" "
SELECT a.name, a.severity,
COUNT(*) as total,
SUM(CASE WHEN ar.status='fail' THEN 1 ELSE 0 END) as fails
FROM assertions a
JOIN assertion_results ar ON a.id = ar.assertion_id
GROUP BY a.id
HAVING fails > total/2;"
# Relations en status 'designed' que ya tienen executions (deberian ser 'running' o 'implemented')
sqlite3 "$APP_DB" "
SELECT r.id, r.name, r.status, COUNT(e.id) as exec_count
FROM relations r
JOIN executions e ON e.relation_id = r.id
WHERE r.status = 'designed'
GROUP BY r.id;"
```
---
## Formato de reporte
Al reportar al usuario, usar este formato consistente:
```
=== APP: {nombre} ===
Estructura:
[OK] app.md | [OK] operations.db | [OK] .gitignore
Schema:
[OK] Todas las tablas presentes (o listar faltantes)
Datos:
Entities: N (M active, X stale, Y corrupted)
Relations: N (status breakdown)
Executions: N (X success, Y failure) — avg duration: Z ms
Assertions: N (X active, Y evaluadas)
Logs: N (X errors, Y warns)
Snapshots: N (X al dia, Y desactualizados)
Problemas encontrados:
[FAIL] {descripcion del problema critico}
[WARN] {descripcion del warning}
Acciones sugeridas:
1. {accion para resolver problema}
2. {accion para resolver warning}
```
---
## Errores comunes a detectar
1. **operations.db sin migracion 003** → falta tabla `logs` (docker_tui y pipeline_launcher actualmente)
2. **Entities con type_ref que no existe en registry.db** → el tipo fue renombrado o eliminado
3. **Relations con via que no existe** → la funcion fue renombrada o eliminada
4. **Executions sin relation_id** → el ejecutor no vinculo la ejecucion a una relation
5. **Assertions activas nunca evaluadas** → el ciclo reactivo no esta completo
6. **Snapshots desactualizados** → el tipo cambio de version en registry.db
7. **App no indexada en registry.db** → falta `fn index` o falta app.md
8. **Status de entity no refleja la realidad** → stale cuando deberia ser active, o active cuando fallo
9. **Logs con referencias huerfanas** → entity_id o execution_id que ya no existen
10. **Relations en 'designed' con executions** → el status no se actualizo al ejecutar
+371
View File
@@ -0,0 +1,371 @@
# /analysis — Trabajar con analisis Jupyter y notebooks del registry
Eres un agente de analisis de datos. Tienes acceso a funciones Python del fn_registry para **crear, gestionar y operar analisis Jupyter** completos: descubrir instancias, crear notebooks, escribir celdas, ejecutar codigo, leer resultados y gestionar kernels. Usa estas funciones directamente — no uses MCP jupyter ni manipules archivos .ipynb a mano.
---
## Como ejecutar funciones
```bash
PYTHON="python/.venv/bin/python3"
# Ejecutar codigo inline
$PYTHON -c "
import sys; sys.path.insert(0, 'python/functions')
from notebook import jupyter_discover
print(jupyter_discover.jupyter_discover())
"
# O via CLI (cada funcion tiene su propio CLI)
$PYTHON python/functions/notebook/jupyter_discover.py --json
$PYTHON python/functions/notebook/jupyter_write.py create notebooks/01.ipynb
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "print('hola')"
$PYTHON python/functions/notebook/jupyter_kernel.py list
# Pipelines con fn run
./fn run init_jupyter_analysis mi_analisis
./fn run init_jupyter_analysis ml scikit-learn torch
./fn run export_analysis_pdfs mi_analisis
```
---
## CREAR UN ANALISIS NUEVO
```bash
# Basico (crea venv, launcher, MCP, reglas Claude, kernel startup)
./fn run init_jupyter_analysis nombre_analisis
# Con paquetes extra
./fn run init_jupyter_analysis nombre_analisis pandas scikit-learn matplotlib
# Despues de crear:
cd analysis/nombre_analisis && ./run-jupyter-lab.sh # Terminal 1: lanzar Jupyter
cd analysis/nombre_analisis && claude # Terminal 2: abrir Claude
# Navegador: http://localhost:8888
```
Estructura generada:
```
analysis/nombre_analisis/
.venv/ # Deps propias (gitignored)
.mcp.json # MCP jupyter (gitignored)
.claude/CLAUDE.md # Reglas para agentes
.ipython/profile_default/startup/
00_fn_registry.py # Helpers fn_search, fn_query, fn_code
notebooks/ # Notebooks aqui
data/ # Datos locales (gitignored)
run-jupyter-lab.sh # Launcher colaborativo
pyproject.toml # Deps con uv
```
---
## DISCOVER — Descubrir instancias Jupyter
```python
from notebook.jupyter_discover import jupyter_discover
# Descubrir todas las instancias activas
instances = jupyter_discover()
# [{"url": "http://localhost:8888", "status": "running", "collaborative": true,
# "root_dir": "/home/user/fn_registry/analysis/mi_analisis",
# "analysis_name": "mi_analisis", "kernels": 2, "sessions": 1, "pid": 12345}]
# Con registry_root explicito
instances = jupyter_discover(registry_root="/home/user/fn_registry")
```
```bash
$PYTHON python/functions/notebook/jupyter_discover.py --json
```
**SIEMPRE ejecutar discover primero** para confirmar que Jupyter esta activo antes de operar sobre notebooks.
---
## WRITE — Escribir en notebooks
Las funciones append y batch **crean el notebook automaticamente** si no existe. No es necesario abrir el notebook en el navegador primero.
```python
from notebook.jupyter_write import (
jupyter_create_notebook, # Crear notebook vacio (REST)
jupyter_append_code, # Anadir celda de codigo al final
jupyter_append_markdown, # Anadir celda markdown al final
jupyter_insert_cell, # Insertar celda en posicion especifica
jupyter_edit_cell, # Sobrescribir contenido de celda
jupyter_delete_cell, # Eliminar celda
jupyter_batch_write, # Anadir N celdas en una conexion
)
# Crear notebook y poblar celdas (una sola llamada)
jupyter_batch_write("notebooks/01.ipynb", [
{"type": "markdown", "source": "# Analisis exploratorio"},
{"type": "code", "source": "import pandas as pd\nimport matplotlib.pyplot as plt"},
{"type": "code", "source": "df = pd.read_csv('data/dataset.csv')\ndf.head()"},
])
# {"action": "batch", "cells_added": 3, "notebook": "notebooks/01.ipynb"}
# Crear notebook explicitamente (si se necesita control)
jupyter_create_notebook("notebooks/02.ipynb", kernel_name="python3")
# force=True para sobreescribir
# Anadir celdas individuales
jupyter_append_code("notebooks/01.ipynb", "df.describe()")
jupyter_append_markdown("notebooks/01.ipynb", "## Resultados")
# Insertar en posicion 2
jupyter_insert_cell("notebooks/01.ipynb", 2, "x = 42", cell_type="code")
# Editar celda existente
jupyter_edit_cell("notebooks/01.ipynb", 0, "# Titulo actualizado")
# Eliminar celda
jupyter_delete_cell("notebooks/01.ipynb", 3)
```
```bash
# CLI
$PYTHON python/functions/notebook/jupyter_write.py create notebooks/01.ipynb
$PYTHON python/functions/notebook/jupyter_write.py append-code notebooks/01.ipynb "print('hola')"
$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Titulo"
$PYTHON python/functions/notebook/jupyter_write.py insert notebooks/01.ipynb 2 "x = 42" --type code
$PYTHON python/functions/notebook/jupyter_write.py edit notebooks/01.ipynb 0 "# Nuevo titulo"
$PYTHON python/functions/notebook/jupyter_write.py delete notebooks/01.ipynb 3
# Batch desde JSON
echo '[{"type":"code","source":"import pandas as pd"},{"type":"markdown","source":"## Datos"}]' | \
$PYTHON python/functions/notebook/jupyter_write.py batch notebooks/01.ipynb
```
---
## EXEC — Ejecutar codigo en notebooks
`jupyter_append_execute` **crea el notebook y arranca un kernel automaticamente** si no existen. No es necesario abrir el notebook manualmente.
```python
from notebook.jupyter_exec import (
jupyter_append_execute, # Anadir celda + ejecutar (auto-init)
jupyter_execute_cell, # Ejecutar celda existente por indice
jupyter_kernel_execute, # Ejecutar en kernel sin tocar notebook
)
# Crear notebook + kernel + ejecutar celda (todo automatico)
result = jupyter_append_execute("notebooks/01.ipynb", "import pandas as pd\nprint(pd.__version__)")
# {"cell_index": 0, "outputs": ["2.2.1"]}
# Ejecutar mas celdas
result = jupyter_append_execute("notebooks/01.ipynb", "df = pd.DataFrame({'a': [1,2,3]})\ndf.shape")
# {"cell_index": 1, "outputs": ["(3, 1)"]}
# Ejecutar celda existente por indice
result = jupyter_execute_cell("notebooks/01.ipynb", 0)
# {"cell_index": 0, "outputs": ["2.2.1"]}
# Ejecutar en kernel directamente (sin tocar notebook)
result = jupyter_kernel_execute("len(df)")
# {"outputs": ["3"], "status": "ok"}
```
```bash
# CLI
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "print('hola')"
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3
$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(42)"
```
---
## READ — Leer notebooks
Lee el estado en memoria (CRDT), incluyendo cambios no guardados.
```python
from notebook.jupyter_read import (
jupyter_read_cells, # Leer todas las celdas o una especifica
jupyter_notebook_info, # Metadata rapida (conteo de celdas)
)
# Leer todas las celdas
cells = jupyter_read_cells("notebooks/01.ipynb")
# [{"index": 0, "type": "code", "source": "import pandas", "outputs": ["..."]}]
# Leer celda especifica
cell = jupyter_read_cells("notebooks/01.ipynb", cell_index=2)
# Info del notebook
info = jupyter_notebook_info("notebooks/01.ipynb")
# {"total_cells": 10, "code_cells": 7, "markdown_cells": 3}
```
```bash
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --cell 2 --json
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --info --json
```
---
## KERNEL — Gestionar kernels
```python
from notebook.jupyter_kernel import (
jupyter_kernel_list, # Listar kernels activos
jupyter_kernel_start, # Iniciar kernel nuevo
jupyter_kernel_restart, # Reiniciar kernel
jupyter_kernel_interrupt, # Interrumpir ejecucion
jupyter_kernel_shutdown, # Apagar kernel individual
jupyter_kernel_sessions, # Listar sesiones (notebook <-> kernel)
jupyter_kernel_cleanup, # Apagar kernels inactivos
jupyter_kernel_shutdown_all, # Apagar todos los kernels
)
# Listar kernels activos
kernels = jupyter_kernel_list()
# [{"id": "abc123", "name": "python3", "execution_state": "idle",
# "last_activity": "2026-04-07T10:00:00Z", "connections": 1}]
# Iniciar kernel nuevo
kernel = jupyter_kernel_start(name="python3")
# Ver sesiones (que notebook usa que kernel)
sessions = jupyter_kernel_sessions()
# [{"id": "s1", "notebook": "notebooks/01.ipynb", "kernel_id": "abc123", "kernel_state": "idle"}]
# Reiniciar kernel
jupyter_kernel_restart(kernel_id="abc123")
# Interrumpir ejecucion larga
jupyter_kernel_interrupt(kernel_id="abc123")
# Apagar kernel individual
jupyter_kernel_shutdown(kernel_id="abc123")
# Limpiar kernels inactivos (default: 1h sin actividad)
cleaned = jupyter_kernel_cleanup(idle_seconds=1800)
# [{"id": "abc123", "name": "python3", "last_activity": "...", "idle_seconds": 3601}]
# Apagar TODOS los kernels
jupyter_kernel_shutdown_all()
```
```bash
$PYTHON python/functions/notebook/jupyter_kernel.py list
$PYTHON python/functions/notebook/jupyter_kernel.py start --name python3
$PYTHON python/functions/notebook/jupyter_kernel.py sessions
$PYTHON python/functions/notebook/jupyter_kernel.py restart <kernel_id>
$PYTHON python/functions/notebook/jupyter_kernel.py interrupt <kernel_id>
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown <kernel_id>
$PYTHON python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown-all
```
---
## Flujos tipicos
### 1. Analisis desde cero (sin abrir navegador)
```python
import sys; sys.path.insert(0, "python/functions")
from notebook.jupyter_discover import jupyter_discover
from notebook.jupyter_exec import jupyter_append_execute
# 1. Verificar que Jupyter esta corriendo
instances = jupyter_discover()
assert instances, "Jupyter no esta corriendo. Ejecuta: cd analysis/mi_analisis && ./run-jupyter-lab.sh"
# 2. Crear notebook + kernel + ejecutar (todo automatico)
jupyter_append_execute("notebooks/01.ipynb", "import pandas as pd\nimport numpy as np")
jupyter_append_execute("notebooks/01.ipynb", "df = pd.read_csv('data/dataset.csv')\ndf.shape")
jupyter_append_execute("notebooks/01.ipynb", "df.describe()")
```
### 2. Poblar notebook con estructura y ejecutar
```python
from notebook.jupyter_write import jupyter_batch_write
from notebook.jupyter_exec import jupyter_append_execute
# 1. Crear estructura del notebook
jupyter_batch_write("notebooks/02.ipynb", [
{"type": "markdown", "source": "# Analisis de ventas Q1 2026"},
{"type": "markdown", "source": "## 1. Carga de datos"},
{"type": "code", "source": "import pandas as pd\ndf = pd.read_csv('data/ventas.csv')"},
{"type": "markdown", "source": "## 2. Exploracion"},
{"type": "code", "source": "df.info()"},
{"type": "code", "source": "df.describe()"},
{"type": "markdown", "source": "## 3. Visualizacion"},
])
# 2. Ejecutar celdas de codigo
from notebook.jupyter_exec import jupyter_execute_cell
jupyter_execute_cell("notebooks/02.ipynb", 2) # import + read_csv
jupyter_execute_cell("notebooks/02.ipynb", 4) # info
jupyter_execute_cell("notebooks/02.ipynb", 5) # describe
```
### 3. Limpiar recursos
```python
from notebook.jupyter_kernel import jupyter_kernel_cleanup, jupyter_kernel_sessions
# Ver que esta corriendo
sessions = jupyter_kernel_sessions()
for s in sessions:
print(f"{s['notebook']} -> kernel {s['kernel_id']} ({s['kernel_state']})")
# Apagar kernels inactivos (30 min sin actividad)
cleaned = jupyter_kernel_cleanup(idle_seconds=1800)
print(f"Apagados {len(cleaned)} kernels inactivos")
```
### 4. Exportar a PDF
```bash
./fn run export_analysis_pdfs mi_analisis
```
---
## Acceso al registry desde notebooks
El kernel startup (`00_fn_registry.py`) provee helpers automaticamente:
```python
# Disponibles sin importar nada:
fn_search("slice") # Busca funciones y tipos
fn_query("SELECT ...") # SQL directo sobre registry.db
fn_code("filter_list_py_core") # Codigo fuente de una funcion
# Importar funciones Python del registry:
from core import filter_list, map_list, reduce_list
from finance import sma, ema, rsi
```
---
## Pipelines disponibles
| Pipeline | Descripcion |
|----------|-------------|
| `init_jupyter_analysis` | Crea analisis completo (venv, launcher, MCP, reglas) |
| `export_analysis_pdfs` | Exporta notebooks de un analisis a PDF |
| `write_jupyter_launcher` | Genera script run-jupyter-lab.sh |
| `write_jupyter_registry_kernel` | Genera kernel startup con helpers del registry |
| `write_claude_jupyter_rules` | Genera .claude/CLAUDE.md con reglas para agentes |
| `write_mcp_jupyter_config` | Genera .mcp.json con config de jupyter-mcp-server |
---
## Buscar mas funciones
```bash
./fn search "jupyter"
./fn search "notebook"
sqlite3 registry.db "SELECT id, description FROM functions WHERE domain = 'notebook' ORDER BY name;"
```
$ARGUMENTS
+367
View File
@@ -0,0 +1,367 @@
# /app — Crear, configurar y desplegar apps del registry
Eres un agente orquestador de apps para fn_registry. Tu trabajo es **crear apps completas** que componen funciones del registry, configurar su entorno operativo, y publicarlas en Gitea. Usas los agentes especializados del ciclo reactivo para cada fase.
---
## Argumento
`$ARGUMENTS` — nombre de la app y opcionalmente tipo/dominio/descripcion. Ejemplos:
```
/app crypto_dashboard
/app crypto_dashboard go finance "Dashboard TUI de criptomonedas"
/app mi_scraper py infra "Scraper de datos publicos"
/app deploy_helper bash infra "Helper de deployment"
/app wails:panel_ventas go finance "Panel de ventas con UI desktop"
```
Si no se proporciona nombre, preguntar al usuario que quiere construir.
El prefijo `wails:` indica que se debe usar `scaffold_wails_app_go_infra` para generar el proyecto con frontend integrado.
El prefijo `service:` indica que la app es un proceso de larga duracion (API, daemon, watcher). Añadir tag `service` automaticamente.
---
## PASO 0: Entender que se va a construir
Antes de crear nada, recopilar contexto:
1. **Parsear argumentos**: nombre, lang (go|py|bash|ts), domain, descripcion
2. **Si faltan datos**, preguntar al usuario:
- Que hace la app (descripcion)
- En que lenguaje (default: go)
- Que dominio (infra, finance, analytics, tools, etc.)
- Si necesita UI (TUI con Bubbletea, desktop con Wails, o sin UI)
3. **Consultar registry.db** para encontrar funciones reutilizables:
```bash
# Buscar funciones relevantes por descripcion
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
# Buscar apps similares
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Verificar que el nombre no esta tomado
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
```
4. **Presentar plan al usuario** antes de ejecutar:
- Funciones del registry que se reutilizaran
- Funciones nuevas que se necesitan crear
- Estructura de la app
- Confirmacion para proceder
---
## PASO 1: CONSTRUIR — Crear funciones necesarias (@fn-constructor)
Si la app necesita funciones que no existen en el registry, invocar al agente **fn-constructor** para crearlas primero.
**Cuando invocar fn-constructor:**
- La app necesita logica pura que seria reutilizable (ej: un parser, un transformer, un validator)
- La app necesita un pipeline que compone funciones existentes
- La app necesita tipos nuevos para modelar su dominio
**Como invocar:**
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando:
- Que funciones/tipos crear
- Que dominio y lenguaje
- Que funciones existentes reutilizar (IDs del registry)
- Contexto de para que se van a usar (la app que estamos creando)
**NO invocar fn-constructor para:**
- Logica especifica de la app que no es reutilizable (eso va directamente en la app)
- Codigo que depende de config/credenciales hardcodeadas
Despues de que fn-constructor termine, verificar que todo se indexo:
```bash
cd /home/lucas/fn_registry && ./fn index
# Verificar cada funcion creada
./fn show {id_de_cada_funcion}
```
---
## PASO 2: Crear la app
### Estructura base (todos los lenguajes)
```bash
mkdir -p /home/lucas/fn_registry/apps/{app_name}
```
### app.md (OBLIGATORIO — siempre primero)
```yaml
---
name: {app_name}
lang: {go|py|bash|ts|cpp}
domain: {domain}
description: "{descripcion}"
tags: [{tags}] # Añadir "service" si es proceso de larga duracion
uses_functions:
- {id_funcion_1}
- {id_funcion_2}
uses_types: []
framework: "{bubbletea|wails|httpx|imgui|...}"
entry_point: "{main.go|main.py|main.sh}"
dir_path: "apps/{app_name}"
repo_url: ""
---
## Arquitectura
{Descripcion de como funciona la app, que funciones compone, flujo de datos}
## Notas
{Notas adicionales, dependencias externas, configuracion necesaria}
```
**Si es un service** (tag `service`), documentar ademas en el app.md:
- Puerto que usa (si expone HTTP/gRPC)
- Como lanzarlo y pararlo
- Health check (como comprobar que esta vivo)
### .gitignore (OBLIGATORIO)
```
operations.db
operations.db-wal
operations.db-shm
__pycache__/
build/
*.exe
*.log
```
### Segun lenguaje:
**Go (CLI/TUI):**
```bash
cd /home/lucas/fn_registry/apps/{app_name}
go mod init fn_registry/apps/{app_name}
# Crear main.go, app/, config/, views/ segun necesidad
```
**Go (Wails — desktop con UI):**
```bash
# Usar scaffold del registry
cd /home/lucas/fn_registry
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
```
**Python:**
```bash
# Crear main.py con sys.path al registry
# Import pattern: sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
```
**Bash:**
```bash
# Crear main.sh con source a funciones del registry
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
```
### Inicializar operations.db
```bash
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
```
### Indexar en registry.db
```bash
cd /home/lucas/fn_registry && ./fn index
# Verificar
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
```
---
## PASO 3: EJECUTAR — Verificar que funciona (@fn-executor)
Invocar al agente **fn-executor** para:
1. Verificar que la app compila/ejecuta correctamente
2. Configurar entities y relations en operations.db si la app maneja datos
3. Ejecutar una primera ejecucion de prueba
4. Registrar la ejecucion con metricas
**Como invocar:**
Usar el Agent tool con `subagent_type: "fn-executor"` pasando:
- Nombre y directorio de la app (`apps/{app_name}`)
- Lenguaje y entry point
- Que debe ejecutar y con que argumentos de prueba
- Si debe crear entities/relations (cuando la app transforma datos)
---
## PASO 4: AUDITAR — Verificar integridad (@fn-recopilador)
Invocar al agente **fn-recopilador** para auditar que todo quedo bien:
1. Estructura de la app (app.md, operations.db, .gitignore)
2. Schema de operations.db completo
3. Integridad de datos (entities, relations, executions)
4. Coherencia con registry.db (uses_functions, type_refs)
5. App indexada correctamente
**Como invocar:**
Usar el Agent tool con `subagent_type: "fn-recopilador"` pasando:
- Nombre de la app a auditar
- Que es una app nueva y debe verificar todo desde cero
Si el recopilador detecta problemas, corregirlos antes de continuar.
---
## PASO 5: PUBLICAR en Gitea (@gitea) — OBLIGATORIO
Toda app nueva DEBE publicarse en Gitea. Este paso NO es opcional.
**Como invocar:**
Usar el Agent tool con `subagent_type: "gitea"` pasando:
- Crear repo `{app_name}` en la organizacion `dataforge` de Gitea
- La URL base de Gitea: `https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com`
- Inicializar el repo con el contenido de `apps/{app_name}/`
- El repo debe tener su propio `.git` independiente del fn_registry
**Pasos que el agente gitea debe ejecutar:**
```bash
# 1. Crear repo en Gitea (via API)
# 2. Inicializar git en la app
cd /home/lucas/fn_registry/apps/{app_name}
git init
git add -A
git commit -m "Initial commit: {app_name} — {descripcion}"
# 3. Configurar remote y push
git remote add origin https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/{app_name}.git
git push -u origin master
# 4. Actualizar repo_url en app.md
```
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
```bash
cd /home/lucas/fn_registry && ./fn index
```
---
## PASO 6: Resumen final
Reportar al usuario:
```
=== APP CREADA: {app_name} ===
Directorio: apps/{app_name}/
Lenguaje: {lang}
Dominio: {domain}
Framework: {framework}
Entry point: {entry_point}
Funciones del registry usadas:
- {id1}: {descripcion}
- {id2}: {descripcion}
Funciones nuevas creadas:
- {id3}: {descripcion}
Operations:
Entities: N
Relations: N
Executions: N (primera ejecucion: {status})
Repo Gitea: {repo_url}
Para ejecutar:
cd apps/{app_name} && {comando_ejecucion}
```
---
## Flujos segun tipo de app
### App Go TUI (Bubbletea)
1. Consultar funciones TUI existentes: `sqlite3 registry.db "SELECT id, description FROM functions WHERE domain = 'tui' ORDER BY name;"`
2. Crear app con framework bubbletea
3. Estructura: main.go + app/model.go + views/ + config/
4. Tag `launcher` en app.md si debe aparecer en Pipeline Launcher
### App Go Desktop (Wails)
1. Usar `scaffold_wails_app_go_infra` para generar el proyecto
2. Consultar componentes Wails del registry: `sqlite3 registry.db "SELECT id, description FROM functions WHERE id LIKE '%wails%' ORDER BY name;"`
3. Frontend usa @fn_library (Mantine v9, @tabler/icons-react)
4. Bindings Go via `wails_bind_crud_go_infra`
### App Python
1. Consultar funciones Python: `sqlite3 registry.db "SELECT id, description FROM functions WHERE lang = 'py' AND domain = 'DOMINIO' ORDER BY name;"`
2. Import pattern con sys.path al registry
3. Deps con requirements.txt o pyproject.toml
### App Bash
1. Consultar funciones Bash: `sqlite3 registry.db "SELECT id, description FROM functions WHERE lang = 'bash' ORDER BY name;"`
2. Source pattern con REGISTRY_ROOT
3. set -euo pipefail obligatorio
### App C++ (ImGui)
1. Codigo fuente va en `apps/{app_name}/` (no en `cpp/apps/`)
2. `cpp/CMakeLists.txt` referencia la app con `add_subdirectory(../apps/{app_name} ...)`
3. Funciones C++ del registry se incluyen como .cpp en el CMakeLists.txt de la app
4. Para Windows: cross-compile con `cmake -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w64.cmake`
### Service (tag `service`)
1. Detectar si el usuario pide un servicio (API, daemon, watcher, server) o usa prefijo `service:`
2. Añadir tag `service` al array `tags` del app.md
3. Documentar en app.md: puerto, como lanzar/parar, health check
4. Estructura tipica para un HTTP service en Go:
```
apps/{service_name}/
├── app.md # tags: [service, api, ...]
├── main.go # Bind port, listen, graceful shutdown
├── handlers.go # HTTP handlers que componen funciones del registry
├── go.mod
├── .gitignore
```
5. El service se ejecuta como: `go run . --port 8080`
6. Para consultar services existentes: `sqlite3 registry.db "SELECT id, name, description FROM apps WHERE tags LIKE '%service%';"`
---
## Reglas
- **Codigo reutilizable** va en `functions/`, NO en la app → usar fn-constructor
- **Codigo especifico** de la app va en `apps/{app_name}/`
- **Todas las apps van en `apps/`**, incluidas C++, TypeScript, etc. Nunca en `cpp/apps/` ni otros subdirectorios
- **operations.db** SOLO dentro de la app, NUNCA en la raiz
- **registry.db** SOLO en la raiz, NUNCA en apps
- Toda app DEBE tener `app.md` con frontmatter completo
- `uses_functions` en app.md DEBE listar TODAS las funciones del registry importadas
- Siempre `./fn index` despues de crear/modificar la app — **verificar que aparece en registry.db**
- Siempre auditar con fn-recopilador antes de publicar
- **Siempre publicar en Gitea** (PASO 5) — toda app tiene repo en `dataforge/{app_name}`
- **Siempre actualizar `repo_url`** en app.md despues de publicar y re-indexar
- **Tag `service`**: añadir a apps que son procesos de larga duracion (APIs, daemons, watchers, schedulers)
- **Tag `launcher`**: añadir a pipelines que deben aparecer en Pipeline Launcher TUI
$ARGUMENTS
+273
View File
@@ -0,0 +1,273 @@
# /create_functions — Crear funciones para el registry a partir de una peticion
Eres un agente orquestador que evalua una peticion del usuario, consulta el registry, planifica las funciones necesarias y las crea en paralelo usando agentes fn-constructor especializados. Tambien creas unit tests y verificas que todo quedo indexado correctamente.
---
## Argumento
`$ARGUMENTS` — descripcion de lo que el usuario necesita. Ejemplos:
```
/create_functions funciones para parsear y validar JSON schema en Go
/create_functions pipeline Python para ETL de CSVs con filtrado y agregacion
/create_functions funciones de hashing y encoding para ciberseguridad en Go
/create_functions componentes React para formularios con validacion
/create_functions funciones Bash para gestion de contenedores Docker
```
Si `$ARGUMENTS` esta vacio, preguntar al usuario que funciones necesita.
---
## FASE 1: EVALUAR — Entender la peticion
1. **Parsear la peticion** para identificar:
- Dominio(s) involucrados (core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, notebook, ui)
- Lenguaje(s) preferido(s) (go, py, bash, typescript). Si no se especifica, inferir del contexto.
- Tipo de funciones necesarias: puras (algoritmos, transformaciones), impuras (I/O, red, DB), pipelines (composiciones), tipos, componentes
- Nivel de granularidad: funciones atomicas vs composiciones
2. **Si la peticion es ambigua**, preguntar al usuario SOLO lo esencial (no mas de 2 preguntas).
---
## FASE 2: OBSERVAR — Consultar el registry
Consultar `registry.db` para encontrar funciones existentes relevantes y evitar duplicados.
```bash
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
# Buscar tipos relacionados
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Funciones del dominio objetivo
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
# Tipos del dominio objetivo
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
# Funciones que podrian componerse (misma firma de retorno)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
```
**Clasificar resultados en:**
- **Reutilizables directamente**: funciones que ya hacen lo que se necesita
- **Componibles**: funciones que pueden usarse como building blocks
- **Similares pero diferentes**: funciones parecidas que confirman que no hay duplicado exacto
---
## FASE 3: PLANIFICAR — Disenar las funciones con un agente Plan
Invocar el Agent tool con `subagent_type: "Plan"` para disenar la lista de funciones a crear.
El prompt al agente Plan debe incluir:
- La peticion original del usuario
- Las funciones existentes encontradas en FASE 2 (IDs y descripciones)
- Los tipos existentes relevantes
- Las reglas de pureza del registry
El agente Plan debe producir una lista estructurada de funciones a crear, cada una con:
- **nombre** (snake_case)
- **kind** (function | pipeline | component)
- **lang** (go | py | bash | typescript)
- **domain**
- **purity** (pure | impure) — justificando por que
- **signature** propuesta
- **description** breve
- **uses_functions** — IDs de funciones existentes que reutiliza
- **uses_types** — IDs de tipos existentes que usa
- **dependencias** — si una funcion nueva depende de otra funcion nueva del mismo batch, indicar el orden
- **tests** — que se debe testear (casos de exito, edge cases, errores)
**Reglas del plan:**
- Funciones puras primero, impuras despues, pipelines al final
- Maximizar reutilizacion de funciones existentes
- Cada funcion debe tener tests propuestos
- El plan debe indicar el **orden de creacion** (las que tienen dependencias internas van despues)
- Agrupar funciones independientes para creacion en paralelo
**NO pedir confirmacion al usuario** — proceder directamente a la fase de construccion. Mostrar el plan brevemente en el output como referencia pero sin pausar:
---
## FASE 4: CONSTRUIR — Crear funciones en paralelo con fn-constructor
Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un agente por funcion o grupo pequeno de funciones relacionadas).
**Como invocar cada fn-constructor:**
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
```
Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry:
Funcion: {nombre}
Kind: {kind}
Lang: {lang}
Domain: {domain}
Purity: {purity}
Signature: {signature}
Description: {descripcion}
Uses_functions: [{ids}]
Uses_types: [{ids}]
Tests requeridos:
- {test1}: {descripcion del test}
- {test2}: {descripcion del test}
- {test3}: {descripcion del test}
Contexto: Esta funcion es parte de un batch para {descripcion general del objetivo}.
Funciones existentes del registry que puedes reutilizar: {ids relevantes}
IMPORTANTE:
- Crear el archivo de codigo Y el .md con frontmatter completo
- Crear el archivo de tests correspondiente
- Marcar tested: true en el .md si creas tests
- Respetar las reglas de pureza
- Usar tipos nativos en la firma
- file_path relativo a la raiz del registry
- NO ejecutar fn index (lo hare yo al final)
```
**Orden de ejecucion:**
1. Lanzar todos los fn-constructor del Batch 1 en paralelo
2. Esperar a que terminen
3. Lanzar todos los fn-constructor del Batch 2 en paralelo (dependen de Batch 1)
4. Repetir para cada batch subsiguiente
**Sin limite de agentes en paralelo** — lanzar todos los fn-constructor del batch simultaneamente para maxima velocidad.
---
## FASE 5: INDEXAR — Registrar todo en el registry
Despues de que TODOS los fn-constructor terminen:
```bash
# Indexar todo de una vez
cd /home/lucas/fn_registry && ./fn index
```
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
- ID duplicado → renombrar
- uses_functions referencia ID inexistente → verificar que el batch anterior se creo correctamente
- Violacion de pureza → ajustar purity o quitar dependencia impura
- file_path incorrecto → corregir la ruta
---
## FASE 6: VERIFICAR — Asegurar que todo esta correcto
### 6.1 Verificar indexacion
```bash
# Verificar cada funcion creada
cd /home/lucas/fn_registry
./fn show {id_de_cada_funcion}
# Verificar que no hay funciones sin params_schema
./fn check params
```
### 6.2 Ejecutar tests
Para cada funcion con tests, ejecutar:
```bash
cd /home/lucas/fn_registry
# Go
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
# Python
python/.venv/bin/python3 -m pytest python/functions/{domain}/{nombre}_test.py -v
# TypeScript
cd frontend && pnpm exec vitest run functions/{domain}/{nombre}.test.ts
# Bash (si hay tests)
bash bash/functions/{domain}/{nombre}_test.sh
```
### 6.3 Verificar integridad
```bash
# Verificar que todas las funciones nuevas estan en la BD
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
# Verificar que los tests estan indexados
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
# Verificar dependencias
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
```
### 6.4 Si algo fallo
- Si un test falla → corregir el codigo y re-ejecutar
- Si una funcion no se indexo → verificar el .md y re-indexar
- Si hay errores de integridad → corregir y re-indexar
- NO continuar al reporte si hay tests fallando o funciones sin indexar
---
## FASE 7: REPORTE — Resumen final
```
=== FUNCIONES CREADAS ===
Peticion: {descripcion original}
Funciones del registry reutilizadas:
- {id}: {descripcion}
Funciones nuevas:
- {id} [{kind}, {purity}, {lang}] — {descripcion}
Tests: N pasando
Archivo: {file_path}
- {id} [{kind}, {purity}, {lang}] — {descripcion}
Tests: N pasando
Archivo: {file_path}
Tipos nuevos:
- {id}: {descripcion}
Tests: X/Y pasando
Indexacion: OK
Para usar estas funciones:
# Go
import "fn_registry/functions/{domain}"
result := domain.FunctionName(args)
# Python
from {domain} import function_name
# Bash
source "$FN_REGISTRY_ROOT/bash/functions/{domain}/{name}.sh"
```
---
## Reglas
- **SIEMPRE** consultar registry.db antes de crear — evitar duplicados
- **NO pedir confirmacion** — mostrar el plan brevemente y proceder directamente
- **SIEMPRE** crear tests para cada funcion
- **SIEMPRE** indexar y verificar despues de crear
- **Funciones puras primero**, impuras despues, pipelines al final
- **Maximizar paralelismo** en la creacion (agentes fn-constructor en paralelo)
- **Maximizar reutilizacion** de funciones existentes
- **NO crear funciones especificas de una app** — solo codigo reutilizable y generico
- Si el usuario pide algo que ya existe, informar y sugerir reutilizar en vez de duplicar
- Si una funcion del batch falla, las demas del mismo batch pueden continuar independientemente
- **Tags con significado especial** — ver `.claude/rules/function_tags.md`:
- `launcher`: pipelines que deben aparecer en Pipeline Launcher TUI. Añadir cuando se crea un pipeline ejecutable desde el launcher. NO añadir a pipelines interactivos/TUIs.
- `service`: para apps que son procesos de larga duracion (usado en /app, no en funciones)
$ARGUMENTS
+215
View File
@@ -0,0 +1,215 @@
# /extract-source — Extraer funciones de un repo en sources/
Eres un agente extractor de funciones. Tu trabajo es analizar un repositorio clonado en `sources/` y extraer funciones reutilizables al registry siguiendo las reglas de `.claude/rules/sources.md`.
---
## Argumento
`$ARGUMENTS` — nombre del directorio en `sources/` (ej: `MiroFish`, `OpenViking`). Si no se proporciona, listar los directorios disponibles en `sources/` y pedir al usuario que elija.
---
## PASO 0: Validar el source
```bash
ls sources/$ARGUMENTS/
```
Si no existe, abortar. Verificar que tenga licencia compatible (MIT, Apache 2.0, BSD, ISC, MPL-2.0, Unlicense). Si es AGPL, GPL, o no tiene licencia, **advertir al usuario** y pedir confirmacion antes de continuar.
Identificar:
- **Licencia**: leer LICENSE/LICENSE.md/COPYING
- **Lenguaje principal**: detectar por archivos (*.go, *.py, *.rs, *.ts, *.js, Cargo.toml, go.mod, pyproject.toml, package.json)
- **URL del repo**: buscar en README, .git/config, o package.json
---
## PASO 1: Revisar el manifest
Leer `sources/sources.yaml` para ver si este repo ya tiene extracciones previas. Si las tiene, listarlas al usuario y preguntar si quiere continuar extrayendo mas o si quiere re-evaluar las existentes.
---
## PASO 2: Explorar el repositorio
Analizar la estructura del repo para identificar **todas las funciones candidatas** — puras e impuras. El objetivo es maximizar la extraccion de codigo util.
### Que buscar (por categoria)
**A. Funciones puras** (algoritmos, transformaciones, calculos, validaciones):
- Parsers, encoders/decoders, formatters
- Algoritmos matematicos, estadisticos, financieros
- Transformaciones de datos, filtros, mappers
- Validaciones, sanitizaciones
**B. Funciones impuras** (I/O, red, estado externo):
- Clientes HTTP/API (REST, GraphQL, WebSocket)
- Operaciones de filesystem (leer, escribir, monitorear archivos)
- Interacciones con bases de datos (queries, migraciones)
- Operaciones Docker, cloud, infraestructura
- Scraping, crawling, recoleccion de datos
- Notificaciones, envio de mensajes
**C. Pipelines** (composiciones multi-paso):
- Flujos ETL (extract-transform-load)
- Workflows de setup/deploy/provision
- Secuencias de procesamiento de datos
- Orquestaciones que componen varias funciones
**D. Tipos reutilizables** (structs, enums, interfaces):
- Modelos de dominio genericos
- Tipos de configuracion
- Interfaces/protocolos bien definidos
### Estrategia de exploracion segun lenguaje
- **Go**: `pkg/`, `internal/`, `utils/`, `lib/`, `cmd/` — funciones exportadas, handlers, clients
- **Python**: `src/`, `lib/`, `utils/`, `core/`, `api/` — funciones, clases client, decoradores
- **Rust**: `crates/`, `src/lib.rs` — funciones pub, traits implementados
- **TypeScript/JS**: `src/`, `lib/`, `utils/`, `services/` — funciones, hooks, componentes
- **Bash**: `scripts/`, `bin/`, `tools/` — funciones con firma clara
### Que ignorar
- main(), CLI entry points (pero extraer las funciones que invocan)
- Tests (pero notar cuales funciones estan bien testeadas — marcar `tested: true`)
- Funciones que dependen de tipos internos complejos **no adaptables**
- Codigo con dependencias externas pesadas que no esten en fn_registry
- Config loaders hardcodeados a un proyecto especifico
---
## PASO 3: Consultar el registry para evitar duplicados
Antes de proponer cualquier funcion, buscar en registry.db con FTS5:
```bash
# Por cada candidata, buscar similares
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NOMBRE* OR description:DESCRIPCION') ORDER BY name;"
```
Si ya existe algo similar, descartarla o anotar que es una mejora/variante.
---
## PASO 4: Presentar candidatas al usuario
Agrupar las candidatas por categoria y mostrar en tablas separadas:
### Funciones puras
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Descripcion |
|---|---|---|---|---|---|
### Funciones impuras
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | I/O tipo | Descripcion |
|---|---|---|---|---|---|---|
(I/O tipo: HTTP, filesystem, DB, Docker, network, etc.)
### Pipelines (composiciones)
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Funciones que compone | Descripcion |
|---|---|---|---|---|---|---|
### Tipos
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Algebraic | Descripcion |
|---|---|---|---|---|---|---|
Para cada candidata indicar:
- Por que cumple el filtro de calidad
- Si requiere adaptacion (renombrar tipos, quitar dependencias, traducir lenguaje)
- Si es traduccion de otro lenguaje (ej: Rust → Go)
- Para impuras: cual es el `error_type` apropiado
**Esperar confirmacion del usuario** antes de extraer. El usuario puede:
- Aprobar todas (`all`)
- Seleccionar por numero (`1,3,5-8`)
- Seleccionar por categoria (`todas las puras`, `solo pipelines`)
- Pedir explorar mas areas del repo
- Descartar y terminar
---
## PASO 5: Extraer funciones aprobadas
Para cada funcion aprobada:
### 5a. Determinar destino y clasificacion
| Naturaleza | Destino | kind | purity |
|---|---|---|---|
| Algoritmo/logica pura | Go/Python `functions/{domain}/` | function | pure |
| Funcion con I/O (HTTP, DB, fs) | Go/Python `functions/{domain}/` | function | impure |
| Script/utilidad sistema | Bash `bash/functions/{domain}/` | function | impure |
| UI/componente | TypeScript `frontend/functions/{domain}/` | component | — |
| Composicion multi-paso | `functions/pipelines/` o `python/functions/pipelines/` | pipeline | impure |
| C/Rust/otro lenguaje | Traducir a Go o Python manteniendo semantica | segun caso | segun caso |
### 5b. Crear archivos
1. **Codigo** — copiar y adaptar:
- Renombrar a snake_case
- Usar tipos nativos en firma (no tipos internos del repo)
- Quitar dependencias externas, usar stdlib
- Ajustar al paquete Go destino (nombre = nombre del directorio)
- Si es traduccion, mantener la semantica y documentar el origen
2. **Metadata .md** — crear frontmatter completo:
- `source_repo`: URL del repo original
- `source_license`: licencia del repo
- `source_file`: path relativo del archivo original dentro del repo
- Todos los campos obligatorios segun el tipo (function/pipeline/component)
- Reglas de pureza:
- `pure``returns_optional: false` + `error_type: ""`
- `impure``error_type: "error_go_core"` (o equivalente Python)
- `pipeline``purity: impure` + `uses_functions` con las funciones que compone
### 5c. Verificar integridad
```bash
# Indexar
./fn index
# Verificar cada funcion extraida
./fn show {id}
```
Si el indexer reporta errores, corregir antes de continuar.
---
## PASO 6: Actualizar manifest
Anadir las funciones extraidas a `sources/sources.yaml` bajo el repo correspondiente:
```yaml
- repo: https://github.com/user/project
license: MIT
cloned_dir: nombre_directorio
extracted:
- id: funcion_go_core
source_file: pkg/utils.go
date: YYYY-MM-DD # fecha de hoy
```
Si el repo no existe en el manifest, crear la entrada completa.
---
## PASO 7: Resumen
Mostrar al usuario:
- Funciones extraidas exitosamente (con IDs)
- Funciones descartadas y por que
- Warnings del indexer si hubo
- Sugerencia de areas del repo que podrian explorarse en el futuro
---
## Reglas criticas
- **NUNCA extraer sin aprobacion del usuario** — siempre presentar candidatas primero
- **NUNCA ignorar el filtro de calidad** — si no cumple todos los criterios, no se extrae
- **SIEMPRE consultar registry.db** antes de proponer — evitar duplicados
- **SIEMPRE atribuir** — source_repo, source_license, source_file en el .md
- **SIEMPRE actualizar sources.yaml** — es el manifest versionado
- **Licencias no permisivas** (GPL, AGPL) requieren advertencia explicita al usuario
- **Traduccion de lenguaje** es valida — documentar el origen claramente
+486
View File
@@ -0,0 +1,486 @@
# /frontend — Skill para proyectos frontend
Eres un arquitecto frontend experto. Esta skill se activa cuando el usuario pide crear un proyecto frontend, una app con UI, un componente nuevo, o una feature frontend. Tu trabajo es garantizar que TODO el frontend se construya usando el sistema de funciones reutilizables del registry y las mejores practicas actuales.
## Stack
- **pnpm** — gestor de paquetes
- **React 19** — UI library
- **Vite 8** — build tool
- **Mantine v9** — component library + styling (props, no CSS manual)
- **Phosphor Icons** — `@phosphor-icons/react`
- **Recharts** — charts (via `@mantine/charts`)
**NO usar:** Tailwind, shadcn, CVA, clsx, cn(), lucide-react, styled-components, emotion, CSS-in-JS runtime.
---
## PASO 1: Consultar el registry (OBLIGATORIO)
Antes de escribir una sola linea de codigo, consulta registry.db para saber que componentes, funciones y tipos frontend ya existen:
```bash
# Componentes y funciones frontend disponibles
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE lang IN ('ts','typescript') ORDER BY domain, name;"
# Tipos frontend disponibles
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE lang IN ('ts','typescript') ORDER BY domain, name;"
# Busqueda FTS5 si buscas algo especifico
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:chart* OR description:chart*') ORDER BY name;"
```
Tambien lista los archivos reales en disco ya que no todos estan indexados aun:
```bash
ls frontend/functions/ui/ # Componentes React
ls frontend/functions/core/ # Utilidades TS puras
ls frontend/types/ # Tipos
```
**REGLA:** Si un componente ya existe en `frontend/functions/ui/` (alias `@fn_library`), USALO. Nunca recrear lo que ya existe.
---
## PASO 2: Determinar el tipo de trabajo
### A) App nueva en `apps/`
Ir a → Seccion SCAFFOLD APP
### B) Componente nuevo para el registry
Ir a → Seccion CREAR COMPONENTE
### C) Feature en app existente
Ir a → Seccion CREAR FEATURE
---
## SCAFFOLD APP
Crear la estructura completa de una app frontend nueva en `apps/{nombre}/frontend/`.
### Estructura obligatoria
```
apps/{nombre}/
frontend/
package.json
vite.config.ts
tsconfig.json
postcss.config.cjs
index.html
src/
main.tsx # Entry point con MantineProvider
App.tsx # Root con Router
app.css # Minimal (font-smoothing solo)
features/ # Feature-based co-location
{feature}/
components/ # Componentes del feature
hooks/ # Hooks del feature
types.ts # Tipos del feature
index.ts # Barrel export publico
components/ # Componentes compartidos de esta app (no reutilizables)
hooks/ # Hooks compartidos
lib/ # Utilidades, API client
types/ # Tipos globales de la app
```
### package.json base
```json
{
"name": "{nombre}",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
"@mantine/notifications": "^9.0.0",
"@phosphor-icons/react": "^2.1.10",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}
```
Agregar dependencias extras segun necesidad:
- **Charts**: `@mantine/charts`, `recharts`
- **Tablas**: `@tanstack/react-table`
- **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod`
- **Dates**: `@mantine/dates`, `dayjs`
- **Router**: `react-router` o `@tanstack/react-router`
- **State**: `zustand` (client state), `@tanstack/react-query` (server state)
- **Wails**: los hooks de Wails ya estan en `@fn_library` (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent, WailsProvider)
### vite.config.ts base
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
},
dedupe: ['react', 'react-dom'],
},
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
},
},
},
},
})
```
### postcss.config.cjs base
```js
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
```
### app.css base
```css
/* Minimal — Mantine handles all theming via MantineProvider */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
### main.tsx base
```tsx
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import './app.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { MantineProvider, createTheme } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import App from './App'
const theme = createTheme({
primaryColor: 'blue',
defaultRadius: 'md',
// Customize colors, fonts, etc. here
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications />
<App />
</MantineProvider>
</React.StrictMode>,
)
```
### Despues del scaffold
```bash
cd apps/{nombre}/frontend && pnpm install
```
---
## CREAR COMPONENTE
Para componentes nuevos que van al registry en `frontend/functions/`.
### Reglas de implementacion
1. **Mantine first**: wrappear componentes de Mantine. Solo crear desde cero si Mantine no tiene equivalente.
2. **Styling via props**: usar props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.) y el style system. NUNCA clases CSS manuales ni Tailwind.
3. **CSS variables de Mantine**: si necesitas styles inline, usar `var(--mantine-color-*)`, `var(--mantine-spacing-*)`, etc.
4. **Iconos**: usar `@phosphor-icons/react`, no lucide-react ni @tabler/icons-react.
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading.
6. **Accesibilidad**:
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion
- NUNCA `<div onClick>` para elementos interactivos
- `aria-label` en botones de solo icono
- `aria-invalid` + `aria-describedby` en inputs con error
- Focus management en modales/popovers
7. **Discriminated unions** cuando las props cambian segun variante:
```tsx
type Props = { size?: 'sm' | 'md' | 'lg'; children: React.ReactNode } & (
| { variant: 'link'; href: string; onClick?: never }
| { variant: 'button'; onClick: () => void; href?: never }
)
```
### Patron de archivo .tsx
```tsx
import { Select, type SelectProps } from '@mantine/core'
// Re-export con defaults o logica adicional si necesario
interface MySelectProps extends Omit<SelectProps, 'xxx'> {
customProp?: string
}
function MySelect({ customProp, ...props }: MySelectProps) {
return <Select {...props} />
}
export { MySelect }
export type { MySelectProps }
```
### Patron de archivo .md
**IMPORTANTE:** El campo `lang` debe ser `ts` (no `typescript`). El indexer solo reconoce `ts`. Los IDs siguen el formato `{name}_ts_{domain}`.
```yaml
---
name: component_name
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
signature: "ComponentName(props: ComponentProps): JSX.Element"
description: "Descripcion concisa de que hace el componente"
tags: [component, ui, ...]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["@mantine/core"]
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/component_name.tsx"
props:
- name: variant
type: "'default' | 'secondary'"
required: false
description: "Estilo visual"
emits: []
has_state: false
framework: react
variant: [default]
---
## Ejemplo
...codigo de ejemplo...
## Notas
...notas relevantes...
```
### Despues de crear
```bash
./fn index && ./fn show {id}
```
---
## CREAR FEATURE
Para features dentro de una app existente. Co-location obligatoria.
### Estructura
```
src/features/{feature_name}/
components/
FeatureMain.tsx # Componente principal
FeatureDetail.tsx # Sub-componentes
hooks/
useFeatureData.ts # Hooks del feature
types.ts # Tipos locales
index.ts # Barrel export
```
### Barrel export (index.ts)
```ts
// Solo exportar la API publica del feature
export { FeatureMain } from './components/FeatureMain'
export { useFeatureData } from './hooks/useFeatureData'
export type { FeatureItem, FeatureConfig } from './types'
```
### Patrones de estado obligatorios
**Server state** (datos de API/backend):
```tsx
// Con @tanstack/react-query
const queryKeys = {
all: ['feature'] as const,
list: (filters: Filters) => [...queryKeys.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.all, 'detail', id] as const,
}
function useFeatureList(filters: Filters) {
return useQuery({
queryKey: queryKeys.list(filters),
queryFn: () => fetchFeatureList(filters),
})
}
```
**Client state** (UI state compartido):
```tsx
// Con Zustand
import { create } from 'zustand'
interface FeatureStore {
selectedId: string | null
setSelected: (id: string | null) => void
}
const useFeatureStore = create<FeatureStore>((set) => ({
selectedId: null,
setSelected: (id) => set({ selectedId: id }),
}))
```
**Wails** (apps de escritorio):
```tsx
// Usar hooks del registry
import { useWailsQuery, useWailsMutation } from '@fn_library'
function useFeatureData() {
return useWailsQuery('GetFeatureData', [], { staleTime: 60_000 })
}
```
### Code splitting por ruta
```tsx
import { lazy, Suspense } from 'react'
import { Skeleton } from '@mantine/core'
const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage'))
function AppRoutes() {
return (
<Routes>
<Route path="/feature" element={
<Suspense fallback={<Skeleton height="100vh" />}>
<FeaturePage />
</Suspense>
} />
</Routes>
)
}
```
---
## CHECKLIST DE VALIDACION (ejecutar siempre al final)
Antes de dar por terminado cualquier trabajo frontend, verificar:
### Colores y estilos
- [ ] CERO colores hardcodeados en componentes (no hex, no rgb inline)
- [ ] Styling via props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.)
- [ ] Si se necesitan styles inline, usar CSS variables de Mantine (`var(--mantine-color-*)`)
- [ ] NO clases CSS manuales, NO Tailwind, NO cn(), NO CVA
### Componentes del registry
- [ ] Verificado que no se esta recreando algo que ya existe en `@fn_library` (`frontend/functions/ui/`)
- [ ] Componentes de `@fn_library` usados donde aplica: Card, Select, SimpleSelect, KPICard, Sparkline, DashboardLayout, DataTable, charts, hooks Wails
- [ ] Componentes de Mantine usados directamente donde `@fn_library` no tiene wrapper: Button, TextInput, Table, Alert, Badge, Skeleton, Tabs, Tooltip, Group, Stack, Grid, Box, Paper, AppShell, Container
### Iconos
- [ ] Usando `@phosphor-icons/react` para iconos
- [ ] NO lucide-react, NO @tabler/icons-react
### TypeScript
- [ ] Props interfaces con `React.ComponentPropsWithoutRef` para HTML spreading
- [ ] Discriminated unions donde las props varian segun tipo/variante
- [ ] `as const` para arrays literales y config objects
- [ ] No `any` — usar `unknown` + type guards si es necesario
### Accesibilidad
- [ ] Elementos semanticos (button, a — no div onClick)
- [ ] `aria-label` en botones de solo icono
- [ ] `aria-invalid` + `aria-describedby` en inputs con validacion
- [ ] Focus trap en modales y popovers
- [ ] `prefers-reduced-motion` respetado (ya en app.css base)
### Performance
- [ ] Lazy loading en rutas (`React.lazy` + `Suspense`)
- [ ] `manualChunks` en vite.config para vendor splitting
- [ ] Sin barrel exports profundos que maten tree-shaking
- [ ] Listas largas virtualizadas si >100 items
### Estructura
- [ ] Features co-located: componente + hook + tipos + barrel en el mismo directorio
- [ ] Un `index.ts` por feature con API publica explicita
- [ ] Componentes reutilizables de la app en `src/components/`
- [ ] Tipos compartidos en `src/types/`
---
## ANTI-PATRONES (nunca hacer)
1. **`<div onClick={...}>`** → usar `<button>` o componente Mantine
2. **`style={{ color: '#3b82f6' }}`** → usar prop `c="blue"` o `var(--mantine-color-blue-6)`
3. **`import Button from './MyButton'`** cuando existe en Mantine → usar `import { Button } from '@mantine/core'`
4. **Estado global para todo** → segmentar: server state (React Query), client state (Zustand), form state (React Hook Form), URL state (search params)
5. **`index.ts` en la raiz de `src/`** que re-exporta todo → mata tree-shaking
6. **`// @ts-ignore`** → arreglar el tipo
7. **CSS-in-JS runtime** (styled-components, emotion) → usar props de Mantine
8. **Tailwind, CVA, cn(), clsx** → usar props de Mantine y su style system
9. **Crear utilidades que ya existen**: `getSeriesColor()`, `ChartContainer`, `DashboardLayout`, `DataTable` ya estan en `@fn_library`
10. **Colores de chart hardcodeados** → usar `@mantine/charts` color system o `getSeriesColor()`
$ARGUMENTS
+457
View File
@@ -0,0 +1,457 @@
# /meta_bigq — Operar Metabase y BigQuery desde el registry
Eres un agente de datos. Tienes acceso a funciones Python del fn_registry para controlar **Metabase** (dashboards, cards, queries, usuarios) y **Google BigQuery** (datasets, tablas, queries, jobs, routines). Usa estas funciones directamente — no inventes llamadas HTTP manuales.
---
## Como ejecutar funciones
```bash
PYTHON="python/.venv/bin/python3"
# Ejecutar codigo inline
$PYTHON -c "
import sys; sys.path.insert(0, 'python/functions')
from metabase import metabase_auth, metabase_list_dashboards
client = metabase_auth('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
print(metabase_list_dashboards(client))
"
# O con fn run para pipelines
./fn run init_metabase --project fn_registry
./fn run setup_metabase_volume
./fn run metabase_create_ops_dashboard docker_tui
```
Variables de entorno tipicas:
- `METABASE_URL` (default: `http://localhost:3000`)
- `METABASE_ADMIN_EMAIL` (default: `admin@fnregistry.local`)
- `METABASE_ADMIN_PASSWORD` (default: `FnRegistry2024!`)
- BigQuery usa ADC (`gcloud auth application-default login`) o `GOOGLE_APPLICATION_CREDENTIALS`
---
## METABASE — Referencia rapida
### Auth
```python
from metabase import metabase_auth, MetabaseClient
# Login con email/password
client = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
# O directo con API key
client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx")
# Context manager
with metabase_auth(...) as client:
pass # se cierra solo
```
### Cards (preguntas)
```python
from metabase import (
metabase_list_cards, # (client, filter="", model_id=0) -> list[dict]
metabase_get_card, # (client, card_id) -> dict
metabase_create_card, # (client, name, dataset_query, display="table", collection_id=0, description="") -> dict
metabase_update_card, # (client, card_id, **fields) -> dict # fields: name, description, display, dataset_query, archived...
metabase_delete_card, # (client, card_id) -> None # IRREVERSIBLE, preferir archived=True
metabase_execute_card, # (client, card_id, parameters=None) -> dict # ejecuta query de card guardada
metabase_execute_query, # (client, database_id, sql, max_results=0) -> dict # query ad-hoc
)
# Crear card con SQL nativo
card = metabase_create_card(client, "Ventas por mes", {
"database": 1, "type": "native",
"native": {"query": "SELECT date_trunc('month', created_at) as mes, SUM(total) FROM orders GROUP BY 1"},
}, display="line")
# Actualizar query de una card
metabase_update_card(client, card["id"], dataset_query={
"database": 1, "type": "native",
"native": {"query": "SELECT ... nueva query ..."},
})
# Archivar (soft-delete)
metabase_update_card(client, 42, archived=True)
# Query ad-hoc sin guardar
result = metabase_execute_query(client, 1, "SELECT COUNT(*) FROM users")
# result["data"]["rows"] = [[42]]
```
**Filtros de list_cards:** `all`, `mine`, `fav`, `archived`, `recent`, `popular`, `database`, `table`
### Dashboards
```python
from metabase import (
metabase_list_dashboards, # (client, filter="") -> list[dict]
metabase_get_dashboard, # (client, dashboard_id) -> dict # incluye dashcards
metabase_create_dashboard, # (client, name, description="", collection_id=0) -> dict
metabase_update_dashboard, # (client, dashboard_id, **fields) -> dict
metabase_delete_dashboard, # (client, dashboard_id) -> None # IRREVERSIBLE
)
# Crear dashboard + agregar cards
dash = metabase_create_dashboard(client, "KPIs Operativos", description="Metricas diarias")
# Posicionar cards en el dashboard (dashcards es el estado COMPLETO)
metabase_update_dashboard(client, dash["id"], dashcards=[
{"id": -1, "card_id": card1["id"], "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"id": -2, "card_id": card2["id"], "row": 0, "col": 6, "size_x": 6, "size_y": 4},
{"id": -3, "card_id": card3["id"], "row": 4, "col": 0, "size_x": 12, "size_y": 6},
])
# id negativo = card nueva, id positivo = card existente, omitida = eliminada
```
**Filtros de list_dashboards:** `all`, `mine`, `archived`
### Databases
```python
from metabase import (
metabase_list_databases, # (client, include_tables=False) -> list
metabase_add_database, # (client, name, engine, details) -> dict
metabase_get_database, # (client, database_id) -> dict
)
# Agregar SQLite
metabase_add_database(client, "Operations DB", "sqlite", {"db": "/data/operations.db"})
# Agregar PostgreSQL
metabase_add_database(client, "DW", "postgres", {
"host": "localhost", "port": 5432, "dbname": "warehouse",
"user": "reader", "password": "secret",
})
```
### Usuarios
```python
from metabase import (
metabase_list_users, # (client, status="", query="", limit=0, offset=0) -> dict
metabase_get_user, # (client, user_id) -> dict
metabase_create_user, # (client, first_name, last_name, email, password="", group_ids=None) -> dict
metabase_update_user, # (client, user_id, **fields) -> dict
metabase_deactivate_user, # (client, user_id) -> None # soft-delete
)
```
### Setup y pipelines
```python
from metabase import metabase_setup
# Setup inicial de instancia nueva (obtiene setup-token automaticamente)
metabase_setup("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
```
```bash
# Pipelines ejecutables con fn run
./fn run init_metabase --project fn_registry # Docker: Postgres + Metabase
./fn run setup_metabase_volume # Copiar registry.db al contenedor
./fn run metabase_add_ops_db docker_tui # Registrar operations.db como database
./fn run metabase_create_ops_dashboard docker_tui # Dashboard operativo completo
./fn run metabase_fix_permissions # Arreglar permisos SQLite en Docker
```
---
## BIGQUERY — Referencia rapida
### Auth
```python
from bigquery import bq_auth, BQClient
# ADC (gcloud auth application-default login)
client = bq_auth()
# Proyecto explicito
client = bq_auth("my-project-id")
# Service account JSON
client = bq_auth(credentials_path="/path/to/sa.json")
# Context manager
with bq_auth("my-project") as client:
pass
```
### Datasets
```python
from bigquery import (
bq_create_dataset, # (client, dataset_id, location="US", description="", labels=None, default_table_expiration_ms=0) -> dict
bq_get_dataset, # (client, dataset_id) -> dict
bq_list_datasets, # (client) -> list[dict]
bq_update_dataset, # (client, dataset_id, description=None, labels=None, default_table_expiration_ms=None) -> dict
bq_delete_dataset, # (client, dataset_id, delete_contents=False) -> None
)
bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
bq_delete_dataset(client, "temp", delete_contents=True) # borra tablas incluidas
```
### Tables
```python
from bigquery import (
bq_create_table, # (client, dataset_id, table_id, schema, partitioning=None, clustering=None, description="", labels=None) -> dict
bq_get_table, # (client, dataset_id, table_id) -> dict # schema, num_rows, num_bytes, partitioning...
bq_list_tables, # (client, dataset_id) -> list[dict]
bq_update_table, # (client, dataset_id, table_id, schema=None, description=None, labels=None) -> dict
bq_delete_table, # (client, dataset_id, table_id) -> None
bq_preview_rows, # (client, dataset_id, table_id, max_results=10) -> dict # SIN COSTE de query
)
# Crear tabla con particionamiento
bq_create_table(client, "analytics", "events",
schema=[
{"name": "event_id", "type": "STRING", "mode": "REQUIRED"},
{"name": "user_id", "type": "STRING"},
{"name": "event_type", "type": "STRING"},
{"name": "created_at", "type": "TIMESTAMP"},
{"name": "payload", "type": "JSON"},
],
partitioning={"type": "DAY", "field": "created_at"},
clustering=["event_type", "user_id"],
)
# Preview sin coste (usa Storage Read API, no ejecuta query)
preview = bq_preview_rows(client, "analytics", "events", max_results=5)
# {"columns": [...], "rows": [[...], ...], "total_rows": 1234567}
# Schema: solo se pueden AGREGAR columnas, nunca eliminar
bq_update_table(client, "analytics", "events", schema=[
*existing_schema,
{"name": "new_col", "type": "STRING"},
])
```
**Tipos de schema:** `STRING`, `INT64`, `FLOAT64`, `BOOL`, `TIMESTAMP`, `DATE`, `DATETIME`, `BYTES`, `NUMERIC`, `JSON`, `RECORD`/`STRUCT`, `GEOGRAPHY`
**Modos:** `NULLABLE` (default), `REQUIRED`, `REPEATED`
### Queries y datos
```python
from bigquery import (
bq_query, # (client, sql, params=None, dry_run=False) -> dict
bq_insert_rows, # (client, dataset_id, table_id, rows) -> dict
bq_load_from_gcs, # (client, uri, dataset_id, table_id, source_format="CSV", write_disposition="WRITE_APPEND", autodetect=True, skip_leading_rows=0) -> dict
bq_load_from_file, # (client, file_path, dataset_id, table_id, ...) -> dict # mismos params que gcs
bq_export_to_gcs, # (client, dataset_id, table_id, destination_uri, destination_format="CSV", compression="NONE") -> dict
bq_copy_table, # (client, source_dataset, source_table, dest_dataset, dest_table, write_disposition="WRITE_EMPTY") -> dict
)
# Query simple
result = bq_query(client, "SELECT COUNT(*) as total FROM analytics.events")
# {"columns": ["total"], "rows": [[1234567]], "total_rows": 1, "bytes_processed": 0, "cache_hit": True}
# Query parametrizada (usa @nombre en SQL)
result = bq_query(client, "SELECT * FROM analytics.events WHERE event_type = @tipo LIMIT @n", params=[
{"name": "tipo", "type": "STRING", "value": "purchase"},
{"name": "n", "type": "INT64", "value": 100},
])
# Estimar coste ANTES de ejecutar (no procesa datos)
estimate = bq_query(client, "SELECT * FROM analytics.events", dry_run=True)
# {"total_bytes_processed": 5368709120, "total_bytes_billed": 5368709120}
gb = estimate["total_bytes_processed"] / (1024**3)
print(f"Esta query procesara {gb:.2f} GB (~${gb * 6.25:.2f} USD)")
# Streaming insert
bq_insert_rows(client, "analytics", "events", [
{"event_id": "e1", "user_id": "u1", "event_type": "click", "created_at": "2026-04-07T10:00:00Z"},
{"event_id": "e2", "user_id": "u2", "event_type": "purchase", "created_at": "2026-04-07T10:01:00Z"},
])
# {"inserted": 2, "errors": []}
# Cargar CSV desde GCS
bq_load_from_gcs(client, "gs://bucket/data/*.csv", "analytics", "events",
source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)
# Cargar archivo local
bq_load_from_file(client, "/tmp/data.parquet", "analytics", "events",
source_format="PARQUET", write_disposition="WRITE_APPEND")
# Exportar a GCS
bq_export_to_gcs(client, "analytics", "events", "gs://bucket/export/events-*.csv",
destination_format="CSV", compression="GZIP")
# Copiar tabla
bq_copy_table(client, "analytics", "events", "analytics_backup", "events_20260407")
```
**write_disposition:** `WRITE_TRUNCATE` (reemplazar), `WRITE_APPEND` (agregar), `WRITE_EMPTY` (solo si vacia)
**source_format:** `CSV`, `NEWLINE_DELIMITED_JSON`, `AVRO`, `PARQUET`, `ORC`
### Jobs
```python
from bigquery import (
bq_list_jobs, # (client, state_filter="", max_results=50, all_users=False) -> list[dict]
bq_get_job, # (client, job_id) -> dict # state, bytes_processed, errors
bq_cancel_job, # (client, job_id) -> dict
)
# Ver jobs corriendo
running = bq_list_jobs(client, state_filter="running")
for j in running:
print(j["job_id"], j["job_type"], j["bytes_processed"])
# Cancelar un job pesado
bq_cancel_job(client, "job_abc123")
```
**state_filter:** `running`, `pending`, `done`
### Routines (UDFs / Procedures)
```python
from bigquery import (
bq_create_routine, # (client, dataset_id, routine_id, body, routine_type="SCALAR_FUNCTION", language="SQL", arguments=None, return_type="", description="") -> dict
bq_list_routines, # (client, dataset_id) -> list[dict]
bq_delete_routine, # (client, dataset_id, routine_id) -> None
)
# UDF SQL
bq_create_routine(client, "analytics", "double_value",
body="x * 2",
arguments=[{"name": "x", "data_type": "INT64"}],
return_type="INT64",
)
# Stored procedure
bq_create_routine(client, "analytics", "refresh_summary",
body="BEGIN INSERT INTO summary SELECT ... FROM events; END;",
routine_type="PROCEDURE",
)
# UDF JavaScript
bq_create_routine(client, "analytics", "parse_ua",
body="return uaParser.parse(ua).browser.name;",
language="JAVASCRIPT",
arguments=[{"name": "ua", "data_type": "STRING"}],
return_type="STRING",
)
```
---
## Flujos tipicos
### 1. Explorar BigQuery y visualizar en Metabase
```python
import sys; sys.path.insert(0, "python/functions")
from bigquery import bq_auth, bq_query
from metabase import metabase_auth, metabase_create_card, metabase_create_dashboard, metabase_update_dashboard
# 1. Explorar datos en BQ
bq = bq_auth("my-project")
result = bq_query(bq, "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC LIMIT 10")
print(result["columns"], result["rows"])
# 2. Registrar BQ como database en Metabase (si no esta)
# Metabase soporta BigQuery como engine nativo
# 3. Crear cards en Metabase apuntando a BQ
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
card = metabase_create_card(mb, "Eventos por tipo", {
"database": 2, # ID de la database BQ en Metabase
"type": "native",
"native": {"query": "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC"},
}, display="bar")
# 4. Crear dashboard
dash = metabase_create_dashboard(mb, "Analytics Overview")
metabase_update_dashboard(mb, dash["id"], dashcards=[
{"id": -1, "card_id": card["id"], "row": 0, "col": 0, "size_x": 12, "size_y": 6},
])
```
### 2. ETL: archivo local -> BigQuery -> Metabase dashboard
```python
from bigquery import bq_auth, bq_load_from_file, bq_query, bq_preview_rows
from metabase import metabase_auth, metabase_execute_query
bq = bq_auth("my-project")
# Cargar datos
bq_load_from_file(bq, "/tmp/sales.csv", "warehouse", "sales",
source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)
# Verificar
preview = bq_preview_rows(bq, "warehouse", "sales", max_results=3)
print(preview["total_rows"], "filas cargadas")
# Consultar via Metabase (si BQ esta registrado como database)
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
result = metabase_execute_query(mb, 2, "SELECT region, SUM(amount) FROM sales GROUP BY 1")
```
### 3. Montar infraestructura desde cero
```bash
# 1. Levantar Metabase + Postgres
./fn run init_metabase --project fn_registry
# 2. Copiar registry.db al contenedor
./fn run setup_metabase_volume
# 3. Setup inicial
python/.venv/bin/python3 -c "
import sys; sys.path.insert(0, 'python/functions')
from metabase import metabase_setup
metabase_setup('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
"
# 4. Registrar operations.db de una app
./fn run metabase_add_ops_db docker_tui
# 5. Dashboard operativo automatico
./fn run metabase_create_ops_dashboard docker_tui
```
### 4. Auditar costes de BigQuery
```python
from bigquery import bq_auth, bq_list_jobs, bq_query
bq = bq_auth("my-project")
# Jobs recientes completados
jobs = bq_list_jobs(bq, state_filter="done", max_results=20, all_users=True)
total_bytes = sum(j.get("bytes_processed") or 0 for j in jobs)
print(f"Ultimos 20 jobs: {total_bytes / (1024**3):.2f} GB procesados")
# Dry-run antes de queries caras
estimate = bq_query(bq, "SELECT * FROM analytics.events WHERE created_at > '2026-01-01'", dry_run=True)
gb = estimate["total_bytes_processed"] / (1024**3)
cost = gb * 6.25 # $6.25/TB on-demand
print(f"Coste estimado: ${cost:.2f} USD ({gb:.1f} GB)")
```
---
## Buscar mas funciones
Si necesitas algo que no esta aqui, busca en el registry:
```bash
# FTS5 por nombre o descripcion
./fn search "lo que buscas"
# Ver detalles de una funcion
./fn show <id>
# Inline desde Python
sqlite3 registry.db "SELECT id, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:export*') ORDER BY name;"
```
$ARGUMENTS
+7 -1
View File
@@ -11,5 +11,11 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 05 | [stubs.md](stubs.md) | Stubs impuros para dependencias externas | | 05 | [stubs.md](stubs.md) | Stubs impuros para dependencias externas |
| 06 | [assertions.md](assertions.md) | Kinds de assertions son texto libre | | 06 | [assertions.md](assertions.md) | Kinds de assertions son texto libre |
| 07 | [proposals.md](proposals.md) | Quien crea proposals y cuando | | 07 | [proposals.md](proposals.md) | Quien crea proposals y cuando |
| 08 | [tag_launcher.md](tag_launcher.md) | Tag launcher para Pipeline Launcher TUI | | 08 | [function_tags.md](function_tags.md) | Tags con significado especial: launcher, service |
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio | | 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
| 14 | [deploy.md](deploy.md) | Deploy de apps a VPS remotos via SSH + systemd + rsync |
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
+18
View File
@@ -0,0 +1,18 @@
Solo codigo reutilizable y componible va en `functions/`, `python/functions/`, `bash/functions/`, `frontend/functions/`.
Scripts especificos, dashboards hardcodeados, CLIs de un solo uso, y cualquier codigo que no sea una primitiva componible va en `apps/`. Cada app en `apps/` es independiente: puede importar funciones del registry pero nunca al reves.
Criterios para decidir:
- **functions/**: firma generica, sin credenciales ni config hardcodeada, util en multiples contextos
- **apps/**: orquesta funciones del registry para un caso concreto, tiene config/credenciales, layout fijo
Las apps Python importan funciones del registry con: `sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))` y luego `from <paquete> import ...` (sin prefijo `functions.`).
## temp/ — workspace efimero
`temp/` es un espacio de trabajo desechable para pruebas rapidas: probar una API, un script exploratorio, un analisis puntual, prototipos. Todo gitignored.
- **NO es codigo del registry** — nada en `temp/` se indexa ni se versiona
- **Estructura libre** — subcarpetas por tema: `temp/api_test/`, `temp/quick_analysis/`, etc.
- **Extraccion**: si algo en `temp/` resulta util, se extrae al registry con el flujo normal (como si fuera `sources/`)
- **Limpieza**: se puede borrar el contenido en cualquier momento sin consecuencias
+134
View File
@@ -0,0 +1,134 @@
## Deploy de apps a VPS remotos
### Arquitectura
El sistema de deploy usa SSH + systemd + rsync. No Docker, no Kubernetes.
- **Conexiones SSH** → `~/.ssh/config` (alias, IP, user, key). Ya hay funciones CRUD: `ssh_config_read`, `ssh_config_find`, `ssh_config_parse`.
- **Config de deploy** → `apps/deploy_server/operations.db` tabla `deploy_targets` (app, host, remote_dir, build_cmd, port, health_path, env).
- **Logs de deploy** → misma BD, tabla `deploy_logs` (app, host, status, trigger, duration_ms, error).
### App: `deploy_server` (`apps/deploy_server/`)
CLI + servidor HTTP. Binario: `deploy_server`. Build: `CGO_ENABLED=1 go build -o deploy_server .`
```bash
cd apps/deploy_server
# Gestionar targets
./deploy_server target add --app <app> --host <ssh_alias> --port <N> --health /path --build "comando" [--user deploy] [--env '{"K":"V"}']
./deploy_server target list
./deploy_server target remove <app>
# Setup inicial (primera vez, crea dirs + systemd unit)
./deploy_server setup <app> --host <ssh_alias>
# Deploy continuo (build local → rsync → restart → health check)
./deploy_server deploy <app> [--host <ssh_alias>]
# Estado del servicio remoto
./deploy_server status <app>
./deploy_server status --all
# Servidor webhook (auto-deploy en cada push a Gitea)
./deploy_server serve --port 9090
```
### Funciones del registry involucradas
| Función | Qué hace | Purity |
|---|---|---|
| `rsync_deploy_bash_infra` | rsync local→remoto con exclusiones | impure |
| `systemd_generate_unit_go_infra` | Genera texto .service | **pure** |
| `systemd_install_go_infra` | Sube unit + daemon-reload + enable + start | impure |
| `systemd_restart_go_infra` | Reinicia servicio remoto | impure |
| `systemd_status_go_infra` | Estado + logs de servicio remoto | impure |
| `vps_setup_app_go_infra` | Crea dirs + usuario en VPS | impure |
| `gitea_create_webhook_bash_infra` | Crea webhook push en Gitea | impure |
| `setup_vps_app_go_infra` | Pipeline: setup completo primera vez | impure |
| `deploy_app_remote_go_infra` | Pipeline: deploy continuo | impure |
Tipo: `DeployConfig_go_infra` — struct con toda la config de deploy.
### Workflow para un agente
Cuando el usuario diga **"sube esta app a este VPS"** o **"deploya X en Y"**:
#### 1. Verificar que el host SSH existe
```bash
grep "^Host " ~/.ssh/config
# Si no existe el alias, añadirlo:
# Usar ssh_config_add_entry o editar ~/.ssh/config directamente
```
#### 2. Verificar conectividad
```bash
ssh -o BatchMode=yes -o ConnectTimeout=5 <alias> true
```
#### 3. Registrar el target en deploy_server
```bash
cd apps/deploy_server
# Build deploy_server si no existe el binario
CGO_ENABLED=1 go build -o deploy_server .
./deploy_server target add \
--app <nombre_app> \
--host <ssh_alias> \
--port <puerto> \
--health <path_o_vacio> \
--build "CGO_ENABLED=0 GOOS=linux go build -o <binario> ." \
--user deploy
```
#### 4. Setup inicial
```bash
./deploy_server setup <app> --host <ssh_alias>
```
Esto crea dirs en `/opt/apps/<app>/`, sube el código, genera el unit systemd e instala el servicio.
#### 5. Deploys posteriores
```bash
./deploy_server deploy <app>
```
Build local → rsync → restart systemd → health check.
#### 6. Auto-deploy con webhook (opcional)
```bash
# Lanzar servidor
./deploy_server serve --port 9090
# Crear webhook en Gitea
source bash/functions/infra/gitea_create_webhook.sh
gitea_create_webhook "<owner>" "<repo>" "http://<ip_deploy_server>:9090/webhook/push" "<secret>"
```
### Requisitos en el VPS
- SSH accesible con key auth (configurado en `~/.ssh/config` local)
- El usuario SSH debe tener **sudo sin password** para: `systemctl`, `mv` a `/etc/systemd/system/`, `mkdir` en `/opt/apps/`, `useradd`, `chown`
- `rsync` instalado en el VPS
- Puerto del servicio abierto en el firewall del VPS
### Builds por lenguaje
| Lenguaje | Build command típico |
|---|---|
| Go | `CGO_ENABLED=0 GOOS=linux go build -o <nombre> .` |
| Go + SQLite | `CGO_ENABLED=1 GOOS=linux go build -tags fts5 -o <nombre> .` |
| Python | No build — rsync sube los .py, systemd ejecuta `python3 main.py` |
| Bash | No build — rsync sube los .sh, systemd ejecuta `bash main.sh` |
Para Go con CGO (SQLite), el VPS debe tener `gcc` y `libc-dev`, o cross-compilar con `CGO_ENABLED=0` si la app no usa SQLite.
### Exclusiones de rsync
El deploy excluye automáticamente: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`, `registry.db`.
+11
View File
@@ -0,0 +1,11 @@
En todos los frontends se usan los componentes de `@fn_library` (alias a `frontend/functions/ui/`) antes que elementos HTML nativos o librerias externas.
El sistema de UI es Mantine v9. Todos los componentes de @fn_library wrappean componentes de Mantine.
**Theming:** Cada app define su tema con `createTheme()` de `@mantine/core` y lo pasa a `MantineProvider` (o `FnMantineProvider` de @fn_library). No se usan CSS variables custom — Mantine genera las suyas automaticamente (`--mantine-color-*`).
**Styling:** No se usa Tailwind, CVA, cn(), ni clases CSS manuales. Los componentes se estilizan con props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, etc.) y el style system de Mantine.
**Iconos:** Se usa `@tabler/icons-react` (el set nativo de Mantine), no lucide-react.
**Layout:** Se usan los componentes de layout de Mantine: `Group`, `Stack`, `Grid`, `Flex`, `SimpleGrid`, `AppShell`, `Container`, `Box`, `Paper`.
+30
View File
@@ -0,0 +1,30 @@
Los pipelines con tag `launcher` aparecen en el Pipeline Launcher TUI (`apps/pipeline_launcher`).
Sin el tag, el pipeline no es lanzable desde la TUI. Añadir `launcher` al array `tags` del .md al crear un pipeline ejecutable desde el launcher.
Pipelines interactivos (TUIs) o que no son subprocesos NO deben llevar este tag.
## Tag `service`
Las apps con tag `service` son procesos de larga duracion: APIs, daemons, watchers, servers.
Diferencia con una app normal:
- Una **app** se ejecuta, hace su trabajo, y termina (CLI, TUI, script)
- Un **service** se lanza y queda corriendo indefinidamente (API server, scheduler, watcher)
Añadir `service` al array `tags` del `app.md` cuando la app esta diseñada para correr como proceso persistente.
Un service sigue siendo una app — vive en `apps/`, tiene `app.md`, se indexa igual. El tag es solo metadata para filtrar:
```sql
-- Listar services
SELECT id, name, description FROM apps WHERE tags LIKE '%service%';
-- Listar apps que NO son services
SELECT id, name, description FROM apps WHERE tags NOT LIKE '%service%';
```
Documentar en el `app.md` del service:
- El puerto que usa (si expone HTTP/gRPC)
- Como lanzarlo y pararlo
- Como comprobar que esta vivo (health check)
+55
View File
@@ -0,0 +1,55 @@
## Colaboración en notebooks Jupyter
### Requisito previo
El usuario debe tener Jupyter Lab corriendo en modo colaborativo (`--collaborative`) y el notebook abierto en el browser. Sin esto, los cambios no se ven en tiempo real.
El launcher estándar (`run-jupyter-lab.sh` generado por `init_jupyter_analysis`) ya incluye `--collaborative`.
### Funciones del registry (dominio `notebook`)
| Función | ID | Para qué |
|---|---|---|
| `jupyter_discover` | `jupyter_discover_py_notebook` | Descubrir instancias Jupyter activas, kernels, sesiones, modo colaborativo |
| `jupyter_read` | `jupyter_read_py_notebook` | Leer celdas (todas o una), metadata del notebook |
| `jupyter_exec` | `jupyter_exec_py_notebook` | Ejecutar: append+execute, execute celda existente, o directo al kernel |
| `jupyter_write` | `jupyter_write_py_notebook` | Escribir: append code/markdown, insert, edit, delete celdas |
| `jupyter_kernel` | `jupyter_kernel_py_notebook` | CRUD de kernels: list, start, restart, interrupt, shutdown, sessions |
### Invocación desde cualquier sesión de Claude
```bash
PYTHON="python/.venv/bin/python3"
# 1. Descubrir qué Jupyter está corriendo
$PYTHON python/functions/notebook/jupyter_discover.py --json
# 2. Leer notebook
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
# 3. Añadir celda y ejecutar (el usuario la ve en tiempo real)
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "df.describe()"
# 4. Ejecutar celda existente
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3
# 5. Ejecutar en kernel sin tocar notebook
$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(df.shape)"
# 6. Añadir markdown
$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Resumen"
# 7. Gestionar kernels
$PYTHON python/functions/notebook/jupyter_kernel.py list
$PYTHON python/functions/notebook/jupyter_kernel.py sessions
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown <kernel_id>
```
### Reglas de uso
- **SIEMPRE** ejecutar `jupyter_discover` primero para confirmar que Jupyter está activo y el notebook abierto.
- Las funciones resuelven automáticamente el `kernel_id` de la sesión del notebook y el `username` colaborativo via `/api/sessions` y `/api/me`.
- Después de escribir/ejecutar, las funciones mantienen la conexión WebSocket 2 segundos para que Y.js propague los cambios al browser.
- **NO usar MCP jupyter** — estas funciones reemplazan al MCP y funcionan desde cualquier directorio sin registrar nada.
- El token por defecto es vacío (sin auth). Si el server tiene token, pasarlo con `--token`.
- Los paths de notebooks son relativos a la raíz del servidor Jupyter (normalmente `analysis/{tema}/`).
+90
View File
@@ -0,0 +1,90 @@
## Projects: apps, analysis y vaults bajo un tema comun
Un project agrupa apps, analyses y vaults relacionados. Vive en `projects/{nombre}/` con esta estructura:
```
projects/{nombre}/
project.md # Frontmatter obligatorio (name, description, tags)
apps/ # Apps del proyecto (cada una con app.md)
{app_name}/
app.md
...
analysis/ # Analyses del proyecto (cada uno con analysis.md)
{analysis_name}/
analysis.md
.venv/
notebooks/
run-jupyter-lab.sh
...
vaults/ # Datos del proyecto
vault.yaml # Manifest de vaults (nombre, descripcion, path, tags)
{vault_name} -> /abs/path # Symlinks a directorios reales de datos
```
### Reglas
- `project.md` sigue el template de `docs/templates/project.md` — campos: `name`, `description`, `tags`, `repo_url`
- `analysis.md` sigue el template de `docs/templates/analysis.md``dir_path` debe apuntar a `projects/{nombre}/analysis/{tema}/`
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
### Raiz vs proyecto
| Ubicacion | Para que |
|-----------|---------|
| `apps/` | Apps independientes que no pertenecen a ningun proyecto |
| `analysis/` | Analyses independientes |
| `projects/{nombre}/apps/` | Apps de un proyecto — `project_id` se setea automaticamente |
| `projects/{nombre}/analysis/` | Analyses de un proyecto — `project_id` se setea automaticamente |
### Crear un proyecto nuevo
```bash
# 1. Crear estructura
mkdir -p projects/{nombre}/{apps,analysis,vaults}
# 2. Crear project.md con frontmatter
fn add -k project # genera template
# 3. Crear vault (datos fuera del repo, symlink dentro)
mkdir -p ~/vaults/{vault_name}/{raw,processed,exports}
ln -s ~/vaults/{vault_name} projects/{nombre}/vaults/{vault_name}
# Crear vault.yaml con la entrada
# 4. Crear analysis dentro del proyecto
fn run init_jupyter_analysis {nombre_analysis} [paquetes...]
mv analysis/{nombre_analysis} projects/{nombre}/analysis/
# Crear analysis.md con dir_path correcto
# Regenerar launcher y kernel startup:
source bash/functions/infra/write_jupyter_launcher.sh && write_jupyter_launcher projects/{nombre}/analysis/{tema}
source bash/functions/infra/write_jupyter_registry_kernel.sh && write_jupyter_registry_kernel projects/{nombre}/analysis/{tema}
# 5. Indexar
fn index
fn show {nombre} # verifica el project y sus componentes
```
### Consultas utiles
```sql
-- Listar proyectos
SELECT id, description FROM projects;
-- Analysis de un proyecto
SELECT id, name, dir_path FROM analysis WHERE project_id = 'app_turismo';
-- Vaults de un proyecto
SELECT id, name, path, symlink FROM vaults WHERE project_id = 'app_turismo';
-- Apps de un proyecto
SELECT id, name, dir_path FROM apps WHERE project_id = 'app_turismo';
-- Todo lo que pertenece a un proyecto
SELECT 'analysis' as tipo, id, name FROM analysis WHERE project_id = ?
UNION ALL
SELECT 'vault', id, name FROM vaults WHERE project_id = ?
UNION ALL
SELECT 'app', id, name FROM apps WHERE project_id = ?;
```
+60
View File
@@ -0,0 +1,60 @@
## Extraccion de funciones desde repos externos (`sources/`)
### Workflow
1. Clonar repo en `sources/<nombre>` (gitignored, solo el manifest `sources/sources.yaml` se versiona)
2. El agente analiza el repo y propone funciones candidatas
3. Las funciones se **copian y adaptan** al formato del registry (.go/.py/.sh/.ts + .md con frontmatter)
4. `fn index` las registra. El manifest se actualiza con las funciones extraidas.
### Filtro de calidad (obligatorio antes de extraer)
Una funcion externa solo se extrae si cumple TODOS estos criterios:
- **Firma generica**: no depende de tipos internos del repo origen ni de config hardcodeada
- **Sin estado global**: no usa variables globales, singletons, ni init() con side effects
- **Dependencias minimas**: solo stdlib o dependencias ya presentes en fn_registry
- **Sin credenciales**: no contiene secrets, API keys, ni paths absolutos
- **Testeable**: la logica debe poder validarse con tests unitarios
- **No duplicada**: consultar registry.db con FTS5 antes de extraer para evitar duplicados
- **Licencia compatible**: el repo debe tener licencia permisiva (MIT, Apache 2.0, BSD, etc.)
### Clasificacion de pureza al extraer
Extraer tanto funciones puras como impuras. La clasificacion correcta es obligatoria:
- **Pure**: sin I/O, sin estado mutable, determinista. Extraer como `purity: pure`.
- **Impure**: hace I/O (red, disco, DB, HTTP), usa concurrencia, o depende de estado externo. Extraer como `purity: impure` con `error_type` apropiado.
- **Pipeline**: compone multiples funciones para un flujo completo. Extraer como `kind: pipeline`, siempre impuro.
No descartar funciones utiles solo por ser impuras. Una funcion que hace HTTP requests, lee archivos, o interactua con bases de datos es valiosa si su firma es generica y reutilizable.
### Adaptacion al extraer
- Renombrar a snake_case siguiendo la convencion del registry
- Adaptar firma para usar tipos nativos (no tipos internos del repo)
- Crear .md con frontmatter completo incluyendo `source_repo`, `source_license`, `source_file`
- Actualizar `sources/sources.yaml` con la extraccion
### Campos de atribucion en frontmatter
```yaml
source_repo: "https://github.com/user/project"
source_license: "MIT"
source_file: "pkg/original_file.go"
```
Estos campos se indexan en registry.db y permiten consultar:
```sql
SELECT id, source_repo, source_license FROM functions WHERE source_repo != '';
```
### Lenguajes soportados para extraccion
Cualquier lenguaje puede analizarse como fuente. El destino depende de la naturaleza de la funcion:
- Algoritmos/logica pura → Go (functions/{domain}/) o Python (python/functions/{domain}/)
- Funciones impuras (I/O, HTTP, DB) → Go o Python segun el dominio
- Scripts/utilidades sistema → Bash (bash/functions/{domain}/)
- UI/frontend → TypeScript (frontend/functions/{domain}/)
- Flujos multi-paso → Pipeline en el lenguaje mas natural
- C/Rust/otros → Traducir a Go o Python, manteniendo la semantica original
-5
View File
@@ -1,5 +0,0 @@
Los pipelines con tag `launcher` aparecen en el Pipeline Launcher TUI (`apps/pipeline_launcher`).
Sin el tag, el pipeline no es lanzable desde la TUI. Añadir `launcher` al array `tags` del .md al crear un pipeline ejecutable desde el launcher.
Pipelines interactivos (TUIs) o que no son subprocesos NO deben llevar este tag.
+41 -2
View File
@@ -1,5 +1,4 @@
# SQLite index (regenerable con fn index) — SOLO en raiz # SQLite index — journal/wal temporales
registry.db
registry.db-journal registry.db-journal
registry.db-wal registry.db-wal
@@ -24,6 +23,46 @@ registry.db-wal
*.swo *.swo
*~ *~
# Secrets
**/.env
**/.env.*
# Python
**/__pycache__/
**/*.pyc
**/*.pyo
python/.venv/
# Externalized apps and analysis (each is its own Gitea repo)
apps/*/
analysis/*/
# Projects (each is its own git repo, only project.md templates are versioned)
projects/*/
# Vaults — data stores (symlinks, dirs, files); only vault.yaml manifest is versioned
vaults/*/
!vaults/vault.yaml
# Node / pnpm
**/node_modules/
# Sources — repos externos clonados (solo se versiona el manifest)
sources/*/
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
temp/
# C++ build artifacts
cpp/build/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Archivos locales
.local
broken_paths.txt
imgui.ini
prompts/
+13
View File
@@ -0,0 +1,13 @@
[submodule "cpp/vendor/imgui"]
path = cpp/vendor/imgui
url = https://github.com/ocornut/imgui.git
branch = docking
[submodule "cpp/vendor/implot"]
path = cpp/vendor/implot
url = https://github.com/epezent/implot.git
[submodule "cpp/vendor/tracy"]
path = cpp/vendor/tracy
url = https://github.com/wolfpld/tracy.git
[submodule "/home/lucas/fn_registry/cpp/vendor/glfw"]
path = /home/lucas/fn_registry/cpp/vendor/glfw
url = https://github.com/glfw/glfw.git
View File
+18
View File
@@ -0,0 +1,18 @@
# Build output
dag_engine
*.exe
# Frontend build
frontend/dist/
frontend/node_modules/
# Go
vendor/
# Editor
.idea/
.vscode/
*.swp
# OS
.DS_Store
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"io/fs"
"net/http"
)
// RegisterAPI sets up all HTTP routes on the given mux.
func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, frontendFS fs.FS) {
// API routes.
mux.HandleFunc("GET /api/dags", handleListDags(executor))
mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor))
mux.HandleFunc("POST /api/dags/{name}/run", handleRunDag(executor))
mux.HandleFunc("GET /api/runs", handleListRuns(executor))
mux.HandleFunc("GET /api/runs/{id}", handleGetRun(executor))
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
// Frontend SPA fallback.
if frontendFS != nil {
mux.Handle("/", spaHandler(frontendFS))
}
}
// spaHandler serves static files from the embedded FS, falling back to index.html
// for unknown paths (SPA client-side routing).
func spaHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file directly.
path := r.URL.Path
if path == "/" {
path = "index.html"
} else {
path = path[1:] // strip leading /
}
if _, err := fs.Stat(fsys, path); err != nil {
// File not found — serve index.html for SPA routing.
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
+86
View File
@@ -0,0 +1,86 @@
---
name: dag_engine
lang: go
domain: infra
description: "Motor de ejecucion de DAGs con CLI y interfaz web. Reemplaza Dagu con implementacion propia compatible con el formato YAML existente. Almacena historial de ejecuciones en SQLite."
tags: [service, dag, workflow, scheduler, web, cron]
uses_functions:
- dag_parse_go_core
- dag_validate_go_core
- dag_topo_sort_go_core
- dag_resolve_env_go_core
- parse_cron_expr_go_core
- next_cron_time_go_core
- cron_ticker_go_infra
- cron_match_go_core
- process_spawn_go_infra
- process_wait_go_infra
- process_kill_go_infra
uses_types:
- dag_definition_go_core
- dag_step_go_core
- dag_validation_result_go_core
- cron_schedule_go_core
- process_handle_go_infra
- process_result_go_infra
- DagRun_go_infra
- DagStepResult_go_infra
framework: "net/http + vite + react"
entry_point: "main.go"
dir_path: "apps/dag_engine"
---
## Arquitectura
CLI + servidor web en un unico binario:
```
dag-engine run <path.yaml> # ejecuta un DAG desde terminal
dag-engine list [dir] # lista DAGs con schedule y estado
dag-engine status [dag_name] # historial de ejecuciones
dag-engine validate <path.yaml> # valida sin ejecutar
dag-engine server # arranca HTTP + frontend web
```
### Backend (Go)
- `net/http` con `ServeMux` (Go 1.22+ pattern routing)
- SQLite via `go-sqlite3` para historial de runs
- Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
- Scheduler: cron_ticker por cada DAG con schedule
### Frontend (Vite + React + Mantine)
- DagList: tabla de DAGs con schedule, tags, ultimo status
- DagDetail: metadata + "Run Now" + historial
- RunDetail: timeline de steps con stdout/stderr expandible
### Storage
SQLite `dag_engine.db`:
- `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
- `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms
### Build
```bash
cd frontend && pnpm install && pnpm build
cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
```
### Uso
```bash
# CLI
./dag-engine run ~/dagu/dags/example.yaml
./dag-engine list ~/dagu/dags/
# Servidor web
./dag-engine server --port 8090 --dags-dir ~/dagu/dags/ --scheduler
# Browser: http://localhost:8090
```
## Notas
Compatible con el formato YAML de Dagu. Lee DAGs existentes de `~/dagu/dags/` sin modificaciones.
Puerto por defecto 8090 (mismo que Dagu).
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"flag"
"os"
"path/filepath"
)
// Config holds the runtime configuration for the DAG engine.
type Config struct {
Port int
DagsDir string
DBPath string
AutoScheduler bool
}
// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
home, _ := os.UserHomeDir()
return Config{
Port: 8090,
DagsDir: filepath.Join(home, "dagu", "dags"),
DBPath: "dag_engine.db",
}
}
// ParseFlags populates config from CLI flags for the "server" subcommand.
func (c *Config) ParseFlags(fs *flag.FlagSet, args []string) error {
fs.IntVar(&c.Port, "port", c.Port, "HTTP server port")
fs.StringVar(&c.DagsDir, "dags-dir", c.DagsDir, "directory containing DAG YAML files")
fs.StringVar(&c.DBPath, "db", c.DBPath, "path to SQLite database")
fs.BoolVar(&c.AutoScheduler, "scheduler", c.AutoScheduler, "auto-start cron scheduler")
return fs.Parse(args)
}
+482
View File
@@ -0,0 +1,482 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
"dag-engine/store"
)
// Executor orchestrates DAG parsing, validation, and execution.
type Executor struct {
store *store.DB
dagsDir string
}
// NewExecutor creates a new executor.
func NewExecutor(s *store.DB, dagsDir string) *Executor {
return &Executor{store: s, dagsDir: dagsDir}
}
// ExecuteDAG runs a DAG from a YAML file path and returns the run ID.
// It runs asynchronously: steps execute in topological order with parallel levels.
func (e *Executor) ExecuteDAG(ctx context.Context, dagPath string, trigger string) (string, error) {
data, err := os.ReadFile(dagPath)
if err != nil {
return "", fmt.Errorf("read dag: %w", err)
}
dag, err := core.DagParse(data)
if err != nil {
return "", fmt.Errorf("parse dag: %w", err)
}
dag.FilePath = dagPath
// Resolve env variables.
dag = core.DagResolveEnv(dag, os.Environ())
// Validate.
result := core.DagValidate(dag)
if !result.Valid {
return "", fmt.Errorf("validate dag: %s", strings.Join(result.Errors, "; "))
}
// Create run record.
runID := generateID()
now := time.Now()
run := &store.DagRun{
ID: runID,
DagName: dag.Name,
DagPath: dagPath,
Status: "running",
Trigger: trigger,
StartedAt: now,
}
if err := e.store.CreateRun(run); err != nil {
return "", fmt.Errorf("create run: %w", err)
}
// Topological sort.
levels, err := core.DagTopoSort(dag.Steps)
if err != nil {
e.failRun(runID, err)
return runID, err
}
// Setup DAGU_ENV temp file for inter-step communication.
daguEnvFile, err := os.CreateTemp("", "dagu_env_*")
if err != nil {
e.failRun(runID, err)
return runID, err
}
daguEnvPath := daguEnvFile.Name()
daguEnvFile.Close()
defer os.Remove(daguEnvPath)
// Track step outputs for ${step_id.stdout} references.
stepOutputs := make(map[string]string)
// Execute levels.
runFailed := false
var runErr error
for _, level := range levels {
if runFailed {
// Skip remaining levels, mark steps as skipped.
for _, step := range level {
e.recordStepSkipped(runID, step)
}
continue
}
var wg sync.WaitGroup
var mu sync.Mutex
levelFailed := false
for _, step := range level {
step := step
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
if levelFailed {
mu.Unlock()
e.recordStepSkipped(runID, step)
return
}
mu.Unlock()
err := e.executeStep(ctx, runID, dag, step, daguEnvPath, stepOutputs, &mu)
if err != nil && !step.ContinueOn.Failure {
mu.Lock()
levelFailed = true
runFailed = true
runErr = fmt.Errorf("step %q failed: %w", stepName(step), err)
mu.Unlock()
}
}()
}
wg.Wait()
}
// Run handlers.
if runFailed {
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Failure, daguEnvPath, stepOutputs)
} else {
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, daguEnvPath, stepOutputs)
}
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, daguEnvPath, stepOutputs)
// Finalize run.
fin := time.Now()
status := "success"
errMsg := ""
if runFailed {
status = "failed"
if runErr != nil {
errMsg = runErr.Error()
}
}
e.store.UpdateRunStatus(runID, status, &fin, errMsg)
return runID, runErr
}
// executeStep runs a single step, recording results in the store.
func (e *Executor) executeStep(ctx context.Context, runID string, dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string, mu *sync.Mutex) error {
stepID := generateID()
now := time.Now()
e.store.InsertStepResult(&store.DagStepResult{
ID: stepID,
RunID: runID,
StepName: stepName(step),
Status: "running",
StartedAt: &now,
})
// Build environment.
env := buildStepEnv(dag, step, daguEnvPath, outputs)
// Determine command.
command := step.Command
if command == "" && step.Script != "" {
command = step.Script
}
if command == "" {
e.store.UpdateStepResult(stepID, "skipped", 0, "", "", nil, 0, "no command or script")
return nil
}
// Resolve step-level ${VAR} references and ${step_id.stdout} patterns.
mu.Lock()
command = resolveStepRefs(command, outputs)
mu.Unlock()
// Determine working directory.
dir := step.Dir
if dir == "" {
dir = dag.WorkingDir
}
shell := step.Shell
if shell == "" {
shell = dag.Shell
}
// Spawn process.
handle, err := infra.ProcessSpawn(command, dir, env, shell)
if err != nil {
fin := time.Now()
e.store.UpdateStepResult(stepID, "failed", -1, "", "", &fin, time.Since(now).Milliseconds(), err.Error())
return err
}
// Wait for process.
result, err := infra.ProcessWait(handle, step.TimeoutSec)
fin := time.Now()
duration := time.Since(now).Milliseconds()
if err != nil && result.ExitCode == 0 {
result.ExitCode = -1
}
status := "success"
errMsg := ""
if result.ExitCode != 0 || err != nil {
status = "failed"
if err != nil {
errMsg = err.Error()
}
}
e.store.UpdateStepResult(stepID, status, result.ExitCode, result.Stdout, result.Stderr, &fin, duration, errMsg)
// Store output for ${step_id.stdout} references.
if step.ID != "" || step.Output != "" {
mu.Lock()
key := step.ID
if key == "" {
key = step.Output
}
outputs[key] = strings.TrimSpace(result.Stdout)
mu.Unlock()
}
// Read DAGU_ENV for inter-step env propagation.
readDaguEnv(daguEnvPath, outputs)
if status == "failed" {
return fmt.Errorf("exit code %d", result.ExitCode)
}
return nil
}
func (e *Executor) runHandlers(ctx context.Context, runID string, dag core.DagDefinition, handlers []core.DagStep, daguEnvPath string, outputs map[string]string) {
var mu sync.Mutex
for _, step := range handlers {
e.executeStep(ctx, runID, dag, step, daguEnvPath, outputs, &mu)
}
}
func (e *Executor) failRun(runID string, err error) {
fin := time.Now()
e.store.UpdateRunStatus(runID, "failed", &fin, err.Error())
}
func (e *Executor) recordStepSkipped(runID string, step core.DagStep) {
now := time.Now()
e.store.InsertStepResult(&store.DagStepResult{
ID: generateID(),
RunID: runID,
StepName: stepName(step),
Status: "skipped",
StartedAt: &now,
})
}
// --- helpers ---
func stepName(s core.DagStep) string {
if s.Name != "" {
return s.Name
}
return s.ID
}
func buildStepEnv(dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string) []string {
env := os.Environ()
// Add DAG-level env.
for k, v := range dag.Env {
env = append(env, k+"="+v)
}
// Add step-level env.
for k, v := range step.Env {
env = append(env, k+"="+v)
}
// Add DAGU_ENV path.
env = append(env, "DAGU_ENV="+daguEnvPath)
return env
}
func resolveStepRefs(command string, outputs map[string]string) string {
for k, v := range outputs {
command = strings.ReplaceAll(command, "${"+k+".stdout}", v)
command = strings.ReplaceAll(command, "$"+k+".stdout", v)
}
return command
}
func readDaguEnv(path string, outputs map[string]string) {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
outputs[parts[0]] = parts[1]
}
}
}
// generateID creates a simple time-based unique ID.
func generateID() string {
return fmt.Sprintf("%d-%04x", time.Now().UnixNano(), time.Now().Nanosecond()%0xFFFF)
}
// --- DAG listing helpers ---
// DagInfo summarizes a DAG file for listing.
type DagInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Schedule []string `json:"schedule,omitempty"`
Tags []string `json:"tags,omitempty"`
Type string `json:"type,omitempty"`
FilePath string `json:"file_path"`
Valid bool `json:"valid"`
LastRun *store.DagRun `json:"last_run,omitempty"`
}
// ListDAGs scans a directory for YAML files and returns parsed DAG info.
func (e *Executor) ListDAGs() ([]DagInfo, error) {
entries, err := os.ReadDir(e.dagsDir)
if err != nil {
return nil, fmt.Errorf("read dags dir: %w", err)
}
var dags []DagInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext != ".yaml" && ext != ".yml" {
continue
}
path := filepath.Join(e.dagsDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
dag, err := core.DagParse(data)
if err != nil {
dags = append(dags, DagInfo{
Name: strings.TrimSuffix(entry.Name(), ext),
FilePath: path,
Valid: false,
})
continue
}
info := DagInfo{
Name: dag.Name,
Description: dag.Description,
Schedule: dag.Schedule,
Tags: dag.Tags,
Type: dag.Type,
FilePath: path,
Valid: true,
}
// Attach last run info.
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
if len(runs) > 0 {
info.LastRun = &runs[0]
}
dags = append(dags, info)
}
return dags, nil
}
// GetDAG returns detailed info for a specific DAG by name.
func (e *Executor) GetDAG(name string) (*DagInfo, *core.DagDefinition, *core.DagValidationResult, error) {
// Find the YAML file.
entries, err := os.ReadDir(e.dagsDir)
if err != nil {
return nil, nil, nil, err
}
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
base := strings.TrimSuffix(entry.Name(), ext)
if (ext != ".yaml" && ext != ".yml") || base != name {
continue
}
path := filepath.Join(e.dagsDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, nil, err
}
dag, err := core.DagParse(data)
if err != nil {
return nil, nil, nil, fmt.Errorf("parse: %w", err)
}
dag.FilePath = path
validationResult := core.DagValidate(dag)
info := &DagInfo{
Name: dag.Name,
Description: dag.Description,
Schedule: dag.Schedule,
Tags: dag.Tags,
Type: dag.Type,
FilePath: path,
Valid: validationResult.Valid,
}
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
if len(runs) > 0 {
info.LastRun = &runs[0]
}
return info, &dag, &validationResult, nil
}
return nil, nil, nil, fmt.Errorf("dag %q not found in %s", name, e.dagsDir)
}
// ValidateDAG parses and validates a DAG file, printing results.
func ValidateDAG(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
dag, err := core.DagParse(data)
if err != nil {
return fmt.Errorf("parse error: %w", err)
}
result := core.DagValidate(dag)
log.Printf("DAG: %s", dag.Name)
log.Printf("Steps: %d", len(dag.Steps))
log.Printf("Schedule: %v", dag.Schedule)
if result.Valid {
log.Printf("Validation: PASS")
log.Printf("Topological levels: %d", len(result.Levels))
for i, level := range result.Levels {
log.Printf(" Level %d: %v", i, level)
}
} else {
log.Printf("Validation: FAIL")
for _, e := range result.Errors {
log.Printf(" ERROR: %s", e)
}
}
for _, w := range result.Warnings {
log.Printf(" WARNING: %s", w)
}
if !result.Valid {
return fmt.Errorf("validation failed")
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DAG Engine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "dag-engine-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"@tabler/icons-react": "^3.31.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1"
},
"devDependencies": {
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"postcss": "^8.5.4",
"postcss-preset-mantine": "^1.17.0",
"typescript": "~5.8.3",
"vite": "^6.3.5"
}
}
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
},
};
+32
View File
@@ -0,0 +1,32 @@
import { Routes, Route } from "react-router-dom";
import { AppShell, Container, Title, Group, Text } from "@mantine/core";
import { IconTopologyRing } from "@tabler/icons-react";
import { DagList } from "./pages/DagList";
import { DagDetail } from "./pages/DagDetail";
import { RunDetail } from "./pages/RunDetail";
export function App() {
return (
<AppShell header={{ height: 50 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md">
<IconTopologyRing size={24} />
<Title order={4}>DAG Engine</Title>
<Text size="xs" c="dimmed">
fn_registry workflow executor
</Text>
</Group>
</AppShell.Header>
<AppShell.Main>
<Container size="lg">
<Routes>
<Route path="/" element={<DagList />} />
<Route path="/dags/:name" element={<DagDetail />} />
<Route path="/runs/:id" element={<RunDetail />} />
</Routes>
</Container>
</AppShell.Main>
</AppShell>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type {
DagSummary,
DagDetail,
DagRun,
RunDetail,
SchedulerStatus,
} from "./types";
const BASE = "/api";
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, init);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export function listDags(): Promise<DagSummary[]> {
return fetchJSON("/dags");
}
export function getDag(name: string): Promise<DagDetail> {
return fetchJSON(`/dags/${encodeURIComponent(name)}`);
}
export function triggerDag(
name: string
): Promise<{ status: string; dag: string; message: string }> {
return fetchJSON(`/dags/${encodeURIComponent(name)}/run`, {
method: "POST",
});
}
export function listRuns(params?: {
dag?: string;
limit?: number;
offset?: number;
}): Promise<{ runs: DagRun[]; total: number }> {
const search = new URLSearchParams();
if (params?.dag) search.set("dag", params.dag);
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
const qs = search.toString();
return fetchJSON(`/runs${qs ? "?" + qs : ""}`);
}
export function getRun(id: string): Promise<RunDetail> {
return fetchJSON(`/runs/${encodeURIComponent(id)}`);
}
export function startScheduler(): Promise<void> {
return fetchJSON("/scheduler/start", { method: "POST" });
}
export function stopScheduler(): Promise<void> {
return fetchJSON("/scheduler/stop", { method: "POST" });
}
export function getSchedulerStatus(): Promise<SchedulerStatus> {
return fetchJSON("/scheduler/status");
}
@@ -0,0 +1,18 @@
import { Badge } from "@mantine/core";
const colorMap: Record<string, string> = {
success: "green",
failed: "red",
running: "blue",
pending: "gray",
cancelled: "yellow",
skipped: "dimmed",
};
export function StatusBadge({ status }: { status: string }) {
return (
<Badge color={colorMap[status] || "gray"} variant="light" size="sm">
{status}
</Badge>
);
}
@@ -0,0 +1,85 @@
import { Timeline, Text, Code, Collapse, Box, Group } from "@mantine/core";
import {
IconCircleCheck,
IconCircleX,
IconLoader,
IconCircleMinus,
IconClock,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import type { DagStepResult } from "../types";
const iconMap: Record<string, React.ReactNode> = {
success: <IconCircleCheck size={16} color="var(--mantine-color-green-6)" />,
failed: <IconCircleX size={16} color="var(--mantine-color-red-6)" />,
running: <IconLoader size={16} color="var(--mantine-color-blue-6)" />,
skipped: <IconCircleMinus size={16} color="var(--mantine-color-dimmed)" />,
pending: <IconClock size={16} color="var(--mantine-color-gray-6)" />,
};
function StepItem({ step }: { step: DagStepResult }) {
const [opened, { toggle }] = useDisclosure(step.Status === "failed");
const hasOutput = step.Stdout || step.Stderr;
return (
<Timeline.Item
bullet={iconMap[step.Status] || iconMap.pending}
title={
<Group gap="xs">
<Text
size="sm"
fw={500}
onClick={hasOutput ? toggle : undefined}
style={hasOutput ? { cursor: "pointer" } : undefined}
>
{step.StepName}
</Text>
<Text size="xs" c="dimmed">
{step.DurationMs}ms
</Text>
{step.ExitCode !== 0 && step.ExitCode !== -1 && (
<Text size="xs" c="red">
exit {step.ExitCode}
</Text>
)}
</Group>
}
>
{hasOutput && (
<Collapse in={opened}>
<Box mt="xs">
{step.Stdout && (
<Code block mb="xs" style={{ maxHeight: 200, overflow: "auto" }}>
{step.Stdout}
</Code>
)}
{step.Stderr && (
<Code
block
color="red"
style={{ maxHeight: 200, overflow: "auto" }}
>
{step.Stderr}
</Code>
)}
</Box>
</Collapse>
)}
</Timeline.Item>
);
}
export function StepTimeline({ steps }: { steps: DagStepResult[] }) {
const activeIndex = steps.findIndex((s) => s.Status === "running");
return (
<Timeline
active={activeIndex >= 0 ? activeIndex : steps.length - 1}
bulletSize={24}
>
{steps.map((step) => (
<StepItem key={step.ID} step={step} />
))}
</Timeline>
);
}
+18
View File
@@ -0,0 +1,18 @@
import "@mantine/core/styles.css";
import { MantineProvider, createTheme } from "@mantine/core";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
const theme = createTheme({
primaryColor: "blue",
fontFamily: "system-ui, -apple-system, sans-serif",
});
createRoot(document.getElementById("root")!).render(
<MantineProvider theme={theme} defaultColorScheme="dark">
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
);
@@ -0,0 +1,204 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Title,
Text,
Group,
Button,
Badge,
Stack,
Paper,
Table,
Alert,
Loader,
Code,
} from "@mantine/core";
import { IconPlayerPlay, IconArrowLeft } from "@tabler/icons-react";
import { getDag, triggerDag } from "../api";
import { StatusBadge } from "../components/StatusBadge";
import type { DagDetail as DagDetailType } from "../types";
export function DagDetail() {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [data, setData] = useState<DagDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState(false);
const load = async () => {
if (!name) return;
setLoading(true);
try {
setData(await getDag(name));
setError(null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [name]);
const handleRun = async () => {
if (!name) return;
setTriggering(true);
try {
await triggerDag(name);
setTimeout(load, 1000);
} catch (e) {
setError((e as Error).message);
} finally {
setTriggering(false);
}
};
if (loading) return <Loader />;
if (error) return <Alert color="red">{error}</Alert>;
if (!data) return <Text>Not found</Text>;
const { dag, validation, runs } = data;
return (
<Stack gap="md">
<Group>
<Button
variant="subtle"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate("/")}
>
Back
</Button>
</Group>
<Group justify="space-between">
<div>
<Title order={2}>{dag.Name}</Title>
{dag.Description && (
<Text size="sm" c="dimmed">
{dag.Description}
</Text>
)}
</div>
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={handleRun}
loading={triggering}
>
Run Now
</Button>
</Group>
<Group gap="xs">
{dag.Schedule?.map((s: string) => (
<Badge key={s} variant="light" ff="monospace">
{s}
</Badge>
))}
<Badge variant="light">{dag.Type || "chain"}</Badge>
{dag.Tags?.map((t: string) => (
<Badge key={t} variant="dot">
{t}
</Badge>
))}
</Group>
{!validation.Valid && (
<Alert color="red" title="Validation errors">
{validation.Errors.map((e: string, i: number) => (
<Text key={i} size="sm">
{e}
</Text>
))}
</Alert>
)}
<Paper p="md" withBorder>
<Title order={4} mb="sm">
Steps ({dag.Steps?.length || 0})
</Title>
{validation.Levels?.map((level: string[], i: number) => (
<Group key={i} gap="xs" mb="xs">
<Text size="xs" c="dimmed" w={60}>
Level {i}:
</Text>
{level.map((name: string) => {
const step = dag.Steps?.find(
(s) => s.Name === name || s.ID === name
);
return (
<Badge key={name} variant="outline" size="sm">
{name}
{step?.Depends?.length
? ` (after ${step.Depends.join(",")})`
: ""}
</Badge>
);
})}
</Group>
))}
{dag.Env && Object.keys(dag.Env).length > 0 && (
<>
<Title order={5} mt="md" mb="xs">
Environment
</Title>
<Code block>
{Object.entries(dag.Env)
.map(([k, v]) => `${k}=${v}`)
.join("\n")}
</Code>
</>
)}
</Paper>
<Paper p="md" withBorder>
<Title order={4} mb="sm">
Run History
</Title>
{runs?.length ? (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Trigger</Table.Th>
<Table.Th>Started</Table.Th>
<Table.Th>Duration</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((r) => (
<Table.Tr
key={r.ID}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/runs/${r.ID}`)}
>
<Table.Td>
<StatusBadge status={r.Status} />
</Table.Td>
<Table.Td>{r.Trigger}</Table.Td>
<Table.Td>
{new Date(r.StartedAt).toLocaleString()}
</Table.Td>
<Table.Td>
{r.FinishedAt
? `${Math.round((new Date(r.FinishedAt).getTime() - new Date(r.StartedAt).getTime()) / 1000)}s`
: "running..."}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="sm" c="dimmed">
No runs yet
</Text>
)}
</Paper>
</Stack>
);
}
@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Table,
Title,
Group,
Button,
Badge,
Text,
Loader,
Stack,
Alert,
} from "@mantine/core";
import {
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
} from "@tabler/icons-react";
import { listDags, getSchedulerStatus, startScheduler, stopScheduler } from "../api";
import { StatusBadge } from "../components/StatusBadge";
import type { DagSummary, SchedulerStatus } from "../types";
export function DagList() {
const [dags, setDags] = useState<DagSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scheduler, setScheduler] = useState<SchedulerStatus | null>(null);
const navigate = useNavigate();
const load = async () => {
setLoading(true);
setError(null);
try {
const [d, s] = await Promise.all([listDags(), getSchedulerStatus()]);
setDags(d || []);
setScheduler(s);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, []);
const toggleScheduler = async () => {
if (scheduler?.running) {
await stopScheduler();
} else {
await startScheduler();
}
const s = await getSchedulerStatus();
setScheduler(s);
};
return (
<Stack gap="md">
<Group justify="space-between">
<Title order={2}>DAGs</Title>
<Group gap="xs">
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={load}
>
Refresh
</Button>
<Button
size="xs"
variant={scheduler?.running ? "filled" : "light"}
color={scheduler?.running ? "green" : "gray"}
leftSection={
scheduler?.running ? (
<IconPlayerStop size={14} />
) : (
<IconPlayerPlay size={14} />
)
}
onClick={toggleScheduler}
>
Scheduler {scheduler?.running ? "ON" : "OFF"}
</Button>
</Group>
</Group>
{error && <Alert color="red">{error}</Alert>}
{loading && !dags.length ? (
<Loader />
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Schedule</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th>Last Status</Table.Th>
<Table.Th>Last Run</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{dags.map((d) => (
<Table.Tr
key={d.file_path}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dags/${d.name}`)}
>
<Table.Td>
<Text fw={500}>{d.name}</Text>
{d.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{d.description}
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace">
{d.schedule?.join(", ") || "-"}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" size="xs">
{d.type || "chain"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4}>
{d.tags?.map((t) => (
<Badge key={t} variant="dot" size="xs">
{t}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
{d.last_run ? (
<StatusBadge status={d.last_run.Status} />
) : (
<Text size="xs" c="dimmed">
-
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="xs">
{d.last_run
? new Date(d.last_run.StartedAt).toLocaleString()
: "-"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Title,
Text,
Group,
Button,
Stack,
Paper,
Alert,
Loader,
} from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import { getRun } from "../api";
import { StatusBadge } from "../components/StatusBadge";
import { StepTimeline } from "../components/StepTimeline";
import type { RunDetail as RunDetailType } from "../types";
export function RunDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [data, setData] = useState<RunDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
if (!id) return;
try {
setData(await getRun(id));
setError(null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// Auto-refresh while running.
const interval = setInterval(() => {
if (data?.run.Status === "running") {
load();
}
}, 2000);
return () => clearInterval(interval);
}, [id, data?.run.Status]);
if (loading) return <Loader />;
if (error) return <Alert color="red">{error}</Alert>;
if (!data) return <Text>Not found</Text>;
const { run, steps } = data;
const duration = run.FinishedAt
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
: "running...";
return (
<Stack gap="md">
<Group>
<Button
variant="subtle"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(`/dags/${run.DagName}`)}
>
Back to {run.DagName}
</Button>
</Group>
<Group justify="space-between">
<div>
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
<Text size="sm" c="dimmed">
{run.DagName} &middot; {run.Trigger} &middot;{" "}
{new Date(run.StartedAt).toLocaleString()}
</Text>
</div>
<Group gap="xs">
<StatusBadge status={run.Status} />
<Text size="sm">{duration}</Text>
</Group>
</Group>
{run.Error && (
<Alert color="red" title="Error">
{run.Error}
</Alert>
)}
<Paper p="md" withBorder>
<Title order={4} mb="md">
Steps ({steps?.length || 0})
</Title>
{steps?.length ? (
<StepTimeline steps={steps} />
) : (
<Text size="sm" c="dimmed">
No steps recorded
</Text>
)}
</Paper>
</Stack>
);
}
+66
View File
@@ -0,0 +1,66 @@
export interface DagSummary {
name: string;
description?: string;
schedule?: string[];
tags?: string[];
type?: string;
file_path: string;
valid: boolean;
last_run?: DagRun;
}
export interface DagRun {
ID: string;
DagName: string;
DagPath: string;
Status: string;
Trigger: string;
StartedAt: string;
FinishedAt?: string;
Error: string;
}
export interface DagStepResult {
ID: string;
RunID: string;
StepName: string;
Status: string;
ExitCode: number;
Stdout: string;
Stderr: string;
StartedAt?: string;
FinishedAt?: string;
DurationMs: number;
Error: string;
}
export interface DagDetail {
info: DagSummary;
dag: {
Name: string;
Description: string;
Type: string;
Schedule: string[];
Steps: { Name: string; ID: string; Command: string; Script: string; Depends: string[] }[];
Env: Record<string, string>;
Tags: string[];
HandlerOn: { Failure: unknown[]; Success: unknown[] };
};
validation: {
Valid: boolean;
Errors: string[];
Warnings: string[];
Levels: string[][];
};
runs: DagRun[];
}
export interface RunDetail {
run: DagRun;
steps: DagStepResult[];
}
export interface SchedulerStatus {
running: boolean;
dags: { name: string; path: string; schedule: string; next_run: string }[];
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5175,
proxy: {
"/api": "http://localhost:8090",
},
},
build: {
outDir: "dist",
},
});
+48
View File
@@ -0,0 +1,48 @@
module dag-engine
go 1.25.0
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/mattn/go-sqlite3 v1.14.37
)
require (
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace fn-registry => /home/lucas/fn_registry
+168
View File
@@ -0,0 +1,168 @@
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
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/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"context"
"encoding/json"
"net/http"
)
func handleListDags(executor *Executor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dags, err := executor.ListDAGs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, dags)
}
}
func handleGetDag(executor *Executor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
info, dag, validation, err := executor.GetDAG(name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
// Get recent runs.
runs, _, _ := executor.store.ListRuns(dag.Name, 10, 0)
resp := map[string]interface{}{
"info": info,
"dag": dag,
"validation": validation,
"runs": runs,
}
writeJSON(w, http.StatusOK, resp)
}
}
func handleRunDag(executor *Executor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
info, _, _, err := executor.GetDAG(name)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
// Execute asynchronously.
go func() {
ctx := context.Background()
executor.ExecuteDAG(ctx, info.FilePath, "api")
}()
// Return run acknowledgment.
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "accepted",
"dag": name,
"message": "DAG execution started",
})
}
}
// --- JSON helpers ---
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"net/http"
"strconv"
)
func handleListRuns(executor *Executor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dagName := r.URL.Query().Get("dag")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
runs, total, err := executor.store.ListRuns(dagName, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"runs": runs,
"total": total,
"limit": limit,
"offset": offset,
})
}
}
func handleGetRun(executor *Executor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
run, err := executor.store.GetRun(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if run == nil {
writeError(w, http.StatusNotFound, "run not found")
return
}
steps, err := executor.store.ListStepResults(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"run": run,
"steps": steps,
})
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import "net/http"
func handleSchedulerStart(scheduler *Scheduler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := scheduler.Start(); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
}
}
func handleSchedulerStop(scheduler *Scheduler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
scheduler.Stop()
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
}
func handleSchedulerStatus(scheduler *Scheduler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status := scheduler.Status()
writeJSON(w, http.StatusOK, status)
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
iofs "io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"text/tabwriter"
"time"
"fn-registry/functions/core"
"dag-engine/store"
)
//go:embed all:frontend/dist
var frontendDist embed.FS
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "run":
cmdRun(args)
case "list":
cmdList(args)
case "status":
cmdStatus(args)
case "validate":
cmdValidate(args)
case "server":
cmdServer(args)
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`dag-engine DAG workflow executor
Usage:
dag-engine <command> [options]
Commands:
run <path.yaml> Execute a DAG and show results
list [dir] List DAGs with schedule and last status
status [dag_name] Show execution history
validate <path.yaml> Parse and validate without executing
server Start HTTP server with web frontend
Server options:
--port <port> HTTP port (default: 8090)
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
--db <path> SQLite database path (default: dag_engine.db)
--scheduler Auto-start cron scheduler`)
}
// --- CLI Commands ---
func cmdRun(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: dag-engine run <path.yaml>")
os.Exit(1)
}
dagPath := args[0]
cfg := DefaultConfig()
// Parse optional flags after the path.
fs := flag.NewFlagSet("run", flag.ExitOnError)
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
fs.Parse(args[1:])
db, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
executor := NewExecutor(db, filepath.Dir(dagPath))
fmt.Printf("Executing %s...\n", dagPath)
ctx := context.Background()
runID, err := executor.ExecuteDAG(ctx, dagPath, "manual")
// Print results.
if runID != "" {
run, _ := db.GetRun(runID)
steps, _ := db.ListStepResults(runID)
if run != nil {
fmt.Println()
for _, s := range steps {
icon := " "
switch s.Status {
case "success":
icon = "OK"
case "failed":
icon = "!!"
case "skipped":
icon = "--"
case "running":
icon = ".."
}
fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs)
if s.Status == "failed" && s.Stderr != "" {
for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") {
fmt.Printf(" %s\n", line)
}
}
}
fmt.Println()
dur := ""
if run.FinishedAt != nil {
dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond))
}
fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur)
}
}
if err != nil {
os.Exit(1)
}
}
func cmdList(args []string) {
cfg := DefaultConfig()
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
cfg.DagsDir = args[0]
args = args[1:]
}
fs := flag.NewFlagSet("list", flag.ExitOnError)
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
fs.Parse(args)
db, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
executor := NewExecutor(db, cfg.DagsDir)
dags, err := executor.ListDAGs()
if err != nil {
log.Fatalf("list dags: %v", err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN")
for _, d := range dags {
sched := strings.Join(d.Schedule, ", ")
tags := strings.Join(d.Tags, ", ")
lastStatus := "-"
lastRun := "-"
if d.LastRun != nil {
lastStatus = d.LastRun.Status
lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04")
}
typ := d.Type
if typ == "" {
typ = "chain"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun)
}
w.Flush()
}
func cmdStatus(args []string) {
cfg := DefaultConfig()
fs := flag.NewFlagSet("status", flag.ExitOnError)
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
limit := fs.Int("limit", 10, "number of runs to show")
fs.Parse(args)
dagName := ""
if fs.NArg() > 0 {
dagName = fs.Arg(0)
}
db, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
runs, total, err := db.ListRuns(dagName, *limit, 0)
if err != nil {
log.Fatalf("list runs: %v", err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total)
if dagName != "" {
fmt.Fprintf(w, " for %s", dagName)
}
fmt.Fprintln(w)
fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION")
for _, r := range runs {
dur := "-"
if r.FinishedAt != nil {
dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String()
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
r.ID, r.DagName, r.Status, r.Trigger,
r.StartedAt.Format("2006-01-02 15:04:05"), dur)
}
w.Flush()
}
func cmdValidate(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: dag-engine validate <path.yaml>")
os.Exit(1)
}
data, err := os.ReadFile(args[0])
if err != nil {
log.Fatalf("read: %v", err)
}
dag, err := core.DagParse(data)
if err != nil {
log.Fatalf("parse error: %v", err)
}
result := core.DagValidate(dag)
fmt.Printf("DAG: %s\n", dag.Name)
fmt.Printf("Steps: %d\n", len(dag.Steps))
fmt.Printf("Schedule: %v\n", dag.Schedule)
fmt.Printf("Type: %s\n", dag.Type)
if result.Valid {
fmt.Println("Validation: PASS")
for i, level := range result.Levels {
fmt.Printf(" Level %d: %v\n", i, level)
}
} else {
fmt.Println("Validation: FAIL")
for _, e := range result.Errors {
fmt.Printf(" ERROR: %s\n", e)
}
}
for _, w := range result.Warnings {
fmt.Printf(" WARNING: %s\n", w)
}
if !result.Valid {
os.Exit(1)
}
}
// --- Server Command ---
func cmdServer(args []string) {
cfg := DefaultConfig()
fs := flag.NewFlagSet("server", flag.ExitOnError)
cfg.ParseFlags(fs, args)
db, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
executor := NewExecutor(db, cfg.DagsDir)
scheduler := NewScheduler(executor, cfg.DagsDir)
// Prepare frontend FS.
var feFS iofs.FS
distFS, err := iofs.Sub(frontendDist, "frontend/dist")
if err == nil {
// Check if dist has content (built frontend exists).
entries, _ := iofs.ReadDir(distFS, ".")
if len(entries) > 0 {
feFS = distFS
log.Printf("serving frontend from embedded dist/")
}
}
if feFS == nil {
log.Printf("no frontend build found, API-only mode")
}
mux := http.NewServeMux()
RegisterAPI(mux, executor, scheduler, feFS)
handler := corsMiddleware(loggingMiddleware(mux))
if cfg.AutoScheduler {
if err := scheduler.Start(); err != nil {
log.Printf("scheduler start: %v", err)
}
}
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("dag-engine server starting on http://0.0.0.0%s", addr)
log.Printf("dags dir: %s", cfg.DagsDir)
log.Printf("database: %s", cfg.DBPath)
srv := &http.Server{Addr: addr, Handler: handler}
// Graceful shutdown.
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
scheduler.Stop()
srv.Shutdown(context.Background())
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"log"
"net/http"
"time"
)
// corsMiddleware adds permissive CORS headers for development.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// loggingMiddleware logs each HTTP request with method, path and duration.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
)
// ScheduledDAG represents a DAG with a parsed cron schedule.
type ScheduledDAG struct {
Name string `json:"name"`
Path string `json:"path"`
Schedule string `json:"schedule"`
NextRun time.Time `json:"next_run"`
}
// Scheduler manages cron-triggered DAG execution.
type Scheduler struct {
mu sync.Mutex
running bool
cancel context.CancelFunc
dagsDir string
executor *Executor
dags []ScheduledDAG
}
// NewScheduler creates a new scheduler.
func NewScheduler(executor *Executor, dagsDir string) *Scheduler {
return &Scheduler{
executor: executor,
dagsDir: dagsDir,
}
}
// Start scans for DAGs with schedules and starts cron tickers for each.
func (s *Scheduler) Start() error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return fmt.Errorf("scheduler already running")
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.running = true
s.mu.Unlock()
scheduled, err := s.scanDAGs()
if err != nil {
s.mu.Lock()
s.running = false
s.mu.Unlock()
cancel()
return err
}
s.mu.Lock()
s.dags = scheduled
s.mu.Unlock()
log.Printf("[scheduler] started with %d DAGs", len(scheduled))
for _, dag := range scheduled {
dag := dag
go s.runTicker(ctx, dag)
}
return nil
}
// Stop cancels all tickers and stops the scheduler.
func (s *Scheduler) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
s.cancel()
s.running = false
s.dags = nil
log.Printf("[scheduler] stopped")
}
// IsRunning returns true if the scheduler is active.
func (s *Scheduler) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// Status returns the list of scheduled DAGs with their next run time.
type SchedulerStatus struct {
Running bool `json:"running"`
DAGs []ScheduledDAG `json:"dags"`
}
func (s *Scheduler) Status() SchedulerStatus {
s.mu.Lock()
defer s.mu.Unlock()
return SchedulerStatus{
Running: s.running,
DAGs: s.dags,
}
}
// scanDAGs reads the dags directory and returns DAGs that have cron schedules.
func (s *Scheduler) scanDAGs() ([]ScheduledDAG, error) {
entries, err := os.ReadDir(s.dagsDir)
if err != nil {
return nil, err
}
var scheduled []ScheduledDAG
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
if ext != ".yaml" && ext != ".yml" {
continue
}
path := filepath.Join(s.dagsDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
dag, err := core.DagParse(data)
if err != nil {
continue
}
for _, expr := range dag.Schedule {
sched, err := core.ParseCronExpr(strings.TrimSpace(expr))
if err != nil {
log.Printf("[scheduler] invalid cron %q in %s: %v", expr, dag.Name, err)
continue
}
next := core.NextCronTime(sched, time.Now())
scheduled = append(scheduled, ScheduledDAG{
Name: dag.Name,
Path: path,
Schedule: expr,
NextRun: next,
})
}
}
return scheduled, nil
}
// runTicker starts a cron ticker for a single DAG schedule.
func (s *Scheduler) runTicker(ctx context.Context, dag ScheduledDAG) {
sched, err := core.ParseCronExpr(strings.TrimSpace(dag.Schedule))
if err != nil {
return
}
// Convert core.CronSchedule to infra.CronTickerSchedule.
tickerSched := infra.CronTickerSchedule{
Minute: sched.Minute,
Hour: sched.Hour,
DayOfMonth: sched.DayOfMonth,
Month: sched.Month,
DayOfWeek: sched.DayOfWeek,
}
ch := infra.CronTicker(tickerSched, ctx)
log.Printf("[scheduler] ticker started for %s (%s), next: %s", dag.Name, dag.Schedule, dag.NextRun.Format(time.RFC3339))
for t := range ch {
log.Printf("[scheduler] triggered %s at %s", dag.Name, t.Format(time.RFC3339))
go func() {
runID, err := s.executor.ExecuteDAG(ctx, dag.Path, "cron")
if err != nil {
log.Printf("[scheduler] %s failed: %v (run: %s)", dag.Name, err, runID)
} else {
log.Printf("[scheduler] %s completed (run: %s)", dag.Name, runID)
}
}()
}
}
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS dag_runs (
id TEXT PRIMARY KEY,
dag_name TEXT NOT NULL,
dag_path TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','cancelled')),
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual','cron','api')),
started_at TEXT NOT NULL,
finished_at TEXT,
error TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS dag_step_results (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES dag_runs(id) ON DELETE CASCADE,
step_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','skipped')),
exit_code INTEGER NOT NULL DEFAULT -1,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_runs_dag_name ON dag_runs(dag_name);
CREATE INDEX IF NOT EXISTS idx_runs_status ON dag_runs(status);
CREATE INDEX IF NOT EXISTS idx_runs_started ON dag_runs(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_step_results_run ON dag_step_results(run_id);
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"database/sql"
_ "embed"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/001_init.sql
var migrationSQL string
// DB wraps a SQLite connection for DAG run persistence.
type DB struct {
conn *sql.DB
path string
}
// Open opens or creates a DAG engine database at the given path.
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("store: open %s: %w", path, err)
}
if _, err := conn.Exec(migrationSQL); err != nil {
conn.Close()
return nil, fmt.Errorf("store: migrate: %w", err)
}
return &DB{conn: conn, path: path}, nil
}
// Close closes the database connection.
func (db *DB) Close() error {
return db.conn.Close()
}
// --- DagRun CRUD ---
// DagRun mirrors infra.DagRun for the store layer.
type DagRun struct {
ID string
DagName string
DagPath string
Status string
Trigger string
StartedAt time.Time
FinishedAt *time.Time
Error string
}
// CreateRun inserts a new run record.
func (db *DB) CreateRun(run *DagRun) error {
_, err := db.conn.Exec(
`INSERT INTO dag_runs (id, dag_name, dag_path, status, trigger, started_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
run.ID, run.DagName, run.DagPath, run.Status, run.Trigger,
run.StartedAt.Format(time.RFC3339), run.Error,
)
return err
}
// UpdateRunStatus updates a run's status and optionally its finished_at and error.
func (db *DB) UpdateRunStatus(id, status string, finishedAt *time.Time, errMsg string) error {
var fin *string
if finishedAt != nil {
s := finishedAt.Format(time.RFC3339)
fin = &s
}
_, err := db.conn.Exec(
`UPDATE dag_runs SET status=?, finished_at=?, error=? WHERE id=?`,
status, fin, errMsg, id,
)
return err
}
// GetRun retrieves a single run by ID.
func (db *DB) GetRun(id string) (*DagRun, error) {
row := db.conn.QueryRow(
`SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error
FROM dag_runs WHERE id=?`, id,
)
return scanRun(row)
}
// ListRuns returns runs, newest first, with optional dag name filter.
func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) {
var total int
var args []interface{}
where := ""
if dagName != "" {
where = " WHERE dag_name=?"
args = append(args, dagName)
}
err := db.conn.QueryRow("SELECT COUNT(*) FROM dag_runs"+where, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
query := "SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error FROM dag_runs" +
where + " ORDER BY started_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var runs []DagRun
for rows.Next() {
r, err := scanRunRows(rows)
if err != nil {
return nil, 0, err
}
runs = append(runs, *r)
}
return runs, total, rows.Err()
}
// --- DagStepResult CRUD ---
// DagStepResult mirrors infra.DagStepResult for the store layer.
type DagStepResult struct {
ID string
RunID string
StepName string
Status string
ExitCode int
Stdout string
Stderr string
StartedAt *time.Time
FinishedAt *time.Time
DurationMs int64
Error string
}
// InsertStepResult inserts a new step result.
func (db *DB) InsertStepResult(r *DagStepResult) error {
var startedAt, finishedAt *string
if r.StartedAt != nil {
s := r.StartedAt.Format(time.RFC3339)
startedAt = &s
}
if r.FinishedAt != nil {
s := r.FinishedAt.Format(time.RFC3339)
finishedAt = &s
}
_, err := db.conn.Exec(
`INSERT INTO dag_step_results (id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.RunID, r.StepName, r.Status, r.ExitCode, r.Stdout, r.Stderr,
startedAt, finishedAt, r.DurationMs, r.Error,
)
return err
}
// UpdateStepResult updates a step result by ID.
func (db *DB) UpdateStepResult(id, status string, exitCode int, stdout, stderr string, finishedAt *time.Time, durationMs int64, errMsg string) error {
var fin *string
if finishedAt != nil {
s := finishedAt.Format(time.RFC3339)
fin = &s
}
_, err := db.conn.Exec(
`UPDATE dag_step_results SET status=?, exit_code=?, stdout=?, stderr=?, finished_at=?, duration_ms=?, error=? WHERE id=?`,
status, exitCode, stdout, stderr, fin, durationMs, errMsg, id,
)
return err
}
// ListStepResults returns all step results for a given run.
func (db *DB) ListStepResults(runID string) ([]DagStepResult, error) {
rows, err := db.conn.Query(
`SELECT id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error
FROM dag_step_results WHERE run_id=? ORDER BY started_at ASC`, runID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var results []DagStepResult
for rows.Next() {
var r DagStepResult
var startedAt, finishedAt sql.NullString
if err := rows.Scan(&r.ID, &r.RunID, &r.StepName, &r.Status, &r.ExitCode,
&r.Stdout, &r.Stderr, &startedAt, &finishedAt, &r.DurationMs, &r.Error); err != nil {
return nil, err
}
if startedAt.Valid {
t, _ := time.Parse(time.RFC3339, startedAt.String)
r.StartedAt = &t
}
if finishedAt.Valid {
t, _ := time.Parse(time.RFC3339, finishedAt.String)
r.FinishedAt = &t
}
results = append(results, r)
}
return results, rows.Err()
}
// --- scan helpers ---
type scanner interface {
Scan(dest ...interface{}) error
}
func scanRun(s scanner) (*DagRun, error) {
var r DagRun
var startedAt string
var finishedAt sql.NullString
if err := s.Scan(&r.ID, &r.DagName, &r.DagPath, &r.Status, &r.Trigger, &startedAt, &finishedAt, &r.Error); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
r.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
if finishedAt.Valid {
t, _ := time.Parse(time.RFC3339, finishedAt.String)
r.FinishedAt = &t
}
return &r, nil
}
func scanRunRows(rows *sql.Rows) (*DagRun, error) {
return scanRun(rows)
}
-5
View File
@@ -1,5 +0,0 @@
build/
*.exe
*.dll
*.so
*.dylib
-19
View File
@@ -1,19 +0,0 @@
.PHONY: run build clean install tidy help
run: ## Ejecuta la TUI
go run .
build: ## Compila el binario
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
clean: ## Limpia artefactos
rm -rf build/
install: build ## Instala en ~/.local/bin
cp build/docker-tui ~/.local/bin/docker-tui
tidy: ## go mod tidy
go mod tidy
help: ## Muestra esta ayuda
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
-175
View File
@@ -1,175 +0,0 @@
package app
import (
"docker-tui/views"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type View int
const (
ViewContainers View = iota
ViewImages
ViewVolumes
ViewNetworks
ViewCompose
)
var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"}
type Model struct {
tui.BaseModel
activeTab int
containers views.ContainersModel
images views.ImagesModel
volumes views.VolumesModel
networks views.NetworksModel
compose views.ComposeModel
ready bool
}
func New() Model {
styles := tui.DefaultStyles()
return Model{
BaseModel: tui.NewBaseModel().WithStyles(styles),
containers: views.NewContainersModel(styles),
images: views.NewImagesModel(styles),
volumes: views.NewVolumesModel(styles),
networks: views.NewNetworksModel(styles),
compose: views.NewComposeModel(styles),
}
}
func (m Model) Init() tea.Cmd {
return m.containers.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case views.KeyQuit:
return m, tea.Quit
case "q", "0", "esc":
updated, atBase := m.handleBack()
if atBase {
return updated, tea.Quit
}
return updated, nil
case views.KeyTab:
m.activeTab = (m.activeTab + 1) % len(tabNames)
return m, m.initActiveView()
case "shift+tab":
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
return m, m.initActiveView()
}
case tea.WindowSizeMsg:
m.HandleWindowSize(msg)
m.ready = true
}
var cmd tea.Cmd
switch View(m.activeTab) {
case ViewContainers:
m.containers, cmd = m.containers.Update(msg)
case ViewImages:
m.images, cmd = m.images.Update(msg)
case ViewVolumes:
m.volumes, cmd = m.volumes.Update(msg)
case ViewNetworks:
m.networks, cmd = m.networks.Update(msg)
case ViewCompose:
m.compose, cmd = m.compose.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
// Tab bar
tabs := m.renderTabs()
// Active view content
var content string
switch View(m.activeTab) {
case ViewContainers:
content = m.containers.View()
case ViewImages:
content = m.images.View()
case ViewVolumes:
content = m.volumes.View()
case ViewNetworks:
content = m.networks.View()
case ViewCompose:
content = m.compose.View()
}
// Status bar
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
return lipgloss.JoinVertical(lipgloss.Left,
tabs,
"",
content,
"",
status,
)
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range tabNames {
if i == m.activeTab {
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
} else {
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
}
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.Styles.Header.Render("Docker TUI") + " " + row
}
// handleBack asks the active view to go back one level.
// Returns the updated model and true if the view was already at base level (app should quit).
func (m Model) handleBack() (Model, bool) {
switch View(m.activeTab) {
case ViewContainers:
atBase := m.containers.HandleBack()
return m, atBase
case ViewImages:
atBase := m.images.HandleBack()
return m, atBase
case ViewVolumes:
atBase := m.volumes.HandleBack()
return m, atBase
case ViewNetworks:
atBase := m.networks.HandleBack()
return m, atBase
case ViewCompose:
atBase := m.compose.HandleBack()
return m, atBase
}
return m, true
}
func (m Model) initActiveView() tea.Cmd {
switch View(m.activeTab) {
case ViewContainers:
return m.containers.Init()
case ViewImages:
return m.images.Init()
case ViewVolumes:
return m.volumes.Init()
case ViewNetworks:
return m.networks.Init()
case ViewCompose:
return m.compose.Init()
}
return nil
}
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Tidying modules..."
go mod tidy
echo "==> Building docker-tui..."
mkdir -p build
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
echo "==> Done: build/docker-tui ($(du -h build/docker-tui | cut -f1))"
echo " Run with: ./build/docker-tui"
-15
View File
@@ -1,15 +0,0 @@
package config
// Config holds Docker TUI configuration.
type Config struct {
ComposeFile string
RefreshInterval int // seconds, 0 = manual
}
// Default returns sensible defaults.
func Default() Config {
return Config{
ComposeFile: "docker-compose.yml",
RefreshInterval: 0,
}
}
-43
View File
@@ -1,43 +0,0 @@
module docker-tui
go 1.22.2
require (
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/lucasdataproyects/devfactory v0.0.0
)
require (
github.com/apache/arrow/go/v14 v14.0.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.18.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/marcboeker/go-duckdb v1.6.5 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
)
replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
-84
View File
@@ -1,84 +0,0 @@
github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw=
github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI=
github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o=
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-6
View File
@@ -1,6 +0,0 @@
go 1.22.2
use (
.
/home/lucas/.local_agentes/backend
)
-40
View File
@@ -1,40 +0,0 @@
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-20
View File
@@ -1,20 +0,0 @@
package main
import (
"fmt"
"os"
"docker-tui/app"
"github.com/lucasdataproyects/devfactory/tui"
)
func main() {
model := app.New()
result := tui.RunFullscreen(model)
if result.IsErr() {
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
os.Exit(1)
}
}
-201
View File
@@ -1,201 +0,0 @@
package views
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type composeState int
const (
composeLoading composeState = iota
composeList
composeAction
composeLogs
)
type composeLoadedMsg []ComposeService
type composeActionMsg struct{ output string; err error }
type composeLogsMsg struct{ output string; err error }
type ComposeModel struct {
state composeState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
services []ComposeService
output string
scrollOff int
err error
}
func NewComposeModel(styles tui.Styles) ComposeModel {
return ComposeModel{
state: composeLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading compose services..."),
styles: styles,
}
}
func (m ComposeModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadCompose)
}
func loadCompose() tea.Msg {
services, err := ComposePS()
if err != nil {
return composeLoadedMsg(nil)
}
return composeLoadedMsg(services)
}
func (m ComposeModel) Update(msg tea.Msg) (ComposeModel, tea.Cmd) {
switch msg := msg.(type) {
case composeLoadedMsg:
m.services = []ComposeService(msg)
items := make([]tui.ListItem, 0, len(m.services)+2)
// Add action items at the top
items = append(items,
tui.ListItem{Title: "▶ Compose Up", Description: "docker compose up -d", Value: "up"},
tui.ListItem{Title: "■ Compose Down", Description: "docker compose down", Value: "down"},
)
for _, s := range m.services {
stateIcon := "●"
if s.State == "running" {
stateIcon = "▶"
}
items = append(items, tui.ListItem{
Title: fmt.Sprintf("%s %s", stateIcon, s.Name),
Description: fmt.Sprintf("Service: %s — %s", s.Service, s.Status),
Value: s,
})
}
m.list.SetItems(items)
m.state = composeList
return m, nil
case composeActionMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = composeList
return m, loadCompose
case composeLogsMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = composeLogs
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case composeList:
switch msg.String() {
case "r":
m.state = composeLoading
return m, tea.Batch(m.spinner.Init(), loadCompose)
case "l":
m.state = composeAction
return m, func() tea.Msg {
output, err := ComposeLogs(100)
return composeLogsMsg{output: output, err: err}
}
case "enter":
if item := m.list.SelectedItem(); item != nil {
switch v := item.Value.(type) {
case string:
m.state = composeAction
if v == "up" {
return m, func() tea.Msg {
output, err := ComposeUp()
return composeActionMsg{output: output, err: err}
}
}
return m, func() tea.Msg {
output, err := ComposeDown()
return composeActionMsg{output: output, err: err}
}
}
}
}
case composeLogs:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
var cmd tea.Cmd
switch m.state {
case composeLoading, composeAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case composeList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *ComposeModel) HandleBack() bool {
switch m.state {
case composeLogs:
m.state = composeList
return false
default:
return true
}
}
func (m ComposeModel) View() string {
switch m.state {
case composeLoading, composeAction:
return m.spinner.View()
case composeList:
if len(m.services) == 0 {
help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.")
return m.list.View() + "\n" + help
}
help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh")
return m.list.View() + "\n" + help
case composeLogs:
return m.renderLogs()
}
return ""
}
func (m ComposeModel) renderLogs() string {
lines := strings.Split(m.output, "\n")
if len(lines) == 0 {
lines = []string{"(empty)"}
}
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Compose Logs")
content := strings.Join(visible, "\n")
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
-251
View File
@@ -1,251 +0,0 @@
package views
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type containersState int
const (
containersLoading containersState = iota
containersList
containersAction
containersLogs
)
type containersLoadedMsg []DockerContainer
type containersActionMsg struct{ output string; err error }
type containersLogsMsg struct{ output string; err error }
type ContainersModel struct {
state containersState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
containers []DockerContainer
output string
scrollOff int
err error
}
func NewContainersModel(styles tui.Styles) ContainersModel {
return ContainersModel{
state: containersLoading,
list: tui.NewFilteredList(nil, "Filter containers..."),
spinner: tui.NewSpinner("Loading containers..."),
styles: styles,
}
}
func (m ContainersModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
loadContainers,
)
}
func loadContainers() tea.Msg {
containers, err := ListContainers()
if err != nil {
return containersLoadedMsg(nil)
}
return containersLoadedMsg(containers)
}
func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) {
switch msg := msg.(type) {
case containersLoadedMsg:
m.containers = []DockerContainer(msg)
items := make([]tui.ListItem, len(m.containers))
for i, c := range m.containers {
stateIcon := "●"
if c.State == "running" {
stateIcon = "▶"
} else if c.State == "exited" {
stateIcon = "■"
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s %s", stateIcon, c.Names),
Description: fmt.Sprintf("%s — %s", c.Image, c.Status),
Value: c,
}
}
m.list.SetItems(items)
m.state = containersList
return m, nil
case containersActionMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = containersList
// Refresh after action
return m, loadContainers
case containersLogsMsg:
m.output = msg.output
if msg.err != nil {
m.output = fmt.Sprintf("Error: %v", msg.err)
}
m.state = containersLogs
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case containersList:
switch msg.String() {
case "r":
m.state = containersLoading
return m, tea.Batch(m.spinner.Init(), loadContainers)
case "enter":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
if c.State == "running" {
return m, stopContainerCmd(c.ID)
}
return m, startContainerCmd(c.ID)
}
case "l":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
m.state = containersAction
return m, logsContainerCmd(c.ID)
}
case "x":
if item := m.list.SelectedItem(); item != nil {
c := item.Value.(DockerContainer)
return m, restartContainerCmd(c.ID)
}
}
case containersLogs:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case containersLoading:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case containersList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base (el caller debe salir).
func (m *ContainersModel) HandleBack() bool {
switch m.state {
case containersLogs:
m.state = containersList
return false
default:
return true
}
}
func (m ContainersModel) View() string {
switch m.state {
case containersLoading:
return m.spinner.View()
case containersList:
if len(m.containers) == 0 {
return m.styles.Muted.Render("No containers found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case containersAction:
return m.spinner.View()
case containersLogs:
return m.renderOutput()
}
return ""
}
func (m ContainersModel) renderOutput() string {
lines := splitLines(m.output)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Container Logs")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func startContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := StartContainer(id)
return containersActionMsg{output: "Started " + id, err: err}
}
}
func stopContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := StopContainer(id)
return containersActionMsg{output: "Stopped " + id, err: err}
}
}
func restartContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
err := RestartContainer(id)
return containersActionMsg{output: "Restarted " + id, err: err}
}
}
func logsContainerCmd(id string) tea.Cmd {
return func() tea.Msg {
output, err := ContainerLogs(id, 100)
return containersLogsMsg{output: output, err: err}
}
}
func splitLines(s string) []string {
if s == "" {
return []string{"(empty)"}
}
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return []string{"(empty)"}
}
return lines
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
-192
View File
@@ -1,192 +0,0 @@
package views
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/lucasdataproyects/devfactory/shell"
)
const dockerTimeout = 15 * time.Second
// --- Containers ---
func ListContainers() ([]DockerContainer, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerContainer](stdout.Stdout)
}
func StartContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both()
return err
}
func StopContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both()
return err
}
func RestartContainer(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both()
return err
}
func ContainerLogs(id string, lines int) (string, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id)
out, err := result.Both()
if err != nil {
return "", err
}
// docker logs writes to both stdout and stderr
output := out.Stdout
if out.Stderr != "" {
if output != "" {
output += "\n"
}
output += out.Stderr
}
return output, nil
}
// --- Images ---
func ListImages() ([]DockerImage, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerImage](stdout.Stdout)
}
func PullImage(name string) (string, error) {
result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name)
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout, nil
}
func RemoveImage(id string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both()
return err
}
// --- Volumes ---
func ListVolumes() ([]DockerVolume, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerVolume](stdout.Stdout)
}
func CreateVolume(name string) error {
args := []string{"volume", "create"}
if name != "" {
args = append(args, name)
}
_, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both()
return err
}
func RemoveVolume(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both()
return err
}
// --- Networks ---
func ListNetworks() ([]DockerNetwork, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}")
stdout, err := result.Both()
if err != nil {
return nil, err
}
return parseJSONLines[DockerNetwork](stdout.Stdout)
}
func CreateNetwork(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both()
return err
}
func RemoveNetwork(name string) error {
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both()
return err
}
// --- Compose ---
func ComposePS() ([]ComposeService, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json")
stdout, err := result.Both()
if err != nil {
return nil, err
}
// docker compose ps --format json returns a JSON array
var services []ComposeService
if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil {
// Try line-by-line as fallback
return parseJSONLines[ComposeService](stdout.Stdout)
}
return services, nil
}
func ComposeUp() (string, error) {
result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d")
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
func ComposeDown() (string, error) {
result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down")
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
func ComposeLogs(lines int) (string, error) {
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines))
out, err := result.Both()
if err != nil {
return "", err
}
return out.Stdout + out.Stderr, nil
}
// --- Helpers ---
func parseJSONLines[T any](s string) ([]T, error) {
var result []T
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var item T
if err := json.Unmarshal([]byte(line), &item); err != nil {
continue
}
result = append(result, item)
}
return result, nil
}
func itoa(n int) string {
return fmt.Sprintf("%d", n)
}
-132
View File
@@ -1,132 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type imagesState int
const (
imagesLoading imagesState = iota
imagesList
imagesAction
)
type imagesLoadedMsg []DockerImage
type imagesActionMsg struct{ output string; err error }
type ImagesModel struct {
state imagesState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
images []DockerImage
err error
}
func NewImagesModel(styles tui.Styles) ImagesModel {
return ImagesModel{
state: imagesLoading,
list: tui.NewFilteredList(nil, "Filter images..."),
spinner: tui.NewSpinner("Loading images..."),
styles: styles,
}
}
func (m ImagesModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadImages)
}
func loadImages() tea.Msg {
images, err := ListImages()
if err != nil {
return imagesLoadedMsg(nil)
}
return imagesLoadedMsg(images)
}
func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) {
switch msg := msg.(type) {
case imagesLoadedMsg:
m.images = []DockerImage(msg)
items := make([]tui.ListItem, len(m.images))
for i, img := range m.images {
tag := img.Tag
if tag == "" {
tag = "latest"
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s:%s", img.Repository, tag),
Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]),
Value: img,
}
}
m.list.SetItems(items)
m.state = imagesList
return m, nil
case imagesActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = imagesList
return m, loadImages
case tea.KeyMsg:
if m.state == imagesList {
switch msg.String() {
case "r":
m.state = imagesLoading
return m, tea.Batch(m.spinner.Init(), loadImages)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
img := item.Value.(DockerImage)
m.state = imagesAction
return m, func() tea.Msg {
err := RemoveImage(img.ID)
return imagesActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case imagesLoading, imagesAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case imagesList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *ImagesModel) HandleBack() bool {
return true
}
func (m ImagesModel) View() string {
switch m.state {
case imagesLoading, imagesAction:
return m.spinner.View()
case imagesList:
if len(m.images) == 0 {
return m.styles.Muted.Render("No images found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-14
View File
@@ -1,14 +0,0 @@
package views
// Navigation key constants.
const (
KeyQuit = "ctrl+c"
KeyEsc = "esc"
KeyBack = "0"
KeyTab = "tab"
)
// IsBack returns true if the key should trigger back navigation.
func IsBack(key string) bool {
return key == KeyEsc || key == KeyBack
}
-128
View File
@@ -1,128 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type networksState int
const (
networksLoading networksState = iota
networksList
networksAction
)
type networksLoadedMsg []DockerNetwork
type networksActionMsg struct{ output string; err error }
type NetworksModel struct {
state networksState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
networks []DockerNetwork
err error
}
func NewNetworksModel(styles tui.Styles) NetworksModel {
return NetworksModel{
state: networksLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading networks..."),
styles: styles,
}
}
func (m NetworksModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadNetworks)
}
func loadNetworks() tea.Msg {
networks, err := ListNetworks()
if err != nil {
return networksLoadedMsg(nil)
}
return networksLoadedMsg(networks)
}
func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) {
switch msg := msg.(type) {
case networksLoadedMsg:
m.networks = []DockerNetwork(msg)
items := make([]tui.ListItem, len(m.networks))
for i, n := range m.networks {
items[i] = tui.ListItem{
Title: n.Name,
Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope),
Value: n,
}
}
m.list.SetItems(items)
m.state = networksList
return m, nil
case networksActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = networksList
return m, loadNetworks
case tea.KeyMsg:
if m.state == networksList {
switch msg.String() {
case "r":
m.state = networksLoading
return m, tea.Batch(m.spinner.Init(), loadNetworks)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
net := item.Value.(DockerNetwork)
m.state = networksAction
return m, func() tea.Msg {
err := RemoveNetwork(net.Name)
return networksActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case networksLoading, networksAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case networksList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *NetworksModel) HandleBack() bool {
return true
}
func (m NetworksModel) View() string {
switch m.state {
case networksLoading, networksAction:
return m.spinner.View()
case networksList:
if len(m.networks) == 0 {
return m.styles.Muted.Render("No networks found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-45
View File
@@ -1,45 +0,0 @@
package views
// DockerContainer represents a container from docker ps --format json.
type DockerContainer struct {
ID string `json:"ID"`
Names string `json:"Names"`
Image string `json:"Image"`
Status string `json:"Status"`
State string `json:"State"`
Ports string `json:"Ports"`
}
// DockerImage represents an image from docker image ls --format json.
type DockerImage struct {
ID string `json:"ID"`
Repository string `json:"Repository"`
Tag string `json:"Tag"`
Size string `json:"Size"`
CreatedAt string `json:"CreatedAt"`
}
// DockerVolume represents a volume from docker volume ls --format json.
type DockerVolume struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Mountpoint string `json:"Mountpoint"`
}
// DockerNetwork represents a network from docker network ls --format json.
type DockerNetwork struct {
ID string `json:"ID"`
Name string `json:"Name"`
Driver string `json:"Driver"`
Scope string `json:"Scope"`
}
// ComposeService represents a compose service from docker compose ps --format json.
type ComposeService struct {
ID string `json:"ID"`
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
Status string `json:"Status"`
Ports string `json:"Ports"`
}
-128
View File
@@ -1,128 +0,0 @@
package views
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lucasdataproyects/devfactory/tui"
)
type volumesState int
const (
volumesLoading volumesState = iota
volumesList
volumesAction
)
type volumesLoadedMsg []DockerVolume
type volumesActionMsg struct{ output string; err error }
type VolumesModel struct {
state volumesState
list tui.ListModel
spinner tui.SpinnerModel
styles tui.Styles
volumes []DockerVolume
err error
}
func NewVolumesModel(styles tui.Styles) VolumesModel {
return VolumesModel{
state: volumesLoading,
list: tui.NewList(nil),
spinner: tui.NewSpinner("Loading volumes..."),
styles: styles,
}
}
func (m VolumesModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Init(), loadVolumes)
}
func loadVolumes() tea.Msg {
volumes, err := ListVolumes()
if err != nil {
return volumesLoadedMsg(nil)
}
return volumesLoadedMsg(volumes)
}
func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) {
switch msg := msg.(type) {
case volumesLoadedMsg:
m.volumes = []DockerVolume(msg)
items := make([]tui.ListItem, len(m.volumes))
for i, v := range m.volumes {
items[i] = tui.ListItem{
Title: v.Name,
Description: fmt.Sprintf("Driver: %s", v.Driver),
Value: v,
}
}
m.list.SetItems(items)
m.state = volumesList
return m, nil
case volumesActionMsg:
if msg.err != nil {
m.err = msg.err
}
m.state = volumesList
return m, loadVolumes
case tea.KeyMsg:
if m.state == volumesList {
switch msg.String() {
case "r":
m.state = volumesLoading
return m, tea.Batch(m.spinner.Init(), loadVolumes)
case "d", "delete":
if item := m.list.SelectedItem(); item != nil {
vol := item.Value.(DockerVolume)
m.state = volumesAction
return m, func() tea.Msg {
err := RemoveVolume(vol.Name)
return volumesActionMsg{output: "Removed", err: err}
}
}
}
}
}
var cmd tea.Cmd
switch m.state {
case volumesLoading, volumesAction:
var model tea.Model
model, cmd = m.spinner.Update(msg)
m.spinner = model.(tui.SpinnerModel)
case volumesList:
var model tea.Model
model, cmd = m.list.Update(msg)
m.list = model.(tui.ListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
func (m *VolumesModel) HandleBack() bool {
return true
}
func (m VolumesModel) View() string {
switch m.state {
case volumesLoading, volumesAction:
return m.spinner.View()
case volumesList:
if len(m.volumes) == 0 {
return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" d: remove │ r: refresh")
view := m.list.View() + "\n" + help
if m.err != nil {
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
}
return view
}
return ""
}
-181
View File
@@ -1,181 +0,0 @@
package app
import (
"fmt"
ops "fn-registry/fn_operations"
"fn-registry/registry"
"pipeline-launcher/config"
"pipeline-launcher/views"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
// View identifies which tab is active.
type View int
const (
ViewPipelines View = iota
ViewHistory
)
var tabNames = []string{"Pipelines", "History"}
// Model is the top-level TUI model with two tabs.
type Model struct {
tui.BaseModel
activeTab int
pipelines views.PipelinesModel
history views.HistoryModel
ready bool
registryDB *registry.DB
opsDB *ops.DB
}
// New creates the Model, opening both databases.
func New(cfg config.Config) (Model, error) {
regDB, err := registry.Open(cfg.RegistryDB)
if err != nil {
return Model{}, fmt.Errorf("opening registry: %w", err)
}
opsDB, err := ops.Open(cfg.OperationsDB)
if err != nil {
regDB.Close()
return Model{}, fmt.Errorf("opening operations: %w", err)
}
// Build pipeline name map for history view
fns, _ := regDB.SearchFunctions("", registry.KindPipeline, "", "", "")
names := make(map[string]string, len(fns))
for _, f := range fns {
names[f.ID] = f.Name
}
styles := tui.DarkStyles()
return Model{
BaseModel: tui.NewBaseModel().WithStyles(styles),
pipelines: views.NewPipelinesModel(styles, regDB, opsDB, cfg.RegistryRoot),
history: views.NewHistoryModel(styles, opsDB, names),
registryDB: regDB,
opsDB: opsDB,
}, nil
}
// Close closes both database connections.
func (m Model) Close() {
if m.registryDB != nil {
m.registryDB.Close()
}
if m.opsDB != nil {
m.opsDB.Close()
}
}
func (m Model) Init() tea.Cmd {
return m.pipelines.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case views.KeyQuit:
return m, tea.Quit
case "q":
updated, atBase := m.handleBack()
if atBase {
return updated, tea.Quit
}
return updated, nil
case views.KeyEsc, views.KeyBack:
updated, atBase := m.handleBack()
if atBase {
return updated, nil
}
return updated, nil
case views.KeyTab:
m.activeTab = (m.activeTab + 1) % len(tabNames)
return m, m.initActiveView()
case "shift+tab":
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
return m, m.initActiveView()
}
case tea.WindowSizeMsg:
m.HandleWindowSize(msg)
m.ready = true
}
var cmd tea.Cmd
switch View(m.activeTab) {
case ViewPipelines:
m.pipelines, cmd = m.pipelines.Update(msg)
case ViewHistory:
m.history, cmd = m.history.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
tabs := m.renderTabs()
var content string
switch View(m.activeTab) {
case ViewPipelines:
content = m.pipelines.View()
case ViewHistory:
content = m.history.View()
}
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
return lipgloss.JoinVertical(lipgloss.Left,
tabs,
"",
content,
"",
status,
)
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range tabNames {
if i == m.activeTab {
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
} else {
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
}
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.Styles.Header.Render("Pipeline Launcher") + " " + row
}
func (m Model) handleBack() (Model, bool) {
switch View(m.activeTab) {
case ViewPipelines:
atBase := m.pipelines.HandleBack()
return m, atBase
case ViewHistory:
atBase := m.history.HandleBack()
return m, atBase
}
return m, true
}
func (m Model) initActiveView() tea.Cmd {
switch View(m.activeTab) {
case ViewPipelines:
return m.pipelines.Init()
case ViewHistory:
return m.history.Init()
}
return nil
}
-27
View File
@@ -1,27 +0,0 @@
package config
import (
"os"
"path/filepath"
)
// Config holds paths to databases.
type Config struct {
RegistryDB string // Path to registry.db
OperationsDB string // Path to operations.db
RegistryRoot string // Root directory of the registry (for resolving file paths)
}
// Default returns a Config resolved from environment or sensible defaults.
func Default() Config {
root := os.Getenv("FN_REGISTRY_ROOT")
if root == "" {
root = "."
}
return Config{
RegistryDB: filepath.Join(root, "registry.db"),
OperationsDB: filepath.Join(root, "apps", "pipeline_launcher", "operations.db"),
RegistryRoot: root,
}
}
-38
View File
@@ -1,38 +0,0 @@
module pipeline-launcher
go 1.22.2
require (
fn-registry v0.0.0
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/lucasdataproyects/devfactory v0.0.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
fn-registry => /home/lucas/fn_registry
github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
)
-51
View File
@@ -1,51 +0,0 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-29
View File
@@ -1,29 +0,0 @@
package main
import (
"fmt"
"os"
"pipeline-launcher/app"
"pipeline-launcher/config"
"github.com/lucasdataproyects/devfactory/tui"
)
func main() {
cfg := config.Default()
model, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer model.Close()
result := tui.RunFullscreen(model)
if result.IsErr() {
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
os.Exit(1)
}
}
-219
View File
@@ -1,219 +0,0 @@
package views
import (
"encoding/json"
"fmt"
"strings"
ops "fn-registry/fn_operations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type historyState int
const (
historyLoading historyState = iota
historyList
historyDetail
)
type historyLoadedMsg []ops.Execution
// HistoryModel shows execution history.
type HistoryModel struct {
state historyState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
executions []ops.Execution
detail string
scrollOff int
opsDB *ops.DB
pipelineNames map[string]string
}
// NewHistoryModel creates a new history view.
func NewHistoryModel(styles tui.Styles, opsDB *ops.DB, names map[string]string) HistoryModel {
return HistoryModel{
state: historyLoading,
list: tui.NewFilteredList(nil, "Filter executions..."),
spinner: tui.NewSpinner("Loading history..."),
styles: styles,
opsDB: opsDB,
pipelineNames: names,
}
}
func (m HistoryModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadHistory(),
)
}
func (m HistoryModel) loadHistory() tea.Cmd {
return func() tea.Msg {
execs, err := m.opsDB.ListExecutions("", "", "")
if err != nil {
return historyLoadedMsg(nil)
}
return historyLoadedMsg(execs)
}
}
func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) {
switch msg := msg.(type) {
case historyLoadedMsg:
m.executions = []ops.Execution(msg)
items := make([]tui.ListItem, len(m.executions))
for i, e := range m.executions {
icon := "●"
switch e.Status {
case ops.ExecSuccess:
icon = "✓"
case ops.ExecFailure:
icon = "✗"
case ops.ExecPartial:
icon = "~"
}
name := e.PipelineID
if n, ok := m.pipelineNames[e.PipelineID]; ok {
name = n
}
dur := ""
if e.DurationMs != nil {
dur = fmt.Sprintf("%dms", *e.DurationMs)
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s %s", icon, name),
Description: fmt.Sprintf("%s — %s — %s", string(e.Status), dur, e.StartedAt.Format("2006-01-02 15:04:05")),
Value: e,
}
}
m.list.SetItems(items)
m.state = historyList
return m, nil
case tea.KeyMsg:
switch m.state {
case historyList:
switch msg.String() {
case "r":
m.state = historyLoading
m.spinner = tui.NewSpinner("Loading history...")
return m, tea.Batch(m.spinner.Init(), m.loadHistory())
case "enter":
// Delegate enter to list first so it selects the cursor item
updated, _ := m.list.Update(msg)
m.list = updated.(tui.FilteredListModel)
if item := m.list.SelectedItem(); item != nil {
e := item.Value.(ops.Execution)
m.detail = formatExecution(e)
m.state = historyDetail
m.scrollOff = 0
}
return m, nil
}
case historyDetail:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case historyLoading:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case historyList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *HistoryModel) HandleBack() bool {
switch m.state {
case historyDetail:
m.state = historyList
return false
default:
return true
}
}
func (m HistoryModel) View() string {
switch m.state {
case historyLoading:
return m.spinner.View()
case historyList:
if len(m.executions) == 0 {
return m.styles.Muted.Render("No executions found. Launch a pipeline first.")
}
help := m.styles.Muted.Render(" Enter: details │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case historyDetail:
return m.renderDetail()
}
return ""
}
func (m HistoryModel) renderDetail() string {
lines := splitLines(m.detail)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Execution Detail")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func formatExecution(e ops.Execution) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("ID: %s\n", e.ID))
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", e.PipelineID))
sb.WriteString(fmt.Sprintf("Status: %s\n", e.Status))
sb.WriteString(fmt.Sprintf("Started: %s\n", e.StartedAt.Format("2006-01-02 15:04:05")))
if e.EndedAt != nil {
sb.WriteString(fmt.Sprintf("Ended: %s\n", e.EndedAt.Format("2006-01-02 15:04:05")))
}
if e.DurationMs != nil {
sb.WriteString(fmt.Sprintf("Duration: %dms\n", *e.DurationMs))
}
if e.RecordsIn != nil {
sb.WriteString(fmt.Sprintf("Records In: %d\n", *e.RecordsIn))
}
if e.RecordsOut != nil {
sb.WriteString(fmt.Sprintf("Records Out: %d\n", *e.RecordsOut))
}
if e.Error != "" {
sb.WriteString(fmt.Sprintf("\n--- Error ---\n%s\n", e.Error))
}
if len(e.Metrics) > 0 {
sb.WriteString("\n--- Metrics ---\n")
b, _ := json.MarshalIndent(e.Metrics, "", " ")
sb.WriteString(string(b))
sb.WriteString("\n")
}
return sb.String()
}
-14
View File
@@ -1,14 +0,0 @@
package views
// Navigation key constants.
const (
KeyQuit = "ctrl+c"
KeyEsc = "esc"
KeyBack = "0"
KeyTab = "tab"
)
// IsBack returns true if the key should trigger back navigation.
func IsBack(key string) bool {
return key == KeyEsc || key == KeyBack
}
-398
View File
@@ -1,398 +0,0 @@
package views
import (
"fmt"
"strings"
ops "fn-registry/fn_operations"
"fn-registry/registry"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type pipelinesState int
const (
pipelinesLoading pipelinesState = iota
pipelinesList
pipelinesArgs
pipelinesRunning
pipelinesOutput
)
type pipelinesLoadedMsg []registry.Function
type pipelineFinishedMsg RunResult
type pipelineFlagsMsg []PipelineFlag
// PipelinesModel lists and launches pipelines.
type PipelinesModel struct {
state pipelinesState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
pipelines []registry.Function
selectedFn *registry.Function
flags []PipelineFlag
inputs []textinput.Model
focusIdx int
output string
lastResult *RunResult
scrollOff int
err error
registryDB *registry.DB
opsDB *ops.DB
registryRoot string
}
// NewPipelinesModel creates a new pipelines view.
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
return PipelinesModel{
state: pipelinesLoading,
list: tui.NewFilteredList(nil, "Filter pipelines..."),
spinner: tui.NewSpinner("Loading pipelines..."),
styles: styles,
registryDB: regDB,
opsDB: opsDB,
registryRoot: root,
}
}
func (m PipelinesModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadPipelines(),
)
}
func (m PipelinesModel) loadPipelines() tea.Cmd {
return func() tea.Msg {
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
if err != nil {
return pipelinesLoadedMsg(nil)
}
// Only show pipelines tagged with "launcher"
var launchable []registry.Function
for _, f := range fns {
for _, t := range f.Tags {
if t == "launcher" {
launchable = append(launchable, f)
break
}
}
}
return pipelinesLoadedMsg(launchable)
}
}
// buildInputs creates a textinput for each flag, pre-filled with defaults.
func (m *PipelinesModel) buildInputs() tea.Cmd {
m.inputs = make([]textinput.Model, len(m.flags))
for i, f := range m.flags {
ti := textinput.New()
ti.CharLimit = 256
ti.Width = 40
if f.Default != "" {
ti.SetValue(f.Default)
}
if f.Required {
ti.Placeholder = "(requerido)"
}
m.inputs[i] = ti
}
m.focusIdx = 0
if len(m.inputs) > 0 {
m.inputs[0].Focus()
return textinput.Blink
}
return nil
}
func (m *PipelinesModel) focusInput(idx int) tea.Cmd {
if idx < 0 || idx >= len(m.inputs) {
return nil
}
for i := range m.inputs {
m.inputs[i].Blur()
}
m.focusIdx = idx
m.inputs[idx].Focus()
return textinput.Blink
}
// collectArgs builds CLI args from the form inputs.
func (m PipelinesModel) collectArgs() []string {
var args []string
for i, f := range m.flags {
val := strings.TrimSpace(m.inputs[i].Value())
if val != "" {
args = append(args, "--"+f.Name, val)
}
}
return args
}
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
switch msg := msg.(type) {
case pipelinesLoadedMsg:
m.pipelines = []registry.Function(msg)
items := make([]tui.ListItem, len(m.pipelines))
for i, p := range m.pipelines {
items[i] = tui.ListItem{
Title: p.Name,
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
Value: p,
}
}
m.list.SetItems(items)
m.state = pipelinesList
return m, nil
case pipelineFlagsMsg:
m.flags = []PipelineFlag(msg)
cmd := m.buildInputs()
return m, cmd
case pipelineFinishedMsg:
result := RunResult(msg)
m.lastResult = &result
var sb strings.Builder
if result.Status == ops.ExecSuccess {
sb.WriteString("[OK] ")
} else {
sb.WriteString("[FAIL] ")
}
fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID)
fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID)
fmt.Fprintf(&sb, "Duration: %dms\n", result.DurationMs)
sb.WriteString("\n--- stdout ---\n")
if result.Stdout != "" {
sb.WriteString(result.Stdout)
} else {
sb.WriteString("(empty)")
}
if result.Stderr != "" {
sb.WriteString("\n--- stderr ---\n")
sb.WriteString(result.Stderr)
}
if result.Err != nil {
fmt.Fprintf(&sb, "\n--- error ---\n%v", result.Err)
}
m.output = sb.String()
m.state = pipelinesOutput
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case pipelinesList:
switch msg.String() {
case "r":
m.state = pipelinesLoading
m.spinner = tui.NewSpinner("Loading pipelines...")
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
case "enter":
updated, _ := m.list.Update(msg)
m.list = updated.(tui.FilteredListModel)
if item := m.list.SelectedItem(); item != nil {
fn := item.Value.(registry.Function)
m.selectedFn = &fn
m.flags = nil
m.inputs = nil
m.state = pipelinesArgs
root := m.registryRoot
fnCopy := fn
return m, func() tea.Msg {
return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root))
}
}
return m, nil
}
case pipelinesArgs:
switch msg.String() {
case "tab", "down":
cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1))
return m, cmd
case "shift+tab", "up":
idx := m.focusIdx - 1
if idx < 0 {
idx = max(len(m.inputs)-1, 0)
}
cmd := m.focusInput(idx)
return m, cmd
case "ctrl+enter", "ctrl+s":
args := m.collectArgs()
m.state = pipelinesRunning
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name))
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args))
case "esc":
m.state = pipelinesList
return m, nil
}
case pipelinesOutput:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case pipelinesLoading, pipelinesRunning:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case pipelinesList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
case pipelinesArgs:
if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) {
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
}
}
return m, cmd
}
func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd {
regRoot := m.registryRoot
opsDB := m.opsDB
fnCopy := *fn
return func() tea.Msg {
result := RunPipeline(&fnCopy, regRoot, opsDB, args)
return pipelineFinishedMsg(result)
}
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *PipelinesModel) HandleBack() bool {
switch m.state {
case pipelinesArgs:
m.state = pipelinesList
return false
case pipelinesOutput:
m.state = pipelinesList
return false
default:
return true
}
}
func (m PipelinesModel) View() string {
switch m.state {
case pipelinesLoading:
return m.spinner.View()
case pipelinesList:
if len(m.pipelines) == 0 {
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case pipelinesArgs:
return m.renderArgsForm()
case pipelinesRunning:
return m.spinner.View()
case pipelinesOutput:
return m.renderOutput()
}
return ""
}
func (m PipelinesModel) renderArgsForm() string {
header := m.styles.Header.Render(m.selectedFn.Name)
var parts []string
parts = append(parts, header, "")
if len(m.flags) == 0 {
parts = append(parts, m.styles.Muted.Render(" Loading flags..."))
} else if len(m.inputs) == 0 {
parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run."))
} else {
for i, f := range m.flags {
marker := " "
if f.Required {
marker = m.styles.Error.Render("* ")
}
name := fmt.Sprintf("--%-16s", f.Name)
cursor := " "
if i == m.focusIdx {
cursor = m.styles.Info.Render("> ")
}
label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name))
input := m.inputs[i].View()
desc := f.Desc
if f.Default != "" {
desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default))
}
parts = append(parts, label+input)
parts = append(parts, " "+m.styles.Muted.Render(desc))
}
}
parts = append(parts, "")
parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m PipelinesModel) renderOutput() string {
lines := splitLines(m.output)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Pipeline Output")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func splitLines(s string) []string {
if s == "" {
return []string{"(empty)"}
}
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return []string{"(empty)"}
}
return lines
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-3] + "..."
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
-146
View File
@@ -1,146 +0,0 @@
package views
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
// PipelineFlag describes a CLI flag parsed from -help output.
type PipelineFlag struct {
Name string // e.g. "project"
Type string // e.g. "string"
Desc string // description text
Default string // default value, empty if none
Required bool // true if no default
}
var flagLineRe = regexp.MustCompile(`^\s+-(\S+)\s+(\S+)$`)
var defaultRe = regexp.MustCompile(`\(default "(.*)"\)`)
// GetPipelineFlags runs `go run . -help` and parses the flag output.
func GetPipelineFlags(fn *registry.Function, registryRoot string) []PipelineFlag {
absPath := filepath.Join(registryRoot, fn.FilePath)
dir := filepath.Dir(absPath)
cmd := exec.Command("go", "run", ".", "-help")
cmd.Dir = dir
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Run() // -help exits with code 2, ignore error
return parseFlags(stderr.String())
}
func parseFlags(output string) []PipelineFlag {
var flags []PipelineFlag
lines := strings.Split(output, "\n")
for i := 0; i < len(lines); i++ {
m := flagLineRe.FindStringSubmatch(lines[i])
if m == nil {
continue
}
f := PipelineFlag{Name: m[1], Type: m[2]}
// Next line is the description
if i+1 < len(lines) {
desc := strings.TrimSpace(lines[i+1])
if dm := defaultRe.FindStringSubmatch(desc); dm != nil {
f.Default = dm[1]
f.Desc = strings.TrimSpace(defaultRe.ReplaceAllString(desc, ""))
} else {
f.Desc = desc
}
i++
}
f.Required = f.Default == "" && !strings.Contains(strings.ToLower(f.Desc), "opcional")
flags = append(flags, f)
}
return flags
}
// RunResult holds the outcome of a pipeline execution.
type RunResult struct {
Stdout string
Stderr string
ExecID string
PipelineID string
Status ops.ExecutionStatus
DurationMs int64
Err error
}
// RunPipeline executes a pipeline as a subprocess and records the execution.
func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB, args []string) RunResult {
absPath := filepath.Join(registryRoot, fn.FilePath)
dir := filepath.Dir(absPath)
startedAt := time.Now().UTC()
cmdArgs := append([]string{"run", "."}, args...)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
endedAt := time.Now().UTC()
status := ops.ExecSuccess
var execErr string
if err != nil {
status = ops.ExecFailure
execErr = err.Error()
if stderr.Len() > 0 {
execErr = stderr.String()
}
}
execID := fmt.Sprintf("exec_%d", time.Now().UnixNano())
durationMs := endedAt.Sub(startedAt).Milliseconds()
execution := &ops.Execution{
ID: execID,
PipelineID: fn.ID,
Status: status,
StartedAt: startedAt,
EndedAt: &endedAt,
DurationMs: &durationMs,
Error: execErr,
CreatedAt: time.Now().UTC(),
}
insertErr := ops.InsertExecutionSafe(opsDB, execution)
if insertErr != nil {
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: fmt.Errorf("pipeline ran but failed to record: %w", insertErr),
}
}
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: err,
}
}
@@ -0,0 +1,52 @@
---
name: analyze_dns
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "analyze_dns(domain: string, mode: string) -> void"
description: "Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA, consulta whois y verificación contra listas negras DNSBL (spamhaus, spamcop, sorbs, barracuda)."
tags: [bash, cybersecurity, dns, network, whois, dnsbl, reconnaissance]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: domain
desc: "dominio a analizar, ej: example.com"
- name: mode
desc: "modo de análisis: records (solo registros DNS), whois (solo whois), dnsbl (solo listas negras) o all (todo, por defecto)"
output: "imprime registros DNS, información whois y estado DNSBL a stdout con colores ANSI"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/analyze_dns.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/redes/analisis_dns.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/analyze_dns.sh
# Análisis completo
analyze_dns example.com
# Solo registros DNS
analyze_dns example.com records
# Solo whois
analyze_dns example.com whois
# Solo DNSBL
analyze_dns example.com dnsbl
```
## Notas
Requiere `dig` (paquete dnsutils). `whois` es opcional — si no está instalado y el modo es `all`, se omite el paso whois con aviso. Las listas negras DNSBL se consultan via DNS inverso (técnica estándar sin HTTP). El modo `dnsbl` resuelve primero la IP del dominio y luego construye la consulta invertida para cada blacklist.
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# analyze_dns
# -----------
# Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA,
# consulta whois y verificación contra listas negras DNSBL.
#
# USO (directo):
# analyze_dns example.com [records|whois|dnsbl|all]
#
# Depende de: dig, whois (opcional), curl (para DNSBL)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Funciones puras ──────────────────────────────────────────────────────────
_dns_is_valid_domain() {
local domain="$1"
[[ -n "$domain" && "$domain" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
}
_dns_build_dnsbl_query() {
local ip="$1"
local bl="$2"
local reversed
reversed="$(echo "$ip" | awk -F. '{print $4"."$3"."$2"."$1}')"
echo "${reversed}.${bl}"
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_dns_query_record() {
local domain="$1"
local type="$2"
local result
result="$(dig +short "$type" "$domain" 2>/dev/null || true)"
if [[ -z "$result" ]]; then
echo " (sin registros)"
else
echo "$result" | while IFS= read -r line; do
echo " * $line"
done
fi
}
_dns_show_all_records() {
local domain="$1"
echo ""
for type in A AAAA MX NS TXT CNAME SOA; do
echo -e "${CYAN}── ${type} ──────────────────${NC}"
_dns_query_record "$domain" "$type"
echo ""
done
}
_dns_show_whois() {
local domain="$1"
echo ""
info "Consultando whois de ${domain}..."
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
whois "$domain" 2>/dev/null \
| grep -iE "(registrar|registrant|creation|expiry|expire|updated|name server|status)" \
| head -20 \
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
}
_dns_check_dnsbl() {
local domain="$1"
local ip
ip="$(dig +short A "$domain" 2>/dev/null | head -1 || true)"
if [[ -z "$ip" ]]; then
warning "No se pudo resolver la IP de $domain para comprobar DNSBL"
return
fi
info "IP a comprobar: $ip"
echo ""
local blacklists=(
"zen.spamhaus.org"
"bl.spamcop.net"
"dnsbl.sorbs.net"
"b.barracudacentral.org"
)
local found=0
for bl in "${blacklists[@]}"; do
local query
query="$(_dns_build_dnsbl_query "$ip" "$bl")"
local result
result="$(dig +short A "$query" 2>/dev/null || true)"
if [[ -n "$result" ]]; then
echo -e " ${RED}LISTADO${NC} ${bl} ($result)"
found=$((found + 1))
else
echo -e " ${GREEN}limpio${NC} ${bl}"
fi
done
echo ""
if [[ $found -eq 0 ]]; then
success "La IP no aparece en ninguna lista negra comprobada"
else
warning "La IP aparece en ${found} lista(s) negra(s)"
fi
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
analyze_dns() {
local domain="$1"
local mode="${2:-all}"
if [[ -z "$domain" ]]; then
error "analyze_dns: se requiere un dominio como primer argumento" >&2
return 1
fi
if ! _dns_is_valid_domain "$domain"; then
error "analyze_dns: dominio no válido: '$domain'" >&2
return 1
fi
if ! command -v dig &>/dev/null; then
error "analyze_dns: 'dig' no está instalado (sudo apt install dnsutils)" >&2
return 1
fi
info "Analizando: ${domain}"
case "$mode" in
records)
_dns_show_all_records "$domain"
;;
whois)
if ! command -v whois &>/dev/null; then
error "analyze_dns: 'whois' no está instalado (sudo apt install whois)" >&2
return 1
fi
_dns_show_whois "$domain"
;;
dnsbl)
_dns_check_dnsbl "$domain"
;;
all)
_dns_show_all_records "$domain"
if command -v whois &>/dev/null; then
_dns_show_whois "$domain"
else
warning "whois no disponible, omitiendo"
fi
echo ""
_dns_check_dnsbl "$domain"
;;
*)
error "analyze_dns: modo no válido '$mode'. Use: records|whois|dnsbl|all" >&2
return 1
;;
esac
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
analyze_dns "$@"
fi
@@ -0,0 +1,47 @@
---
name: audit_http_headers
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "audit_http_headers(url: string) -> void"
description: "Audita las cabeceras HTTP de seguridad de una URL: verifica la presencia de HSTS (con validación de max-age mínimo de 6 meses), Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y cabeceras CORS. También detecta cabeceras que exponen información del servidor."
tags: [bash, cybersecurity, web, http, headers, security, hsts, csp, hardening]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: url
desc: "URL del sitio web a auditar; si no tiene esquema se añade https:// automáticamente"
output: "imprime el estado de cada cabecera de seguridad (ok/falta/advertencia), el valor de las presentes y cabeceras que exponen información del servidor"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/audit_http_headers.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/web/cabeceras_http.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/audit_http_headers.sh
# Con URL completa
audit_http_headers https://example.com
# Sin esquema (añade https:// automáticamente)
audit_http_headers example.com
# Seguir redirecciones
audit_http_headers http://example.com
```
## Notas
Usa `curl -sI --location` para seguir redirecciones y obtener solo cabeceras. El check de HSTS valida que `max-age` sea >= 15.768.000 segundos (6 meses), valor mínimo recomendado por OWASP. Las cabeceras Server, X-Powered-By, X-AspNet-Version y X-Generator se marcan como advertencia por revelar información del stack tecnológico. Timeout de 15 segundos para evitar cuelgues.
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# audit_http_headers
# ------------------
# Audita las cabeceras HTTP de seguridad de una URL: HSTS, CSP, X-Frame-Options,
# X-Content-Type-Options, Referrer-Policy, Permissions-Policy y otras.
# También muestra cabeceras que exponen información del servidor.
#
# USO (directo):
# audit_http_headers <url>
#
# Depende de: curl
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Funciones puras ──────────────────────────────────────────────────────────
_hdr_normalize_url() {
local url="$1"
if [[ ! "$url" =~ ^https?:// ]]; then
echo "https://${url}"
else
echo "$url"
fi
}
_hdr_header_present() {
local headers="$1"
local name="$2"
echo "$headers" | grep -qi "^${name}:"
}
_hdr_extract_value() {
local headers="$1"
local name="$2"
echo "$headers" | grep -i "^${name}:" | cut -d: -f2- | xargs
}
_hdr_hsts_is_strong() {
local value="$1"
local max_age
max_age="$(echo "$value" | grep -oE 'max-age=[0-9]+' | cut -d= -f2 || echo 0)"
[[ "$max_age" -ge 15768000 ]] # 6 meses en segundos
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_hdr_fetch() {
local url="$1"
curl -sI --max-time 15 --location "$url" 2>/dev/null | tr -d '\r'
}
_hdr_check_header() {
local headers="$1"
local name="$2"
local description="$3"
if _hdr_header_present "$headers" "$name"; then
local value
value="$(_hdr_extract_value "$headers" "$name")"
echo -e " ${GREEN}[ok]${NC} ${name}"
echo -e " ${GRAY}${value}${NC}"
else
echo -e " ${RED}[x] ${NC} ${name} -- ${description}"
fi
}
_hdr_check_hsts() {
local headers="$1"
local name="Strict-Transport-Security"
if _hdr_header_present "$headers" "$name"; then
local value
value="$(_hdr_extract_value "$headers" "$name")"
if _hdr_hsts_is_strong "$value"; then
echo -e " ${GREEN}[ok]${NC} ${name}"
else
echo -e " ${YELLOW}[!] ${NC} ${name} -- max-age demasiado corto (<6 meses)"
fi
echo -e " ${GRAY}${value}${NC}"
else
echo -e " ${RED}[x] ${NC} ${name} -- HSTS no configurado"
fi
}
_hdr_show_server_info() {
local headers="$1"
echo ""
echo -e "${CYAN}── Información del servidor ──────────────────────${NC}"
for h in Server X-Powered-By X-AspNet-Version X-Generator; do
if _hdr_header_present "$headers" "$h"; then
local val
val="$(_hdr_extract_value "$headers" "$h")"
echo -e " ${YELLOW}[!]${NC} ${h}: ${val} (información expuesta)"
fi
done
local status
status="$(echo "$headers" | head -1)"
echo -e " ${CYAN}Status:${NC} ${status}"
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
audit_http_headers() {
local raw_url="$1"
if [[ -z "$raw_url" ]]; then
error "audit_http_headers: se requiere una URL como argumento" >&2
return 1
fi
if ! command -v curl &>/dev/null; then
error "audit_http_headers: 'curl' no está instalado (sudo apt install curl)" >&2
return 1
fi
local url
url="$(_hdr_normalize_url "$raw_url")"
info "Consultando cabeceras de: ${url}"
local headers
headers="$(_hdr_fetch "$url")"
if [[ -z "$headers" ]]; then
error "audit_http_headers: no se pudieron obtener las cabeceras. ¿El sitio está disponible?" >&2
return 1
fi
echo ""
echo -e "${PURPLE}════════ Cabeceras de Seguridad ════════════════${NC}"
echo ""
_hdr_check_hsts "$headers"
_hdr_check_header "$headers" "Content-Security-Policy" "Previene XSS e inyección de contenido"
_hdr_check_header "$headers" "X-Frame-Options" "Previene clickjacking"
_hdr_check_header "$headers" "X-Content-Type-Options" "Previene MIME sniffing"
_hdr_check_header "$headers" "Referrer-Policy" "Controla información del referrer"
_hdr_check_header "$headers" "Permissions-Policy" "Controla acceso a APIs del navegador"
_hdr_check_header "$headers" "Cross-Origin-Opener-Policy" "Aísla el contexto de navegación"
_hdr_check_header "$headers" "Cross-Origin-Resource-Policy" "Controla compartición de recursos"
_hdr_show_server_info "$headers"
echo ""
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
audit_http_headers "$@"
fi
@@ -0,0 +1,44 @@
---
name: audit_ssh_config
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "audit_ssh_config(config_path: string) -> void"
description: "Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual."
tags: [bash, cybersecurity, ssh, audit, security, hardening, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: config_path
desc: "ruta al archivo sshd_config a auditar (por defecto: /etc/ssh/sshd_config)"
output: "imprime checks con nivel ok/warn/bad para cada parámetro, últimos 10 intentos de login fallidos y lista de claves autorizadas"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/audit_ssh_config.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/sistema/auditar_ssh.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/audit_ssh_config.sh
# Auditar la configuración por defecto
audit_ssh_config
# Auditar un archivo alternativo
audit_ssh_config /etc/ssh/sshd_config.d/custom.conf
```
## Notas
Los logs de intentos fallidos se buscan primero en `journalctl` (systemd) y si no está disponible en `/var/log/auth.log`. Leer `/etc/ssh/sshd_config` puede requerir permisos de root en algunos sistemas. Los criterios de evaluación siguen las recomendaciones de CIS Benchmark para SSH: PermitRootLogin=no, PasswordAuthentication=no, MaxAuthTries<=3.
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# audit_ssh_config
# ----------------
# Audita la configuración de sshd_config evaluando parámetros de seguridad,
# revisa intentos de login fallidos y lista las claves autorizadas del usuario.
#
# USO (directo):
# audit_ssh_config [/ruta/a/sshd_config]
#
# Depende de: grep, ssh (opcional para validación), journalctl o /var/log/auth.log
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Funciones puras ──────────────────────────────────────────────────────────
_ssh_get_value() {
local config="$1"
local key="$2"
grep -iE "^[[:space:]]*${key}[[:space:]]" "$config" 2>/dev/null \
| tail -1 | awk '{print $2}' | xargs
}
_ssh_eval_permit_root() {
local val="${1:-yes}"
case "${val,,}" in
no|prohibit-password) echo "ok" ;;
without-password) echo "warn" ;;
*) echo "bad" ;;
esac
}
_ssh_eval_password_auth() {
local val="${1:-yes}"
[[ "${val,,}" == "no" ]] && echo "ok" || echo "bad"
}
_ssh_eval_max_auth_tries() {
local val="${1:-6}"
[[ "$val" -le 3 ]] && echo "ok" || echo "warn"
}
_ssh_eval_x11_forwarding() {
local val="${1:-no}"
[[ "${val,,}" == "no" ]] && echo "ok" || echo "warn"
}
# ─── Funciones de presentación ────────────────────────────────────────────────
_ssh_print_check() {
local level="$1"
local label="$2"
local value="$3"
local note="$4"
case "$level" in
ok) echo -e " ${GREEN}[ok]${NC} ${label}: ${value}" ;;
warn) echo -e " ${YELLOW}[!] ${NC} ${label}: ${value} -- ${note}" ;;
bad) echo -e " ${RED}[x] ${NC} ${label}: ${value} -- ${note}" ;;
esac
}
_ssh_show_config_checks() {
local config="$1"
echo -e "${PURPLE}════════ Configuración sshd_config ════════════${NC}"
echo ""
local permit_root
permit_root="$(_ssh_get_value "$config" "PermitRootLogin")"
permit_root="${permit_root:-yes (por defecto)}"
_ssh_print_check "$(_ssh_eval_permit_root "$permit_root")" \
"PermitRootLogin" "$permit_root" "debería ser 'no' o 'prohibit-password'"
local pass_auth
pass_auth="$(_ssh_get_value "$config" "PasswordAuthentication")"
pass_auth="${pass_auth:-yes (por defecto)}"
_ssh_print_check "$(_ssh_eval_password_auth "$pass_auth")" \
"PasswordAuthentication" "$pass_auth" "debería ser 'no' (usar claves)"
local port
port="$(_ssh_get_value "$config" "Port")"
port="${port:-22 (por defecto)}"
if [[ "$port" == "22"* ]]; then
_ssh_print_check "warn" "Port" "$port" "considera cambiar el puerto 22"
else
_ssh_print_check "ok" "Port" "$port" ""
fi
local max_tries
max_tries="$(_ssh_get_value "$config" "MaxAuthTries")"
max_tries="${max_tries:-6 (por defecto)}"
_ssh_print_check "$(_ssh_eval_max_auth_tries "${max_tries%% *}")" \
"MaxAuthTries" "$max_tries" "recomendado <= 3"
local x11
x11="$(_ssh_get_value "$config" "X11Forwarding")"
x11="${x11:-no (por defecto)}"
_ssh_print_check "$(_ssh_eval_x11_forwarding "$x11")" \
"X11Forwarding" "$x11" "deshabilitar si no se usa"
local allow_users allow_groups
allow_users="$(_ssh_get_value "$config" "AllowUsers")"
allow_groups="$(_ssh_get_value "$config" "AllowGroups")"
if [[ -z "$allow_users" && -z "$allow_groups" ]]; then
_ssh_print_check "warn" "AllowUsers/AllowGroups" "(no definidos)" "considera restringir acceso por usuario o grupo"
else
[[ -n "$allow_users" ]] && _ssh_print_check "ok" "AllowUsers" "$allow_users" ""
[[ -n "$allow_groups" ]] && _ssh_print_check "ok" "AllowGroups" "$allow_groups" ""
fi
echo ""
}
_ssh_show_failed_logins() {
echo -e "${PURPLE}════════ Últimos intentos de login fallidos ════${NC}"
echo ""
if command -v journalctl &>/dev/null; then
journalctl -u ssh -u sshd --no-pager -q 2>/dev/null \
| grep -i "failed\|invalid\|error" | tail -10 \
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
elif [[ -f /var/log/auth.log ]]; then
grep -i "failed\|invalid" /var/log/auth.log 2>/dev/null | tail -10 \
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
else
info "No se encontró fuente de logs de autenticación"
fi
echo ""
}
_ssh_show_authorized_keys() {
echo -e "${PURPLE}════════ Claves autorizadas (~/.ssh) ═══════════${NC}"
echo ""
local auth_keys="$HOME/.ssh/authorized_keys"
if [[ -f "$auth_keys" ]]; then
local count
count="$(wc -l < "$auth_keys")"
info "${count} clave(s) en authorized_keys:"
while IFS= read -r line; do
[[ -z "$line" || "$line" == "#"* ]] && continue
local key_type key_comment
key_type="$(echo "$line" | awk '{print $1}')"
key_comment="$(echo "$line" | awk '{print $NF}')"
echo -e " ${GREEN}*${NC} ${key_type} -- ${key_comment}"
done < "$auth_keys"
else
info "No existe $auth_keys"
fi
echo ""
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
audit_ssh_config() {
local config_path="${1:-/etc/ssh/sshd_config}"
if [[ ! -f "$config_path" ]]; then
warning "audit_ssh_config: no se encontró $config_path -- ¿está instalado sshd?"
else
_ssh_show_config_checks "$config_path"
fi
_ssh_show_failed_logins
_ssh_show_authorized_keys
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
audit_ssh_config "$@"
fi
@@ -0,0 +1,38 @@
---
name: check_firewall
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "check_firewall() -> void"
description: "Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra su estado, reglas activas y puertos en escucha para cruzar con las reglas. Si no se detecta ningún firewall, emite una advertencia de exposición."
tags: [bash, cybersecurity, firewall, ufw, iptables, network, hardening, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params: []
output: "imprime el firewall detectado, su estado (activo/inactivo), reglas vigentes y lista de puertos en escucha"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/check_firewall.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/sistema/firewall_status.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/check_firewall.sh
check_firewall
```
## Notas
La detección sigue el orden: ufw > firewalld > iptables > none. Para ufw muestra `ufw status verbose`; para firewalld muestra zona por defecto, servicios y puertos permitidos; para iptables muestra las cadenas INPUT/OUTPUT/FORWARD. Leer reglas de iptables requiere privilegios de root. El cruce de puertos en escucha (via `ss -tlnp`) ayuda a identificar servicios sin regla de firewall correspondiente.
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# check_firewall
# --------------
# Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra
# su estado y reglas. También lista los puertos en escucha para cruzar con reglas.
#
# USO (directo):
# check_firewall
#
# Depende de: ufw, firewall-cmd o iptables (el que esté disponible), ss
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Funciones puras ──────────────────────────────────────────────────────────
_fw_detect() {
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status:"; then
echo "ufw"
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q "running"; then
echo "firewalld"
elif command -v iptables &>/dev/null; then
echo "iptables"
else
echo "none"
fi
}
_fw_ufw_is_active() {
ufw status 2>/dev/null | grep -q "Status: active"
}
_fw_firewalld_is_running() {
firewall-cmd --state 2>/dev/null | grep -q "running"
}
_fw_iptables_has_rules() {
local count
count="$(iptables -L INPUT --line-numbers 2>/dev/null | grep -c "^[0-9]" || echo 0)"
[[ "$count" -gt 0 ]]
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_fw_show_ufw() {
echo -e "${PURPLE}════════ UFW ════════════════════════════════════${NC}"
echo ""
if _fw_ufw_is_active; then
success "UFW está activo"
else
echo -e " ${RED}[x]${NC} UFW está INACTIVO"
fi
echo ""
info "Reglas activas:"
ufw status verbose 2>/dev/null | while IFS= read -r line; do
echo -e " ${DIM_GRAY}${line}${NC}"
done
echo ""
}
_fw_show_firewalld() {
echo -e "${PURPLE}════════ FirewallD ══════════════════════════════${NC}"
echo ""
if _fw_firewalld_is_running; then
success "firewalld está activo"
else
echo -e " ${RED}[x]${NC} firewalld está INACTIVO"
fi
echo ""
local zone
zone="$(firewall-cmd --get-default-zone 2>/dev/null || echo "desconocida")"
info "Zona por defecto: ${zone}"
echo ""
info "Servicios permitidos en zona ${zone}:"
firewall-cmd --zone="$zone" --list-services 2>/dev/null \
| tr ' ' '\n' | while IFS= read -r svc; do
echo -e " ${GREEN}*${NC} ${svc}"
done
echo ""
info "Puertos permitidos:"
firewall-cmd --zone="$zone" --list-ports 2>/dev/null \
| tr ' ' '\n' | while IFS= read -r port; do
[[ -n "$port" ]] && echo -e " ${YELLOW}*${NC} ${port}"
done || true
echo ""
}
_fw_show_iptables() {
echo -e "${PURPLE}════════ iptables ═══════════════════════════════${NC}"
echo ""
for chain in INPUT OUTPUT FORWARD; do
echo -e "${CYAN}── ${chain} ──${NC}"
iptables -L "$chain" --line-numbers -n 2>/dev/null \
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
echo ""
done
if ! _fw_iptables_has_rules; then
echo -e " ${YELLOW}[!]${NC} No hay reglas INPUT definidas -- el sistema puede estar sin filtrar tráfico"
fi
}
_fw_show_none() {
echo ""
echo -e " ${RED}[x]${NC} No se detectó ningún firewall activo (ufw, firewalld, iptables)"
echo -e " ${YELLOW}[!]${NC} El sistema puede estar completamente expuesto"
echo ""
info "Para instalar y activar ufw: sudo apt install ufw && sudo ufw enable"
}
_fw_show_listening_crosscheck() {
echo ""
echo -e "${PURPLE}════════ Puertos en escucha (para cruzar con reglas) ════${NC}"
echo ""
ss -tlnp 2>/dev/null | tail -n +2 | while IFS= read -r line; do
echo -e " ${DIM_GRAY}${line}${NC}"
done
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
check_firewall() {
local fw
fw="$(_fw_detect)"
info "Firewall detectado: ${fw}"
echo ""
case "$fw" in
ufw) _fw_show_ufw ;;
firewalld) _fw_show_firewalld ;;
iptables) _fw_show_iptables ;;
none) _fw_show_none ;;
esac
_fw_show_listening_crosscheck
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
check_firewall "$@"
fi
@@ -0,0 +1,38 @@
---
name: detect_suspicious_users
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "detect_suspicious_users() -> void"
description: "Revisa el sistema en busca de indicadores de compromiso en cuentas de usuario: UIDs 0 extras (además de root), usuarios con shell de login válida, homes en rutas inusuales, miembros de grupos privilegiados (sudo, docker, wheel, adm, etc.) y sesiones activas."
tags: [bash, cybersecurity, users, audit, linux, privilege-escalation, hardening]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params: []
output: "imprime secciones con UIDs 0, usuarios con shell, homes inusuales, grupos privilegiados, últimos logins y sesiones activas"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/detect_suspicious_users.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/sistema/usuarios_sospechosos.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/detect_suspicious_users.sh
detect_suspicious_users
```
## Notas
Los usuarios de sistema (UID <= 999) se excluyen del check de shell válida para evitar falsos positivos. Los grupos privilegiados monitorizados son: sudo, wheel, docker, adm, lxd, libvirt, kvm, disk, shadow. Homes inusuales son aquellos fuera de /home, /root, /var, /srv, /nonexistent y /tmp. `lastlog` puede no estar disponible en todas las distribuciones.
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# detect_suspicious_users
# -----------------------
# Revisa el sistema en busca de usuarios potencialmente sospechosos:
# UIDs 0 extras, shells válidas, homes en rutas inusuales, grupos privilegiados
# y sesiones activas.
#
# USO (directo):
# detect_suspicious_users
#
# Depende de: /etc/passwd, getent, w, lastlog
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Constantes ───────────────────────────────────────────────────────────────
_SUSPICIOUS_VALID_SHELLS=("/bin/bash" "/bin/sh" "/bin/zsh" "/bin/fish" "/usr/bin/bash" "/usr/bin/zsh" "/usr/bin/fish")
_SUSPICIOUS_PRIVILEGED_GROUPS=("sudo" "wheel" "docker" "adm" "lxd" "libvirt" "kvm" "disk" "shadow")
_SUSPICIOUS_SYSTEM_USERS_MAX_UID=999
# ─── Funciones puras ──────────────────────────────────────────────────────────
_sus_is_valid_shell() {
local shell="$1"
for s in "${_SUSPICIOUS_VALID_SHELLS[@]}"; do
[[ "$shell" == "$s" ]] && return 0
done
return 1
}
_sus_is_system_user() {
local uid="$1"
[[ "$uid" -le $_SUSPICIOUS_SYSTEM_USERS_MAX_UID ]]
}
_sus_is_unusual_home() {
local home="$1"
[[ ! "$home" =~ ^(/home|/root|/var|/srv|/nonexistent|/tmp) ]]
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_sus_show_uid0_users() {
echo -e "${PURPLE}════════ Usuarios con UID 0 (root) ═════════════${NC}"
echo ""
local found=0
while IFS=: read -r username _ uid _; do
if [[ "$uid" -eq 0 ]]; then
if [[ "$username" == "root" ]]; then
echo -e " ${GREEN}[ok]${NC} root (esperado)"
else
echo -e " ${RED}[x]${NC} ${username} tiene UID 0 -- SOSPECHOSO"
fi
found=$((found + 1))
fi
done < /etc/passwd
if [[ $found -eq 1 ]]; then
echo ""
success "Solo root tiene UID 0"
fi
echo ""
}
_sus_show_users_with_shell() {
echo -e "${PURPLE}════════ Usuarios con shell de login válida ═════${NC}"
echo ""
echo -e " ${GRAY}(excluye usuarios de sistema con UID <= ${_SUSPICIOUS_SYSTEM_USERS_MAX_UID})${NC}"
echo ""
local found=0
while IFS=: read -r username _ uid _ _ home shell; do
if ! _sus_is_system_user "$uid" && _sus_is_valid_shell "$shell"; then
echo -e " ${CYAN}*${NC} ${username} (UID ${uid}) -- shell: ${shell} -- home: ${home}"
found=$((found + 1))
fi
done < /etc/passwd
[[ $found -eq 0 ]] && info "No se encontraron usuarios normales con shell válida"
echo ""
}
_sus_show_unusual_homes() {
echo -e "${PURPLE}════════ Usuarios con home inusual ══════════════${NC}"
echo ""
local found=0
while IFS=: read -r username _ uid _ _ home shell; do
if _sus_is_valid_shell "$shell" && _sus_is_unusual_home "$home"; then
echo -e " ${YELLOW}[!]${NC} ${username} -- home: ${home}"
found=$((found + 1))
fi
done < /etc/passwd
[[ $found -eq 0 ]] && success "No se detectaron homes en rutas inusuales"
echo ""
}
_sus_show_privileged_groups() {
echo -e "${PURPLE}════════ Grupos privilegiados y sus miembros ════${NC}"
echo ""
for group in "${_SUSPICIOUS_PRIVILEGED_GROUPS[@]}"; do
local members
members="$(getent group "$group" 2>/dev/null | cut -d: -f4 || true)"
if [[ -n "$members" ]]; then
echo -e " ${YELLOW}*${NC} ${group}: ${members}"
fi
done
echo ""
}
_sus_show_active_sessions() {
echo -e "${PURPLE}════════ Sesiones activas ═══════════════════════${NC}"
echo ""
w 2>/dev/null | while IFS= read -r line; do
echo -e " ${DIM_GRAY}${line}${NC}"
done
echo ""
}
_sus_show_last_logins() {
echo -e "${PURPLE}════════ Últimos logins por usuario ═════════════${NC}"
echo ""
if command -v lastlog &>/dev/null; then
lastlog 2>/dev/null | awk 'NR==1 || $NF != "logged" {
if (NR==1 || $2 != "**Never") printf " %-16s %-10s %s\n", $1, $2, $NF
}' | grep -v "^$" | while IFS= read -r line; do
echo -e " ${DIM_GRAY}${line}${NC}"
done
else
warning "lastlog no disponible"
fi
echo ""
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
detect_suspicious_users() {
_sus_show_uid0_users
_sus_show_users_with_shell
_sus_show_unusual_homes
_sus_show_privileged_groups
_sus_show_last_logins
_sus_show_active_sessions
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
detect_suspicious_users "$@"
fi
@@ -0,0 +1,50 @@
---
name: encrypt_file
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "encrypt_file(mode: string, file: string) -> void"
description: "Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones) via openssl. La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita interactivamente. El archivo cifrado se guarda con extensión .enc; al descifrar se recupera el nombre original."
tags: [bash, cybersecurity, encryption, aes256, openssl, crypto, pbkdf2]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: mode
desc: "operación a realizar: encrypt (cifrar) o decrypt (descifrar)"
- name: file
desc: "ruta al archivo a cifrar o descifrar"
output: "genera el archivo cifrado (input.enc) o descifrado (input sin .enc, o input.dec) e imprime progreso a stdout"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/encrypt_file.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/utilidades/cifrar_archivo.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/encrypt_file.sh
# Cifrar (solicita contraseña interactivamente)
encrypt_file encrypt documento.pdf
# Descifrar
encrypt_file decrypt documento.pdf.enc
# Con contraseña via variable de entorno (no interactivo)
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file encrypt datos.tar.gz
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file decrypt datos.tar.gz.enc
```
## Notas
Usa `openssl enc -aes-256-cbc -pbkdf2 -iter 310000` — compatible con OpenSSL 1.1.1+. Las 310.000 iteraciones de PBKDF2 siguen las recomendaciones NIST para derivación de claves en 2024. La contraseña se limpia de memoria al terminar. Si el archivo de salida ya existe, la función falla silenciosamente (no sobrescribe por seguridad cuando se usa con ENCRYPT_PASSWORD).
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# encrypt_file
# ------------
# Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones).
# La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita
# interactivamente por stdin.
#
# USO (directo):
# encrypt_file encrypt <archivo>
# encrypt_file decrypt <archivo.enc>
# ENCRYPT_PASSWORD=secreto encrypt_file encrypt archivo.txt
#
# Depende de: openssl
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Funciones puras ──────────────────────────────────────────────────────────
_enc_output_path_encrypt() {
local input="$1"
echo "${input}.enc"
}
_enc_output_path_decrypt() {
local input="$1"
if [[ "$input" == *.enc ]]; then
echo "${input%.enc}"
else
echo "${input}.dec"
fi
}
_enc_human_size() {
local file="$1"
du -sh "$file" 2>/dev/null | cut -f1
}
# ─── Funciones de efecto ──────────────────────────────────────────name──────────
_enc_ask_password() {
local pass
read -rsp "Contraseña: " pass
echo "" >&2
echo "$pass"
}
_enc_ask_password_confirm() {
local pass1 pass2
read -rsp "Contraseña: " pass1
echo "" >&2
read -rsp "Confirmar contraseña: " pass2
echo "" >&2
if [[ "$pass1" != "$pass2" ]]; then
error "Las contraseñas no coinciden" >&2
return 1
fi
if [[ ${#pass1} -lt 8 ]]; then
warning "La contraseña es muy corta (mínimo 8 caracteres recomendado)"
fi
echo "$pass1"
}
_enc_do_encrypt() {
local input="$1"
local output="$2"
local password="$3"
openssl enc -aes-256-cbc -pbkdf2 -iter 310000 \
-in "$input" -out "$output" \
-pass "pass:${password}" 2>/dev/null
}
_enc_do_decrypt() {
local input="$1"
local output="$2"
local password="$3"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 \
-in "$input" -out "$output" \
-pass "pass:${password}" 2>/dev/null
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
encrypt_file() {
local mode="$1"
local file="$2"
if [[ -z "$mode" || -z "$file" ]]; then
error "encrypt_file: uso: encrypt_file <encrypt|decrypt> <archivo>" >&2
return 1
fi
if ! command -v openssl &>/dev/null; then
error "encrypt_file: 'openssl' no está instalado (sudo apt install openssl)" >&2
return 1
fi
if [[ ! -f "$file" ]]; then
error "encrypt_file: archivo no encontrado: $file" >&2
return 1
fi
local password
if [[ -n "${ENCRYPT_PASSWORD:-}" ]]; then
password="$ENCRYPT_PASSWORD"
else
case "$mode" in
encrypt) password="$(_enc_ask_password_confirm)" || return 1 ;;
decrypt) password="$(_enc_ask_password)" ;;
*)
error "encrypt_file: modo no válido '$mode'. Use: encrypt|decrypt" >&2
return 1
;;
esac
fi
case "$mode" in
encrypt)
local output
output="$(_enc_output_path_encrypt "$file")"
info "Archivo: ${file} ($(_enc_human_size "$file"))"
info "Salida: ${output}"
info "Cifrando con AES-256-CBC + PBKDF2..."
if _enc_do_encrypt "$file" "$output" "$password"; then
success "Archivo cifrado: ${output} ($(_enc_human_size "$output"))"
else
error "encrypt_file: el cifrado falló" >&2
rm -f "$output"
return 1
fi
;;
decrypt)
local output
output="$(_enc_output_path_decrypt "$file")"
info "Archivo: ${file} ($(_enc_human_size "$file"))"
info "Salida: ${output}"
info "Descifrando..."
if _enc_do_decrypt "$file" "$output" "$password"; then
success "Archivo descifrado: ${output} ($(_enc_human_size "$output"))"
else
error "encrypt_file: el descifrado falló -- ¿contraseña incorrecta?" >&2
rm -f "$output"
return 1
fi
;;
esac
# Limpiar contraseña de memoria
password=""
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
encrypt_file "$@"
fi
@@ -0,0 +1,46 @@
---
name: enumerate_subdomains
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "enumerate_subdomains(domain: string, output_file: string) -> void"
description: "Enumera subdominios de un dominio objetivo usando un diccionario integrado de ~100 subdominios comunes (www, mail, api, dev, admin, vpn, etc.). Detecta tanto registros A (IP directa) como CNAME. Muestra progreso cada 20 subdominios y opcionalmente guarda los resultados en un archivo."
tags: [bash, cybersecurity, dns, subdomain, enumeration, reconnaissance, osint]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: domain
desc: "dominio objetivo a enumerar, ej: example.com"
- name: output_file
desc: "ruta al archivo donde guardar los resultados (opcional; si se omite, solo imprime a stdout)"
output: "imprime subdominios encontrados con su IP o CNAME, progreso cada 20 entradas y resumen final con total encontrados"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/enumerate_subdomains.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/web/subdominios.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/enumerate_subdomains.sh
# Solo imprimir resultados
enumerate_subdomains example.com
# Guardar resultados en archivo
enumerate_subdomains example.com /tmp/subdominios.txt
```
## Notas
Requiere `dig` (paquete dnsutils). El diccionario integrado cubre subdominios comunes en entornos corporativos y de desarrollo: www, api, dev, admin, vpn, git, jenkins, staging, prod, db, mail, smtp, ns1/ns2, grafana, kibana, docker, k8s, auth, sso, etc. (~100 entradas). La enumeración es puramente pasiva via DNS — no realiza ningún tipo de conexión al servidor web. Los subdominios con CNAME sin resolución A se marcan en amarillo.
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# enumerate_subdomains
# --------------------
# Enumera subdominios de un dominio objetivo usando un diccionario integrado de
# ~100 subdominios comunes. Detecta tanto registros A (IP directa) como CNAME.
# Opcionalmente guarda el resultado en un archivo.
#
# USO (directo):
# enumerate_subdomains <dominio> [archivo_salida]
# enumerate_subdomains example.com
# enumerate_subdomains example.com /tmp/resultado.txt
#
# Depende de: dig (dnsutils)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Diccionario integrado ─────────────────────────────────────────────────────
_SUBDOMAIN_WORDLIST=(
www mail ftp api dev admin vpn ssh git gitlab github jenkins ci cd
staging prod test demo beta alpha app web portal intranet extranet
remote desktop files cdn static assets media img images upload
db database mysql postgres redis mongo smtp pop imap webmail mx
ns1 ns2 dns autodiscover autoconfig crm erp shop store payment
backup old legacy v1 v2 v3 internal corp office support helpdesk
wiki docs doc status monitor grafana kibana elastic search
registry docker k8s kubernetes auth sso login oauth api2 mobile
)
# ─── Funciones puras ──────────────────────────────────────────────────────────
_sub_is_valid_domain() {
[[ -n "$1" && "$1" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
}
_sub_build_fqdn() {
local sub="$1"
local domain="$2"
echo "${sub}.${domain}"
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_sub_resolve_a() {
local fqdn="$1"
dig +short A "$fqdn" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || true
}
_sub_resolve_cname() {
local fqdn="$1"
dig +short CNAME "$fqdn" 2>/dev/null | head -1 || true
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
enumerate_subdomains() {
local domain="$1"
local output_file="${2:-}"
if [[ -z "$domain" ]]; then
error "enumerate_subdomains: se requiere un dominio como argumento" >&2
return 1
fi
if ! _sub_is_valid_domain "$domain"; then
error "enumerate_subdomains: dominio no válido: '$domain'" >&2
return 1
fi
if ! command -v dig &>/dev/null; then
error "enumerate_subdomains: 'dig' no está instalado (sudo apt install dnsutils)" >&2
return 1
fi
local total="${#_SUBDOMAIN_WORDLIST[@]}"
local found=0
local checked=0
info "Probando ${total} subdominios en ${domain}..."
echo ""
if [[ -n "$output_file" ]]; then
echo "# Subdominios encontrados en ${domain} -- $(date)" > "$output_file"
fi
for sub in "${_SUBDOMAIN_WORDLIST[@]}"; do
local fqdn
fqdn="$(_sub_build_fqdn "$sub" "$domain")"
checked=$((checked + 1))
local ip
ip="$(_sub_resolve_a "$fqdn")"
if [[ -n "$ip" ]]; then
echo -e " ${GREEN}[ok]${NC} ${fqdn} -> ${CYAN}${ip}${NC}"
[[ -n "$output_file" ]] && echo "${fqdn} -> ${ip}" >> "$output_file"
found=$((found + 1))
else
local cname
cname="$(_sub_resolve_cname "$fqdn")"
if [[ -n "$cname" ]]; then
echo -e " ${YELLOW}[cn]${NC} ${fqdn} -> CNAME: ${cname}"
[[ -n "$output_file" ]] && echo "${fqdn} -> CNAME: ${cname}" >> "$output_file"
found=$((found + 1))
fi
fi
# Progreso cada 20 subdominios
if (( checked % 20 == 0 )); then
echo -e " ${DIM_GRAY}[${checked}/${total} probados, ${found} encontrados]${NC}"
fi
done
echo ""
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
if [[ $found -eq 0 ]]; then
info "No se encontraron subdominios en el diccionario"
else
success "Total encontrados: ${found} de ${total} probados"
[[ -n "$output_file" ]] && info "Resultado guardado en: ${output_file}"
fi
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
enumerate_subdomains "$@"
fi
@@ -0,0 +1,54 @@
---
name: generate_password
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "generate_password(mode: string, length: int, count: int) -> void"
description: "Genera contraseñas seguras en cuatro modos: full (alfanumérico + símbolos, excluye caracteres ambiguos), alpha (solo alfanumérico), passphrase (palabras aleatorias unidas con guión) y pin (numérico). Calcula y muestra la entropía en bits para cada modo."
tags: [bash, cybersecurity, password, generator, entropy, security, urandom]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: mode
desc: "modo de generación: full (alfanumérico+símbolos, por defecto), alpha (solo letras y números), passphrase (palabras), pin (numérico)"
- name: length
desc: "longitud en caracteres para full/alpha/pin, o número de palabras para passphrase (por defecto: 16)"
- name: count
desc: "número de contraseñas a generar (por defecto: 1)"
output: "imprime las contraseñas generadas a stdout (una por línea) con información de entropía"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/generate_password.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/utilidades/generar_password.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/generate_password.sh
# Contraseña completa de 20 caracteres
generate_password full 20
# 5 contraseñas alfanuméricas de 16 caracteres
generate_password alpha 16 5
# Passphrase de 6 palabras
generate_password passphrase 6
# PIN de 8 dígitos
generate_password pin 8
```
## Notas
Usa `/dev/urandom` como fuente de aleatoriedad criptográficamente segura. El modo `full` excluye caracteres ambiguos (0, O, l, I, 1) para mejorar legibilidad. El modo `passphrase` requiere un diccionario del sistema (`/usr/share/dict/words` o similar). La entropía se calcula como log2(charset^length) en bits. Las contraseñas nunca se escriben a disco.
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# generate_password
# -----------------
# Genera contraseñas seguras en varios modos: completo (alfanumérico + símbolos),
# solo alfanumérico, passphrase de palabras o PIN numérico.
# Calcula la entropía en bits para cada contraseña generada.
#
# USO (directo):
# generate_password [full|alpha|passphrase|pin] [longitud] [cantidad]
#
# Depende de: /dev/urandom, python3 (para entropía), shuf (para passphrases)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Constantes ───────────────────────────────────────────────────────────────
_GENPW_DEFAULT_LENGTH=16
_GENPW_DEFAULT_COUNT=1
_GENPW_WORDLIST_PATHS=("/usr/share/dict/words" "/usr/dict/words" "/usr/share/dict/american-english")
# ─── Funciones puras ──────────────────────────────────────────────────────────
_genpw_find_wordlist() {
for path in "${_GENPW_WORDLIST_PATHS[@]}"; do
[[ -f "$path" ]] && echo "$path" && return
done
echo ""
}
_genpw_calc_entropy() {
local charset_size="$1"
local length="$2"
python3 -c "import math; print(f'{math.log2(${charset_size}**${length}):.1f}')" 2>/dev/null || echo "?"
}
# ─── Funciones de generación ──────────────────────────────────────────────────
_genpw_gen_full() {
local length="$1"
# Alfanumérico + símbolos (excluye ambiguos: 0OlI1)
tr -dc 'A-HJ-NP-Za-km-z2-9!@#$%^&*()_+-=[]{}|;:,.<>?' \
< /dev/urandom | head -c "$length"
echo
}
_genpw_gen_alpha() {
local length="$1"
tr -dc 'A-HJ-NP-Za-km-z2-9' \
< /dev/urandom | head -c "$length"
echo
}
_genpw_gen_passphrase() {
local words="$1"
local wordlist
wordlist="$(_genpw_find_wordlist)"
if [[ -z "$wordlist" ]]; then
error "generate_password: no se encontró diccionario (sudo apt install wamerican)" >&2
return 1
fi
local phrase=""
for ((i=0; i<words; i++)); do
local word
word="$(shuf -n1 "$wordlist" | tr -dc 'a-z' | head -c 20)"
[[ ${#word} -lt 3 ]] && { i=$((i-1)); continue; }
phrase="${phrase}${word}-"
done
echo "${phrase%-}"
}
_genpw_gen_pin() {
local length="$1"
tr -dc '0-9' < /dev/urandom | head -c "$length"
echo
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
generate_password() {
local mode="${1:-full}"
local length="${2:-$_GENPW_DEFAULT_LENGTH}"
local count="${3:-$_GENPW_DEFAULT_COUNT}"
# Validar que length y count son numéricos
if ! [[ "$length" =~ ^[0-9]+$ ]] || ! [[ "$count" =~ ^[0-9]+$ ]]; then
error "generate_password: longitud y cantidad deben ser números enteros positivos" >&2
return 1
fi
local charset_size entropy
case "$mode" in
full)
charset_size=78
entropy="$(_genpw_calc_entropy $charset_size "$length")"
info "Contraseñas alfanuméricas + símbolos (longitud: ${length}, entropía: ~${entropy} bits)"
echo ""
for ((i=1; i<=count; i++)); do
_genpw_gen_full "$length"
done
;;
alpha)
charset_size=56
entropy="$(_genpw_calc_entropy $charset_size "$length")"
info "Contraseñas alfanuméricas (longitud: ${length}, entropía: ~${entropy} bits)"
echo ""
for ((i=1; i<=count; i++)); do
_genpw_gen_alpha "$length"
done
;;
passphrase)
info "Passphrases (${length} palabras)"
echo ""
for ((i=1; i<=count; i++)); do
_genpw_gen_passphrase "$length" || return 1
done
;;
pin)
charset_size=10
entropy="$(_genpw_calc_entropy $charset_size "$length")"
info "PINs numéricos (longitud: ${length}, entropía: ~${entropy} bits)"
echo ""
for ((i=1; i<=count; i++)); do
_genpw_gen_pin "$length"
done
;;
*)
error "generate_password: modo no válido '$mode'. Use: full|alpha|passphrase|pin" >&2
return 1
;;
esac
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
generate_password "$@"
fi
@@ -0,0 +1,44 @@
---
name: geolocate_ip
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "geolocate_ip(target: string) -> void"
description: "Geolocaliza una dirección IP o dominio usando la API pública de ip-api.com. Muestra país, región, ciudad, coordenadas, ISP, ASN y detecta VPN, Proxy o infraestructura de hosting."
tags: [bash, cybersecurity, network, geoip, ip, osint, reconnaissance]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: target
desc: "dirección IP (IPv4) o nombre de dominio a geolocalizar"
output: "imprime información de geolocalización a stdout: país, ciudad, ISP, ASN, coordenadas y flags de VPN/Proxy/Hosting"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/geolocate_ip.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/redes/geoip.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/geolocate_ip.sh
# Geolocalizar una IP
geolocate_ip 8.8.8.8
# Geolocalizar un dominio (resuelve a IP primero)
geolocate_ip example.com
```
## Notas
Requiere `curl`. Si se pasa un dominio en lugar de una IP, se resuelve a IP usando `dig` antes de consultar la API. La API de ip-api.com es gratuita para uso no comercial con límite de 45 req/min. Los campos `proxy=true` y `hosting=true` indican posible uso de VPN, proxy Tor o datacenter.
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# geolocate_ip
# ------------
# Geolocaliza una IP o dominio usando la API pública de ip-api.com.
# Muestra país, ciudad, ISP, ASN y detecta VPN/Proxy/Hosting.
#
# USO (directo):
# geolocate_ip <ip_o_dominio>
#
# Depende de: curl, dig (para resolver dominios)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Constantes ───────────────────────────────────────────────────────────────
_GEOIP_API="http://ip-api.com/json"
_GEOIP_FIELDS="status,message,country,countryCode,regionName,city,zip,lat,lon,isp,org,as,proxy,hosting,query"
# ─── Funciones puras ──────────────────────────────────────────────────────────
_geo_is_ip() {
[[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]
}
_geo_build_api_url() {
local target="$1"
echo "${_GEOIP_API}/${target}?fields=${_GEOIP_FIELDS}"
}
_geo_extract_field() {
local json="$1"
local key="$2"
echo "$json" | grep -o "\"${key}\":[^,}]*" | cut -d: -f2- | tr -d '"' | xargs
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_geo_resolve_domain() {
local domain="$1"
dig +short A "$domain" 2>/dev/null | head -1 || true
}
_geo_fetch() {
local target="$1"
local url
url="$(_geo_build_api_url "$target")"
curl -s --max-time 10 "$url" 2>/dev/null
}
_geo_display_result() {
local json="$1"
local status
status="$(_geo_extract_field "$json" "status")"
if [[ "$status" != "success" ]]; then
local msg
msg="$(_geo_extract_field "$json" "message")"
error "La API devolvió error: ${msg:-respuesta inesperada}" >&2
return 1
fi
local ip_queried country country_code region city zip lat lon isp org asn proxy hosting
ip_queried="$(_geo_extract_field "$json" "query")"
country="$(_geo_extract_field "$json" "country")"
country_code="$(_geo_extract_field "$json" "countryCode")"
region="$(_geo_extract_field "$json" "regionName")"
city="$(_geo_extract_field "$json" "city")"
zip="$(_geo_extract_field "$json" "zip")"
lat="$(_geo_extract_field "$json" "lat")"
lon="$(_geo_extract_field "$json" "lon")"
isp="$(_geo_extract_field "$json" "isp")"
org="$(_geo_extract_field "$json" "org")"
asn="$(_geo_extract_field "$json" "as")"
proxy="$(_geo_extract_field "$json" "proxy")"
hosting="$(_geo_extract_field "$json" "hosting")"
echo ""
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
echo -e " ${CYAN}IP consultada:${NC} ${ip_queried}"
echo -e " ${CYAN}País:${NC} ${country} (${country_code})"
echo -e " ${CYAN}Región:${NC} ${region}"
echo -e " ${CYAN}Ciudad:${NC} ${city} ${zip}"
echo -e " ${CYAN}Coordenadas:${NC} ${lat}, ${lon}"
echo -e " ${CYAN}ISP:${NC} ${isp}"
echo -e " ${CYAN}Organización:${NC} ${org}"
echo -e " ${CYAN}ASN:${NC} ${asn}"
echo ""
if [[ "$proxy" == "true" ]]; then
echo -e " ${RED}[!] VPN / Proxy detectado${NC}"
fi
if [[ "$hosting" == "true" ]]; then
echo -e " ${YELLOW}[i] Hosting / datacenter detectado${NC}"
fi
if [[ "$proxy" != "true" && "$hosting" != "true" ]]; then
echo -e " ${GREEN}[ok] Sin indicios de VPN, Proxy o Tor${NC}"
fi
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
geolocate_ip() {
local target="$1"
if [[ -z "$target" ]]; then
error "geolocate_ip: se requiere una IP o dominio como argumento" >&2
return 1
fi
if ! command -v curl &>/dev/null; then
error "geolocate_ip: 'curl' no está instalado (sudo apt install curl)" >&2
return 1
fi
local query_target="$target"
if ! _geo_is_ip "$target"; then
info "Resolviendo dominio a IP..."
local resolved
resolved="$(_geo_resolve_domain "$target")"
if [[ -z "$resolved" ]]; then
error "geolocate_ip: no se pudo resolver '$target'" >&2
return 1
fi
info "Resuelto: ${target} -> ${resolved}"
query_target="$resolved"
fi
info "Consultando geolocalización de ${query_target}..."
local json
json="$(_geo_fetch "$query_target")"
if [[ -z "$json" ]]; then
error "geolocate_ip: no se obtuvo respuesta de la API" >&2
return 1
fi
_geo_display_result "$json"
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
geolocate_ip "$@"
fi
@@ -0,0 +1,47 @@
---
name: inspect_ssl_cert
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "inspect_ssl_cert(host: string) -> void"
description: "Inspecciona el certificado SSL/TLS de un host: muestra sujeto, emisor, fechas de validez, días hasta expiración, SANs (Subject Alternative Names), cadena de confianza completa y detecta soporte de versiones inseguras TLS 1.0/1.1."
tags: [bash, cybersecurity, ssl, tls, certificate, web, openssl, security]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "host a inspeccionar, acepta formato host o host:puerto (por defecto puerto 443), ej: example.com o example.com:8443"
output: "imprime detalles del certificado SSL/TLS, días hasta expiración con nivel de alerta, SANs, cadena de confianza y resultado de checks de versiones TLS"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/inspect_ssl_cert.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/web/ssl_cert_info.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/inspect_ssl_cert.sh
# Puerto 443 por defecto
inspect_ssl_cert example.com
# Puerto personalizado
inspect_ssl_cert example.com:8443
# API interna
inspect_ssl_cert api.internal.example.com:4443
```
## Notas
Requiere `openssl` y `timeout`. Usa `openssl s_client` con SNI (`-servername`) para soportar virtual hosting. La alerta de expiración se activa a 30 días o menos. La detección de TLS 1.0/1.1 usa flags `-tls1` y `-tls1_1` de openssl s_client — si el servidor acepta la conexión y negocia un cipher, el protocolo inseguro está habilitado. Cada conexión tiene timeout de 10 segundos para evitar cuelgues en hosts sin respuesta.
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# inspect_ssl_cert
# ----------------
# Inspecciona el certificado SSL/TLS de un host: sujeto, emisor, fechas de validez,
# SANs, cadena de confianza y versiones de TLS aceptadas (detecta TLS 1.0/1.1 inseguros).
#
# USO (directo):
# inspect_ssl_cert <host[:puerto]>
# inspect_ssl_cert example.com
# inspect_ssl_cert example.com:8443
#
# Depende de: openssl, timeout
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../shell/bash_colors.sh"
source "$SCRIPT_DIR/../shell/bash_log.sh"
bash_colors
bash_log_init
# ─── Constantes ───────────────────────────────────────────────────────────────
_SSL_WARN_DAYS=30
# ─── Funciones puras ──────────────────────────────────────────────────────────
_ssl_parse_host_port() {
local input="$1"
if [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
else
echo "${input} 443"
fi
}
_ssl_days_until_expiry() {
local expiry_str="$1"
local expiry_epoch
expiry_epoch="$(date -d "$expiry_str" +%s 2>/dev/null || echo 0)"
local now_epoch
now_epoch="$(date +%s)"
echo $(( (expiry_epoch - now_epoch) / 86400 ))
}
# ─── Funciones de efecto ──────────────────────────────────────────────────────
_ssl_fetch_subject() {
local host="$1"
local port="$2"
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
| openssl x509 -noout -subject -issuer 2>/dev/null
}
_ssl_fetch_dates() {
local host="$1"
local port="$2"
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
| openssl x509 -noout -dates 2>/dev/null
}
_ssl_fetch_san() {
local host="$1"
local port="$2"
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
| grep -oE 'DNS:[^,]+' | sed 's/DNS://g' | tr '\n' ' '
}
_ssl_fetch_chain() {
local host="$1"
local port="$2"
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" \
-showcerts 2>/dev/null \
| grep -E "^(subject|issuer)=" | sed 's/^/ /'
}
_ssl_check_tls_version() {
local host="$1"
local port="$2"
local proto="$3"
local label="$4"
if echo | timeout 5 openssl s_client -connect "${host}:${port}" \
-servername "$host" "${proto}" 2>/dev/null | grep -q "Cipher"; then
echo -e " ${RED}[x]${NC} ${label} -- soportado (inseguro)"
else
echo -e " ${GREEN}[ok]${NC} ${label} no soportado"
fi
}
# ─── Punto de entrada ─────────────────────────────────────────────────────────
inspect_ssl_cert() {
local input="$1"
if [[ -z "$input" ]]; then
error "inspect_ssl_cert: se requiere un host como argumento (ej: example.com o example.com:8443)" >&2
return 1
fi
if ! command -v openssl &>/dev/null; then
error "inspect_ssl_cert: 'openssl' no está instalado (sudo apt install openssl)" >&2
return 1
fi
local host port
read -r host port <<< "$(_ssl_parse_host_port "$input")"
info "Conectando a ${host}:${port}..."
echo ""
local subj_issuer
subj_issuer="$(_ssl_fetch_subject "$host" "$port")"
if [[ -z "$subj_issuer" ]]; then
error "inspect_ssl_cert: no se pudo obtener el certificado. ¿El host está disponible?" >&2
return 1
fi
local subject issuer
subject="$(echo "$subj_issuer" | grep ^subject | cut -d= -f2- | xargs)"
issuer="$(echo "$subj_issuer" | grep ^issuer | cut -d= -f2- | xargs)"
local dates
dates="$(_ssl_fetch_dates "$host" "$port")"
local not_before not_after
not_before="$(echo "$dates" | grep notBefore | cut -d= -f2)"
not_after="$(echo "$dates" | grep notAfter | cut -d= -f2)"
local days_left
days_left="$(_ssl_days_until_expiry "$not_after")"
local sans
sans="$(_ssl_fetch_san "$host" "$port")"
echo -e "${PURPLE}════════ Certificado SSL/TLS ════════════════════${NC}"
echo -e " ${CYAN}Sujeto:${NC} ${subject}"
echo -e " ${CYAN}Emisor:${NC} ${issuer}"
echo -e " ${CYAN}Válido desde:${NC} ${not_before}"
echo -e " ${CYAN}Válido hasta:${NC} ${not_after}"
echo ""
if [[ $days_left -le 0 ]]; then
echo -e " ${RED}[x] CERTIFICADO EXPIRADO${NC}"
elif [[ $days_left -le $_SSL_WARN_DAYS ]]; then
echo -e " ${YELLOW}[!] Expira en ${days_left} días -- renovar pronto${NC}"
else
echo -e " ${GREEN}[ok] Válido -- expira en ${days_left} días${NC}"
fi
echo ""
echo -e " ${CYAN}SANs:${NC}"
echo "$sans" | tr ' ' '\n' | grep -v '^$' | while IFS= read -r san; do
echo -e " ${GREEN}*${NC} ${san}"
done
echo ""
echo -e "${PURPLE}════════ Cadena de confianza ════════════════════${NC}"
_ssl_fetch_chain "$host" "$port"
echo ""
echo -e "${PURPLE}════════ Versiones TLS aceptadas ════════════════${NC}"
_ssl_check_tls_version "$host" "$port" "-tls1" "TLS 1.0"
_ssl_check_tls_version "$host" "$port" "-tls1_1" "TLS 1.1"
echo -e " ${GREEN}[ok]${NC} TLS 1.2 / 1.3 (estándar)"
echo -e "${PURPLE}═════════════════════════════════════════════════${NC}"
}
# Ejecutar si se llama directamente
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
inspect_ssl_cert "$@"
fi
@@ -0,0 +1,47 @@
---
name: list_active_connections
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "list_active_connections(mode: string) -> void"
description: "Muestra conexiones de red activas del sistema usando ss: puertos en escucha, conexiones establecidas y detección de conexiones hacia IPs externas (excluye RFC1918, loopback y link-local)."
tags: [bash, cybersecurity, network, connections, monitoring, ss, ports]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: mode
desc: "modo de visualización: listening (puertos en escucha), established (conexiones activas), external (solo IPs externas) o all (todo, por defecto)"
output: "imprime tabla de conexiones de red a stdout con colores ANSI; las conexiones a IPs externas se resaltan en amarillo"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/list_active_connections.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/ciberseguridad/redes/conexiones_activas.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/list_active_connections.sh
# Todas las conexiones
list_active_connections
# Solo puertos en escucha
list_active_connections listening
# Solo conexiones hacia internet
list_active_connections external
```
## Notas
Requiere `ss` del paquete iproute2 (disponible por defecto en la mayoría de distribuciones modernas). La detección de IPs externas excluye: 127.x, ::1, 0.0.0.0, rangos RFC1918 (10.x, 172.16-31.x, 192.168.x) y link-local (fe80:). Usa `ss -tnp` para mostrar el proceso asociado a cada conexión (puede requerir sudo para ver procesos de otros usuarios).

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