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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:23:51 +02:00
parent 722f29b71b
commit bf1efb2099
111 changed files with 2766 additions and 5043 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: init_uv_venv
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "init_uv_venv([project_dir: string]) -> string"
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
tags: [python, venv, uv, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/init_uv_venv.sh"
---
## Ejemplo
```bash
source init_uv_venv.sh
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
echo "Venv creado en: $venv"
# Idempotente — si ya existe, retorna la ruta sin recrear
venv=$(init_uv_venv .)
```
## Notas
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
+35
View File
@@ -0,0 +1,35 @@
# init_uv_venv
# -------------
# Crea un venv con uv en el directorio especificado si no existe.
# Fallback a python -m venv si uv no esta disponible.
# Imprime la ruta del venv a stdout.
#
# USO (sourced):
# source init_uv_venv.sh
# venv_path=$(init_uv_venv /path/to/project)
init_uv_venv() {
local project_dir="${1:-.}"
local venv_path="${project_dir}/.venv"
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
echo "$venv_path"
return 0
fi
if command -v uv &>/dev/null; then
(cd "$project_dir" && uv venv) >/dev/null 2>&1
elif command -v python3 &>/dev/null; then
python3 -m venv "$venv_path"
else
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
return 1
fi
if [ ! -f "$venv_path/bin/python" ]; then
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
return 1
fi
echo "$venv_path"
}
+35
View File
@@ -0,0 +1,35 @@
---
name: uv_add_packages
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "uv_add_packages(project_dir: string, ...packages: string) -> void"
description: "Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe."
tags: [python, uv, pip, packages, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/uv_add_packages.sh"
---
## Ejemplo
```bash
source uv_add_packages.sh
uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy
# Solo un paquete
uv_add_packages . polars
```
## Notas
Requiere que el venv ya exista (usa `init_uv_venv` antes). Prefiere uv por velocidad y reproducibilidad (lockfile). Si uv no esta disponible, usa pip del venv directamente.
+35
View File
@@ -0,0 +1,35 @@
# uv_add_packages
# -----------------
# Instala paquetes Python en un proyecto con uv add.
# Inicializa pyproject.toml si no existe.
# Fallback a pip install si uv no esta disponible.
#
# USO (sourced):
# source uv_add_packages.sh
# uv_add_packages /path/to/project jupyter jupyterlab pandas
uv_add_packages() {
local project_dir="$1"
shift
local packages=("$@")
if [ ${#packages[@]} -eq 0 ]; then
echo "uv_add_packages: se requiere al menos un paquete" >&2
return 1
fi
if [ ! -d "$project_dir/.venv" ]; then
echo "uv_add_packages: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
return 1
fi
if command -v uv &>/dev/null; then
# Inicializar pyproject.toml si no existe
if [ ! -f "$project_dir/pyproject.toml" ]; then
(cd "$project_dir" && uv init 2>/dev/null) || true
fi
(cd "$project_dir" && uv add "${packages[@]}" 2>&1)
else
"$project_dir/.venv/bin/pip" install "${packages[@]}" 2>&1
fi
}
@@ -0,0 +1,33 @@
---
name: write_claude_jupyter_rules
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_claude_jupyter_rules([project_dir: string]) -> string"
description: "Genera o actualiza .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter: celdas inmutables, programacion funcional, uso de MCP, acceso al fn_registry."
tags: [claude, jupyter, rules, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_claude_jupyter_rules.sh"
---
## Ejemplo
```bash
source write_claude_jupyter_rules.sh
path=$(write_claude_jupyter_rules /home/lucas/analysis/finanzas)
echo "Reglas escritas en: $path"
```
## Notas
Idempotente: si CLAUDE.md ya contiene las reglas (detecta "JUPYTER HABILITADO"), no las duplica. Si CLAUDE.md existe sin las reglas, las prepend al contenido existente. Incluye instrucciones de acceso al fn_registry via `FN_REGISTRY_ROOT`.
@@ -0,0 +1,74 @@
# write_claude_jupyter_rules
# ----------------------------
# Genera .claude/CLAUDE.md con reglas para agentes que trabajan con Jupyter.
# Si ya existe CLAUDE.md y no tiene las reglas, las prepend.
#
# USO (sourced):
# source write_claude_jupyter_rules.sh
# write_claude_jupyter_rules /path/to/project
write_claude_jupyter_rules() {
local project_dir="${1:-.}"
local claude_dir="${project_dir}/.claude"
local claude_md="${claude_dir}/CLAUDE.md"
mkdir -p "$claude_dir"
# Si ya tiene las reglas, no hacer nada
if [ -f "$claude_md" ] && grep -q "JUPYTER HABILITADO" "$claude_md" 2>/dev/null; then
echo "$claude_md"
return 0
fi
local rules
rules='# JUPYTER HABILITADO EN ESTE ANALISIS
## Reglas OBLIGATORIAS para Claude
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
- Si hay un error en una celda, crear celda nueva con la correccion
- El historial de trabajo debe quedar intacto para trazabilidad
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
- **Composicion**: funciones pequenas que se combinan
- Preferir: `map`, `filter`, `reduce`, list comprehensions
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
- Compartimos variables y estado del kernel
- **NUNCA usar bash para ejecutar Python en este analisis**
### 4. Verificar Jupyter activo ANTES de ejecutar
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
### 5. Gestion de notebooks
- Notebooks en la carpeta `notebooks/` o subcarpetas
- Si un notebook tiene >50 celdas, crear uno nuevo
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
### 6. Gestion de Python
- **SIEMPRE usar `uv`** para gestionar dependencias
- Anadir paquetes con `uv add nombre_paquete`
### 7. Acceso al fn_registry
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
'
if [ -f "$claude_md" ]; then
local existing
existing=$(cat "$claude_md")
printf '%s\n%s' "$rules" "$existing" > "$claude_md"
else
echo "$rules" > "$claude_md"
fi
echo "$claude_md"
}
@@ -0,0 +1,41 @@
---
name: write_jupyter_launcher
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_launcher([project_dir: string]) -> string"
description: "Genera un script run-jupyter-lab.sh que lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto y token deshabilitado."
tags: [jupyter, launcher, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_launcher.sh"
---
## Ejemplo
```bash
source write_jupyter_launcher.sh
path=$(write_jupyter_launcher /home/lucas/analysis/finanzas)
echo "Launcher creado en: $path"
# Luego en otra terminal:
# ./run-jupyter-lab.sh [puerto]
```
## Notas
El launcher generado:
- Autodetecta un puerto libre (8888-8899)
- Guarda el puerto en `.jupyter-port` para que otros procesos lo lean
- Activa el venv automaticamente
- Lanza Jupyter Lab en modo `--collaborative` (requiere jupyter-collaboration)
- Token y password deshabilitados para acceso local
@@ -0,0 +1,65 @@
# write_jupyter_launcher
# -----------------------
# Genera un script run-jupyter-lab.sh en el directorio dado.
# El script lanza Jupyter Lab en modo colaborativo con autodeteccion de puerto.
#
# USO (sourced):
# source write_jupyter_launcher.sh
# write_jupyter_launcher /path/to/project
write_jupyter_launcher() {
local project_dir="${1:-.}"
local launcher="${project_dir}/run-jupyter-lab.sh"
cat > "$launcher" << 'LAUNCHER'
#!/bin/bash
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
# Generado por write_jupyter_launcher (fn_registry)
find_free_port() {
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo $port
return
fi
done
echo 8888
}
PORT=${1:-$(find_free_port)}
cd "$(dirname "$0")"
echo $PORT > .jupyter-port
source .venv/bin/activate 2>/dev/null || true
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
echo "ERROR: jupyter-collaboration no esta instalado"
echo "Instala con: uv add jupyter-collaboration"
exit 1
fi
echo "════════════════════════════════════════════════"
echo " Jupyter Lab + Colaboracion en puerto $PORT"
echo "════════════════════════════════════════════════"
echo ""
echo " Abre: http://localhost:$PORT"
echo " Ctrl+C para detener"
echo ""
jupyter lab \
--port=$PORT \
--no-browser \
--ServerApp.token='' \
--ServerApp.password='' \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
--ServerApp.root_dir="$(pwd)" \
--YDocExtension.ystore_class='ypy_websocket.ystore.TempFileYStore' \
--collaborative
LAUNCHER
chmod +x "$launcher"
echo "$launcher"
}
@@ -0,0 +1,56 @@
---
name: write_jupyter_registry_kernel
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_jupyter_registry_kernel([project_dir: string]) -> string"
description: "Genera un script de startup de IPython que autoconfigura FN_REGISTRY_ROOT, sys.path a python/functions del registry, y helpers fn_query/fn_search/fn_code para consultar registry.db desde notebooks."
tags: [jupyter, ipython, kernel, registry, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_jupyter_registry_kernel.sh"
---
## Ejemplo
```bash
source write_jupyter_registry_kernel.sh
path=$(write_jupyter_registry_kernel /home/lucas/analysis/finanzas)
echo "Startup creado en: $path"
```
Luego en cualquier notebook del proyecto:
```python
# Ya disponible automaticamente al abrir el notebook:
# Buscar funciones
fn_search("finance")
# Consultar registry.db directamente
fn_query("SELECT id, signature FROM functions WHERE domain = ?", ("core",))
# Ver codigo de una funcion
print(fn_code("filter_list_py_core"))
# Importar funciones Python del registry directamente
from core import filter_list, map_list
from finance import sma, ema, rsi
```
## Notas
Genera `.ipython/profile_default/startup/00_fn_registry.py` que se ejecuta automaticamente al iniciar cualquier kernel IPython en el proyecto. No requiere imports manuales — las funciones `fn_query`, `fn_search` y `fn_code` estan disponibles inmediatamente en cada notebook.
El `sys.path` se configura para que cada dominio de `python/functions/` sea importable directamente (`from core import filter_list`).
Detecta `FN_REGISTRY_ROOT` buscando la raiz del repo git o subiendo directorios hasta encontrar `registry.db`.
@@ -0,0 +1,111 @@
# write_jupyter_registry_kernel
# -------------------------------
# Genera un script de startup de IPython que autoconfigura el acceso
# al fn_registry en cada notebook: FN_REGISTRY_ROOT, sys.path a
# python/functions, y un helper fn_query() para consultar registry.db.
#
# USO (sourced):
# source write_jupyter_registry_kernel.sh
# write_jupyter_registry_kernel /path/to/project
write_jupyter_registry_kernel() {
local project_dir="${1:-.}"
local startup_dir="${project_dir}/.ipython/profile_default/startup"
local registry_root
registry_root="$(cd "$project_dir" && cd "$(git -C "$project_dir" rev-parse --show-toplevel 2>/dev/null || echo "../..")" && pwd)"
# Fallback: si no es git, buscar registry.db subiendo directorios
if [ ! -f "$registry_root/registry.db" ] && [ -f "$project_dir/../../registry.db" ]; then
registry_root="$(cd "$project_dir/../.." && pwd)"
fi
mkdir -p "$startup_dir"
cat > "${startup_dir}/00_fn_registry.py" << STARTUP
"""
fn_registry kernel startup
Autoconfigura acceso al registry en cada notebook.
Generado por write_jupyter_registry_kernel (fn_registry).
"""
import os
import sys
import sqlite3
from pathlib import Path
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
FN_REGISTRY_ROOT = Path("${registry_root}")
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
# ── sys.path: importar funciones Python del registry ────────
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
if _domain.is_dir() and not _domain.name.startswith("_"):
_path = str(_domain)
if _path not in sys.path:
sys.path.insert(0, _path)
# Tambien el directorio padre para imports por dominio: from core import filter_list
_pf = str(_python_functions)
if _pf not in sys.path:
sys.path.insert(0, _pf)
# ── fn_query: consultar registry.db desde el notebook ───────
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
def fn_query(sql, params=()):
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
Ejemplos:
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
"""
if not _REGISTRY_DB.exists():
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
con = sqlite3.connect(str(_REGISTRY_DB))
con.row_factory = sqlite3.Row
try:
rows = con.execute(sql, params).fetchall()
return [dict(r) for r in rows]
finally:
con.close()
def fn_search(term):
"""Busca funciones y tipos en el registry por nombre o descripcion.
Ejemplo:
fn_search("slice")
fn_search("finance")
"""
fts_term = f"name:{term}* OR description:{term}*"
functions = fn_query(
"SELECT id, kind, purity, lang, description FROM functions "
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
types = fn_query(
"SELECT id, algebraic, lang, description FROM types "
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
return {"functions": functions, "types": types}
def fn_code(function_id):
"""Retorna el codigo fuente de una funcion del registry.
Ejemplo:
print(fn_code("filter_list_py_core"))
"""
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
if not rows:
raise KeyError(f"Funcion no encontrada: {function_id}")
return rows[0]["code"]
# ── Mensaje de bienvenida ───────────────────────────────────
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
print(f" Python functions: {_pf}")
print(f" Helpers: fn_query(), fn_search(), fn_code()")
STARTUP
echo "${startup_dir}/00_fn_registry.py"
}
@@ -0,0 +1,33 @@
---
name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
tags: [mcp, jupyter, config, setup, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
---
## Ejemplo
```bash
source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
echo "Config MCP en: $path"
```
## Notas
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
@@ -0,0 +1,57 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el python del venv local con -m jupyter_mcp_server.
# Hace merge si ya existe .mcp.json (requiere jq).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
local port="${2:-8888}"
local mcp_file="${project_dir}/.mcp.json"
local abs_project
abs_project="$(cd "$project_dir" && pwd)"
local python_bin="${abs_project}/.venv/bin/python"
if [ ! -f "$python_bin" ]; then
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
return 1
fi
# Verificar que el modulo esta instalado
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${python_bin}",
"args": [
"-m", "jupyter_mcp_server",
"--runtime-url", "http://localhost:${port}",
"--start-new-runtime", "false"
]
}
}
}
EOF
)
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
else
echo "$new_config" > "$mcp_file"
fi
echo "$mcp_file"
}
@@ -0,0 +1,63 @@
---
name: init_jupyter_analysis
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_jupyter_analysis(nombre: string, [...paquetes_extra: string]) -> void"
description: "Inicializa un analisis Jupyter completo en analysis/{nombre}/ con venv, paquetes, launcher, MCP y reglas para agentes Claude. Acepta paquetes extra opcionales."
tags: [jupyter, analysis, setup, pipeline, bash, launcher]
uses_functions:
- assert_command_exists_bash_shell
- find_free_port_bash_shell
- init_uv_venv_bash_infra
- uv_add_packages_bash_infra
- write_jupyter_launcher_bash_infra
- write_mcp_jupyter_config_bash_infra
- write_claude_jupyter_rules_bash_infra
- write_jupyter_registry_kernel_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/init_jupyter_analysis.sh"
---
## Ejemplo
```bash
# Analisis basico
./init_jupyter_analysis.sh finanzas
# Con paquetes extra
./init_jupyter_analysis.sh duckdb polars duckdb
./init_jupyter_analysis.sh ml scikit-learn torch
# Via fn run
fn run init_jupyter_analysis finanzas
fn run init_jupyter_analysis ml scikit-learn torch
```
## Flujo
1. `assert_command_exists` — verifica que uv o python3 estan disponibles
2. Crea estructura `analysis/{nombre}/notebooks/` y `analysis/{nombre}/data/`
3. `init_uv_venv` — crea venv en `analysis/{nombre}/.venv/`
4. `uv_add_packages` — instala jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib + extras
5. `write_jupyter_launcher` — genera `run-jupyter-lab.sh` con modo colaborativo
6. `find_free_port` + `write_mcp_jupyter_config` — detecta puerto libre y genera `.mcp.json`
7. `write_claude_jupyter_rules` — genera `.claude/CLAUDE.md` con reglas de agente
8. `write_jupyter_registry_kernel` — genera IPython startup con `fn_query`, `fn_search`, `fn_code` y acceso a `python/functions/`
## Notas
Cada analisis es independiente (propio venv, propio Jupyter, propio MCP). Mismo patron que `apps/` pero para exploraciones no reutilizables.
El pipeline usa `set -euo pipefail` — cualquier fallo detiene la ejecucion.
Paquetes base siempre incluidos: jupyter, jupyterlab, jupyter-collaboration, jupyter-mcp-server, pandas, numpy, matplotlib. Los paquetes extra se añaden a estos.
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# init_jupyter_analysis
# ----------------------
# Inicializa un analisis Jupyter completo en analysis/{nombre}/.
# Compone: assert_command_exists + find_free_port + init_uv_venv +
# uv_add_packages + write_jupyter_launcher +
# write_mcp_jupyter_config + write_claude_jupyter_rules +
# write_jupyter_registry_kernel
#
# USO:
# ./init_jupyter_analysis.sh <nombre> [paquetes_extra...]
#
# EJEMPLOS:
# ./init_jupyter_analysis.sh finanzas
# ./init_jupyter_analysis.sh duckdb polars duckdb
# ./init_jupyter_analysis.sh ml scikit-learn torch
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Source funciones atomicas
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
source "$REGISTRY_ROOT/bash/functions/shell/find_free_port.sh"
source "$REGISTRY_ROOT/bash/functions/infra/init_uv_venv.sh"
source "$REGISTRY_ROOT/bash/functions/infra/uv_add_packages.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_launcher.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_mcp_jupyter_config.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_claude_jupyter_rules.sh"
source "$REGISTRY_ROOT/bash/functions/infra/write_jupyter_registry_kernel.sh"
# ── Argumentos ──────────────────────────────────────────────
NOMBRE="${1:-}"
if [ -z "$NOMBRE" ]; then
echo "Uso: $0 <nombre> [paquetes_extra...]" >&2
echo " Ejemplo: $0 finanzas polars" >&2
exit 1
fi
shift
EXTRA_PACKAGES=("$@")
ANALYSIS_DIR="${REGISTRY_ROOT}/analysis/${NOMBRE}"
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT JUPYTER ANALYSIS: ${NOMBRE}"
echo " Directorio: ${ANALYSIS_DIR}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── 1. Verificar herramientas ───────────────────────────────
echo "[1/8] Verificando herramientas..."
assert_command_exists uv || assert_command_exists python3
echo " OK"
# ── 2. Crear estructura de carpetas ─────────────────────────
echo "[2/8] Creando estructura..."
mkdir -p "$ANALYSIS_DIR/notebooks" "$ANALYSIS_DIR/data"
echo " ${ANALYSIS_DIR}/notebooks/"
echo " ${ANALYSIS_DIR}/data/"
# ── 3. Crear venv ───────────────────────────────────────────
echo "[3/8] Inicializando venv..."
venv_path=$(init_uv_venv "$ANALYSIS_DIR")
echo " $venv_path"
# ── 4. Instalar paquetes ────────────────────────────────────
echo "[4/8] Instalando paquetes..."
BASE_PACKAGES=(jupyter jupyterlab jupyter-collaboration jupyter-mcp-server pandas numpy matplotlib)
ALL_PACKAGES=("${BASE_PACKAGES[@]}" "${EXTRA_PACKAGES[@]}")
uv_add_packages "$ANALYSIS_DIR" "${ALL_PACKAGES[@]}"
echo " Instalados: ${ALL_PACKAGES[*]}"
# ── 5. Generar launcher ─────────────────────────────────────
echo "[5/8] Generando launcher..."
launcher=$(write_jupyter_launcher "$ANALYSIS_DIR")
echo " $launcher"
# ── 6. Configurar MCP ───────────────────────────────────────
echo "[6/8] Configurando MCP..."
port=$(find_free_port 8888 8899)
mcp_config=$(write_mcp_jupyter_config "$ANALYSIS_DIR" "$port")
echo " $mcp_config (puerto: $port)"
# ── 7. Reglas para agentes ──────────────────────────────────
echo "[7/8] Escribiendo reglas Claude..."
rules=$(write_claude_jupyter_rules "$ANALYSIS_DIR")
echo " $rules"
# ── 8. Kernel startup con acceso al registry ────────────────
echo "[8/8] Configurando kernel con acceso al registry..."
kernel_startup=$(write_jupyter_registry_kernel "$ANALYSIS_DIR")
echo " $kernel_startup"
# ── Resumen ─────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════════"
echo " ANALISIS '${NOMBRE}' LISTO"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo ""
echo " 1. En otra terminal:"
echo " cd ${ANALYSIS_DIR} && ./run-jupyter-lab.sh"
echo ""
echo " 2. Abrir Claude en el analisis:"
echo " cd ${ANALYSIS_DIR} && claude"
echo ""
echo " 3. Abrir en navegador: http://localhost:${port}"
echo ""
echo " FN_REGISTRY_ROOT=${REGISTRY_ROOT}"
echo ""
+35
View File
@@ -0,0 +1,35 @@
---
name: exit_with_status
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: pure
signature: "exit_with_status(total_steps: int, ok_steps: int, failed_steps: int) -> int"
description: "Calcula el exit code estandar (0=success, 1=failure, 2=partial) a partir de contadores de pasos. Si failed_steps=0 imprime 0 y sale con 0. Si ok_steps=0 imprime 1 y sale con 1. Si hay ambos imprime 2 y sale con 2."
tags: [execution, status, exit-code, standard]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/exit_with_status.sh"
---
## Ejemplo
```bash
source bash/functions/shell/exit_with_status.sh
exit_with_status 5 5 0 # stdout: 0, exit code: 0
exit_with_status 5 0 5 # stdout: 1, exit code: 1
exit_with_status 5 3 2 # stdout: 2, exit code: 2
```
## Notas
Funcion pura: no realiza I/O de sistema, no modifica estado global, no lee variables de entorno. El argumento `total_steps` se recibe para completitud semantica pero la logica solo depende de `ok_steps` y `failed_steps`. El valor se imprime a stdout ademas de usarse como exit code, de modo que el caller puede capturarlo con `$(exit_with_status ...)` o evaluar directamente con `exit_with_status ... ; echo $?`.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# exit_with_status — calcula el exit code estandar a partir de contadores de pasos
# exit_with_status <total_steps> <ok_steps> <failed_steps>
#
# Calcula el exit code estandar:
# 0 — todos los pasos exitosos (failed_steps == 0)
# 1 — todos los pasos fallaron (ok_steps == 0)
# 2 — resultado parcial (hay ok y failed)
#
# Imprime el codigo a stdout y sale con ese codigo.
exit_with_status() {
local total_steps="$1"
local ok_steps="$2"
local failed_steps="$3"
local code
if [[ "$failed_steps" -eq 0 ]]; then
code=0
elif [[ "$ok_steps" -eq 0 ]]; then
code=1
else
code=2
fi
echo "$code"
return "$code"
}
+36
View File
@@ -0,0 +1,36 @@
---
name: find_free_port
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "find_free_port([start_port: int], [end_port: int]) -> int"
description: "Busca el primer puerto TCP libre en un rango dado usando ss y lsof. Retorna el numero de puerto a stdout."
tags: [network, port, shell, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/find_free_port.sh"
---
## Ejemplo
```bash
source find_free_port.sh
port=$(find_free_port 8888 8899)
echo "Puerto libre: $port"
# Con defaults (8888-8899)
port=$(find_free_port)
```
## Notas
Usa `ss -tln` como primer intento y `lsof` como fallback. Ambos deben confirmar que el puerto no esta en uso. Si ningun puerto esta libre en el rango, sale con exit code 1.
+25
View File
@@ -0,0 +1,25 @@
# find_free_port
# ---------------
# Busca el primer puerto libre en un rango dado.
# Imprime el puerto encontrado a stdout.
# Sale con exit code 1 si no encuentra ninguno.
#
# USO (sourced):
# source find_free_port.sh
# port=$(find_free_port 8888 8899)
find_free_port() {
local start="${1:-8888}"
local end="${2:-8899}"
for ((port=start; port<=end; port++)); do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo "$port"
return 0
fi
done
echo "find_free_port: no se encontro puerto libre en rango ${start}-${end}" >&2
return 1
}
@@ -0,0 +1,57 @@
---
name: report_execution_json
kind: function
lang: bash
domain: shell
version: "2.0.0"
purity: pure
signature: "report_execution_json(flow_name: string, status: string, exit_code: int, started_at: string, ended_at: string, duration_ms: int, steps_file: string) -> string"
description: "Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry (docs/execution_standard.md). Recibe los metadatos del flujo y un archivo TSV con resultados de pasos (columnas: name, action, status, elapsed_ms, output, error). Imprime el JSON completo a stdout. Usa jq si esta disponible, con fallback a printf. Funcion pura: sin efectos secundarios ni I/O adicional."
tags: [execution, json, report, standard, shell, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/report_execution_json.sh"
---
## Ejemplo
```bash
source bash/functions/shell/report_execution_json.sh
# Crear archivo de pasos (TSV sin cabecera, 6 columnas)
cat > /tmp/steps.tsv <<'EOF'
check_db command ok 10 exists
backup command ok 450 done
push command error 2540 remote rejected
EOF
report_execution_json \
"backup_db" "partial" 2 \
"2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 \
/tmp/steps.tsv
```
Output esperado:
```json
{"name":"backup_db","status":"partial","exit_code":2,"started_at":"2026-04-01T02:00:00Z","ended_at":"2026-04-01T02:00:03Z","duration_ms":3000,"steps_total":3,"steps_ok":2,"steps_failed":1,"steps":[{"name":"check_db","action":"command","status":"ok","elapsed_ms":10,"output":"exists"},{"name":"backup","action":"command","status":"ok","elapsed_ms":450,"output":"done"},{"name":"push","action":"command","status":"error","elapsed_ms":2540,"error":"remote rejected"}]}
```
## Notas
Funcion pura: solo lee el archivo de pasos y escribe JSON a stdout, sin efectos secundarios ni I/O adicional mas alla de leer el steps_file.
Formato TSV: 6 columnas separadas por tabulador real (sin cabecera): name, action, status (ok|error), elapsed_ms, output, error. Los campos output y error pueden estar vacios; se omiten del JSON si no tienen valor, siguiendo el estandar de output estructurado.
El caller es responsable de calcular status (success|failure|partial) y exit_code (0|1|2) segun las reglas del estandar. Ver exit_with_status_bash_shell para esa logica.
Compatibilidad dual: con jq construye el JSON de forma robusta (maneja caracteres especiales y saltos de linea en output/error). Sin jq, el fallback con printf escapa backslash, comillas dobles y caracteres de control basicos.
Puede ejecutarse directamente: `bash report_execution_json.sh <args>`.
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# report_execution_json — Genera un JSON de reporte de ejecucion siguiendo el estandar fn-registry.
#
# USO (sourced):
# source report_execution_json.sh
# report_execution_json "backup_db" "partial" 2 \
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
#
# USO (ejecutado directamente):
# bash report_execution_json.sh "backup_db" "partial" 2 \
# "2026-04-01T02:00:00Z" "2026-04-01T02:00:03Z" 3000 /tmp/steps.tsv
#
# FORMATO steps_file (TSV sin cabecera, 6 columnas):
# name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
# Los campos output y error pueden estar vacios.
# status valido: ok | error
#
# NOTA sobre IFS y tabs: bash trata tab como whitespace en IFS y colapsa
# campos vacios consecutivos. Se usa 'cut -f N' para parsear cada columna
# de forma correcta cuando hay campos vacios entre tabs.
report_execution_json() {
local flow_name="$1"
local status="$2"
local exit_code="$3"
local started_at="$4"
local ended_at="$5"
local duration_ms="$6"
local steps_file="$7"
if [[ -z "$flow_name" || -z "$status" || -z "$exit_code" || \
-z "$started_at" || -z "$ended_at" || -z "$duration_ms" || \
-z "$steps_file" ]]; then
echo "report_execution_json: uso: report_execution_json <flow_name> <status> <exit_code> <started_at> <ended_at> <duration_ms> <steps_file>" >&2
return 1
fi
if [[ ! -f "$steps_file" ]]; then
echo "report_execution_json: archivo de pasos no encontrado: $steps_file" >&2
return 1
fi
if command -v jq >/dev/null 2>&1; then
_report_execution_json_jq \
"$flow_name" "$status" "$exit_code" \
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
else
_report_execution_json_printf \
"$flow_name" "$status" "$exit_code" \
"$started_at" "$ended_at" "$duration_ms" "$steps_file"
fi
}
# --- Implementacion con jq ---
_report_execution_json_jq() {
local flow_name="$1" status="$2" exit_code="$3"
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
local steps_ok=0 steps_failed=0
local steps_json="[]"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local s_name s_action s_status s_elapsed s_output s_error
s_name=$(printf '%s' "$line" | cut -f1)
s_action=$(printf '%s' "$line" | cut -f2)
s_status=$(printf '%s' "$line" | cut -f3)
s_elapsed=$(printf '%s' "$line" | cut -f4)
s_output=$(printf '%s' "$line" | cut -f5)
s_error=$(printf '%s' "$line" | cut -f6)
[[ -z "$s_name" ]] && continue
local step_obj
step_obj=$(jq -n \
--arg name "$s_name" \
--arg action "$s_action" \
--arg st "$s_status" \
--argjson ms "${s_elapsed:-0}" \
--arg output "$s_output" \
--arg error "$s_error" \
'{name: $name, action: $action, status: $st, elapsed_ms: $ms}
+ (if $output != "" then {output: $output} else {} end)
+ (if $error != "" then {error: $error} else {} end)')
steps_json=$(printf '%s' "$steps_json" | jq --argjson step "$step_obj" '. + [$step]')
if [[ "$s_status" == "ok" ]]; then
((steps_ok++))
else
((steps_failed++))
fi
done < "$steps_file"
local steps_total=$(( steps_ok + steps_failed ))
jq -n \
--arg name "$flow_name" \
--arg st "$status" \
--argjson exit_code "$exit_code" \
--arg started_at "$started_at" \
--arg ended_at "$ended_at" \
--argjson duration_ms "$duration_ms" \
--argjson total "$steps_total" \
--argjson ok "$steps_ok" \
--argjson failed "$steps_failed" \
--argjson steps "$steps_json" \
'{
name: $name,
status: $st,
exit_code: $exit_code,
started_at: $started_at,
ended_at: $ended_at,
duration_ms: $duration_ms,
steps_total: $total,
steps_ok: $ok,
steps_failed: $failed,
steps: $steps
}'
}
# --- Implementacion con printf (fallback sin jq) ---
_json_escape_rj() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
_report_execution_json_printf() {
local flow_name="$1" status="$2" exit_code="$3"
local started_at="$4" ended_at="$5" duration_ms="$6" steps_file="$7"
local steps_ok=0 steps_failed=0
local steps_parts=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local s_name s_action s_status s_elapsed s_output s_error
s_name=$(printf '%s' "$line" | cut -f1)
s_action=$(printf '%s' "$line" | cut -f2)
s_status=$(printf '%s' "$line" | cut -f3)
s_elapsed=$(printf '%s' "$line" | cut -f4)
s_output=$(printf '%s' "$line" | cut -f5)
s_error=$(printf '%s' "$line" | cut -f6)
[[ -z "$s_name" ]] && continue
local part
part=$(printf '{"name":"%s","action":"%s","status":"%s","elapsed_ms":%s' \
"$(_json_escape_rj "$s_name")" \
"$(_json_escape_rj "$s_action")" \
"$(_json_escape_rj "$s_status")" \
"${s_elapsed:-0}")
if [[ -n "$s_output" ]]; then
part+=",\"output\":\"$(_json_escape_rj "$s_output")\""
fi
if [[ -n "$s_error" ]]; then
part+=",\"error\":\"$(_json_escape_rj "$s_error")\""
fi
part+="}"
steps_parts+=("$part")
if [[ "$s_status" == "ok" ]]; then
((steps_ok++))
else
((steps_failed++))
fi
done < "$steps_file"
local steps_total=$(( steps_ok + steps_failed ))
local steps_array="["
local first=1
for part in "${steps_parts[@]}"; do
if [[ $first -eq 1 ]]; then
steps_array+="$part"
first=0
else
steps_array+=",$part"
fi
done
steps_array+="]"
printf '{"name":"%s","status":"%s","exit_code":%s,"started_at":"%s","ended_at":"%s","duration_ms":%s,"steps_total":%s,"steps_ok":%s,"steps_failed":%s,"steps":%s}\n' \
"$(_json_escape_rj "$flow_name")" \
"$(_json_escape_rj "$status")" \
"$exit_code" \
"$(_json_escape_rj "$started_at")" \
"$(_json_escape_rj "$ended_at")" \
"$duration_ms" \
"$steps_total" \
"$steps_ok" \
"$steps_failed" \
"$steps_array"
}
# Permitir ejecucion directa (no solo sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
report_execution_json "$@"
fi
+72
View File
@@ -0,0 +1,72 @@
---
name: run_steps
kind: function
lang: bash
domain: shell
version: "1.0.0"
purity: impure
signature: "run_steps(yaml_file: string, [--strict]) -> string"
description: "Ejecuta pasos de un YAML generico donde cada step tiene action=command. Lee el YAML con yq, ejecuta cada paso secuencialmente con timeout configurable, captura exit code y output, respeta continue_on_error, y al final reporta JSON estandar a stdout via report_execution_json. Sale con exit code 0 (success), 1 (failure) o 2 (partial). Con --strict mapea partial a failure."
tags: [execution, yaml, runner, standard, pipeline]
uses_functions: [exit_with_status_bash_shell, report_execution_json_bash_shell]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/shell/run_steps.sh"
---
## Ejemplo
```yaml
# /tmp/test_flow.yaml
name: test_flow
steps:
- name: check
action: command
command: "echo hello"
- name: list
action: command
command: "ls /tmp"
continue_on_error: true
- name: slow_step
action: command
command: "sleep 2"
timeout_ms: 1000
continue_on_error: true
```
```bash
source bash/functions/shell/run_steps.sh
run_steps /tmp/test_flow.yaml
# Imprime JSON a stdout:
# {
# "name": "test_flow",
# "status": "partial",
# "exit_code": 2,
# "started_at": "2026-04-01T10:00:00Z",
# "ended_at": "2026-04-01T10:00:01Z",
# "duration_ms": 1250,
# "steps_total": 3,
# "steps_ok": 2,
# "steps_failed": 1,
# "steps": [
# {"name": "check", "action": "command", "status": "ok", "elapsed_ms": 10, "output": "hello"},
# {"name": "list", "action": "command", "status": "ok", "elapsed_ms": 15},
# {"name": "slow_step", "action": "command", "status": "error", "elapsed_ms": 1001, "error": "timeout: comando excedio 1000ms"}
# ]
# }
# Sale con exit code 2 (partial)
run_steps /tmp/test_flow.yaml --strict
# Igual pero status="failure" y exit code=1
```
## Notas
Requiere `yq` (v4+, modo expression `-e`) y `jq` (o la implementacion printf de report_execution_json). Solo soporta `action: command` — otros actions se marcan como error del paso. El campo `timeout_ms` por defecto es 30000 (30s); si el comando excede el timeout, `timeout(1)` sale con exit code 124 y se registra el error correspondiente. El output del comando (stdout+stderr combinados) se sanitiza reemplazando tabuladores y saltos de linea por espacios antes de escribir al TSV temporal. Las funciones `exit_with_status` y `report_execution_json` se cargan via `source` desde el mismo directorio que `run_steps.sh`.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# run_steps — ejecuta pasos de un YAML generico (action=command)
# run_steps <yaml_file> [--strict]
#
# Lee un YAML con la estructura:
# name: <run_name>
# steps:
# - name: <step_name>
# action: command
# command: "<shell_command>"
# continue_on_error: true|false # opcional, default false
# timeout_ms: 30000 # opcional, default 30000
#
# Para cada paso de action=command:
# - Ejecuta el command con timeout (timeout_ms ms)
# - Captura exit code, stdout+stderr y elapsed time
# - Si falla y continue_on_error=false → aborta
# - Acumula resultados en TSV temporal
#
# Al final:
# - Llama a report_execution_json para generar JSON a stdout
# - Llama a exit_with_status para determinar el exit code
#
# --strict: mapea partial (2) a failure (1) en status y exit code
#
# Requiere: yq, jq (jq opcional si report_execution_json tiene fallback printf)
run_steps() {
local yaml_file="$1"
local strict=0
if [[ "${2:-}" == "--strict" ]]; then
strict=1
fi
# --- validaciones previas ---
if [[ -z "$yaml_file" ]]; then
echo "run_steps: yaml_file requerido" >&2
return 1
fi
if [[ ! -f "$yaml_file" ]]; then
echo "run_steps: archivo '$yaml_file' no existe" >&2
return 1
fi
if ! command -v yq &>/dev/null; then
echo "run_steps: yq no encontrado en PATH" >&2
return 1
fi
# --- cargar funciones del estandar ---
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=bash/functions/shell/exit_with_status.sh
source "$script_dir/exit_with_status.sh"
# shellcheck source=bash/functions/shell/report_execution_json.sh
source "$script_dir/report_execution_json.sh"
# --- leer nombre del run ---
local run_name
run_name=$(yq e '.name // "unnamed"' "$yaml_file")
# --- contar pasos ---
local step_count
step_count=$(yq e '.steps | length' "$yaml_file")
if [[ -z "$step_count" || "$step_count" -eq 0 ]]; then
echo "run_steps: el YAML no tiene steps" >&2
return 1
fi
# --- archivo TSV temporal para acumular resultados ---
# columnas: name<TAB>action<TAB>status<TAB>elapsed_ms<TAB>output<TAB>error
local tsv_file
tsv_file=$(mktemp /tmp/run_steps_XXXXXX.tsv)
# shellcheck disable=SC2064
trap "rm -f '$tsv_file'" EXIT
local ok_steps=0
local failed_steps=0
local started_at
started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local t_run_start
t_run_start=$(date +%s%3N)
# --- iterar sobre cada paso ---
local i
for (( i=0; i<step_count; i++ )); do
local step_name step_action step_command step_continue step_timeout_ms
step_name=$(yq e ".steps[$i].name // \"step_$i\"" "$yaml_file")
step_action=$(yq e ".steps[$i].action // \"command\"" "$yaml_file")
step_command=$(yq e ".steps[$i].command // \"\"" "$yaml_file")
step_continue=$(yq e ".steps[$i].continue_on_error // false" "$yaml_file")
step_timeout_ms=$(yq e ".steps[$i].timeout_ms // 30000" "$yaml_file")
# convertir timeout de ms a segundos enteros (timeout(1) usa segundos)
local timeout_sec
timeout_sec=$(( (step_timeout_ms + 999) / 1000 ))
# solo soportamos action=command
if [[ "$step_action" != "command" ]]; then
local err_msg="action '$step_action' no soportada (solo command)"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "$step_action" "error" "0" "" "$err_msg" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: paso '$step_name' — $err_msg — abortando" >&2
break
fi
continue
fi
if [[ -z "$step_command" ]]; then
local err_msg="campo 'command' vacio en paso '$step_name'"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "error" "0" "" "$err_msg" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: $err_msg — abortando" >&2
break
fi
continue
fi
# ejecutar con timeout y capturar output + tiempos
local t_start t_end elapsed_ms step_output step_exit
t_start=$(date +%s%3N)
step_output=$(timeout "$timeout_sec" bash -c "$step_command" 2>&1)
step_exit=$?
t_end=$(date +%s%3N)
elapsed_ms=$(( t_end - t_start ))
# timeout(1) sale con 124 cuando agota el tiempo
local step_error=""
if [[ "$step_exit" -eq 124 ]]; then
step_error="timeout: comando excedio ${step_timeout_ms}ms"
fi
# escapar tabuladores y saltos de linea en el output para el TSV
local safe_output
safe_output=$(printf '%s' "$step_output" | tr '\t' ' ' | tr '\n' ' ')
if [[ "$step_exit" -eq 0 ]]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "ok" "$elapsed_ms" "$safe_output" "" >> "$tsv_file"
ok_steps=$((ok_steps + 1))
else
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$step_name" "command" "error" "$elapsed_ms" "$safe_output" "$step_error" >> "$tsv_file"
failed_steps=$((failed_steps + 1))
if [[ "$step_continue" != "true" ]]; then
echo "run_steps: paso '$step_name' fallo (exit $step_exit) — abortando" >&2
break
fi
fi
done
local t_run_end
t_run_end=$(date +%s%3N)
local total_duration_ms=$(( t_run_end - t_run_start ))
local ended_at
ended_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local total_steps=$(( ok_steps + failed_steps ))
# --- calcular status y exit code ---
local status_code
status_code=$(exit_with_status "$total_steps" "$ok_steps" "$failed_steps")
local run_status
case "$status_code" in
0) run_status="success" ;;
1) run_status="failure" ;;
2)
if [[ "$strict" -eq 1 ]]; then
run_status="failure"
status_code=1
else
run_status="partial"
fi
;;
*) run_status="failure"; status_code=1 ;;
esac
# --- generar JSON a stdout ---
report_execution_json \
"$run_name" \
"$run_status" \
"$status_code" \
"$started_at" \
"$ended_at" \
"$total_duration_ms" \
"$tsv_file"
return "$status_code"
}