Compare commits
12 Commits
029dbf57bd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| eb8dbf66a1 | |||
| 6bc97df5c0 | |||
| e769836b0d | |||
| 93756fbd0c | |||
| 0a6d1b8d17 | |||
| 82f1f1bd58 | |||
| 9a9b876400 | |||
| 5c253a26e2 | |||
| 10bfb846a8 | |||
| d996542f88 | |||
| 8742cb25be | |||
| 37aacfcfa9 |
+6
-2
@@ -23,7 +23,9 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
|
||||
|
||||
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
|
||||
**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
|
||||
|
||||
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
|
||||
|
||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||
|
||||
@@ -148,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
|
||||
**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
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
|
||||
|
||||
**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)
|
||||
@@ -231,6 +233,8 @@ fn-registry/
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
||||
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
|
||||
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
|
||||
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: orquestador
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
---
|
||||
|
||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
||||
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
|
||||
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
|
||||
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
|
||||
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
|
||||
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
||||
integras cuando terminan.
|
||||
|
||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
||||
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
|
||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
||||
re-invocar `/orquestador` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
||||
|
||||
```
|
||||
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
||||
```
|
||||
|
||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
||||
|
||||
Hay dos cosas con nombre parecido. No las confundas:
|
||||
|
||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
||||
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
|
||||
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
|
||||
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
|
||||
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
|
||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
||||
|
||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
||||
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
|
||||
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
|
||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
||||
usa este modo.
|
||||
|
||||
## El ciclo del orquestador (8 pasos)
|
||||
|
||||
### 1. Descomponer
|
||||
|
||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
||||
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
|
||||
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
|
||||
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
|
||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
||||
|
||||
### 2. Lanzar cada secundario
|
||||
|
||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
||||
prompts de permiso en cada Bash los atascarían:
|
||||
|
||||
```bash
|
||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
||||
```
|
||||
|
||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
||||
|
||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
||||
queda en telemetría):
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
||||
```
|
||||
|
||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
|
||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
||||
prompt_file existan y que kitty esté instalado.
|
||||
|
||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
||||
|
||||
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
|
||||
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
|
||||
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
|
||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
||||
cuál y se lo **impone** en el prompt del secundario:
|
||||
|
||||
| Opción | Cómo | Cuándo |
|
||||
|---|---|---|
|
||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
|
||||
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
|
||||
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
|
||||
|
||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
||||
|
||||
### 4. El prompt de cada secundario
|
||||
|
||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||
|
||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
||||
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
|
||||
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
|
||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
||||
merge a master (lo integra el orquestador).
|
||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
||||
commitean.
|
||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
||||
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
||||
`delegation.md`).
|
||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||
kitty vea el estado sin abrir el report.
|
||||
|
||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
||||
|
||||
### 5. Seguir la flota
|
||||
|
||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
||||
|
||||
```bash
|
||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
||||
./fn run list_claude_agents --json # para parsear y decidir
|
||||
```
|
||||
|
||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
||||
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
|
||||
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
|
||||
|
||||
Tu tabla de seguimiento, una fila por secundario:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
|
||||
|
||||
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
|
||||
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
|
||||
|
||||
### 6. NUNCA `pkill`/`killall` sobre claude
|
||||
|
||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||
Para parar un secundario:
|
||||
|
||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
||||
|
||||
### 7. Integrar
|
||||
|
||||
Cuando un secundario termina (rama pusheada + report verde):
|
||||
|
||||
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
|
||||
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
|
||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
||||
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
|
||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
||||
rama y el merge los lleva a master.
|
||||
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
|
||||
sigue, qué se integró, qué falta.
|
||||
|
||||
### 8. kitty vs Agent tool — cuándo cada uno
|
||||
|
||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
||||
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
|
||||
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
|
||||
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
|
||||
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
|
||||
|
||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | En su lugar |
|
||||
|---|---|---|
|
||||
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
|
||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
||||
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
|
||||
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
|
||||
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
|
||||
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
|
||||
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
|
||||
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
|
||||
|
||||
## Funciones del registry que usa este modo (grupo `orchestration`)
|
||||
|
||||
| Función | Para qué |
|
||||
|---|---|
|
||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
||||
|
||||
## Ejemplo end-to-end
|
||||
|
||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
||||
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
|
||||
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
|
||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
||||
|
||||
```bash
|
||||
# 1. Descomponer → 2 secundarios independientes:
|
||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
||||
# B) doc capability → worktree del padre (aislamiento (b))
|
||||
|
||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
||||
|
||||
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
|
||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
||||
# repo padre. Reporta tu progreso en esta terminal."
|
||||
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
|
||||
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
|
||||
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
|
||||
|
||||
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
|
||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
||||
|
||||
# 5. Seguir la flota (cada turno):
|
||||
./fn run list_claude_agents
|
||||
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
|
||||
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
|
||||
|
||||
# 7. Integrar (desde el working tree principal):
|
||||
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
|
||||
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
|
||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
||||
|
||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
||||
# flota vacía. Tarea grande hecha.
|
||||
```
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
||||
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
||||
lo que este modo **no** es; tenlas claras separadas.
|
||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
||||
worktree con una app nueva dentro.
|
||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
||||
report con evidencia ejecutable + gaps.
|
||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
||||
registry-first y delegan a `fn-constructor` igual que tú.
|
||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
||||
@@ -40,3 +40,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
|
||||
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
|
||||
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
|
||||
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Artefactos: termino colectivo
|
||||
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
|
||||
|
||||
Tipos de artefacto:
|
||||
|
||||
@@ -11,6 +11,7 @@ Tipos de artefacto:
|
||||
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
||||
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
||||
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
|
||||
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
|
||||
|
||||
Caracteristicas comunes de los artefactos:
|
||||
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
|
||||
@@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos:
|
||||
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
|
||||
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
|
||||
|
||||
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
|
||||
|
||||
### Cuando usar el termino
|
||||
|
||||
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
## Flow replay: guardar un flujo web como función reproducible
|
||||
|
||||
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
|
||||
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
|
||||
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
|
||||
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
|
||||
de un solo paso, no inflando funciones existentes.
|
||||
|
||||
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
|
||||
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
|
||||
|
||||
### El patrón: grabar → destilar → reproducir
|
||||
|
||||
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
|
||||
exportas el tramo a HAR (`query_mitm_flows --har`).
|
||||
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
|
||||
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
|
||||
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
|
||||
|
||||
| Nivel | Mecanismo | Cuándo |
|
||||
|---|---|---|
|
||||
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
|
||||
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
|
||||
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
|
||||
|
||||
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
|
||||
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
|
||||
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
|
||||
|
||||
### Flujo de autoría (cómo guardar una función-acción nueva)
|
||||
|
||||
1. Grabar el flujo y exportar el HAR del tramo.
|
||||
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
|
||||
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
|
||||
confirmación), descartando el resto.
|
||||
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
|
||||
reglas `extract` para los tokens que una respuesta genera y otro request consume.
|
||||
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
|
||||
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
|
||||
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
|
||||
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
|
||||
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
|
||||
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
|
||||
|
||||
### Reglas duras de seguridad
|
||||
|
||||
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
|
||||
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
|
||||
`{{param}}`.
|
||||
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
|
||||
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
|
||||
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
|
||||
explícito (`confirm=True` / `--yes`) antes de disparar.
|
||||
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
|
||||
JSON es dot-path simple, no JSONPath completo.
|
||||
|
||||
### Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
|
||||
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
|
||||
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
|
||||
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
|
||||
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
|
||||
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
|
||||
`fn-constructor`, no se escribe inline.
|
||||
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
|
||||
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
|
||||
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
|
||||
`fn run` / heredoc que importa).
|
||||
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
|
||||
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
|
||||
|
||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
|
||||
### Excepciones
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
## Reports: reportes de trabajo como artefacto local
|
||||
|
||||
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
|
||||
|
||||
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
|
||||
|
||||
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
|
||||
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
|
||||
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
|
||||
|
||||
### Qué NO es un report
|
||||
|
||||
| Es | Va a |
|
||||
|---|---|
|
||||
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
|
||||
| Norma operativa / convención | `.claude/rules/` (versionado) |
|
||||
| Bitácora cronológica libre | `docs/diary/` (versionado) |
|
||||
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
|
||||
|
||||
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
|
||||
|
||||
### Ubicación
|
||||
|
||||
Como cualquier artefacto, un report puede vivir en dos sitios:
|
||||
|
||||
| Ubicación | Para qué |
|
||||
|---|---|
|
||||
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
|
||||
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
|
||||
|
||||
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
|
||||
|
||||
### Convención de nombre
|
||||
|
||||
```
|
||||
NNNN-YYYY-MM-DD-slug-corto.md
|
||||
```
|
||||
|
||||
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
|
||||
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
|
||||
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
|
||||
|
||||
### Plantilla mínima
|
||||
|
||||
```markdown
|
||||
# Report NNNN — Título
|
||||
|
||||
- **Fecha:** DD/MM/AAAA
|
||||
- **Autor:** (agente/humano)
|
||||
- **Ámbito:** (dominio/app/módulo tocado)
|
||||
- **Estado:** done | parcial | bloqueado
|
||||
|
||||
## Resumen
|
||||
Qué se hizo y el resultado, en 2-4 líneas.
|
||||
|
||||
## Cambios
|
||||
Tabla o lista de lo tocado/creado, con el porqué.
|
||||
|
||||
## Verificación
|
||||
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
|
||||
|
||||
## Gaps / pendientes
|
||||
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
|
||||
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
|
||||
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
|
||||
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
|
||||
|
||||
### Relación con otras reglas y ADRs
|
||||
|
||||
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
|
||||
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
|
||||
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
|
||||
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
|
||||
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(CGO_ENABLED=1 go test *)",
|
||||
"Bash(sqlite3 *)"
|
||||
"Bash(sqlite3 *)",
|
||||
"Read(//home/enmanuel/.claude/**)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
|
||||
@@ -46,6 +46,13 @@ projects/*/
|
||||
vaults/*/
|
||||
!vaults/vault.yaml
|
||||
|
||||
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
|
||||
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
|
||||
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
|
||||
reports/*
|
||||
!reports/.gitkeep
|
||||
projects/*/reports/
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: ensure_project_gitignore
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "ensure_project_gitignore(project_dir: string) -> void"
|
||||
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
|
||||
tags: [git, gitignore, projects, infra]
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
|
||||
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
|
||||
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/ensure_project_gitignore.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/ensure_project_gitignore.sh
|
||||
|
||||
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
|
||||
ensure_project_gitignore projects/aurgi
|
||||
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
|
||||
# (o: updated: anadidas 2 lineas / ok: ya completo)
|
||||
```
|
||||
|
||||
Las lineas canonicas que la funcion garantiza son:
|
||||
|
||||
```
|
||||
apps/*/
|
||||
analysis/*/
|
||||
vaults/*
|
||||
!vaults/.gitkeep
|
||||
!vaults/vault.yaml
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
|
||||
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
|
||||
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
|
||||
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
|
||||
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
|
||||
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
|
||||
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
|
||||
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
|
||||
#
|
||||
# Esto evita que al hacer push del project se trackee por error el contenido de
|
||||
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
|
||||
# .claude/rules/projects.md.
|
||||
#
|
||||
# Uso:
|
||||
# ensure_project_gitignore <project_dir>
|
||||
#
|
||||
# Salida:
|
||||
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
|
||||
|
||||
ensure_project_gitignore() {
|
||||
local project_dir="$1"
|
||||
|
||||
if [[ -z "$project_dir" ]]; then
|
||||
echo "ensure_project_gitignore: se requiere project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$project_dir" ]]; then
|
||||
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local gitignore="$project_dir/.gitignore"
|
||||
|
||||
# Lineas canonicas que deben estar presentes (orden de referencia).
|
||||
local -a canonical=(
|
||||
"apps/*/"
|
||||
"analysis/*/"
|
||||
"vaults/*"
|
||||
"!vaults/.gitkeep"
|
||||
"!vaults/vault.yaml"
|
||||
)
|
||||
|
||||
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
|
||||
if [[ ! -f "$gitignore" ]]; then
|
||||
printf '%s\n' "${canonical[@]}" > "$gitignore"
|
||||
echo "ensure_project_gitignore: created $gitignore" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
|
||||
# preservando el contenido y el orden existentes.
|
||||
# Si el archivo no termina en newline, anadir uno antes de apendar para no
|
||||
# pegar la primera linea nueva al final de la ultima existente.
|
||||
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
|
||||
printf '\n' >> "$gitignore"
|
||||
fi
|
||||
|
||||
local line added=0
|
||||
for line in "${canonical[@]}"; do
|
||||
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
|
||||
if ! grep -Fxq -- "$line" "$gitignore"; then
|
||||
printf '%s\n' "$line" >> "$gitignore"
|
||||
added=$((added + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $added -gt 0 ]]; then
|
||||
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
|
||||
else
|
||||
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Si se invoca como script (no source), ejecutar la funcion.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
ensure_project_gitignore "$@"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: launch_claude_agent_kitty
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
|
||||
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
|
||||
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
|
||||
- name: directory
|
||||
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
|
||||
- name: prompt_file
|
||||
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
|
||||
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
|
||||
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/launch_claude_agent_kitty.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/launch_claude_agent_kitty.sh
|
||||
|
||||
# El orquestador prepara un worktree aislado y un archivo de prompt...
|
||||
git worktree add /tmp/orq_docs_wt -b orq/docs
|
||||
cat > /tmp/orq_docs.md <<'PROMPT'
|
||||
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
|
||||
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
|
||||
PROMPT
|
||||
|
||||
# ...y lanza un claude secundario en su propia kitty:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
|
||||
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
|
||||
```
|
||||
|
||||
O directo via `fn run`:
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
|
||||
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
|
||||
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
|
||||
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
|
||||
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
|
||||
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
|
||||
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
|
||||
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
|
||||
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
|
||||
# kitty, que el humano ve y puede retomar.
|
||||
#
|
||||
# Mecanismo:
|
||||
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
|
||||
# terminal padre (igual que reboot_all_claudes con setsid).
|
||||
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
|
||||
# interactiva viva para que el humano siga en esa terminal.
|
||||
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
|
||||
# confirmaciones). Riesgo asumido a proposito.
|
||||
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
|
||||
# el shell del orquestador.
|
||||
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
|
||||
# title (minusculas, no-alfanumerico -> '_').
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
launch_claude_agent_kitty() {
|
||||
# -----------------------------------------------------------------------
|
||||
# Ayuda / sin argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
cat <<'USAGE'
|
||||
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
|
||||
|
||||
Lanza un Claude Code secundario interactivo y persistente en su propia
|
||||
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
|
||||
--dangerously-skip-permissions (agente autonomo desatendido).
|
||||
|
||||
Argumentos (los 3 obligatorios):
|
||||
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
|
||||
directory Directorio de trabajo AISLADO donde arranca el claude
|
||||
secundario (worktree git, sub-repo, o dir cualquiera). Debe
|
||||
existir. Usa un dir aislado: dos claudes en el mismo working
|
||||
tree comparten HEAD y dispersan commits.
|
||||
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
|
||||
Debe existir y ser legible.
|
||||
|
||||
Ejemplo:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
|
||||
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
|
||||
USAGE
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Validacion de argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local title="$1"
|
||||
local directory="$2"
|
||||
local prompt_file="$3"
|
||||
|
||||
if [[ -z "$title" ]]; then
|
||||
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$directory" ]]; then
|
||||
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -r "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comprobar que kitty esta instalado.
|
||||
# -----------------------------------------------------------------------
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derivar el slug del title para el nombre del log.
|
||||
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
|
||||
# -----------------------------------------------------------------------
|
||||
local slug
|
||||
slug="$(printf '%s' "$title" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr -c 'a-z0-9' '_' \
|
||||
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
|
||||
[[ -z "$slug" ]] && slug="agent"
|
||||
|
||||
local log="/tmp/orq_${slug}_kitty.log"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
|
||||
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
|
||||
# exec zsh deja una shell viva cuando el claude termina.
|
||||
# -----------------------------------------------------------------------
|
||||
local inner
|
||||
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
|
||||
|
||||
setsid nohup kitty \
|
||||
--title "$title" \
|
||||
--directory "$directory" \
|
||||
zsh -ic "$inner" \
|
||||
>"$log" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
|
||||
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "launch_claude_agent_kitty: claude secundario lanzado."
|
||||
echo " title: $title"
|
||||
echo " directory: $directory"
|
||||
echo " prompt_file: $prompt_file"
|
||||
echo " log: $log"
|
||||
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_claude_agent_kitty "$@"
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: list_claude_agents
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
|
||||
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
|
||||
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--json"
|
||||
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
|
||||
- name: "--exclude-current"
|
||||
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
|
||||
- name: "-h|--help"
|
||||
desc: "Muestra el uso y termina con exit 0."
|
||||
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/list_claude_agents.sh"
|
||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
|
||||
./fn run list_claude_agents
|
||||
|
||||
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
|
||||
./fn run list_claude_agents --json
|
||||
|
||||
# Omitir la propia sesion (ver solo los agentes secundarios).
|
||||
./fn run list_claude_agents --exclude-current
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
||||
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
|
||||
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
|
||||
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
|
||||
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
|
||||
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
|
||||
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
|
||||
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
|
||||
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
|
||||
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
|
||||
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
|
||||
#
|
||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
|
||||
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
|
||||
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
|
||||
# - etime via ps -o etime= -p <PID>.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
list_claude_agents() {
|
||||
local output="table" # table | json
|
||||
local exclude_current=0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
output="json"
|
||||
;;
|
||||
--exclude-current)
|
||||
exclude_current=1
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: list_claude_agents [--json] [--exclude-current]
|
||||
|
||||
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
|
||||
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
|
||||
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
|
||||
(claude --resume <sessionId>) o decidir cual parar.
|
||||
|
||||
Opciones:
|
||||
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
|
||||
kitty_pid) en vez de la tabla. Util para parsear.
|
||||
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
|
||||
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
|
||||
la sesion actual se marca con self=true / SELF en la tabla.
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
list_claude_agents
|
||||
list_claude_agents --json
|
||||
list_claude_agents --exclude-current
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
||||
# Se usa tanto para --exclude-current como para marcar la fila propia.
|
||||
# -----------------------------------------------------------------------
|
||||
local current_claude_pid=""
|
||||
local walk="$$"
|
||||
local guard=0
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
local comm=""
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
current_claude_pid="$walk"
|
||||
break
|
||||
fi
|
||||
# campo 4 de /proc/<pid>/stat es el PPID
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Recolectar las sesiones vivas y validarlas.
|
||||
# -----------------------------------------------------------------------
|
||||
local sessions_dir="$HOME/.claude/sessions"
|
||||
local pids=""
|
||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Arrays paralelos con la flota validada.
|
||||
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
|
||||
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
# Validacion 1: el proceso debe seguir vivo.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validacion 2: debe existir su JSON de sesion.
|
||||
local json="$sessions_dir/$pid.json"
|
||||
if [[ ! -f "$json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
||||
# Salida: lineas "clave=valor" en orden fijo.
|
||||
local parsed=""
|
||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
print("sessionId=" + str(d.get("sessionId", "")))
|
||||
print("cwd=" + str(d.get("cwd", "")))
|
||||
print("status=" + str(d.get("status", "")))
|
||||
print("procStart=" + str(d.get("procStart", "")))
|
||||
PY
|
||||
)"
|
||||
[[ -z "$parsed" ]] && continue
|
||||
|
||||
local sid cwd status proc_start_json
|
||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
||||
|
||||
[[ -z "$sid" ]] && continue
|
||||
|
||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
||||
# con el campo 22 de /proc/<PID>/stat.
|
||||
local proc_start_real=""
|
||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
||||
# JSON huerfano de un PID reciclado: omitir.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Omitir la propia sesion si se pidio --exclude-current.
|
||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
||||
local kitty_pid=""
|
||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
||||
|
||||
# etime: tiempo transcurrido desde que arranco el proceso.
|
||||
local etime=""
|
||||
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
||||
|
||||
# Marca de sesion propia (solo relevante cuando NO se excluye).
|
||||
local self="false"
|
||||
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
self="true"
|
||||
fi
|
||||
|
||||
a_pid+=("$pid")
|
||||
a_status+=("${status:-?}")
|
||||
a_etime+=("${etime:-?}")
|
||||
a_kitty+=("${kitty_pid:-}")
|
||||
a_sid+=("$sid")
|
||||
a_cwd+=("${cwd:-?}")
|
||||
a_self+=("$self")
|
||||
done
|
||||
|
||||
local total="${#a_pid[@]}"
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida JSON.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ "$output" == "json" ]]; then
|
||||
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
|
||||
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
|
||||
# el heredoc con el stdin del pipe.
|
||||
local i
|
||||
{
|
||||
for ((i = 0; i < total; i++)); do
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]}" \
|
||||
"${a_self[$i]}"
|
||||
done
|
||||
} | python3 -c '
|
||||
import json, sys
|
||||
out = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
|
||||
out.append({
|
||||
"pid": int(pid) if pid.isdigit() else pid,
|
||||
"session_id": sid,
|
||||
"cwd": cwd,
|
||||
"status": status,
|
||||
"etime": etime,
|
||||
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
|
||||
"self": (self_ == "true"),
|
||||
})
|
||||
print(json.dumps(out, indent=2))
|
||||
'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida tabla legible.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "list_claude_agents — claudes vivos: ${total}"
|
||||
echo
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"--------" "-------" "------------" "---------" "------" \
|
||||
"--------------------------------------" "---"
|
||||
|
||||
local i
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local self_mark=""
|
||||
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]:-(none)}" \
|
||||
"${self_mark:--}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
list_claude_agents "$@"
|
||||
fi
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: reboot_all_claudes
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
|
||||
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
|
||||
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--go"
|
||||
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
|
||||
- name: "--yes"
|
||||
desc: "Alias de --go."
|
||||
- name: "--resume-mode <resume|continue|none>"
|
||||
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
|
||||
- name: "--exclude-current"
|
||||
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
|
||||
- name: "--only-idle"
|
||||
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
|
||||
- name: "-h|--help"
|
||||
desc: "Muestra el uso y termina."
|
||||
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/reboot_all_claudes.sh"
|
||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dry-run (default seguro): ver el plan sin tocar nada.
|
||||
reboot_all_claudes
|
||||
|
||||
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
|
||||
reboot_all_claudes --go --exclude-current
|
||||
|
||||
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
|
||||
reboot_all_claudes --go --only-idle
|
||||
|
||||
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
|
||||
reboot_all_claudes --go --resume-mode none
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
|
||||
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
|
||||
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
||||
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
|
||||
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
|
||||
Executable
+356
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env bash
|
||||
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
|
||||
# corriendo y las relanza retomando exactamente la sesion que tenian
|
||||
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
|
||||
# tocar nada. Usar --go para ejecutarlo de verdad.
|
||||
#
|
||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
|
||||
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
|
||||
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
|
||||
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
reboot_all_claudes() {
|
||||
local mode="dry" # dry | go
|
||||
local resume_mode="resume" # resume | continue | none
|
||||
local exclude_current=0
|
||||
local only_idle=0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--go|--yes)
|
||||
mode="go"
|
||||
;;
|
||||
--resume-mode)
|
||||
shift
|
||||
resume_mode="${1:-}"
|
||||
case "$resume_mode" in
|
||||
resume|continue|none) ;;
|
||||
*)
|
||||
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
--exclude-current)
|
||||
exclude_current=1
|
||||
;;
|
||||
--only-idle)
|
||||
only_idle=1
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: reboot_all_claudes [opciones]
|
||||
|
||||
Cierra todas las terminales con una sesion de Claude Code corriendo y las
|
||||
relanza retomando la misma sesion (claude --resume <sessionId>).
|
||||
|
||||
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
|
||||
y NO mata ni relanza nada.
|
||||
|
||||
Opciones:
|
||||
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
|
||||
--resume-mode <modo> resume (default) | continue | none.
|
||||
resume -> claude --resume <sessionId>
|
||||
continue -> claude --continue
|
||||
none -> claude (sesion nueva en el mismo cwd)
|
||||
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
|
||||
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
reboot_all_claudes # dry-run, ve el plan
|
||||
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
||||
# -----------------------------------------------------------------------
|
||||
local current_claude_pid=""
|
||||
if [[ "$exclude_current" -eq 1 ]]; then
|
||||
local walk="$$"
|
||||
local guard=0
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
local comm=""
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
current_claude_pid="$walk"
|
||||
break
|
||||
fi
|
||||
# campo 4 de /proc/<pid>/stat es el PPID
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Recolectar las sesiones vivas y validarlas.
|
||||
# -----------------------------------------------------------------------
|
||||
local sessions_dir="$HOME/.claude/sessions"
|
||||
local pids=""
|
||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Arrays paralelos con el plan validado.
|
||||
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
|
||||
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
# Validacion 1: el proceso debe seguir vivo.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validacion 2: debe existir su JSON de sesion.
|
||||
local json="$sessions_dir/$pid.json"
|
||||
if [[ ! -f "$json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
||||
# Salida: lineas "clave=valor" en orden fijo.
|
||||
local parsed=""
|
||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
print("sessionId=" + str(d.get("sessionId", "")))
|
||||
print("cwd=" + str(d.get("cwd", "")))
|
||||
print("status=" + str(d.get("status", "")))
|
||||
print("procStart=" + str(d.get("procStart", "")))
|
||||
PY
|
||||
)"
|
||||
[[ -z "$parsed" ]] && continue
|
||||
|
||||
local sid cwd status proc_start_json
|
||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
||||
|
||||
[[ -z "$sid" ]] && continue
|
||||
|
||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
||||
# con el campo 22 de /proc/<PID>/stat.
|
||||
local proc_start_real=""
|
||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
||||
# JSON huerfano de un PID reciclado: omitir.
|
||||
continue
|
||||
fi
|
||||
|
||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
||||
local kitty_pid=""
|
||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
||||
|
||||
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
|
||||
# flag de resume/continue previo para no duplicarlos.
|
||||
local raw_cmd=""
|
||||
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
|
||||
local -a kept_flags=()
|
||||
local first=1 tok skipnext=0
|
||||
while IFS= read -r tok; do
|
||||
[[ -z "$tok" ]] && continue
|
||||
if [[ "$first" -eq 1 ]]; then
|
||||
# argv0 (la ruta o nombre de claude) — descartar.
|
||||
first=0
|
||||
continue
|
||||
fi
|
||||
if [[ "$skipnext" -eq 1 ]]; then
|
||||
skipnext=0
|
||||
continue
|
||||
fi
|
||||
case "$tok" in
|
||||
--resume|--continue|-r|-c)
|
||||
# Resume/continue previos: omitir (y su posible valor para --resume).
|
||||
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
|
||||
skipnext=1
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
kept_flags+=("$tok")
|
||||
done <<< "$raw_cmd"
|
||||
|
||||
# Construir la estrategia de resume.
|
||||
local -a launch_args=()
|
||||
case "$resume_mode" in
|
||||
resume) launch_args=("--resume" "$sid") ;;
|
||||
continue) launch_args=("--continue") ;;
|
||||
none) launch_args=() ;;
|
||||
esac
|
||||
launch_args+=("${kept_flags[@]}")
|
||||
|
||||
# Comando claude final (para mostrar y ejecutar).
|
||||
local claude_cmd="claude"
|
||||
local a
|
||||
for a in "${launch_args[@]}"; do
|
||||
claude_cmd+=" $(printf '%q' "$a")"
|
||||
done
|
||||
|
||||
# Decidir si se omite esta sesion del plan.
|
||||
local skip=0 skipreason=""
|
||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
skip=1
|
||||
skipreason="terminal actual (--exclude-current)"
|
||||
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
|
||||
skip=1
|
||||
skipreason="busy (--only-idle)"
|
||||
fi
|
||||
|
||||
plan_pid+=("$pid")
|
||||
plan_kitty+=("${kitty_pid:-}")
|
||||
plan_status+=("${status:-?}")
|
||||
plan_cwd+=("${cwd:-?}")
|
||||
plan_sid+=("$sid")
|
||||
plan_cmd+=("$claude_cmd")
|
||||
plan_skip+=("$skip")
|
||||
plan_skipreason+=("$skipreason")
|
||||
done
|
||||
|
||||
local total="${#plan_pid[@]}"
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Imprimir el plan (siempre, tanto en dry-run como en --go).
|
||||
# -----------------------------------------------------------------------
|
||||
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
|
||||
echo
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
|
||||
|
||||
local i busy_count=0 act_count=0
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local accion="reinic"
|
||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
||||
accion="OMITE"
|
||||
else
|
||||
act_count=$((act_count + 1))
|
||||
fi
|
||||
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
|
||||
"${plan_pid[$i]}" \
|
||||
"${plan_kitty[$i]:-(none)}" \
|
||||
"${plan_status[$i]}" \
|
||||
"$accion" \
|
||||
"${plan_sid[$i]}" \
|
||||
"${plan_cwd[$i]}"
|
||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
||||
echo " -> omitida: ${plan_skipreason[$i]}"
|
||||
else
|
||||
echo " -> ${plan_cmd[$i]}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Aviso explicito de sesiones busy que SI se van a reiniciar.
|
||||
if [[ "$only_idle" -eq 0 ]]; then
|
||||
local warned=0
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
|
||||
if [[ "$warned" -eq 0 ]]; then
|
||||
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
|
||||
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
|
||||
warned=1
|
||||
fi
|
||||
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
|
||||
fi
|
||||
done
|
||||
[[ "$warned" -eq 1 ]] && echo
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# DRY-RUN: parar aqui.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ "$mode" == "dry" ]]; then
|
||||
echo "DRY-RUN: no se ha matado ni relanzado nada."
|
||||
echo "Para ejecutar de verdad: reboot_all_claudes --go"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$act_count" -eq 0 ]]; then
|
||||
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MODO --go: construir un script desacoplado que mata las ventanas y
|
||||
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
|
||||
# de la propia terminal (que es una de las victimas).
|
||||
# -----------------------------------------------------------------------
|
||||
local ts script log
|
||||
ts="$(date +%s)"
|
||||
script="/tmp/reboot_all_claudes.$$.$ts.sh"
|
||||
log="/tmp/reboot_all_claudes.$ts.log"
|
||||
|
||||
{
|
||||
echo '#!/usr/bin/env bash'
|
||||
echo 'set -uo pipefail'
|
||||
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
|
||||
echo 'sleep 1'
|
||||
echo
|
||||
for ((i = 0; i < total; i++)); do
|
||||
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
|
||||
local kp="${plan_kitty[$i]}"
|
||||
local cp="${plan_pid[$i]}"
|
||||
local cwd="${plan_cwd[$i]}"
|
||||
local cmd="${plan_cmd[$i]}"
|
||||
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
|
||||
if [[ -n "$kp" ]]; then
|
||||
# Cerrar la ventana kitty limpia con SIGTERM.
|
||||
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
|
||||
else
|
||||
# Sin kitty: matar el propio claude.
|
||||
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
|
||||
fi
|
||||
# Relanzar en una kitty nueva, detached, en el cwd correcto.
|
||||
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
|
||||
# claude queda una shell interactiva viva.
|
||||
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
|
||||
"$cwd" "${cmd}; exec zsh"
|
||||
echo
|
||||
done
|
||||
echo 'exit 0'
|
||||
} > "$script"
|
||||
|
||||
chmod +x "$script"
|
||||
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
|
||||
setsid bash "$script" </dev/null >>"$log" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
reboot_all_claudes "$@"
|
||||
fi
|
||||
@@ -3,14 +3,15 @@ name: full_git_pull
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "full_git_pull() -> stdout: tabla resumen"
|
||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
|
||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
|
||||
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
||||
uses_functions:
|
||||
- discover_git_repos_bash_infra
|
||||
- git_pull_with_stash_bash_infra
|
||||
- clone_project_subrepos_bash_pipelines
|
||||
- pass_get_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
|
||||
|
||||
## Notas
|
||||
|
||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
|
||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
|
||||
|
||||
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
|
||||
|
||||
@@ -149,6 +149,42 @@ full_git_pull() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
|
||||
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
|
||||
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
|
||||
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
|
||||
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
|
||||
echo "" >&2
|
||||
echo "[6/6] Reclonando sub-repos de projects..." >&2
|
||||
local reclone_summary=" [skip] sin projects o registry.db"
|
||||
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||
export FN_REGISTRY_ROOT="$registry_root"
|
||||
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
|
||||
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
|
||||
local any_cloned=0
|
||||
if [[ -f "$clone_script" ]]; then
|
||||
while IFS= read -r proj_id; do
|
||||
[[ -z "$proj_id" ]] && continue
|
||||
local clone_out
|
||||
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
|
||||
if echo "$clone_out" | grep -q '\[cloned\]'; then
|
||||
any_cloned=1
|
||||
echo " $proj_id: nuevos sub-repos clonados" >&2
|
||||
fi
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
|
||||
if [[ "$any_cloned" -eq 1 ]]; then
|
||||
echo " re-index tras clonado..." >&2
|
||||
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
|
||||
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
|
||||
else
|
||||
reclone_summary=" OK: nada que clonar (todo presente)"
|
||||
fi
|
||||
else
|
||||
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
|
||||
fi
|
||||
fi
|
||||
echo " $reclone_summary" >&2
|
||||
|
||||
# --- Resumen ---
|
||||
echo ""
|
||||
echo "===== RESUMEN full_git_pull ====="
|
||||
@@ -171,6 +207,9 @@ full_git_pull() {
|
||||
echo ""
|
||||
echo "fn sync:"
|
||||
echo "$sync_summary"
|
||||
echo ""
|
||||
echo "Reclonado sub-repos de projects:"
|
||||
echo "$reclone_summary"
|
||||
|
||||
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
|
||||
@@ -3,10 +3,10 @@ name: full_git_push
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
|
||||
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
||||
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
||||
tags: [git, push, sync, registry, pipeline, pendiente-usar]
|
||||
uses_functions:
|
||||
- discover_git_repos_bash_infra
|
||||
@@ -14,6 +14,7 @@ uses_functions:
|
||||
- git_auto_commit_dirty_bash_infra
|
||||
- git_push_if_ahead_bash_infra
|
||||
- ensure_repo_synced_bash_infra
|
||||
- ensure_project_gitignore_bash_infra
|
||||
- pass_get_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
|
||||
## Notas
|
||||
|
||||
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
|
||||
|
||||
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
|
||||
source "$INFRA_DIR/git_push_if_ahead.sh"
|
||||
source "$INFRA_DIR/pass_get.sh"
|
||||
source "$INFRA_DIR/ensure_repo_synced.sh"
|
||||
source "$INFRA_DIR/ensure_project_gitignore.sh"
|
||||
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
|
||||
|
||||
full_git_push() {
|
||||
@@ -65,6 +66,32 @@ full_git_push() {
|
||||
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
|
||||
echo " [warn] fallo inicializando $d" >&2
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
|
||||
|
||||
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
|
||||
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
|
||||
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
|
||||
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
|
||||
# canonico que ensure_project_gitignore garantiza ANTES del push para no
|
||||
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
|
||||
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
|
||||
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||
while IFS= read -r proj_dir; do
|
||||
[[ -z "$proj_dir" ]] && continue
|
||||
local pd="$registry_root/$proj_dir"
|
||||
[[ -d "$pd" ]] || continue
|
||||
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
|
||||
ensure_project_gitignore "$pd" || \
|
||||
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
|
||||
if [[ -d "$pd/.git" ]]; then
|
||||
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
|
||||
echo " fix-remote: $pd (.git sin origin)" >&2
|
||||
else
|
||||
echo " auto-init project: $pd" >&2
|
||||
fi
|
||||
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
|
||||
echo " [warn] fallo inicializando project $pd" >&2
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
|
||||
fi
|
||||
else
|
||||
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
|
||||
fi
|
||||
@@ -72,28 +99,13 @@ full_git_push() {
|
||||
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
|
||||
fi
|
||||
|
||||
# Redescubrir repos tras posibles inicializaciones
|
||||
# Redescubrir repos tras posibles inicializaciones.
|
||||
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
|
||||
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
|
||||
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
|
||||
# como un repo mas. No necesita tratamiento especial.
|
||||
repos=$(discover_git_repos "$registry_root")
|
||||
|
||||
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
|
||||
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
|
||||
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
|
||||
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
|
||||
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
|
||||
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
|
||||
local claude_repo=""
|
||||
if [[ -L "$HOME/.claude/settings.json" ]]; then
|
||||
local _claude_settings_real
|
||||
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
|
||||
if [[ -n "$_claude_settings_real" ]]; then
|
||||
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
|
||||
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
|
||||
repos="$repos"$'\n'"$claude_repo"
|
||||
fi
|
||||
|
||||
# --- Paso 2: Escanear secrets ---
|
||||
echo "" >&2
|
||||
echo "[2/6] Escaneando secrets en dirty trees..." >&2
|
||||
|
||||
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
|
||||
doctorDod(r, jsonOut)
|
||||
case "e2e-coverage":
|
||||
doctorE2ECoverage(r, jsonOut)
|
||||
case "projects":
|
||||
doctorProjects(r, jsonOut)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
||||
doctorUsage()
|
||||
@@ -100,6 +102,7 @@ Subcommands:
|
||||
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
|
||||
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
|
||||
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
|
||||
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
|
||||
|
||||
Flags:
|
||||
--json Salida JSON (para scripting/agentes)
|
||||
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
|
||||
fmt.Printf("\nOverall ML environment: %s\n", overall)
|
||||
}
|
||||
|
||||
func doctorProjects(root string, jsonOut bool) {
|
||||
rows, err := infra.AuditProjectsCoverage(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
orphans, oerr := infra.FindOrphanProjectRefs(root)
|
||||
if oerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if jsonOut {
|
||||
emit(map[string]any{
|
||||
"coverage": rows,
|
||||
"orphan_project_ids": orphans,
|
||||
})
|
||||
return
|
||||
}
|
||||
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
|
||||
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||
}
|
||||
|
||||
func emit(v any) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
id: "0171"
|
||||
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
|
||||
status: pendiente
|
||||
type: enhancement
|
||||
domain:
|
||||
- registry-quality
|
||||
- infra
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0166"]
|
||||
created: 2026-06-10
|
||||
updated: 2026-06-10
|
||||
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
|
||||
---
|
||||
|
||||
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
|
||||
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
|
||||
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
|
||||
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
|
||||
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
|
||||
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0171 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta (riesgo de pérdida de datos) |
|
||||
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
|
||||
|
||||
## Contexto
|
||||
|
||||
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
|
||||
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
|
||||
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
|
||||
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
|
||||
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
|
||||
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
|
||||
|
||||
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
|
||||
|
||||
| Project | Repo Gitea | Riesgo |
|
||||
|---|---|---|
|
||||
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
|
||||
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
|
||||
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
|
||||
|
||||
Dos problemas estructurales quedan abiertos:
|
||||
|
||||
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
|
||||
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
|
||||
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
|
||||
|
||||
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
|
||||
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
|
||||
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
|
||||
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
|
||||
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
|
||||
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
|
||||
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
|
||||
re-clonar automáticamente todo su árbol sin adivinar nada.
|
||||
|
||||
## Propuesta
|
||||
|
||||
### 1. Manifest de sub-repos por project
|
||||
|
||||
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
|
||||
|
||||
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
|
||||
|
||||
```yaml
|
||||
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
|
||||
subrepos:
|
||||
- kind: analysis # app | analysis
|
||||
name: venta_web
|
||||
path: analysis/venta_web
|
||||
repo: dataforge/venta_web
|
||||
url: https://gitea-.../dataforge/venta_web
|
||||
- kind: analysis
|
||||
name: sale_prices_comprobation
|
||||
path: analysis/sale_prices_comprobation
|
||||
repo: dataforge/sale_prices_comprobation
|
||||
url: https://gitea-.../dataforge/sale_prices_comprobation
|
||||
```
|
||||
|
||||
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
|
||||
|
||||
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
|
||||
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
|
||||
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
|
||||
|
||||
### 2. Generación y mantenimiento del manifest
|
||||
|
||||
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
|
||||
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
|
||||
- Escribe/actualiza `subrepos.yaml`.
|
||||
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
|
||||
|
||||
### 3. Re-clonado desde el manifest en `/full-git-pull`
|
||||
|
||||
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
|
||||
los hijos que falten** (`url` → `path`). Así, en un PC nuevo: clonar `dataforge/<project>` →
|
||||
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
|
||||
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
|
||||
|
||||
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
|
||||
|
||||
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
|
||||
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
|
||||
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
|
||||
clonar). Salida `--json`. Cero hallazgos = sano.
|
||||
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
|
||||
dataforge <p> master "init: project <p>"`.
|
||||
|
||||
### 5. Backfill inicial
|
||||
|
||||
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
|
||||
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
|
||||
mano un `project.md` mínimo (divergiría del real).
|
||||
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
|
||||
generar su `subrepos.yaml` con la función del punto 2.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
|
||||
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
|
||||
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
|
||||
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
|
||||
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
|
||||
|
||||
## Decisiones abiertas
|
||||
|
||||
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
|
||||
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
|
||||
`fn index`; preferible en push).
|
||||
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
|
||||
|
||||
## Notas
|
||||
|
||||
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
|
||||
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
|
||||
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
|
||||
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
|
||||
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
|
||||
|
||||
## Estado de implementación (10/06/2026)
|
||||
|
||||
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
|
||||
función). Cambios:
|
||||
|
||||
**Funciones nuevas:**
|
||||
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
|
||||
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
|
||||
para no trackear el contenido de los sub-repos hijos.
|
||||
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
|
||||
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
|
||||
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
|
||||
|
||||
**Integraciones:**
|
||||
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
|
||||
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
|
||||
aurgi/obsidian/osint.
|
||||
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
|
||||
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
|
||||
el árbol entero.
|
||||
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
|
||||
problemas**.
|
||||
|
||||
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
|
||||
en sus `project.md`.
|
||||
|
||||
### Pendientes (no bloquean el núcleo)
|
||||
|
||||
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
|
||||
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
|
||||
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
|
||||
subidos a Gitea — mismo caso que aurgi):
|
||||
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
|
||||
matrix_admin_panel, matrix_client_pc)
|
||||
- `imagegen` (image_to_3d_studio)
|
||||
- `osint_graph` (graph_explorer)
|
||||
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
|
||||
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
|
||||
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
|
||||
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
|
||||
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
|
||||
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
|
||||
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
|
||||
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
id: "0172"
|
||||
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- osint
|
||||
- frontend
|
||||
scope: app-scoped
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0171"]
|
||||
created: 2026-06-10
|
||||
updated: 2026-06-10
|
||||
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
|
||||
---
|
||||
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0172 |
|
||||
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
|
||||
| **Project** | osint (`projects/osint/`) |
|
||||
|
||||
## Contexto
|
||||
|
||||
El project `osint` guarda sus investigaciones en el vault de Obsidian
|
||||
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
|
||||
|
||||
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
|
||||
`lugares/` (10), `dominios/` (1), `casos/` (1).
|
||||
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
|
||||
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
|
||||
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
|
||||
`Documentos` conectan unas fichas con otras (y con sus attachments).
|
||||
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
|
||||
embebidos en las notas con `![[...]]`.
|
||||
|
||||
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
|
||||
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
|
||||
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
|
||||
y no muestran ni grafo de nodos ni imágenes inline.
|
||||
|
||||
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
|
||||
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
|
||||
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
|
||||
la app se implementa cuando haya suficiente material que justifique la inversión.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
|
||||
obligatoria en v1), permita:
|
||||
|
||||
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
|
||||
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
|
||||
filtro de tipos visibles, búsqueda de nodo.
|
||||
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
|
||||
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
|
||||
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
|
||||
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
|
||||
|
||||
## Arquitectura propuesta
|
||||
|
||||
```
|
||||
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
|
||||
app.md frontmatter de registro (framework: react-vite-mantine)
|
||||
server/ backend Python (lee el vault, sirve JSON + attachments)
|
||||
main.py FastAPI o stdlib http
|
||||
frontend/ React + Vite + Mantine + sigma.js
|
||||
src/
|
||||
views/GraphView.tsx sigma.js + graphology
|
||||
views/TablesView.tsx Mantine DataTable filtrable por tipo
|
||||
views/NodeCard.tsx ficha + galería de attachments
|
||||
```
|
||||
|
||||
### Backend (Python — máximo reuso del grupo `obsidian`)
|
||||
|
||||
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
|
||||
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
|
||||
el parseo.
|
||||
|
||||
Funciones del registry a reutilizar:
|
||||
|
||||
| Función | Uso en la app |
|
||||
|---|---|
|
||||
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
|
||||
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
|
||||
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
|
||||
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
|
||||
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
|
||||
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
|
||||
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
|
||||
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
|
||||
|
||||
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
|
||||
|
||||
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
|
||||
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
|
||||
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
|
||||
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
|
||||
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
|
||||
("No indexa el grafo agregado") — esta función la cierra.
|
||||
|
||||
Endpoints HTTP (JSON salvo el de attachments):
|
||||
|
||||
| Método | Ruta | Devuelve |
|
||||
|---|---|---|
|
||||
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
|
||||
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
|
||||
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
|
||||
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
|
||||
| GET | `/api/search?q=...` | nodos que matchean |
|
||||
|
||||
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
|
||||
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
|
||||
exponer a red. No es un service desplegable a VPS.
|
||||
|
||||
### Frontend (React + Vite + Mantine + sigma.js)
|
||||
|
||||
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
|
||||
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
|
||||
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
|
||||
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
|
||||
con toggles de tipos visibles y caja de búsqueda.
|
||||
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
|
||||
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
|
||||
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
|
||||
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
|
||||
(imágenes con lightbox; PDFs como enlace/embed).
|
||||
|
||||
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
|
||||
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
|
||||
|
||||
## Decisiones abiertas
|
||||
|
||||
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
|
||||
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
|
||||
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
|
||||
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
|
||||
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
|
||||
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
|
||||
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
|
||||
filesystem? v1: botón refrescar (simple). Watcher si molesta.
|
||||
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
|
||||
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
|
||||
estilo atenuado, toggle para ocultar.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
|
||||
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
|
||||
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
|
||||
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
|
||||
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
|
||||
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
|
||||
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
|
||||
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
|
||||
|
||||
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
|
||||
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
|
||||
|
||||
## Notas
|
||||
|
||||
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
|
||||
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
|
||||
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
|
||||
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
|
||||
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
|
||||
`obsidian`.
|
||||
|
||||
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
|
||||
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
|
||||
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
|
||||
datos sensibles del vault.
|
||||
|
||||
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
|
||||
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
|
||||
@@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func
|
||||
- `integrity.md` — Reglas de integridad y referencias cruzadas
|
||||
- `architecture.md` — Visión general del sistema
|
||||
- `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting)
|
||||
- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué)
|
||||
- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md)
|
||||
|
||||
## Tablas
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR 0006 — `reports/` como artefacto local para reportes de trabajo
|
||||
|
||||
- **Fecha:** 2026-06-06
|
||||
- **Estado:** accepted
|
||||
|
||||
## Contexto
|
||||
|
||||
Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas:
|
||||
|
||||
1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco.
|
||||
2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat.
|
||||
3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps.
|
||||
|
||||
Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia.
|
||||
|
||||
Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre.
|
||||
|
||||
## Decisión
|
||||
|
||||
Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**:
|
||||
|
||||
1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults.
|
||||
2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/<p>/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar.
|
||||
3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`.
|
||||
4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria.
|
||||
|
||||
Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia.
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea.
|
||||
- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR.
|
||||
- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable.
|
||||
- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos.
|
||||
- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`.
|
||||
- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`.
|
||||
- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea).
|
||||
- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`.
|
||||
- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte).
|
||||
- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea.
|
||||
|
||||
## Relación con otras reglas y ADRs
|
||||
|
||||
- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía.
|
||||
- `.claude/rules/reports.md` — convención operativa derivada de este ADR.
|
||||
- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado).
|
||||
- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps.
|
||||
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps).
|
||||
- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea.
|
||||
@@ -63,3 +63,4 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
|
||||
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
|
||||
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
|
||||
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
|
||||
| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted |
|
||||
|
||||
@@ -24,6 +24,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
|
||||
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
|
||||
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
|
||||
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
|
||||
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
|
||||
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
|
||||
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
|
||||
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
|
||||
@@ -38,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
|
||||
@@ -47,6 +50,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
||||
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
||||
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||
| [obsidian](obsidian.md) | 14 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve. Sin app GUI |
|
||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Flow Replay — Guardar un flujo web como función reproducible
|
||||
|
||||
Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo
|
||||
una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una
|
||||
**función del registry reproducible sin intervención**. Materializa la doctrina del issue
|
||||
0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`.
|
||||
|
||||
Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico,
|
||||
`flow-replay` lo **destila y reproduce**.
|
||||
|
||||
## El patrón: grabar → destilar → reproducir
|
||||
|
||||
Tres fases, con una jerarquía de reproducción de más barato a más caro:
|
||||
|
||||
```
|
||||
Fase 0 — GRABAR (una vez, siempre con browser + proxy)
|
||||
web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR
|
||||
(funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har)
|
||||
|
||||
Fase 1 — DESTILAR (del HAR a una secuencia de requests)
|
||||
har_filter_flows → descarta estáticos/analytics, deja los flujos que importan
|
||||
har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url,
|
||||
headers, cookies, body), aislando los datos de auth
|
||||
|
||||
Fase 2 — REPRODUCIR, en orden de preferencia:
|
||||
Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO.
|
||||
Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente,
|
||||
challenge JS o WAF con fingerprint de navegador. Reutiliza
|
||||
cdp_extract_recipe + cdp_save_storage_state (ver Fronteras).
|
||||
Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado
|
||||
(cdp_click_xy_human, cdp_move_mouse_human del dominio browser).
|
||||
```
|
||||
|
||||
La función-acción concreta que guardas en el registry (`reboot_<panel>_server`,
|
||||
`login_<panel>`, etc.) envuelve el nivel que funcione: idealmente una llamada a
|
||||
`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde
|
||||
`pass`/vault.
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. |
|
||||
| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. |
|
||||
| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. |
|
||||
|
||||
## Ejemplo canónico end-to-end
|
||||
|
||||
Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se
|
||||
encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2:
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity.har_filter_flows import har_filter_flows
|
||||
from cybersecurity.har_extract_calls import har_extract_calls
|
||||
from infra.http_replay_sequence import http_replay_sequence
|
||||
|
||||
# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har
|
||||
import json
|
||||
har = json.load(open(os.path.expanduser("~/sesion.har")))
|
||||
|
||||
# 2. Destilar: del ruido a la secuencia mínima
|
||||
flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel
|
||||
calls = har_extract_calls(flows) # call specs reproducibles
|
||||
|
||||
# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST.
|
||||
res = http_replay_sequence(
|
||||
calls,
|
||||
params={"server_id": "vps-42"}, # parametrizado por el caller
|
||||
extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}],
|
||||
verify_tls=True,
|
||||
)
|
||||
print(res["status"], [s["status_code"] for s in res["steps"]])
|
||||
```
|
||||
|
||||
Una vez validado, el flujo se promueve a una función-acción nombrada del registry
|
||||
(p. ej. `reboot_vps_server_<panel>`) que internamente llama a `http_replay_sequence`
|
||||
con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde
|
||||
`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza
|
||||
con un HAR ya existente.
|
||||
- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo
|
||||
qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`)
|
||||
y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección
|
||||
automática de tokens/CSRF sería una función nueva del grupo, no una ampliación.
|
||||
- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el
|
||||
patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un
|
||||
"action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo
|
||||
formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser`
|
||||
para saltarse el login. No se construye por adelantado (KISS / registry-first).
|
||||
- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El
|
||||
grupo nunca los hardcodea ni los persiste.
|
||||
|
||||
## Gotchas (seguridad — leer antes de usar)
|
||||
|
||||
- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto —
|
||||
gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de
|
||||
`har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`.
|
||||
- **Secretos a `pass`/vault**, nunca en el código de la función-acción.
|
||||
- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es
|
||||
destructivo. La función-acción debe pedir confirmación o exponer un flag explícito
|
||||
(`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible.
|
||||
- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que
|
||||
exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado).
|
||||
- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción
|
||||
JSON es dot-path simple (`a.b.0.c`), no JSONPath completo.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página.
|
||||
- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Capability group: `hoppscotch`
|
||||
|
||||
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
|
||||
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
|
||||
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
|
||||
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
|
||||
|
||||
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
|
||||
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
|
||||
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
|
||||
|
||||
## Stack self-host
|
||||
|
||||
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
|
||||
|
||||
| Servicio | URL | Para qué |
|
||||
|---|---|---|
|
||||
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
|
||||
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
|
||||
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
|
||||
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
|
||||
|
||||
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
|
||||
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
|
||||
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
|
||||
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
|
||||
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
|
||||
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
|
||||
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
|
||||
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
|
||||
|
||||
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
from infra.hoppscotch_set_environment import hoppscotch_set_environment
|
||||
|
||||
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
|
||||
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
|
||||
|
||||
tok = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
|
||||
hoppscotch_set_environment(TEAM, "registry", [
|
||||
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
|
||||
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
|
||||
], access_token=tok)
|
||||
|
||||
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
|
||||
hoppscotch_create_request(
|
||||
COLL, "GET", "<<baseURL>>/api/status",
|
||||
title="status", headers={"Accept": "application/json"},
|
||||
team_id=TEAM, access_token=tok,
|
||||
)
|
||||
```
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
|
||||
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
|
||||
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
|
||||
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
|
||||
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
|
||||
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
|
||||
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
|
||||
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
|
||||
ni se logea en crudo.
|
||||
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Capability: obsidian
|
||||
|
||||
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. NO depende de la app GUI de Obsidian ni de su URI scheme — manipula los archivos `.md` directamente en disco. Scriptable, rapido, con telemetria del registry.
|
||||
|
||||
Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan enlazados como vaults del registry en el project `obsidian` (`projects/obsidian/vaults/`). Ver `projects/obsidian/project.md`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `parse_obsidian_frontmatter_py_obsidian` | `parse_obsidian_frontmatter(content: str) -> {"frontmatter": dict, "body": str}` | **Pure.** Separa el frontmatter YAML (bloque `---` inicial) del cuerpo. Si no hay frontmatter valido devuelve `{}` + el contenido completo. |
|
||||
| `extract_obsidian_wikilinks_py_obsidian` | `extract_obsidian_wikilinks(body: str) -> list` | **Pure.** Extrae los targets de los wikilinks `[[...]]` y embeds `![[...]]`. Normaliza `[[nota\|alias]]`, `[[nota#heading]]`, `[[nota#^block]]` -> `nota`. Dedup preservando orden. |
|
||||
| `format_obsidian_note_py_obsidian` | `format_obsidian_note(frontmatter: dict, body: str) -> str` | **Pure.** Inversa de parse: serializa frontmatter (YAML entre `---`) + body a una nota `.md` completa. |
|
||||
| `read_obsidian_note_py_obsidian` | `read_obsidian_note(path: str) -> dict` | Lee una nota: `{path, frontmatter, body, wikilinks, tags}`. Compone parse + extract. |
|
||||
| `create_obsidian_note_py_obsidian` | `create_obsidian_note(vault_dir, rel_path, body="", frontmatter=None, overwrite=False) -> str` | Crea nota nueva (crea dirs padre, añade `.md`). Error si existe y `overwrite=False`. |
|
||||
| `update_obsidian_note_py_obsidian` | `update_obsidian_note(path, body=None, set_frontmatter=None, append=None) -> str` | Edita nota existente: merge de frontmatter, reemplazo de body, o append al final. |
|
||||
| `delete_obsidian_note_py_obsidian` | `delete_obsidian_note(path: str) -> bool` | Borra una nota (solo archivo, nunca directorio). Error si no existe. |
|
||||
| `list_obsidian_notes_py_obsidian` | `list_obsidian_notes(vault_dir, subfolder="", tag="") -> list` | Lista paths de notas `.md` (recursivo). Excluye `.obsidian/` y `.trash/`. Filtro opcional por tag de frontmatter. |
|
||||
| `search_obsidian_notes_py_obsidian` | `search_obsidian_notes(vault_dir, query, in_body=True, in_frontmatter=True) -> list` | Busca substring (case-insensitive) en las notas. Devuelve `[{path, matches:[{line, text}]}]`. |
|
||||
| `list_obsidian_vaults_py_obsidian` | `list_obsidian_vaults(base_dir: str) -> list` | Lista los vaults (subdirs con `.obsidian/`) bajo `base_dir`. `[{name, path}]`. |
|
||||
| `create_obsidian_vault_py_obsidian` | `create_obsidian_vault(parent_dir, name) -> str` | Crea un vault nuevo: carpeta + `.obsidian/app.json` minimo. Error si ya existe. |
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
Componer varias funciones del grupo se hace por heredoc importando del registry (las funciones se importan, no se reescriben):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from obsidian import (
|
||||
list_obsidian_vaults, list_obsidian_notes, search_obsidian_notes,
|
||||
create_obsidian_note, read_obsidian_note, update_obsidian_note, delete_obsidian_note,
|
||||
)
|
||||
|
||||
# 1. Descubrir vaults del usuario
|
||||
vaults = list_obsidian_vaults("/home/enmanuel/Obsidian")
|
||||
print("vaults:", [v["name"] for v in vaults])
|
||||
|
||||
# 2. Listar y buscar notas en un vault
|
||||
finanzas = "/home/enmanuel/Obsidian/Finanzas"
|
||||
print("notas:", len(list_obsidian_notes(finanzas)))
|
||||
print("hits:", [h["path"] for h in search_obsidian_notes(finanzas, "presupuesto")][:5])
|
||||
|
||||
# 3. CRUD de una nota (crear -> leer -> editar -> borrar)
|
||||
p = create_obsidian_note(finanzas, "inbox/idea_x", body="Primera linea",
|
||||
frontmatter={"tags": ["inbox"], "created": "2026-06-09"})
|
||||
note = read_obsidian_note(p)
|
||||
print("creada:", note["path"], note["frontmatter"], note["wikilinks"])
|
||||
update_obsidian_note(p, set_frontmatter={"status": "done"}, append="Ver [[Otra Nota]]")
|
||||
delete_obsidian_note(p)
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Para una sola operacion con un id conocido, `fn run` tambien sirve:
|
||||
|
||||
```bash
|
||||
./fn run list_obsidian_vaults /home/enmanuel/Obsidian
|
||||
./fn run list_obsidian_notes /home/enmanuel/Obsidian/Finanzas
|
||||
```
|
||||
|
||||
## Cuando usar el grupo
|
||||
|
||||
- Crear/editar/leer notas de cualquier vault de Obsidian desde un agente o script, sin abrir la app.
|
||||
- Buscar o listar notas por contenido o tag (ingesta, migracion, reporting sobre el vault).
|
||||
- Crear vaults nuevos o inventariar los existentes.
|
||||
|
||||
## Fronteras (que NO cubre)
|
||||
|
||||
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
|
||||
- **No resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
|
||||
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
|
||||
- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Vaults grandes son caros: `NotasDeObsidian` pesa ~554M. `list_obsidian_notes` / `search_obsidian_notes` recorren todo el arbol — filtra por `subfolder` cuando puedas.
|
||||
- `delete_obsidian_note` borra de verdad (no manda a `.trash/`). Para acciones destructivas masivas, listar primero y confirmar.
|
||||
- El frontmatter `tags` puede venir como lista o como CSV string; `read_obsidian_note` lo normaliza a lista.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Capability: osint-enrich
|
||||
|
||||
Orquestadores de enriquecimiento OSINT: componen las funciones atómicas de
|
||||
[osint-passive](osint-passive.md) para aumentar los datapoints de una entidad (persona u
|
||||
organización) del vault `osint` a partir de fuentes públicas. No tocan al objetivo de forma
|
||||
intrusiva. Mismo encuadre dual-use que `osint-passive`: solo investigación autorizada.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `scan_ficha_attachments_metadata_py_cybersecurity` | `scan_ficha_attachments_metadata(attachments_dir) -> dict` | Escanea los attachments de una ficha (imágenes + PDFs), extrae EXIF/PDF metadata y agrega GPS y fechas. |
|
||||
| `enrich_person_passive_py_cybersecurity` | `enrich_person_passive(nombre, apellidos, dominios=None, usernames=None) -> dict` | Candidatos para una persona: emails (guess), username hits, dorks. No verifica ni ejecuta. |
|
||||
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Perfil pasivo de una org: whois + dns + subdominios. Resiliente a fallo parcial (campo `errors`). |
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys; sys.path.insert(0, "python/functions")
|
||||
from cybersecurity import (scan_ficha_attachments_metadata,
|
||||
enrich_person_passive, enrich_org_passive)
|
||||
|
||||
# 1. Metadatos de los documentos ya guardados de una persona (datos propios)
|
||||
m = scan_ficha_attachments_metadata(
|
||||
"/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez")
|
||||
print(m["summary"]) # {n_files, n_images, n_pdfs, n_gps_points, n_dates, errors}
|
||||
|
||||
# 2. Candidatos de enriquecimiento de una persona (no toca al objetivo)
|
||||
p = enrich_person_passive("Enmanuel", "Gutierrez Perez",
|
||||
dominios=["gmail.com"], usernames=["enmanuelgp"])
|
||||
print(p["email_candidates"][:5], len(p["dorks"]))
|
||||
|
||||
# 3. Perfil pasivo de una organización por su dominio
|
||||
o = enrich_org_passive("organic-machine.com")
|
||||
print(o["whois"].get("registrar"), o["dns"].get("A"), len(o["subdomains"]), o["errors"])
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- Compone solo funciones `osint-passive`. Para activa (port scan, fingerprint) haría falta
|
||||
`osint-active` (no construido).
|
||||
- Devuelve candidatos/datos crudos; **decidir qué escribir en la ficha** (y verificar) es del
|
||||
caller. Encaja con el reporte de `projects/osint/tools/person_datapoints.py`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `enrich_org_passive` nunca peta por una fuente lenta (crt.sh): el fallo va a `errors`.
|
||||
- `enrich_person_passive` puede tardar por `enumerate_username_sites` (un request por sitio).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Capability: osint-passive
|
||||
|
||||
Recolección OSINT **pasiva**: obtener información sin interactuar de forma intrusiva con el
|
||||
objetivo, usando solo fuentes públicas (DNS público, RDAP, Certificate Transparency, metadatos
|
||||
de documentos propios, servicios de perfil públicos). Pensado para investigación autorizada,
|
||||
due diligence, pentest con permiso y enriquecimiento de las fichas del vault `osint`.
|
||||
|
||||
**Encuadre:** dual-use. Úsese solo contra objetivos propios o con autorización. Las funciones
|
||||
que tocan servicios públicos (`enumerate_username_sites`, `enum_subdomains_crtsh`) dejan una
|
||||
huella mínima (un request a cada servicio); respeta sus rate limits.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `extract_exif_metadata_py_cybersecurity` | `extract_exif_metadata(image_path) -> dict` | EXIF de una imagen (fecha, cámara, software, GPS decimal) vía Pillow. |
|
||||
| `extract_pdf_metadata_py_cybersecurity` | `extract_pdf_metadata(pdf_path) -> dict` | Document Info de un PDF (autor, fechas, software, páginas) vía pypdf. |
|
||||
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Candidatos de email comunes a partir de nombre + dominio. |
|
||||
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, ...) -> list` | ¿Existe un username en ~12 redes públicas? (sherlock ligero, por código HTTP). |
|
||||
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo, ...) -> list` | **Pure.** Genera dorks de motor de búsqueda (persona/email/dominio/usuario). |
|
||||
| `dns_records_py_cybersecurity` | `dns_records(dominio, types=None) -> dict` | Registros DNS (A/AAAA/MX/TXT/NS/CNAME) vía `dig`. |
|
||||
| `whois_lookup_py_cybersecurity` | `whois_lookup(dominio, ...) -> dict` | Datos de registro vía RDAP (WHOIS moderno HTTP/JSON, sin CLI). |
|
||||
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, ...) -> list` | Subdominios desde Certificate Transparency (crt.sh). |
|
||||
|
||||
Orquestadores (grupo [osint-enrich](osint-enrich.md)): `scan_ficha_attachments_metadata`,
|
||||
`enrich_person_passive`, `enrich_org_passive`.
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys; sys.path.insert(0, "python/functions")
|
||||
from cybersecurity import (dns_records, whois_lookup, enum_subdomains_crtsh,
|
||||
guess_email_formats, build_search_dorks, extract_exif_metadata)
|
||||
|
||||
# Dominio (org)
|
||||
print(whois_lookup("organic-machine.com")["registrar"]) # OVH sas
|
||||
print(dns_records("organic-machine.com")["A"]) # ['135.125.201.30']
|
||||
print(enum_subdomains_crtsh("organic-machine.com")[:5])
|
||||
|
||||
# Persona
|
||||
print(guess_email_formats("Enmanuel", "Gutierrez Perez", "gmail.com")[:5])
|
||||
print(build_search_dorks("Enmanuel Gutierrez Perez", "persona")[:3])
|
||||
|
||||
# Metadatos de un documento propio
|
||||
print(extract_exif_metadata("/home/enmanuel/Obsidian/osint/attachments/personas/enmanuel-gutierrez-perez/dni-1.jpg"))
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Fronteras (qué NO es)
|
||||
|
||||
- **No es recolección activa**: no hace port scan, dns brute, ni sondea la infra del objetivo.
|
||||
Eso sería el grupo `osint-active` (no construido todavía).
|
||||
- **No verifica** los candidatos: `guess_email_formats` propone, no confirma que el email exista.
|
||||
- **No ejecuta** los dorks: `build_search_dorks` los genera; ejecutarlos es otro paso (browser).
|
||||
- **No incluye breach/leak lookup** (HIBP requiere API key de pago) — pendiente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `crt.sh` va lento / rate-limitado y a veces responde 404; los orquestadores lo capturan en
|
||||
`errors` y siguen.
|
||||
- `enumerate_username_sites` da falsos positivos/negativos por anti-bot de algunos sitios.
|
||||
- El GPS de EXIF revela ubicación — dato sensible; trátese como PII.
|
||||
@@ -0,0 +1,80 @@
|
||||
# WhatsApp — Operar WhatsApp Web por CDP sobre la sesión existente
|
||||
|
||||
Tag: `whatsapp`. Grupo de funciones para automatizar WhatsApp Web (buscar/abrir un chat,
|
||||
leer la conversación, enviar texto) operando por Chrome DevTools Protocol sobre la **pestaña
|
||||
ya abierta y logueada** del navegador diario, **sin abrir ventana nueva ni darle foco**.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="whatsapp"`.
|
||||
|
||||
## Por qué CDP y no HTTP replay
|
||||
|
||||
WhatsApp Web **no envía mensajes por HTTP requests REST**: usa un **WebSocket** (wss) como
|
||||
transporte y **cifrado extremo a extremo (Signal/Noise)**, con claves que rotan por mensaje y
|
||||
viven en el navegador. El tráfico capturable es binario cifrado e irreproducible — por eso el
|
||||
patrón `flow-replay` (grabar HTTP → reproducir) **no aplica** aquí. La única vía que opera la
|
||||
sesión existente sin ventana nueva es **automatizar el DOM por CDP**. (Baileys/whatsapp-web.js
|
||||
quedan descartados: emparejan un dispositivo nuevo por QR, o abren su propio navegador.)
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [whatsapp_open_chat_py_browser](../../python/functions/browser/whatsapp_open_chat.md) | `whatsapp_open_chat(name, *, port=9222) -> dict` | Busca y abre un chat por nombre exacto (ancla `span[title]` + click de ratón real). Verifica el destinatario. Base de read/send. |
|
||||
| [whatsapp_read_chat_py_browser](../../python/functions/browser/whatsapp_read_chat.md) | `whatsapp_read_chat(name, *, n=15, open_first=True) -> dict` | Lee los últimos N mensajes renderizados del chat (`{text, outgoing}`). |
|
||||
| [whatsapp_send_message_py_browser](../../python/functions/browser/whatsapp_send_message.md) | `whatsapp_send_message(name, text, *, open_first=True) -> dict` | Envía un texto. Salvaguarda: verifica destinatario + contenido exacto del composer antes de pulsar Enter. |
|
||||
|
||||
### Primitivas CDP que componen (grupo `navegator`)
|
||||
|
||||
El transport está en 4 primitivas Python reutilizables (cualquier automatización de la sesión diaria):
|
||||
|
||||
| ID | Qué hace |
|
||||
|---|---|
|
||||
| [cdp_eval_py_browser](../../python/functions/browser/cdp_eval.md) | Evalúa JS en un target por substring de URL (leer DOM, `focus()`, resolver coords). |
|
||||
| [cdp_type_chars_py_browser](../../python/functions/browser/cdp_type_chars.md) | Escribe char-by-char con key events reales (único método que funciona con el editor Lexical). |
|
||||
| [cdp_press_key_py_browser](../../python/functions/browser/cdp_press_key.md) | Pulsa una tecla nombrada (Enter, Escape, Backspace, Arrows...) con modificadores. |
|
||||
| [cdp_click_xy_py_browser](../../python/functions/browser/cdp_click_xy.md) | Click de ratón real en coordenadas (necesario: `element.click()` JS no dispara los handlers de React). |
|
||||
|
||||
## Ejemplo canónico end-to-end
|
||||
|
||||
Requisito: WhatsApp Web abierto y logueado en un Chrome con `--remote-debugging-port=9222`
|
||||
(en este equipo, el CDP global de chromium ya lo expone). No hace falta foco ni ventana visible.
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from browser.whatsapp_read_chat import whatsapp_read_chat
|
||||
from browser.whatsapp_send_message import whatsapp_send_message
|
||||
|
||||
# Leer los últimos mensajes de un chat
|
||||
r = whatsapp_read_chat("NOTAS WASAP", n=5)
|
||||
for m in r["messages"]:
|
||||
print(("→" if m["outgoing"] else "←"), m["text"])
|
||||
|
||||
# Enviar un mensaje (acción con efecto: envía de verdad)
|
||||
res = whatsapp_send_message("NOTAS WASAP", "hola desde el registry")
|
||||
print(res) # {"sent": True, "last_row": "hola desde el registry 11:48"}
|
||||
```
|
||||
|
||||
## Fronteras y gotchas (leer antes de usar)
|
||||
|
||||
- **Viola los ToS de WhatsApp; riesgo de ban del número.** Probar en un chat propio reduce
|
||||
molestia a terceros pero no elimina el riesgo de detección por patrón.
|
||||
- **Envío irreversible**: `whatsapp_send_message` envía de verdad y WhatsApp no permite
|
||||
des-enviar por esta vía. La función verifica destinatario (`name` exacto en el composer) y
|
||||
contenido antes de Enter, pero el `name` lo das tú: un nombre ambiguo abre el primer match.
|
||||
- **Nombre exacto requerido** (`span[title]` exacto). El buscador **no filtra de forma fiable
|
||||
los contactos NO cargados** en la lista lateral; funciona para chats recientes/visibles. Un
|
||||
contacto sin chat reciente puede no encontrarse (limitación conocida; mejora futura: scroll).
|
||||
- **Lexical**: escribir SOLO con `cdp_type_chars` (key events reales). `execCommand`/`el.value`
|
||||
meten texto fantasma y producen duplicación/intercalado.
|
||||
- **Abrir chats**: requiere click de ratón real (`cdp_click_xy`); `element.click()` JS no abre.
|
||||
- **`outgoing`** se infiere de `.message-out` (heurístico) y puede no marcar bien los mensajes
|
||||
propios en algunos grupos; el `text` siempre es fiable.
|
||||
- **Solo lee lo renderizado** en el viewport del chat; mensajes muy antiguos requieren scroll
|
||||
(no implementado).
|
||||
- Funciona con la ventana **minimizada y sin foco** (CDP no depende del foco del SO).
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Chrome/Chromium con remote debugging en el puerto 9222 y WhatsApp Web logueado.
|
||||
- `websocket-client` en `python/.venv` (ya presente). Sin dependencias nuevas.
|
||||
@@ -23,18 +23,49 @@ func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
|
||||
return cx, cy, nil
|
||||
}
|
||||
|
||||
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
// CdpClickRef hace click sobre el elemento del #ref (un backendDOMNodeId extraído
|
||||
// del AX outline por page_perceive). Por defecto usa click humanizado (Bézier +
|
||||
// jitter) sobre el centro del bbox. Dos casos caen al click via element.click() JS:
|
||||
// - opts.Mode == "instant": sin eventos de ratón reales (rápido, tests).
|
||||
// - el nodo no tiene box model (display:contents, área 0): degradado natural en
|
||||
// vez de fallar con error duro — un elemento clicable sin geometría sí se clica.
|
||||
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
|
||||
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click ref: conexión nil")
|
||||
}
|
||||
if opts.Mode == "instant" {
|
||||
return clickRefViaJS(c, backendNodeID)
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click ref: %w", err)
|
||||
// Sin geometría: fallback a element.click() JS en vez de error duro.
|
||||
return clickRefViaJS(c, backendNodeID)
|
||||
}
|
||||
return CdpClickXYHuman(c, cx, cy, opts)
|
||||
}
|
||||
|
||||
// clickRefViaJS resuelve el nodo por backendDOMNodeId y llama element.click() en
|
||||
// el contexto JS de la página. No dispara eventos de ratón reales (mousemove/
|
||||
// mousedown), por lo que algunos listeners de hover no se activan; a cambio
|
||||
// funciona sin geometría y al instante.
|
||||
func clickRefViaJS(c *CDPConn, backendNodeID int) error {
|
||||
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click ref (js): resolveNode ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
obj, _ := res["object"].(map[string]any)
|
||||
objID, _ := obj["objectId"].(string)
|
||||
if objID == "" {
|
||||
return fmt.Errorf("cdp click ref (js): sin objectId para ref %d", backendNodeID)
|
||||
}
|
||||
if _, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
|
||||
"objectId": objID,
|
||||
"functionDeclaration": "function(){ this.click(); }",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cdp click ref (js): click ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30-90 ms).
|
||||
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
|
||||
// Pausa entre press y release según el modo de velocidad.
|
||||
if pms := clickPauseMs(opts.Mode); pms > 0 {
|
||||
time.Sleep(time.Duration(pms) * time.Millisecond)
|
||||
}
|
||||
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
@@ -47,3 +49,16 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
|
||||
// velocidad: human 30-90, fast 5-15, instant 0.
|
||||
func clickPauseMs(mode string) int {
|
||||
switch mode {
|
||||
case "instant":
|
||||
return 0
|
||||
case "fast":
|
||||
return 5 + rand.Intn(11) // 5..15
|
||||
default: // "human" o ""
|
||||
return 30 + rand.Intn(61) // 30..90
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,21 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// CdpDisconnect cierra SOLO la conexion WebSocket CDP, sin tocar el proceso
|
||||
// Chrome. Es un alias legible de CdpClose(c, 0): usalo cuando quieras soltar la
|
||||
// sesion pero dejar el navegador vivo (p.ej. el navegador diario en 9222 al que
|
||||
// te adjuntaste, no quieres matarlo).
|
||||
func CdpDisconnect(c *CDPConn) error {
|
||||
return CdpClose(c, 0)
|
||||
}
|
||||
|
||||
// CdpQuit cierra la conexion WebSocket Y mata el proceso Chrome (y su grupo de
|
||||
// proceso completo en Linux nativo). Es un alias legible de CdpClose(c, pid) con
|
||||
// pid > 0: usalo para apagar un Chrome que TU lanzaste con ChromeLaunch.
|
||||
func CdpQuit(c *CDPConn, pid int) error {
|
||||
return CdpClose(c, pid)
|
||||
}
|
||||
|
||||
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
|
||||
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
|
||||
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
|
||||
|
||||
@@ -3,11 +3,11 @@ name: cdp_close
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpClose(c *CDPConn, pid int) error"
|
||||
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
|
||||
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
|
||||
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle. Wrappers nombrados: CdpDisconnect(c) solo cierra el WebSocket; CdpQuit(c, pid) cierra y mata Chrome."
|
||||
tags: [chrome, cdp, browser, automation, cleanup, devtools, linux, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -20,9 +20,9 @@ params:
|
||||
- name: pid
|
||||
desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
|
||||
output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests: ["TestCdpCloseWrappers"]
|
||||
test_file_path: "functions/browser/cdp_close_test.go"
|
||||
file_path: "functions/browser/cdp_close.go"
|
||||
---
|
||||
|
||||
@@ -43,6 +43,14 @@ defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
|
||||
|
||||
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
|
||||
|
||||
**Elige el wrapper según la intención** (más legible que el `pid` mágico):
|
||||
|
||||
| Quiero... | Usa | Equivale a |
|
||||
|---|---|---|
|
||||
| Soltar la sesión, dejar Chrome vivo (navegador diario en 9222) | `CdpDisconnect(c)` | `CdpClose(c, 0)` |
|
||||
| Apagar el Chrome que yo lancé | `CdpQuit(c, pid)` | `CdpClose(c, pid)` |
|
||||
| Control fino (decidir pid en runtime) | `CdpClose(c, pid)` | — |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
|
||||
@@ -56,4 +64,5 @@ Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso so
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — añade wrappers nombrados CdpDisconnect(c) (solo WebSocket) y CdpQuit(c, pid) (WebSocket + mata Chrome) para desambiguar el `pid` mágico; CdpClose sin cambios de comportamiento.
|
||||
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package browser
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCdpCloseWrappers es un smoke nil-safe de los wrappers nombrados. Sin Chrome:
|
||||
// con conexión nil y pid 0 no hay nada que cerrar ni matar, así que no debe error.
|
||||
func TestCdpCloseWrappers(t *testing.T) {
|
||||
t.Run("CdpDisconnect(nil) no error (nada que cerrar)", func(t *testing.T) {
|
||||
if err := CdpDisconnect(nil); err != nil {
|
||||
t.Errorf("CdpDisconnect(nil) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CdpQuit(nil, 0) no error (sin conexion ni pid)", func(t *testing.T) {
|
||||
if err := CdpQuit(nil, 0); err != nil {
|
||||
t.Errorf("CdpQuit(nil, 0) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CdpClose(nil, 0) sigue siendo no-op", func(t *testing.T) {
|
||||
if err := CdpClose(nil, 0); err != nil {
|
||||
t.Errorf("CdpClose(nil, 0) = %v, esperaba nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,12 @@ type CDPConn struct {
|
||||
closed bool
|
||||
handlers map[string][]EventHandler
|
||||
hMu sync.Mutex
|
||||
|
||||
// frameCtx cachea el executionContextId del isolated world por frameID, para
|
||||
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
|
||||
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
|
||||
frameCtx *frameCtxCache
|
||||
frameCtxMu sync.Mutex
|
||||
}
|
||||
|
||||
type cdpRequest struct {
|
||||
|
||||
@@ -3,75 +3,119 @@ package browser
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
|
||||
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
|
||||
// Retorna el resultado serializado como string.
|
||||
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
|
||||
}
|
||||
// frameCtxCache mapea frameID -> executionContextId del isolated world creado
|
||||
// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame.
|
||||
// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP).
|
||||
type frameCtxCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
// Page.enable es idempotente; necesario antes de crear mundos aislados
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
|
||||
}
|
||||
func newFrameCtxCache() *frameCtxCache {
|
||||
return &frameCtxCache{m: map[string]int{}}
|
||||
}
|
||||
|
||||
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
|
||||
func (f *frameCtxCache) get(frameID string) (int, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := f.m[frameID]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (f *frameCtxCache) set(frameID string, ctxID int) {
|
||||
f.mu.Lock()
|
||||
f.m[frameID] = ctxID
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *frameCtxCache) invalidate(frameID string) {
|
||||
f.mu.Lock()
|
||||
delete(f.m, frameID)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// isStaleContextError reconoce el error de CDP cuando un executionContextId
|
||||
// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es
|
||||
// puro: decide a partir del texto del error. Permite reintentar recreando el
|
||||
// mundo en vez de fallar.
|
||||
func isStaleContextError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "Cannot find context") ||
|
||||
strings.Contains(s, "context with specified id") ||
|
||||
strings.Contains(s, "Execution context was destroyed") ||
|
||||
strings.Contains(s, "uniqueContextId")
|
||||
}
|
||||
|
||||
// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion,
|
||||
// inicializandolo en el primer uso. El mutex de CDPConn solo protege este
|
||||
// lazy-init del puntero.
|
||||
func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache {
|
||||
c.frameCtxMu.Lock()
|
||||
defer c.frameCtxMu.Unlock()
|
||||
if c.frameCtx == nil {
|
||||
c.frameCtx = newFrameCtxCache()
|
||||
}
|
||||
return c.frameCtx
|
||||
}
|
||||
|
||||
// createIsolatedWorld crea un mundo aislado en el frame y devuelve su
|
||||
// executionContextId.
|
||||
func createIsolatedWorld(c *CDPConn, frameID string) (int, error) {
|
||||
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
|
||||
"frameId": frameID,
|
||||
"worldName": "fn_registry_isolated",
|
||||
"grantUniveralAccess": false,
|
||||
"frameId": frameID,
|
||||
"worldName": "fn_registry_isolated",
|
||||
"grantUniversalAccess": false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
|
||||
return 0, fmt.Errorf("createIsolatedWorld: %w", err)
|
||||
}
|
||||
|
||||
ctxIDRaw, ok := ctxRes["executionContextId"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
|
||||
return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta")
|
||||
}
|
||||
ctxID, ok := ctxIDRaw.(float64)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
|
||||
return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw)
|
||||
}
|
||||
return int(ctxID), nil
|
||||
}
|
||||
|
||||
// Evaluar la expresion en el contexto aislado del frame
|
||||
// evalInFrameContext ejecuta la expresion en el executionContextId dado y
|
||||
// serializa el resultado como string (mismo patron que CdpEvaluate).
|
||||
func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) {
|
||||
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
||||
"expression": expression,
|
||||
"contextId": int(ctxID),
|
||||
"contextId": ctxID,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
|
||||
return "", fmt.Errorf("Runtime.evaluate: %w", err)
|
||||
}
|
||||
|
||||
// Verificar excepcion JS
|
||||
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
text, _ := excMap["text"].(string)
|
||||
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
|
||||
return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text)
|
||||
}
|
||||
|
||||
// Extraer valor del resultado (mismo patron que CdpEvaluate)
|
||||
resVal, ok := evRes["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
|
||||
return "", fmt.Errorf("resultado inesperado: %v", evRes)
|
||||
}
|
||||
|
||||
value, ok := resVal["value"]
|
||||
if !ok {
|
||||
// undefined u otro tipo no serializable
|
||||
typ, _ := resVal["type"].(string)
|
||||
return typ, nil
|
||||
}
|
||||
|
||||
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
|
||||
if s, ok := value.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
@@ -81,3 +125,52 @@ func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
|
||||
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
|
||||
// Retorna el resultado serializado como string.
|
||||
//
|
||||
// Cachea el executionContextId por frameID en la conexion: la primera llamada
|
||||
// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado
|
||||
// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta.
|
||||
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
|
||||
}
|
||||
|
||||
// Page.enable es idempotente; necesario antes de crear mundos aislados.
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
|
||||
}
|
||||
|
||||
cache := c.frameCtxCacheLazy()
|
||||
|
||||
ctxID, cached := cache.get(frameID)
|
||||
if !cached {
|
||||
newID, err := createIsolatedWorld(c, frameID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
||||
}
|
||||
ctxID = newID
|
||||
cache.set(frameID, ctxID)
|
||||
}
|
||||
|
||||
out, evErr := evalInFrameContext(c, ctxID, frameID, expression)
|
||||
if evErr != nil && cached && isStaleContextError(evErr) {
|
||||
// El contexto cacheado murió (frame recargó). Recrear una vez.
|
||||
cache.invalidate(frameID)
|
||||
newID, err := createIsolatedWorld(c, frameID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
||||
}
|
||||
cache.set(frameID, newID)
|
||||
out, evErr = evalInFrameContext(c, newID, frameID, expression)
|
||||
}
|
||||
if evErr != nil {
|
||||
return "", fmt.Errorf("cdp eval in frame: %w", evErr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestCdpEvalInFrame_guards", "TestFrameCtxCache", "TestIsStaleContextError"]
|
||||
test_file_path: "functions/browser/cdp_eval_in_frame_test.go"
|
||||
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. Cachea el executionContextId por frameID en la conexión para no recrear el isolated world en cada llamada; si el contexto caduca (frame recargó) lo recrea una vez y reintenta."
|
||||
tags: [cdp, browser, iframe, javascript, eval, navegator]
|
||||
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
|
||||
uses_functions: []
|
||||
@@ -71,3 +73,8 @@ Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el
|
||||
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
|
||||
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
|
||||
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
|
||||
- **Cache de contexto por frameID**: la primera llamada crea el isolated world; las siguientes reutilizan su `executionContextId` (más rápido). Si el frame navega/recarga, el contexto cacheado caduca; la función detecta el error ("Cannot find context", "Execution context was destroyed") y recrea el mundo una vez automáticamente. El cache vive en la conexión: persiste entre llamadas mientras la conexión esté viva.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — corrige typo `grantUniveralAccess` → `grantUniversalAccess` (la opción nunca se aplicaba); cachea executionContextId por frameID en CDPConn (vía `frameCtxCache`) para no crear un isolated world por llamada; recrea+reintenta una vez si el contexto cacheado caducó.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpEvalInFrame_guards cubre precondiciones sin Chrome.
|
||||
func TestCdpEvalInFrame_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, err := CdpEvalInFrame(nil, "f1", "1"); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("frameID vacio", func(t *testing.T) {
|
||||
if _, err := CdpEvalInFrame(&CDPConn{}, "", "1"); err == nil {
|
||||
t.Fatal("esperaba error con frameID vacio")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFrameCtxCache cubre el núcleo puro del cache de contextos por frame.
|
||||
func TestFrameCtxCache(t *testing.T) {
|
||||
t.Run("golden: set/get devuelve el ctxId cacheado", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
if _, ok := c.get("frameA"); ok {
|
||||
t.Fatal("cache recién creado no debería tener frameA")
|
||||
}
|
||||
c.set("frameA", 42)
|
||||
id, ok := c.get("frameA")
|
||||
if !ok || id != 42 {
|
||||
t.Fatalf("get(frameA) = (%d,%v), esperaba (42,true)", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: frames distintos no se pisan", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
c.set("frameA", 1)
|
||||
c.set("frameB", 2)
|
||||
if id, _ := c.get("frameA"); id != 1 {
|
||||
t.Errorf("frameA = %d, esperaba 1", id)
|
||||
}
|
||||
if id, _ := c.get("frameB"); id != 2 {
|
||||
t.Errorf("frameB = %d, esperaba 2", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalidate: tras invalidar, get falla (fuerza recrear mundo)", func(t *testing.T) {
|
||||
c := newFrameCtxCache()
|
||||
c.set("frameA", 7)
|
||||
c.invalidate("frameA")
|
||||
if _, ok := c.get("frameA"); ok {
|
||||
t.Error("tras invalidate, get(frameA) debería fallar")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsStaleContextError cubre el discriminador puro que decide si reintentar
|
||||
// recreando el isolated world.
|
||||
func TestIsStaleContextError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil no es stale", nil, false},
|
||||
{"error generico no es stale", errors.New("boom"), false},
|
||||
{"Cannot find context es stale", errors.New("cdp error: Cannot find context with specified id"), true},
|
||||
{"Execution context was destroyed es stale", errors.New("Execution context was destroyed"), true},
|
||||
{"uniqueContextId es stale", errors.New("invalid uniqueContextId"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isStaleContextError(tc.err); got != tc.want {
|
||||
t.Errorf("isStaleContextError(%v) = %v, esperaba %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de
|
||||
// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos.
|
||||
// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno
|
||||
// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve.
|
||||
const findByTextCoreJS = `
|
||||
var P = %s;
|
||||
var target = P.cs ? P.text : P.text.toLowerCase();
|
||||
var nodes = document.querySelectorAll(P.tag || '*');
|
||||
function norm(v) {
|
||||
v = (v || '').replace(/\s+/g, ' ').trim();
|
||||
return P.cs ? v : v.toLowerCase();
|
||||
}
|
||||
function matches(el) {
|
||||
var v = norm(el.innerText || el.textContent || '');
|
||||
return P.exact ? v === target : v.indexOf(target) >= 0;
|
||||
}
|
||||
function leafmost(el) {
|
||||
for (var i = 0; i < el.children.length; i++) {
|
||||
if (matches(el.children[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}`
|
||||
|
||||
// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode.
|
||||
// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un
|
||||
// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía).
|
||||
func parseBackendNodeID(resp map[string]any) (int, error) {
|
||||
node, ok := resp["node"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: respuesta sin campo node")
|
||||
}
|
||||
raw, ok := node["backendNodeId"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: node sin backendNodeId")
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y
|
||||
// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que
|
||||
// produce el outline de page_perceive y que consume CdpClickRef. Así se puede
|
||||
// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type).
|
||||
//
|
||||
// Retorna (backendNodeID, count, error):
|
||||
// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef.
|
||||
// - count: número total de elementos que matchean (tras el filtro leafmost).
|
||||
// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda.
|
||||
// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay
|
||||
// ningún match (count == 0).
|
||||
//
|
||||
// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un
|
||||
// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM
|
||||
// (DOM.describeNode), evitando el round-trip por selector CSS.
|
||||
func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula")
|
||||
}
|
||||
if text == "" {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio")
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"tag": opts.Tag,
|
||||
"exact": opts.Exact,
|
||||
"cs": opts.CaseSensitive,
|
||||
})
|
||||
core := fmt.Sprintf(findByTextCoreJS, string(payload))
|
||||
|
||||
// 1. Contar matches (returnByValue=true vía CdpEvaluate).
|
||||
countJS := "(function(){" + core + `
|
||||
var n = 0;
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) n++;
|
||||
}
|
||||
return n;
|
||||
})()`
|
||||
countStr, err := CdpEvaluate(c, countJS)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err)
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(countStr))
|
||||
if count == 0 {
|
||||
return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text)
|
||||
}
|
||||
|
||||
// 2. Resolver el primer match a un RemoteObject (returnByValue=false para
|
||||
// obtener un objectId que apunta al nodo DOM vivo).
|
||||
elJS := "(function(){" + core + `
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i];
|
||||
}
|
||||
return null;
|
||||
})()`
|
||||
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
||||
"expression": elJS,
|
||||
"returnByValue": false,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err)
|
||||
}
|
||||
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
txt, _ := excMap["text"].(string)
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt)
|
||||
}
|
||||
remote, ok := evRes["result"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result")
|
||||
}
|
||||
objID, _ := remote["objectId"].(string)
|
||||
if objID == "" {
|
||||
// El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM
|
||||
// mutó): tratamos como no encontrado para no devolver un ref inválido.
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)")
|
||||
}
|
||||
|
||||
// 3. Del RemoteObject al nodo DOM: backendNodeId.
|
||||
dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID})
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err)
|
||||
}
|
||||
backendNodeID, err := parseBackendNodeID(dn)
|
||||
if err != nil {
|
||||
return 0, count, fmt.Errorf("cdp find ref by text: %w", err)
|
||||
}
|
||||
return backendNodeID, count, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: cdp_find_ref_by_text
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error)"
|
||||
description: "Busca el primer elemento cuyo innerText matchea el texto dado y devuelve su backendDOMNodeId (#ref estable) en vez de un selector CSS. Resuelve el nodo JS a RemoteObject (Runtime.evaluate returnByValue=false) y de ahi al nodo DOM (DOM.describeNode), unificando la identidad con page_perceive y CdpClickRef. Devuelve tambien el numero de matches para detectar ambiguedad. Prefiere elementos hoja (leafmost)."
|
||||
tags: [browser, cdp, find, locator, ref, accessibility, navegator]
|
||||
uses_functions:
|
||||
- cdp_evaluate_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [encoding/json, fmt, strconv, strings]
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP activa obtenida con CdpConnect."
|
||||
- name: text
|
||||
desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)."
|
||||
- name: opts
|
||||
desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)."
|
||||
output: "(backendNodeID, count, error): backendNodeID es el #ref del primer match listo para CdpClickRef; count es el numero total de matches (>1 = ambiguo); error si conexion nula, texto vacio, eval JS falla o no hay match (count==0)."
|
||||
tested: true
|
||||
tests: ["TestCdpFindRefByText_guards", "TestParseBackendNodeID"]
|
||||
test_file_path: "functions/browser/cdp_find_ref_by_text_test.go"
|
||||
file_path: "functions/browser/cdp_find_ref_by_text.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
c, _ := browser.CdpConnect(9222)
|
||||
defer browser.CdpClose(c, 0)
|
||||
|
||||
// Encontrar el botón "Login" por su texto y clicar por #ref (sin selector CSS).
|
||||
ref, count, err := browser.CdpFindRefByText(c, "Login", browser.FindByTextOpts{Tag: "button"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if count > 1 {
|
||||
log.Printf("aviso: %d elementos matchean 'Login', usando el primero", count)
|
||||
}
|
||||
_ = browser.CdpClickRef(c, ref, browser.MouseProfileForMode("human"))
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras clicar/hacer hover sobre un elemento identificándolo por su texto visible y operar después por `#ref` (backendDOMNodeId) en vez de por un selector CSS frágil. Es el puente entre "lo veo por su texto" y el bucle percibir→actuar de `page_perceive` + `CdpClickRef`. Preferible a `cdp_find_by_text` (que devuelve selector `nth-of-type`) cuando el frontend cambia sus clases/estructura con cada build pero el texto es estable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **count > 1 = ambigüedad**: la función devuelve el primer match pero te avisa con `count` cuántos hay. Refina con `opts.Tag` o `opts.Exact` si el texto aparece en varios sitios.
|
||||
- **Elemento volátil**: si el DOM muta entre el conteo y la resolución del nodo (SPA re-renderizando), el `objectId` puede venir vacío y la función devuelve error "elemento volátil" en vez de un `#ref` inválido. Reintenta tras `CdpWaitIdle`.
|
||||
- **El #ref es efímero por documento**: el `backendDOMNodeId` es estable mientras el nodo viva, pero se invalida tras navegar o recargar. No lo persistas entre páginas.
|
||||
- **Tests sin Chrome**: el núcleo puro (`parseBackendNodeID`) y los guards se testean sin navegador. El flujo completo (eval + describeNode contra DOM real) requiere Chrome y se valida por e2e.
|
||||
@@ -0,0 +1,70 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpFindRefByText_guards cubre las precondiciones (sin Chrome).
|
||||
func TestCdpFindRefByText_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, _, err := CdpFindRefByText(nil, "x", FindByTextOpts{}); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("texto vacio", func(t *testing.T) {
|
||||
c := &CDPConn{}
|
||||
_, _, err := CdpFindRefByText(c, "", FindByTextOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error con texto vacio")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "vacio") {
|
||||
t.Fatalf("mensaje %q no menciona vacio", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseBackendNodeID cubre el nucleo puro que convierte la respuesta de
|
||||
// DOM.describeNode en el backendNodeId entero. No requiere Chrome.
|
||||
func TestParseBackendNodeID(t *testing.T) {
|
||||
t.Run("golden: node con backendNodeId", func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"node": map[string]any{"backendNodeId": 123.0, "nodeName": "BUTTON"},
|
||||
}
|
||||
id, err := parseBackendNodeID(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
if id != 123 {
|
||||
t.Fatalf("id = %d, esperaba 123", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: backendNodeId grande se trunca a int correctamente", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"backendNodeId": 90001.0}}
|
||||
id, err := parseBackendNodeID(resp)
|
||||
if err != nil || id != 90001 {
|
||||
t.Fatalf("id=%d err=%v, esperaba 90001 sin error", id, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: respuesta sin node", func(t *testing.T) {
|
||||
if _, err := parseBackendNodeID(map[string]any{}); err == nil {
|
||||
t.Error("esperaba error cuando falta node")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: node sin backendNodeId", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"nodeName": "DIV"}}
|
||||
if _, err := parseBackendNodeID(resp); err == nil {
|
||||
t.Error("esperaba error cuando falta backendNodeId")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: backendNodeId tipo no numerico", func(t *testing.T) {
|
||||
resp := map[string]any{"node": map[string]any{"backendNodeId": "abc"}}
|
||||
if _, err := parseBackendNodeID(resp); err == nil {
|
||||
t.Error("esperaba error cuando backendNodeId no es numero")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma
|
||||
// lista que _ACTIONABLE_ROLES de render_ax_outline.py.
|
||||
var axoActionableRoles = map[string]struct{}{
|
||||
"button": {},
|
||||
"link": {},
|
||||
"textbox": {},
|
||||
"searchbox": {},
|
||||
"checkbox": {},
|
||||
"radio": {},
|
||||
"combobox": {},
|
||||
"listbox": {},
|
||||
"menuitem": {},
|
||||
"menuitemcheckbox": {},
|
||||
"menuitemradio": {},
|
||||
"tab": {},
|
||||
"option": {},
|
||||
"switch": {},
|
||||
"slider": {},
|
||||
"spinbutton": {},
|
||||
"treeitem": {},
|
||||
"gridcell": {},
|
||||
}
|
||||
|
||||
// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al
|
||||
// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py.
|
||||
var axoSkipRoles = map[string]struct{}{
|
||||
"none": {},
|
||||
"presentation": {},
|
||||
"ignored": {},
|
||||
}
|
||||
|
||||
// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de
|
||||
// arboles AX patologicos). Igual que _MAX_DEPTH del .py.
|
||||
const axoMaxDepth = 60
|
||||
|
||||
// axNode es la representacion interna de un AXNode CDP, ya extraida del
|
||||
// map[string]any de la respuesta. Los helpers de poda y render operan sobre
|
||||
// estos structs, lo que los hace puros y testeables sin Chrome.
|
||||
type axNode struct {
|
||||
nodeID string
|
||||
backendDOMNodeID string
|
||||
ignored bool
|
||||
role string
|
||||
name string
|
||||
value string
|
||||
childIDs []string
|
||||
parentID string
|
||||
}
|
||||
|
||||
// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un
|
||||
// outline accesible indentado y accionable, reusando la conexion CDP viva del
|
||||
// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python.
|
||||
//
|
||||
// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si
|
||||
// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese
|
||||
// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1).
|
||||
//
|
||||
// El resultado se poda (trim) y luego se renderiza replicando exactamente el
|
||||
// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline:
|
||||
// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs,
|
||||
// y marcador ` #ref=<backendDOMNodeId>` en roles accionables. maxChars > 0
|
||||
// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite.
|
||||
func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp get ax outline: conexion nula")
|
||||
}
|
||||
|
||||
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
|
||||
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
|
||||
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
|
||||
}
|
||||
|
||||
var params map[string]any
|
||||
if frameID != "" {
|
||||
params = map[string]any{"frameId": frameID}
|
||||
}
|
||||
|
||||
res, err := c.sendCDP("Accessibility.getFullAXTree", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err)
|
||||
}
|
||||
|
||||
nodes := axoParseNodes(res)
|
||||
trimmed := trimAXTree(nodes)
|
||||
return renderAXOutline(trimmed, maxChars), nil
|
||||
}
|
||||
|
||||
// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el
|
||||
// JSON unmarshal a map[string]any, los nodos vienen como []any de
|
||||
// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y
|
||||
// childIds suelen llegar como strings. Normalizamos todo a string.
|
||||
func axoParseNodes(result map[string]any) []axNode {
|
||||
raw, ok := result["nodes"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]axNode, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := axNode{
|
||||
nodeID: axoStr(m["nodeId"]),
|
||||
backendDOMNodeID: axoStr(m["backendDOMNodeId"]),
|
||||
ignored: axoBool(m["ignored"]),
|
||||
role: axoNested(m["role"]),
|
||||
name: axoNested(m["name"]),
|
||||
value: axoNested(m["value"]),
|
||||
childIDs: axoStrSlice(m["childIds"]),
|
||||
parentID: axoStr(m["parentId"]),
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role,
|
||||
// name, value vienen asi). Devuelve "" si esta ausente o vacio.
|
||||
func axoNested(v any) string {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return axoStr(v)
|
||||
}
|
||||
return axoStr(m["value"])
|
||||
}
|
||||
|
||||
// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como
|
||||
// float64 tras el unmarshal; los renderizamos sin decimales.
|
||||
func axoStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
// IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0.
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func axoBool(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func axoStrSlice(v any) []string {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
out = append(out, axoStr(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y
|
||||
// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py.
|
||||
//
|
||||
// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds;
|
||||
// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del
|
||||
// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta
|
||||
// convergencia. Preserva el orden original de aparicion.
|
||||
func trimAXTree(nodes []axNode) []axNode {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
shouldDiscard := func(n axNode) bool {
|
||||
if n.ignored {
|
||||
return true
|
||||
}
|
||||
if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
if n.role == "StaticText" && n.name == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
byID := map[string]axNode{}
|
||||
for _, n := range nodes {
|
||||
if shouldDiscard(n) {
|
||||
continue
|
||||
}
|
||||
byID[n.nodeID] = n
|
||||
}
|
||||
|
||||
// Colapso iterativo hasta convergencia.
|
||||
for {
|
||||
changed := false
|
||||
removed := map[string]struct{}{}
|
||||
for _, node := range byID {
|
||||
if _, gone := removed[node.nodeID]; gone {
|
||||
continue
|
||||
}
|
||||
if len(node.childIDs) != 1 {
|
||||
continue
|
||||
}
|
||||
childID := node.childIDs[0]
|
||||
child, ok := byID[childID]
|
||||
if !ok || child.role != node.role {
|
||||
continue
|
||||
}
|
||||
// Fusionar: el padre hereda los childIds del hijo.
|
||||
merged := node
|
||||
merged.childIDs = child.childIDs
|
||||
byID[node.nodeID] = merged
|
||||
removed[childID] = struct{}{}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
for id := range removed {
|
||||
delete(byID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Preservar orden original.
|
||||
result := make([]axNode, 0, len(byID))
|
||||
seen := map[string]struct{}{}
|
||||
for _, n := range nodes {
|
||||
node, ok := byID[n.nodeID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[n.nodeID]; dup {
|
||||
continue
|
||||
}
|
||||
result = append(result, node)
|
||||
seen[n.nodeID] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renderAXOutline convierte axNode en un outline indentado, legible y
|
||||
// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se
|
||||
// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de
|
||||
// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo.
|
||||
func renderAXOutline(nodes []axNode, maxChars int) string {
|
||||
if len(nodes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
byID := map[string]axNode{}
|
||||
for _, n := range nodes {
|
||||
if n.nodeID != "" {
|
||||
byID[n.nodeID] = n
|
||||
}
|
||||
}
|
||||
|
||||
allChildIDs := map[string]struct{}{}
|
||||
for _, n := range nodes {
|
||||
for _, cid := range n.childIDs {
|
||||
allChildIDs[cid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var roots []axNode
|
||||
for _, n := range nodes {
|
||||
if _, isChild := allChildIDs[n.nodeID]; !isChild {
|
||||
roots = append(roots, n)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
roots = []axNode{nodes[0]}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces
|
||||
|
||||
var renderNode func(node axNode, depth int)
|
||||
renderNode = func(node axNode, depth int) {
|
||||
nid := node.nodeID
|
||||
if depth > axoMaxDepth {
|
||||
return
|
||||
}
|
||||
if nid != "" {
|
||||
if _, dup := visited[nid]; dup {
|
||||
return
|
||||
}
|
||||
visited[nid] = struct{}{}
|
||||
}
|
||||
|
||||
if node.ignored {
|
||||
return
|
||||
}
|
||||
|
||||
role := node.role
|
||||
if _, skip := axoSkipRoles[role]; role == "" || skip {
|
||||
// Nodos sin role util: elevar los hijos al nivel actual.
|
||||
for _, cid := range node.childIDs {
|
||||
if child, ok := byID[cid]; ok {
|
||||
renderNode(child, depth)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
indent := strings.Repeat(" ", depth)
|
||||
var base string
|
||||
if node.name != "" {
|
||||
base = fmt.Sprintf("%s%s %q", indent, role, node.name)
|
||||
} else {
|
||||
base = indent + role
|
||||
}
|
||||
|
||||
// Estado actual del campo (texto escrito, valor de slider/combobox).
|
||||
if node.value != "" {
|
||||
base += " = " + axoPyRepr(node.value)
|
||||
}
|
||||
|
||||
// Ref accionable, sin padding.
|
||||
if _, ok := axoActionableRoles[role]; ok {
|
||||
ref := axoRefID(node)
|
||||
if ref != "" {
|
||||
base += " #ref=" + ref
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, base)
|
||||
|
||||
for _, cid := range node.childIDs {
|
||||
if child, ok := byID[cid]; ok {
|
||||
renderNode(child, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
renderNode(root, 0)
|
||||
}
|
||||
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
if maxChars > 0 && len(result) > maxChars {
|
||||
result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f")
|
||||
result += "\n…[outline truncado]"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM
|
||||
// real, estable mientras el nodo viva) con fallback al nodeId. Igual que
|
||||
// _ref_id() del .py.
|
||||
func axoRefID(n axNode) string {
|
||||
if n.backendDOMNodeID != "" {
|
||||
return n.backendDOMNodeID
|
||||
}
|
||||
return n.nodeID
|
||||
}
|
||||
|
||||
// axoPyRepr replica Python repr() para strings: comillas simples por defecto;
|
||||
// comillas dobles si la cadena contiene comilla simple pero no doble; escape de
|
||||
// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}`
|
||||
// del render_ax_outline.py para que la salida coincida al caracter.
|
||||
func axoPyRepr(s string) string {
|
||||
hasSingle := strings.Contains(s, "'")
|
||||
hasDouble := strings.Contains(s, "\"")
|
||||
quote := byte('\'')
|
||||
if hasSingle && !hasDouble {
|
||||
quote = '"'
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteByte(quote)
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch ch {
|
||||
case '\\':
|
||||
b.WriteString("\\\\")
|
||||
case '\n':
|
||||
b.WriteString("\\n")
|
||||
case '\r':
|
||||
b.WriteString("\\r")
|
||||
case '\t':
|
||||
b.WriteString("\\t")
|
||||
case quote:
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(quote)
|
||||
default:
|
||||
b.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
b.WriteByte(quote)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: cdp_get_ax_outline_go_browser
|
||||
name: cdp_get_ax_outline
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error)"
|
||||
description: "Percibe la pagina (o un iframe via frameID) como outline accesible indentado y accionable reusando la conexion CDP viva del pool. Envia Accessibility.enable + getFullAXTree, poda el arbol y lo renderiza con #ref=backendDOMNodeId en roles accionables. Replica al caracter el pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline pero nativo en Go, sin subprocess ni venv."
|
||||
tags: [browser, cdp, ax, accessibility, perceive, iframe, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests: ["TestRenderAXOutline_ActionableRoleCarriesRef", "TestRenderAXOutline_InputShowsValue", "TestRenderAXOutline_SkipRoleElevatesChildren", "TestRenderAXOutline_IndentationPerLevel", "TestRenderAXOutline_TruncationAddsSuffix", "TestTrimAXTree_DiscardsIgnored", "TestTrimAXTree_CollapsesSameRoleSingleChild", "TestAxoPyRepr", "TestAxoParseNodes"]
|
||||
test_file_path: "functions/browser/cdp_get_ax_outline_test.go"
|
||||
file_path: "functions/browser/cdp_get_ax_outline.go"
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP viva (*CDPConn) del pool, ya conectada al tab/target objetivo. No abre WebSocket nuevo: reusa la del pool. Nil devuelve error."
|
||||
- name: frameID
|
||||
desc: "frameId CDP del iframe a percibir. Cadena vacia ('') percibe el arbol completo de la pagina (depth -1). Con valor, obtiene el AX tree DENTRO de ese iframe."
|
||||
- name: maxChars
|
||||
desc: "Limite de caracteres del outline. >0 trunca y añade '\\n…[outline truncado]'. <=0 = sin limite."
|
||||
output: "Outline accesible multi-linea: 2 espacios de indentacion por nivel, 'role \"name\"' por nodo, ' = '\\''value'\\''' en inputs, y marcador ' #ref=<backendDOMNodeId>' en roles accionables. Cadena vacia si no hay nodos utiles."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// c es una *CDPConn viva del pool (la misma que usa el browser_mcp).
|
||||
// Percibir la pagina entera, truncando a 8000 chars:
|
||||
outline, err := CdpGetAXOutline(c, "", 8000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(outline)
|
||||
// WebArea "Example Domain"
|
||||
// heading "Example Domain"
|
||||
// link "More information..." #ref=128
|
||||
|
||||
// Percibir DENTRO de un iframe concreto (frameId del frame tree):
|
||||
inner, err := CdpGetAXOutline(c, "F1A2B3C4D5E6", 0) // 0 = sin limite
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites **percibir la pagina (o un iframe) como outline accionable** para que un LLM decida sobre `#ref` sin reventar el contexto.
|
||||
- **Reemplaza el subprocess Python** `fn run cdp_perceive_outline`: es nativo Go, reusa la conexion CDP viva del pool y no arranca el venv en cada percepcion (mas rapido y sin dependencia de runtime `fn`/venv).
|
||||
- Pasa `frameID` cuando el contenido objetivo vive dentro de un iframe; deja `frameID=""` para la pagina top-level.
|
||||
- El `#ref` que devuelve (backendDOMNodeId) se pasa luego a `cdp_click_ref` / `cdp_type_ref` / `cdp_hover_ref`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: requiere un Chrome vivo con CDP accesible y el dominio `Accessibility` disponible. `Accessibility.enable` se envia siempre (idempotente).
|
||||
- **Conexion nula** devuelve error inmediato; no intenta reconectar.
|
||||
- **OOPIF cross-origin**: un iframe de distinto origen corre en un target (proceso) separado. Si `Accessibility.getFullAXTree` con ese `frameId` no devuelve nodos, probablemente necesites una `*CDPConn` adjunta al target del frame, no el `frameId` desde el target padre.
|
||||
- **`#ref` = backendDOMNodeId**: estable mientras el nodo DOM viva, pero si la pagina re-renderiza ese subarbol el ref puede invalidarse. Percibe de nuevo tras una mutacion grande antes de actuar.
|
||||
- El outline omite roles `none`/`presentation`/`ignored` y nodos `ignored=true`, y eleva sus hijos al nivel actual; un arbol con todo ignorado devuelve cadena vacia.
|
||||
- Guard de profundidad 60 y guard de ciclo: arboles patologicos no cuelgan, pero pueden quedar recortados a partir de la profundidad 60.
|
||||
@@ -0,0 +1,279 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- renderAXOutline: casos clave portados de render_ax_outline.py ---
|
||||
|
||||
func TestRenderAXOutline_ActionableRoleCarriesRef(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "Page", childIDs: []string{"2"}},
|
||||
{nodeID: "2", backendDOMNodeID: "555", role: "button", name: "Submit"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"Page\"\n button \"Submit\" #ref=555"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_NonActionableHasNoRef(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", backendDOMNodeID: "9", role: "heading", name: "Title"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
if strings.Contains(got, "#ref") {
|
||||
t.Errorf("rol no accionable no debe llevar #ref: %q", got)
|
||||
}
|
||||
if got != "heading \"Title\"" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_InputShowsValue(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "form", childIDs: []string{"2"}},
|
||||
{nodeID: "2", backendDOMNodeID: "42", role: "textbox", name: "Email", value: "a@b.com"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "form\n textbox \"Email\" = 'a@b.com' #ref=42"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_ValueWithSingleQuoteUsesDoubleQuote(t *testing.T) {
|
||||
// Python repr: "it's" -> "it's" (comilla doble como delimitador).
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", backendDOMNodeID: "7", role: "textbox", value: "it's"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "textbox = \"it's\" #ref=7"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_SkipRoleElevatesChildren(t *testing.T) {
|
||||
// El nodo 'none' se omite; su hijo button sube al nivel del padre (depth 1,
|
||||
// no depth 2), porque el render del skip-node reusa el mismo depth.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "Root", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "none", childIDs: []string{"3"}},
|
||||
{nodeID: "3", backendDOMNodeID: "30", role: "button", name: "Go"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"Root\"\n button \"Go\" #ref=30"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_EmptyRoleElevatesChildren(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "", childIDs: []string{"2"}}, // sin role: se omite
|
||||
{nodeID: "2", backendDOMNodeID: "20", role: "link", name: "Home"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
// El nodo raiz sin role eleva su hijo a depth 0.
|
||||
want := "link \"Home\" #ref=20"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_IndentationPerLevel(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "A", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "group", name: "B", childIDs: []string{"3"}},
|
||||
{nodeID: "3", role: "group", name: "C"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "WebArea \"A\"\n group \"B\"\n group \"C\""
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_TruncationAddsSuffix(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "WebArea", name: "AAAAAAAAAAAAAAAAAAAA"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 10)
|
||||
if !strings.HasSuffix(got, "\n…[outline truncado]") {
|
||||
t.Errorf("falta sufijo de truncado: %q", got)
|
||||
}
|
||||
// El cuerpo truncado (sin sufijo) no debe exceder los 10 chars.
|
||||
body := strings.TrimSuffix(got, "\n…[outline truncado]")
|
||||
if len([]byte(body)) > 10 {
|
||||
t.Errorf("cuerpo truncado mas largo que maxChars: %q (%d bytes)", body, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_NoTruncationWhenUnderLimit(t *testing.T) {
|
||||
nodes := []axNode{{nodeID: "1", role: "button", name: "X", backendDOMNodeID: "1"}}
|
||||
got := renderAXOutline(nodes, 1000)
|
||||
if strings.Contains(got, "truncado") {
|
||||
t.Errorf("no debe truncar bajo el limite: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_Empty(t *testing.T) {
|
||||
if got := renderAXOutline(nil, 0); got != "" {
|
||||
t.Errorf("nil -> %q, want vacio", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_RefFallsBackToNodeID(t *testing.T) {
|
||||
// Sin backendDOMNodeId, el #ref usa el nodeId.
|
||||
nodes := []axNode{
|
||||
{nodeID: "77", role: "button", name: "Fallback"},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
want := "button \"Fallback\" #ref=77"
|
||||
if got != want {
|
||||
t.Errorf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAXOutline_CycleGuard(t *testing.T) {
|
||||
// Ciclo 1 -> 2 -> 1: no debe colgar ni duplicar nodos.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "group", name: "A", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "group", name: "B", childIDs: []string{"1"}},
|
||||
}
|
||||
got := renderAXOutline(nodes, 0)
|
||||
if strings.Count(got, "group \"A\"") != 1 {
|
||||
t.Errorf("nodo A renderizado mas de una vez: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- trimAXTree: casos clave portados de trim_ax_tree.py ---
|
||||
|
||||
func TestTrimAXTree_DiscardsIgnored(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "button", name: "Keep"},
|
||||
{nodeID: "2", role: "button", name: "Drop", ignored: true},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 1 || got[0].nodeID != "1" {
|
||||
t.Errorf("trim debe descartar ignored: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_DiscardsEmptyGeneric(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "generic"}, // sin name ni childIds -> descartado
|
||||
{nodeID: "2", role: "none"}, // idem
|
||||
{nodeID: "3", role: "StaticText", name: ""}, // staticText vacio -> descartado
|
||||
{nodeID: "4", role: "StaticText", name: "Hola"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 1 || got[0].nodeID != "4" {
|
||||
t.Errorf("trim debe descartar generic/none/staticText vacios: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_KeepsGenericWithChildren(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "generic", childIDs: []string{"2"}}, // tiene hijos -> se queda
|
||||
{nodeID: "2", role: "button", name: "X"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("generic con hijos debe conservarse: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_CollapsesSameRoleSingleChild(t *testing.T) {
|
||||
// list -> list (1 hijo, mismo role): se fusiona, el padre hereda los childIds.
|
||||
nodes := []axNode{
|
||||
{nodeID: "1", role: "list", childIDs: []string{"2"}},
|
||||
{nodeID: "2", role: "list", childIDs: []string{"3"}},
|
||||
{nodeID: "3", role: "listitem", name: "item"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
// Nodo 2 desaparece; nodo 1 debe apuntar ahora a 3.
|
||||
var saw1, saw2 bool
|
||||
var node1 axNode
|
||||
for _, n := range got {
|
||||
if n.nodeID == "1" {
|
||||
saw1 = true
|
||||
node1 = n
|
||||
}
|
||||
if n.nodeID == "2" {
|
||||
saw2 = true
|
||||
}
|
||||
}
|
||||
if !saw1 || saw2 {
|
||||
t.Fatalf("colapso fallido: saw1=%v saw2=%v got=%+v", saw1, saw2, got)
|
||||
}
|
||||
if len(node1.childIDs) != 1 || node1.childIDs[0] != "3" {
|
||||
t.Errorf("padre fusionado debe heredar childIds del hijo: %+v", node1.childIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_PreservesOrder(t *testing.T) {
|
||||
nodes := []axNode{
|
||||
{nodeID: "3", role: "button", name: "C"},
|
||||
{nodeID: "1", role: "button", name: "A"},
|
||||
{nodeID: "2", role: "button", name: "B"},
|
||||
}
|
||||
got := trimAXTree(nodes)
|
||||
if len(got) != 3 || got[0].nodeID != "3" || got[1].nodeID != "1" || got[2].nodeID != "2" {
|
||||
t.Errorf("orden original no preservado: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimAXTree_Empty(t *testing.T) {
|
||||
if got := trimAXTree(nil); got != nil {
|
||||
t.Errorf("nil -> %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- axoPyRepr: paridad con Python repr() ---
|
||||
|
||||
func TestAxoPyRepr(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"hola", "'hola'"},
|
||||
{"it's", "\"it's\""}, // tiene ', no " -> delimitador "
|
||||
{"say \"hi\"", "'say \"hi\"'"}, // tiene " -> delimitador '
|
||||
{"both ' and \"", "'both \\' and \"'"}, // ambos -> ' con escape del '
|
||||
{"a\nb", "'a\\nb'"},
|
||||
{"back\\slash", "'back\\\\slash'"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := axoPyRepr(c.in); got != c.want {
|
||||
t.Errorf("axoPyRepr(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- axoParseNodes: extraccion del map CDP (numeros como float64) ---
|
||||
|
||||
func TestAxoParseNodes(t *testing.T) {
|
||||
result := map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{
|
||||
"nodeId": "1",
|
||||
"backendDOMNodeId": float64(555), // CDP int llega como float64
|
||||
"ignored": false,
|
||||
"role": map[string]any{"value": "button"},
|
||||
"name": map[string]any{"value": "Go"},
|
||||
"value": map[string]any{"value": "x"},
|
||||
"childIds": []any{"2", "3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := axoParseNodes(result)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d nodos, want 1", len(got))
|
||||
}
|
||||
n := got[0]
|
||||
if n.nodeID != "1" || n.backendDOMNodeID != "555" || n.role != "button" ||
|
||||
n.name != "Go" || n.value != "x" || len(n.childIDs) != 2 {
|
||||
t.Errorf("parse incorrecto: %+v", n)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpGetHTML(c *CDPConn) (string, error)"
|
||||
description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original."
|
||||
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools]
|
||||
tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools, navegator]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -35,6 +35,16 @@ html, err := CdpGetHTML(conn)
|
||||
// html contiene el DOM completo con todos los cambios JS aplicados
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites el HTML completo del DOM vivo (post-JavaScript) para parsear/extraer con un selector externo, guardar un snapshot fiel, o alimentar un parser HTML. Ideal para scraping de SPAs (React, Vue, Angular) donde el HTML fuente original está vacío.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Devuelve el HTML COMPLETO sin límite, a propósito**: no trunca ni resume. En páginas complejas pueden ser cientos de KB. Esto es deliberado: su trabajo es dar el DOM íntegro para parsing fiel, no un resumen.
|
||||
- **NO usar para alimentar un LLM directamente**: el HTML crudo quema tokens y trae ruido (scripts, estilos inline, atributos). Para contexto de modelo usa `cdp_get_text` (innerText, con `maxBytes` opcional) o `cdp_perceive_outline` (outline accesible con #refs accionables). Reserva `cdp_get_html` para parsing programático.
|
||||
- **Es el DOM actual, no el HTML fuente**: incluye los cambios que el JavaScript haya aplicado hasta el instante de la llamada. Si la página sigue hidratando, espera con `cdp_wait_idle` antes.
|
||||
|
||||
## Notas
|
||||
|
||||
A diferencia de `Page.getResourceContent`, esta funcion captura el estado actual del DOM incluyendo modificaciones hechas por JavaScript. Ideal para scraping de SPAs (React, Vue, Angular). El HTML retornado puede ser muy largo para paginas complejas.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// CdpGetTextInFrame retorna el texto visible (innerText) del documento de un
|
||||
// iframe especifico, componiendo sobre CdpEvalInFrame con un mundo aislado CDP.
|
||||
//
|
||||
// Lee document.body.innerText (cae a document.documentElement.innerText si no
|
||||
// hay body), evitando parsear HTML crudo. Replica la politica de truncado de
|
||||
// CdpGetText: si maxBytes > 0 trunca al limite dado con corte rune-safe y añade
|
||||
// un sufijo con el total original en bytes; si maxBytes <= 0 no hay limite.
|
||||
//
|
||||
// Propaga los errores de CdpEvalInFrame (frame inexistente, contexto caducado)
|
||||
// envueltos con %w.
|
||||
func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("cdp get text in frame: conexion nula")
|
||||
}
|
||||
if frameID == "" {
|
||||
return "", fmt.Errorf("cdp get text in frame: frameID vacio")
|
||||
}
|
||||
|
||||
const expr = `(document.body ? document.body.innerText : document.documentElement.innerText) || ""`
|
||||
|
||||
text, err := CdpEvalInFrame(c, frameID, expr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdp get text in frame: %w", err)
|
||||
}
|
||||
|
||||
if maxBytes > 0 && len(text) > maxBytes {
|
||||
total := len(text)
|
||||
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
|
||||
cut := maxBytes
|
||||
for cut > 0 && !utf8.RuneStart(text[cut]) {
|
||||
cut--
|
||||
}
|
||||
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: cdp_get_text_in_frame_go_browser
|
||||
name: cdp_get_text_in_frame
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Devuelve el texto visible (innerText) del documento de un iframe concreto componiendo sobre CdpEvalInFrame en un mundo aislado CDP, sin parsear HTML crudo. Trunca a maxBytes con corte rune-safe igual que CdpGetText."
|
||||
tags: [browser, cdp, iframe, frame, text, navegator]
|
||||
signature: "func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error)"
|
||||
uses_functions: [cdp_eval_in_frame_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_get_text_in_frame.go"
|
||||
example: |
|
||||
conn, _ := CdpConnect("localhost", 9222, "")
|
||||
frames, _ := CdpListFrames(conn)
|
||||
text, err := CdpGetTextInFrame(conn, frames[1].ID, 4096)
|
||||
fmt.Println(text) // texto visible del primer iframe, truncado a 4096 bytes
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa obtenida con CdpConnect."
|
||||
- name: frameID
|
||||
desc: "ID del frame cuyo texto visible se quiere leer; obtenido de CdpListFrames (campo CdpFrame.ID)."
|
||||
- name: maxBytes
|
||||
desc: "Límite de bytes del texto devuelto. Si maxBytes > 0 trunca con corte rune-safe y añade un sufijo con el total original; si maxBytes <= 0 no hay límite."
|
||||
output: "String con el innerText visible del documento del iframe (document.body.innerText, o document.documentElement.innerText si no hay body), opcionalmente truncado a maxBytes; error si la conexión es nula, el frameID está vacío o la evaluación CDP del frame falla."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, err := CdpConnect("localhost", 9222, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 1. Listar frames para localizar el iframe deseado
|
||||
frames, err := CdpListFrames(conn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. Leer el texto visible de cada iframe (saltando el frame raíz)
|
||||
for _, f := range frames {
|
||||
if f.ParentID == "" { // frame raíz, no es un iframe
|
||||
continue
|
||||
}
|
||||
text, err := CdpGetTextInFrame(conn, f.ID, 4096)
|
||||
if err != nil {
|
||||
log.Printf("error en frame %s: %v", f.ID, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, text)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites leer los datos visibles dentro de un iframe sin parsear HTML crudo: extraer el contenido textual de un widget embebido, un panel de pago, un captcha de texto o cualquier documento dentro de un `<iframe>`. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetTextInFrame`. Para HTML estructural completo usa `CdpGetFrameHTML`; para texto visible usa esta.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Impura: el frame debe existir y haber terminado de cargar. Un `frameID` obsoleto (frame recargado/navegado) o un frame aún sin cargar propaga el error de `CdpEvalInFrame`.
|
||||
- Cross-origin OOPIF (out-of-process iframe): el mundo aislado puede vivir en un contexto distinto; si el frame es de otro origen y aislado del proceso, la lectura puede fallar o requerir el `frameID` exacto del OOPIF.
|
||||
- `innerText` omite el texto oculto por CSS (`display:none`, `visibility:hidden`) y colapsa espacios; refleja lo *visible*, no el contenido literal del DOM. Si necesitas todo el texto del DOM usa `textContent` vía `CdpEvalInFrame`, o el HTML completo vía `CdpGetFrameHTML`.
|
||||
- El corte por `maxBytes` es rune-safe pero ciego al contenido: puede cortar a mitad de una palabra o de una línea.
|
||||
@@ -0,0 +1,21 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpGetTextInFrame_guards cubre las precondiciones sin necesitar Chrome vivo.
|
||||
// La lectura real del innerText de un iframe requiere una conexion CDP activa y
|
||||
// un frame cargado, igual que los demas tests del paquete que la dejan gated.
|
||||
func TestCdpGetTextInFrame_guards(t *testing.T) {
|
||||
t.Run("conexion nula", func(t *testing.T) {
|
||||
if _, err := CdpGetTextInFrame(nil, "f1", 0); err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
})
|
||||
t.Run("frameID vacio", func(t *testing.T) {
|
||||
if _, err := CdpGetTextInFrame(&CDPConn{}, "", 0); err == nil {
|
||||
t.Fatal("esperaba error con frameID vacio")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,35 +1,106 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DialogLog acumula lo que CdpHandleDialog auto-respondió. El worker lo rellena en
|
||||
// cada diálogo; el caller lo lee con Snapshot() de forma segura (mutex interno).
|
||||
// Los campos son públicos para inspección directa en tests controlados, pero en
|
||||
// concurrencia usa siempre Snapshot() para evitar data races.
|
||||
type DialogLog struct {
|
||||
mu sync.Mutex
|
||||
Count int // número de diálogos auto-respondidos
|
||||
LastType string // tipo del último diálogo: alert|confirm|prompt|beforeunload
|
||||
LastMessage string // mensaje del último diálogo
|
||||
}
|
||||
|
||||
// record registra un diálogo auto-respondido. Es el núcleo puro (no toca CDP).
|
||||
func (l *DialogLog) record(dialogType, message string) {
|
||||
l.mu.Lock()
|
||||
l.Count++
|
||||
l.LastType = dialogType
|
||||
l.LastMessage = message
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshot devuelve una copia consistente del estado actual del log.
|
||||
func (l *DialogLog) Snapshot() (count int, lastType, lastMessage string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.Count, l.LastType, l.LastMessage
|
||||
}
|
||||
|
||||
// dialogJobBuffer es el tamaño del canal que desacopla el readLoop del worker
|
||||
// que responde diálogos. Amplio para absorber ráfagas sin bloquear la lectura
|
||||
// del WebSocket.
|
||||
const dialogJobBuffer = 64
|
||||
|
||||
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
|
||||
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
|
||||
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
|
||||
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame la
|
||||
// funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
|
||||
// Page.handleJavaScriptDialog del protocolo CDP.
|
||||
//
|
||||
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
|
||||
// para evitar deadlock — el evento llega en la goroutine de lectura del
|
||||
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
|
||||
// goroutine si se llamara de forma sincrona.
|
||||
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
|
||||
// Devuelve, además del cancel, un *DialogLog que el handler rellena en cada
|
||||
// diálogo: así el caller sabe cuántos diálogos se auto-respondieron y cuál fue
|
||||
// el último (tipo + mensaje).
|
||||
//
|
||||
// Concurrencia: el handler de evento corre en la goroutine de lectura del
|
||||
// WebSocket y NO puede llamar sendCDP de forma síncrona (deadlock). En vez de
|
||||
// lanzar una goroutine nueva por diálogo (spawn ilimitado), encola el evento en
|
||||
// un canal con buffer que consume UN único worker; el worker serializa las
|
||||
// respuestas. cancel() detiene el worker y des-registra el handler; es
|
||||
// idempotente (seguro llamarlo varias veces).
|
||||
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
|
||||
return nil, nil, fmt.Errorf("cdp handle dialog: conexion nula")
|
||||
}
|
||||
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return nil, fmt.Errorf("cdp handle dialog: %w", err)
|
||||
return nil, nil, fmt.Errorf("cdp handle dialog: %w", err)
|
||||
}
|
||||
|
||||
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
|
||||
p := map[string]any{"accept": accept}
|
||||
if promptText != "" {
|
||||
p["promptText"] = promptText
|
||||
dlog := &DialogLog{}
|
||||
jobs := make(chan map[string]any, dialogJobBuffer)
|
||||
done := make(chan struct{})
|
||||
|
||||
// Worker único: serializa las respuestas a diálogos. Una sola goroutine para
|
||||
// toda la vida del handler, no una por diálogo.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case params := <-jobs:
|
||||
dtype, _ := params["type"].(string)
|
||||
msg, _ := params["message"].(string)
|
||||
dlog.record(dtype, msg)
|
||||
p := map[string]any{"accept": accept}
|
||||
if promptText != "" {
|
||||
p["promptText"] = promptText
|
||||
}
|
||||
_, _ = c.sendCDP("Page.handleJavaScriptDialog", p)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancelEvent := c.OnEvent("Page.javascriptDialogOpening", func(_ string, params map[string]any) {
|
||||
// Encolar sin bloquear el readLoop. Si el buffer está lleno (tormenta de
|
||||
// diálogos), descartamos ese evento para no colgar la conexión entera.
|
||||
select {
|
||||
case jobs <- params:
|
||||
default:
|
||||
}
|
||||
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
|
||||
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
|
||||
// sendCDP espera una respuesta que la misma goroutine deberia leer.
|
||||
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
|
||||
})
|
||||
|
||||
return cancel, nil
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
cancelEvent()
|
||||
close(done)
|
||||
})
|
||||
}
|
||||
|
||||
return cancel, dlog, nil
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestCdpHandleDialog_nilConn", "TestDialogLog"]
|
||||
test_file_path: "functions/browser/cdp_handle_dialog_test.go"
|
||||
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto. Devuelve un *DialogLog con Count/LastType/LastMessage de lo auto-respondido. Un unico worker serializa las respuestas (no spawnea una goroutine por dialogo)."
|
||||
tags: [cdp, browser, dialog, input, navegator]
|
||||
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
|
||||
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), *DialogLog, error)"
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -32,7 +32,7 @@ params:
|
||||
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
|
||||
- name: promptText
|
||||
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
|
||||
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
|
||||
output: "(cancel func(), *DialogLog, error): cancel des-registra el handler y detiene el worker (idempotente, seguro llamarlo varias veces); DialogLog acumula Count/LastType/LastMessage de lo auto-respondido (leer con Snapshot()); error si la conexion es nula o Page.enable falla."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
@@ -40,10 +40,10 @@ output: "cancel func() para des-registrar el handler cuando ya no se necesite, y
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
_ = CdpNavigate(conn, "https://example.com/admin")
|
||||
_ = CdpWaitLoad(conn, 3000)
|
||||
_ = CdpWaitLoad(conn, 3*time.Second)
|
||||
|
||||
// Instalar handler antes de la accion que dispara el dialogo
|
||||
cancel, err := CdpHandleDialog(conn, true, "")
|
||||
cancel, dlog, err := CdpHandleDialog(conn, true, "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -52,13 +52,11 @@ defer cancel()
|
||||
// Este boton dispara confirm("¿Seguro que quieres borrar?")
|
||||
// El handler lo acepta automaticamente sin bloquear
|
||||
_ = CdpClick(conn, "#btn-delete-all")
|
||||
_ = CdpWaitIdle(conn, 2000)
|
||||
_ = CdpWaitIdle(conn, CdpWaitIdleOpts{})
|
||||
|
||||
// Ejemplo con prompt(): responder con texto especifico
|
||||
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
|
||||
defer cancelPrompt()
|
||||
_ = CdpClick(conn, "#btn-ask-password")
|
||||
_ = CdpWaitIdle(conn, 1000)
|
||||
// Saber qué se auto-respondió
|
||||
count, lastType, lastMsg := dlog.Snapshot()
|
||||
fmt.Printf("auto-respondidos: %d (último %s: %q)\n", count, lastType, lastMsg)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
@@ -67,8 +65,13 @@ Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `p
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
|
||||
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
|
||||
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
|
||||
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
|
||||
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
|
||||
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion encola el evento en un canal y lo responde desde UN worker aparte — no modificar este patron.
|
||||
- **Un único worker, no goroutine por diálogo**: el handler antiguo hacía `go c.sendCDP(...)` por cada diálogo (spawn ilimitado). Ahora encola en un canal con buffer (64) que consume un worker. Si la página dispara una tormenta de diálogos que llena el buffer, los excedentes se descartan (no se responden) para no colgar la conexión — caso patológico, raro en la práctica.
|
||||
- **Leer el log con `Snapshot()`**: `DialogLog` tiene mutex interno. En concurrencia, usa `dlog.Snapshot()` en vez de leer los campos públicos directamente (evita data race con el worker).
|
||||
- El handler responde todos los diálogos con los mismos `accept` y `promptText` hasta que se llame `cancel()`.
|
||||
- `cancel()` es idempotente (seguro llamarlo varias veces) y detiene el worker. No cierra diálogos ya abiertos; solo evita responder los futuros.
|
||||
- Para `beforeunload`, `accept: true` permite la navegacion y `accept: false` la bloquea.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — devuelve `*DialogLog` (Count/LastType/LastMessage) para que el caller sepa qué se auto-respondió; reemplaza el spawn de una goroutine por diálogo por un worker único alimentado por canal con buffer; `cancel()` ahora idempotente vía sync.Once.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCdpHandleDialog_nilConn cubre la precondición sin Chrome.
|
||||
func TestCdpHandleDialog_nilConn(t *testing.T) {
|
||||
_, _, err := CdpHandleDialog(nil, true, "")
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error con conexion nula")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialogLog cubre el núcleo puro del registro de diálogos: contar, recordar
|
||||
// el último, y la seguridad concurrente del mutex. No requiere Chrome.
|
||||
func TestDialogLog(t *testing.T) {
|
||||
t.Run("golden: cuenta y recuerda el ultimo", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
l.record("alert", "hola")
|
||||
l.record("confirm", "¿seguro?")
|
||||
count, lastType, lastMsg := l.Snapshot()
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, esperaba 2", count)
|
||||
}
|
||||
if lastType != "confirm" || lastMsg != "¿seguro?" {
|
||||
t.Errorf("last = (%q,%q), esperaba (confirm, ¿seguro?)", lastType, lastMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: log vacio", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
count, lastType, lastMsg := l.Snapshot()
|
||||
if count != 0 || lastType != "" || lastMsg != "" {
|
||||
t.Errorf("log vacio = (%d,%q,%q), esperaba (0,\"\",\"\")", count, lastType, lastMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrencia: 100 records desde N goroutines no pierde cuentas", func(t *testing.T) {
|
||||
l := &DialogLog{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
l.record("alert", "x")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if count, _, _ := l.Snapshot(); count != 100 {
|
||||
t.Errorf("count = %d, esperaba 100 (sin perder por race)", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,12 +9,21 @@ import (
|
||||
|
||||
// MouseHumanOpts configura el movimiento humano del ratón.
|
||||
type MouseHumanOpts struct {
|
||||
// Steps es el número de puntos intermedios de la curva (default 25).
|
||||
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
|
||||
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
|
||||
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
|
||||
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
|
||||
// para scraping masivo propio).
|
||||
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
|
||||
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
|
||||
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
|
||||
Mode string
|
||||
// Steps es el número de puntos intermedios de la curva (default según Mode).
|
||||
Steps int
|
||||
// DurationMs es la duración total aproximada del movimiento en milisegundos.
|
||||
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
|
||||
// Si es 0, se elige según Mode.
|
||||
DurationMs int
|
||||
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
|
||||
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default según Mode).
|
||||
JitterPx float64
|
||||
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
|
||||
FromX float64
|
||||
@@ -22,16 +31,49 @@ type MouseHumanOpts struct {
|
||||
FromY float64
|
||||
}
|
||||
|
||||
// mouseHumanDefaults aplica valores por defecto a opts.
|
||||
// MouseProfileForMode construye las opciones de ratón para un modo de velocidad.
|
||||
// Es la fuente única que MCP, runner YAML y CLI usan para mapear un modo a opts,
|
||||
// sin duplicar números. El mapeo modo→valores concretos vive en mouseHumanDefaults.
|
||||
// Un modo desconocido se trata como "human" (el más seguro).
|
||||
func MouseProfileForMode(mode string) MouseHumanOpts {
|
||||
switch mode {
|
||||
case "fast", "instant", "human", "":
|
||||
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
|
||||
default:
|
||||
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
|
||||
}
|
||||
}
|
||||
|
||||
// mouseHumanDefaults aplica valores por defecto a opts según opts.Mode.
|
||||
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 25
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 350 + rand.Intn(451) // 350..800
|
||||
}
|
||||
if opts.JitterPx <= 0 {
|
||||
opts.JitterPx = 2.0
|
||||
switch opts.Mode {
|
||||
case "instant":
|
||||
// El movimiento se omite en CdpMoveMouseHuman; valores mínimos por si acaso.
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 1
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 1
|
||||
}
|
||||
// JitterPx se queda en 0.
|
||||
case "fast":
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 5
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 40 + rand.Intn(41) // 40..80
|
||||
}
|
||||
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
|
||||
default: // "human" o ""
|
||||
if opts.Steps <= 0 {
|
||||
opts.Steps = 25
|
||||
}
|
||||
if opts.DurationMs <= 0 {
|
||||
opts.DurationMs = 350 + rand.Intn(451) // 350..800
|
||||
}
|
||||
if opts.JitterPx <= 0 {
|
||||
opts.JitterPx = 2.0
|
||||
}
|
||||
}
|
||||
if opts.FromX < 0 {
|
||||
opts.FromX = 0
|
||||
@@ -119,6 +161,12 @@ func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error
|
||||
}
|
||||
opts = mouseHumanDefaults(opts)
|
||||
|
||||
// Modo instant: sin movimiento de ratón (el click lo resuelve quien llama,
|
||||
// por coords directas o por element.click() JS).
|
||||
if opts.Mode == "instant" {
|
||||
return nil
|
||||
}
|
||||
|
||||
p0 := [2]float64{opts.FromX, opts.FromY}
|
||||
p3 := [2]float64{toX, toY}
|
||||
ctrl1, ctrl2 := randomControlPoints(p0, p3)
|
||||
|
||||
@@ -15,21 +15,45 @@ type CdpStorageState struct {
|
||||
SessionStorage map[string]string `json:"sessionStorage"`
|
||||
}
|
||||
|
||||
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa. Si el
|
||||
// origen no permite acceso (about:blank, chrome://) devuelve un mapa vacío.
|
||||
func readWebStorage(c *CDPConn, store string) map[string]string {
|
||||
// isStorageAccessDenied reconoce el error de CdpEvaluate cuando el origen no
|
||||
// permite acceder a window.localStorage/sessionStorage (about:blank, chrome://,
|
||||
// data:, sandbox sin allow-same-origin): el navegador lanza SecurityError. Es
|
||||
// puro: decide a partir del texto del error. Distingue ese caso legítimo (no hay
|
||||
// storage que guardar -> {}) de un error real (conexión caída, JS roto) que SÍ
|
||||
// debe propagarse para no escribir una sesión incompleta en silencio.
|
||||
func isStorageAccessDenied(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "SecurityError") ||
|
||||
strings.Contains(s, "Access is denied") ||
|
||||
strings.Contains(s, "operation is insecure") ||
|
||||
strings.Contains(s, "denied for this document")
|
||||
}
|
||||
|
||||
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa.
|
||||
// Distingue tres casos:
|
||||
// - storage accesible (con o sin datos) -> (mapa, nil)
|
||||
// - origen sin storage accesible (about:blank, chrome://) -> ({}, nil)
|
||||
// - error REAL de evaluación (conexión caída, JS roto, JSON inválido) -> (nil, error)
|
||||
func readWebStorage(c *CDPConn, store string) (map[string]string, error) {
|
||||
raw, err := CdpEvaluate(c, "JSON.stringify(Object.assign({}, window."+store+"))")
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
if isStorageAccessDenied(err) {
|
||||
// Origen sin storage accesible: vacío legítimo, no error.
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("leer %s: %w", store, err)
|
||||
}
|
||||
if raw == "" || raw == "undefined" || raw == "null" {
|
||||
return map[string]string{}
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return map[string]string{}
|
||||
return nil, fmt.Errorf("parsear %s: %w", store, err)
|
||||
}
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// cookieDomainMatchesHost indica si una cookie con `domain` aplica al `host` dado.
|
||||
@@ -100,11 +124,21 @@ func CdpSaveStorageState(c *CDPConn, outPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Capturar localStorage y sessionStorage del origen actualmente cargado.
|
||||
// Capturar localStorage y sessionStorage del origen actualmente cargado. Un
|
||||
// error real (no un origen sin storage) aborta el guardado: mejor fallar que
|
||||
// escribir una sesión incompleta que el caller creería válida.
|
||||
localStorage, err := readWebStorage(c, "localStorage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp save storage state: %w", err)
|
||||
}
|
||||
sessionStorage, err := readWebStorage(c, "sessionStorage")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp save storage state: %w", err)
|
||||
}
|
||||
state := CdpStorageState{
|
||||
Cookies: cookies,
|
||||
LocalStorage: readWebStorage(c, "localStorage"),
|
||||
SessionStorage: readWebStorage(c, "sessionStorage"),
|
||||
LocalStorage: localStorage,
|
||||
SessionStorage: sessionStorage,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
|
||||
@@ -5,9 +5,11 @@ kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Captura cookies y localStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login."
|
||||
version: 1.1.0
|
||||
tested: true
|
||||
tests: ["TestIsStorageAccessDenied", "TestCookieDomainMatchesHost"]
|
||||
test_file_path: "functions/browser/cdp_save_storage_state_test.go"
|
||||
description: "Captura cookies, localStorage y sessionStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login. Distingue 'origen sin storage accesible' (vacío legítimo) de un error real de evaluación, que aborta el guardado en vez de escribir una sesión incompleta en silencio."
|
||||
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
|
||||
signature: "func CdpSaveStorageState(c *CDPConn, outPath string) error"
|
||||
uses_functions:
|
||||
@@ -58,5 +60,9 @@ Tras completar un login en el browser (manual o automatizado), antes de cerrar l
|
||||
|
||||
- **localStorage es por-origen**: solo captura el localStorage del origen actualmente cargado en la pestaña. Si necesitas preservar localStorage de múltiples dominios, guarda un estado por cada dominio navegado.
|
||||
- **Cookies globales del perfil**: `Network.getAllCookies` devuelve todas las cookies del perfil de Chrome, no solo las del origen activo. El JSON puede ser grande si el perfil tiene muchas cookies.
|
||||
- **Páginas especiales** (`about:blank`, `chrome://`, extensiones): `CdpEvaluate` sobre localStorage fallará; la función lo maneja devolviendo un mapa vacío de forma defensiva, así que no romperá — pero el localStorage quedará vacío en el JSON.
|
||||
- **Páginas especiales** (`about:blank`, `chrome://`, `data:`, extensiones): acceder a `window.localStorage` lanza `SecurityError`. La función lo detecta (`isStorageAccessDenied`) y devuelve `{}` legítimo, no error — el storage queda vacío en el JSON. **Pero** un error REAL (conexión caída, JS roto, JSON inválido) ahora SÍ se propaga y aborta el guardado: antes se tragaba en silencio y escribía una sesión incompleta que parecía válida.
|
||||
- **Permisos**: el archivo se escribe con `0644`; asegúrate de que el directorio de destino existe antes de llamar a la función.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-06) — `readWebStorage` distingue "origen sin storage accesible" (SecurityError → `{}`) de "error real de evaluación" (se propaga); `CdpSaveStorageState` aborta en error real en vez de guardar sesión incompleta en silencio; captura también sessionStorage; test del discriminador `isStorageAccessDenied` + del matcher `cookieDomainMatchesHost`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsStorageAccessDenied cubre el discriminador puro que separa "origen sin
|
||||
// storage accesible" (vacío legítimo) de "error real" (que debe propagarse).
|
||||
func TestIsStorageAccessDenied(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil no es denied", nil, false},
|
||||
{"error de conexion (real) no es denied", errors.New("cdp evaluate: ws read: EOF"), false},
|
||||
{"SecurityError es denied", errors.New("cdp evaluate: excepcion JS: SecurityError: ..."), true},
|
||||
{"Access is denied es denied", errors.New("Access is denied for this document"), true},
|
||||
{"operation is insecure es denied", errors.New("The operation is insecure"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isStorageAccessDenied(tc.err); got != tc.want {
|
||||
t.Errorf("isStorageAccessDenied(%v) = %v, esperaba %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieDomainMatchesHost cubre el matcher puro de dominio de cookie vs host
|
||||
// (no tenía test previo).
|
||||
func TestCookieDomainMatchesHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
domain, host string
|
||||
want bool
|
||||
}{
|
||||
{"example.com", "example.com", true}, // exacto
|
||||
{".example.com", "example.com", true}, // punto inicial
|
||||
{".example.com", "app.example.com", true}, // subdominio
|
||||
{"example.com", "app.example.com", true}, // subdominio sin punto
|
||||
{"example.com", "notexample.com", false}, // sufijo engañoso
|
||||
{"example.com", "example.com.evil.com", false}, // no es subdominio real
|
||||
{"", "example.com", false}, // dominio vacío
|
||||
{"example.com", "", false}, // host vacío
|
||||
{"other.com", "example.com", false}, // distinto
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := cookieDomainMatchesHost(tc.domain, tc.host)
|
||||
if got != tc.want {
|
||||
t.Errorf("cookieDomainMatchesHost(%q, %q) = %v, esperaba %v", tc.domain, tc.host, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,57 @@ type CdpScreenshotOpts struct {
|
||||
Format string
|
||||
}
|
||||
|
||||
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
|
||||
// fullPageClip es el rectangulo de recorte (en CSS pixels) que cubre la pagina
|
||||
// completa. scale=1 mantiene la resolucion nativa.
|
||||
type fullPageClip struct {
|
||||
X, Y, Width, Height, Scale float64
|
||||
}
|
||||
|
||||
// buildFullPageClip construye el clip de pagina completa a partir de la respuesta
|
||||
// de Page.getLayoutMetrics. Es una funcion pura: no toca red, recibe el mapa ya
|
||||
// deserializado por CDP y decide el rectangulo.
|
||||
//
|
||||
// Prefiere cssContentSize (dimensiones en CSS pixels, ya divididas por el DPR),
|
||||
// que es lo que espera el campo "clip" de Page.captureScreenshot. Cae a
|
||||
// contentSize (device pixels, protocolo antiguo) si cssContentSize no esta
|
||||
// presente. Devuelve ok=false cuando no hay un tamano valido (>0 en ambos ejes),
|
||||
// para que el caller capture solo el viewport en vez de un clip degenerado.
|
||||
func buildFullPageClip(metrics map[string]any) (fullPageClip, bool) {
|
||||
asFloat := func(v any) float64 {
|
||||
f, _ := v.(float64)
|
||||
return f
|
||||
}
|
||||
for _, key := range []string{"cssContentSize", "contentSize"} {
|
||||
size, ok := metrics[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w := asFloat(size["width"])
|
||||
h := asFloat(size["height"])
|
||||
if w > 0 && h > 0 {
|
||||
return fullPageClip{X: 0, Y: 0, Width: w, Height: h, Scale: 1}, true
|
||||
}
|
||||
}
|
||||
return fullPageClip{}, false
|
||||
}
|
||||
|
||||
// CdpScreenshotBytes captura un screenshot de la pagina actual y devuelve los
|
||||
// bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco.
|
||||
// Usa Page.captureScreenshot del protocolo CDP.
|
||||
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
|
||||
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
|
||||
//
|
||||
// El mimeType es "image/jpeg" cuando opts pide JPEG y "image/png" en cualquier
|
||||
// otro caso (incluido el default cuando opts.Format esta vacio).
|
||||
//
|
||||
// Si opts.FullPage es true, consulta Page.getLayoutMetrics para construir un clip
|
||||
// que cubra la altura completa del documento (no solo el viewport) y mantiene
|
||||
// captureBeyondViewport=true para que Chrome renderice mas alla del area visible.
|
||||
//
|
||||
// Es la primitiva reutilizable de captura: util para devolver la imagen al LLM
|
||||
// como image content (bytes) sin pasar por archivo. CdpScreenshot compone sobre
|
||||
// ella para persistir a disco.
|
||||
func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error) {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp screenshot: conexion nula")
|
||||
return nil, "", fmt.Errorf("cdp screenshot: conexion nula")
|
||||
}
|
||||
|
||||
if opts.Format == "" {
|
||||
@@ -32,8 +77,13 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
|
||||
opts.Quality = 80
|
||||
}
|
||||
|
||||
mimeType := "image/png"
|
||||
if opts.Format == "jpeg" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"format": opts.Format,
|
||||
"format": opts.Format,
|
||||
"captureBeyondViewport": opts.FullPage,
|
||||
}
|
||||
if opts.Format == "jpeg" {
|
||||
@@ -41,27 +91,52 @@ func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error
|
||||
}
|
||||
|
||||
if opts.FullPage {
|
||||
// Expandir clip para capturar toda la pagina
|
||||
scrollHeight, err := CdpEvaluate(c, "document.documentElement.scrollHeight")
|
||||
if err == nil {
|
||||
params["clip"] = nil // dejar que Chrome capture todo
|
||||
_ = scrollHeight
|
||||
// Page.getLayoutMetrics da el tamano real del documento. Construimos el
|
||||
// clip con la funcion pura buildFullPageClip. Si la consulta falla o no
|
||||
// hay dimensiones validas, omitimos el clip y caemos a captura normal
|
||||
// (con captureBeyondViewport=true Chrome aun captura algo razonable).
|
||||
if metrics, err := c.sendCDP("Page.getLayoutMetrics", nil); err == nil {
|
||||
if clip, ok := buildFullPageClip(metrics); ok {
|
||||
params["clip"] = map[string]any{
|
||||
"x": clip.X,
|
||||
"y": clip.Y,
|
||||
"width": clip.Width,
|
||||
"height": clip.Height,
|
||||
"scale": clip.Scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err := c.sendCDP("Page.captureScreenshot", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp screenshot: %w", err)
|
||||
return nil, "", fmt.Errorf("cdp screenshot: %w", err)
|
||||
}
|
||||
|
||||
dataStr, ok := result["data"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
|
||||
return nil, "", fmt.Errorf("cdp screenshot: campo data ausente en respuesta")
|
||||
}
|
||||
|
||||
imgData, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
|
||||
return nil, "", fmt.Errorf("cdp screenshot: decodificar base64: %w", err)
|
||||
}
|
||||
|
||||
return imgData, mimeType, nil
|
||||
}
|
||||
|
||||
// CdpScreenshot captura un screenshot de la pagina actual y lo guarda en outputPath.
|
||||
// outputPath debe tener extension .png o .jpg/.jpeg segun el formato elegido.
|
||||
//
|
||||
// Compone sobre CdpScreenshotBytes para obtener los bytes de imagen y luego crea
|
||||
// el directorio destino si no existe y escribe el archivo. Mismo comportamiento
|
||||
// observable que antes: mismos parametros, mismos efectos en disco, mismos
|
||||
// errores de captura.
|
||||
func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error {
|
||||
imgData, _, err := CdpScreenshotBytes(c, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Crear directorio si no existe
|
||||
|
||||
@@ -3,12 +3,12 @@ name: cdp_screenshot
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpScreenshot(c *CDPConn, outputPath string, opts CdpScreenshotOpts) error"
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. Crea el directorio destino si no existe."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png]
|
||||
uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser]
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y lo guarda en el archivo indicado. Soporta PNG y JPEG, viewport o pagina completa. En modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Crea el directorio destino si no existe. Compone sobre CdpScreenshotBytes para la captura a memoria."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, navegator]
|
||||
uses_functions: [cdp_screenshot_bytes_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -23,8 +23,8 @@ params:
|
||||
desc: "opciones de captura (FullPage, Quality, Format)"
|
||||
output: "error si falla la captura o la escritura del archivo"
|
||||
tested: true
|
||||
tests: ["TestCdpScreenshot"]
|
||||
test_file_path: "functions/browser/chrome_launch_test.go"
|
||||
tests: ["TestBuildFullPageClip", "TestCdpScreenshot"]
|
||||
test_file_path: "functions/browser/cdp_screenshot_test.go"
|
||||
file_path: "functions/browser/cdp_screenshot.go"
|
||||
---
|
||||
|
||||
@@ -40,6 +40,22 @@ err := CdpScreenshot(conn, "/tmp/page.png", CdpScreenshotOpts{
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para guardar evidencia visual de una página tras navegar o ejecutar acciones. Usa `FullPage: true` cuando necesites toda la altura del documento (capturas de auditoría, scraping visual de páginas largas); `false` (default) para capturar solo el viewport visible, más rápido.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
|
||||
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
|
||||
- **Formato según extensión**: la función no infiere el formato de la extensión del `outputPath`; pásalo explícito en `opts.Format` ("png" o "jpeg"). El default es "png".
|
||||
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
|
||||
|
||||
## Notas
|
||||
|
||||
El struct `CdpScreenshotOpts` tiene campos: `FullPage bool`, `Quality int` (JPEG), `Format string` ("png" o "jpeg"). Chrome retorna la imagen como base64 que se decodifica y escribe al disco.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — refactor a composición: toda la lógica de captura (enable/clip FullPage/captureScreenshot/decode base64) se extrae a `CdpScreenshotBytes` (`cdp_screenshot_bytes_go_browser`), que devuelve bytes + mimeType en memoria. `CdpScreenshot` ahora compone sobre ella + crea el directorio + escribe el archivo. Firma pública y comportamiento observable intactos.
|
||||
- v1.1.0 (2026-06-06) — FullPage implementado de verdad: clip desde Page.getLayoutMetrics (cssContentSize) vía función pura `buildFullPageClip`, en vez del código muerto que calculaba scrollHeight y lo descartaba.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: cdp_screenshot_bytes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpScreenshotBytes(c *CDPConn, opts CdpScreenshotOpts) ([]byte, string, error)"
|
||||
description: "Captura un screenshot de la pagina actual via Page.captureScreenshot y devuelve los bytes de imagen ya decodificados junto con su mimeType, sin tocar el disco. mimeType es image/jpeg si opts pide JPEG, si no image/png. Soporta viewport o pagina completa: en modo FullPage usa Page.getLayoutMetrics (cssContentSize) para construir un clip que cubre la altura real del documento. Primitiva reutilizable para devolver la imagen al LLM como image content."
|
||||
tags: [chrome, cdp, browser, automation, screenshot, devtools, png, image, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/base64, fmt]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa"
|
||||
- name: opts
|
||||
desc: "opciones de captura (FullPage, Quality, Format)"
|
||||
output: "bytes de imagen decodificados + mimeType (image/png o image/jpeg), o error si falla la captura"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_screenshot.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
imgData, mimeType, err := CdpScreenshotBytes(conn, CdpScreenshotOpts{
|
||||
FullPage: true,
|
||||
Format: "png",
|
||||
})
|
||||
// imgData: bytes PNG listos para enviar al LLM como image content
|
||||
// mimeType: "image/png"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas la imagen capturada en memoria, no en disco: típicamente para devolverla al LLM como image content (bytes + mimeType) en un MCP o tool, sin pasar por un archivo temporal. Es la primitiva de captura sobre la que compone `CdpScreenshot` (que persiste a disco). Úsala directamente cuando el destino no es el filesystem.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **FullPage usa el tamaño real del documento**: consulta `Page.getLayoutMetrics` y construye el clip desde `cssContentSize` (CSS pixels). Si Chrome no devuelve dimensiones válidas, cae a captura normal con `captureBeyondViewport=true` en vez de fallar.
|
||||
- **mimeType según opts, no según extensión**: devuelve `"image/jpeg"` solo cuando `opts.Format == "jpeg"`; en cualquier otro caso (incluido el default con `Format` vacío) devuelve `"image/png"`. No hay archivo, así que no infiere nada de una extensión.
|
||||
- **JPEG quality**: solo aplica si `Format == "jpeg"`; el default es 80.
|
||||
- **Páginas con lazy-loading**: el `cssContentSize` refleja el DOM en el instante de la captura. Si la página carga contenido al hacer scroll, haz scroll + `CdpWaitIdle` antes para que la altura sea la final.
|
||||
|
||||
## Notas
|
||||
|
||||
Adición de `cdp_screenshot` (estilo ADR 0003): el `.go` vive junto a `cdp_screenshot.go` en el mismo paquete `browser`. El struct `CdpScreenshotOpts` (campos `FullPage bool`, `Quality int`, `Format string`) es compartido con `CdpScreenshot`. Chrome retorna la imagen como base64; esta función la decodifica a `[]byte` y la devuelve sin escribir a disco. `CdpScreenshot` compone sobre esta primitiva añadiendo creación de directorio + escritura del archivo.
|
||||
@@ -0,0 +1,76 @@
|
||||
package browser
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBuildFullPageClip cubre el nucleo puro del modo FullPage: dado el mapa de
|
||||
// Page.getLayoutMetrics, construir el clip que cubre el documento entero. No
|
||||
// requiere Chrome.
|
||||
func TestBuildFullPageClip(t *testing.T) {
|
||||
t.Run("golden: pagina larga via cssContentSize", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{
|
||||
"x": 0.0, "y": 0.0, "width": 1280.0, "height": 8000.0,
|
||||
},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true para cssContentSize valido")
|
||||
}
|
||||
if clip.Width != 1280 || clip.Height != 8000 {
|
||||
t.Errorf("clip = %+v, esperaba width=1280 height=8000", clip)
|
||||
}
|
||||
if clip.X != 0 || clip.Y != 0 || clip.Scale != 1 {
|
||||
t.Errorf("clip = %+v, esperaba x=0 y=0 scale=1", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: viewport pequeno (pagina corta) sigue produciendo clip valido", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 320.0, "height": 480.0},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true para pagina corta")
|
||||
}
|
||||
if clip.Width != 320 || clip.Height != 480 {
|
||||
t.Errorf("clip = %+v, esperaba 320x480", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: fallback a contentSize cuando falta cssContentSize", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"contentSize": map[string]any{"width": 1024.0, "height": 2048.0},
|
||||
}
|
||||
clip, ok := buildFullPageClip(metrics)
|
||||
if !ok {
|
||||
t.Fatal("esperaba ok=true via contentSize")
|
||||
}
|
||||
if clip.Width != 1024 || clip.Height != 2048 {
|
||||
t.Errorf("clip = %+v, esperaba 1024x2048", clip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: dimensiones cero -> ok=false (captura solo viewport)", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 0.0, "height": 0.0},
|
||||
}
|
||||
if _, ok := buildFullPageClip(metrics); ok {
|
||||
t.Error("esperaba ok=false para dimensiones cero")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: pagina vacia (metrics sin tamano) -> ok=false", func(t *testing.T) {
|
||||
if _, ok := buildFullPageClip(map[string]any{}); ok {
|
||||
t.Error("esperaba ok=false para metrics vacio")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error: width valido pero height cero -> ok=false", func(t *testing.T) {
|
||||
metrics := map[string]any{
|
||||
"cssContentSize": map[string]any{"width": 800.0, "height": 0.0},
|
||||
}
|
||||
if _, ok := buildFullPageClip(metrics); ok {
|
||||
t.Error("esperaba ok=false cuando un eje es cero")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,17 +10,84 @@ import (
|
||||
type CdpWaitIdleOpts struct {
|
||||
QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500)
|
||||
Timeout time.Duration // maximo total a esperar (default 8s)
|
||||
MaxInflight int // requests en vuelo tolerados para considerar idle (default 0)
|
||||
MaxInflight int // requests en vuelo tolerados para considerar idle (default 2)
|
||||
PollMs int // intervalo de chequeo en ms (default 100)
|
||||
}
|
||||
|
||||
// isPersistentResourceType indica si un Network resourceType corresponde a una
|
||||
// conexion de larga duracion que NO emite loadingFinished/loadingFailed y por
|
||||
// tanto colgaria el contador inflight para siempre. La pagina abre estas
|
||||
// conexiones (analytics en vivo, push, hot-reload) y nunca "terminan".
|
||||
func isPersistentResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "WebSocket", "EventSource":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InflightTracker cuenta requests de red en vuelo de forma pura y testeable: no
|
||||
// toca red ni CDP, solo recibe eventos ya parseados (requestId + resourceType) y
|
||||
// mantiene el conjunto de requests activos. Trackea por requestId para que el
|
||||
// loadingFinished/loadingFailed de un request que nunca contamos (una conexion
|
||||
// persistente) sea un no-op en vez de un decremento espurio.
|
||||
//
|
||||
// Las conexiones persistentes (WebSocket, EventSource) se excluyen del conteo
|
||||
// porque no emiten un evento de finalizacion: contarlas haria que la red nunca
|
||||
// pareciera idle.
|
||||
type InflightTracker struct {
|
||||
mu sync.Mutex
|
||||
tracked map[string]bool
|
||||
}
|
||||
|
||||
// NewInflightTracker crea un tracker vacio listo para recibir eventos.
|
||||
func NewInflightTracker() *InflightTracker {
|
||||
return &InflightTracker{tracked: map[string]bool{}}
|
||||
}
|
||||
|
||||
// OnRequest registra el inicio de un request (Network.requestWillBeSent). Ignora
|
||||
// las conexiones persistentes para no contaminar el conteo.
|
||||
func (t *InflightTracker) OnRequest(requestID, resourceType string) {
|
||||
if isPersistentResourceType(resourceType) {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.tracked[requestID] = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnFinish marca un request como completado (Network.loadingFinished).
|
||||
func (t *InflightTracker) OnFinish(requestID string) { t.complete(requestID) }
|
||||
|
||||
// OnFail marca un request como fallido (Network.loadingFailed). A efectos de
|
||||
// inflight, fallar y terminar son lo mismo: el request ya no esta en vuelo.
|
||||
func (t *InflightTracker) OnFail(requestID string) { t.complete(requestID) }
|
||||
|
||||
func (t *InflightTracker) complete(requestID string) {
|
||||
t.mu.Lock()
|
||||
delete(t.tracked, requestID)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Inflight retorna el numero de requests actualmente en vuelo.
|
||||
func (t *InflightTracker) Inflight() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.tracked)
|
||||
}
|
||||
|
||||
// IsIdle indica si el numero de requests en vuelo esta dentro del umbral dado.
|
||||
func (t *InflightTracker) IsIdle(maxInflight int) bool {
|
||||
return t.Inflight() <= maxInflight
|
||||
}
|
||||
|
||||
// CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle.
|
||||
// Suscribe eventos Network.requestWillBeSent / Network.loadingFinished /
|
||||
// Network.loadingFailed via el mecanismo OnEvent del CDPConn para mantener
|
||||
// un contador de requests en vuelo (inflight). Cuando inflight <= MaxInflight
|
||||
// de forma continuada durante QuietMs milisegundos, la funcion retorna nil.
|
||||
// Si se alcanza Timeout sin lograr esa ventana quieta, retorna error con el
|
||||
// inflight actual en el mensaje.
|
||||
// Network.loadingFailed via el mecanismo OnEvent del CDPConn y delega el conteo
|
||||
// en un InflightTracker. Cuando inflight <= MaxInflight de forma continuada
|
||||
// durante QuietMs milisegundos, la funcion retorna nil. Si se alcanza Timeout
|
||||
// sin lograr esa ventana quieta, retorna error con el inflight actual.
|
||||
//
|
||||
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
|
||||
// JS, ya que la señal es red, no DOM.
|
||||
@@ -36,41 +103,36 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 8 * time.Second
|
||||
}
|
||||
// MaxInflight 0 es el default semantico: queremos red completamente idle.
|
||||
// MaxInflight default 2: la web moderna mantiene 1-2 beacons/analytics de
|
||||
// fondo que casi nunca dejan inflight en 0; exigir 0 cuelga hasta el timeout.
|
||||
if opts.MaxInflight <= 0 {
|
||||
opts.MaxInflight = 2
|
||||
}
|
||||
if opts.PollMs <= 0 {
|
||||
opts.PollMs = 100
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inflight int
|
||||
)
|
||||
tracker := NewInflightTracker()
|
||||
|
||||
// Suscribir eventos Network usando el mismo mecanismo que cdp_har_record:
|
||||
// c.OnEvent retorna una funcion cancel que des-registra el handler.
|
||||
// Multiples consumidores del mismo metodo son soportados (slice de handlers).
|
||||
cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
inflight++
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
typ, _ := p["type"].(string)
|
||||
tracker.OnRequest(id, typ)
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
tracker.OnFinish(id)
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
|
||||
mu.Lock()
|
||||
if inflight > 0 {
|
||||
inflight--
|
||||
}
|
||||
mu.Unlock()
|
||||
id, _ := p["requestId"].(string)
|
||||
tracker.OnFail(id)
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
@@ -89,11 +151,7 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
|
||||
if current <= opts.MaxInflight {
|
||||
if tracker.IsIdle(opts.MaxInflight) {
|
||||
// Red idle: iniciar o mantener la ventana de quietud.
|
||||
if quietSince.IsZero() {
|
||||
quietSince = time.Now()
|
||||
@@ -107,8 +165,5 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
current := inflight
|
||||
mu.Unlock()
|
||||
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
|
||||
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, tracker.Inflight())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ name: cdp_wait_idle
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error"
|
||||
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight): +1 en requestWillBeSent, -1 en loadingFinished/loadingFailed. Cuando inflight <= MaxInflight de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
|
||||
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight) via InflightTracker: trackea por requestId, excluye conexiones persistentes (WebSocket, EventSource) que nunca terminan. Cuando inflight <= MaxInflight (default 2) de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
|
||||
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -18,14 +18,12 @@ params:
|
||||
- name: c
|
||||
desc: "conexion CDP activa (obtenida con CdpConnect)"
|
||||
- name: opts
|
||||
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 0), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
|
||||
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 2), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
|
||||
output: "nil si la red llega a idle dentro del timeout; error descriptivo con inflight actual si se agota el tiempo o la conexion falla"
|
||||
tested: true
|
||||
tests:
|
||||
- "conexion nula retorna error inmediato"
|
||||
- "opts con ceros aplica defaults antes de usar"
|
||||
- "error de conexion nula contiene texto descriptivo"
|
||||
- "mensaje de error nil-conn menciona cdp wait idle"
|
||||
- "TestCdpWaitIdleDefaults"
|
||||
- "TestInflightTracker"
|
||||
test_file_path: "functions/browser/cdp_wait_idle_test.go"
|
||||
file_path: "functions/browser/cdp_wait_idle.go"
|
||||
---
|
||||
@@ -64,12 +62,14 @@ La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Ne
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Paginas con polling persistente o WebSockets**: si la pagina lanza un request periodico (ej. SSE, long-poll cada 30 s), inflight puede no llegar a 0 durante `QuietMs`. Solucionar con `MaxInflight: 1` para tolerar ese request de fondo, o reducir `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
|
||||
- **Timeout corto por defecto (8 s)**: es deliberado. Para paginas de polling persistente donde inflight nunca llega a 0, un timeout largo solo bloquea. Preferir `MaxInflight > 0` o `Timeout` mas largo explicitamente.
|
||||
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para facilitar diagnostico (saber cuantos requests quedaron colgados).
|
||||
- **Network.enable/disable**: la funcion habilita el dominio Network al entrar y lo deshabilita al salir via defer. Si otra funcion en la misma conexion (ej. `cdp_har_record`) ya lo tiene habilitado, el disable al salir lo desactivara para todos. Usar `MaxInflight` y `Timeout` razonables y no interleave con `cdp_har_record` en la misma conexion salvo que el orden de cierre sea controlado.
|
||||
- **Test e2e real**: los tests del paquete no requieren Chrome. Para pruebas reales, lanzar Chrome con `--remote-debugging-port=9222`, navegar a la pagina objetivo y llamar esta funcion tras `CdpWaitLoad`.
|
||||
- **MaxInflight default = 2**: la web moderna mantiene 1-2 beacons/analytics de fondo que rara vez dejan inflight en 0. El zero-value de `MaxInflight` (0) se reescribe a 2 para no colgar hasta el timeout. Para exigir idle absoluto en una página simple, no hay valor de "0 explícito" (0 == default); usa una página sin analytics o asume el umbral 2.
|
||||
- **WebSocket / EventSource excluidos del conteo**: estas conexiones persistentes no emiten `loadingFinished`, así que contarlas dejaría inflight clavado para siempre. El `InflightTracker` las ignora en `requestWillBeSent` (por `params.type`). Un stream WS/SSE abierto ya NO impide llegar a idle.
|
||||
- **Polling/long-poll periódico**: si la página lanza un XHR cada N segundos, inflight oscila; con `MaxInflight: 2` (default) suele tolerarse. Si no, reduce `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
|
||||
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para diagnóstico.
|
||||
- **Network.enable/disable**: la función habilita Network al entrar y lo deshabilita al salir via defer. No interleave con `cdp_har_record` en la misma conexión salvo orden de cierre controlado.
|
||||
- **Tests sin Chrome**: el núcleo (`InflightTracker`) se testea con secuencias de eventos sintéticas. El bucle de polling con timeout real requiere Chrome y no está simulado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-06) — refactor a `InflightTracker` puro (testeable sin red); default MaxInflight 0→2 (analytics ya no cuelga); excluye WebSocket/EventSource del conteo (no terminan); tracking por requestId (finish de request no contado = no-op).
|
||||
- v1.1.0 (2026-06-05) — cambia señal DOM-length → network-idle via eventos CDP Network.*; añade MaxInflight configurable; defaults mas ajustados (QuietMs 800→500, Timeout 15s→8s, PollMs 200→100).
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
|
||||
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
|
||||
QuietMs: 100,
|
||||
Timeout: 500 * time.Millisecond,
|
||||
@@ -34,19 +34,89 @@ func TestCdpWaitIdleDefaults(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) == 0 {
|
||||
t.Error("el mensaje de error no debe estar vacio")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
|
||||
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cdp wait idle") {
|
||||
t.Errorf("mensaje de error %q no contiene 'cdp wait idle'", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInflightTracker cubre el nucleo puro del contador de red. No requiere Chrome:
|
||||
// alimenta secuencias de eventos {requestId, resourceType} y verifica el conteo.
|
||||
func TestInflightTracker(t *testing.T) {
|
||||
t.Run("golden: carga normal llega a idle", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("r1", "Document")
|
||||
tr.OnRequest("r2", "Script")
|
||||
tr.OnRequest("r3", "Image")
|
||||
if got := tr.Inflight(); got != 3 {
|
||||
t.Fatalf("inflight tras 3 requests = %d, esperaba 3", got)
|
||||
}
|
||||
tr.OnFinish("r1")
|
||||
tr.OnFinish("r2")
|
||||
tr.OnFail("r3") // un recurso que falla tambien deja de estar en vuelo
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight tras completar todo = %d, esperaba 0", got)
|
||||
}
|
||||
if !tr.IsIdle(0) {
|
||||
t.Error("esperaba IsIdle(0)=true con inflight=0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: analytics residual idle ok con MaxInflight=2", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
// La pagina cargo, pero 2 beacons de analytics quedan sin finalizar.
|
||||
tr.OnRequest("doc", "Document")
|
||||
tr.OnFinish("doc")
|
||||
tr.OnRequest("beacon1", "Ping")
|
||||
tr.OnRequest("beacon2", "XHR")
|
||||
if got := tr.Inflight(); got != 2 {
|
||||
t.Fatalf("inflight = %d, esperaba 2 (beacons residuales)", got)
|
||||
}
|
||||
if tr.IsIdle(0) {
|
||||
t.Error("con MaxInflight=0 NO deberia ser idle (2 beacons en vuelo)")
|
||||
}
|
||||
if !tr.IsIdle(2) {
|
||||
t.Error("con MaxInflight=2 (default) SI deberia ser idle")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error/regresion: WebSocket abierto NO impide idle", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("doc", "Document")
|
||||
tr.OnFinish("doc")
|
||||
// Un stream WebSocket se abre y nunca emite loadingFinished.
|
||||
tr.OnRequest("ws1", "WebSocket")
|
||||
// Un EventSource (SSE) tampoco termina.
|
||||
tr.OnRequest("sse1", "EventSource")
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight = %d, esperaba 0 (WS/SSE excluidos)", got)
|
||||
}
|
||||
if !tr.IsIdle(0) {
|
||||
t.Error("con WS+SSE abiertos pero excluidos, deberia ser idle absoluto")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: finish de request no trackeado es no-op (no va negativo)", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
// loadingFinished de un requestId que nunca contamos (p.ej. el handshake
|
||||
// de un WebSocket excluido) no debe romper el conteo.
|
||||
tr.OnFinish("desconocido")
|
||||
tr.OnFail("ws-handshake")
|
||||
if got := tr.Inflight(); got != 0 {
|
||||
t.Fatalf("inflight = %d, esperaba 0 (no negativo)", got)
|
||||
}
|
||||
tr.OnRequest("r1", "Fetch")
|
||||
if got := tr.Inflight(); got != 1 {
|
||||
t.Fatalf("inflight tras un request real = %d, esperaba 1", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge: requestId duplicado no infla el conteo", func(t *testing.T) {
|
||||
tr := NewInflightTracker()
|
||||
tr.OnRequest("r1", "Fetch")
|
||||
tr.OnRequest("r1", "Fetch") // mismo id (redirect re-emite)
|
||||
if got := tr.Inflight(); got != 1 {
|
||||
t.Fatalf("inflight = %d, esperaba 1 (id deduplicado)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ type ChromeLaunchOpts struct {
|
||||
// Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles).
|
||||
// Ej: "Default", "Automation".
|
||||
ProfileDirectory string
|
||||
// ReuseExisting, si es true y el puerto CDP ya responde a una conexion TCP,
|
||||
// NO lanza un Chrome nuevo: devuelve (0, nil) para que el caller reutilice el
|
||||
// navegador que ya está vivo en ese puerto. Evita acumular procesos chromium
|
||||
// duplicados (cada uno ~789 MiB RSS) cuando se llama repetidamente al mismo
|
||||
// puerto. El caller distingue el reuso por pid == 0.
|
||||
ReuseExisting bool
|
||||
}
|
||||
|
||||
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
|
||||
@@ -137,6 +143,30 @@ func findChrome() (string, error) {
|
||||
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas")
|
||||
}
|
||||
|
||||
// dialCDP intenta una conexion TCP unica al puerto CDP. Devuelve true si el
|
||||
// puerto acepta la conexion (hay algo escuchando), false en caso contrario.
|
||||
// host vacio usa "127.0.0.1".
|
||||
func dialCDP(host string, port int, timeout time.Duration) bool {
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// cdpPortResponds indica si ya hay un proceso escuchando el puerto CDP en
|
||||
// 127.0.0.1. Es un sondeo TCP unico con timeout corto, usado por ChromeLaunch
|
||||
// (opts.ReuseExisting) para no relanzar un Chrome duplicado cuando el puerto ya
|
||||
// tiene uno vivo.
|
||||
func cdpPortResponds(port int) bool {
|
||||
return dialCDP("127.0.0.1", port, 300*time.Millisecond)
|
||||
}
|
||||
|
||||
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
|
||||
// host puede estar vacio (usa "127.0.0.1").
|
||||
func waitCDPReady(host string, port int, timeout time.Duration) error {
|
||||
@@ -144,16 +174,14 @@ func waitCDPReady(host string, port int, timeout time.Duration) error {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
if dialCDP(host, port, 200*time.Millisecond) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", addr, timeout)
|
||||
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s",
|
||||
net.JoinHostPort(host, fmt.Sprintf("%d", port)), timeout)
|
||||
}
|
||||
|
||||
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
|
||||
@@ -170,6 +198,13 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
opts.Port = 9222
|
||||
}
|
||||
|
||||
// Anti-duplicado: si el caller pide reusar y ya hay un Chrome escuchando el
|
||||
// puerto CDP, no lanzamos otro. Devolvemos pid 0 para que el caller sepa que
|
||||
// debe adjuntarse al existente en vez de registrar un proceso nuevo.
|
||||
if opts.ReuseExisting && cdpPortResponds(opts.Port) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
chromePath := opts.ChromePath
|
||||
if chromePath == "" {
|
||||
var err error
|
||||
|
||||
@@ -3,7 +3,7 @@ name: chrome_launch
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
purity: impure
|
||||
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
|
||||
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. En Linux nativo busca primero chromium/google-chrome/brave; en WSL2 busca chrome.exe primero. En WSL2+chrome.exe traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0. En Linux nativo setea Setpgid=true para crear grupo de proceso propio (permite matar el arbol completo con CdpClose). Espera hasta 15s a que el puerto CDP este listo. Retorna el PID del proceso."
|
||||
@@ -16,10 +16,10 @@ error_type: "error_go_core"
|
||||
imports: [fmt, net, os, os/exec, regexp, strings, syscall, time]
|
||||
params:
|
||||
- name: opts
|
||||
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag)"
|
||||
output: "int: PID del proceso Chrome lanzado"
|
||||
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag), ReuseExisting (si true y el puerto CDP ya responde, no lanza Chrome nuevo y devuelve pid 0 — anti-duplicado)"
|
||||
output: "int: PID del proceso Chrome lanzado, o 0 si ReuseExisting=true y ya había un Chrome vivo en el puerto"
|
||||
tested: true
|
||||
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
|
||||
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect", "TestCdpPortResponds", "TestChromeLaunchReuseExisting"]
|
||||
test_file_path: "functions/browser/chrome_launch_test.go"
|
||||
file_path: "functions/browser/chrome_launch.go"
|
||||
---
|
||||
@@ -71,8 +71,9 @@ Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, t
|
||||
- **KeepExtensions**: por defecto se añade `--disable-extensions`. Pasar `KeepExtensions: true` para omitir ese flag y mantener extensiones del perfil (útil con perfiles reales de usuario).
|
||||
- **`wslpath` debe estar disponible** (WSL2 desde Windows 10 1903+): se invoca como subproceso en modo WSL2+exe. Si falla, `ChromeLaunch` retorna error.
|
||||
- **ProfileDirectory obligatorio con múltiples perfiles**: sin `--profile-directory`, si el `user-data-dir` contiene varios perfiles (Default, Personal, Profile 1, Automation…) Chrome se queda atascado en el selector de perfil y no carga nada — el puerto CDP responde pero no hay perfil activo y las extensiones no se procesan. Pasar `ProfileDirectory: "Default"` (o el nombre exacto del subdirectorio) para evitarlo.
|
||||
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos.
|
||||
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
|
||||
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos. Quien lance debe guardar el pid; sin él, `CdpClose(c, 0)` solo cierra el WebSocket y deja Chrome huérfano (~789 MiB RSS cada uno). Acumular lanzamientos sin matar = leak de RAM.
|
||||
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión, o pasar `ReuseExisting: true` para que la función NO lance un duplicado y devuelva pid 0 (el caller se adjunta al Chrome existente).
|
||||
- **ReuseExisting + pid 0**: con `ReuseExisting: true` un retorno `(0, nil)` significa "ya había un Chrome vivo en el puerto, no lancé otro". El caller NO debe registrar ni intentar matar ese pid 0; el proceso no es suyo (puede ser el navegador diario del usuario).
|
||||
|
||||
## Notas
|
||||
|
||||
@@ -95,3 +96,4 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
|
||||
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
|
||||
- v1.2.0 (2026-06-05) — Linux-first: reordena busqueda (chromium antes que chrome.exe) en Linux nativo; añade KeepExtensions; setea Setpgid=true en Linux para habilitar kill-by-group en CdpClose
|
||||
- v1.3.0 (2026-06-05) — añade ProfileDirectory / --profile-directory para seleccionar perfil dentro del user-data-dir (evita quedarse atascado en el selector cuando hay varios perfiles)
|
||||
- v1.4.0 (2026-06-06) — añade ReuseExisting: guarda anti-duplicado que devuelve (0, nil) sin lanzar cuando el puerto CDP ya responde. Extrae helper dialCDP/cdpPortResponds (sondeo TCP reutilizado por waitCDPReady). Cierra el leak de chromium huérfanos del browser_mcp (lanzamientos repetidos al mismo puerto)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -288,3 +289,46 @@ func TestCdpScreenshot(t *testing.T) {
|
||||
t.Logf("Screenshot creado: %s (%d bytes)", outputPath, info.Size())
|
||||
})
|
||||
}
|
||||
|
||||
// TestCdpPortResponds verifica el sondeo TCP del puerto CDP sin Chrome real:
|
||||
// un net.Listener local hace de "puerto ocupado" y, al cerrarlo, el puerto
|
||||
// deja de responder.
|
||||
func TestCdpPortResponds(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if !cdpPortResponds(port) {
|
||||
t.Errorf("cdpPortResponds(%d) = false con listener vivo, want true", port)
|
||||
}
|
||||
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
if cdpPortResponds(port) {
|
||||
t.Errorf("cdpPortResponds(%d) = true tras cerrar el listener, want false", port)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChromeLaunchReuseExisting verifica que con ReuseExisting=true y un puerto
|
||||
// ya ocupado, ChromeLaunch NO lanza Chrome y devuelve (0, nil). No requiere
|
||||
// Chrome real: el listener simula un endpoint CDP vivo. Esto es la guarda
|
||||
// anti-duplicado que evita el leak de procesos chromium huerfanos.
|
||||
func TestChromeLaunchReuseExisting(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{Port: port, ReuseExisting: true})
|
||||
if err != nil {
|
||||
t.Fatalf("ChromeLaunch(ReuseExisting): %v", err)
|
||||
}
|
||||
if pid != 0 {
|
||||
t.Errorf("pid = %d, want 0 (debe reusar el existente sin lanzar Chrome)", pid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ProjectCoverage reports how well a project registered in registry.db is
|
||||
// backed by a Gitea sub-repo and how many of its children (apps + analyses)
|
||||
// are actually cloned on disk. It is the engine of `fn doctor projects`.
|
||||
//
|
||||
// The audit only touches the local filesystem and registry.db: it never hits
|
||||
// the network nor the Gitea API, so it runs fast and requires no token.
|
||||
type ProjectCoverage struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
DirPath string `json:"dir_path"`
|
||||
HasGit bool `json:"has_git"` // <root>/<dir_path>/.git exists as a directory
|
||||
HasRemote bool `json:"has_remote"` // git -C <dir> remote get-url origin returned a non-empty url
|
||||
RepoURLDeclared bool `json:"repo_url_declared"` // projects.repo_url != ""
|
||||
ChildrenInDB int `json:"children_in_db"` // apps + analyses with project_id = ProjectID
|
||||
ChildrenCloned int `json:"children_cloned"` // of those, how many have a .git on disk
|
||||
ChildrenMissing int `json:"children_missing"` // ChildrenInDB - ChildrenCloned (at risk when re-cloning)
|
||||
Issues []string `json:"issues"` // e.g. "dir_not_found", "no_gitea_repo", "children_missing"
|
||||
}
|
||||
|
||||
// AuditProjectsCoverage walks every row in the projects table and reports, per
|
||||
// project, whether it has a local git repo, whether that repo declares a remote
|
||||
// origin, whether its repo_url is filled in registry.db, and how many of its
|
||||
// children (apps + analyses) are cloned versus only known to the database.
|
||||
//
|
||||
// registryRoot is the repository root (the directory that holds registry.db).
|
||||
// All relative dir_path values are resolved against it.
|
||||
//
|
||||
// Returns an error only if registry.db cannot be opened or queried. Projects
|
||||
// whose directory is missing on disk are still reported, flagged with the
|
||||
// "dir_not_found" issue, so the caller can surface them rather than silently
|
||||
// dropping them.
|
||||
func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: ping db: %w", err)
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT id, COALESCE(dir_path,''), COALESCE(repo_url,'') FROM projects ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: query projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ProjectCoverage
|
||||
for rows.Next() {
|
||||
var pc ProjectCoverage
|
||||
var dirPath, repoURL string
|
||||
if err := rows.Scan(&pc.ProjectID, &dirPath, &repoURL); err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: scan project: %w", err)
|
||||
}
|
||||
|
||||
// DirPath: use projects.dir_path; if empty, derive projects/<id>.
|
||||
if dirPath == "" {
|
||||
dirPath = filepath.Join("projects", pc.ProjectID)
|
||||
}
|
||||
pc.DirPath = dirPath
|
||||
pc.RepoURLDeclared = repoURL != ""
|
||||
|
||||
absDir := dirPath
|
||||
if !filepath.IsAbs(absDir) {
|
||||
absDir = filepath.Join(registryRoot, dirPath)
|
||||
}
|
||||
|
||||
dirFound := dirExists(absDir)
|
||||
if !dirFound {
|
||||
pc.Issues = append(pc.Issues, "dir_not_found")
|
||||
} else {
|
||||
pc.HasGit = dirExists(filepath.Join(absDir, ".git"))
|
||||
if pc.HasGit {
|
||||
pc.HasRemote = gitHasRemoteOrigin(absDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Children: apps + analyses with this project_id.
|
||||
children, err := projectChildren(db, pc.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc.ChildrenInDB = len(children)
|
||||
for _, childDir := range children {
|
||||
absChild := childDir
|
||||
if !filepath.IsAbs(absChild) {
|
||||
absChild = filepath.Join(registryRoot, childDir)
|
||||
}
|
||||
if dirExists(filepath.Join(absChild, ".git")) {
|
||||
pc.ChildrenCloned++
|
||||
}
|
||||
}
|
||||
pc.ChildrenMissing = pc.ChildrenInDB - pc.ChildrenCloned
|
||||
|
||||
// Issues derived from the gathered state.
|
||||
if !pc.HasRemote && !pc.RepoURLDeclared {
|
||||
pc.Issues = append(pc.Issues, "no_gitea_repo")
|
||||
}
|
||||
if pc.ChildrenMissing > 0 {
|
||||
pc.Issues = append(pc.Issues, "children_missing")
|
||||
}
|
||||
|
||||
out = append(out, pc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// projectChildren returns the dir_path of every app and analysis whose
|
||||
// project_id matches the given project id.
|
||||
func projectChildren(db *sql.DB, projectID string) ([]string, error) {
|
||||
var dirs []string
|
||||
for _, table := range []string{"apps", "analysis"} {
|
||||
q := fmt.Sprintf(`SELECT COALESCE(dir_path,'') FROM %s WHERE project_id = ?`, table)
|
||||
rows, err := db.Query(q, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: query %s children: %w", table, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var dp string
|
||||
if err := rows.Scan(&dp); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("audit_projects_coverage: scan %s child: %w", table, err)
|
||||
}
|
||||
if dp != "" {
|
||||
dirs = append(dirs, dp)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// gitHasRemoteOrigin reports whether the git repo at dir declares an origin
|
||||
// remote with a non-empty URL. It shells out to `git -C <dir> remote get-url
|
||||
// origin` and treats exit 0 with non-empty output as success. Any error
|
||||
// (no origin, not a repo, git missing) is reported as false.
|
||||
func gitHasRemoteOrigin(dir string) bool {
|
||||
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
|
||||
outBytes, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(outBytes)) != ""
|
||||
}
|
||||
|
||||
// FormatProjectsCoverage renders a tabwriter table, one row per project, with
|
||||
// git / remote / repo_url presence, children cloned vs declared, and the
|
||||
// issues list. When no project has coverage problems it makes that explicit.
|
||||
func FormatProjectsCoverage(rows []ProjectCoverage) string {
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PROJECT\tGIT\tREMOTE\tREPO_URL\tCHILDREN\tISSUES")
|
||||
|
||||
withIssues := 0
|
||||
for _, r := range rows {
|
||||
issues := "-"
|
||||
if len(r.Issues) > 0 {
|
||||
issues = strings.Join(r.Issues, "; ")
|
||||
withIssues++
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n",
|
||||
r.ProjectID,
|
||||
checkMark(r.HasGit),
|
||||
checkMark(r.HasRemote),
|
||||
checkMark(r.RepoURLDeclared),
|
||||
r.ChildrenCloned, r.ChildrenInDB,
|
||||
issues,
|
||||
)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
if len(rows) == 0 {
|
||||
sb.WriteString("\nNo projects registered.\n")
|
||||
return sb.String()
|
||||
}
|
||||
if withIssues == 0 {
|
||||
sb.WriteString("\n0 projects con problemas de cobertura.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "\n%d/%d projects con problemas de cobertura.\n", withIssues, len(rows))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// checkMark returns ✓ for true, ✗ for false.
|
||||
func checkMark(b bool) string {
|
||||
if b {
|
||||
return "✓"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
// OrphanProjectRef describes a project_id that one or more children (apps and
|
||||
// analyses) reference in registry.db but for which no row exists in the projects
|
||||
// table. This is the inverse drift of AuditProjectsCoverage: instead of a
|
||||
// registered project whose children are not cloned, it surfaces an umbrella
|
||||
// project that was never synced to this PC (or never created here at all), so
|
||||
// the children are pointing at a project that this registry does not know about.
|
||||
// That is a data-loss risk: the umbrella project may exist on another machine
|
||||
// and the link would be silently broken on a clone/sync into this one.
|
||||
type OrphanProjectRef struct {
|
||||
ProjectID string `json:"project_id"` // referenced by children but missing from projects
|
||||
Apps []string `json:"apps"` // ids of apps that reference it (sorted)
|
||||
Analyses []string `json:"analyses"` // ids of analyses that reference it (sorted)
|
||||
}
|
||||
|
||||
// FindOrphanProjectRefs scans every app and analysis that declares a non-empty
|
||||
// project_id and reports those project_id values that have no matching row in
|
||||
// the projects table. Each orphan groups the ids of the apps and analyses that
|
||||
// reference it.
|
||||
//
|
||||
// registryRoot is the repository root (the directory that holds registry.db).
|
||||
//
|
||||
// The result is sorted by ProjectID, and within each entry the Apps and
|
||||
// Analyses lists are sorted alphabetically. When every child references a known
|
||||
// project, an empty (non-nil) slice is returned with a nil error.
|
||||
//
|
||||
// Like AuditProjectsCoverage it only reads registry.db (opened read-only) and
|
||||
// never touches the network nor the Gitea API. Returns an error only when
|
||||
// registry.db cannot be opened or queried.
|
||||
func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: ping db: %w", err)
|
||||
}
|
||||
|
||||
// Set of known project ids.
|
||||
known := map[string]bool{}
|
||||
prows, err := db.Query(`SELECT id FROM projects`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: query projects: %w", err)
|
||||
}
|
||||
for prows.Next() {
|
||||
var id string
|
||||
if err := prows.Scan(&id); err != nil {
|
||||
prows.Close()
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: scan project: %w", err)
|
||||
}
|
||||
known[id] = true
|
||||
}
|
||||
if err := prows.Err(); err != nil {
|
||||
prows.Close()
|
||||
return nil, err
|
||||
}
|
||||
prows.Close()
|
||||
|
||||
// Accumulate orphan references from apps and analyses.
|
||||
orphans := map[string]*OrphanProjectRef{}
|
||||
|
||||
get := func(pid string) *OrphanProjectRef {
|
||||
o, ok := orphans[pid]
|
||||
if !ok {
|
||||
o = &OrphanProjectRef{ProjectID: pid}
|
||||
orphans[pid] = o
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
for _, table := range []string{"apps", "analysis"} {
|
||||
q := fmt.Sprintf(`SELECT id, project_id FROM %s WHERE project_id != ''`, table)
|
||||
rows, err := db.Query(q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: query %s: %w", table, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, pid string
|
||||
if err := rows.Scan(&id, &pid); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: scan %s row: %w", table, err)
|
||||
}
|
||||
if known[pid] {
|
||||
continue
|
||||
}
|
||||
o := get(pid)
|
||||
if table == "apps" {
|
||||
o.Apps = append(o.Apps, id)
|
||||
} else {
|
||||
o.Analyses = append(o.Analyses, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
out := make([]OrphanProjectRef, 0, len(orphans))
|
||||
for _, o := range orphans {
|
||||
sort.Strings(o.Apps)
|
||||
sort.Strings(o.Analyses)
|
||||
out = append(out, *o)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ProjectID < out[j].ProjectID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormatOrphanProjectRefs renders a human-readable report of orphan project_id
|
||||
// references, one block per orphan, listing how many apps and analyses point at
|
||||
// the missing project plus their ids. When there are no orphans it makes that
|
||||
// explicit on a single line.
|
||||
func FormatOrphanProjectRefs(rows []OrphanProjectRef) string {
|
||||
if len(rows) == 0 {
|
||||
return "0 project_id huérfanos (todos los hijos tienen project declarado)\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PROJECT_ID\tAPPS\tANALYSES\tREFERENCED_BY")
|
||||
for _, r := range rows {
|
||||
refs := append(append([]string{}, r.Apps...), r.Analyses...)
|
||||
refStr := strings.Join(refs, ", ")
|
||||
if refStr == "" {
|
||||
refStr = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%d\t%d\t%s\n",
|
||||
r.ProjectID, len(r.Apps), len(r.Analyses), refStr)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Fprintf(&sb, "\n%d project_id huérfanos (referenciados por hijos pero sin fila en projects).\n", len(rows))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: audit_projects_coverage
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error)"
|
||||
description: "Audita la cobertura de los projects del registry frente a sus sub-repos Gitea: comprueba si cada project tiene .git local, remote origin y repo_url declarado, y cuantos de sus hijos (apps + analyses) estan clonados en disco versus solo conocidos por la BD. Motor del subcomando fn doctor projects. Solo lee registry.db + filesystem + git local, nunca la red ni la API de Gitea. Incluye FindOrphanProjectRefs, el check inverso: detecta apps o analyses que declaran un project_id sin fila en la tabla projects (project paraguas huerfano, riesgo de perdida al sincronizar)."
|
||||
tags: [projects, gitea, subrepo, audit, infra, fn-doctor, doctor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "os/exec", "path/filepath", "strings", "text/tabwriter", "github.com/mattn/go-sqlite3"]
|
||||
tested: true
|
||||
tests: ["healthy project con un hijo sin clonar marca children_missing", "project sin repo_url ni remote marca no_gitea_repo", "project sin directorio en disco marca dir_not_found", "error si registry.db no existe", "repo con origin devuelve true", "repo sin origin devuelve false", "sin issues lo deja claro", "con issues cuenta los afectados", "app con project_id huerfano lo detecta y agrupa ordenado", "app con project_id valido no aparece", "sin huerfanos devuelve slice vacio sin error", "sin huerfanos lo deja claro", "con huerfanos lista ids y cuenta"]
|
||||
test_file_path: "functions/infra/audit_projects_coverage_test.go"
|
||||
file_path: "functions/infra/audit_projects_coverage.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Raiz del repositorio (el directorio que contiene registry.db). Los dir_path relativos de projects, apps y analysis se resuelven contra esta raiz."
|
||||
output: "Slice de ProjectCoverage, una entrada por fila de la tabla projects, con flags de git/remote/repo_url, conteos de hijos clonados vs declarados, y la lista de issues detectados. La funcion de formato FormatProjectsCoverage produce una tabla de texto humano."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rows, err := infra.AuditProjectsCoverage("/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||
}
|
||||
```
|
||||
|
||||
Salida típica:
|
||||
|
||||
```
|
||||
PROJECT GIT REMOTE REPO_URL CHILDREN ISSUES
|
||||
fleet_monitoring ✓ ✓ ✓ 2/2 -
|
||||
fn_monitoring ✓ ✓ ✓ 3/3 -
|
||||
message_bus ✓ ✓ ✓ 3/4 children_missing
|
||||
web_scraping ✗ ✗ ✗ 0/3 no_gitea_repo; children_missing
|
||||
|
||||
1/4 projects con problemas de cobertura.
|
||||
```
|
||||
|
||||
## Check inverso: FindOrphanProjectRefs
|
||||
|
||||
Mientras `AuditProjectsCoverage` parte de la tabla `projects` y mira hacia abajo (¿están sus hijos clonados?), `FindOrphanProjectRefs` recorre el grafo en sentido contrario: parte de las apps y analyses y mira hacia arriba (¿existe el project paraguas que declaran?). Detecta el drift inverso, apps o analyses cuyo `project_id` no tiene ninguna fila en la tabla `projects`. Es un project huérfano: existe en otro PC y nunca se sincronizó a este, o nunca se creó aquí. Es un riesgo de pérdida silenciosa, porque el enlace del hijo apunta a un project que este registro no conoce.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
orphans, err := infra.FindOrphanProjectRefs("/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||
}
|
||||
```
|
||||
|
||||
La firma es `func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error)`. Cada `OrphanProjectRef` agrupa, por `ProjectID` huérfano, los ids de las apps (`Apps`) y analyses (`Analyses`) que lo referencian, ambas listas ordenadas alfabéticamente y el slice resultante ordenado por `ProjectID`. Cuando todos los hijos apuntan a un project conocido devuelve un slice vacío (no nil) sin error. `FormatOrphanProjectRefs` produce una tabla de texto humano con el `project_id`, cuántas apps y analyses lo referencian y sus ids; si no hay huérfanos imprime una sola línea dejándolo claro.
|
||||
|
||||
Caso real detectado en este registro: apps con `project_id` ∈ {`element_agents`, `imagegen`, `osint_graph`} sin fila correspondiente en `projects`.
|
||||
|
||||
Salida típica con huérfanos:
|
||||
|
||||
```
|
||||
PROJECT_ID APPS ANALYSES REFERENCED_BY
|
||||
element_agents 1 0 shell_agent
|
||||
imagegen 1 0 imagegen_ui
|
||||
osint_graph 2 1 graph_explorer, scraper, gliner_glirel_tuning
|
||||
|
||||
3 project_id huérfanos (referenciados por hijos pero sin fila en projects).
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de un `/full-git-pull` masivo o tras clonar el registry en un PC nuevo para saber qué projects están realmente respaldados por su sub-repo Gitea y cuántos de sus hijos (apps y analyses) quedarían sin clonar. También como motor del futuro subcomando `fn doctor projects`: el caller la enchufa desde `cmd/fn/doctor.go` igual que `AuditUsesFunctions` o `AuditServicesSpec`, formatea con `FormatProjectsCoverage` para texto humano y serializa el slice directamente para `--json`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es **impura**: lee `registry.db` (abierto en modo read-only `?mode=ro`), recorre el filesystem y ejecuta `git -C <dir> remote get-url origin`. No toca la red ni la API de Gitea, así que no necesita token y es rápida.
|
||||
- `HasRemote` solo se evalúa cuando el project tiene `.git` local; si no hay `.git`, queda `false` sin intentar el comando git.
|
||||
- `gitHasRemoteOrigin` devuelve `false` ante cualquier error (no hay remote `origin`, no es un repo, git no instalado). No distingue "sin origin" de "git ausente"; si necesitas esa distinción, comprueba `git` por separado.
|
||||
- El issue `no_gitea_repo` se emite solo cuando faltan **ambos** indicadores (`!HasRemote && !RepoURLDeclared`). Un project con `repo_url` declarado pero sin clonar (`dir_not_found`) NO se marca `no_gitea_repo` — el repo existe en Gitea, simplemente no está en este disco.
|
||||
- `ChildrenMissing` cuenta los hijos (apps + analyses con ese `project_id`) cuya carpeta no tiene `.git` en disco: son los que se perderían o habría que reclonar. Cero hijos en la BD produce `0/0` y no genera issue.
|
||||
- Si `projects.dir_path` está vacío, se deriva `projects/<id>`. Los `dir_path` ya absolutos se respetan tal cual.
|
||||
- Devuelve error únicamente si `registry.db` no puede abrirse o consultarse. Los projects cuyo directorio no existe SÍ aparecen en el resultado, marcados con `dir_not_found`, para que el caller los muestre en vez de descartarlos en silencio.
|
||||
- `FindOrphanProjectRefs` también es **impura**: lee `registry.db` en modo read-only (`?mode=ro`), pero no toca el filesystem ni git, solo cruza las tablas `projects`, `apps` y `analysis`. Ignora los hijos con `project_id` vacío (no son huérfanos, simplemente no pertenecen a ningún project). Devuelve un slice vacío no-nil cuando no hay huérfanos, así que el caller puede distinguir "sin huérfanos" (slice vacío, error nil) de "fallo al leer la BD" (error no nil).
|
||||
@@ -0,0 +1,417 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// seedProjectsRegistry builds a temp registry.db with the columns
|
||||
// AuditProjectsCoverage reads, plus a handful of on-disk directories that
|
||||
// model the cloned/missing states.
|
||||
func seedProjectsRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO projects VALUES
|
||||
('healthy', 'projects/healthy', 'https://gitea.example/dataforge/healthy'),
|
||||
('no_repo', 'projects/no_repo', ''),
|
||||
('missing', 'projects/missing', 'https://gitea.example/dataforge/missing');
|
||||
INSERT INTO apps VALUES
|
||||
('app_cloned', 'projects/healthy/apps/app_cloned', 'healthy'),
|
||||
('app_orphan', 'projects/healthy/apps/app_orphan', 'healthy');
|
||||
INSERT INTO analysis VALUES
|
||||
('an_cloned', 'projects/healthy/analysis/an_cloned', 'healthy');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed data: %v", err)
|
||||
}
|
||||
|
||||
// healthy: directory + .git present; one child cloned, one missing.
|
||||
mkGitDir(t, dir, "projects/healthy")
|
||||
mkGitDir(t, dir, "projects/healthy/apps/app_cloned")
|
||||
mkGitDir(t, dir, "projects/healthy/analysis/an_cloned")
|
||||
// app_orphan has no .git on disk → counts as missing.
|
||||
|
||||
// no_repo: directory exists, no .git.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "projects/no_repo"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir no_repo: %v", err)
|
||||
}
|
||||
|
||||
// missing: no directory at all on disk.
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// mkGitDir creates <root>/<rel>/.git so dirExists treats it as a git repo.
|
||||
func mkGitDir(t *testing.T, root, rel string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s/.git: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func findCoverage(rows []ProjectCoverage, id string) *ProjectCoverage {
|
||||
for i := range rows {
|
||||
if rows[i].ProjectID == id {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasIssue(pc *ProjectCoverage, issue string) bool {
|
||||
for _, i := range pc.Issues {
|
||||
if i == issue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAuditProjectsCoverage(t *testing.T) {
|
||||
t.Run("healthy project con un hijo sin clonar marca children_missing", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditProjectsCoverage error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "healthy")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'healthy' missing from results")
|
||||
}
|
||||
if !pc.HasGit {
|
||||
t.Error("expected HasGit=true for healthy")
|
||||
}
|
||||
if !pc.RepoURLDeclared {
|
||||
t.Error("expected RepoURLDeclared=true for healthy")
|
||||
}
|
||||
if pc.ChildrenInDB != 3 {
|
||||
t.Errorf("expected 3 children in db, got %d", pc.ChildrenInDB)
|
||||
}
|
||||
if pc.ChildrenCloned != 2 {
|
||||
t.Errorf("expected 2 cloned children, got %d", pc.ChildrenCloned)
|
||||
}
|
||||
if pc.ChildrenMissing != 1 {
|
||||
t.Errorf("expected 1 missing child, got %d", pc.ChildrenMissing)
|
||||
}
|
||||
if !hasIssue(pc, "children_missing") {
|
||||
t.Errorf("expected children_missing issue, got %v", pc.Issues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project sin repo_url ni remote marca no_gitea_repo", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "no_repo")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'no_repo' missing")
|
||||
}
|
||||
if pc.HasGit {
|
||||
t.Error("expected HasGit=false for no_repo")
|
||||
}
|
||||
if pc.RepoURLDeclared {
|
||||
t.Error("expected RepoURLDeclared=false for no_repo")
|
||||
}
|
||||
if !hasIssue(pc, "no_gitea_repo") {
|
||||
t.Errorf("expected no_gitea_repo issue, got %v", pc.Issues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project sin directorio en disco marca dir_not_found", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "missing")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'missing' missing")
|
||||
}
|
||||
if !hasIssue(pc, "dir_not_found") {
|
||||
t.Errorf("expected dir_not_found issue, got %v", pc.Issues)
|
||||
}
|
||||
if pc.HasGit {
|
||||
t.Error("expected HasGit=false when dir not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := AuditProjectsCoverage(dir); err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHasRemoteOrigin(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
t.Run("repo con origin devuelve true", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init", "-q")
|
||||
runGit(t, dir, "remote", "add", "origin", "https://example.com/x.git")
|
||||
if !gitHasRemoteOrigin(dir) {
|
||||
t.Error("expected true for repo with origin")
|
||||
}
|
||||
})
|
||||
t.Run("repo sin origin devuelve false", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init", "-q")
|
||||
if gitHasRemoteOrigin(dir) {
|
||||
t.Error("expected false for repo without origin")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// seedOrphanRefsRegistry builds a temp registry.db where some children declare
|
||||
// a project_id with no matching row in the projects table (orphan refs) while
|
||||
// others reference a valid project. It returns the registry root directory.
|
||||
func seedOrphanRefsRegistry(t *testing.T, withOrphans bool) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
// One known project exists in the projects table.
|
||||
if _, err := db.Exec(`INSERT INTO projects VALUES ('known', 'projects/known', '')`); err != nil {
|
||||
t.Fatalf("seed projects: %v", err)
|
||||
}
|
||||
|
||||
// app_valid + an_valid reference the known project: never orphans.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO apps VALUES ('app_valid', 'projects/known/apps/app_valid', 'known');
|
||||
INSERT INTO analysis VALUES ('an_valid', 'projects/known/analysis/an_valid', 'known');
|
||||
`); err != nil {
|
||||
t.Fatalf("seed valid children: %v", err)
|
||||
}
|
||||
|
||||
// app_no_project has an empty project_id: must be ignored.
|
||||
if _, err := db.Exec(`INSERT INTO apps VALUES ('app_no_project', 'apps/app_no_project', '')`); err != nil {
|
||||
t.Fatalf("seed no-project app: %v", err)
|
||||
}
|
||||
|
||||
if withOrphans {
|
||||
// Two apps + one analysis pointing at 'ghost' (missing from projects),
|
||||
// plus one app pointing at 'specter' (also missing). Insert the apps for
|
||||
// 'ghost' out of alphabetical order to exercise sorting.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO apps VALUES ('app_zeta', 'apps/app_zeta', 'ghost');
|
||||
INSERT INTO apps VALUES ('app_alpha', 'apps/app_alpha', 'ghost');
|
||||
INSERT INTO analysis VALUES ('an_ghost', 'analysis/an_ghost', 'ghost');
|
||||
INSERT INTO apps VALUES ('app_specter', 'apps/app_specter', 'specter');
|
||||
`); err != nil {
|
||||
t.Fatalf("seed orphan children: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func findOrphan(rows []OrphanProjectRef, id string) *OrphanProjectRef {
|
||||
for i := range rows {
|
||||
if rows[i].ProjectID == id {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindOrphanProjectRefs(t *testing.T) {
|
||||
t.Run("app con project_id huerfano lo detecta y agrupa ordenado", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, true)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrphanProjectRefs error: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 orphan refs, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Sorted by ProjectID: ghost before specter.
|
||||
if rows[0].ProjectID != "ghost" || rows[1].ProjectID != "specter" {
|
||||
t.Fatalf("expected [ghost specter], got [%s %s]", rows[0].ProjectID, rows[1].ProjectID)
|
||||
}
|
||||
|
||||
ghost := findOrphan(rows, "ghost")
|
||||
if ghost == nil {
|
||||
t.Fatal("orphan 'ghost' missing")
|
||||
}
|
||||
wantApps := []string{"app_alpha", "app_zeta"} // alphabetical
|
||||
if len(ghost.Apps) != 2 || ghost.Apps[0] != wantApps[0] || ghost.Apps[1] != wantApps[1] {
|
||||
t.Errorf("expected ghost.Apps=%v, got %v", wantApps, ghost.Apps)
|
||||
}
|
||||
if len(ghost.Analyses) != 1 || ghost.Analyses[0] != "an_ghost" {
|
||||
t.Errorf("expected ghost.Analyses=[an_ghost], got %v", ghost.Analyses)
|
||||
}
|
||||
|
||||
specter := findOrphan(rows, "specter")
|
||||
if specter == nil {
|
||||
t.Fatal("orphan 'specter' missing")
|
||||
}
|
||||
if len(specter.Apps) != 1 || specter.Apps[0] != "app_specter" {
|
||||
t.Errorf("expected specter.Apps=[app_specter], got %v", specter.Apps)
|
||||
}
|
||||
if len(specter.Analyses) != 0 {
|
||||
t.Errorf("expected specter.Analyses empty, got %v", specter.Analyses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("app con project_id valido no aparece", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, true)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if findOrphan(rows, "known") != nil {
|
||||
t.Error("valid project 'known' should not appear as orphan")
|
||||
}
|
||||
// Children of 'known' must never be listed in any orphan entry.
|
||||
for _, r := range rows {
|
||||
for _, a := range append(append([]string{}, r.Apps...), r.Analyses...) {
|
||||
if a == "app_valid" || a == "an_valid" || a == "app_no_project" {
|
||||
t.Errorf("child %q with valid/empty project leaked into orphan %q", a, r.ProjectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin huerfanos devuelve slice vacio sin error", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, false)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if rows == nil {
|
||||
t.Fatal("expected non-nil empty slice, got nil")
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0 orphans, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := FindOrphanProjectRefs(dir); err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatOrphanProjectRefs(t *testing.T) {
|
||||
t.Run("sin huerfanos lo deja claro", func(t *testing.T) {
|
||||
out := FormatOrphanProjectRefs(nil)
|
||||
if !contains(out, "0 project_id huérfanos") {
|
||||
t.Errorf("expected clean message, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("con huerfanos lista ids y cuenta", func(t *testing.T) {
|
||||
rows := []OrphanProjectRef{
|
||||
{ProjectID: "ghost", Apps: []string{"app_alpha", "app_zeta"}, Analyses: []string{"an_ghost"}},
|
||||
}
|
||||
out := FormatOrphanProjectRefs(rows)
|
||||
if !contains(out, "ghost") {
|
||||
t.Errorf("expected project_id in output, got:\n%s", out)
|
||||
}
|
||||
if !contains(out, "app_alpha") || !contains(out, "an_ghost") {
|
||||
t.Errorf("expected referencing ids in output, got:\n%s", out)
|
||||
}
|
||||
if !contains(out, "1 project_id huérfanos") {
|
||||
t.Errorf("expected count line, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatProjectsCoverage(t *testing.T) {
|
||||
t.Run("sin issues lo deja claro", func(t *testing.T) {
|
||||
rows := []ProjectCoverage{
|
||||
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true, ChildrenInDB: 2, ChildrenCloned: 2},
|
||||
}
|
||||
out := FormatProjectsCoverage(rows)
|
||||
if !contains(out, "0 projects con problemas de cobertura") {
|
||||
t.Errorf("expected clean message, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("con issues cuenta los afectados", func(t *testing.T) {
|
||||
rows := []ProjectCoverage{
|
||||
{ProjectID: "bad", Issues: []string{"no_gitea_repo"}},
|
||||
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true},
|
||||
}
|
||||
out := FormatProjectsCoverage(rows)
|
||||
if !contains(out, "1/2 projects con problemas de cobertura") {
|
||||
t.Errorf("expected 1/2 count, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batteryStatus modela el JSON que imprime `termux-battery-status` (binario del
|
||||
// paquete termux-api en Android/Termux). Solo se declaran los campos que
|
||||
// consumimos como metricas.
|
||||
type batteryStatus struct {
|
||||
Health string `json:"health"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Plugged string `json:"plugged"`
|
||||
Status string `json:"status"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Current float64 `json:"current"`
|
||||
}
|
||||
|
||||
// CollectBatteryMetrics recolecta metricas de bateria de un dispositivo
|
||||
// Android via el comando `termux-battery-status` (paquete termux-api) y las
|
||||
// devuelve como slice de PromSample con nombres estilo node_exporter.
|
||||
//
|
||||
// Es best-effort y diseñada para correr en cualquier nodo de la flota,
|
||||
// incluidos Linux normales donde `termux-battery-status` NO existe: en ese
|
||||
// caso (binario no encontrado, comando fallido o JSON invalido) devuelve un
|
||||
// slice vacio y error nil — NO es un fallo, simplemente no hay bateria que
|
||||
// reportar. Solo emite samples cuando el comando existe y responde JSON valido.
|
||||
//
|
||||
// El comando se ejecuta con un timeout de 5s via context para no colgar el
|
||||
// agente de monitorizacion si termux-api se queda sin responder.
|
||||
func CollectBatteryMetrics() ([]PromSample, error) {
|
||||
// Localizamos el binario por ruta absoluta con os.Stat en vez de
|
||||
// exec.LookPath: en Android el syscall faccessat2 que usa LookPath esta
|
||||
// bloqueado por seccomp y mata el proceso con SIGSYS. Si no esta presente
|
||||
// (Linux normal), no hay bateria que reportar (no-op).
|
||||
bin := findTermuxBattery()
|
||||
if bin == "" {
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, bin).Output()
|
||||
if err != nil {
|
||||
// Comando presente pero fallido (timeout, sin permisos, etc.): no-op.
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
|
||||
samples, err := parseBatteryJSON(out)
|
||||
if err != nil {
|
||||
// JSON inesperado o invalido: no-op, no abortamos al caller.
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// findTermuxBattery devuelve la ruta absoluta del binario termux-battery-status
|
||||
// si existe, o "" si no. Usa os.Stat (permitido por seccomp en Android) en vez
|
||||
// de exec.LookPath (que invoca faccessat2 y crashea con SIGSYS en Android).
|
||||
func findTermuxBattery() string {
|
||||
candidates := []string{}
|
||||
if prefix := os.Getenv("PREFIX"); prefix != "" {
|
||||
candidates = append(candidates, prefix+"/bin/termux-battery-status")
|
||||
}
|
||||
candidates = append(candidates, "/data/data/com.termux/files/usr/bin/termux-battery-status")
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BatterySamplesFromJSON parsea la salida JSON de `termux-battery-status` y
|
||||
// produce los PromSample de bateria. Es pura y exportada para que un caller que
|
||||
// ya tenga el JSON (por ejemplo leido de un fichero, util en Android donde el
|
||||
// agente no puede ejecutar subprocesos) lo convierta sin volver a ejecutar el
|
||||
// comando.
|
||||
func BatterySamplesFromJSON(data []byte) ([]PromSample, error) {
|
||||
return parseBatteryJSON(data)
|
||||
}
|
||||
|
||||
// parseBatteryJSON parsea la salida JSON de `termux-battery-status` y produce
|
||||
// los PromSample de bateria. Es pura: no ejecuta comandos ni toca el entorno,
|
||||
// lo que la hace testeable con un JSON fijo. Devuelve error solo si el JSON no
|
||||
// es valido o no tiene la forma esperada.
|
||||
func parseBatteryJSON(data []byte) ([]PromSample, error) {
|
||||
var bs batteryStatus
|
||||
if err := json.Unmarshal(data, &bs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// charging = 1 si el status indica carga/lleno o si esta enchufado.
|
||||
var charging float64
|
||||
if bs.Status == "CHARGING" || bs.Status == "FULL" || bs.Plugged != "UNPLUGGED" {
|
||||
charging = 1
|
||||
}
|
||||
|
||||
samples := []PromSample{
|
||||
{Name: "node_battery_percent", Value: bs.Percentage},
|
||||
{Name: "node_battery_temp_celsius", Value: bs.Temperature},
|
||||
{Name: "node_battery_charging", Value: charging},
|
||||
{Name: "node_battery_current_ua", Value: bs.Current},
|
||||
{
|
||||
Name: "node_battery_health_info",
|
||||
Labels: map[string]string{
|
||||
"health": bs.Health,
|
||||
"status": bs.Status,
|
||||
"plugged": bs.Plugged,
|
||||
},
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: collect_battery_metrics
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CollectBatteryMetrics() ([]PromSample, error)"
|
||||
description: "Recolecta metricas de bateria de un dispositivo Android via el comando termux-battery-status (paquete termux-api) y las devuelve como slice de PromSample con nombres estilo node_exporter: porcentaje, temperatura, estado de carga (booleano), corriente en microamperios y una serie informativa node_battery_health_info con labels health/status/plugged. Best-effort y multiplataforma: en nodos sin termux-battery-status (Linux normales) es un no-op que devuelve slice vacio y error nil; solo emite samples cuando el comando existe y responde JSON valido. El comando corre con timeout de 5s via context."
|
||||
tags: [prometheus, metrics, node-exporter, battery, termux, android, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "encoding/json", "os/exec", "time"]
|
||||
params: []
|
||||
output: "slice de PromSample con metricas de bateria. node_battery_percent (0-100), node_battery_temp_celsius, node_battery_charging (1 si carga/lleno/enchufado, si no 0), node_battery_current_ua (microamperios, negativo al descargar) y node_battery_health_info{health,status,plugged} con value 1. En nodos sin termux-api devuelve slice vacio. Error nil siempre en condiciones normales: la funcion traga los fallos de ejecucion/parseo como no-op (slice vacio)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestCollectBatteryMetrics_ParseDischarging"
|
||||
- "TestCollectBatteryMetrics_ParseCharging"
|
||||
- "TestCollectBatteryMetrics_ParsePluggedNotUnplugged"
|
||||
- "TestCollectBatteryMetrics_ParseFull"
|
||||
- "TestCollectBatteryMetrics_InvalidJSON"
|
||||
test_file_path: "functions/infra/collect_battery_metrics_test.go"
|
||||
file_path: "functions/infra/collect_battery_metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples, err := CollectBatteryMetrics()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// En un Linux normal samples sera vacio (no-op); en Android/Termux trae
|
||||
// node_battery_percent, node_battery_temp_celsius, node_battery_charging, etc.
|
||||
// Componer con el resto del capability group fleet-metrics:
|
||||
host, _ := CollectHostMetrics()
|
||||
all := append(host, samples...)
|
||||
body := FormatPromExposition(all, time.Now().UnixMilli())
|
||||
err = PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"user", "pass",
|
||||
body,
|
||||
map[string]string{"instance": "pixel-phone"},
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un nodo de la flota es un movil Android con Termux + termux-api y quieres
|
||||
exponer la salud de su bateria como metricas Prometheus para push a un backend
|
||||
remoto (VictoriaMetrics, Mimir). Llamala junto a `collect_host_metrics_go_infra`
|
||||
en el loop del agente de monitorizacion push y concatena los slices: en moviles
|
||||
añade las series de bateria, en el resto de nodos no aporta nada (no-op seguro),
|
||||
asi puedes usar el MISMO agente en toda la flota sin ramas por plataforma.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo produce datos en Termux/Android con termux-api instalado**: necesita el
|
||||
binario `termux-battery-status` (paquete `termux-api` + la app Termux:API). En
|
||||
cualquier otro nodo (Linux de escritorio, VPS, macOS) `exec.LookPath` falla y
|
||||
la funcion es un no-op que devuelve `[]PromSample{}, nil`. No es un error:
|
||||
simplemente no hay bateria que reportar.
|
||||
- **No devuelve error nunca en condiciones normales**: por diseño best-effort,
|
||||
tanto el binario ausente como un comando fallido (timeout, permisos) o un JSON
|
||||
invalido se tragan como slice vacio. La firma mantiene `error` por convencion
|
||||
de impureza, pero el caller no necesita ramificar por error de plataforma.
|
||||
- **Timeout de 5s**: usa `exec.CommandContext` con `context.WithTimeout`. Si
|
||||
termux-api se cuelga, la llamada aborta a los 5s y devuelve no-op.
|
||||
- **node_battery_current_ua puede ser negativo**: convencion de Android — corriente
|
||||
negativa = descarga, positiva = carga. Se reporta tal cual (microamperios).
|
||||
- **node_battery_charging es heuristico**: vale 1 si `status` es `CHARGING` o
|
||||
`FULL`, o si `plugged != "UNPLUGGED"`. Cubre el caso de estar enchufado sin
|
||||
cargar activamente (ej. `NOT_CHARGING` con cargador conectado).
|
||||
- **No incluye la label `instance`**: igual que el resto de colectores del grupo,
|
||||
esa la añade `push_prom_remote_go_infra` via extra_label en el push.
|
||||
- **El parseo esta factorizado** en `parseBatteryJSON` (funcion pura interna) para
|
||||
poder testear los samples sin ejecutar termux-battery-status real.
|
||||
@@ -0,0 +1,142 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
// findSample devuelve el primer sample con el nombre dado, o nil si no existe.
|
||||
func findSample(samples []PromSample, name string) *PromSample {
|
||||
for i := range samples {
|
||||
if samples[i].Name == name {
|
||||
return &samples[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollectBatteryMetrics_ParseDischarging(t *testing.T) {
|
||||
in := []byte(`{"health":"GOOD","percentage":85,"plugged":"UNPLUGGED","status":"DISCHARGING","temperature":28.9,"current":-350000}`)
|
||||
|
||||
samples, err := parseBatteryJSON(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBatteryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
t.Run("percent", func(t *testing.T) {
|
||||
s := findSample(samples, "node_battery_percent")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_percent")
|
||||
}
|
||||
if s.Value != 85 {
|
||||
t.Errorf("got percent %v, want 85", s.Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temp", func(t *testing.T) {
|
||||
s := findSample(samples, "node_battery_temp_celsius")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_temp_celsius")
|
||||
}
|
||||
if s.Value != 28.9 {
|
||||
t.Errorf("got temp %v, want 28.9", s.Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("charging zero when discharging and unplugged", func(t *testing.T) {
|
||||
s := findSample(samples, "node_battery_charging")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_charging")
|
||||
}
|
||||
if s.Value != 0 {
|
||||
t.Errorf("got charging %v, want 0", s.Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current", func(t *testing.T) {
|
||||
s := findSample(samples, "node_battery_current_ua")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_current_ua")
|
||||
}
|
||||
if s.Value != -350000 {
|
||||
t.Errorf("got current %v, want -350000", s.Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("health info series with labels", func(t *testing.T) {
|
||||
s := findSample(samples, "node_battery_health_info")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_health_info")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("got health_info value %v, want 1", s.Value)
|
||||
}
|
||||
if s.Labels["health"] != "GOOD" {
|
||||
t.Errorf("got health label %q, want GOOD", s.Labels["health"])
|
||||
}
|
||||
if s.Labels["status"] != "DISCHARGING" {
|
||||
t.Errorf("got status label %q, want DISCHARGING", s.Labels["status"])
|
||||
}
|
||||
if s.Labels["plugged"] != "UNPLUGGED" {
|
||||
t.Errorf("got plugged label %q, want UNPLUGGED", s.Labels["plugged"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectBatteryMetrics_ParseCharging(t *testing.T) {
|
||||
in := []byte(`{"health":"GOOD","percentage":60,"plugged":"PLUGGED_AC","status":"CHARGING","temperature":31.2,"current":420000}`)
|
||||
|
||||
samples, err := parseBatteryJSON(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBatteryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
s := findSample(samples, "node_battery_charging")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_charging")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("got charging %v, want 1 (status CHARGING)", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBatteryMetrics_ParsePluggedNotUnplugged(t *testing.T) {
|
||||
// status no es CHARGING/FULL pero plugged != UNPLUGGED -> charging = 1.
|
||||
in := []byte(`{"health":"GOOD","percentage":100,"plugged":"PLUGGED_USB","status":"NOT_CHARGING","temperature":30.0,"current":0}`)
|
||||
|
||||
samples, err := parseBatteryJSON(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBatteryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
s := findSample(samples, "node_battery_charging")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_charging")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("got charging %v, want 1 (plugged != UNPLUGGED)", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBatteryMetrics_ParseFull(t *testing.T) {
|
||||
in := []byte(`{"health":"GOOD","percentage":100,"plugged":"UNPLUGGED","status":"FULL","temperature":29.5,"current":0}`)
|
||||
|
||||
samples, err := parseBatteryJSON(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBatteryJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
s := findSample(samples, "node_battery_charging")
|
||||
if s == nil {
|
||||
t.Fatal("missing node_battery_charging")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("got charging %v, want 1 (status FULL)", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBatteryMetrics_InvalidJSON(t *testing.T) {
|
||||
in := []byte(`not a json at all`)
|
||||
|
||||
_, err := parseBatteryJSON(in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
"github.com/shirou/gopsutil/v4/load"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
// isAndroidHost indica si el host es Android (incluido Termux). Se usa para
|
||||
// evitar rutas de gopsutil que invocan os.FindProcess -> pidfd_open, syscall
|
||||
// bloqueado por el seccomp de Android que mata el proceso con SIGSYS.
|
||||
func isAndroidHost() bool {
|
||||
if os.Getenv("ANDROID_ROOT") != "" || os.Getenv("ANDROID_DATA") != "" {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/system/build.prop"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pseudoFstypes son filesystems virtuales que no representan almacenamiento
|
||||
// real y se ignoran al recolectar metricas de particiones.
|
||||
var pseudoFstypes = map[string]bool{
|
||||
"tmpfs": true,
|
||||
"devtmpfs": true,
|
||||
"overlay": true,
|
||||
"squashfs": true,
|
||||
"proc": true,
|
||||
"sysfs": true,
|
||||
"cgroup": true,
|
||||
"cgroup2": true,
|
||||
"devpts": true,
|
||||
"mqueue": true,
|
||||
"debugfs": true,
|
||||
"tracefs": true,
|
||||
"fusectl": true,
|
||||
"configfs": true,
|
||||
"pstore": true,
|
||||
"bpf": true,
|
||||
"securityfs": true,
|
||||
}
|
||||
|
||||
// CollectHostMetrics recolecta metricas del host actual (CPU, memoria, swap,
|
||||
// disco, red, temperaturas y procesos) y las devuelve como un slice de
|
||||
// PromSample con nombres estilo node_exporter simplificados.
|
||||
//
|
||||
// Es robusta: cada grupo de colector se ejecuta en su propio bloque con manejo
|
||||
// de error local. Si un colector secundario falla (red, temperaturas, etc.) se
|
||||
// omite ese grupo sin abortar. Solo retorna error si falla la informacion
|
||||
// basica de host (uptime), que se considera el minimo imprescindible.
|
||||
//
|
||||
// Funciona en Linux amd64 y Android/Termux (linux arm64): las temperaturas son
|
||||
// best-effort y se omiten si no hay sensores disponibles (tipico en Android).
|
||||
func CollectHostMetrics() ([]PromSample, error) {
|
||||
var samples []PromSample
|
||||
|
||||
// --- Host basico: uptime (imprescindible, error si falla) ---
|
||||
uptime, err := host.Uptime()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect host uptime: %w", err)
|
||||
}
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_uptime_seconds",
|
||||
Value: float64(uptime),
|
||||
})
|
||||
|
||||
// --- Load average (linux/darwin; best-effort) ---
|
||||
if avg, err := load.Avg(); err == nil && avg != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_load1", Value: avg.Load1},
|
||||
PromSample{Name: "node_load5", Value: avg.Load5},
|
||||
PromSample{Name: "node_load15", Value: avg.Load15},
|
||||
)
|
||||
}
|
||||
|
||||
// --- CPU global (intervalo corto de muestreo) ---
|
||||
if pcts, err := cpu.Percent(200*time.Millisecond, false); err == nil && len(pcts) > 0 {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_cpu_percent",
|
||||
Value: pcts[0],
|
||||
})
|
||||
}
|
||||
|
||||
// --- CPU por nucleo ---
|
||||
if pcts, err := cpu.Percent(200*time.Millisecond, true); err == nil {
|
||||
for i, p := range pcts {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_cpu_core_percent",
|
||||
Labels: map[string]string{"core": strconv.Itoa(i)},
|
||||
Value: p,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Memoria virtual ---
|
||||
if vm, err := mem.VirtualMemory(); err == nil && vm != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_mem_total_bytes", Value: float64(vm.Total)},
|
||||
PromSample{Name: "node_mem_used_bytes", Value: float64(vm.Used)},
|
||||
PromSample{Name: "node_mem_available_bytes", Value: float64(vm.Available)},
|
||||
PromSample{Name: "node_mem_used_percent", Value: vm.UsedPercent},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Swap ---
|
||||
if sw, err := mem.SwapMemory(); err == nil && sw != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_swap_total_bytes", Value: float64(sw.Total)},
|
||||
PromSample{Name: "node_swap_used_bytes", Value: float64(sw.Used)},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Particiones fisicas (ignora fstypes pseudo) ---
|
||||
if parts, err := disk.Partitions(false); err == nil {
|
||||
for _, p := range parts {
|
||||
if pseudoFstypes[p.Fstype] {
|
||||
continue
|
||||
}
|
||||
u, err := disk.Usage(p.Mountpoint)
|
||||
if err != nil || u == nil {
|
||||
continue
|
||||
}
|
||||
lbl := map[string]string{"mount": p.Mountpoint}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_disk_total_bytes", Labels: lbl, Value: float64(u.Total)},
|
||||
PromSample{Name: "node_disk_used_bytes", Labels: lbl, Value: float64(u.Used)},
|
||||
PromSample{Name: "node_disk_used_percent", Labels: lbl, Value: u.UsedPercent},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contadores I/O por dispositivo ---
|
||||
if io, err := disk.IOCounters(); err == nil {
|
||||
for dev, c := range io {
|
||||
lbl := map[string]string{"device": dev}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_disk_read_bytes", Labels: lbl, Value: float64(c.ReadBytes)},
|
||||
PromSample{Name: "node_disk_write_bytes", Labels: lbl, Value: float64(c.WriteBytes)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Red por interfaz (excluye loopback "lo") ---
|
||||
if nics, err := net.IOCounters(true); err == nil {
|
||||
for _, n := range nics {
|
||||
if n.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
lbl := map[string]string{"iface": n.Name}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_net_recv_bytes", Labels: lbl, Value: float64(n.BytesRecv)},
|
||||
PromSample{Name: "node_net_sent_bytes", Labels: lbl, Value: float64(n.BytesSent)},
|
||||
PromSample{Name: "node_net_recv_errs", Labels: lbl, Value: float64(n.Errin)},
|
||||
PromSample{Name: "node_net_sent_errs", Labels: lbl, Value: float64(n.Errout)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Temperaturas (best-effort; omite el grupo si falla o no hay sensores) ---
|
||||
if temps, err := sensors.SensorsTemperatures(); err == nil {
|
||||
for _, t := range temps {
|
||||
if t.SensorKey == "" {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_temp_celsius",
|
||||
Labels: map[string]string{"sensor": t.SensorKey},
|
||||
Value: t.Temperature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Procesos: total + top 5 por CPU ---
|
||||
// En Android (Termux) gopsutil process.Processes() llama internamente a
|
||||
// os.FindProcess, que usa el syscall pidfd_open bloqueado por el seccomp de
|
||||
// Android (mata el proceso con SIGSYS, no recuperable). Alli contamos los
|
||||
// PIDs con process.Pids() (que solo lee /proc, sin FindProcess) y omitimos
|
||||
// el top por CPU.
|
||||
if isAndroidHost() {
|
||||
if pids, err := process.Pids(); err == nil {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_procs_total",
|
||||
Value: float64(len(pids)),
|
||||
})
|
||||
}
|
||||
} else if procs, err := process.Processes(); err == nil {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_procs_total",
|
||||
Value: float64(len(procs)),
|
||||
})
|
||||
|
||||
type procStat struct {
|
||||
pid int32
|
||||
name string
|
||||
cpu float64
|
||||
mem float32
|
||||
}
|
||||
stats := make([]procStat, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
cpuPct, err := p.CPUPercent()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
name = ""
|
||||
}
|
||||
memPct, err := p.MemoryPercent()
|
||||
if err != nil {
|
||||
memPct = 0
|
||||
}
|
||||
stats = append(stats, procStat{pid: p.Pid, name: name, cpu: cpuPct, mem: memPct})
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
return stats[i].cpu > stats[j].cpu
|
||||
})
|
||||
top := stats
|
||||
if len(top) > 5 {
|
||||
top = top[:5]
|
||||
}
|
||||
for _, s := range top {
|
||||
lbl := map[string]string{
|
||||
"pid": strconv.Itoa(int(s.pid)),
|
||||
"name": s.name,
|
||||
}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_proc_cpu_percent", Labels: lbl, Value: s.cpu},
|
||||
PromSample{Name: "node_proc_mem_percent", Labels: lbl, Value: float64(s.mem)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: collect_host_metrics
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CollectHostMetrics() ([]PromSample, error)"
|
||||
description: "Recolecta metricas del host actual (uptime, load, CPU global y por nucleo, memoria, swap, disco por particion fisica e I/O por dispositivo, red por interfaz, temperaturas best-effort y procesos: total + top 5 por CPU) y las devuelve como slice de PromSample con nombres estilo node_exporter simplificados. Robusta: cada grupo de colector tiene manejo de error local; si un colector secundario falla se omite ese grupo sin abortar. Funciona en Linux amd64 y Android/Termux (linux arm64)."
|
||||
tags: [prometheus, metrics, node-exporter, gopsutil, fleet-metrics, infra, monitoring, host]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "sort", "strconv", "time", "github.com/shirou/gopsutil/v4/cpu", "github.com/shirou/gopsutil/v4/disk", "github.com/shirou/gopsutil/v4/host", "github.com/shirou/gopsutil/v4/load", "github.com/shirou/gopsutil/v4/mem", "github.com/shirou/gopsutil/v4/net", "github.com/shirou/gopsutil/v4/process", "github.com/shirou/gopsutil/v4/sensors"]
|
||||
params: []
|
||||
output: "slice de PromSample con las metricas del host. Cada sample lleva nombre estilo node_exporter (node_cpu_percent, node_disk_used_bytes{mount}, etc.) y sus labels. Error solo si falla el uptime de host (informacion basica imprescindible)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestCollectHostMetrics_ReturnsBasics"
|
||||
- "TestCollectHostMetrics_SamplesWellFormed"
|
||||
test_file_path: "functions/infra/collect_host_metrics_test.go"
|
||||
file_path: "functions/infra/collect_host_metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples, err := CollectHostMetrics()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Formatear a exposition Prometheus y enviar a VictoriaMetrics:
|
||||
body := FormatPromExposition(samples, time.Now().UnixMilli())
|
||||
err = PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"user", "pass",
|
||||
body,
|
||||
map[string]string{"instance": "lucas-pc"},
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un snapshot completo de salud del host en formato Prometheus
|
||||
para hacer push a un backend remoto (VictoriaMetrics, Mimir, etc.) en lugar de
|
||||
exponer un endpoint /metrics para scraping. Es el colector base del capability
|
||||
group `fleet-metrics`: combinala con `format_prom_exposition_go_infra` y
|
||||
`push_prom_remote_go_infra` para un agente de monitorizacion push estilo
|
||||
node_exporter. Llamala periodicamente (cron, timer, loop) en cada nodo de la
|
||||
flota.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Bloquea ~400ms**: hace dos llamadas a `cpu.Percent` con intervalo de 200ms
|
||||
cada una (global + por nucleo). No la llames en hot paths ni con periodo < 1s.
|
||||
- **Temperaturas best-effort**: usa `sensors.SensorsTemperatures` (movido del
|
||||
paquete `host` al paquete `sensors` en gopsutil v4). Si no hay sensores
|
||||
(tipico en Android/Termux y muchos VPS) el grupo `node_temp_celsius` se omite
|
||||
sin error.
|
||||
- **Particiones pseudo ignoradas**: tmpfs, devtmpfs, overlay, squashfs, proc,
|
||||
sysfs y similares se filtran. Solo reporta particiones de almacenamiento real.
|
||||
- **Loopback excluido**: la interfaz `lo` no genera metricas de red.
|
||||
- **CPU por proceso necesita dos lecturas**: `CPUPercent()` de gopsutil sobre un
|
||||
proceso recien obtenido puede devolver un valor calculado desde el arranque
|
||||
del proceso, no un delta. Util para ranking relativo del top 5, no como medida
|
||||
instantanea precisa.
|
||||
- **No incluye la label `instance`**: los samples no llevan instance; esa la
|
||||
añade `push_prom_remote_go_infra` via extra_label en el push.
|
||||
- **Permisos**: algunos contadores (procesos de otros usuarios, ciertos sensores)
|
||||
pueden requerir privilegios; los fallos parciales se omiten silenciosamente.
|
||||
@@ -0,0 +1,43 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCollectHostMetrics_ReturnsBasics(t *testing.T) {
|
||||
samples, err := CollectHostMetrics()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
t.Fatal("expected at least one sample")
|
||||
}
|
||||
|
||||
// node_uptime_seconds es el unico colector imprescindible: debe estar siempre.
|
||||
found := false
|
||||
for _, s := range samples {
|
||||
if s.Name == "node_uptime_seconds" {
|
||||
found = true
|
||||
if s.Value <= 0 {
|
||||
t.Errorf("node_uptime_seconds should be positive, got %v", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("node_uptime_seconds not present in samples")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectHostMetrics_SamplesWellFormed(t *testing.T) {
|
||||
samples, err := CollectHostMetrics()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for i, s := range samples {
|
||||
if s.Name == "" {
|
||||
t.Errorf("sample %d has empty Name", i)
|
||||
}
|
||||
// La label "instance" NO debe estar: la añade el pusher.
|
||||
if _, ok := s.Labels["instance"]; ok {
|
||||
t.Errorf("sample %d (%s) must not carry the instance label", i, s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatPromExposition convierte un slice de PromSample en texto con formato
|
||||
// Prometheus exposition. Genera una linea por sample:
|
||||
//
|
||||
// name{k1="v1",k2="v2"} value timestampMs
|
||||
//
|
||||
// Reglas:
|
||||
// - Si timestampMs <= 0, omite el campo timestamp.
|
||||
// - Sin labels: "name value" (sin llaves).
|
||||
// - Las labels se ordenan por clave (salida determinista).
|
||||
// - En los valores de label se escapa: backslash -> \\, comilla -> \", newline -> \n.
|
||||
// - El nombre de metrica se sanitiza a [a-zA-Z0-9_:] (el resto -> _).
|
||||
// - El valor se formatea con strconv.FormatFloat(v, 'g', -1, 64).
|
||||
//
|
||||
// Es una funcion pura: no tiene efectos secundarios y la salida es deterministica
|
||||
// para una entrada dada.
|
||||
func FormatPromExposition(samples []PromSample, timestampMs int64) string {
|
||||
var b strings.Builder
|
||||
for _, s := range samples {
|
||||
b.WriteString(sanitizeMetricName(s.Name))
|
||||
|
||||
if len(s.Labels) > 0 {
|
||||
keys := make([]string, 0, len(s.Labels))
|
||||
for k := range s.Labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeLabelValue(s.Labels[k]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
b.WriteByte('}')
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.FormatFloat(s.Value, 'g', -1, 64))
|
||||
|
||||
if timestampMs > 0 {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.FormatInt(timestampMs, 10))
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sanitizeMetricName sustituye cualquier caracter fuera de [a-zA-Z0-9_:] por '_'.
|
||||
func sanitizeMetricName(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '_' || r == ':' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeLabelValue escapa los caracteres especiales del formato exposition en
|
||||
// el valor de una label: backslash, comilla doble y newline.
|
||||
func escapeLabelValue(v string) string {
|
||||
v = strings.ReplaceAll(v, `\`, `\\`)
|
||||
v = strings.ReplaceAll(v, `"`, `\"`)
|
||||
v = strings.ReplaceAll(v, "\n", `\n`)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: format_prom_exposition
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FormatPromExposition(samples []PromSample, timestampMs int64) string"
|
||||
description: "Convierte un slice de PromSample en texto con formato Prometheus exposition (una linea por sample: name{k=\"v\"} value timestampMs). Ordena labels por clave (salida determinista), escapa backslash/comilla/newline en valores de label, sanitiza el nombre de metrica a [a-zA-Z0-9_:], formatea el valor con FormatFloat 'g'. Si timestampMs<=0 omite el timestamp; sin labels omite las llaves. Funcion pura."
|
||||
tags: [prometheus, exposition, metrics, format, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["sort", "strconv", "strings"]
|
||||
params:
|
||||
- name: samples
|
||||
desc: "slice de PromSample a serializar; cada uno aporta una linea de exposition"
|
||||
- name: timestampMs
|
||||
desc: "timestamp en milisegundos epoch a adjuntar a cada linea; si es <=0 se omite el campo timestamp"
|
||||
output: "string con el texto exposition Prometheus, una linea por sample terminada en \\n. String vacio si samples esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestFormatPromExposition"
|
||||
test_file_path: "functions/infra/format_prom_exposition_test.go"
|
||||
file_path: "functions/infra/format_prom_exposition.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples := []PromSample{
|
||||
{Name: "node_load1", Value: 0.42},
|
||||
{Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5},
|
||||
{Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1024},
|
||||
}
|
||||
text := FormatPromExposition(samples, 1700000000000)
|
||||
// node_load1 0.42 1700000000000
|
||||
// node_cpu_core_percent{core="0"} 12.5 1700000000000
|
||||
// node_disk_used_bytes{mount="/"} 1024 1700000000000
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un slice de PromSample (tipicamente de collect_host_metrics) y
|
||||
necesites serializarlo al formato de texto que entienden los endpoints de
|
||||
ingestion Prometheus (`/api/v1/import/prometheus` de VictoriaMetrics, pushgateway,
|
||||
etc.). Es el paso intermedio del capability group `fleet-metrics`: colecta ->
|
||||
formatea -> empuja. Al ser pura y determinista, tambien sirve para snapshots
|
||||
reproducibles y golden tests.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El timestamp es **milisegundos** epoch (Prometheus exposition usa ms), no
|
||||
segundos. Pasa `time.Now().UnixMilli()`.
|
||||
- `timestampMs <= 0` (incluido 0) omite el campo timestamp por completo.
|
||||
- La label `instance` NO se gestiona aqui: si esta en `Labels` se serializa tal
|
||||
cual, pero la convencion del grupo es dejarla fuera y añadirla en el push via
|
||||
extra_label.
|
||||
- No agrupa por nombre ni emite lineas `# HELP` / `# TYPE`: salida cruda de
|
||||
series, suficiente para ingestion pero no para un endpoint /metrics canonico.
|
||||
- El nombre de metrica se sanitiza de forma destructiva: `node.cpu-percent!` se
|
||||
convierte en `node_cpu_percent_`. Nombra bien los samples en origen.
|
||||
@@ -0,0 +1,74 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatPromExposition(t *testing.T) {
|
||||
t.Run("varias series con y sin labels con timestamp", func(t *testing.T) {
|
||||
samples := []PromSample{
|
||||
{Name: "node_load1", Value: 0.42},
|
||||
{Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5},
|
||||
{Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1024},
|
||||
}
|
||||
got := FormatPromExposition(samples, 1700000000000)
|
||||
want := "node_load1 0.42 1700000000000\n" +
|
||||
"node_cpu_core_percent{core=\"0\"} 12.5 1700000000000\n" +
|
||||
"node_disk_used_bytes{mount=\"/\"} 1024 1700000000000\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin timestamp omite el campo", func(t *testing.T) {
|
||||
samples := []PromSample{
|
||||
{Name: "node_load1", Value: 0.42},
|
||||
{Name: "node_cpu_percent", Value: 3},
|
||||
}
|
||||
got := FormatPromExposition(samples, 0)
|
||||
want := "node_load1 0.42\n" +
|
||||
"node_cpu_percent 3\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("labels ordenadas por clave deterministico", func(t *testing.T) {
|
||||
samples := []PromSample{
|
||||
{Name: "node_proc_cpu_percent", Labels: map[string]string{"pid": "42", "name": "claude"}, Value: 7.5},
|
||||
}
|
||||
got := FormatPromExposition(samples, 0)
|
||||
// "name" antes que "pid" alfabeticamente.
|
||||
want := "node_proc_cpu_percent{name=\"claude\",pid=\"42\"} 7.5\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("escapa backslash comilla y newline en valor de label", func(t *testing.T) {
|
||||
samples := []PromSample{
|
||||
{Name: "node_proc_cpu_percent", Labels: map[string]string{"name": "a\\b\"c\nd"}, Value: 1},
|
||||
}
|
||||
got := FormatPromExposition(samples, 0)
|
||||
want := "node_proc_cpu_percent{name=\"a\\\\b\\\"c\\nd\"} 1\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sanitiza nombre de metrica invalido", func(t *testing.T) {
|
||||
samples := []PromSample{
|
||||
{Name: "node.cpu-percent!", Value: 5},
|
||||
}
|
||||
got := FormatPromExposition(samples, 0)
|
||||
want := "node_cpu_percent_ 5\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("slice vacio produce string vacio", func(t *testing.T) {
|
||||
got := FormatPromExposition(nil, 1700000000000)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// natsVarz refleja los campos relevantes de la respuesta JSON del endpoint
|
||||
// /varz del monitoring HTTP embebido de un nats-server (puerto 8222, loopback).
|
||||
// Solo se mapean los campos que producen series; el resto se ignora.
|
||||
type natsVarz struct {
|
||||
InMsgs int64 `json:"in_msgs"`
|
||||
OutMsgs int64 `json:"out_msgs"`
|
||||
InBytes int64 `json:"in_bytes"`
|
||||
OutBytes int64 `json:"out_bytes"`
|
||||
Connections int `json:"connections"`
|
||||
SlowConsumers int `json:"slow_consumers"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
Mem int64 `json:"mem"`
|
||||
Start string `json:"start"`
|
||||
}
|
||||
|
||||
// natsConnz refleja los campos relevantes de /connz.
|
||||
type natsConnz struct {
|
||||
NumConnections int `json:"num_connections"`
|
||||
}
|
||||
|
||||
// natsStreamDetail refleja un stream dentro de account_details[].stream_detail[].
|
||||
type natsStreamDetail struct {
|
||||
Name string `json:"name"`
|
||||
Cluster struct {
|
||||
Leader string `json:"leader"`
|
||||
} `json:"cluster"`
|
||||
State struct {
|
||||
Messages int64 `json:"messages"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
} `json:"state"`
|
||||
}
|
||||
|
||||
// natsJsz refleja los campos relevantes de /jsz?streams=1.
|
||||
type natsJsz struct {
|
||||
Streams int64 `json:"streams"`
|
||||
Messages int64 `json:"messages"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Memory int64 `json:"memory"`
|
||||
Storage int64 `json:"storage"`
|
||||
AccountDetails []struct {
|
||||
StreamDetail []natsStreamDetail `json:"stream_detail"`
|
||||
} `json:"account_details"`
|
||||
}
|
||||
|
||||
// ParseNatsMonitor convierte las respuestas JSON del endpoint de monitoring HTTP
|
||||
// embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample
|
||||
// lista para empujar a VictoriaMetrics. Es la hermana de ParseUnibusHealth para
|
||||
// las métricas server-level de NATS/JetStream (msgs/s, conexiones, KV bucket
|
||||
// msgs, RAFT leader por stream, memoria). La consume el unibus_exporter de
|
||||
// fleet_monitoring en modo scraper local por nodo.
|
||||
//
|
||||
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a CADA serie
|
||||
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||
// exporter scrapea varios.
|
||||
//
|
||||
// varz, connz y jsz son los cuerpos crudos de GET /varz, GET /connz y
|
||||
// GET /jsz?streams=1 respectivamente:
|
||||
// - varz es el core: si NO parsea como JSON válido devuelve (nil, error).
|
||||
// - connz y jsz son best-effort: si vienen vacíos o no parsean, sus series se
|
||||
// omiten sin abortar (no error), para que el scraper resista que un endpoint
|
||||
// falle. nats_connections cae a varz.connections cuando connz no parsea.
|
||||
func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) {
|
||||
var v natsVarz
|
||||
if err := json.Unmarshal(varz, &v); err != nil {
|
||||
return nil, fmt.Errorf("parse nats varz for node %q: %w", node, err)
|
||||
}
|
||||
|
||||
// mk construye un PromSample con las labels base {node, instance} más, de
|
||||
// forma opcional, labels extra (clave/valor alternados). Las labels base no
|
||||
// se pueden sobreescribir desde extra.
|
||||
mk := func(name string, val float64, extra ...string) PromSample {
|
||||
labels := map[string]string{"node": node, "instance": node}
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
labels[extra[i]] = extra[i+1]
|
||||
}
|
||||
return PromSample{Name: name, Labels: labels, Value: val}
|
||||
}
|
||||
|
||||
out := []PromSample{
|
||||
mk("nats_msgs_in_total", float64(v.InMsgs)),
|
||||
mk("nats_msgs_out_total", float64(v.OutMsgs)),
|
||||
mk("nats_bytes_in_total", float64(v.InBytes)),
|
||||
mk("nats_bytes_out_total", float64(v.OutBytes)),
|
||||
}
|
||||
|
||||
// nats_connections: prefiere connz.num_connections; si connz no parsea, cae
|
||||
// a varz.connections para no perder la serie.
|
||||
connections := float64(v.Connections)
|
||||
if len(connz) > 0 {
|
||||
var c natsConnz
|
||||
if err := json.Unmarshal(connz, &c); err == nil {
|
||||
connections = float64(c.NumConnections)
|
||||
}
|
||||
}
|
||||
out = append(out,
|
||||
mk("nats_connections", connections),
|
||||
mk("nats_slow_consumers", float64(v.SlowConsumers)),
|
||||
mk("nats_mem_bytes", float64(v.Mem)),
|
||||
mk("nats_subscriptions", float64(v.Subscriptions)),
|
||||
)
|
||||
|
||||
// nats_server_start_seconds: epoch (segundos Unix) del campo start (RFC3339).
|
||||
// Proxy de reinicios del nats-server: un cambio de este valor = el server
|
||||
// reinició. Si el parse de la fecha falla, se omite la serie (no se aborta).
|
||||
if t, err := time.Parse(time.RFC3339, v.Start); err == nil {
|
||||
out = append(out, mk("nats_server_start_seconds", float64(t.Unix())))
|
||||
}
|
||||
|
||||
// jsz es best-effort: si vacío o inválido, se omiten todas sus series.
|
||||
if len(jsz) > 0 {
|
||||
var j natsJsz
|
||||
if err := json.Unmarshal(jsz, &j); err == nil {
|
||||
out = append(out,
|
||||
mk("nats_jetstream_streams", float64(j.Streams)),
|
||||
mk("nats_jetstream_messages", float64(j.Messages)),
|
||||
mk("nats_jetstream_bytes", float64(j.Bytes)),
|
||||
mk("nats_jetstream_memory_bytes", float64(j.Memory)),
|
||||
mk("nats_jetstream_storage_bytes", float64(j.Storage)),
|
||||
)
|
||||
for _, acc := range j.AccountDetails {
|
||||
for _, sd := range acc.StreamDetail {
|
||||
out = append(out,
|
||||
mk("nats_stream_messages", float64(sd.State.Messages), "stream", sd.Name),
|
||||
mk("nats_stream_bytes", float64(sd.State.Bytes), "stream", sd.Name),
|
||||
)
|
||||
leader := 0.0
|
||||
if sd.Cluster.Leader == node {
|
||||
leader = 1
|
||||
}
|
||||
out = append(out, mk("nats_jetstream_raft_leader", leader, "stream", sd.Name))
|
||||
|
||||
if bucket, ok := strings.CutPrefix(sd.Name, "KV_"); ok {
|
||||
out = append(out, mk("kv_bucket_msgs", float64(sd.State.Messages), "bucket", bucket))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: parse_nats_monitor
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error)"
|
||||
description: "Convierte las respuestas JSON del endpoint de monitoring HTTP embebido de un nats-server (puerto 8222, loopback) en una serie de PromSample lista para empujar a VictoriaMetrics. Hermana de ParseUnibusHealth pero para las métricas server-level de NATS/JetStream: msgs/s, bytes, conexiones, slow consumers, memoria RSS, start epoch (proxy de reinicios), streams/messages/bytes/memory/storage de JetStream, y por stream nats_stream_messages/bytes, nats_jetstream_raft_leader y kv_bucket_msgs para los buckets KV_. Adjunta labels node e instance a cada serie. varz es el core (error si no parsea); connz y jsz son best-effort (se omiten sin abortar). La consume el unibus_exporter de fleet_monitoring como scraper local por nodo."
|
||||
tags: [prometheus, metrics, nats, jetstream, monitoring, varz, connz, jsz, kv, raft, fleet-metrics, infra]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: true
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "strings", "time"]
|
||||
params:
|
||||
- name: node
|
||||
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a CADA serie y se compara con cluster.leader de cada stream para nats_jetstream_raft_leader"
|
||||
- name: varz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/varz; core de la función (in_msgs, out_msgs, in_bytes, out_bytes, connections, slow_consumers, subscriptions, mem, start). Si no parsea, la función devuelve error"
|
||||
- name: connz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/connz; best-effort (num_connections). Si vacío o inválido, nats_connections cae a varz.connections sin abortar"
|
||||
- name: jsz
|
||||
desc: "cuerpo JSON crudo de GET http://127.0.0.1:8222/jsz?streams=1; best-effort (streams, messages, bytes, memory, storage y account_details[].stream_detail[]). Si vacío o inválido, se omiten sus series sin abortar. Necesita ?streams=1 para traer stream_detail"
|
||||
output: "slice de PromSample con labels base {node,instance}: nats_msgs_in/out_total, nats_bytes_in/out_total, nats_connections, nats_slow_consumers, nats_mem_bytes, nats_subscriptions, nats_server_start_seconds (omitida si start no parsea), nats_jetstream_streams/messages/bytes/memory_bytes/storage_bytes; y por stream nats_stream_messages{stream}, nats_stream_bytes{stream}, nats_jetstream_raft_leader{stream} (1 si cluster.leader==node) y, para streams KV_, kv_bucket_msgs{bucket} con el prefijo KV_ recortado. Error solo si varz no es JSON válido."
|
||||
tested: true
|
||||
test_file_path: "functions/infra/parse_nats_monitor_test.go"
|
||||
tests:
|
||||
- "TestParseNatsMonitorGolden"
|
||||
- "TestParseNatsMonitorEmptyJsz"
|
||||
- "TestParseNatsMonitorInvalidConnz"
|
||||
- "TestParseNatsMonitorInvalidVarz"
|
||||
---
|
||||
|
||||
# parse_nats_monitor
|
||||
|
||||
Función de transformación (clasificada `impure` porque devuelve `error` al fallar el
|
||||
unmarshal del core; no hace I/O ni red por sí misma) que traduce las métricas
|
||||
server-level de un **nats-server** a series Prometheus. Es la hermana de
|
||||
`parse_unibus_health_go_infra`: aquella lee el `/healthz` de `membershipd` (posture),
|
||||
esta lee el monitoring embebido de NATS (puerto 8222) para las métricas profundas que
|
||||
`/healthz` no expone: msgs/s, conexiones, RAFT leader por stream, memoria, KV buckets.
|
||||
|
||||
Pertenece al grupo de capacidad `fleet-metrics`: se compone con
|
||||
`format_prom_exposition_go_infra` (serializar) y `push_prom_remote_go_infra` (empujar a
|
||||
VictoriaMetrics). La consume el `unibus_exporter` de `fleet_monitoring` en modo scraper
|
||||
local por nodo, que hace los tres GET y le pasa los cuerpos crudos.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func get(url string) []byte {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil // best-effort: connz/jsz pueden faltar
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := "http://127.0.0.1:8222"
|
||||
varz := get(base + "/varz")
|
||||
connz := get(base + "/connz")
|
||||
jsz := get(base + "/jsz?streams=1")
|
||||
|
||||
samples, err := infra.ParseNatsMonitor("magnus", varz, connz, jsz)
|
||||
if err != nil {
|
||||
panic(err) // varz es el core: sin él no hay métricas
|
||||
}
|
||||
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||
// nats_msgs_in_total{instance="magnus",node="magnus"} 17 ...
|
||||
// kv_bucket_msgs{bucket="UNIBUS_users",instance="magnus",node="magnus"} 2 ...
|
||||
// nats_jetstream_raft_leader{instance="magnus",node="magnus",stream="KV_UNIBUS_users"} 1 ...
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro de un exporter que monitoriza un nats-server con el monitoring HTTP
|
||||
embebido activado (`http: 127.0.0.1:8222` en la config de NATS): tras hacer
|
||||
`GET /varz`, `GET /connz` y `GET /jsz?streams=1` contra loopback, pasa los tres cuerpos
|
||||
crudos a esta función para obtener todas las series server-level del nodo. Llámala como
|
||||
scraper local por nodo (cada nodo expone su 8222 solo en loopback), no centralizado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura por contrato**: solo devuelve `error` si `varz` no es JSON válido (es el core).
|
||||
`connz` y `jsz` son **best-effort**: si vienen vacíos o no parsean, sus series se omiten
|
||||
sin abortar. Esto hace al scraper resistente a que un endpoint falle de forma puntual.
|
||||
- **Monitoring loopback-only sin auth**: el puerto 8222 de NATS no tiene autenticación; por
|
||||
eso debe bindearse a `127.0.0.1` y scrapearse localmente en cada nodo, nunca exponerse a
|
||||
la red. El push agregado a VictoriaMetrics lo hace el exporter, no esta función.
|
||||
- **`/jsz` necesita `?streams=1`** para traer `account_details[].stream_detail[]`. Sin ese
|
||||
parámetro el cuerpo trae los totales pero no el detalle por stream, y entonces no salen
|
||||
`nats_stream_*`, `nats_jetstream_raft_leader` ni `kv_bucket_msgs`.
|
||||
- **`nats_connections`**: prefiere `connz.num_connections`; si `connz` no parsea, cae a
|
||||
`varz.connections` para no perder la serie.
|
||||
- **RAFT leader en standalone**: en un nats-server sin clúster, el objeto `cluster` puede
|
||||
faltar o `leader` venir vacío; en ese caso `nats_jetstream_raft_leader` sale 0 salvo que
|
||||
`cluster.leader == node`. Es esperado: en standalone no hay quorum RAFT real.
|
||||
- **`kv_bucket_msgs`** solo se emite para streams cuyo nombre empieza por `KV_`, recortando
|
||||
el prefijo (stream `KV_UNIBUS_users` → bucket `UNIBUS_users`).
|
||||
- **`nats_server_start_seconds`** es el epoch Unix del campo `start` (RFC3339): sirve como
|
||||
proxy de reinicios (un cambio de valor = el server reinició). Si el campo no parsea como
|
||||
fecha válida, la serie se omite en lugar de abortar.
|
||||
@@ -0,0 +1,160 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// findNatsSample devuelve el primer PromSample cuyo Name coincide y cuyos labels
|
||||
// extra (clave/valor alternados) están todos presentes con el valor esperado.
|
||||
// El segundo retorno indica si se encontró.
|
||||
func findNatsSample(samples []PromSample, name string, labels ...string) (PromSample, bool) {
|
||||
for _, s := range samples {
|
||||
if s.Name != name {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for i := 0; i+1 < len(labels); i += 2 {
|
||||
if s.Labels[labels[i]] != labels[i+1] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return PromSample{}, false
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// golden: fixtures reales de un nats-server 2.11.15, node="probe" (== el leader
|
||||
// de los streams), valores concretos verificados a mano.
|
||||
func TestParseNatsMonitorGolden(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
connz := mustRead(t, "testdata/nats_connz.json")
|
||||
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]float64{
|
||||
"nats_msgs_in_total": 17,
|
||||
"nats_msgs_out_total": 17,
|
||||
"nats_mem_bytes": 18288640,
|
||||
"nats_jetstream_streams": 3,
|
||||
"nats_connections": 1,
|
||||
"nats_jetstream_messages": 6,
|
||||
}
|
||||
for name, w := range want {
|
||||
s, ok := findNatsSample(got, name)
|
||||
if !ok {
|
||||
t.Errorf("missing sample %q", name)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("%s = %v, want %v", name, s.Value, w)
|
||||
}
|
||||
if s.Labels["node"] != "probe" || s.Labels["instance"] != "probe" {
|
||||
t.Errorf("%s labels = %v, want node=instance=probe", name, s.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// kv_bucket_msgs por cada KV bucket (prefijo KV_ recortado).
|
||||
for bucket, w := range map[string]float64{
|
||||
"UNIBUS_users": 2,
|
||||
"UNIBUS_rooms": 2,
|
||||
"UNIBUS_members": 2,
|
||||
} {
|
||||
s, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", bucket)
|
||||
if !ok {
|
||||
t.Errorf("missing kv_bucket_msgs{bucket=%q}", bucket)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("kv_bucket_msgs{bucket=%q} = %v, want %v", bucket, s.Value, w)
|
||||
}
|
||||
}
|
||||
|
||||
// raft leader: probe == node, así que el stream KV_UNIBUS_users tiene leader=1.
|
||||
s, ok := findNatsSample(got, "nats_jetstream_raft_leader", "stream", "KV_UNIBUS_users")
|
||||
if !ok {
|
||||
t.Fatal("missing nats_jetstream_raft_leader{stream=KV_UNIBUS_users}")
|
||||
}
|
||||
if s.Value != 1 {
|
||||
t.Errorf("nats_jetstream_raft_leader{stream=KV_UNIBUS_users} = %v, want 1", s.Value)
|
||||
}
|
||||
|
||||
// stream_detail también emite nats_stream_messages con label stream completo.
|
||||
if s, ok := findNatsSample(got, "nats_stream_messages", "stream", "KV_UNIBUS_users"); !ok || s.Value != 2 {
|
||||
t.Errorf("nats_stream_messages{stream=KV_UNIBUS_users} = %v ok=%v, want 2", s.Value, ok)
|
||||
}
|
||||
|
||||
// nats_server_start_seconds presente (start es RFC3339 válido).
|
||||
if _, ok := findNatsSample(got, "nats_server_start_seconds"); !ok {
|
||||
t.Error("missing nats_server_start_seconds (start is a valid RFC3339)")
|
||||
}
|
||||
}
|
||||
|
||||
// edge: jsz sin streams ni account_details. No produce series kv_bucket_msgs ni
|
||||
// nats_stream_*, pero sí las de varz/connz y las jetstream top-level (en 0).
|
||||
func TestParseNatsMonitorEmptyJsz(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
connz := mustRead(t, "testdata/nats_connz.json")
|
||||
jsz := []byte(`{"streams":0,"account_details":[]}`)
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, connz, jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := findNatsSample(got, "kv_bucket_msgs", "bucket", "UNIBUS_users"); ok {
|
||||
t.Error("did not expect kv_bucket_msgs with empty account_details")
|
||||
}
|
||||
if _, ok := findNatsSample(got, "nats_stream_messages"); ok {
|
||||
t.Error("did not expect nats_stream_messages with empty account_details")
|
||||
}
|
||||
// varz/connz siguen presentes.
|
||||
if s, ok := findNatsSample(got, "nats_msgs_in_total"); !ok || s.Value != 17 {
|
||||
t.Errorf("nats_msgs_in_total = %v ok=%v, want 17", s.Value, ok)
|
||||
}
|
||||
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||
t.Errorf("nats_connections = %v ok=%v, want 1", s.Value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// edge: connz inválido. No es error; nats_connections cae a varz.connections (1).
|
||||
// varz/jsz siguen produciendo sus series.
|
||||
func TestParseNatsMonitorInvalidConnz(t *testing.T) {
|
||||
varz := mustRead(t, "testdata/nats_varz.json")
|
||||
jsz := mustRead(t, "testdata/nats_jsz.json")
|
||||
|
||||
got, err := ParseNatsMonitor("probe", varz, []byte("not json"), jsz)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// fallback a varz.connections (= 1).
|
||||
if s, ok := findNatsSample(got, "nats_connections"); !ok || s.Value != 1 {
|
||||
t.Errorf("nats_connections = %v ok=%v, want 1 (fallback varz.connections)", s.Value, ok)
|
||||
}
|
||||
// jsz sigue vivo.
|
||||
if s, ok := findNatsSample(got, "nats_jetstream_streams"); !ok || s.Value != 3 {
|
||||
t.Errorf("nats_jetstream_streams = %v ok=%v, want 3", s.Value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// error path: varz inválido devuelve error no-nil (es el core, sin él no hay nada).
|
||||
func TestParseNatsMonitorInvalidVarz(t *testing.T) {
|
||||
if _, err := ParseNatsMonitor("probe", []byte("{{{"), nil, nil); err == nil {
|
||||
t.Fatal("expected error for invalid varz, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// unibusHealth refleja la respuesta JSON del endpoint /healthz de un nodo del
|
||||
// cluster de mensajería unibus (membershipd). Forma verificada en producción:
|
||||
//
|
||||
// {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||
type unibusHealth struct {
|
||||
Status string `json:"status"`
|
||||
Posture struct {
|
||||
Enforce bool `json:"enforce"`
|
||||
ACL bool `json:"acl"`
|
||||
TLS bool `json:"tls"`
|
||||
Cluster bool `json:"cluster"`
|
||||
Store string `json:"store"`
|
||||
} `json:"posture"`
|
||||
}
|
||||
|
||||
// ParseUnibusHealth convierte la respuesta JSON del endpoint /healthz de un nodo
|
||||
// del cluster de mensajería unibus en una serie de PromSample lista para empujar
|
||||
// a VictoriaMetrics, sin instrumentar el bus (solo lee su endpoint de salud).
|
||||
//
|
||||
// node es el nombre lógico del nodo (p.ej. "magnus"); se adjunta a cada serie
|
||||
// como las labels "node" e "instance" para distinguir los nodos cuando un único
|
||||
// exporter scrapea varios. La función SOLO debe llamarse cuando el nodo
|
||||
// respondió: el caso "no responde" (unibus_up=0) lo emite el llamador, no esta
|
||||
// función, porque sin cuerpo no hay nada que parsear.
|
||||
//
|
||||
// Devuelve siete series por nodo:
|
||||
// - unibus_up = 1 (si el body parseó, el nodo respondió)
|
||||
// - unibus_status_ok = 1 si status=="ok", si no 0
|
||||
// - unibus_posture_enforce / _acl / _tls / _cluster = 1/0 según el booleano
|
||||
// - unibus_store_kv = 1 si posture.store=="kv", si no 0
|
||||
//
|
||||
// Si el body no es JSON válido con la forma esperada, devuelve (nil, error).
|
||||
func ParseUnibusHealth(node string, body []byte) ([]PromSample, error) {
|
||||
var h unibusHealth
|
||||
if err := json.Unmarshal(body, &h); err != nil {
|
||||
return nil, fmt.Errorf("parse unibus healthz for node %q: %w", node, err)
|
||||
}
|
||||
b2f := func(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
mk := func(name string, v float64) PromSample {
|
||||
return PromSample{
|
||||
Name: name,
|
||||
Labels: map[string]string{"node": node, "instance": node},
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
return []PromSample{
|
||||
mk("unibus_up", 1),
|
||||
mk("unibus_status_ok", b2f(h.Status == "ok")),
|
||||
mk("unibus_posture_enforce", b2f(h.Posture.Enforce)),
|
||||
mk("unibus_posture_acl", b2f(h.Posture.ACL)),
|
||||
mk("unibus_posture_tls", b2f(h.Posture.TLS)),
|
||||
mk("unibus_posture_cluster", b2f(h.Posture.Cluster)),
|
||||
mk("unibus_store_kv", b2f(h.Posture.Store == "kv")),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: parse_unibus_health
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ParseUnibusHealth(node string, body []byte) ([]PromSample, error)"
|
||||
description: "Convierte la respuesta JSON del endpoint /healthz de un nodo del cluster de mensajería unibus (membershipd) en una serie de PromSample lista para empujar a VictoriaMetrics, sin instrumentar el bus: solo lee su endpoint de salud. Adjunta a cada serie las labels node e instance (= nombre lógico del nodo) para distinguir los nodos cuando un único exporter scrapea varios. Emite siete series por nodo: unibus_up, unibus_status_ok, unibus_posture_enforce/acl/tls/cluster y unibus_store_kv. Devuelve error si el body no es JSON válido con la forma esperada."
|
||||
tags: [prometheus, metrics, unibus, nats, healthz, posture, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt"]
|
||||
params:
|
||||
- name: node
|
||||
desc: "nombre lógico del nodo (p.ej. \"magnus\"); se adjunta como labels node e instance a cada serie"
|
||||
- name: body
|
||||
desc: "cuerpo JSON crudo devuelto por GET https://<nodo>:8470/healthz, forma {\"posture\":{enforce,acl,tls,cluster bool; store string},\"status\":string}"
|
||||
output: "slice de 7 PromSample con labels {node,instance}: unibus_up=1, unibus_status_ok (1 si status==ok), unibus_posture_enforce/acl/tls/cluster (1/0), unibus_store_kv (1 si posture.store==kv). Error si el body no es JSON válido."
|
||||
tested: true
|
||||
test_file_path: "functions/infra/parse_unibus_health_test.go"
|
||||
tests:
|
||||
- "TestParseUnibusHealthGolden"
|
||||
- "TestParseUnibusHealthDegraded"
|
||||
- "TestParseUnibusHealthInvalid"
|
||||
---
|
||||
|
||||
# parse_unibus_health
|
||||
|
||||
Función pura de transformación (clasificada `impure` solo porque devuelve `error` al
|
||||
fallar el unmarshal; no hace I/O ni red) que traduce la salud de un nodo del bus de
|
||||
mensajería **unibus** a métricas Prometheus. Pertenece al grupo de capacidad
|
||||
`fleet-metrics`: se compone con `format_prom_exposition_go_infra` (serializar) y
|
||||
`push_prom_remote_go_infra` (empujar a VictoriaMetrics).
|
||||
|
||||
El endpoint `/healthz` de cada nodo (`membershipd`) responde, verificado en producción:
|
||||
|
||||
```json
|
||||
{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
||||
```
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||
samples, err := infra.ParseUnibusHealth("magnus", body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Serializa y (en un exporter real) empuja a VictoriaMetrics.
|
||||
fmt.Print(infra.FormatPromExposition(samples, time.Now().UnixMilli()))
|
||||
// unibus_up{instance="magnus",node="magnus"} 1 ...
|
||||
// unibus_posture_enforce{instance="magnus",node="magnus"} 1 ...
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala dentro de un exporter que monitoriza el cluster unibus: tras hacer
|
||||
`GET https://<nodo>:8470/healthz` con la CA del cluster, pasa el cuerpo a esta función
|
||||
para obtener las series del nodo. Llámala **solo cuando el nodo respondió**; si el GET
|
||||
falla (timeout, TLS, no-2xx), emite tú `unibus_up=0` para ese nodo, porque sin cuerpo
|
||||
no hay nada que parsear.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- No emite `unibus_up=0`: ese caso (nodo caído) es responsabilidad del llamador, que sabe
|
||||
si el GET falló. Esta función siempre emite `unibus_up=1` porque solo se la llama con un
|
||||
cuerpo recibido.
|
||||
- Las labels `node` e `instance` toman el mismo valor (el nombre lógico del nodo). El
|
||||
`push_prom_remote_go_infra` añadiría `instance` vía `extra_label` por igual a todas las
|
||||
series del body; por eso aquí ya se fija `instance` por-serie, para que cada nodo unibus
|
||||
conserve su identidad cuando un solo exporter empuja los de varios nodos en un único POST.
|
||||
- Solo lee la posture y el status que hoy expone `/healthz`. Métricas profundas de
|
||||
NATS/JetStream (msgs/s, conexiones, RAFT leader por stream) NO salen de aquí: requieren
|
||||
el monitoring embebido de NATS (puerto 8222), que en producción está cerrado.
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
// golden: nodo seguro con la posture homogénea esperada en producción.
|
||||
func TestParseUnibusHealthGolden(t *testing.T) {
|
||||
body := []byte(`{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}`)
|
||||
got, err := ParseUnibusHealth("magnus", body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]float64{
|
||||
"unibus_up": 1,
|
||||
"unibus_status_ok": 1,
|
||||
"unibus_posture_enforce": 1,
|
||||
"unibus_posture_acl": 1,
|
||||
"unibus_posture_tls": 1,
|
||||
"unibus_posture_cluster": 1,
|
||||
"unibus_store_kv": 1,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d samples, want %d", len(got), len(want))
|
||||
}
|
||||
for _, s := range got {
|
||||
w, ok := want[s.Name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected sample %q", s.Name)
|
||||
continue
|
||||
}
|
||||
if s.Value != w {
|
||||
t.Errorf("%s = %v, want %v", s.Name, s.Value, w)
|
||||
}
|
||||
if s.Labels["node"] != "magnus" || s.Labels["instance"] != "magnus" {
|
||||
t.Errorf("%s labels = %v, want node=instance=magnus", s.Name, s.Labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// edge: nodo degradado (posture todo false, store distinto de kv, status != ok).
|
||||
func TestParseUnibusHealthDegraded(t *testing.T) {
|
||||
body := []byte(`{"posture":{"enforce":false,"acl":false,"tls":false,"cluster":false,"store":"sqlite"},"status":"degraded"}`)
|
||||
got, err := ParseUnibusHealth("homer", body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]float64{
|
||||
"unibus_up": 1,
|
||||
"unibus_status_ok": 0,
|
||||
"unibus_posture_enforce": 0,
|
||||
"unibus_posture_acl": 0,
|
||||
"unibus_posture_tls": 0,
|
||||
"unibus_posture_cluster": 0,
|
||||
"unibus_store_kv": 0,
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Value != want[s.Name] {
|
||||
t.Errorf("%s = %v, want %v", s.Name, s.Value, want[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// error path: body que no es JSON válido devuelve error, no panic.
|
||||
func TestParseUnibusHealthInvalid(t *testing.T) {
|
||||
if _, err := ParseUnibusHealth("datardos", []byte("not json at all")); err == nil {
|
||||
t.Fatal("expected error for invalid body, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
// PromSample representa una unica serie de metrica en formato Prometheus:
|
||||
// el nombre de la metrica, sus labels y un valor numerico.
|
||||
//
|
||||
// La label "instance" NO se incluye aqui: la añade el pusher remoto via
|
||||
// extra_label cuando hace el push a VictoriaMetrics.
|
||||
type PromSample struct {
|
||||
Name string // nombre de metrica prometheus, ej "node_cpu_percent"
|
||||
Labels map[string]string // labels de la serie, ej {"core":"0"} (sin la label "instance")
|
||||
Value float64
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushLokiStream envia lineas de log a un servidor Grafana Loki via su push API.
|
||||
// Construye el cuerpo JSON con la forma {"streams":[{"stream":{labels},"values":[["<ts_ns>","<line>"],...]}]}
|
||||
// y lo manda por POST a endpoint (ej "https://logs-xxxx.organic-machine.com/loki/api/v1/push").
|
||||
//
|
||||
// Reglas:
|
||||
// - timestampsNs y lines deben tener la misma longitud; si no, retorna error antes de hacer la peticion.
|
||||
// - Si len(lines)==0 es un no-op: no hace ninguna peticion y retorna nil.
|
||||
// - labels va tal cual en el campo "stream".
|
||||
// - Si user != "", usa Basic Auth con user/pass.
|
||||
// - Content-Type: application/json. TLS verificado. Timeout 10s.
|
||||
// - Exito = status 2xx (Loki devuelve 204). Si no-2xx, error con el codigo + primeros 200 bytes del cuerpo.
|
||||
func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error {
|
||||
if len(timestampsNs) != len(lines) {
|
||||
return fmt.Errorf("push_loki_stream: timestampsNs (%d) y lines (%d) tienen longitudes distintas", len(timestampsNs), len(lines))
|
||||
}
|
||||
|
||||
// No-op cuando no hay lineas.
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([][2]string, len(lines))
|
||||
for i := range lines {
|
||||
values[i] = [2]string{strconv.FormatInt(timestampsNs[i], 10), lines[i]}
|
||||
}
|
||||
|
||||
stream := labels
|
||||
if stream == nil {
|
||||
stream = map[string]string{}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"streams": []map[string]any{
|
||||
{
|
||||
"stream": stream,
|
||||
"values": values,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return fmt.Errorf("push_loki_stream: HTTP %d: %s", resp.StatusCode, string(snippet))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: push_loki_stream
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error"
|
||||
description: "Envia lineas de log a un servidor Grafana Loki via su push API. Construye el cuerpo JSON {\"streams\":[{\"stream\":{labels},\"values\":[[\"<ts_ns>\",\"<line>\"],...]}]} y lo POSTea al endpoint. Soporta Basic Auth opcional, valida que timestamps y lineas tengan igual longitud, es no-op si no hay lineas, y exige status 2xx. Solo stdlib, TLS verificado."
|
||||
tags: [loki, grafana, logs, push, metrics, http, json, stdlib, infra, fleet-metrics]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "strconv", "time"]
|
||||
params:
|
||||
- name: endpoint
|
||||
desc: "URL completa del push API de Loki (ej https://logs-xxxx.organic-machine.com/loki/api/v1/push)"
|
||||
- name: user
|
||||
desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization"
|
||||
- name: pass
|
||||
desc: "password para Basic Auth; solo se usa cuando user != ''"
|
||||
- name: labels
|
||||
desc: "labels del stream Loki (ej {instance:lucas, job:journald, unit:ssh.service}); van tal cual en el campo stream"
|
||||
- name: timestampsNs
|
||||
desc: "timestamps en nanosegundos desde epoch, uno por linea; debe tener la misma longitud que lines"
|
||||
- name: lines
|
||||
desc: "lineas de log a enviar, alineadas posicionalmente con timestampsNs; si esta vacio la funcion es no-op"
|
||||
output: "error si la peticion falla, las longitudes no coinciden o el status no es 2xx; nil en exito (incluido el no-op de 0 lineas)"
|
||||
tested: true
|
||||
tests:
|
||||
- "JSON enviado tiene estructura streams/stream/values correcta"
|
||||
- "longitudes desiguales dan error antes del POST"
|
||||
- "len lines cero es no-op sin peticion"
|
||||
- "Basic Auth presente cuando user no vacio"
|
||||
- "status 500 produce error"
|
||||
test_file_path: "functions/infra/push_loki_stream_test.go"
|
||||
file_path: "functions/infra/push_loki_stream.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
labels := map[string]string{
|
||||
"instance": "lucas",
|
||||
"job": "journald",
|
||||
"unit": "ssh.service",
|
||||
}
|
||||
nowNs := time.Now().UnixNano()
|
||||
ts := []int64{nowNs, nowNs + 1}
|
||||
lines := []string{
|
||||
"Accepted publickey for lucas from 10.0.0.2",
|
||||
"session opened for user lucas",
|
||||
}
|
||||
|
||||
err := PushLokiStream(
|
||||
"https://logs-abcd.organic-machine.com/loki/api/v1/push",
|
||||
"tenant1", // user para Basic Auth (vacio = sin auth)
|
||||
"s3cr3t", // pass
|
||||
labels,
|
||||
ts,
|
||||
lines,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enviar lineas de log a Grafana Loki desde un agente o servicio Go
|
||||
(ej. reenviar journald, eventos de una app, o lineas de un tailer) sin arrastrar el
|
||||
cliente oficial de Loki. Util para alimentar dashboards de la flota (`fleet-metrics`)
|
||||
con logs etiquetados por instancia/job/unit. Pasa los logs ya batcheados: un solo
|
||||
stream por llamada con sus labels.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `timestampsNs` y `lines` deben tener exactamente la misma longitud; si no, retorna
|
||||
error ANTES de hacer la peticion (no envia nada).
|
||||
- `len(lines)==0` es un no-op deliberado: retorna `nil` sin tocar la red. Comprueba el
|
||||
caso vacio en el caller si necesitas distinguir "no habia logs" de "envio ok".
|
||||
- Loki exige timestamps en NANOSEGUNDOS. Pasar segundos o milisegundos hace que las
|
||||
lineas caigan fuera de la ventana de retencion y Loki las rechace silenciosamente.
|
||||
- Dentro de un mismo stream las entradas deberian ir en orden creciente de timestamp;
|
||||
Loki puede rechazar entradas fuera de orden segun su config.
|
||||
- Exito = status 2xx (Loki normalmente devuelve 204 No Content). Un 4xx/5xx produce
|
||||
error con el codigo + primeros 200 bytes del cuerpo de respuesta para diagnostico.
|
||||
- TLS verificado (sin InsecureSkipVerify) y `http.Client` con timeout de 10s fijo. Para
|
||||
endpoints con certificado interno hace falta CA confiable en el sistema.
|
||||
- Secretos (`user`/`pass`): nunca hardcodear — resolver desde `pass`/vault en el caller.
|
||||
@@ -0,0 +1,139 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lokiPushBody refleja la estructura JSON que espera el push API de Loki.
|
||||
type lokiPushBody struct {
|
||||
Streams []struct {
|
||||
Stream map[string]string `json:"stream"`
|
||||
Values [][2]string `json:"values"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
func TestPushLokiStream(t *testing.T) {
|
||||
t.Run("JSON enviado tiene estructura streams/stream/values correcta", func(t *testing.T) {
|
||||
var captured lokiPushBody
|
||||
var contentType string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(raw, &captured); err != nil {
|
||||
t.Errorf("body no es JSON valido: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
labels := map[string]string{"instance": "lucas", "job": "journald", "unit": "ssh.service"}
|
||||
ts := []int64{1700000000000000001, 1700000000000000002}
|
||||
lines := []string{"line one", "line two"}
|
||||
|
||||
err := PushLokiStream(srv.URL, "", "", labels, ts, lines)
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", contentType)
|
||||
}
|
||||
if len(captured.Streams) != 1 {
|
||||
t.Fatalf("streams len = %d, want 1", len(captured.Streams))
|
||||
}
|
||||
s := captured.Streams[0]
|
||||
if s.Stream["unit"] != "ssh.service" || s.Stream["job"] != "journald" || s.Stream["instance"] != "lucas" {
|
||||
t.Errorf("stream labels = %v, want %v", s.Stream, labels)
|
||||
}
|
||||
if len(s.Values) != 2 {
|
||||
t.Fatalf("values len = %d, want 2", len(s.Values))
|
||||
}
|
||||
if s.Values[0][0] != "1700000000000000001" || s.Values[0][1] != "line one" {
|
||||
t.Errorf("values[0] = %v, want [1700000000000000001 line one]", s.Values[0])
|
||||
}
|
||||
if s.Values[1][0] != "1700000000000000002" || s.Values[1][1] != "line two" {
|
||||
t.Errorf("values[1] = %v, want [1700000000000000002 line two]", s.Values[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("longitudes desiguales dan error antes del POST", func(t *testing.T) {
|
||||
hit := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ts := []int64{1, 2, 3}
|
||||
lines := []string{"only one"}
|
||||
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, ts, lines)
|
||||
if err == nil {
|
||||
t.Fatalf("se esperaba error por longitudes desiguales")
|
||||
}
|
||||
if hit {
|
||||
t.Errorf("no debe haber peticion HTTP cuando las longitudes no coinciden")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("len lines cero es no-op sin peticion", func(t *testing.T) {
|
||||
hit := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{}, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf("no-op no debe retornar error: %v", err)
|
||||
}
|
||||
if hit {
|
||||
t.Errorf("no-op no debe hacer ninguna peticion HTTP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Basic Auth presente cuando user no vacio", func(t *testing.T) {
|
||||
var gotUser, gotPass string
|
||||
var hadAuth bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser, gotPass, hadAuth = r.BasicAuth()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushLokiStream(srv.URL, "tenant", "secret", map[string]string{"job": "x"}, []int64{1}, []string{"hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("error inesperado: %v", err)
|
||||
}
|
||||
if !hadAuth {
|
||||
t.Fatalf("se esperaba header Authorization Basic")
|
||||
}
|
||||
if gotUser != "tenant" || gotPass != "secret" {
|
||||
t.Errorf("basic auth = %q/%q, want tenant/secret", gotUser, gotPass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status 500 produce error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("loki rejected the push"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{1}, []string{"hi"})
|
||||
if err == nil {
|
||||
t.Fatalf("se esperaba error con status 500")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error no menciona el codigo 500: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "loki rejected the push") {
|
||||
t.Errorf("error no incluye el cuerpo de respuesta: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushPromRemote hace POST del body (texto en formato Prometheus exposition) al
|
||||
// endpoint dado, tipicamente el import de VictoriaMetrics
|
||||
// (".../api/v1/import/prometheus").
|
||||
//
|
||||
// - Si user != "" usa Basic Auth con user/pass.
|
||||
// - extraLabels se adjuntan como query params repetidos
|
||||
// "extra_label=clave=valor" URL-encoded. VictoriaMetrics añade esas labels a
|
||||
// TODAS las series del push (util para la label "instance").
|
||||
// - Content-Type: text/plain.
|
||||
// - http.Client con Timeout 10s y TLS verificado.
|
||||
// - Exito = status 2xx (VictoriaMetrics devuelve 204). Si no-2xx, retorna error
|
||||
// con el codigo y los primeros 200 bytes del cuerpo de respuesta.
|
||||
func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse endpoint %q: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if len(extraLabels) > 0 {
|
||||
q := u.Query()
|
||||
for k, v := range extraLabels {
|
||||
q.Add("extra_label", k+"="+v)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push to %q: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return fmt.Errorf("push to %q failed: status %d: %s", endpoint, resp.StatusCode, string(snippet))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: push_prom_remote
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error"
|
||||
description: "Hace POST de un body en formato Prometheus exposition a un endpoint remoto (ej VictoriaMetrics /api/v1/import/prometheus). Soporta Basic Auth (si user!=\"\"), adjunta extraLabels como query params repetidos extra_label=clave=valor (VictoriaMetrics los añade a todas las series del push), Content-Type text/plain, http.Client con Timeout 10s y TLS verificado. Exito = status 2xx; si no, error con codigo + primeros 200 bytes del cuerpo de respuesta."
|
||||
tags: [prometheus, push, victoriametrics, remote-write, fleet-metrics, infra, monitoring, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "io", "net/http", "net/url", "strings", "time"]
|
||||
params:
|
||||
- name: endpoint
|
||||
desc: "URL completa del endpoint de ingestion, ej https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus"
|
||||
- name: user
|
||||
desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization"
|
||||
- name: pass
|
||||
desc: "password para Basic Auth; se ignora si user esta vacio"
|
||||
- name: body
|
||||
desc: "texto en formato Prometheus exposition (tipicamente salida de format_prom_exposition)"
|
||||
- name: extraLabels
|
||||
desc: "labels a adjuntar a todas las series via extra_label, ej {\"instance\":\"lucas\"}; puede ser nil"
|
||||
output: "nil si el push devuelve status 2xx (VictoriaMetrics responde 204). Error si la request falla, el endpoint es invalido, o el status no es 2xx (con codigo y snippet del cuerpo)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestPushPromRemote"
|
||||
test_file_path: "functions/infra/push_prom_remote_test.go"
|
||||
file_path: "functions/infra/push_prom_remote.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
body := FormatPromExposition(samples, time.Now().UnixMilli())
|
||||
err := PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"ingest-user", "ingest-pass",
|
||||
body,
|
||||
map[string]string{"instance": "lucas-pc"},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("push fallo: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un nodo de la flota tiene que **empujar** sus metricas a un backend
|
||||
central (VictoriaMetrics, Mimir, pushgateway) en vez de exponer un /metrics para
|
||||
scraping. Es el paso final del capability group `fleet-metrics`:
|
||||
collect_host_metrics -> format_prom_exposition -> push_prom_remote. Tipica en
|
||||
nodos detras de NAT, moviles (Termux) o cualquier host al que el servidor central
|
||||
no puede alcanzar para hacer pull.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **extra_label es clave=valor como un solo valor de query**: para {"instance":"lucas"}
|
||||
produce `?extra_label=instance%3Dlucas` (el `=` interno se URL-encodea a `%3D`).
|
||||
VictoriaMetrics lo aplica a todas las series del push; otros backends pueden no
|
||||
soportar este parametro.
|
||||
- **Secretos**: nunca hardcodees `user`/`pass` — resuelvelos desde `pass`/vault.
|
||||
- **TLS verificado** (sin InsecureSkipVerify): un endpoint con certificado
|
||||
autofirmado fallara. Usa un certificado valido o un proxy de confianza.
|
||||
- **Timeout 10s**: un backend lento o un body enorme puede dar timeout. Trocea
|
||||
pushes muy grandes si es necesario.
|
||||
- **204 No Content es exito**: VictoriaMetrics no devuelve 200. La funcion acepta
|
||||
cualquier 2xx; no asumas 200 al testear contra el real.
|
||||
- **El cuerpo de error se trunca a 200 bytes**: suficiente para diagnostico
|
||||
rapido, no para el detalle completo del backend.
|
||||
@@ -0,0 +1,111 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPushPromRemote(t *testing.T) {
|
||||
t.Run("body llega completo y status 204 es exito", func(t *testing.T) {
|
||||
const body = "node_load1 0.42 1700000000000\nnode_cpu_percent 3\n"
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
if ct := r.Header.Get("Content-Type"); ct != "text/plain" {
|
||||
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := PushPromRemote(srv.URL, "", "", body, nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotBody != body {
|
||||
t.Errorf("body got %q, want %q", gotBody, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("basic auth presente cuando user no vacio", func(t *testing.T) {
|
||||
var hadAuth bool
|
||||
var gotUser, gotPass string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser, gotPass, hadAuth = r.BasicAuth()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := PushPromRemote(srv.URL, "alice", "s3cr3t", "x 1\n", nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hadAuth {
|
||||
t.Fatal("expected Authorization Basic header, got none")
|
||||
}
|
||||
if gotUser != "alice" || gotPass != "s3cr3t" {
|
||||
t.Errorf("basic auth = %q/%q, want alice/s3cr3t", gotUser, gotPass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin user no manda Authorization", func(t *testing.T) {
|
||||
var hadAuth bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _, hadAuth = r.BasicAuth()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := PushPromRemote(srv.URL, "", "ignored", "x 1\n", nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hadAuth {
|
||||
t.Error("expected no Authorization header when user is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extra_label aparece en la query", func(t *testing.T) {
|
||||
var gotQuery []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.Query()["extra_label"]
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
labels := map[string]string{"instance": "lucas", "region": "eu"}
|
||||
if err := PushPromRemote(srv.URL, "", "", "x 1\n", labels); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotQuery) != 2 {
|
||||
t.Fatalf("got %d extra_label params, want 2: %v", len(gotQuery), gotQuery)
|
||||
}
|
||||
joined := strings.Join(gotQuery, ",")
|
||||
if !strings.Contains(joined, "instance=lucas") {
|
||||
t.Errorf("extra_label missing instance=lucas: %v", gotQuery)
|
||||
}
|
||||
if !strings.Contains(joined, "region=eu") {
|
||||
t.Errorf("extra_label missing region=eu: %v", gotQuery)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status 500 produce error con codigo y snippet", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, "boom: bad input")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushPromRemote(srv.URL, "", "", "x 1\n", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on status 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error should mention status 500: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("error should include response body snippet: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"now": "2026-06-07T19:02:25.326833943Z",
|
||||
"num_connections": 1,
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 1024,
|
||||
"connections": [
|
||||
{
|
||||
"cid": 5,
|
||||
"kind": "Client",
|
||||
"type": "nats",
|
||||
"ip": "127.0.0.1",
|
||||
"port": 52734,
|
||||
"start": "2026-06-07T21:02:24.812382826+02:00",
|
||||
"last_activity": "2026-06-07T21:02:24.821005187+02:00",
|
||||
"rtt": "623µs",
|
||||
"uptime": "0s",
|
||||
"idle": "0s",
|
||||
"pending_bytes": 0,
|
||||
"in_msgs": 17,
|
||||
"out_msgs": 17,
|
||||
"in_bytes": 1304,
|
||||
"out_bytes": 3905,
|
||||
"subscriptions": 2,
|
||||
"lang": "go",
|
||||
"version": "1.49.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 0,
|
||||
"reserved_storage": 0,
|
||||
"accounts": 1,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 1,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
},
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"now": "2026-06-07T19:02:25.327216549Z",
|
||||
"config": {
|
||||
"max_memory": 3221225472,
|
||||
"max_storage": 546399169536,
|
||||
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||
"sync_interval": 120000000000
|
||||
},
|
||||
"limits": {},
|
||||
"streams": 3,
|
||||
"consumers": 0,
|
||||
"messages": 6,
|
||||
"bytes": 310,
|
||||
"account_details": [
|
||||
{
|
||||
"name": "$G",
|
||||
"id": "$G",
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 18446744073709551615,
|
||||
"reserved_storage": 18446744073709551615,
|
||||
"accounts": 0,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 0,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
},
|
||||
"stream_detail": [
|
||||
{
|
||||
"name": "KV_UNIBUS_rooms",
|
||||
"created": "2026-06-07T19:02:24.8170934Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 102,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.817910599Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.818011867Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "KV_UNIBUS_members",
|
||||
"created": "2026-06-07T19:02:24.818494147Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 106,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.81917932Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.819283444Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "KV_UNIBUS_users",
|
||||
"created": "2026-06-07T19:02:24.814500069Z",
|
||||
"cluster": {
|
||||
"leader": "probe"
|
||||
},
|
||||
"state": {
|
||||
"messages": 2,
|
||||
"bytes": 102,
|
||||
"first_seq": 1,
|
||||
"first_ts": "2026-06-07T19:02:24.81638123Z",
|
||||
"last_seq": 2,
|
||||
"last_ts": "2026-06-07T19:02:24.816570377Z",
|
||||
"num_subjects": 2,
|
||||
"consumer_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"server_id": "NC23B47RQSJYPX5AIUC5CA3ND5RLCYREKSAFLM65MLBY5PBRIXPAFL7O",
|
||||
"server_name": "probe",
|
||||
"version": "2.11.15",
|
||||
"proto": 1,
|
||||
"go": "go1.26.4",
|
||||
"host": "127.0.0.1",
|
||||
"port": 14260,
|
||||
"max_connections": 65536,
|
||||
"ping_interval": 120000000000,
|
||||
"ping_max": 2,
|
||||
"http_host": "127.0.0.1",
|
||||
"http_port": 8222,
|
||||
"http_base_path": "",
|
||||
"https_port": 0,
|
||||
"auth_timeout": 2,
|
||||
"max_control_line": 4096,
|
||||
"max_payload": 1048576,
|
||||
"max_pending": 67108864,
|
||||
"cluster": {},
|
||||
"gateway": {},
|
||||
"leaf": {},
|
||||
"mqtt": {},
|
||||
"websocket": {},
|
||||
"jetstream": {
|
||||
"config": {
|
||||
"max_memory": 3221225472,
|
||||
"max_storage": 546399169536,
|
||||
"store_dir": "/tmp/natsprobe4019469486/jetstream",
|
||||
"sync_interval": 120000000000
|
||||
},
|
||||
"stats": {
|
||||
"memory": 0,
|
||||
"storage": 310,
|
||||
"reserved_memory": 0,
|
||||
"reserved_storage": 0,
|
||||
"accounts": 1,
|
||||
"ha_assets": 0,
|
||||
"api": {
|
||||
"level": 1,
|
||||
"total": 6,
|
||||
"errors": 0
|
||||
}
|
||||
},
|
||||
"limits": {}
|
||||
},
|
||||
"tls_timeout": 2,
|
||||
"write_deadline": 10000000000,
|
||||
"start": "2026-06-07T19:02:24.785745698Z",
|
||||
"now": "2026-06-07T19:02:25.325501038Z",
|
||||
"uptime": "0s",
|
||||
"mem": 18288640,
|
||||
"cores": 24,
|
||||
"gomaxprocs": 24,
|
||||
"gomemlimit": 4294967296,
|
||||
"cpu": 0,
|
||||
"connections": 1,
|
||||
"total_connections": 1,
|
||||
"routes": 0,
|
||||
"remotes": 0,
|
||||
"leafnodes": 0,
|
||||
"in_msgs": 17,
|
||||
"out_msgs": 17,
|
||||
"in_bytes": 1304,
|
||||
"out_bytes": 3905,
|
||||
"slow_consumers": 0,
|
||||
"subscriptions": 75,
|
||||
"http_req_stats": {
|
||||
"/varz": 1
|
||||
},
|
||||
"config_load_time": "2026-06-07T19:02:24.785745698Z",
|
||||
"config_digest": "",
|
||||
"system_account": "$SYS",
|
||||
"slow_consumer_stats": {
|
||||
"clients": 0,
|
||||
"routes": 0,
|
||||
"gateways": 0,
|
||||
"leafs": 0
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,15 @@ require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/marcboeker/go-duckdb v1.8.5
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/shirou/gopsutil/v4 v4.26.5
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
@@ -40,23 +43,24 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -67,6 +71,7 @@ require (
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/zerolog v1.35.1 // indirect
|
||||
@@ -76,7 +81,10 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
|
||||
@@ -47,6 +47,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
|
||||
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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -55,6 +57,8 @@ 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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
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=
|
||||
@@ -70,6 +74,7 @@ github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLK
|
||||
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.5.6/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=
|
||||
@@ -104,6 +109,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -139,6 +146,8 @@ github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR
|
||||
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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -147,6 +156,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
@@ -170,6 +181,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
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=
|
||||
@@ -182,6 +197,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
@@ -224,8 +241,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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-20190916202348-b4ddaad3f8a3/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-20201204225414-ed752295db88/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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user