Compare commits
122 Commits
master
...
ac9965220d
| Author | SHA1 | Date | |
|---|---|---|---|
| ac9965220d | |||
| 1344e557e5 | |||
| 2721b9cc8f | |||
| d9414e4cba | |||
| 7aa7790931 | |||
| c3dfc9315f | |||
| cb96e85b69 | |||
| 0bdb3d72d7 | |||
| 4e8bbb0a88 | |||
| ffbcafa52d | |||
| d9b448a07b | |||
| 5c712bb974 | |||
| 29dee49a36 | |||
| f0d9ffa2bb | |||
| 132a7d3240 | |||
| dcd1843609 | |||
| d2ae672a23 | |||
| 76a607cf6f | |||
| a1b7e5e143 | |||
| fc8062bade | |||
| 7eef2544ab | |||
| 5aef738bc8 | |||
| 126a20ce07 | |||
| f3e62e8303 | |||
| 5965997c9e | |||
| 690e68a542 | |||
| c311623a76 | |||
| b1016ec845 | |||
| cbc4c5eafa | |||
| 89e443ab18 | |||
| f4932ce64c | |||
| 2d108c295a | |||
| 73a4c3a148 | |||
| 356dbcdadd | |||
| 1233efb31d | |||
| 513c2fb4a7 | |||
| 9b5c430f7f | |||
| 5f4f1f7508 | |||
| 9b4bb3aabc | |||
| 34ecadf5a4 | |||
| b55f120a00 | |||
| 89730911c2 | |||
| 3a3a8fd9a9 | |||
| 29b1c4cd8b | |||
| 131f860a94 | |||
| 9660a1c432 | |||
| 256e038cbe | |||
| b406b29074 | |||
| 834e910bcf | |||
| 7605a5760a | |||
| 9f4ac6de32 | |||
| a9f2c60e3d | |||
| 9fd0ca9cac | |||
| 63a9cb5273 | |||
| 25a392df48 | |||
| 9c0d24d3ef | |||
| bee3b0d946 | |||
| af039f6023 | |||
| f168795bda | |||
| bbd2cbff3e | |||
| 056ce6679c | |||
| eb9476503f | |||
| e7a00e221e | |||
| f61a4c4b18 | |||
| 40d6db312d | |||
| c5bb64160f | |||
| e89b78cc45 | |||
| e33b306225 | |||
| 9c859e96d8 | |||
| 10d17f9362 | |||
| 974f704214 | |||
| 0fa16a033c | |||
| f851988d6f | |||
| e9a8cbf20f | |||
| 846012c087 | |||
| 6d0d63cb23 | |||
| b220f8c0be | |||
| 4c52b41b7b | |||
| aea2131dcb | |||
| 1aaeec5090 | |||
| 7e3599e3ac | |||
| 29c8046d4e | |||
| 125ef74358 | |||
| 960f310bcf | |||
| 268a76602a | |||
| dc78d8fea3 | |||
| e02a950ee0 | |||
| a75170cbc6 | |||
| c33e907fef | |||
| d7f2c00d7b | |||
| 8f24157096 | |||
| 3b88857999 | |||
| 4d6ea9a910 | |||
| bb38eedfd1 | |||
| 9d3bfd2cd2 | |||
| 90693fb32f | |||
| b5a6711c64 | |||
| c72ae15429 | |||
| e3bb9c3b38 | |||
| 48caec5665 | |||
| 169cb0853b | |||
| add09c2faa | |||
| f748256c1d | |||
| 4b2240fbce | |||
| c2528c6ea4 | |||
| dd324b7785 | |||
| 9095fe8c65 | |||
| d6240022a4 | |||
| 405be396c8 | |||
| 2c15a0b5e9 | |||
| eaed99e52c | |||
| ac71d4b079 | |||
| f11f60d121 | |||
| e0573302af | |||
| 54be36dd63 | |||
| 72c572e1ea | |||
| 2bae07d1f5 | |||
| 9abaefeb00 | |||
| 05444f74d3 | |||
| 528a16cd5a | |||
| d549aa0314 | |||
| 3798e2d959 |
+178
-11
@@ -10,13 +10,26 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para compos
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
# FTS5
|
||||
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;"
|
||||
# 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 '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
|
||||
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
|
||||
@@ -24,7 +37,7 @@ sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain =
|
||||
# Puras de un dominio
|
||||
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';"
|
||||
|
||||
# Dependencias
|
||||
@@ -37,19 +50,46 @@ sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status
|
||||
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`
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
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/components/ # React (.tsx)
|
||||
types/{domain}/ # .go + .md por tipo
|
||||
types/{domain}/ # Solo .md de tipos (los .go viven en functions/{domain}/)
|
||||
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
|
||||
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
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
@@ -76,6 +116,24 @@ fn search -k function -p pure -d core "slice"
|
||||
fn list [-d domain] [-k kind]
|
||||
fn show <id>
|
||||
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
|
||||
fn proposal add --kind new_function --title "..." --created-by agent [--target-id <id>]
|
||||
@@ -96,16 +154,41 @@ fn ops assertion result add|list
|
||||
|
||||
`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
|
||||
|
||||
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}`
|
||||
|
||||
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):
|
||||
- Pipeline → siempre impuro + uses_functions no vacio
|
||||
- Pure → returns_optional: false + error_type: ""
|
||||
@@ -118,7 +201,91 @@ Reglas de integridad (el indexer las valida):
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -11,5 +11,9 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 05 | [stubs.md](stubs.md) | Stubs impuros para dependencias externas |
|
||||
| 06 | [assertions.md](assertions.md) | Kinds de assertions son texto libre |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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.`).
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
@@ -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}/`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+31
-2
@@ -1,5 +1,4 @@
|
||||
# SQLite index (regenerable con fn index) — SOLO en raiz
|
||||
registry.db
|
||||
# SQLite index — journal/wal temporales
|
||||
registry.db-journal
|
||||
registry.db-wal
|
||||
|
||||
@@ -24,6 +23,36 @@ registry.db-wal
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Secrets
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
python/.venv/
|
||||
|
||||
# Externalized apps and analysis (each is its own Gitea repo)
|
||||
apps/*/
|
||||
analysis/*/
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||
sources/*/
|
||||
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Archivos locales
|
||||
.local
|
||||
|
||||
broken_paths.txt
|
||||
imgui.ini
|
||||
prompts/
|
||||
|
||||
+13
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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": {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {run.Trigger} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -1,6 +0,0 @@
|
||||
go 1.22.2
|
||||
|
||||
use (
|
||||
.
|
||||
/home/lucas/.local_agentes/backend
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,42 @@
|
||||
---
|
||||
name: assert_docker_container_running
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "assert_docker_container_running(container_name: string) -> void"
|
||||
description: "Verifica que un contenedor Docker está corriendo. Sale con exit code 1 si no está activo, con mensaje a stderr."
|
||||
tags: [assert, docker, container, running, validation, infra, bash]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: container_name
|
||||
desc: "nombre del contenedor Docker a verificar"
|
||||
output: "sin salida; exit code 0 si existe y está corriendo, 1 si no"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/assert_docker_container_running.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source functions/infra/assert_docker_container_running.sh
|
||||
|
||||
assert_docker_container_running metabase
|
||||
echo "Contenedor activo, continuando..."
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `docker ps --format '{{.Names}}'` con grep anclado (`^name$`) para evitar matches parciales (ej: "metabase" no matchea "metabase-test").
|
||||
|
||||
Output limpio: void en éxito. El mensaje de error en stderr no incluye lista de contenedores activos — eso es responsabilidad del pipeline/caller.
|
||||
|
||||
Requiere que `docker` esté en PATH. Combinar con `assert_command_exists` antes de llamar.
|
||||
@@ -0,0 +1,19 @@
|
||||
# assert_docker_container_running
|
||||
# --------------------------------
|
||||
# Verifica que un contenedor Docker está corriendo.
|
||||
# No produce output a stdout en caso de éxito.
|
||||
# Sale con exit code 1 si el contenedor no está corriendo,
|
||||
# con mensaje descriptivo a stderr.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source assert_docker_container_running.sh
|
||||
# assert_docker_container_running metabase
|
||||
|
||||
assert_docker_container_running() {
|
||||
local container_name="$1"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||
echo "assert_docker_container_running: el contenedor '$container_name' no está corriendo" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: build_cpp_linux
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "build_cpp_linux(target?: string) -> void"
|
||||
description: "Compila las funciones y apps C++ del registry para Linux nativo usando cmake"
|
||||
tags: [cpp, build, cmake, linux, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/build_cpp_linux.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||
output: "Compila los binarios en cpp/build/linux/"
|
||||
---
|
||||
|
||||
# build_cpp_linux
|
||||
|
||||
Configura y compila el proyecto C++ (ImGui/ImPlot) para Linux nativo.
|
||||
|
||||
Usa cmake con compilacion paralela (`-j$(nproc)`). Si no se ha configurado antes, ejecuta `cmake -B` automaticamente.
|
||||
|
||||
```bash
|
||||
fn run build_cpp_linux # Compilar todo
|
||||
fn run build_cpp_linux chart_demo # Compilar solo chart_demo
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||
BUILD_DIR="$CPP_ROOT/build/linux"
|
||||
TARGET="${1:-}"
|
||||
|
||||
# Configure if needed
|
||||
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||
echo "[build_cpp_linux] Configuring cmake..."
|
||||
cmake -B "$BUILD_DIR" -S "$CPP_ROOT"
|
||||
fi
|
||||
|
||||
# Build
|
||||
if [ -n "$TARGET" ]; then
|
||||
echo "[build_cpp_linux] Building target: $TARGET"
|
||||
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||
else
|
||||
echo "[build_cpp_linux] Building all targets..."
|
||||
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||
fi
|
||||
|
||||
echo "[build_cpp_linux] Done. Binaries in $BUILD_DIR"
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: build_cpp_windows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "build_cpp_windows(target?: string) -> void"
|
||||
description: "Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64"
|
||||
tags: [cpp, build, cmake, windows, cross-compile, mingw, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/build_cpp_windows.sh"
|
||||
params:
|
||||
- name: target
|
||||
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||
output: "Produce binarios .exe de Windows en cpp/build/windows/"
|
||||
---
|
||||
|
||||
# build_cpp_windows
|
||||
|
||||
Cross-compila el proyecto C++ para Windows desde Linux usando el toolchain mingw-w64.
|
||||
|
||||
Los .exe resultantes incluyen runtime linkado estaticamente (self-contained).
|
||||
|
||||
```bash
|
||||
fn run build_cpp_windows # Compilar todo
|
||||
fn run build_cpp_windows chart_demo # Compilar solo chart_demo
|
||||
```
|
||||
|
||||
Requiere `mingw-w64`: `sudo apt install mingw-w64`
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||
BUILD_DIR="$CPP_ROOT/build/windows"
|
||||
TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake"
|
||||
TARGET="${1:-}"
|
||||
|
||||
# Check mingw is available
|
||||
if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then
|
||||
echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure if needed
|
||||
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||
echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..."
|
||||
cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN"
|
||||
fi
|
||||
|
||||
# Build
|
||||
if [ -n "$TARGET" ]; then
|
||||
echo "[build_cpp_windows] Cross-compiling target: $TARGET"
|
||||
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||
else
|
||||
echo "[build_cpp_windows] Cross-compiling all targets..."
|
||||
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||
fi
|
||||
|
||||
echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR"
|
||||
if [ -n "$TARGET" ]; then
|
||||
file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true
|
||||
fi
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: docker_cp_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "docker_cp_file(local_path: string, container_name: string, dest_path: string) -> string"
|
||||
description: "Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide. Imprime JSON con local_size y remote_size a stdout. Sale con exit code 1 si docker cp falla o los tamaños difieren."
|
||||
tags: [docker, cp, copy, file, container, transfer, infra, bash]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: local_path
|
||||
desc: "ruta del archivo local a copiar"
|
||||
- name: container_name
|
||||
desc: "nombre del contenedor Docker destino"
|
||||
- name: dest_path
|
||||
desc: "ruta destino dentro del contenedor"
|
||||
output: "JSON con local_size y remote_size en bytes"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/docker_cp_file.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source functions/infra/docker_cp_file.sh
|
||||
|
||||
result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
|
||||
echo "$result"
|
||||
# {"local_size":524288,"remote_size":524288}
|
||||
|
||||
local_size=$(echo "$result" | grep -o '"local_size":[0-9]*' | cut -d: -f2)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La verificación de tamaño usa `docker exec stat -c%s` sobre el contenedor destino. Si `stat` no está disponible en el contenedor, `remote_size` será -1 y la función fallará.
|
||||
|
||||
Output a stdout: JSON minificado con campos `local_size` y `remote_size` (enteros, bytes).
|
||||
|
||||
Usa `printf` en lugar de `echo` para garantizar que no haya newline extra en el JSON.
|
||||
@@ -0,0 +1,33 @@
|
||||
# docker_cp_file
|
||||
# --------------
|
||||
# Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide.
|
||||
# Imprime JSON con local_size y remote_size a stdout si la copia es exitosa.
|
||||
# Sale con exit code 1 si docker cp falla o si los tamaños no coinciden.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source docker_cp_file.sh
|
||||
# result=$(docker_cp_file /ruta/local.db metabase /dest/path.db)
|
||||
|
||||
docker_cp_file() {
|
||||
local local_path="$1"
|
||||
local container_name="$2"
|
||||
local dest_path="$3"
|
||||
|
||||
if ! docker cp "$local_path" "${container_name}:${dest_path}" 2>/dev/null; then
|
||||
echo "docker_cp_file: fallo al copiar '$local_path' a '${container_name}:${dest_path}'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local local_size
|
||||
local_size=$(stat -c%s "$local_path")
|
||||
|
||||
local remote_size
|
||||
remote_size=$(docker exec "$container_name" stat -c%s "$dest_path" 2>/dev/null || echo "-1")
|
||||
|
||||
if [ "$local_size" != "$remote_size" ]; then
|
||||
echo "docker_cp_file: tamaños no coinciden (local=${local_size}, remoto=${remote_size})" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '{"local_size":%s,"remote_size":%s}' "$local_size" "$remote_size"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: frontend_doctor
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "frontend_doctor(project_dir: string) -> diagnostics_stdout"
|
||||
description: "Diagnostica la salud de un proyecto frontend Mantine. Verifica Node, React, Mantine, PostCSS, TypeScript, vite.config y detecta residuos de shadcn/@base-ui. Imprime tabla de checks con exit code 0/1."
|
||||
tags: [frontend, mantine, doctor, diagnostics, health, validation]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "directorio del proyecto frontend con package.json"
|
||||
output: "tabla de checks con ✓/✗ por cada validación y resumen final"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/frontend_doctor.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Diagnosticar un proyecto
|
||||
bash frontend_doctor.sh ./apps/rapid_dashboards/frontend
|
||||
|
||||
# Output:
|
||||
# === Frontend Doctor: ./apps/rapid_dashboards/frontend ===
|
||||
#
|
||||
# ✓ Node >= 18 22.12.0
|
||||
# ✓ Package manager detected pnpm
|
||||
# ✓ node_modules present
|
||||
# ✓ @mantine/core 7.17.0
|
||||
# ✓ @mantine/hooks
|
||||
# ✓ @mantine/charts
|
||||
# ✓ React >= 18 19.2.4
|
||||
# ✓ postcss.config present
|
||||
# ✓ TypeScript >= 5 6.0.2
|
||||
# ✓ vite.config present
|
||||
# ✓ No shadcn residual
|
||||
# ✓ No @base-ui residual
|
||||
#
|
||||
# Resultado: todo OK
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Checks informativos, no modifica nada. Util para validar que un proyecto esta correctamente configurado despues de instalar Mantine o migrar desde shadcn. Exit code 0 si todo OK, 1 si hay problemas.
|
||||
@@ -0,0 +1,150 @@
|
||||
# frontend_doctor
|
||||
# ----------------
|
||||
# Diagnostica la salud de un proyecto frontend Mantine.
|
||||
# Verifica dependencias, configuracion y versiones.
|
||||
# Imprime tabla de checks y retorna exit code 0 (ok) o 1 (fallos).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source frontend_doctor.sh
|
||||
# frontend_doctor /path/to/frontend
|
||||
#
|
||||
# USO (directo):
|
||||
# bash frontend_doctor.sh /path/to/frontend
|
||||
|
||||
frontend_doctor() {
|
||||
local project_dir="$1"
|
||||
local failures=0
|
||||
|
||||
if [ -z "$project_dir" ]; then
|
||||
echo "frontend_doctor: se requiere project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$project_dir/package.json" ]; then
|
||||
echo "frontend_doctor: no existe package.json en $project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=== Frontend Doctor: $project_dir ==="
|
||||
echo ""
|
||||
|
||||
# Helper: check y reportar
|
||||
_check() {
|
||||
local label="$1"
|
||||
local ok="$2"
|
||||
local detail="$3"
|
||||
if [ "$ok" = "1" ]; then
|
||||
printf " ✓ %-35s %s\n" "$label" "$detail"
|
||||
else
|
||||
printf " ✗ %-35s %s\n" "$label" "$detail"
|
||||
((failures++))
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Node >= 18
|
||||
local node_ver=""
|
||||
local node_ok=0
|
||||
if command -v node &>/dev/null; then
|
||||
node_ver=$(node -v 2>/dev/null | sed 's/v//')
|
||||
local node_major=$(echo "$node_ver" | cut -d. -f1)
|
||||
[ "$node_major" -ge 18 ] 2>/dev/null && node_ok=1
|
||||
fi
|
||||
_check "Node >= 18" "$node_ok" "${node_ver:-not found}"
|
||||
|
||||
# 2. Package manager
|
||||
local pm_ok=0
|
||||
local pm_name="none"
|
||||
if [ -f "$project_dir/pnpm-lock.yaml" ]; then
|
||||
pm_name="pnpm"; pm_ok=1
|
||||
elif [ -f "$project_dir/yarn.lock" ]; then
|
||||
pm_name="yarn"; pm_ok=1
|
||||
elif [ -f "$project_dir/package-lock.json" ]; then
|
||||
pm_name="npm"; pm_ok=1
|
||||
fi
|
||||
_check "Package manager detected" "$pm_ok" "$pm_name"
|
||||
|
||||
# 3. node_modules existe
|
||||
local nm_ok=0
|
||||
[ -d "$project_dir/node_modules" ] && nm_ok=1
|
||||
_check "node_modules present" "$nm_ok" ""
|
||||
|
||||
# 4. @mantine/core instalado
|
||||
local mantine_ok=0
|
||||
local mantine_ver=""
|
||||
if [ -f "$project_dir/node_modules/@mantine/core/package.json" ]; then
|
||||
mantine_ver=$(node -e "console.log(require('$project_dir/node_modules/@mantine/core/package.json').version)" 2>/dev/null)
|
||||
mantine_ok=1
|
||||
fi
|
||||
_check "@mantine/core" "$mantine_ok" "${mantine_ver:-not installed}"
|
||||
|
||||
# 5. @mantine/hooks
|
||||
local hooks_ok=0
|
||||
[ -d "$project_dir/node_modules/@mantine/hooks" ] && hooks_ok=1
|
||||
_check "@mantine/hooks" "$hooks_ok" ""
|
||||
|
||||
# 6. @mantine/charts
|
||||
local charts_ok=0
|
||||
[ -d "$project_dir/node_modules/@mantine/charts" ] && charts_ok=1
|
||||
_check "@mantine/charts" "$charts_ok" ""
|
||||
|
||||
# 7. React >= 18
|
||||
local react_ok=0
|
||||
local react_ver=""
|
||||
if [ -f "$project_dir/node_modules/react/package.json" ]; then
|
||||
react_ver=$(node -e "console.log(require('$project_dir/node_modules/react/package.json').version)" 2>/dev/null)
|
||||
local react_major=$(echo "$react_ver" | cut -d. -f1)
|
||||
[ "$react_major" -ge 18 ] 2>/dev/null && react_ok=1
|
||||
fi
|
||||
_check "React >= 18" "$react_ok" "${react_ver:-not found}"
|
||||
|
||||
# 8. postcss.config presente
|
||||
local postcss_ok=0
|
||||
if [ -f "$project_dir/postcss.config.cjs" ] || [ -f "$project_dir/postcss.config.js" ] || [ -f "$project_dir/postcss.config.mjs" ]; then
|
||||
postcss_ok=1
|
||||
fi
|
||||
_check "postcss.config present" "$postcss_ok" ""
|
||||
|
||||
# 9. TypeScript >= 5
|
||||
local ts_ok=0
|
||||
local ts_ver=""
|
||||
if [ -f "$project_dir/node_modules/typescript/package.json" ]; then
|
||||
ts_ver=$(node -e "console.log(require('$project_dir/node_modules/typescript/package.json').version)" 2>/dev/null)
|
||||
local ts_major=$(echo "$ts_ver" | cut -d. -f1)
|
||||
[ "$ts_major" -ge 5 ] 2>/dev/null && ts_ok=1
|
||||
fi
|
||||
_check "TypeScript >= 5" "$ts_ok" "${ts_ver:-not found}"
|
||||
|
||||
# 10. vite.config presente
|
||||
local vite_ok=0
|
||||
if [ -f "$project_dir/vite.config.ts" ] || [ -f "$project_dir/vite.config.js" ]; then
|
||||
vite_ok=1
|
||||
fi
|
||||
_check "vite.config present" "$vite_ok" ""
|
||||
|
||||
# 11. Shadcn residual (warning)
|
||||
local shadcn_clean=1
|
||||
if [ -f "$project_dir/components.json" ]; then
|
||||
shadcn_clean=0
|
||||
fi
|
||||
_check "No shadcn residual" "$shadcn_clean" "$([ "$shadcn_clean" = "0" ] && echo 'components.json found')"
|
||||
|
||||
# 12. @base-ui residual (warning)
|
||||
local baseui_clean=1
|
||||
if [ -d "$project_dir/node_modules/@base-ui" ]; then
|
||||
baseui_clean=0
|
||||
fi
|
||||
_check "No @base-ui residual" "$baseui_clean" "$([ "$baseui_clean" = "0" ] && echo '@base-ui still installed')"
|
||||
|
||||
echo ""
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo " Resultado: todo OK"
|
||||
return 0
|
||||
else
|
||||
echo " Resultado: $failures problema(s) encontrado(s)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
frontend_doctor "$@"
|
||||
fi
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: gitea_add_collaborator
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_add_collaborator(owner: string, repo: string, username: string, permission: string) -> void"
|
||||
description: "Añade un colaborador a un repositorio Gitea con el nivel de permisos indicado. Silencioso si el colaborador ya existe (422)."
|
||||
tags: [gitea, git, collaborator, permission, repo, api, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repositorio"
|
||||
- name: repo
|
||||
desc: "nombre del repositorio"
|
||||
- name: username
|
||||
desc: "nombre de usuario del colaborador a añadir"
|
||||
- name: permission
|
||||
desc: "nivel de permisos: 'read', 'write' o 'admin' (default: admin)"
|
||||
output: "vacío — efectos observables a través de la API de Gitea"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_add_collaborator.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_add_collaborator.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Añadir colaborador con permiso admin (default)
|
||||
gitea_add_collaborator "myorg" "my-app" "egutierrez"
|
||||
|
||||
# Añadir colaborador con permiso de solo lectura
|
||||
gitea_add_collaborator "myorg" "my-app" "reviewer" "read"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- Un 422 de la API indica que el usuario ya es colaborador — se trata como éxito silencioso.
|
||||
- La función no produce salida a stdout; los mensajes informativos van a stderr.
|
||||
- Nivel `admin` da acceso completo al repo incluyendo settings.
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_add_collaborator — Añade un colaborador a un repositorio Gitea
|
||||
|
||||
gitea_add_collaborator() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local username="$3"
|
||||
local permission="${4:-admin}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_add_collaborator: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_add_collaborator: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" || -z "$repo" || -z "$username" ]]; then
|
||||
echo "gitea_add_collaborator: se requieren owner, repo y username" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(printf '{"permission":"%s"}' "$permission")
|
||||
|
||||
echo "gitea_add_collaborator: añadiendo '$username' a '$owner/$repo' con permiso '$permission'..." >&2
|
||||
|
||||
local response http_code
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/repos/${owner}/${repo}/collaborators/${username}")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
local body
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "204" || "$http_code" == "200" ]]; then
|
||||
echo "gitea_add_collaborator: '$username' añadido a '$owner/$repo'" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "422" ]]; then
|
||||
echo "gitea_add_collaborator: '$username' ya es colaborador de '$owner/$repo' (silencioso)" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "gitea_add_collaborator: error (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: gitea_create_repo
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_create_repo(owner: string, name: string, private: string, description: string) -> string"
|
||||
description: "Crea un repositorio en Gitea para un owner. Intenta crearlo en org primero; si el owner no es una org (404/422), lo crea en el usuario autenticado. No falla fatalmente si el repo ya existe (409)."
|
||||
tags: [gitea, git, repo, create, infra, api]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repo"
|
||||
- name: name
|
||||
desc: "nombre del repositorio a crear"
|
||||
- name: private
|
||||
desc: "si el repo es privado, 'true' o 'false' (default: false)"
|
||||
- name: description
|
||||
desc: "descripción del repositorio (opcional)"
|
||||
output: "JSON del repositorio creado según la API de Gitea"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_create_repo.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_create_repo.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Crear repo público en org o usuario
|
||||
repo_json=$(gitea_create_repo "myorg" "my-app")
|
||||
|
||||
# Crear repo privado con descripción
|
||||
repo_json=$(gitea_create_repo "myorg" "my-app" "true" "Mi aplicación principal")
|
||||
|
||||
# Extraer la URL del clon
|
||||
clone_url=$(echo "$repo_json" | jq -r '.clone_url')
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere variables de entorno `GITEA_URL` y `GITEA_TOKEN` seteadas antes de invocar.
|
||||
- El fallback org → usuario ocurre con HTTP 404 o 422 en el endpoint de orgs.
|
||||
- Un 409 se reporta a stderr pero la función retorna 0 — el repo ya existe es una condición aceptable para idempotencia.
|
||||
- Los mensajes informativos van a stderr; el JSON de respuesta va a stdout.
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_create_repo — Crea un repositorio en Gitea para un owner (org o usuario)
|
||||
|
||||
gitea_create_repo() {
|
||||
local owner="$1"
|
||||
local name="$2"
|
||||
local private="${3:-false}"
|
||||
local description="${4:-}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_create_repo: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_create_repo: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" || -z "$name" ]]; then
|
||||
echo "gitea_create_repo: se requieren owner y name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(printf '{"name":"%s","private":%s,"description":"%s","auto_init":false}' \
|
||||
"$name" "$private" "$description")
|
||||
|
||||
echo "gitea_create_repo: intentando crear '$owner/$name' en org..." >&2
|
||||
|
||||
local response http_code
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/orgs/${owner}/repos")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
local body
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "201" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' creado en org" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "409" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "404" || "$http_code" == "422" ]]; then
|
||||
echo "gitea_create_repo: org no encontrada (${http_code}), intentando en usuario..." >&2
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-d "$payload" \
|
||||
"${GITEA_URL}/api/v1/user/repos")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" == "201" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' creado en usuario" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" == "409" ]]; then
|
||||
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||
echo "$body"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "gitea_create_repo: error al crear en usuario (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "gitea_create_repo: error inesperado (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gitea_list_repos
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_list_repos(owner: string) -> string"
|
||||
description: "Lista repositorios de un owner en Gitea. Intenta listar como org primero; si falla, lista como usuario. Imprime una línea por repo en formato name<TAB>html_url<TAB>description."
|
||||
tags: [gitea, git, repo, list, org, user, api, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: owner
|
||||
desc: "nombre del usuario u organización cuyos repos se listan"
|
||||
output: "una línea por repositorio con columnas separadas por tabulador: name, html_url, description"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_list_repos.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_list_repos.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Listar todos los repos de una org
|
||||
gitea_list_repos "myorg"
|
||||
# my-app https://git.example.com/myorg/my-app Mi aplicación principal
|
||||
# infra https://git.example.com/myorg/infra
|
||||
|
||||
# Iterar sobre los repos
|
||||
while IFS=$'\t' read -r name url desc; do
|
||||
echo "Repo: $name — $url"
|
||||
done < <(gitea_list_repos "myorg")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- Usa `jq` si está disponible; fallback con grep/sed en caso contrario.
|
||||
- El límite es 50 repos por página. Para owners con más de 50 repos habría que implementar paginación.
|
||||
- Los mensajes informativos van a stderr; los datos tabulados van a stdout.
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_list_repos — Lista repositorios de un owner (org o usuario) en Gitea
|
||||
|
||||
gitea_list_repos() {
|
||||
local owner="$1"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_list_repos: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_list_repos: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$owner" ]]; then
|
||||
echo "gitea_list_repos: se requiere owner" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "gitea_list_repos: listando repos de '$owner' (intentando org)..." >&2
|
||||
|
||||
local response http_code body
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/orgs/${owner}/repos?limit=50")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "gitea_list_repos: org no encontrada (${http_code}), intentando usuario..." >&2
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/users/${owner}/repos?limit=50")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n -1)
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "gitea_list_repos: error listando repos de usuario (HTTP ${http_code}): ${body}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Formatear salida como name\thtml_url\tdescription
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "$body" | jq -r '.[] | [.name, .html_url, (.description // "")] | @tsv'
|
||||
else
|
||||
# Fallback sin jq: extraer campos básicos con grep/sed
|
||||
echo "$body" | grep -o '"name":"[^"]*"\|"html_url":"[^"]*"\|"description":"[^"]*"' \
|
||||
| paste - - - | sed 's/"name":"//;s/"html_url":"//;s/"description":"//;s/"//g'
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: gitea_push_directory
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "gitea_push_directory(directory: string, owner: string, repo: string, branch: string) -> void"
|
||||
description: "Inicializa git en un directorio local y lo sube a un repositorio Gitea existente. Si el directorio ya tiene .git, actualiza el remote y pushea cambios pendientes. Protege registry.db añadiéndolo al .gitignore antes del commit."
|
||||
tags: [gitea, git, push, directory, sync, infra, repo]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: directory
|
||||
desc: "ruta absoluta o relativa al directorio local a subir"
|
||||
- name: owner
|
||||
desc: "usuario u organización propietaria del repositorio Gitea destino"
|
||||
- name: repo
|
||||
desc: "nombre del repositorio Gitea destino (debe existir previamente)"
|
||||
- name: branch
|
||||
desc: "rama en la que hacer push (default: main)"
|
||||
output: "vacío — efectos observables en el repositorio Gitea destino"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/gitea_push_directory.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/gitea_push_directory.sh
|
||||
|
||||
export GITEA_URL="https://git.example.com"
|
||||
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||
|
||||
# Subir directorio a repo existente
|
||||
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app"
|
||||
|
||||
# Subir a rama específica
|
||||
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app" "develop"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||
- El token se embebe en la URL del remote para autenticación (nunca se imprime a stdout/stderr, se enmascara con ***).
|
||||
- Si `registry.db` existe en el directorio, se añade automáticamente al `.gitignore` local.
|
||||
- Si el `.git` ya existe con un remote diferente, se redirige al repo indicado sin perder el historial local.
|
||||
- Usa `--force-with-lease` para el primer push y fallback a push normal (para repos vacíos recién creados).
|
||||
- El commit se firma con `agent@fn-registry` si no hay configuración git en el entorno.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea_push_directory — Inicializa git en un directorio y lo sube a un repo Gitea existente
|
||||
|
||||
gitea_push_directory() {
|
||||
local directory="$1"
|
||||
local owner="$2"
|
||||
local repo="$3"
|
||||
local branch="${4:-main}"
|
||||
|
||||
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||
echo "gitea_push_directory: GITEA_URL no está seteada" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "gitea_push_directory: GITEA_TOKEN no está seteado" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$directory" || -z "$owner" || -z "$repo" ]]; then
|
||||
echo "gitea_push_directory: se requieren directory, owner y repo" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$directory" ]]; then
|
||||
echo "gitea_push_directory: directorio '$directory' no existe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Construir URL con credenciales embebidas para autenticación
|
||||
local gitea_host
|
||||
gitea_host=$(echo "$GITEA_URL" | sed 's|https\?://||')
|
||||
local remote_url="https://${GITEA_TOKEN}@${gitea_host}/${owner}/${repo}.git"
|
||||
local display_url="https://***@${gitea_host}/${owner}/${repo}.git"
|
||||
|
||||
echo "gitea_push_directory: procesando '$directory' → '$owner/$repo' (rama: $branch)..." >&2
|
||||
|
||||
# Añadir registry.db al .gitignore local si existe en el directorio
|
||||
if [[ -f "$directory/registry.db" ]]; then
|
||||
echo "gitea_push_directory: añadiendo registry.db al .gitignore..." >&2
|
||||
if [[ ! -f "$directory/.gitignore" ]] || ! grep -qxF "registry.db" "$directory/.gitignore"; then
|
||||
echo "registry.db" >> "$directory/.gitignore"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Gestionar estado del repositorio git
|
||||
if [[ -d "$directory/.git" ]]; then
|
||||
local existing_remote
|
||||
existing_remote=$(git -C "$directory" remote get-url origin 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$existing_remote" ]]; then
|
||||
echo "gitea_push_directory: añadiendo remote origin..." >&2
|
||||
git -C "$directory" remote add origin "$remote_url"
|
||||
else
|
||||
# Comparar remote sin token para detectar si apunta al mismo repo
|
||||
local clean_existing
|
||||
clean_existing=$(echo "$existing_remote" | sed 's|https://[^@]*@||;s|https://||')
|
||||
local clean_target="${gitea_host}/${owner}/${repo}.git"
|
||||
|
||||
if [[ "$clean_existing" != "$clean_target" ]]; then
|
||||
echo "gitea_push_directory: remote apunta a otro destino ('$clean_existing'), actualizando..." >&2
|
||||
git -C "$directory" remote set-url origin "$remote_url"
|
||||
else
|
||||
echo "gitea_push_directory: remote ya apunta al destino correcto, actualizando token..." >&2
|
||||
git -C "$directory" remote set-url origin "$remote_url"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "gitea_push_directory: inicializando nuevo repositorio git..." >&2
|
||||
git -C "$directory" init
|
||||
git -C "$directory" remote add origin "$remote_url"
|
||||
fi
|
||||
|
||||
# Configurar rama por defecto
|
||||
git -C "$directory" checkout -B "$branch" 2>/dev/null || true
|
||||
|
||||
# Añadir y commitear cambios si los hay
|
||||
git -C "$directory" add -A
|
||||
|
||||
local status
|
||||
status=$(git -C "$directory" status --porcelain)
|
||||
|
||||
if [[ -n "$status" ]]; then
|
||||
echo "gitea_push_directory: commiteando cambios..." >&2
|
||||
git -C "$directory" -c user.email="agent@fn-registry" -c user.name="fn-registry agent" \
|
||||
commit -m "chore: sync from fn-registry agent"
|
||||
else
|
||||
echo "gitea_push_directory: sin cambios pendientes, solo haciendo push..." >&2
|
||||
fi
|
||||
|
||||
echo "gitea_push_directory: haciendo push a $display_url..." >&2
|
||||
git -C "$directory" push --set-upstream origin "$branch" --force-with-lease 2>&1 \
|
||||
| sed "s|${GITEA_TOKEN}|***|g" >&2 \
|
||||
|| git -C "$directory" push --set-upstream origin "$branch" 2>&1 \
|
||||
| sed "s|${GITEA_TOKEN}|***|g" >&2
|
||||
|
||||
echo "gitea_push_directory: push completado a '$owner/$repo' rama '$branch'" >&2
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: init_uv_venv
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_uv_venv([project_dir: string]) -> string"
|
||||
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
|
||||
tags: [python, venv, uv, setup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "directorio del proyecto donde crear el venv (default: directorio actual)"
|
||||
output: "ruta absoluta del venv creado o existente"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/init_uv_venv.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source init_uv_venv.sh
|
||||
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
|
||||
echo "Venv creado en: $venv"
|
||||
|
||||
# Idempotente — si ya existe, retorna la ruta sin recrear
|
||||
venv=$(init_uv_venv .)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
|
||||
@@ -0,0 +1,35 @@
|
||||
# init_uv_venv
|
||||
# -------------
|
||||
# Crea un venv con uv en el directorio especificado si no existe.
|
||||
# Fallback a python -m venv si uv no esta disponible.
|
||||
# Imprime la ruta del venv a stdout.
|
||||
#
|
||||
# USO (sourced):
|
||||
# source init_uv_venv.sh
|
||||
# venv_path=$(init_uv_venv /path/to/project)
|
||||
|
||||
init_uv_venv() {
|
||||
local project_dir="${1:-.}"
|
||||
local venv_path="${project_dir}/.venv"
|
||||
|
||||
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
|
||||
echo "$venv_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v uv &>/dev/null; then
|
||||
(cd "$project_dir" && uv venv) >/dev/null 2>&1
|
||||
elif command -v python3 &>/dev/null; then
|
||||
python3 -m venv "$venv_path"
|
||||
else
|
||||
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$venv_path/bin/python" ]; then
|
||||
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$venv_path"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: install_android_sdk
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_android_sdk() -> void"
|
||||
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
||||
tags: [android, sdk, jdk, java, install, infra, mobile]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "sin salida estructurada; imprime progreso y resumen final con rutas de instalacion"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_android_sdk.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Instalacion en directorio por defecto ($HOME/android-sdk)
|
||||
source install_android_sdk.sh
|
||||
|
||||
# Instalacion en directorio personalizado
|
||||
ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
|
||||
|
||||
# Si ya esta instalado:
|
||||
# Android SDK ya instalado en: /home/user/android-sdk
|
||||
|
||||
# Instalacion completa imprime:
|
||||
# Descargando JDK 17...
|
||||
# JDK 17 instalado: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||
# Descargando Android cmdline-tools...
|
||||
# cmdline-tools instalados
|
||||
# Aceptando licencias de Android SDK...
|
||||
# Instalando platform-tools, platforms;android-34, build-tools;34.0.0...
|
||||
#
|
||||
# Android SDK instalado en: /home/user/android-sdk
|
||||
# JDK 17: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||
# Para activar: source /home/user/android-sdk/env.sh
|
||||
|
||||
# Activar entorno en sesion actual
|
||||
source ~/android-sdk/env.sh
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
||||
|
||||
El JDK se descarga desde Adoptium (Eclipse Temurin) via su API oficial. La URL de cmdline-tools apunta a la version 11076708 (2024). Si Google actualiza la version, cambiar la URL con el nuevo numero de build.
|
||||
|
||||
La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools con estructura `cmdline-tools/bin/...` pero sdkmanager espera estar en `cmdline-tools/latest/bin/sdkmanager` para que Android Studio y otras herramientas lo detecten correctamente.
|
||||
|
||||
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
||||
|
||||
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_android_sdk — Descarga e instala Android SDK command-line tools y JDK 17
|
||||
# localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk).
|
||||
set -euo pipefail
|
||||
|
||||
install_android_sdk() {
|
||||
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
# Limpia temporales al salir
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# 1. Verifica si ya está instalado
|
||||
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
if JAVA_HOME="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1)" \
|
||||
"$sdk_dir/cmdline-tools/latest/bin/sdkmanager" --version &>/dev/null; then
|
||||
echo "Android SDK ya instalado en: $sdk_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$sdk_dir"
|
||||
|
||||
# 2. Descarga JDK 17 si no existe
|
||||
local jdk_dir
|
||||
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||
|
||||
if [[ -z "$jdk_dir" ]]; then
|
||||
echo "Descargando JDK 17..."
|
||||
local jdk_tar="$tmp_dir/jdk17.tar.gz"
|
||||
local jdk_url="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
||||
|
||||
if ! curl -fL --progress-bar -o "$jdk_tar" "$jdk_url"; then
|
||||
echo "ERROR: fallo al descargar JDK 17 desde $jdk_url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$sdk_dir/jdk-17"
|
||||
echo "Extrayendo JDK 17..."
|
||||
if ! tar -xzf "$jdk_tar" -C "$sdk_dir/jdk-17"; then
|
||||
echo "ERROR: fallo al extraer JDK 17" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$jdk_dir" ]]; then
|
||||
echo "ERROR: no se encontro directorio jdk-17* tras la extraccion" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! JAVA_HOME="$jdk_dir" "$jdk_dir/bin/java" -version &>/dev/null; then
|
||||
echo "ERROR: java -version fallo tras instalar JDK" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "JDK 17 instalado: $jdk_dir"
|
||||
else
|
||||
echo "JDK 17 ya presente: $jdk_dir"
|
||||
fi
|
||||
|
||||
export JAVA_HOME="$jdk_dir"
|
||||
|
||||
# 3. Descarga Android cmdline-tools si no existen
|
||||
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
echo "Descargando Android cmdline-tools..."
|
||||
local tools_zip="$tmp_dir/cmdline-tools.zip"
|
||||
local tools_url="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
|
||||
if ! curl -fL --progress-bar -o "$tools_zip" "$tools_url"; then
|
||||
echo "ERROR: fallo al descargar Android cmdline-tools desde $tools_url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tools_tmp="$tmp_dir/cmdline-tools-extracted"
|
||||
mkdir -p "$tools_tmp"
|
||||
echo "Extrayendo cmdline-tools..."
|
||||
if ! unzip -q "$tools_zip" -d "$tools_tmp"; then
|
||||
echo "ERROR: fallo al extraer cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# La estructura del zip es cmdline-tools/bin/..., reorganizar a cmdline-tools/latest/
|
||||
mkdir -p "$sdk_dir/cmdline-tools"
|
||||
if [[ -d "$tools_tmp/cmdline-tools" ]]; then
|
||||
mv "$tools_tmp/cmdline-tools" "$sdk_dir/cmdline-tools/latest"
|
||||
else
|
||||
echo "ERROR: estructura inesperada en el zip de cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
echo "ERROR: sdkmanager no encontrado tras extraer cmdline-tools" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "cmdline-tools instalados"
|
||||
else
|
||||
echo "cmdline-tools ya presentes"
|
||||
fi
|
||||
|
||||
local sdkmanager="$sdk_dir/cmdline-tools/latest/bin/sdkmanager"
|
||||
export ANDROID_HOME="$sdk_dir"
|
||||
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
||||
|
||||
# 4. Acepta licencias e instala paquetes necesarios
|
||||
echo "Aceptando licencias de Android SDK..."
|
||||
if ! yes | "$sdkmanager" --licenses; then
|
||||
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
||||
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
||||
echo "ERROR: fallo al instalar paquetes de Android SDK" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 5. Genera archivo de entorno
|
||||
local env_file="$sdk_dir/env.sh"
|
||||
cat > "$env_file" <<EOF
|
||||
export JAVA_HOME="$JAVA_HOME"
|
||||
export ANDROID_HOME="$sdk_dir"
|
||||
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||
export PATH="\$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:\$PATH"
|
||||
EOF
|
||||
|
||||
# 6. Resumen final
|
||||
echo ""
|
||||
echo "Android SDK instalado en: $sdk_dir"
|
||||
echo "JDK 17: $JAVA_HOME"
|
||||
echo "Para activar: source $sdk_dir/env.sh"
|
||||
}
|
||||
|
||||
install_android_sdk
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: install_cpp_deps
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "install_cpp_deps() -> void"
|
||||
description: "Verifica e instala las dependencias de sistema necesarias para compilar C++ con ImGui (cmake, g++, glfw, mesa)"
|
||||
tags: [cpp, dependencies, setup, cmake, imgui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/install_cpp_deps.sh"
|
||||
params: []
|
||||
output: "Instala paquetes faltantes via apt o confirma que todo esta instalado"
|
||||
---
|
||||
|
||||
# install_cpp_deps
|
||||
|
||||
Verifica las dependencias necesarias para el build C++:
|
||||
- `cmake` — sistema de build
|
||||
- `g++` / `build-essential` — compilador
|
||||
- `libglfw3-dev` — windowing (GLFW)
|
||||
- `libgl1-mesa-dev` — OpenGL headers
|
||||
|
||||
Tambien reporta si `mingw-w64` esta disponible para cross-compile a Windows.
|
||||
|
||||
```bash
|
||||
fn run install_cpp_deps
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user