Compare commits
44 Commits
5b10b419a2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d100e7f3e | |||
| e7a8edfed8 | |||
| cd87a8c28e | |||
| 6ab85ee701 | |||
| 909290ddbf | |||
| 111ee17bcc | |||
| 0d3118d98d | |||
| f6b9747f11 | |||
| 927437a8d8 | |||
| 7d395f39e5 | |||
| 4187f9b6b1 | |||
| c4ecf871c8 | |||
| 9798aed2cf | |||
| 588d092858 | |||
| a90b7443e4 | |||
| e1e9bb7499 | |||
| 1430039688 | |||
| 935008ec3f | |||
| d89da1292d | |||
| 83f1d7c8d3 | |||
| 216cad4c12 | |||
| 167a7e5eb7 | |||
| b8ec97e477 | |||
| 40400c0b88 | |||
| 236a4740b0 | |||
| 1c4a4b9259 | |||
| 1c8a86594f | |||
| a76760edba | |||
| 4a0f0e9dc0 | |||
| 73f41a3474 | |||
| eb8dbf66a1 | |||
| 6bc97df5c0 | |||
| e769836b0d | |||
| 93756fbd0c | |||
| 0a6d1b8d17 | |||
| 82f1f1bd58 | |||
| 9a9b876400 | |||
| 5c253a26e2 | |||
| 10bfb846a8 | |||
| d996542f88 | |||
| 8742cb25be | |||
| 37aacfcfa9 | |||
| 029dbf57bd | |||
| 3f6b652f3f |
+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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-analizador
|
||||
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-constructor
|
||||
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-executor
|
||||
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-mejorador
|
||||
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-orquestador
|
||||
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-recopilador
|
||||
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,113 @@
|
||||
---
|
||||
name: launch_fleetclaude
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.3.2"
|
||||
purity: impure
|
||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--cols <n>]"
|
||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado -L fleet) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
||||
params:
|
||||
- name: --cwd
|
||||
desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)."
|
||||
- name: --bin
|
||||
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
|
||||
- name: --session
|
||||
desc: "Nombre de la sesion tmux a crear o reutilizar. Opcional. Default: fleet. La funcion es idempotente sobre este nombre."
|
||||
- name: --cols
|
||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
||||
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_fleetclaude.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Via fn run (resuelve por nombre o ID):
|
||||
fn run launch_fleetclaude
|
||||
|
||||
# Directo, con cwd explicito:
|
||||
launch_fleetclaude --cwd ~/fn_registry
|
||||
|
||||
# Sesion y ancho de pane personalizados:
|
||||
launch_fleetclaude --session fleet --cols 50
|
||||
```
|
||||
|
||||
Tras invocarlo aparece una ventana kitty titulada `FleetView` con dos panes
|
||||
lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
||||
`claude --dangerously-skip-permissions`. Volver a invocarlo NO duplica la
|
||||
sesion: reusa la existente y solo abre otra kitty adjunta.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de
|
||||
N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un
|
||||
Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o
|
||||
al retomar el trabajo en el repo `fn_registry`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Idempotencia tmux**: si la sesion `<session>` (default `fleet`) ya existe,
|
||||
NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma
|
||||
sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar.
|
||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
||||
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
|
||||
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
|
||||
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
|
||||
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
||||
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
||||
silencio. Compila la TUI antes para el flujo completo.
|
||||
- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux
|
||||
propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n`
|
||||
NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el
|
||||
server fleet no toca nada mas: `tmux -L fleet kill-server`.
|
||||
- **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para
|
||||
`alt+flechas` (mover el cursor de la TUI), `alt+enter` (conmutar al Claude
|
||||
seleccionado) y `alt+n` (abrir Claude nuevo). Son bindings de tmux que
|
||||
redirigen la tecla al pane de la TUI (`send-keys -t console.0`), asi funcionan
|
||||
ESTES DONDE ESTES (incluido escribiendo en el pane de Claude). No modifican la
|
||||
configuracion de kitty ni los atajos globales del escritorio.
|
||||
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
|
||||
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
|
||||
conmutar de Claude redistribuyen el espacio.
|
||||
- **tmux siempre, kitty solo sin TTY**: `tmux` es obligatorio (aborta != 0 si
|
||||
falta). `kitty` solo se necesita en la ruta sin-TTY (atajo de escritorio, cron,
|
||||
script), donde abre una ventana nueva. Invocado desde una terminal interactiva
|
||||
(el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
|
||||
`exec tmux attach` y NO necesita kitty — util en WSL u hosts sin kitty.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.3.2 (2026-06-17) — targeting de panes por **pane ID** (`%0`/`%1`) en vez de
|
||||
por indice (`console.0`). Antes fallaba con `can't find pane: 0` en hosts cuyo
|
||||
`~/.tmux.conf` define `base-index 1`/`pane-base-index 1` (el socket `-L fleet`
|
||||
hereda esa config). Los pane ID son inmunes al base-index. Bug latente que el
|
||||
fix de kitty (v1.3.1) destapo al dejar de abortar antes de montar la sesion.
|
||||
- v1.3.1 (2026-06-17) — el guard de `kitty` se movio a la rama sin-TTY. La ruta
|
||||
interactiva (`exec tmux attach`) ya no exige kitty, asi que `fleetclaude`
|
||||
funciona en hosts sin kitty (p.ej. WSL) reutilizando la terminal actual.
|
||||
- v1.3.0 (2026-06-17) — renombrada de `launch_kittyclaude` a `launch_fleetclaude`
|
||||
(comando `fleetclaude`). Atajos: `alt+0` (= alt+n, abrir Claude nuevo), `alt+k`
|
||||
(kill con confirmacion), `alt+r` (picker de reanudar sesiones cerradas) y
|
||||
`alt+flecha-izquierda` (volver atras desde el picker). Cierra la window al salir
|
||||
el Claude (`remain-on-exit off`).
|
||||
- v1.2.0 (2026-06-16) — ancho del sidebar por defecto 47 columnas; `ctrl+0` como
|
||||
atajo alterno para abrir Claude nuevo; `mouse on` (clic/rueda enrutados a la
|
||||
TUI) y `extended-keys on` (para que `ctrl+0` llegue distinguible por el
|
||||
protocolo de teclado de kitty).
|
||||
- v1.1.0 (2026-06-16) — socket tmux aislado `-L fleet`; instala atajos
|
||||
`alt+flechas` / `alt+enter` / `alt+n` que controlan la TUI desde cualquier
|
||||
pane; hooks que mantienen fijo el ancho del sidebar tras attach/conmutar.
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_fleetclaude — Entrypoint MVP de FleetView.
|
||||
#
|
||||
# Abre UNA ventana kitty corriendo una sesion tmux de dos panes:
|
||||
# - pane izquierdo: la TUI 'fleetview' (la flota de Claudes centralizada).
|
||||
# - pane derecho: 'claude --dangerously-skip-permissions'.
|
||||
#
|
||||
# Objetivo: dejar de tener N ventanas kitty dispersas y centralizar el control
|
||||
# de los Claudes en una sola ventana.
|
||||
#
|
||||
# Funcion IMPURA: lanza procesos (tmux + kitty) con efectos secundarios.
|
||||
# - Crea/reusa una sesion tmux detached llamada <session> (idempotente).
|
||||
# - Lanza una ventana kitty desacoplada del shell padre (setsid) para que
|
||||
# sobreviva al cierre de la terminal que la invoco.
|
||||
# - No toca atajos de teclado ni kitty.conf.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
launch_fleetclaude() {
|
||||
local cwd=""
|
||||
local bin=""
|
||||
local session="fleet"
|
||||
local cols=52
|
||||
local T="tmux -L fleet" # socket tmux aislado: no toca el tmux normal del usuario
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cwd)
|
||||
shift
|
||||
cwd="${1:-}"
|
||||
;;
|
||||
--bin)
|
||||
shift
|
||||
bin="${1:-}"
|
||||
;;
|
||||
--session)
|
||||
shift
|
||||
session="${1:-}"
|
||||
;;
|
||||
--cols)
|
||||
shift
|
||||
cols="${1:-40}"
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: launch_fleetclaude [opciones]
|
||||
|
||||
Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la
|
||||
izquierda y 'claude --dangerously-skip-permissions' a la derecha.
|
||||
|
||||
Opciones:
|
||||
--cwd <dir> Directorio de trabajo de los panes.
|
||||
Default: raiz del repo fn_registry (derivada dinamicamente).
|
||||
--bin <path> Ruta al binario de la TUI fleetview.
|
||||
Default: <repo>/apps/fleetview/fleetview
|
||||
--session <name> Nombre de la sesion tmux. Default: fleet.
|
||||
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
launch_fleetclaude
|
||||
launch_fleetclaude --cwd ~/fn_registry
|
||||
launch_fleetclaude --session fleet --cols 50
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "launch_fleetclaude: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derivar la raiz del repo fn_registry dinamicamente (NO hardcodear paths
|
||||
# de usuario). Estrategia: subir desde la ubicacion del script con
|
||||
# 'git rev-parse --show-toplevel'; fallbacks razonables si no aplica.
|
||||
# -----------------------------------------------------------------------
|
||||
local script_dir repo_root=""
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# El script vive en <repo>/bash/functions/infra/, asi que la raiz son 3
|
||||
# niveles arriba; pero preferimos git para robustez.
|
||||
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
# Fallback 1: navegacion relativa desde la ubicacion del script.
|
||||
repo_root="$(cd "$script_dir/../../.." 2>/dev/null && pwd || true)"
|
||||
fi
|
||||
if [[ -z "$repo_root" ]]; then
|
||||
# Fallback 2: variable de entorno del registry o el cwd actual.
|
||||
repo_root="${FN_REGISTRY_ROOT:-$PWD}"
|
||||
fi
|
||||
|
||||
# Defaults derivados de la raiz del repo.
|
||||
[[ -z "$cwd" ]] && cwd="$repo_root"
|
||||
[[ -z "$bin" ]] && bin="$repo_root/apps/fleetview/fleetview"
|
||||
|
||||
# Validar cwd: si no existe, caer al repo_root.
|
||||
if [[ ! -d "$cwd" ]]; then
|
||||
echo "launch_fleetclaude: --cwd '$cwd' no existe; usando '$repo_root'." >&2
|
||||
cwd="$repo_root"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comprobar herramientas necesarias.
|
||||
# -----------------------------------------------------------------------
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "launch_fleetclaude: tmux no esta instalado." >&2
|
||||
return 1
|
||||
fi
|
||||
# Nota: kitty NO se exige aqui. La ruta interactiva (TTY) reutiliza la
|
||||
# terminal actual con `exec tmux attach` y no necesita kitty. Solo la
|
||||
# ruta sin-TTY (abrir ventana nueva con setsid kitty) lo requiere, y ahi
|
||||
# se comprueba justo antes de usarlo.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comando para el pane izquierdo:
|
||||
# - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado).
|
||||
# - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio).
|
||||
# -----------------------------------------------------------------------
|
||||
local left_cmd
|
||||
if [[ -x "$bin" ]]; then
|
||||
left_cmd="exec $(printf '%q' "$bin")"
|
||||
else
|
||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
||||
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Montar la sesion tmux SOLO si no existe (idempotencia). Socket aislado $T.
|
||||
#
|
||||
# Targeting por PANE ID (%0/%1), no por indice (console.0). El socket
|
||||
# -L fleet sigue leyendo ~/.tmux.conf; si el usuario tiene
|
||||
# `base-index 1` / `pane-base-index 1` (muy comun), el primer pane es el
|
||||
# indice 1 y cualquier referencia a console.0 falla con
|
||||
# "can't find pane: 0". Los pane ID son estables e inmunes al base-index.
|
||||
# -----------------------------------------------------------------------
|
||||
local left_pane right_pane
|
||||
if $T has-session -t "$session" 2>/dev/null; then
|
||||
echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola."
|
||||
else
|
||||
echo "launch_fleetclaude: creando sesion tmux '$session' en '$cwd'."
|
||||
|
||||
# Sesion detached con ventana 'console'. Capturamos el pane ID del pane
|
||||
# izquierdo (la TUI fleetview, o el fallback claro).
|
||||
left_pane=$($T new-session -d -s "$session" -n console -c "$cwd" -P -F '#{pane_id}')
|
||||
$T send-keys -t "$left_pane" "$left_cmd" C-m
|
||||
|
||||
# pane derecho = claude, dividiendo horizontalmente (split lado a lado).
|
||||
right_pane=$($T split-window -h -t "$left_pane" -c "$cwd" -P -F '#{pane_id}')
|
||||
$T send-keys -t "$right_pane" "exec claude --dangerously-skip-permissions" C-m
|
||||
|
||||
# Fijar el ancho del pane izquierdo en columnas.
|
||||
$T resize-pane -t "$left_pane" -x "$cols"
|
||||
|
||||
# Foco inicial en el pane de claude (derecha).
|
||||
$T select-pane -t "$right_pane"
|
||||
fi
|
||||
|
||||
# Si reutilizamos sesion (o por seguridad), derivar el pane ID de la TUI:
|
||||
# el primer pane de la ventana 'console' (orden por indice) es el izquierdo.
|
||||
if [[ -z "$left_pane" ]]; then
|
||||
left_pane=$($T list-panes -t "$session":console -F '#{pane_id}' 2>/dev/null | head -n1)
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Atajos globales (alt+*) en el socket aislado: redirigen la tecla al pane
|
||||
# de la TUI (console.0) ESTES DONDE ESTES, para controlar la flota sin salir
|
||||
# del pane de Claude. La TUI (fleetview) es quien interpreta Up/Down/Enter/n.
|
||||
# `bind -n` = tabla root (sin prefijo). Idempotente: re-set en cada lanzamiento.
|
||||
# -----------------------------------------------------------------------
|
||||
$T bind -n M-Up send-keys -t "$left_pane" Up
|
||||
$T bind -n M-Down send-keys -t "$left_pane" Down
|
||||
$T bind -n M-Enter send-keys -t "$left_pane" Enter
|
||||
$T bind -n M-n send-keys -t "$left_pane" n
|
||||
$T bind -n M-0 send-keys -t "$left_pane" n
|
||||
$T bind -n M-k send-keys -t "$left_pane" k
|
||||
$T bind -n M-r send-keys -t "$left_pane" r
|
||||
$T bind -n M-u send-keys -t "$left_pane" u
|
||||
$T bind -n M-h send-keys -t "$left_pane" h
|
||||
$T bind -n M-Left send-keys -t "$left_pane" Escape
|
||||
$T bind -n M-q send-keys -t "$left_pane" Q
|
||||
# Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta.
|
||||
$T set -g mouse on
|
||||
# Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de
|
||||
# dejarla muerta ("dead" pane) en la sesion.
|
||||
$T set -g remain-on-exit off
|
||||
|
||||
# Estetica neutra: sin el verde fosforo por defecto de tmux. Status bar gris y
|
||||
# bordes de pane gris tenue, iguales en activo e inactivo (separacion simple,
|
||||
# sin resaltado de enfoque).
|
||||
$T set -g status-style "bg=colour236,fg=colour250"
|
||||
$T set -g pane-border-style "fg=colour238"
|
||||
$T set -g pane-active-border-style "fg=colour240"
|
||||
|
||||
# Mantener el ancho del sidebar (pane 0) cuando kitty redimensiona la ventana
|
||||
# tras el attach, o cuando se conmuta de Claude (window-linked / layout change).
|
||||
$T set-hook -g client-resized "resize-pane -t $left_pane -x $cols"
|
||||
$T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con
|
||||
# setsid, para que no muera al cerrar la terminal invocadora.
|
||||
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
|
||||
# -----------------------------------------------------------------------
|
||||
# Adjuntar la sesion:
|
||||
# - Si se invoca desde una terminal interactiva, convertir ESA terminal en
|
||||
# el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
||||
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
||||
# - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana
|
||||
# kitty nueva desacoplada (setsid) como antes.
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
exec tmux -L fleet attach -t "$session"
|
||||
fi
|
||||
# Ruta sin-TTY: necesitamos kitty para abrir la ventana nueva.
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_fleetclaude: kitty no esta instalado (necesario solo sin TTY)." >&2
|
||||
echo "launch_fleetclaude: lanzalo desde una terminal interactiva, o instala kitty." >&2
|
||||
return 1
|
||||
fi
|
||||
setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
echo "launch_fleetclaude: ventana kitty 'FleetView' adjunta a la sesion tmux '$session'."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_fleetclaude "$@"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: close_onlyoffice_instance
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "close_onlyoffice_instance(instance: string = demo, [--purge]) -> json"
|
||||
description: "Termina el/los proceso(s) DesktopEditors de una INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su HOME=/tmp/oo_<instance> leido de /proc/<pid>/environ — asi NUNCA mata la instancia personal del usuario, solo la aislada. Envia SIGTERM, espera ~3s por evento (read -t, sin sleep foreground) y SIGKILL a los que sigan vivos. Con el flag --purge borra ademas los directorios del slot (/tmp/oo_<instance>*). Imprime JSON con instance, killed_pids (array), purged y status (closed|not_running)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado a cerrar (default: demo). Solo se matan procesos DesktopEditors cuyo HOME sea /tmp/oo_<instance>"
|
||||
- name: --purge
|
||||
desc: "flag opcional: si se pasa, borra los directorios del slot (/tmp/oo_<instance>*) tras matar los procesos. Sin el flag, solo termina procesos y deja el estado del slot en disco"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"killed_pids\":[<pids>],\"purged\":true|false,\"status\":\"closed\"|\"not_running\"}. Exit 0 siempre que opere bien (closed si mato procesos, not_running si no habia ninguno del slot), exit 1 si falta dependencia, exit 2 si flag desconocido"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/close_onlyoffice_instance.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar el slot demo (deja /tmp/oo_demo* en disco para reusar la config)
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh demo
|
||||
|
||||
# Cerrar y limpiar todo el estado del slot
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh demo --purge
|
||||
|
||||
# Slot por defecto (demo) sin argumentos
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh
|
||||
|
||||
# Via fn run
|
||||
./fn run close_onlyoffice_instance_bash_shell reporte --purge
|
||||
|
||||
# Sourceado
|
||||
source bash/functions/shell/close_onlyoffice_instance.sh
|
||||
out=$(close_onlyoffice_instance demo --purge)
|
||||
echo "$out"
|
||||
# {"instance":"demo","killed_pids":[12345,12350],"purged":true,"status":"closed"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando terminas un flujo automatizado con ONLYOFFICE Desktop y quieres **cerrar la instancia aislada por completo** (cerrar la ventana con `wmctrl` deja el proceso vivo; esta funcion mata el proceso real).
|
||||
- Para **liberar recursos** de un slot que ya no usas, opcionalmente borrando su estado en /tmp con `--purge`.
|
||||
- Como ultimo paso del ciclo open -> reload -> close, garantizando que no quedan procesos huerfanos de la instancia aislada.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo mata la instancia aislada**: identifica procesos por `HOME=/tmp/oo_<instance>` en `/proc/<pid>/environ`. La instancia personal del usuario (HOME real) NUNCA se toca. Esto es por diseño y por seguridad.
|
||||
- **Cerrar la ventana NO mata el proceso**: por eso esta funcion existe. Tras `reload`/`wmctrl -ic` el proceso de la instancia aislada sigue vivo (deseable para reusar). Usa esta funcion para terminarlo de verdad.
|
||||
- **`--purge` borra /tmp/oo_<instance>***: pierdes la config del slot (perfil, recientes). El slot se recreara limpio en el siguiente `open`. Sin `--purge`, el estado persiste y el siguiente arranque reusa esa config.
|
||||
- **El slot vive en /tmp**: aunque no purgues, `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||
- **Requiere X11 + wmctrl + xdotool** instalados (coherencia con el grupo, aunque esta funcion solo usa /proc para matar). Comprueba `command -v` y falla claro si falta alguna; no funciona en Wayland puro sin XWayland para el resto del grupo.
|
||||
- **Carrera de /proc**: si un pid muere entre listarlo y leer su environ, se ignora silenciosamente (guardas `2>/dev/null || true`); no rompe la funcion (`set -uo pipefail` sin `-e`).
|
||||
- **SIGKILL como ultimo recurso**: tras ~3s de SIGTERM, los procesos vivos reciben SIGKILL. Cambios sin guardar en la app (si los hubiera) se pierden — pero el flujo previsto edita en disco, no en la app, asi que no deberia haber estado sin guardar.
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# close_onlyoffice_instance — termina el/los proceso(s) DesktopEditors de una
|
||||
# INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su
|
||||
# HOME=/tmp/oo_<instance> en /proc/<pid>/environ. Opcionalmente limpia los
|
||||
# directorios del slot con --purge.
|
||||
#
|
||||
# Funcion impura: lee /proc, envia señales a procesos y (con --purge) borra
|
||||
# directorios bajo /tmp. NO toca la instancia personal del usuario: solo mata
|
||||
# procesos cuyo HOME apunta al slot aislado.
|
||||
#
|
||||
# Slot aislado: cada instance usa HOME=/tmp/oo_<instance>,
|
||||
# XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config.
|
||||
|
||||
# Sin -e: lecturas de /proc/<pid>/environ pueden fallar por carrera (el pid
|
||||
# muere entre listar y leer); no deben abortar la funcion.
|
||||
set -uo pipefail
|
||||
|
||||
close_onlyoffice_instance() {
|
||||
local instance="demo"
|
||||
local purge=false
|
||||
|
||||
# Parseo de args: [instance] y/o --purge en cualquier orden.
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--purge) purge=true ;;
|
||||
-*) echo "close_onlyoffice_instance: flag desconocido '$a'" >&2; return 2 ;;
|
||||
*) instance="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1. Dependencias del sistema (consistencia con el grupo, aunque aqui solo
|
||||
# se usa /proc; onlyoffice/wmctrl/xdotool deben existir para operar el slot).
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "close_onlyoffice_instance: falta dependencia '$dep'" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
|
||||
# 2. Encontrar pids de DesktopEditors con HOME=/tmp/oo_<instance>.
|
||||
local pids=() pid environ
|
||||
for pid in $(pgrep -f '/opt/onlyoffice/desktopeditors/DesktopEditors' 2>/dev/null || true); do
|
||||
# Leer el entorno del proceso; saltar si no se puede (carrera/permisos).
|
||||
environ=$(tr '\0' '\n' <"/proc/${pid}/environ" 2>/dev/null || true)
|
||||
[[ -z "$environ" ]] && continue
|
||||
if grep -qx "HOME=${oo_home}" <<<"$environ" 2>/dev/null; then
|
||||
pids+=("$pid")
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Si no hay procesos del slot: not_running (purge opcional igualmente).
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
local purged=false
|
||||
if [[ "$purge" == true ]]; then
|
||||
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||
purged=true
|
||||
fi
|
||||
printf '{"instance":"%s","killed_pids":[],"purged":%s,"status":"not_running"}\n' \
|
||||
"$instance" "$purged"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. SIGTERM a todos los pids del slot.
|
||||
kill -TERM "${pids[@]}" 2>/dev/null || true
|
||||
|
||||
# 5. Esperar ~3s a que mueran (NUNCA sleep foreground): read -t 0.3 x10.
|
||||
local w=0 wmax=10
|
||||
while [[ $w -lt $wmax ]]; do
|
||||
local alive=false p
|
||||
for p in "${pids[@]}"; do
|
||||
if kill -0 "$p" 2>/dev/null; then alive=true; break; fi
|
||||
done
|
||||
[[ "$alive" == false ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
w=$((w + 1))
|
||||
done
|
||||
|
||||
# 6. SIGKILL a los que sigan vivos.
|
||||
local p
|
||||
for p in "${pids[@]}"; do
|
||||
if kill -0 "$p" 2>/dev/null; then
|
||||
kill -KILL "$p" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Purge opcional de los dirs del slot.
|
||||
local purged=false
|
||||
if [[ "$purge" == true ]]; then
|
||||
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||
purged=true
|
||||
fi
|
||||
|
||||
# 8. JSON con el array de pids terminados.
|
||||
local pids_json
|
||||
pids_json=$(printf '%s,' "${pids[@]}")
|
||||
pids_json="[${pids_json%,}]"
|
||||
printf '{"instance":"%s","killed_pids":%s,"purged":%s,"status":"closed"}\n' \
|
||||
"$instance" "$pids_json" "$purged"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo o sourceado.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
close_onlyoffice_instance "$@"
|
||||
fi
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: monitor_listening_ports
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "0.3.0"
|
||||
purity: impure
|
||||
signature: "monitor_listening_ports([--interval N], [--once]) -> void"
|
||||
description: "TUI ligera de terminal que refresca cada N segundos una tabla de los sockets TCP en escucha (LISTEN) del equipo local: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO | CMD (cmdline real, util para distinguir python3/node genericos), ordenada por tiempo de vida del proceso dueño (descendente). Una fila por pid. Lanzada como root rellena tambien los sockets de otros usuarios. Modo --once imprime un solo frame y sale."
|
||||
tags: [recon, ports, monitor, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: --interval N
|
||||
desc: "segundos entre refrescos en modo bucle (default: 1, acepta decimales)"
|
||||
- name: --once
|
||||
desc: "imprime un único frame de la tabla y termina con exit 0 (no interactivo; úsalo en tests y en `fn run` para no colgar)"
|
||||
output: "tabla a stdout con columnas IP, PUERTO, PROCESO, PID, TIEMPO ACTIVO ordenada por uptime del proceso descendente; sin --once refresca en bucle infinito hasta Ctrl-C"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/monitor_listening_ports.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Un solo frame (no cuelga) — ideal para fn run o un pipe
|
||||
./fn run monitor_listening_ports_bash_shell --once
|
||||
|
||||
# Como script directo
|
||||
bash bash/functions/shell/monitor_listening_ports.sh --once
|
||||
|
||||
# Sourceada, en bucle interactivo refrescando cada segundo (Ctrl-C para salir)
|
||||
source bash/functions/shell/monitor_listening_ports.sh
|
||||
monitor_listening_ports --interval 1
|
||||
|
||||
# Refresco mas lento
|
||||
monitor_listening_ports --interval 5
|
||||
```
|
||||
|
||||
Salida (frame `--once`, recortado):
|
||||
|
||||
```
|
||||
IP PUERTO PROCESO PID TIEMPO ACTIVO
|
||||
* 8420 registry_api 1885 4d 23:40:46
|
||||
:: 8889 mitmweb 1892 4d 23:40:46
|
||||
127.0.0.1 8484 sqlite_api 1889 4d 23:40:42
|
||||
127.0.0.1 8899 jupyter-lab 155100 4d 19:33:55
|
||||
::1 631 - - ?
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando quieras vigilar **qué puertos abren tus dev-servers / procesos web locales y desde cuándo** llevan vivos, en una sola pantalla que se actualiza sola.
|
||||
- Para detectar de un vistazo un proceso recién levantado (aparece al fondo, con poco TIEMPO ACTIVO) o uno que lleva días escuchando (arriba del todo).
|
||||
- Como paso de reconocimiento local del grupo `recon`: inventario rápido de superficie de escucha TCP del propio equipo, con el dueño de cada socket.
|
||||
- En tests o automatizaciones que solo necesitan un snapshot: añade `--once` para obtener un frame y salir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: depende de `ss` (paquete iproute2) y `ps` (procps). Si falta cualquiera, sale con exit 1 y un mensaje a stderr.
|
||||
- **Sin sudo no ves PROCESO/PID/CMD de sockets de otros usuarios** (típicamente procesos de root, ej. systemd-resolved en `127.0.0.54:53`, kernels Jupyter de otra sesión, o servidores en contenedores). Esas filas muestran `-`/`?`. La función **no usa sudo** a propósito; para **rellenarlos, lánzala como root**: `pass show claude/sudo | sudo -S bash bash/functions/shell/monitor_listening_ports.sh --interval 1` (el password se pipea, no queda en la cmdline). Como root, `ss` resuelve el dueño de todos los sockets.
|
||||
- **Columna CMD = cmdline real** (`ps -o args=`, recortada a 90 chars). Es lo que distingue un `python3`/`node` genérico (PROCESO) de lo que realmente ejecuta: `python3 -m ipykernel_launcher ...`, `registry_api -port 8420`, etc. Procesos en distinto namespace (docker) pueden seguir sin CMD aunque corras como root.
|
||||
- **Una fila por pid**: un mismo puerto con varios workers (ej. nginx, gunicorn) genera varias filas, una por cada pid dueño del socket.
|
||||
- **`--once` evita colgar**: sin `--once` corre en bucle infinito. No lo lances así en tests ni en `fn run` desatendido — usa `--once`.
|
||||
- **El orden es por uptime del PROCESO, no por el tiempo de la conexión**. `ps -o etimes=` mide cuánto lleva vivo el proceso completo, no cuándo abrió ese socket concreto.
|
||||
- **Carrera ps**: si un pid muere entre `ss` y `ps`, su TIEMPO ACTIVO sale como `?` y la fila se ordena al final (no rompe el bucle; el script usa `set -uo pipefail` sin `-e`).
|
||||
- En modo bucle oculta el cursor (`tput civis`) y lo restaura + limpia en un `trap` EXIT/INT/TERM, de modo que Ctrl-C deja la terminal limpia.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.3.0 (14/06/2026) — añade columna **CMD** con la cmdline real del proceso (mapa pid→args construido en la misma llamada `ps -eo pid=,etimes=,args=`), para distinguir un `python3`/`node` genérico de lo que realmente ejecuta. Documenta cómo rellenar los sockets de otros usuarios (`-`) lanzando la TUI como root. Anchos de columna reajustados para dar sitio a CMD.
|
||||
- v0.2.0 (14/06/2026) — corrige parpadeo y cuelgue del modo bucle. (1) Doble-buffer ANSI: cada frame se computa completo en una variable y se pinta con cursor-home `\033[H` + clear-to-end `\033[J` en vez de `tput clear` antes de recolectar, eliminando el instante en blanco. (2) Rendimiento: una sola llamada a `ps -eo pid=,etimes=` (mapa pid→uptime en memoria, antes era un fork de `ps` por pid) y construcción de filas con `printf -v` (builtin, antes un `$( )` por fila); frame de ~130 ms con cientos de sockets. (3) Bugfix de cuelgue: el avance del parser multi-pid usaba `BASH_REMATCH[0]`, que queda sobrescrito por el `[[ =~ ]]` interno de `_mlp_fmt_etime` → no recortaba el string y entraba en bucle infinito. Ahora el needle se captura justo tras el match, con guard anti-cuelgue si el recorte no progresa.
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bash
|
||||
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
|
||||
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
|
||||
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||
#
|
||||
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
|
||||
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
|
||||
#
|
||||
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
|
||||
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
|
||||
# command substitution por fila: las cadenas se construyen con `printf -v`
|
||||
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
|
||||
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
|
||||
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
|
||||
# entre refrescos.
|
||||
|
||||
# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
|
||||
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
|
||||
set -uo pipefail
|
||||
|
||||
# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
|
||||
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
|
||||
# <1h -> MM:SS ej. 12:45
|
||||
# <1d -> HH:MM:SS ej. 03:12:45
|
||||
# >=1d -> Nd HH:MM:SS ej. 1d 03:12:45
|
||||
_mlp_human=""
|
||||
_mlp_fmt_etime() {
|
||||
local secs="$1"
|
||||
# Si no es un numero entero valido, devolver tal cual (ej. "?").
|
||||
if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
|
||||
_mlp_human="$secs"
|
||||
return 0
|
||||
fi
|
||||
local days=$(( secs / 86400 ))
|
||||
local rem=$(( secs % 86400 ))
|
||||
local hours=$(( rem / 3600 ))
|
||||
local mins=$(( (rem % 3600) / 60 ))
|
||||
local s=$(( rem % 60 ))
|
||||
if (( days > 0 )); then
|
||||
printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
|
||||
elif (( hours > 0 )); then
|
||||
printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
|
||||
else
|
||||
printf -v _mlp_human '%02d:%02d' "$mins" "$s"
|
||||
fi
|
||||
}
|
||||
|
||||
# Imprime un unico frame de la tabla a stdout.
|
||||
# Estrategia de rendimiento (cero forks por fila):
|
||||
# 1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
|
||||
# 2. Un solo `ss -H -tlnp` lista los sockets en escucha.
|
||||
# 3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
|
||||
# (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
|
||||
# fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
|
||||
# O(1) en el mapa.
|
||||
# 4. Se ordena por segundos vivo descendente con un unico `sort`.
|
||||
_mlp_render_frame() {
|
||||
# Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
|
||||
# invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
|
||||
# asi `read` lo captura entero en la tercera variable.
|
||||
local -A etmap=() argmap=()
|
||||
local _pid _et _args
|
||||
while read -r _pid _et _args; do
|
||||
[[ -z "$_pid" ]] && continue
|
||||
etmap["$_pid"]="$_et"
|
||||
argmap["$_pid"]="$_args"
|
||||
done < <(ps -eo pid=,etimes=,args= 2>/dev/null)
|
||||
|
||||
# Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
|
||||
local -a rows=()
|
||||
local line row
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
# Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
|
||||
# Local:Port es el 4o token. Lo extraemos sin fork con read en array.
|
||||
local -a F=()
|
||||
read -ra F <<<"$line"
|
||||
local local_addr="${F[3]:-}"
|
||||
[[ -z "$local_addr" ]] && continue
|
||||
|
||||
# Separar IP y PUERTO partiendo por el ULTIMO ':'.
|
||||
local ip port
|
||||
port="${local_addr##*:}"
|
||||
ip="${local_addr%:*}"
|
||||
# Quitar corchetes de IPv6: [::] -> :: , [::1] -> ::1
|
||||
ip="${ip#[}"
|
||||
ip="${ip%]}"
|
||||
# Caso de bind sin direccion explicita (raro): dejar marcador.
|
||||
[[ -z "$ip" ]] && ip="*"
|
||||
|
||||
# Extraer el bloque users:(...) del final de la linea (si existe).
|
||||
local users=""
|
||||
[[ "$line" == *"users:("* ]] && users="${line#*users:(}"
|
||||
|
||||
if [[ -z "$users" ]]; then
|
||||
# Socket sin info de proceso (pertenece a otro usuario y no corremos
|
||||
# como root). Para verlo, lanzar la TUI como root (ver Gotchas).
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||
rows+=("$row")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
|
||||
# pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
|
||||
local s="$users" pname pid etimes needle prev_s cmd found_any=0
|
||||
while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
|
||||
# IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
|
||||
# comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
|
||||
# cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
|
||||
# despues, contendria el match del ultimo `=~` y el recorte de `s`
|
||||
# no avanzaria -> bucle infinito.
|
||||
pname="${BASH_REMATCH[1]}"
|
||||
pid="${BASH_REMATCH[2]}"
|
||||
needle="${BASH_REMATCH[0]}"
|
||||
found_any=1
|
||||
|
||||
# Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
|
||||
etimes="${etmap[$pid]:-}"
|
||||
if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
|
||||
etimes="-1"
|
||||
_mlp_human="?"
|
||||
else
|
||||
_mlp_fmt_etime "$etimes"
|
||||
fi
|
||||
|
||||
# Comando real (cmdline completa) del pid; dice QUE es realmente un
|
||||
# "python3"/"node" generico. Se recorta para no romper la tabla.
|
||||
cmd="${argmap[$pid]:-}"
|
||||
[[ -z "$cmd" ]] && cmd="-"
|
||||
cmd="${cmd:0:90}"
|
||||
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
|
||||
rows+=("$row")
|
||||
|
||||
# Avanzar mas alla del match actual para no repetir el primer pid.
|
||||
# Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
|
||||
prev_s="$s"
|
||||
s="${s#*"$needle"}"
|
||||
[[ "$s" == "$prev_s" ]] && break
|
||||
done
|
||||
|
||||
# Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
|
||||
if (( found_any == 0 )); then
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||
rows+=("$row")
|
||||
fi
|
||||
done < <(ss -H -tlnp 2>/dev/null)
|
||||
|
||||
# Estilo de cabecera (negrita) si la terminal lo soporta.
|
||||
local bold="" reset=""
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
||||
bold=$(tput bold 2>/dev/null || true)
|
||||
reset=$(tput sgr0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Anchos fijos para alineacion estable (no usamos column -t). La ultima
|
||||
# columna (CMD) es libre: muestra la cmdline real del proceso.
|
||||
local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
|
||||
# shellcheck disable=SC2059
|
||||
printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"
|
||||
|
||||
if (( ${#rows[@]} == 0 )); then
|
||||
printf '(sin sockets TCP en escucha)\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Ordenar por la primera columna (etimes) numerica descendente y emitir las
|
||||
# 5 columnas visibles (descartando la columna de orden).
|
||||
printf '%s\n' "${rows[@]}" \
|
||||
| sort -t$'\t' -k1,1nr \
|
||||
| while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
|
||||
# shellcheck disable=SC2059
|
||||
printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
|
||||
done
|
||||
}
|
||||
|
||||
monitor_listening_ports() {
|
||||
local interval=1
|
||||
local once=0
|
||||
|
||||
# Parseo de flags.
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--interval)
|
||||
interval="${2:-1}"
|
||||
shift 2
|
||||
;;
|
||||
--interval=*)
|
||||
interval="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--once)
|
||||
once=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
monitor_listening_ports [--interval N] [--once]
|
||||
|
||||
--interval N Segundos entre refrescos (default: 1, acepta decimales).
|
||||
--once Imprime un solo frame de la tabla y termina (exit 0).
|
||||
|
||||
Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
|
||||
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Dependencias minimas.
|
||||
if ! command -v ss >/dev/null 2>&1; then
|
||||
printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! command -v ps >/dev/null 2>&1; then
|
||||
printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Modo single-frame: util para tests y para `fn run` sin colgar.
|
||||
if (( once == 1 )); then
|
||||
_mlp_render_frame
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
|
||||
local have_tput=0
|
||||
command -v tput >/dev/null 2>&1 && have_tput=1
|
||||
|
||||
_mlp_cleanup() {
|
||||
if (( have_tput == 1 )); then
|
||||
tput cnorm 2>/dev/null || true # restaurar cursor
|
||||
tput sgr0 2>/dev/null || true # resetear atributos
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT
|
||||
|
||||
(( have_tput == 1 )) && tput civis 2>/dev/null || true # ocultar cursor
|
||||
|
||||
# Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
|
||||
# se computa COMPLETO en una variable y luego se pinta con doble-buffer:
|
||||
# cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
|
||||
# borrar restos de un frame anterior mas largo. Asi nunca hay un instante
|
||||
# con la pantalla vacia mientras se recolectan los datos.
|
||||
printf '\033[2J'
|
||||
|
||||
local frame
|
||||
while true; do
|
||||
frame=$(
|
||||
printf 'monitor_listening_ports — %s — intervalo %ss — orden: TIEMPO ACTIVO desc (Ctrl-C para salir)\n\n' \
|
||||
"$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
|
||||
_mlp_render_frame
|
||||
)
|
||||
printf '\033[H' # cursor al inicio (sin borrar todavia)
|
||||
printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
|
||||
printf '\033[J' # borrar de aqui al final (restos del frame previo)
|
||||
sleep "$interval" || break
|
||||
done
|
||||
}
|
||||
|
||||
# Auto-invocacion cuando se ejecuta como script (no al hacer source).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
monitor_listening_ports "$@"
|
||||
fi
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: open_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "open_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||
description: "Abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE Desktop Editors (Linux/X11) sin perturbar la instancia personal del usuario. Cada 'instance' (slot, default demo) usa su propio HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR y XDG_CONFIG_HOME bajo /tmp, lo que rompe el single-instance lock de ONLYOFFICE y permite una ventana propia en vez de una pestaña en la instancia del usuario. Espera la ventana por evento (xdotool, basename del archivo, timeout ~25s) sin sleep en foreground. Idempotente: si ya hay ventana para ese basename, no relanza y devuelve el wid existente. NO crea archivos: si file_path no existe, falla. Imprime una linea JSON con instance, file (ruta absoluta), wid (hex), pid y status (open|timeout)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta (relativa o absoluta) al archivo a abrir; DEBE existir, esta funcion no crea archivos. Se normaliza con readlink -f y se busca la ventana por su basename"
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado (default: demo). Determina el env: HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config. Usa el MISMO instance en reload/close para operar la misma instancia"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"pid\":<n>|null,\"status\":\"open\"|\"timeout\"}. Exit 0 si abrio (status open), exit 1 si la ventana no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta el argumento file_path"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/open_onlyoffice_file.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Como script directo (slot 'demo' por defecto)
|
||||
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/demo_reload.xlsx
|
||||
|
||||
# Slot nombrado distinto (ventana propia, no perturba la instancia personal)
|
||||
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/informe.docx reporte
|
||||
|
||||
# Via fn run
|
||||
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Sourceado, capturando el wid del JSON
|
||||
source bash/functions/shell/open_onlyoffice_file.sh
|
||||
out=$(open_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||
echo "$out"
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid":"0x3c00007","pid":12345,"status":"open"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites **abrir un archivo en ONLYOFFICE Desktop desde terminal en su propia ventana aislada**, sin que se agregue como pestaña a la instancia personal del usuario.
|
||||
- Como primer paso de un flujo automatizado open -> (editas el archivo en disco) -> `reload_onlyoffice_file` -> `close_onlyoffice_instance`.
|
||||
- Cuando quieras un slot reproducible por nombre (`instance`) que reuse la misma instancia aislada entre llamadas (reabrir rapido en vez de arrancar el motor de cero).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ONLYOFFICE Desktop es single-instance por usuario**: sin el slot aislado (HOME/XDG_RUNTIME_DIR propios), un segundo lanzamiento se reenvia a la instancia viva y abre el archivo como PESTAÑA, no ventana nueva. El lock NO se rompe con XDG_CONFIG_HOME solo; SI con HOME + XDG_RUNTIME_DIR propios. Esta funcion ya aplica esa convencion.
|
||||
- **NO hay reload nativo de cambios externos** (GitHub Issue #2313 abierto, no implementado). Esta funcion solo abre; para reflejar ediciones hechas en disco hay que cerrar+reabrir con `reload_onlyoffice_file`.
|
||||
- **NO crea archivos**: si `file_path` no existe, falla con exit 1. Crea el archivo por tu cuenta antes de llamar.
|
||||
- **El slot vive en /tmp**: los dirs `/tmp/oo_<instance>*` se pierden al reiniciar el PC (tmpfs en muchos sistemas). No guardes nada importante ahi; es estado desechable de la instancia aislada.
|
||||
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland (xdotool no encontrara la ventana). La funcion comprueba `command -v` de las 3 deps y falla claro si falta alguna.
|
||||
- **El pid reportado es el del launcher** (`onlyoffice-desktopeditors`), que puede reexec/fork al proceso real `DesktopEditors`; sirve como referencia best-effort, no para `kill` fiable (usa `close_onlyoffice_instance`, que localiza el proceso real por su HOME).
|
||||
- **Idempotencia por basename**: si ya existe una ventana cuyo titulo contiene el basename del archivo (lo abrio el usuario en su instancia personal, por ejemplo), la funcion la considera "ya abierta" y devuelve ese wid sin relanzar. Usa un basename unico para el slot de pruebas si quieres evitar colisiones.
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# open_onlyoffice_file — abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE
|
||||
# Desktop Editors (Linux/X11), sin perturbar la instancia personal del usuario.
|
||||
#
|
||||
# Funcion impura: lanza un proceso GUI, lee estado de ventanas (xdotool) y
|
||||
# escribe directorios en /tmp. Imprime una linea JSON con el resultado.
|
||||
#
|
||||
# Por que "instancia aislada": ONLYOFFICE Desktop es single-instance por
|
||||
# usuario — un segundo `onlyoffice-desktopeditors <file>` se reenvia a la
|
||||
# instancia viva y abre el archivo como PESTAÑA en su ventana. El lock
|
||||
# single-instance NO se rompe con XDG_CONFIG_HOME, pero SI se rompe lanzando
|
||||
# con HOME y XDG_RUNTIME_DIR propios. Por eso cada "slot" nombrado (instance)
|
||||
# usa su propio HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp.
|
||||
|
||||
# Sin -e: las busquedas de ventana (xdotool search) pueden no matchear y
|
||||
# devolver exit !=0; no deben abortar la funcion. -u y pipefail se mantienen.
|
||||
set -uo pipefail
|
||||
|
||||
open_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
if [[ -z "$file_path" ]]; then
|
||||
echo "open_onlyoffice_file: falta <file_path>" >&2
|
||||
echo "uso: open_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# 1. Dependencias del sistema.
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "open_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. El archivo DEBE existir — esta funcion no crea archivos.
|
||||
if [[ ! -f "$file_path" ]]; then
|
||||
echo "open_onlyoffice_file: el archivo no existe: $file_path (esta funcion no crea archivos)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ruta absoluta y basename para titular/buscar la ventana.
|
||||
local abs_path base
|
||||
abs_path=$(readlink -f -- "$file_path")
|
||||
base=$(basename -- "$abs_path")
|
||||
|
||||
# 3. Slot aislado: HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME propios bajo /tmp.
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
local oo_run="/tmp/oo_${instance}_run"
|
||||
local oo_cfg="${oo_home}/.config"
|
||||
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||
chmod 700 "$oo_run" 2>/dev/null || true
|
||||
|
||||
# 4. Idempotencia: si ya hay ventana para ese basename, no relanzar.
|
||||
local existing_wid
|
||||
existing_wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
if [[ -n "$existing_wid" ]]; then
|
||||
local wid_hex
|
||||
wid_hex=$(printf '0x%x' "$existing_wid" 2>/dev/null || echo "$existing_wid")
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","pid":null,"status":"open"}\n' \
|
||||
"$instance" "$abs_path" "$wid_hex"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 5. Lanzar la instancia aislada con su env propio. setsid lo desacopla de
|
||||
# la terminal; redirige todo a un log del slot.
|
||||
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||
local launch_pid=$!
|
||||
|
||||
# 6. Esperar la ventana por evento (NUNCA sleep en foreground).
|
||||
# ~25s con read -t 0.3 => ~83 iteraciones.
|
||||
local wid="" i=0 max=83
|
||||
while [[ $i -lt $max ]]; do
|
||||
wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
[[ -n "$wid" ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [[ -z "$wid" ]]; then
|
||||
printf '{"instance":"%s","file":"%s","wid":null,"pid":%s,"status":"timeout"}\n' \
|
||||
"$instance" "$abs_path" "$launch_pid"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local wid_hex
|
||||
wid_hex=$(printf '0x%x' "$wid" 2>/dev/null || echo "$wid")
|
||||
# El pid del proceso real (DesktopEditors) puede diferir del launcher; el
|
||||
# launcher reexec/fork. Reportamos el pid del launcher (best-effort).
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","pid":%s,"status":"open"}\n' \
|
||||
"$instance" "$abs_path" "$wid_hex" "$launch_pid"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo: `bash open_onlyoffice_file.sh <file> [instance]`.
|
||||
# Sourceado: define la funcion sin ejecutarla.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
open_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: reload_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reload_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||
description: "Recarga en la ventana de ONLYOFFICE Desktop Editors los datos que el caller edito EN DISCO, cerrando y reabriendo el archivo en la INSTANCIA AISLADA (slot). Es la funcion estrella del grupo: ONLYOFFICE no recarga cambios externos del archivo (GitHub Issue #2313 abierto, no implementado), asi que la unica forma de mostrar datos editados fuera de la app es cerrar la ventana (wmctrl -ic) y reabrir (ONLYOFFICE lee fresco del disco al abrir). Localiza la ventana por basename, la cierra y espera a que desaparezca (timeout ~10s), relanza con el env del slot aislado y espera la ventana nueva (timeout ~25s), todo por evento sin sleep en foreground. Si no habia ventana previa, actua como open. NO edita el archivo: el caller lo edita antes de llamar. Imprime JSON con wid_old, wid_new, reopened, elapsed_s y status (reloaded|timeout)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta (relativa o absoluta) al archivo cuya ventana se recarga; DEBE existir. El caller ya lo edito en disco antes de llamar. Se busca la ventana por su basename"
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado (default: demo); debe coincidir con el usado en open_onlyoffice_file para reusar la misma instancia. Determina HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid_old\":\"<hex>|null\",\"wid_new\":\"<hex>|null\",\"reopened\":true|false,\"elapsed_s\":<n>,\"status\":\"reloaded\"|\"timeout\"}. Exit 0 si reabrio (status reloaded), exit 1 si la ventana nueva no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta file_path"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/reload_onlyoffice_file.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Flujo tipico: editas el .xlsx en disco con tu herramienta y refrescas la vista
|
||||
# (este ejemplo asume que /tmp/demo_reload.xlsx ya esta abierto en el slot demo)
|
||||
bash bash/functions/shell/reload_onlyoffice_file.sh /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Via fn run
|
||||
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Sourceado, dentro de un bucle de "editar en disco -> ver en ONLYOFFICE"
|
||||
source bash/functions/shell/reload_onlyoffice_file.sh
|
||||
# ... el caller modifica /tmp/demo_reload.xlsx por su cuenta ...
|
||||
out=$(reload_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||
echo "$out"
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando **editaste un archivo en disco fuera de ONLYOFFICE** (script, otra herramienta, generador) y necesitas que la ventana de ONLYOFFICE muestre los datos nuevos: esta funcion cierra y reabre para forzar la lectura fresca del disco.
|
||||
- En bucles de iteracion rapida "modificar el archivo -> ver el resultado en ONLYOFFICE" sin tocar la instancia personal del usuario.
|
||||
- Como reemplazo del reload nativo inexistente (Issue #2313): es la unica via fiable de refrescar la vista desde disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No edita el archivo**: solo recarga la ventana desde disco. El caller es responsable de modificar el archivo ANTES de llamar; si no lo modifico, reabrira los mismos datos.
|
||||
- **ONLYOFFICE no tiene reload de cambios externos** (GitHub Issue #2313 abierto, no implementado): por eso esta funcion existe y hace cerrar+reabrir. No hay forma "in-place" de refrescar.
|
||||
- **`wmctrl -ic` puede disparar el dialogo "Guardar cambios"** si el usuario edito EN la app (no en disco) y hay cambios sin guardar en esa ventana. El flujo previsto es editar SOLO en disco con la ventana sin tocar; si editaste en la app, guarda o descarta antes, o el cierre se quedara esperando interaccion (la funcion saldra por timeout).
|
||||
- **Single-instance + slot aislado**: usa el mismo `instance` que en `open_onlyoffice_file`. Con HOME/XDG_RUNTIME_DIR propios el relaunch reenvia a la instancia aislada viva y reabre rapido; con env por defecto se reenviaria a la instancia personal del usuario (no deseado).
|
||||
- **El slot vive en /tmp**: `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland. Comprueba las 3 deps y falla claro si falta alguna.
|
||||
- **Carrera de cierre**: si la ventana tarda mas de ~10s en cerrarse (dialogo modal, app ocupada), la funcion continua igualmente al relaunch; el resultado puede acabar en `timeout` si la ventana nueva no aparece a tiempo.
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# reload_onlyoffice_file — cierra y reabre un archivo en la INSTANCIA AISLADA de
|
||||
# ONLYOFFICE Desktop Editors para que la ventana muestre los datos editados
|
||||
# EN DISCO por el caller (ONLYOFFICE no recarga cambios externos: GitHub Issue
|
||||
# #2313 abierto, no implementado — la unica forma es cerrar+reabrir).
|
||||
#
|
||||
# Funcion impura: cierra una ventana GUI (wmctrl), relanza un proceso y espera
|
||||
# la ventana nueva por evento. NO edita el archivo — solo recarga la ventana
|
||||
# desde el disco. El caller edita el archivo antes de llamar a esta funcion.
|
||||
#
|
||||
# Instancia aislada (slot): mismo HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME que usa
|
||||
# open_onlyoffice_file, para que el relaunch reenvie a la instancia aislada
|
||||
# viva y reabra rapido en vez de arrancar el motor de cero.
|
||||
|
||||
# Sin -e: busquedas de ventana (xdotool/wmctrl) pueden no matchear; no deben
|
||||
# abortar la funcion. -u y pipefail se mantienen.
|
||||
set -uo pipefail
|
||||
|
||||
reload_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
if [[ -z "$file_path" ]]; then
|
||||
echo "reload_onlyoffice_file: falta <file_path>" >&2
|
||||
echo "uso: reload_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# 1. Dependencias del sistema.
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "reload_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. El archivo DEBE existir — no editamos ni creamos archivos.
|
||||
if [[ ! -f "$file_path" ]]; then
|
||||
echo "reload_onlyoffice_file: el archivo no existe: $file_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local abs_path base
|
||||
abs_path=$(readlink -f -- "$file_path")
|
||||
base=$(basename -- "$abs_path")
|
||||
|
||||
# 3. Slot aislado (identico a open_onlyoffice_file).
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
local oo_run="/tmp/oo_${instance}_run"
|
||||
local oo_cfg="${oo_home}/.config"
|
||||
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||
chmod 700 "$oo_run" 2>/dev/null || true
|
||||
|
||||
local start_ts
|
||||
start_ts=$(date +%s)
|
||||
|
||||
# 4. Localizar la ventana actual del archivo por basename.
|
||||
local wid_old=""
|
||||
wid_old=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
|
||||
local wid_old_hex="null"
|
||||
if [[ -n "$wid_old" ]]; then
|
||||
wid_old_hex=$(printf '0x%x' "$wid_old" 2>/dev/null || echo "$wid_old")
|
||||
|
||||
# 5. Cerrar la ventana (sin teclear en la app) y esperar a que
|
||||
# desaparezca (~10s con read -t 0.3 => ~33 iteraciones).
|
||||
wmctrl -ic "$wid_old" 2>/dev/null || true
|
||||
local g=0 gmax=33
|
||||
while [[ $g -lt $gmax ]]; do
|
||||
if ! xdotool search --name -- "$base" 2>/dev/null | grep -q .; then
|
||||
break
|
||||
fi
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
g=$((g + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
# 6. Relanzar con el env del slot aislado. (Si no habia ventana previa,
|
||||
# esto actua simplemente como open.)
|
||||
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||
|
||||
# 7. Esperar la ventana nueva por evento (~25s => ~83 iteraciones).
|
||||
local wid_new="" i=0 max=83
|
||||
while [[ $i -lt $max ]]; do
|
||||
wid_new=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
# Si hubo ventana previa, aceptar cualquier wid que aparezca (el old
|
||||
# ya se cerro; el nuevo puede reutilizar id o no). Si no la hubo,
|
||||
# cualquier wid sirve.
|
||||
[[ -n "$wid_new" ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
local now_ts elapsed
|
||||
now_ts=$(date +%s)
|
||||
elapsed=$((now_ts - start_ts))
|
||||
|
||||
if [[ -z "$wid_new" ]]; then
|
||||
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":null,"reopened":false,"elapsed_s":%s,"status":"timeout"}\n' \
|
||||
"$instance" "$abs_path" "$wid_old_hex" "$elapsed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local wid_new_hex
|
||||
wid_new_hex=$(printf '0x%x' "$wid_new" 2>/dev/null || echo "$wid_new")
|
||||
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":"%s","reopened":true,"elapsed_s":%s,"status":"reloaded"}\n' \
|
||||
"$instance" "$abs_path" "$wid_old_hex" "$wid_new_hex" "$elapsed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo o sourceado.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
reload_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: save_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
purity: impure
|
||||
version: 1.1.0
|
||||
description: "Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de OnlyOffice Desktop en Linux/X11 y confirma que llego a disco por cambio de mtime. Primer paso del flujo seguro guardar -> actualizar -> recargar; evita perder cambios no guardados cuando un build regenera el archivo leyendo del disco."
|
||||
signature: "save_onlyoffice_file(file_path: string, [instance: string]) -> json"
|
||||
error_type: error_go_core
|
||||
tags: [onlyoffice, desktop, x11, gui, save, persist]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
file_path: bash/functions/shell/save_onlyoffice_file.sh
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta al documento abierto en OnlyOffice cuyo guardado se quiere forzar. Debe existir. Se normaliza a ruta absoluta y se usa su basename para localizar la ventana."
|
||||
- name: instance
|
||||
desc: "nombre del slot/instancia para etiquetar la salida JSON (default: 'demo'). Usar el MISMO valor que en open/reload/close del mismo documento por coherencia."
|
||||
output: "linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"status\":\"saved\"|\"no_change\"|\"no_window\",\"dialog_confirmed\":0|1[,\"mtime_before\":N,\"mtime_after\":N]}. dialog_confirmed=1 si se envio Return para cerrar el dialogo modal de formato. Exit 0 salvo error de dependencia o archivo inexistente (exit 1)."
|
||||
---
|
||||
|
||||
Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de ONLYOFFICE
|
||||
Desktop Editors en Linux/X11 y confirma que el guardado llegó a disco observando el
|
||||
cambio de `mtime` del archivo.
|
||||
|
||||
Existe para cerrar una ventana de pérdida de datos: OnlyOffice mantiene los cambios
|
||||
en memoria hasta que el usuario guarda. Cualquier proceso que regenere el archivo
|
||||
leyendo del disco (un build que refresca hojas, un script de sincronización)
|
||||
perdería el trabajo manual no guardado. Esta función vuelca ese trabajo a disco
|
||||
ANTES de tocar el archivo, de modo que el paso de actualización pueda preservarlo.
|
||||
|
||||
Es el primer paso del flujo seguro de refresco:
|
||||
|
||||
```
|
||||
save_onlyoffice_file -> (actualizar el archivo en disco) -> reload_onlyoffice_file
|
||||
```
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Forzar el guardado de un xlsx abierto en la instancia "afiliados"
|
||||
bash bash/functions/shell/save_onlyoffice_file.sh \
|
||||
/home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||
# {"instance":"afiliados","file":"/home/enmanuel/afiliados/programas_afiliados.xlsx","wid":"0x0a20002a","status":"saved","mtime_before":1718380000,"mtime_after":1718380042}
|
||||
|
||||
# Via fn run (tras fn index)
|
||||
./fn run save_onlyoffice_file /home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||
|
||||
# Encadenado con la actualización y la recarga (flujo seguro completo)
|
||||
bash bash/functions/shell/save_onlyoffice_file.sh "$XLSX" afiliados
|
||||
python build_xlsx.py # regenera solo las hojas gestionadas
|
||||
./fn run reload_onlyoffice_file "$XLSX" afiliados
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llámala SIEMPRE justo antes de regenerar o modificar en disco un archivo que el
|
||||
usuario pueda tener abierto en OnlyOffice, para no pisar sus cambios sin guardar.
|
||||
Es el primer eslabón del flujo guardar -> actualizar -> recargar. Si no hay ventana
|
||||
abierta para ese archivo, es un no-op seguro (status `no_window`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Orden crítico**: guarda ANTES de actualizar el archivo. Si actualizas primero y
|
||||
guardas OnlyOffice después, OnlyOffice sobrescribe tu actualización con su copia
|
||||
en memoria (vieja). El flujo correcto es save -> update -> reload.
|
||||
- **status `no_change`**: el `mtime` no cambió. Normalmente significa que no había
|
||||
cambios pendientes (no es un error).
|
||||
- **Auto-confirmación del diálogo de formato (v1.1.0)**: si tras Ctrl+S el guardado no
|
||||
se completa en ~1.2s, la función asume que OnlyOffice mostró un diálogo modal
|
||||
("mantener formato") y le envía Return, que acepta la opción por defecto (mantener el
|
||||
formato actual). El campo `dialog_confirmed` indica si se envió. Si no había diálogo,
|
||||
el Return va al editor y solo mueve de celda (no altera datos). Para suprimir el
|
||||
diálogo de forma permanente, desmárcalo en OnlyOffice: Configuración avanzada →
|
||||
desactivar el aviso de formato al guardar.
|
||||
- **status `no_window`**: no hay ninguna ventana cuyo título contenga el basename del
|
||||
archivo. No hay nada que guardar; el disco ya es la única fuente de verdad.
|
||||
- **Detección por basename**: dos archivos con el mismo nombre en rutas distintas
|
||||
colisionan al localizar la ventana (igual que open/reload).
|
||||
- **X11 obligatorio**: depende de `xdotool` (y `stat` de coreutils). No funciona en
|
||||
Wayland puro sin XWayland.
|
||||
- **Foco**: la función activa la ventana (`windowactivate --sync`) para que Ctrl+S
|
||||
llegue al editor. Roba el foco un instante; es esperable.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-15) — auto-confirma el diálogo modal "mantener formato" enviando
|
||||
Return a la ventana activa cuando el guardado no se completa en ~1.2s; añade el campo
|
||||
`dialog_confirmed` a la salida JSON.
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# save_onlyoffice_file — fuerza el guardado (Ctrl+S) de un documento abierto en una
|
||||
# instancia de ONLYOFFICE Desktop Editors en Linux/X11 y confirma que el archivo se
|
||||
# escribio a disco observando el cambio de mtime.
|
||||
#
|
||||
# Para que existe: OnlyOffice mantiene los cambios en memoria hasta que el usuario
|
||||
# guarda. Cualquier proceso que regenere el .xlsx leyendo del disco (por ejemplo un
|
||||
# build que refresca hojas) perderia el trabajo manual no guardado. Esta funcion
|
||||
# vuelca ese trabajo a disco ANTES de tocar el archivo, de modo que el paso de
|
||||
# actualizacion pueda preservarlo. Es el primer paso del flujo seguro:
|
||||
# save_onlyoffice_file -> (actualizar el archivo) -> reload_onlyoffice_file
|
||||
#
|
||||
# La ventana se localiza por el basename del archivo (OnlyOffice titula la ventana
|
||||
# "<basename> — ONLYOFFICE"), igual que open_onlyoffice_file. Si no hay ventana
|
||||
# abierta para ese basename no hay nada que guardar: se devuelve status "no_window"
|
||||
# con exit 0 (el disco ya es la unica fuente de verdad).
|
||||
#
|
||||
# Funcion impura: envia eventos de teclado a X11 (xdotool) y lee el estado del
|
||||
# sistema de archivos. Imprime una linea JSON con el resultado a stdout.
|
||||
#
|
||||
# No usamos `set -e`: los pipelines de busqueda de ventanas (xdotool|head) pueden no
|
||||
# matchear y no deben abortar el script. Mantenemos -u y pipefail con guardas.
|
||||
set -uo pipefail
|
||||
|
||||
save_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
# --- 1. Validacion de dependencias del sistema ---
|
||||
local dep
|
||||
for dep in xdotool stat; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "error: dependencia ausente: '$dep' (instala xdotool, coreutils)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 2. Validacion de argumentos ---
|
||||
if [ -z "$file_path" ]; then
|
||||
echo "error: uso: save_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$file_path" ]; then
|
||||
echo "error: el archivo no existe: '$file_path'" >&2
|
||||
return 1
|
||||
fi
|
||||
local abs_path
|
||||
abs_path="$(cd "$(dirname "$file_path")" && pwd)/$(basename "$file_path")"
|
||||
local base
|
||||
base="$(basename "$abs_path")"
|
||||
|
||||
# --- 3. Localizar la ventana de OnlyOffice por basename ---
|
||||
local wid=""
|
||||
wid="$(xdotool search --name "$base" 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$wid" ]; then
|
||||
printf '{"instance":"%s","file":"%s","wid":null,"status":"no_window"}\n' \
|
||||
"$instance" "$abs_path"
|
||||
return 0
|
||||
fi
|
||||
local hex
|
||||
hex="$(printf '0x%08x' "$wid" 2>/dev/null || echo "$wid")"
|
||||
|
||||
# --- 4. mtime antes de guardar ---
|
||||
local mtime_before
|
||||
mtime_before="$(stat -c %Y "$abs_path" 2>/dev/null || echo 0)"
|
||||
|
||||
# --- 5. Enfocar la ventana y enviar Ctrl+S ---
|
||||
xdotool windowactivate --sync "$wid" >/dev/null 2>&1 || true
|
||||
xdotool key --clearmodifiers --window "$wid" ctrl+s >/dev/null 2>&1 || true
|
||||
|
||||
# --- 6. Esperar el guardado; auto-confirmar el dialogo de formato si aparece ---
|
||||
# OnlyOffice puede mostrar un dialogo modal ("mantener formato") al guardar. Si el
|
||||
# mtime no cambia en ~1.2s asumimos que hay un modal esperando y le enviamos Return:
|
||||
# acepta la opcion por defecto, que es mantener el formato actual del archivo. Si no
|
||||
# habia dialogo, el Return va al editor y solo mueve de celda (inofensivo: no altera
|
||||
# datos). El intento se repite mientras el guardado no se confirme.
|
||||
local mtime_after="$mtime_before" i=0 confirmed=0
|
||||
local max=27 # ~8s a 0.3s por iteracion
|
||||
until [ "$mtime_after" -gt "$mtime_before" ] || [ "$i" -ge "$max" ]; do
|
||||
read -r -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
mtime_after="$(stat -c %Y "$abs_path" 2>/dev/null || echo "$mtime_before")"
|
||||
i=$((i + 1))
|
||||
# A partir de ~1.2s sin guardar, confirmar el dialogo modal con Return.
|
||||
if [ "$i" -ge 4 ] && [ "$mtime_after" -le "$mtime_before" ]; then
|
||||
local dlg
|
||||
dlg="$(xdotool getactivewindow 2>/dev/null || true)"
|
||||
if [ -n "$dlg" ]; then
|
||||
xdotool key --clearmodifiers --window "$dlg" Return >/dev/null 2>&1 || true
|
||||
confirmed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local status="saved"
|
||||
if [ "$mtime_after" -le "$mtime_before" ]; then
|
||||
# Sin cambio de mtime: no habia nada pendiente que guardar.
|
||||
status="no_change"
|
||||
fi
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","status":"%s","dialog_confirmed":%s,"mtime_before":%s,"mtime_after":%s}\n' \
|
||||
"$instance" "$abs_path" "$hex" "$status" "$confirmed" "$mtime_before" "$mtime_after"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo: `bash save_onlyoffice_file.sh <file> [instance]`.
|
||||
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
||||
save_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -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 {
|
||||
|
||||
Submodule
+1
Submodule cpp/apps/chart_demo added at 026f514bb7
Submodule
+1
Submodule cpp/apps/shaders_lab added at ab38127ac0
Binary file not shown.
@@ -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,10 @@ 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 |
|
||||
| [claude-fleet](claude-fleet.md) | 5 | Orquestar la flota de procesos Claude Code vivos: panel TUI (fleetview) + comando fleetclaude que centraliza N Claudes en una ventana kitty/tmux (socket -L fleet), conmuta cual esta embebido (alt+flechas/enter/n) y los lista desde ~/.claude/sessions+goals |
|
||||
| [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 |
|
||||
| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
|
||||
| [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 +42,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 +52,15 @@ 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) | 16 | 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, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
|
||||
| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz |
|
||||
| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos |
|
||||
| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) |
|
||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||
| [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 |
|
||||
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
|
||||
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Capability group: claude-fleet
|
||||
|
||||
Operar la **flota de procesos Claude Code** vivos en la máquina como una sola
|
||||
unidad: descubrirlos, listarlos en un panel TUI y centralizarlos en una ventana
|
||||
kitty con tmux donde se conmuta cuál está embebido a la derecha. Reemplaza el
|
||||
caos de N ventanas kitty dispersas por un único punto de entrada.
|
||||
|
||||
Pieza visible: la app `fleetview` (TUI). Entrypoint: el comando `fleetclaude`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Qué hace |
|
||||
|---|---|---|
|
||||
| `list_claude_fleet_go_infra` | `ListClaudeFleet() ([]ClaudeFleet, error)` | Escanea `~/.claude/sessions/*.json` + `goals/`, valida procesos vivos (anti-PID-reciclado), join por `sessionId` → lista tipada con status/objetivo/cwd/target. |
|
||||
| `launch_fleetclaude_bash_infra` | `launch_fleetclaude [--cwd <d>] [--bin <p>] [--session <n>] [--cols <n>]` | Entrypoint: abre kitty con sesión tmux (socket aislado `-L fleet`) de dos panes (TUI izq + Claude der). Instala atajos `alt+*` e hijos del sidebar. |
|
||||
| `tmux_new_claude_window_go_infra` | `TmuxNewClaudeWindow(socket, session, cwd string) (string, error)` | Crea una window tmux nueva con `claude --dangerously-skip-permissions`. Devuelve el `window_id`. |
|
||||
| `tmux_swap_window_into_console_go_infra` | `TmuxSwapWindowIntoConsole(socket, session, windowID string) error` | Trae el Claude de `windowID` al pane derecho de `console` (junto a la TUI), parkea el anterior, re-fija el ancho del sidebar. |
|
||||
| `tmux_map_claude_panes_go_infra` | `TmuxMapClaudePanes(socket string) (map[int]string, error)` | Mapa `claudePID → window_id` de los Claude que viven en la sesión (vía `list-panes` + descendencia `/proc`). Permite a la TUI saber cuáles son conmutables. |
|
||||
|
||||
App relacionada: `fleetview_go_infra` (`apps/fleetview/`) — la TUI Bubble Tea que consume `list_claude_fleet` y orquesta los wrappers tmux.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# 1. Compilar la TUI una vez.
|
||||
cd ~/fn_registry/apps/fleetview && go build -o fleetview .
|
||||
|
||||
# 2. Abrir la flota (una ventana kitty: panel izq + Claude der).
|
||||
fn run launch_fleetclaude
|
||||
|
||||
# 3. Dentro de la ventana, desde CUALQUIER pane (incluido escribiendo en Claude):
|
||||
# alt+↑/↓ mueve el cursor de la lista
|
||||
# alt+enter conmuta el pane derecho al Claude seleccionado
|
||||
# alt+n abre un Claude nuevo (window en fleet) y conmuta a él
|
||||
|
||||
# Inspección headless de la flota sin abrir nada:
|
||||
fn run list_claude_fleet | jq '.[] | {rename, status, goal}'
|
||||
```
|
||||
|
||||
Bajo el capó de `alt+enter`/`alt+n`: tmux redirige la tecla al pane de la TUI
|
||||
(`bind -n M-Enter send-keys -t console.0 Enter`); la TUI resuelve el Claude
|
||||
seleccionado con `TmuxMapClaudePanes` y lo trae con `TmuxSwapWindowIntoConsole`
|
||||
(o crea uno con `TmuxNewClaudeWindow`).
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No gestiona Claudes remotos** (ej. los de una sesión tmux del móvil): se
|
||||
listan como contexto pero no se embeben localmente (no son panes de fleet).
|
||||
- **Adopción de Claudes sueltos pendiente**: un Claude vivo en otra ventana kitty
|
||||
(fuera de fleet) se lista, pero `alt+enter` sobre él aún no lo trae —
|
||||
requerirá relaunch `claude --resume <sessionId>` dentro de fleet (patrón de
|
||||
`reboot_all_claudes_bash_infra`).
|
||||
- **No reinicia ni mata Claudes** (todavía): `resume`/`kill` desde el panel son
|
||||
fase posterior. Para reiniciar toda la flota existe `reboot_all_claudes_bash_infra`.
|
||||
- **Linux + kitty + tmux** únicamente (build tag `!windows`, usa `/proc`).
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `kitty` y `tmux` en el PATH. La sesión vive en un server tmux aislado (`-L fleet`).
|
||||
- La TUI `fleetview` compilada (`apps/fleetview/fleetview`).
|
||||
- Claude Code ≥ 2.1.x (escribe `~/.claude/sessions/<PID>.json` con `status`).
|
||||
|
||||
## Notas
|
||||
|
||||
- Toda la sesión usa el socket `-L fleet`: los atajos `bind -n` no afectan al
|
||||
tmux por defecto del usuario; `tmux -L fleet kill-server` lo limpia entero.
|
||||
- `reboot_all_claudes_bash_infra` comparte la misma fuente de verdad
|
||||
(`~/.claude/sessions/<PID>.json`) y es el complemento para reiniciar la flota.
|
||||
@@ -0,0 +1,106 @@
|
||||
# dav — Cliente CardDAV/CalDAV (Python, solo stdlib)
|
||||
|
||||
Grupo de capacidad para operar un servidor **CardDAV/CalDAV** (Xandikos, git-backed,
|
||||
en el VPS `magnus`) desde Python sin dependencias externas. Cubre el flujo de
|
||||
**migracion**: partir un export de Google (un `.vcf` con N contactos, un `.ics` con
|
||||
N eventos) en recursos individuales y subirlos uno a uno por HTTP PUT con Basic auth.
|
||||
Tambien listar y descargar recursos para verificar o hacer backup.
|
||||
|
||||
Formaliza el flujo ad-hoc (heredocs) que migro 820 contactos + 98 eventos a Xandikos
|
||||
(regla `function_growth_and_self_docs`: una composicion repetida >2 veces se promueve
|
||||
a funciones/pipelines del registry).
|
||||
|
||||
## Restriccion de diseno
|
||||
|
||||
**Solo stdlib** (`urllib.request`, `re`, `hashlib`, `base64`, `ssl`). Sin `requests`,
|
||||
`caldav` ni `vobject`. El header `Authorization: Basic base64(user:pass)` se construye
|
||||
a mano. `verify_tls=True` por defecto. Coherente con el grupo `osint-passive` (sin deps).
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace | Purity |
|
||||
|---|---|---|---|
|
||||
| `split_vcards_py_infra` | `split_vcards(vcf_text) -> list` | Parte un `.vcf` en VCARDs individuales | pure |
|
||||
| `split_vevents_to_vcalendars_py_infra` | `split_vevents_to_vcalendars(ics_text, prodid?) -> list` | Parte un VCALENDAR con N VEVENT en N VCALENDARs autonomos (replica VTIMEZONE) | pure |
|
||||
| `extract_or_make_uid_py_infra` | `extract_or_make_uid(text, prefix?) -> str` | Extrae el `UID:` o sintetiza `<prefix><md5[:16]>` determinista | pure |
|
||||
| `carddav_put_vcard_py_infra` | `carddav_put_vcard(base_url, user, pw, coll, uid, vcard) -> dict` | PUT de un VCARD (`.vcf`, `text/vcard`) | impure |
|
||||
| `caldav_put_event_py_infra` | `caldav_put_event(base_url, user, pw, coll, uid, vcal) -> dict` | PUT de un VCALENDAR (`.ics`, `text/calendar`) | impure |
|
||||
| `dav_list_resources_py_infra` | `dav_list_resources(base_url, user, pw, coll) -> dict` | PROPFIND Depth:1 -> lista de `{href, etag}` | impure |
|
||||
| `dav_get_resource_py_infra` | `dav_get_resource(base_url, user, pw, href) -> dict` | GET de un recurso -> texto VCARD/VCALENDAR | impure |
|
||||
| `dav_make_calendar_py_infra` | `dav_make_calendar(base_url, user, pw, calendar_home, slug, name?, color?, desc?) -> dict` | MKCALENDAR + PROPPATCH: crea una coleccion de calendario (agenda) nueva | impure |
|
||||
| `dav_make_addressbook_py_infra` | `dav_make_addressbook(base_url, user, pw, contacts_home, slug, name?, desc?) -> dict` | Extended MKCOL: crea una coleccion CardDAV (libreta/agenda de contactos) nueva | impure |
|
||||
| `dav_list_addressbooks_py_infra` | `dav_list_addressbooks(base_url, user, pw, contacts_home) -> dict` | PROPFIND Depth:1: lista las libretas CardDAV del contacts-home con nombre y descripcion | impure |
|
||||
| `build_vcard_py_core` | `build_vcard(contact: dict) -> str` | Serializa un contacto a VCARD 3.0 MULTI-VALOR (N TEL/EMAIL/ADR + X-OSINT-*); pura | pure |
|
||||
| `expand_rrule_py_infra` | `expand_rrule(dtstart_ical, rrule, range_start, range_end, all_day?) -> list` | Expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango | pure |
|
||||
| `import_vcf_to_carddav_py_pipelines` | `import_vcf_to_carddav(vcf_path, base_url, user, pw, coll) -> dict` | Pipeline: .vcf -> split -> uid -> PUT por tarjeta | impure |
|
||||
| `import_ics_to_caldav_py_pipelines` | `import_ics_to_caldav(ics_path, base_url, user, pw, coll) -> dict` | Pipeline: .ics -> split -> uid -> PUT por evento | impure |
|
||||
|
||||
## Sistema real (para los ejemplos)
|
||||
|
||||
- Servidor: **Xandikos** en `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com`, Basic auth, usuario `enmanuel`.
|
||||
- Password: `pass dav/xandikos-enmanuel` (primera linea). Resolver con `pass_get_secret_py_infra`, NUNCA hardcodear.
|
||||
- Principal: `/enmanuel/`. Colecciones:
|
||||
- CardDAV: `/enmanuel/contacts/addressbook/`
|
||||
- CalDAV: `/enmanuel/calendars/calendar/`
|
||||
|
||||
## Ejemplo canonico end-to-end
|
||||
|
||||
Importar un `.vcf` exportado de Google a Xandikos, leyendo la password de `pass`:
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
|
||||
|
||||
BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||
|
||||
summary = import_vcf_to_carddav(
|
||||
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
|
||||
base_url=BASE,
|
||||
username="enmanuel",
|
||||
password=pw,
|
||||
collection_path="/enmanuel/contacts/addressbook/",
|
||||
)
|
||||
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
|
||||
```
|
||||
|
||||
Verificar el resultado listando la coleccion:
|
||||
|
||||
```python
|
||||
from infra.dav_list_resources import dav_list_resources
|
||||
res = dav_list_resources(BASE, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
|
||||
print(res["status"], len(res["resources"])) # ok 820
|
||||
```
|
||||
|
||||
El calendario es analogo con `import_ics_to_caldav` + `/enmanuel/calendars/calendar/`.
|
||||
|
||||
Desde la CLI del registry (resuelve la pass como variable, no la pongas en claro):
|
||||
|
||||
```bash
|
||||
PW=$(pass show dav/xandikos-enmanuel | head -n1)
|
||||
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
|
||||
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
|
||||
enmanuel "$PW" /enmanuel/contacts/addressbook/
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No descubre el principal ni las colecciones**: hay que conocer los paths
|
||||
(`/enmanuel/contacts/addressbook/`, etc.). No implementa `current-user-principal`
|
||||
ni `addressbook-home-set` discovery.
|
||||
- **No hace sync incremental** real: `dav_list_resources` devuelve etags pero no
|
||||
hay logica de diff/merge. Re-importar es idempotente por UID (sobrescribe), no
|
||||
incremental.
|
||||
- **No parsea campos VCARD/VEVENT**: trata cada componente como texto opaco. Para
|
||||
transformar contenido (renombrar, deduplicar por nombre) usa otra herramienta.
|
||||
- **Solo VEVENT** en calendario: VTODO/VJOURNAL se ignoran al partir el `.ics`.
|
||||
- **Escrituras irreversibles**: los PUT sobrescriben en el servidor. Idempotente
|
||||
por UID pero no hay confirmacion previa; valida el `.vcf`/`.ics` antes de importar.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `pass` configurado con la entrada `dav/xandikos-enmanuel`.
|
||||
- Conectividad TLS al endpoint publico (`verify_tls=True`).
|
||||
- Python del registry: `python/.venv/bin/python3`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Capability: duckdb
|
||||
|
||||
Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).
|
||||
|
||||
Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
|
||||
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
|
||||
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
|
||||
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
|
||||
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
|
||||
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
|
||||
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
|
||||
| `duckdb_list_tables_py_infra` | `duckdb_list_tables(db_path) -> dict` | Introspección read-only: lista las tablas (`information_schema.tables`, schema main) ordenadas. Devuelve `{status, tables}`. |
|
||||
| `duckdb_table_schema_py_infra` | `duckdb_table_schema(db_path, table) -> dict` | Introspección read-only: schema de una tabla (`DESCRIBE`). Devuelve `{status, table, columns:[{name,type}]}`. Útil para mapear tipos a otro motor (p.ej. PostgreSQL). |
|
||||
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | **Puente de entrada Excel→DuckDB**: ingiere una hoja `.xlsx` a una tabla con la extensión nativa `excel` de DuckDB. `replace`/`append`. Devuelve `{status, table, row_count}`. |
|
||||
| `duckdb_to_postgres_py_pipelines` | `duckdb_to_postgres(duckdb_path, table, pg_dsn, pg_table=None, mode='replace', key_cols=None, batch_size=5000) -> dict` | **Puente de salida DuckDB→Postgres**: mapea tipos, crea la tabla y sincroniza filas. Desbloquea que Metabase/Grafana/Superset (que no hablan DuckDB) lean los datos. Devuelve `{status, pg_table, rows_synced, created}`. |
|
||||
|
||||
## Puentes: Excel → DuckDB → Postgres → visualización
|
||||
|
||||
DuckDB es el centro del stack de datos: el motor analítico embebido. Los datos entran desde Excel y salen hacia BI:
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import excel_to_duckdb, duckdb_list_tables, duckdb_query_readonly
|
||||
from pipelines.duckdb_to_postgres import duckdb_to_postgres
|
||||
|
||||
# 1. Excel -> DuckDB (extensión nativa, sin pandas)
|
||||
excel_to_duckdb("/tmp/ventas.xlsx", "/tmp/datos.duckdb", "ventas", sheet="ventas")
|
||||
print(duckdb_list_tables("/tmp/datos.duckdb"))
|
||||
|
||||
# 2. Analítica en DuckDB
|
||||
print(duckdb_query_readonly("/tmp/datos.duckdb",
|
||||
"SELECT categoria, SUM(importe) AS total FROM ventas GROUP BY 1")["rows"])
|
||||
|
||||
# 3. DuckDB -> Postgres (para que Metabase/Grafana lo lean)
|
||||
# dsn = "postgresql://captacion:<pass>@localhost:5433/trends"
|
||||
# duckdb_to_postgres("/tmp/datos.duckdb", "ventas", dsn, pg_table="ventas", mode="replace")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
- **Evidence.dev** lee el `.duckdb` directamente (nativo) — no necesita el puente a Postgres.
|
||||
- **Metabase / Grafana / Superset** no hablan DuckDB → usa `duckdb_to_postgres` y apunta la herramienta al Postgres espejo.
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import duckdb_query_readonly
|
||||
|
||||
res = duckdb_query_readonly(
|
||||
"projects/osint/apps/osint_db/data/osint.duckdb",
|
||||
"SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
|
||||
max_rows=50,
|
||||
)
|
||||
print(res["status"], res["row_count"])
|
||||
for row in res["rows"]:
|
||||
print(row)
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Conversion CSV -> Parquet en una linea:
|
||||
|
||||
```bash
|
||||
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
|
||||
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
|
||||
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
|
||||
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
|
||||
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Capability: excel
|
||||
|
||||
CRUD de hojas de cálculo Excel (`.xlsx`) desde el registry con openpyxl: escribir libros multi-hoja, actualizar una hoja sin destruir las demás (preservando columnas editadas a mano), leer a estructuras en memoria o a markdown, añadir gráficos nativos, e ingerir una hoja a DuckDB.
|
||||
|
||||
Es el extremo Excel del **stack de datos** `Excel → DuckDB → Postgres → visualización`: el Excel sirve como entrada (lo que produce un humano o un export) y como entregable (un libro con gráficos que viaja por email/disco, sin servidor). El round-trip humano lo cubre `upsert_xlsx_sheet`, que conserva las columnas que las personas rellenan a mano mientras regenera las columnas calculadas.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `write_xlsx_sheets_py_infra` | `write_xlsx_sheets(out_path, sheets, header_bold=True, autofit=True, freeze_header=True) -> str` | Escribe (o sobrescribe) un libro `.xlsx` multi-hoja desde un dict `{nombre_hoja: datos}`. Cada hoja acepta `list[list]` (primera fila = headers) o `{"headers": [...], "rows": [[...]]}`. Cabecera en negrita, auto-ancho, freeze de cabecera. Devuelve la ruta absoluta. |
|
||||
| `upsert_xlsx_sheet_py_infra` | `upsert_xlsx_sheet(xlsx_path, sheet_name, records, columns, key_col="", preserve_cols=None, formulas=None, backup=True, ...) -> dict` | Actualiza NO destructivamente UNA hoja: reescribe solo `sheet_name` y conserva las demás. Antes de limpiar, lee por `key_col` las columnas de trabajo manual (`preserve_cols`) y las reescribe ganando sobre los datos nuevos. Cabecera estilizada, freeze, autofilter, fórmulas por columna, backup `.bak`. |
|
||||
| `read_xlsx_py_infra` | `read_xlsx(path, sheet=None, max_rows=None, header=True) -> dict` | Lee un `.xlsx` a memoria (NO a markdown). Devuelve `{status, sheets: {nombre: {headers, rows}}}`. `sheet=None` lee todas. Tipos de celda: fechas→ISO, int/float, bool, None, fórmulas (valor calculado, `data_only=True`). Espejo en lectura de `write_xlsx_sheets`. |
|
||||
| `excel_to_markdown_py_core` | `excel_to_markdown(path, max_rows_per_sheet=1000) -> str` | Convierte `.xlsx/.xls/.xlsm` a markdown, cada hoja como sección H2. Para inspección rápida / pegar en un prompt o nota. |
|
||||
| `add_xlsx_chart_py_infra` | `add_xlsx_chart(xlsx_path, sheet_name, chart_type, data_range, cats_range=None, anchor='H2', title='', x_title='', y_title='') -> dict` | Añade un gráfico nativo (`bar`/`line`/`pie`/`scatter`) a una hoja EXISTENTE, refiriendo rangos de celdas ya escritos (notación Excel `'C1:C7'`). `anchor` = celda destino. La pieza para generar hojas Excel CON gráficos. |
|
||||
| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | Ingesta una hoja del `.xlsx` a una tabla DuckDB con la extensión nativa `excel` de DuckDB. Puente Excel→DuckDB. También etiquetada en el grupo `duckdb`. |
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
Escribir un libro, añadirle un gráfico y releerlo a memoria (verificado):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import write_xlsx_sheets, add_xlsx_chart, read_xlsx
|
||||
|
||||
xlsx = "/tmp/ventas.xlsx"
|
||||
write_xlsx_sheets(xlsx, {"ventas": [
|
||||
["mes", "categoria", "importe"],
|
||||
["2026-01", "neumaticos", 12500.50],
|
||||
["2026-02", "neumaticos", 15800.75],
|
||||
["2026-03", "neumaticos", 18200.00],
|
||||
]})
|
||||
|
||||
# Gráfico de barras del importe por mes, anclado en la celda G2
|
||||
add_xlsx_chart(xlsx, "ventas", "bar", data_range="C1:C4", cats_range="A2:A4",
|
||||
anchor="G2", title="Importe por mes", y_title="EUR")
|
||||
|
||||
rd = read_xlsx(xlsx, sheet="ventas")
|
||||
print(rd["sheets"]["ventas"]["headers"], len(rd["sheets"]["ventas"]["rows"]))
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **openpyxl no evalúa fórmulas.** `read_xlsx` con `data_only=True` devuelve el valor **cacheado** por la última app que guardó el libro (Excel/LibreOffice). Un `.xlsx` con fórmulas escritas por openpyxl y nunca abierto en una hoja de cálculo devuelve `None` en esas celdas.
|
||||
- **`add_xlsx_chart` exige libro y hoja existentes:** no crea el `.xlsx` ni escribe datos; los rangos deben apuntar a celdas ya escritas. Flujo: `write_xlsx_sheets` → `add_xlsx_chart`.
|
||||
- **Rangos 1-indexed, notación Excel** (`'C1:C7'`). Si `data_range` incluye la fila de cabecera, el nombre de la serie sale de esa celda (`titles_from_data`). `scatter` usa `data_range` como Y y `cats_range` como X; `pie` ignora los títulos de eje.
|
||||
- **Carga en memoria:** openpyxl carga el libro entero; para libros muy grandes considera ingerir a DuckDB (`excel_to_duckdb`) y consultar allí.
|
||||
- **`upsert_xlsx_sheet` es la vía para datos editados por humanos:** si una persona rellena columnas a mano, pásalas en `preserve_cols` para que un re-volcado no las pise.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO es una herramienta de BI ni de dashboards. Para visualización interactiva/compartida: Metabase, Evidence (sobre DuckDB) o gráficos embebidos con `add_xlsx_chart` para el caso "todo en el .xlsx".
|
||||
- El análisis pesado (agregaciones, joins, histórico) NO se hace en Excel: ingiere a DuckDB con `excel_to_duckdb` y usa el grupo `duckdb`.
|
||||
- NO cubre `.csv` de entrada con encodings legacy — eso es `safe_read_csv_fallback_py_core`.
|
||||
|
||||
## Relación con otros grupos
|
||||
|
||||
- `duckdb` — `excel_to_duckdb` es el puente de entrada; el motor analítico vive allí.
|
||||
- `postgres` — la salida hacia BI pasa por `duckdb_to_postgres` (grupo `duckdb`/`postgres`).
|
||||
- `metabase` — consume los datos una vez en Postgres.
|
||||
@@ -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,54 @@
|
||||
# market-intel
|
||||
|
||||
Inteligencia de mercado para captación de clientes: scrapers de señales de demanda y
|
||||
tendencias de productos/nichos desde varias fuentes públicas, más vigilancia de precios de
|
||||
la competencia, aterrizados en Postgres y analizados con Metabase. Scheduling con
|
||||
`dag_engine`. Origen: proyecto `captacion_clientes`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `scrape_amazon_bestsellers_py_datascience` | `(marketplace, categories, list_type, max_items)` | Amazon Best Sellers + Movers & Shakers (ranking real de demanda). HTTP, funciona. |
|
||||
| `scrape_google_trends_py_datascience` | `(keywords, geo, timeframe, include_related)` | Interés de búsqueda (0-100) + rising/top via pytrends. Backoff ante 429. |
|
||||
| `scrape_tiktok_creative_py_datascience` | `(country, kind, limit, period)` | TikTok Creative Center (hashtags/songs/creators). **Bloqueado por anti-bot vía HTTP**; pendiente browser CDP. |
|
||||
| `scrape_aliexpress_trending_py_datascience` | `(query, category, limit, ship_to)` | Productos populares AliExpress (orders/rating). **Bloqueado por captcha vía HTTP**; pendiente browser CDP. |
|
||||
| `scrape_competitor_prices_py_datascience` | `(targets) -> list[dict]` | Precio actual de una lista de URLs de competidores (cascada: selector → JSON-LD → meta → heurística). |
|
||||
| `pg_insert_rows_py_infra` | `(dsn, table, rows, add_snapshot_date=True)` | Insert append-only por lote en Postgres (execute_values parametrizado, añade snapshot_date). |
|
||||
| `pg_apply_sql_py_infra` | `(dsn, sql_path) -> int` | Aplica un `.sql` de migración a Postgres (idempotente con IF NOT EXISTS). |
|
||||
| `ingest_market_trends_py_pipelines` | `(source)` | Dispatcher: scrapea una fuente y la aterriza en su tabla. Lo invoca `dag_engine`. |
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# 1. (una vez) Stack Metabase + Postgres en Docker
|
||||
fn run init_metabase_go_infra --project captacion --metabase-port 3030 --pg-port 5433 \
|
||||
--pg-user captacion --pg-password "$(pass show captacion/postgres | head -1)"
|
||||
docker exec captacion-postgres psql -U captacion -d metabase -c "CREATE DATABASE trends OWNER captacion"
|
||||
|
||||
# 2. (una vez) Aplicar el schema
|
||||
python3 -c "import sys; sys.path.insert(0,'python/functions'); from infra import pg_apply_sql; \
|
||||
pg_apply_sql('postgresql://captacion:PW@localhost:5433/trends', 'projects/captacion_clientes/db/migrations/001_schema.sql')"
|
||||
|
||||
# 3. Ingesta una fuente (manual o vía dag_engine)
|
||||
fn run ingest_market_trends_py_pipelines amazon
|
||||
fn run ingest_market_trends_py_pipelines google_trends
|
||||
|
||||
# 4. dag_engine lo hace solo: dags market-intel-daily (06:30) y competitor-prices-hourly
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO hace explotación ni bypass agresivo de anti-bot: TikTok/AliExpress por HTTP-directo
|
||||
caen desde datacenter; la vía robusta es el browser MCP/CDP (grupo `navegator`/`web-proxy`,
|
||||
doctrina `flow_replay.md`), aún no implementada para estas dos fuentes.
|
||||
- NO es un grupo de visualización: el análisis vive en Metabase (grupo `metabase`).
|
||||
- NO gestiona el scheduling: eso es `dag_engine` (grupo `scheduler`).
|
||||
- El DSN de Postgres y credenciales NO se hardcodean: van en `pass`/`.env` del proyecto.
|
||||
|
||||
## Notas
|
||||
|
||||
- Las tablas de `trends` son append-only particionadas por `snapshot_date` — pensadas para
|
||||
series temporales en Metabase (qué tendencia sube/baja). No correr en bucle apretado.
|
||||
- `competitor_prices` se nutre de la tabla `competitor_targets` (el usuario inserta los
|
||||
objetivos a vigilar: competidor + product_key + URL).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Capability: obsidian
|
||||
|
||||
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. El nucleo del grupo manipula los archivos `.md` directamente en disco (no necesita la app GUI). Un sub-conjunto aparte gestiona la **lista de vaults que la app de escritorio Obsidian conoce** (su config `~/.config/obsidian/obsidian.json` + el URI scheme `obsidian://`): `register_*`, `list_registered_*`, `unregister_*`, `open_obsidian_vault`. 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. |
|
||||
| `register_obsidian_vault_py_obsidian` | `register_obsidian_vault(vault_path, open=False, config_path="") -> dict` | Da de alta un vault en la **app** Obsidian (entrada en `~/.config/obsidian/obsidian.json`). Idempotente por path, backup `.bak`, preserva el resto del JSON. NO toca el filesystem del vault. |
|
||||
| `list_registered_obsidian_vaults_py_obsidian` | `list_registered_obsidian_vaults(config_path="") -> list` | Lista los vaults que la **app** Obsidian conoce (de `obsidian.json`), ordenados por path. `[{id, path, open, ts}]`. Distinto de `list_obsidian_vaults` (que escanea el filesystem). |
|
||||
| `unregister_obsidian_vault_py_obsidian` | `unregister_obsidian_vault(vault_ref, config_path="") -> dict` | Quita un vault de la lista de la **app** Obsidian (por id o por path). NO borra la carpeta del vault. Backup `.bak`, preserva el resto del JSON. |
|
||||
| `open_obsidian_vault_py_obsidian` | `open_obsidian_vault(vault, register_if_missing=True, launch=True, config_path="") -> dict` | Abre un vault en la **app** Obsidian via `obsidian://open?vault=<name>` (lanza `xdg-open`). Registra el vault antes si falta. `launch=False` solo construye el URI. |
|
||||
| `slugify_obsidian_name_py_obsidian` | `slugify_obsidian_name(name: str) -> str` | **Pure.** Nombre/titulo -> slug kebab-case estable (translitera acentos, ñ->n). Estabiliza ids de nodo y nombres de archivo. |
|
||||
| `extract_obsidian_embeds_py_obsidian` | `extract_obsidian_embeds(body: str) -> list` | **Pure.** Solo los embeds `![[...]]` (attachments incrustados), ignorando wikilinks normales. Dedup preservando orden. |
|
||||
| `resolve_obsidian_embed_py_obsidian` | `resolve_obsidian_embed(vault_dir, embed_name) -> str` | Resuelve un embed `![[foto.jpg]]` a su path absoluto real (busca por basename unico en el vault). Cadena vacia si no existe. |
|
||||
| `build_obsidian_graph_py_obsidian` | `build_obsidian_graph(vault_dir, include_dangling=True) -> {"nodes":[...], "edges":[...]}` | **Grafo agregado** del vault: cada nota = nodo tipado (`id`=slug, `label`, `tipo`, `frontmatter`); cada wikilink `[[...]]` = arista con `kind` por seccion. Wikilinks rotos -> nodos fantasma `dangling`. |
|
||||
| `render_markdown_table_py_core` | `render_markdown_table(rows: list[dict], columns=None, max_rows=0) -> str` | **Pure** (vive en `core`). Lista de dicts -> tabla Markdown GFM. Escapa pipes, saltos de linea -> `<br>`, truncado opcional con pie `... N de M filas`. Base del render BD -> nota. |
|
||||
| `upsert_sentinel_block_py_core` | `upsert_sentinel_block(text, block_id, content, marker="osintdb") -> str` | **Pure** (vive en `core`). Inserta o reemplaza un bloque gestionado entre sentinels `<!-- marker:begin id=X -->` / `<!-- marker:end id=X -->` dentro del body de una nota. Idempotente; ValueError si el bloque esta corrupto. |
|
||||
|
||||
## 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)
|
||||
|
||||
- **El CRUD de notas no habla con la app GUI** (no abre notas en la interfaz ni dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente. La unica interaccion con la app es la **gestion de su lista de vaults** (`register_*`/`unregister_*`/`list_registered_*` sobre `obsidian.json`) y `open_obsidian_vault` (lanza el URI `obsidian://`); estas no editan notas ni renderizan nada.
|
||||
- **Single-instance gotcha**: Obsidian cachea su `obsidian.json` en memoria al arrancar. Registrar/desregistrar un vault con la app abierta no se reflejara hasta reiniciarla; `open_obsidian_vault` sobre un vault recien registrado puede dar "unable to find a vault" hasta el reinicio.
|
||||
- **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.
|
||||
- **El grafo agregado** del vault ya lo cubre `build_obsidian_graph_py_obsidian` (nodos tipados + aristas con `kind` + nodos fantasma `dangling`). Es la base de la vista grafo (sigma.js) de la app `osint_web`. Lo que sigue fuera del grupo es el *layout* visual del grafo (force-directed) — eso vive en el frontend.
|
||||
|
||||
## 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,79 @@
|
||||
# Capability group: onlyoffice
|
||||
|
||||
Operar **ONLYOFFICE Desktop Editors** (binario `/usr/bin/onlyoffice-desktopeditors`) en Linux/X11 desde terminal, gestionando la **ventana** de los archivos sin perturbar la instancia personal del usuario.
|
||||
|
||||
Este grupo NO es el ONLYOFFICE **Document Server** (web/Docker) — para eso ver `start_documentserver_bash_infra`, `documentserver_health_go_infra`, `onlyoffice_command_service_go_infra` y compañia. Este grupo es el editor de **escritorio**.
|
||||
|
||||
## Convencion de instancia aislada (slot)
|
||||
|
||||
ONLYOFFICE Desktop es **single-instance por usuario**: un segundo `onlyoffice-desktopeditors <file>` se reenvia a la instancia viva y abre el archivo como PESTAÑA en su ventana, no como ventana nueva. El lock single-instance NO se rompe con `XDG_CONFIG_HOME`, pero SI se rompe lanzando con `HOME` y `XDG_RUNTIME_DIR` propios.
|
||||
|
||||
Por eso las 3 funciones comparten un "slot" nombrado por `instance` (string, default `demo`):
|
||||
|
||||
```
|
||||
HOME=/tmp/oo_<instance>
|
||||
XDG_RUNTIME_DIR=/tmp/oo_<instance>_run (mkdir -p + chmod 700)
|
||||
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config
|
||||
```
|
||||
|
||||
Lanzamiento canonico (identico en open y reload):
|
||||
|
||||
```bash
|
||||
env HOME=/tmp/oo_<instance> XDG_RUNTIME_DIR=/tmp/oo_<instance>_run \
|
||||
XDG_CONFIG_HOME=/tmp/oo_<instance>/.config \
|
||||
setsid onlyoffice-desktopeditors <file> >/tmp/oo_<instance>.log 2>&1 </dev/null &
|
||||
```
|
||||
|
||||
Usar el MISMO `instance` en todas las operaciones del slot: asi el relaunch reenvia a la instancia aislada viva y reabre rapido en vez de arrancar el motor de cero.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace |
|
||||
|---|---|---|
|
||||
| `open_onlyoffice_file_bash_shell` | `open_onlyoffice_file <file> [instance]` | Abre un archivo existente en el slot aislado; espera la ventana por basename (~25s); JSON con wid/status. Idempotente, NO crea archivos. |
|
||||
| `reload_onlyoffice_file_bash_shell` | `reload_onlyoffice_file <file> [instance]` | **Funcion estrella**: cierra (wmctrl -ic) y reabre el archivo en el slot para mostrar datos editados EN DISCO (ONLYOFFICE no tiene reload nativo, Issue #2313). JSON con wid_old/wid_new/elapsed_s/status. NO edita el archivo. |
|
||||
| `close_onlyoffice_instance_bash_shell` | `close_onlyoffice_instance [instance] [--purge]` | Mata los procesos DesktopEditors del slot (por HOME=/tmp/oo_<instance> en /proc), SIGTERM->SIGKILL; con --purge borra /tmp/oo_<instance>*. JSON con killed_pids/status. |
|
||||
|
||||
## Ejemplo canonico (end-to-end)
|
||||
|
||||
Flujo completo "abrir -> editar el archivo en disco -> recargar la vista -> cerrar", todo sobre un slot aislado `demo` que no toca la instancia personal del usuario:
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
|
||||
# 0. El caller prepara el archivo (esta funcion NO crea archivos)
|
||||
printf 'a,b\n1,2\n' > /tmp/demo_reload.csv
|
||||
|
||||
# 1. Abrir en el slot aislado 'demo' -> ventana propia
|
||||
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid":"0x3c00007","pid":12345,"status":"open"}
|
||||
|
||||
# 2. El caller edita el archivo EN DISCO (script, generador, otra herramienta)
|
||||
printf 'a,b\n1,2\n3,4\n5,6\n' > /tmp/demo_reload.csv
|
||||
|
||||
# 3. Recargar la ventana para que muestre los datos nuevos (cierra+reabre)
|
||||
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.csv demo
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.csv","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
|
||||
|
||||
# 4. Cerrar la instancia aislada y limpiar su estado
|
||||
./fn run close_onlyoffice_instance_bash_shell demo --purge
|
||||
# {"instance":"demo","killed_pids":[12345],"purged":true,"status":"closed"}
|
||||
```
|
||||
|
||||
## Fronteras (que NO hace el grupo)
|
||||
|
||||
- **NO edita ni crea archivos**. Solo gestiona la VENTANA (abrir, cerrar+reabrir, matar proceso). El contenido lo prepara y modifica el caller en disco.
|
||||
- **NO es el Document Server** (web/Docker/JWT/Command Service). Eso es otro conjunto de funciones (`*documentserver*`, `*onlyoffice_jwt*`, `onlyoffice_command_service_*`).
|
||||
- **NO recarga in-place**: ONLYOFFICE Desktop no soporta reload de cambios externos (Issue #2313 abierto). `reload_onlyoffice_file` lo emula con cerrar+reabrir; no hay alternativa "sin parpadeo".
|
||||
- **NO toca la instancia personal del usuario**: todo opera sobre el slot aislado (HOME=/tmp/oo_<instance>). `close` solo mata procesos cuyo HOME es del slot.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Linux con **X11** (o XWayland). En Wayland puro sin XWayland, `xdotool`/`wmctrl` no encuentran la ventana.
|
||||
- Binarios en PATH: `onlyoffice-desktopeditors`, `wmctrl`, `xdotool`. Cada funcion comprueba `command -v` y falla con exit !=0 si falta alguno.
|
||||
|
||||
## Notas
|
||||
|
||||
- Las esperas son **por evento** (`xdotool search` + `read -t`), nunca `sleep` en foreground, para no colgar bajo `fn run` ni tests.
|
||||
- El slot vive en `/tmp` y se pierde al reiniciar el PC (estado desechable). `--purge` lo borra explicitamente.
|
||||
- `wmctrl -ic` puede disparar el dialogo "Guardar cambios" SOLO si se edito dentro de la app con cambios sin guardar; el flujo previsto edita en disco, asi que la ventana no tiene estado pendiente.
|
||||
@@ -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,61 @@
|
||||
# Capability: postgres
|
||||
|
||||
CRUD de PostgreSQL desde el registry. Las funciones Python (psycopg2) reciben un `dsn: str`, son impuras y devuelven un dict `{status:'ok'|'error', ...}` sin lanzar (mismo estilo que el grupo `duckdb`); la función Go (`postgres_open`) abre un `*sql.DB` desde parámetros individuales.
|
||||
|
||||
Postgres es la **capa que sirve datos a las herramientas de BI** del stack (`Excel → DuckDB → Postgres → visualización`). Metabase, Grafana y Superset NO hablan DuckDB de forma nativa, pero todas hablan PostgreSQL: por eso el motor analítico de trabajo es DuckDB y, cuando un dashboard tiene que consumir esos datos, se sincronizan a Postgres con `duckdb_to_postgres` (grupo `duckdb`).
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `postgres_open_go_infra` | `PostgresOpen(host, port, user, password, dbname, sslmode) (*sql.DB, error)` | Conecta a PostgreSQL desde Go construyendo el DSN. `sslmode` por defecto `disable`. |
|
||||
| `pg_query_py_infra` | `pg_query(dsn, sql, params=None, max_rows=10000) -> dict` | SELECT read-only (`SET TRANSACTION READ ONLY`) con `RealDictCursor`. Devuelve `{status, columns, rows, row_count, truncated}`. Normaliza tipos no JSON (date/datetime→ISO, Decimal→float, bytes→base64, UUID→str). Espejo de `duckdb_query_readonly`. Valores por `%s`. |
|
||||
| `pg_insert_rows_py_infra` | `pg_insert_rows(dsn, table, rows, add_snapshot_date=True) -> int` | INSERT append-only en lote (`execute_values`). Deriva columnas de las claves. Opcional `snapshot_date = date.today()`. Retorna nº de filas. |
|
||||
| `pg_upsert_py_infra` | `pg_upsert(dsn, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET col=EXCLUDED.col`. `update_cols` = ownership selectivo (las no listadas conservan su valor); `[]` = DO NOTHING. Devuelve `{status, inserted, updated}`. `key_cols` deben tener PK/UNIQUE. Espejo de `duckdb_upsert`. |
|
||||
| `pg_create_table_from_rows_py_infra` | `pg_create_table_from_rows(dsn, table, rows, primary_key=None) -> dict` | `CREATE TABLE IF NOT EXISTS` infiriendo columnas y tipos desde los valores (bool→BOOLEAN, int→BIGINT, float→DOUBLE PRECISION, datetime→TIMESTAMP, date→DATE, resto→TEXT). Idempotente. Devuelve `{status, created, table, columns}`. |
|
||||
| `pg_list_tables_py_infra` | `pg_list_tables(dsn, schema='public') -> dict` | Introspección read-only: tablas base con sus columnas vía `information_schema`. Devuelve `{status, schema, tables:[{name, columns:[{name,type,nullable}]}]}`. |
|
||||
| `pg_apply_sql_py_infra` | `pg_apply_sql(dsn, sql_path) -> int` | Ejecuta un archivo `.sql` completo (multi-statement, una transacción). Para migraciones idempotentes (`IF NOT EXISTS`). |
|
||||
|
||||
Relacionadas (otros grupos): `duckdb_to_postgres_py_pipelines` (sincroniza una tabla DuckDB a Postgres) e `init_metabase_go_infra` (despliega el stack Metabase + Postgres en Docker).
|
||||
|
||||
## Ejemplo canónico
|
||||
|
||||
Crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
DSN="postgresql://captacion:$(pass captacion/postgres | head -1)@localhost:5433/trends"
|
||||
python/.venv/bin/python3 - "$DSN" <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import pg_create_table_from_rows, pg_upsert, pg_query
|
||||
|
||||
dsn = sys.argv[1]
|
||||
rows = [{"mes": "2026-01", "total": 12500.5}, {"mes": "2026-02", "total": 15800.75}]
|
||||
|
||||
pg_create_table_from_rows(dsn, "demo_kpi", rows, primary_key=["mes"])
|
||||
print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # inserted/updated
|
||||
print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # idempotente: 0 inserts
|
||||
print(pg_query(dsn, "SELECT * FROM demo_kpi ORDER BY mes")["rows"])
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`). No imprimas el DSN en logs.
|
||||
- **`pg_query`/`pg_list_tables` son read-only por convención** (`SET TRANSACTION READ ONLY` + rollback), protegen la base pero NO son sandbox; los identificadores (tabla/schema) NO se parametrizan — los valores sí (`%s`). Las funciones validan identificadores con `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||
- **`pg_upsert` cuenta insert vs update con el pseudo-columna `xmax`** (`RETURNING (xmax = 0)`). Fiable en el caso normal (single-writer, sin triggers raros). Con `update_cols=[]` (DO NOTHING) las filas en conflicto no se devuelven, así que solo se cuentan las nuevas. BEFORE-triggers / REPLICA IDENTITY pueden desviar el conteo.
|
||||
- **`pg_create_table_from_rows` no reconcilia schema:** si la tabla ya existe, `columns` reporta los tipos inferidos de las filas, no los reales. Inferencia best-effort sin NUMERIC/escala — para dinero define el schema a mano con `pg_apply_sql`.
|
||||
- **`pg_insert_rows` y `pg_apply_sql` lanzan en error** (no devuelven dict); envuélvelas si compones.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO es el motor analítico del stack — ese es DuckDB (columnar, lee CSV/Parquet/Excel nativo). Postgres es el destino para BI.
|
||||
- NO dibuja dashboards: eso es Metabase / Grafana / Evidence leyendo de Postgres.
|
||||
- NO cubre PostGIS más allá de `osm2pgsql_ingest_py_infra` (geo, aparte).
|
||||
|
||||
## Relación con otros grupos
|
||||
|
||||
- `duckdb` — `duckdb_to_postgres` es el puente de entrada de datos a esta capa.
|
||||
- `metabase` — registra la base con `metabase_add_database(engine='postgres', ...)` y consume las tablas.
|
||||
- `excel` — el origen de los datos suele ser un `.xlsx` ingerido por `excel_to_duckdb`.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Capability: recon
|
||||
|
||||
Reconocimiento de red para OSINT desde el registry: lookups de registro (WHOIS/RDAP), DNS, sondeo de disponibilidad y ruta (ping/traceroute), escaneo de puertos y servicios, y fingerprint de la tecnologia web de un sitio (estilo Wappalyzer). El escaneo de puertos tiene dos caminos: el wrapper pesado de `nmap` (perfiles, scripts NSE, versiones), y un **camino nativo en Python puro** (`scan_tcp_ports` + `grab_service_banner` + `identify_port_service`, solo stdlib, sin nmap ni sudo) para escaneo rapido y portable. El fingerprint web sigue el mismo patron pura/impura: `fetch_http_fingerprint` recoge las señales (headers, html, cookies) y `detect_web_tech` (pura) matchea firmas para identificar servidor, CMS, frameworks JS, analytics y CDN. La mayoria de funciones son Python impuras, wrappean CLIs del sistema (`whois`, `rdap`, `dig`, `ping`, `traceroute`, `nmap`) o usan sockets/urllib stdlib, y devuelven siempre un dict `{status: ok|error}` sin lanzar excepciones. El grupo cierra el bucle con un **sink comun** que archiva cada escaneo en el ecosistema OSINT (nota Obsidian + registro DuckDB) y pipelines one-shot que escanean y guardan en una sola llamada.
|
||||
|
||||
Comparte tag y dominio (`cybersecurity`) con el grupo `osint-passive` (recoleccion no intrusiva desde fuentes publicas), del que reutiliza primitivas. La regla de operacion es la misma del project `osint`: **todo escaneo se archiva en OSINT**.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `whois_lookup_py_cybersecurity` | `whois_lookup(target, timeout_s=30) -> dict` | Lookup WHOIS via el CLI `whois`. Captura el `raw` completo y parsea best-effort registrar, registrant_country, creation_date, expiry_date, updated_date, name_servers. Acepta dominio o IP. |
|
||||
| `rdap_lookup_py_cybersecurity` | `rdap_lookup(target, timeout_s=30) -> dict` | Lookup RDAP (reemplazo JSON moderno de WHOIS) via el CLI openrdap `rdap`. Devuelve `data` (dict JSON), `handle`, `ldhName` y el `raw`. Acepta dominio, IP o ASN (`AS15169`). |
|
||||
| `dns_records_py_cybersecurity` | `dns_records(domain, record_types=None, timeout_s=20) -> dict` | Registros DNS via `dig +short` (default A, AAAA, MX, NS, SOA, TXT, CNAME). Devuelve `records` (dict por tipo) y `raw` legible por bloque para el vault. |
|
||||
| `ping_host_py_cybersecurity` | `ping_host(host, count=4, timeout_s=30) -> dict` | Sondeo ICMP via `ping`. Devuelve `loss_pct`, `rtt_avg_ms` (y min/max), `packets_sent`/`recv`, `raw`. Host filtrado = `status:ok` con `loss_pct=100`, no error. |
|
||||
| `traceroute_host_py_cybersecurity` | `traceroute_host(host, max_hops=30, timeout_s=60) -> dict` | Traza la ruta via `traceroute`. Devuelve `hops` (lista de `{hop, hosts:[{name, ip, rtt_ms}]}`) y `raw`. Hops filtrados (`* * *`) = `hosts: []`. |
|
||||
| `nmap_scan_py_cybersecurity` | `nmap_scan(target, profile="quick", ports=None, extra_args=None, out_dir=None, timeout_s=1800) -> dict` | Escaneo de puertos/servicios via `nmap` por perfiles (salida XML parseada). Devuelve `open_ports`, `hosts_up`, `xml_path`, `raw`, `elapsed_s`. Funcion estrella del grupo. |
|
||||
| `scan_tcp_ports_py_cybersecurity` | `scan_tcp_ports(host, ports="common", timeout_s=1.0, workers=100) -> dict` | **Connect-scan TCP nativo (stdlib, sin nmap ni sudo).** Escanea puertos en paralelo con threads y clasifica cada uno en open/closed/filtered. `ports` acepta lista, preset `"common"`, rango `"1-1024"` o CSV. Devuelve `open` (lista de ints), `ip`, `raw`. NO detecta version de servicio. |
|
||||
| `grab_service_banner_py_cybersecurity` | `grab_service_banner(host, port, timeout_s=3.0, send_probe=True) -> dict` | **Banner grab nativo (stdlib, sin nmap -sV).** Abre socket TCP, lee el banner e identifica el servicio real (ssh, http, ftp, smtp, mysql, redis, pop3, imap, telnet...) extrayendo `product` y `version` best-effort. Dice QUE habla detras de un puerto abierto. TLS/HTTPS no da banner plano. |
|
||||
| `identify_port_service_py_cybersecurity` | `identify_port_service(port, proto="tcp") -> dict` | **Pure.** Mapea un puerto a su servicio IANA well-known esperado por convencion (`{service, description, known}`) desde una tabla embebida (~120 puertos). No sondea en vivo: dice que se ESPERA, no que hay. |
|
||||
| `save_scan_to_osint_py_cybersecurity` | `save_scan_to_osint(target, scan_type, raw, summary=None, vault_dir="~/Obsidian/osint", service_url="http://127.0.0.1:8771", tool=None) -> dict` | **Sink OSINT.** Archiva un scan: nota Markdown tipada en el vault (capa critica) + POST a `osint_db` para registro DuckDB (best-effort). Devuelve `note_path`, `registered`, `scan_id`. |
|
||||
| `recon_osint_py_pipelines` | `recon_osint(target, scan_type="whois", save=True, profile="quick", ...) -> dict` | **Pipeline one-shot.** Ejecuta un scan del tipo pedido y lo archiva en OSINT en una sola llamada (compone la funcion de scan + `save_scan_to_osint`). El camino canonico para recon + archivado. |
|
||||
| `scan_port_services_py_pipelines` | `scan_port_services(host, ports="common", timeout_s=1.0, workers=100, grab_banners=True, banner_timeout_s=3.0, save=True) -> dict` | **Pipeline one-shot nativo.** Escanea puertos y, por cada abierto, devuelve servicio esperado (IANA) + servicio/version real del banner. Compone `scan_tcp_ports` + `identify_port_service` + `grab_service_banner` (+ sink OSINT). Reemplaza el patron scan→identify→grab sin nmap. |
|
||||
| `fetch_http_fingerprint_py_cybersecurity` | `fetch_http_fingerprint(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, user_agent=None) -> dict` | **Fetch de señales web (stdlib).** GET con UA de navegador, sigue redirects, descomprime gzip. Devuelve `headers` (lowercase), `cookies` (solo NOMBRES, sin valores), `html`, `title`, `server`, `status_code`, `final_url`, `raw`. Capa impura del fingerprint web. |
|
||||
| `detect_web_tech_py_cybersecurity` | `detect_web_tech(headers, html="", cookies=None, final_url="") -> dict` | **Pure. Detector de tecnologia web estilo Wappalyzer.** Matchea ~50 firmas embebidas (regex) contra headers/html/cookies → `technologies[{name, category, version, confidence, evidence}]`, `by_category`, `count`. Cubre server, lenguaje, CMS, frameworks JS, librerias, analytics, CDN, e-commerce, WAF. |
|
||||
| `fetch_http_fingerprint_cdp_py_browser` | `fetch_http_fingerprint_cdp(url, *, port=9222, wait_render_s=2.0, timeout_s=30.0, close_tab=True) -> dict` | **Fetch del HTML RENDERIZADO (post-JS) via CDP.** Navega en un Chrome remoto (compone `cdp_open_url_and_wait` + `cdp_eval`), espera el render y devuelve el `html` con el DOM ya montado por JS → detecta SPAs (React/Vue/Angular/Next) que el fetch estatico no ve. Mismo shape que `fetch_http_fingerprint` (headers={}, status_code=None: la red la aporta el estatico). |
|
||||
| `fingerprint_web_stack_py_pipelines` | `fingerprint_web_stack(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, save=True, use_cdp=False, cdp_port=9222, wait_render_s=2.0) -> dict` | **Pipeline one-shot = Wappalyzer del registry.** url → tecnologias detectadas. Compone `fetch_http_fingerprint` + `detect_web_tech` (+ sink OSINT). Con `use_cdp=True` añade `fetch_http_fingerprint_cdp`: headers reales del estatico + HTML renderizado del CDP (detecta SPAs); degrada a estatico con warning si no hay Chrome. El camino canonico para fingerprint web. |
|
||||
|
||||
### OSINT pasivo relacionado
|
||||
|
||||
Estas funciones llevan tambien el tag `recon` (y `osint-passive`): recoleccion no intrusiva desde fuentes publicas, sin tocar al objetivo. Utiles antes o junto al escaneo de red. Pagina madre completa: `docs/capabilities/osint-passive.md`.
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo="persona", extra_domains=None) -> list` | **Pure.** Genera dorks de buscador (frase exacta, `site:`, `filetype:`, leaks/pastebin) segun el tipo de target. Sin red. |
|
||||
| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, timeout_s=20.0) -> list` | Enumera subdominios desde Certificate Transparency (crt.sh). Dedup, ordenado, sin wildcards. |
|
||||
| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, timeout_s=8.0, sites=None) -> list` | Comprueba si un username existe en ~12 sitios publicos (estilo sherlock ligero) por codigo HTTP. |
|
||||
| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Genera candidatos de email comunes (nombre.apellido, inicial+apellido, ...). Sin red. |
|
||||
| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Orquestador: perfil pasivo de una organizacion componiendo whois + dns + subdominios crt.sh. |
|
||||
|
||||
## Ejemplo canonico end-to-end
|
||||
|
||||
**1. One-shot (preferido): escanear y archivar en una llamada.** El pipeline corre el scan y lo guarda en OSINT (nota + registro DuckDB) por ti.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
./fn run recon_osint ejemplo.com whois
|
||||
```
|
||||
|
||||
Equivalente desde Python (cuando necesitas el dict de resultado):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.recon_osint import recon_osint
|
||||
|
||||
res = recon_osint("ejemplo.com", scan_type="whois", save=True)
|
||||
print(res["status"], res.get("note_path"), res.get("registered"))
|
||||
PYEOF
|
||||
```
|
||||
|
||||
**2. Manual atomico + sink.** Cuando quieres controlar el scan (perfil, puertos, summary propio) y guardarlo aparte. La funcion de scan se importa, no se reescribe.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from cybersecurity import dns_records
|
||||
from cybersecurity.save_scan_to_osint import save_scan_to_osint
|
||||
|
||||
scan = dns_records("ejemplo.com") # 1. escanear
|
||||
if scan["status"] == "ok":
|
||||
saved = save_scan_to_osint( # 2. archivar en OSINT
|
||||
"ejemplo.com",
|
||||
"dns",
|
||||
scan["raw"],
|
||||
summary={"A": scan["records"].get("A"), "MX": scan["records"].get("MX")},
|
||||
tool="dig",
|
||||
)
|
||||
print(saved["note_path"], "registered:", saved["registered"])
|
||||
PYEOF
|
||||
```
|
||||
|
||||
**3. nmap largo en segundo plano.** Los perfiles pesados tardan de minutos a horas: lanzalos en background con `out_dir` (conserva el XML) y `timeout_s` alto, y archiva al terminar.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
# El pipeline one-shot tambien sirve para nmap; lanzar en background por la duracion:
|
||||
nohup ./fn run recon_osint scanme.nmap.org nmap --profile full-tcp --timeout-s 7200 \
|
||||
> /tmp/recon-fulltcp.log 2>&1 &
|
||||
```
|
||||
|
||||
> `scanme.nmap.org` es el host oficial de pruebas de nmap (legal escanear). Cualquier otro objetivo de terceros exige autorizacion.
|
||||
|
||||
**4. Escaneo nativo de servicios de puertos (sin nmap), one-shot.** Cuando no quieres depender de `nmap`/sudo o buscas un barrido rapido y portable: el pipeline `scan_port_services` escanea los puertos y, por cada abierto, dice el servicio esperado por convencion (IANA) y el servicio/version real leido del banner.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.scan_port_services import scan_port_services
|
||||
|
||||
res = scan_port_services("scanme.nmap.org", ports="common", save=True)
|
||||
print(res["status"], "abiertos:", res.get("open_ports"))
|
||||
for s in res.get("services", []):
|
||||
print(f" {s['port']}: esperado={s['expected_service']} real={s.get('actual_service')} version={s.get('version')}")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Las primitivas tambien sirven sueltas: `scan_tcp_ports(host, ports)` para solo el estado de los puertos, `grab_service_banner(host, port)` para identificar un servicio concreto, e `identify_port_service(port)` (pura) para el servicio esperado por convencion.
|
||||
|
||||
**5. Fingerprint de tecnologia web (Wappalyzer del registry), one-shot.** Identifica el stack de un sitio — servidor, lenguaje, CMS, frameworks JS, analytics, CDN — desde el HTML + cabeceras + cookies, sin ejecutar JS. El pipeline `fingerprint_web_stack` hace fetch + matching de firmas en una llamada.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||
|
||||
res = fingerprint_web_stack("https://example.com", save=True)
|
||||
print(res["status"], "->", res.get("count"), "tecnologias")
|
||||
for t in res.get("technologies", []):
|
||||
print(f" {t['name']} [{t['category']}] v={t['version']!r} ({t['confidence']})")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Las dos capas tambien sueltas: `fetch_http_fingerprint(url)` para inspeccionar cabeceras+html+cookies crudos de una URL, y `detect_web_tech(headers, html, cookies)` (pura) para matchear firmas sobre señales ya recogidas (testeable sin red).
|
||||
|
||||
**Modo CDP (SPAs): detectar mas eficientemente el HTML renderizado.** Un fetch estatico NO ejecuta JavaScript: una SPA (React/Vue/Angular/Next con HTML inicial casi vacio) monta su DOM en runtime y el estatico la pierde. Con `use_cdp=True` el pipeline usa `fetch_http_fingerprint_cdp` (Chrome remoto via CDP) para analizar el DOM ya renderizado, combinando los headers reales del estatico con el HTML post-JS.
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||
|
||||
# cdp_port=9333 = Chrome aislado del browser_mcp (recomendado para terceros); 9222 = navegador diario.
|
||||
res = fingerprint_web_stack("https://una-spa.com", use_cdp=True, cdp_port=9333, save=False)
|
||||
print(res["html_source"], "->", [t["name"] for t in res["technologies"]])
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Ganancia verificada en vivo: sobre una SPA cuyo marcador de framework solo aparece tras ejecutar JS, el estatico detecta solo `nginx`; con `use_cdp=True` detecta ademas `Next.js`, `React`, `Node.js`. Si no hay Chrome en `cdp_port`, degrada al fetch estatico con un `warning` (no falla).
|
||||
|
||||
## Integracion OSINT
|
||||
|
||||
Cada escaneo guardado acaba en **dos sitios**, y por eso `save_scan_to_osint` (y el pipeline `recon_osint`) son el cierre obligatorio del grupo:
|
||||
|
||||
1. **Nota Markdown en el vault** `~/Obsidian/osint` bajo
|
||||
`dominios/<slug>/recon/<scan_type>-<YYYYMMDD-HHMM>.md`. Frontmatter tipado
|
||||
(`tipo: scan-red`, `scan_tipo`, `target`, `slug`, `fecha`, `herramienta`,
|
||||
`tags: [scan-red, <scan_type>, recon]`) y el `raw` del scan en un bloque de
|
||||
codigo. Es la **capa critica**: si falla, el sink devuelve `status:error`.
|
||||
2. **Fila en la tabla DuckDB `network_scans`** (schema `main`) del service
|
||||
`osint_db`, via `POST http://127.0.0.1:8771/api/scan`. Columnas:
|
||||
`id, target, target_slug, scan_type, tool, scan_ts, note_path, summary(JSON),
|
||||
created_at`. Es la **capa best-effort**: si el service esta caido o no expone
|
||||
el endpoint, el sink degrada a solo-nota con `registered=False` +
|
||||
`register_warning`, sin romper. El re-ingest del vault NO borra esta tabla.
|
||||
|
||||
**REGLA: todo escaneo se guarda en OSINT.** No hay scans "sueltos". O usas el
|
||||
pipeline `recon_osint` (scan + archivado en 1 call), o llamas la funcion de scan
|
||||
atomica y a continuacion `save_scan_to_osint` con su `raw`. El slug del target se
|
||||
deriva con `re.sub(r"[^a-z0-9._-]+", "-", target.lower())`.
|
||||
|
||||
## Escaneos nmap utiles para segundo plano
|
||||
|
||||
Los perfiles pesados de `nmap_scan` deben lanzarse en background (`&` / `nohup` / `run_in_background`) por su duracion. Pasa `out_dir` para conservar el XML y sube `timeout_s`.
|
||||
|
||||
| Perfil | Flags nmap | Cuando usarlo | Duracion |
|
||||
|---|---|---|---|
|
||||
| `full-tcp` | `-p- -T4` | Mapear los 65535 puertos TCP (no solo el top 1000). Cuando buscas servicios en puertos no estandar. | Minutos a horas → background |
|
||||
| `vuln` | `-sV --script vuln -T4` | Correr los scripts NSE de vulnerabilidades sobre los servicios detectados. Fase posterior a un service scan. | Largo, ruidoso → background |
|
||||
| `udp-top` | `-sU --top-ports 100 -T4` | Descubrir servicios UDP (DNS, SNMP, NTP...). UDP es lento y suele requerir sudo. | Largo → background |
|
||||
| `service` | `-sV -sC -T4` | Deteccion de version + scripts default sobre puertos abiertos. A veces tolerable en primer plano. | Medio (puede ir a background) |
|
||||
| `aggressive` | `-A -T4` | OS + version + scripts + traceroute de golpe. Muy detectable; el `-O` interno puede pedir sudo. | Largo, ruidoso → background |
|
||||
|
||||
Perfiles ligeros que SI corren bien en primer plano: `quick` (`-T4 -F`, top 100), `top1000` (`-T4`), `discovery` (`-sn`, ping sweep de una subred → puebla `hosts_up`), `os` (`-O`, requiere sudo).
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- **CLIs instaladas** en el PATH: `whois` (`apt install whois`), `rdap` (openrdap, normalmente en `~/go/bin/rdap` — `go install github.com/openrdap/rdap/cmd/rdap@latest`), `dig` (`dnsutils`/`bind-utils`), `ping` (`iputils-ping`), `traceroute`, `nmap`. Si falta el binario, la funcion devuelve `status:error` con la instruccion de instalacion, nunca lanza.
|
||||
- **Privilegios**: los perfiles de nmap `os` (-O), `udp-top` (-sU) y parte de `aggressive` requieren sudo/root; sin privilegios nmap cae a connect-scan TCP y esos modos quedan incompletos (estas funciones no usan sudo).
|
||||
- **Service `osint_db` vivo** en `http://127.0.0.1:8771` para el registro estructurado en `network_scans`. Si esta caido, los scans siguen guardandose como nota (solo se pierde la fila DuckDB hasta el siguiente re-registro). Ver memoria `osint-duckdb-stack`.
|
||||
|
||||
## Fronteras (que NO cubre)
|
||||
|
||||
- **No es un framework de explotacion.** Es reconocimiento: identifica superficie (puertos, servicios, versiones, registro, ruta). No explota vulnerabilidades, no hace fuerza bruta de credenciales, no entrega payloads. Para eso, herramientas dedicadas fuera del registry.
|
||||
- **Solo hosts autorizados o propios.** Escanear infraestructura de terceros sin permiso explicito puede ser delito. `scanme.nmap.org` es el unico host de terceros legal por defecto (es el host oficial de pruebas de nmap).
|
||||
- **No evade deteccion.** No implementa tecnicas de evasion de IDS/WAF, fragmentacion, decoys ni timing de sigilo; `-T4` es ruidoso a proposito. Un objetivo que defienda activamente puede detectar y filtrar el escaneo.
|
||||
- **No cubre OSINT pasivo de personas** (dorks, usernames, emails) mas alla de listar las funciones afines: esas viven en el grupo `osint-passive`. El render BD→nota y el grafo del vault son de `obsidian`/`duckdb`.
|
||||
@@ -19,6 +19,7 @@ Filtro MCP: `mcp__registry__fn_search query="" tag="sink"`.
|
||||
| [http_post_json_py_infra](../../python/functions/infra/http_post_json.md) | py | HTTP JSON POST |
|
||||
| [http_post_json_go_infra](../../functions/infra/http_post_json.md) | go | HTTP JSON POST |
|
||||
| [db_insert_row_go_infra](../../functions/infra/db_insert_row.md) | go | SQL row insert |
|
||||
| [save_scan_to_osint_py_cybersecurity](../../python/functions/cybersecurity/save_scan_to_osint.md) | py | Vault Obsidian (nota) + osint_db (DuckDB via HTTP) — sink de scans de red |
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpClickHuman hace click en el elemento identificado por selector CSS con
|
||||
@@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
|
||||
toX := bx + bw/2 + offX
|
||||
toY := by + bh/2 + offY
|
||||
|
||||
// Mover el ratón con trayectoria humana
|
||||
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
|
||||
return fmt.Errorf("cdp click human: mover raton: %w", err)
|
||||
}
|
||||
|
||||
// mousePressed
|
||||
clickParams := map[string]any{
|
||||
"type": "mousePressed",
|
||||
"x": toX,
|
||||
"y": toY,
|
||||
"button": "left",
|
||||
"clickCount": 1,
|
||||
}
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30–90 ms)
|
||||
pauseMs := 30 + rand.Intn(61)
|
||||
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
|
||||
|
||||
// mouseReleased
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
|
||||
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
|
||||
// y despacha press/release con micro-pausa.
|
||||
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
|
||||
return fmt.Errorf("cdp click human: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// refActionableTimeout es cuánto espera CdpClickRef/CdpHoverRef a que el elemento
|
||||
// sea accionable (visible+stable+hit-test) antes de caer al cálculo de centro
|
||||
// previo. Lo bastante para tragar animaciones/overlays transitorios sin penalizar
|
||||
// el caso común (que converge en ~1 frame).
|
||||
const refActionableTimeout = 2 * time.Second
|
||||
|
||||
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
|
||||
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
|
||||
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
|
||||
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
model, ok := res["model"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
|
||||
}
|
||||
content, ok := model["content"].([]any)
|
||||
if !ok || len(content) < 8 {
|
||||
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
|
||||
}
|
||||
num := func(i int) float64 { f, _ := content[i].(float64); return f }
|
||||
cx := (num(0) + num(2) + num(4) + num(6)) / 4
|
||||
cy := (num(1) + num(3) + num(5) + num(7)) / 4
|
||||
return cx, cy, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Preferir el punto validado por actionability (visible + stable + hit-test):
|
||||
// evita clicks tragados por overlays/banners y elementos aún montándose o
|
||||
// animándose. Si no converge dentro del timeout, se cae al cálculo de centro
|
||||
// previo (sin regresión).
|
||||
if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil {
|
||||
return CdpClickXYHuman(c, x, y, opts)
|
||||
}
|
||||
// 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 {
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cdp_click_ref
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_click_xy_human_go_browser, cdp_wait_actionable_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: opts
|
||||
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
|
||||
file_path: "functions/browser/cdp_click_ref.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve outline con #ref=1234:
|
||||
conn, _ := CdpConnect(9222)
|
||||
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
|
||||
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
|
||||
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
|
||||
@@ -0,0 +1,64 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
|
||||
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
|
||||
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
|
||||
// micro-pausa variable (30-90 ms) entre ambos.
|
||||
//
|
||||
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
|
||||
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
|
||||
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
|
||||
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
|
||||
// Construir un único primitivo evita tener tres caminos de click divergentes.
|
||||
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click xy human: conexion nula")
|
||||
}
|
||||
|
||||
// Mover el ratón hasta el destino con trayectoria humana.
|
||||
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
|
||||
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
|
||||
}
|
||||
|
||||
clickParams := map[string]any{
|
||||
"type": "mousePressed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1,
|
||||
}
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
|
||||
// velocidad: human 30-90, auto/fast 5-15, instant 0.
|
||||
func clickPauseMs(mode string) int {
|
||||
switch mode {
|
||||
case "instant":
|
||||
return 0
|
||||
case "fast", "auto":
|
||||
return 5 + rand.Intn(11) // 5..15
|
||||
default: // "human" o ""
|
||||
return 30 + rand.Intn(61) // 30..90
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: cdp_click_xy_human_go_browser
|
||||
name: cdp_click_xy_human
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
|
||||
tags: [cdp, browser, action, humanized, click, navegator]
|
||||
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
|
||||
uses_functions:
|
||||
- cdp_move_mouse_human_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_click_xy_human.go"
|
||||
example: |
|
||||
conn, _ := browser.CdpConnect(9333)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// Click humanizado en el centro de un elemento detectado por visión (bbox):
|
||||
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa (de CdpConnect)."
|
||||
- name: x
|
||||
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
|
||||
- name: y
|
||||
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
|
||||
- name: opts
|
||||
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
|
||||
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := browser.CdpConnect(9333)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
|
||||
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
|
||||
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
|
||||
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
|
||||
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
|
||||
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
|
||||
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
|
||||
no humanización completa por acción.
|
||||
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConsoleEntry es una entrada del log de consola/diagnostico capturada via CDP
|
||||
// durante una ventana temporal. Type clasifica el origen:
|
||||
// - "log"/"info"/"warn"/"error"/"debug" — Runtime.consoleAPICalled (console.*)
|
||||
// - "exception" — Runtime.exceptionThrown (errores JS no capturados)
|
||||
// - el level de Log.entryAdded ("verbose"/"info"/"warning"/"error") para
|
||||
// avisos del propio navegador (network, security, deprecaciones...)
|
||||
type ConsoleEntry struct {
|
||||
Type string `json:"type"` // log|info|warn|warning|error|debug|exception|verbose
|
||||
Text string `json:"text"` // mensaje legible (args concatenados / descripcion + stack)
|
||||
URL string `json:"url"` // URL del script o recurso, si Chrome lo informa
|
||||
Line int `json:"line"` // numero de linea (1-based), 0 si desconocido
|
||||
Timestamp float64 `json:"timestamp"` // CDP timestamp (monotonic seconds) o wall time
|
||||
}
|
||||
|
||||
// consoleCollectDefaultMax es el tope de entradas por defecto cuando el caller
|
||||
// pasa maxEntries <= 0. Acota la salida en paginas verbosas (setInterval ruidoso,
|
||||
// SPA que loguea sin parar) para no devolver cientos de entradas y reventar el
|
||||
// output del tool.
|
||||
const consoleCollectDefaultMax = 200
|
||||
|
||||
// CdpCollectConsole habilita los dominios Runtime y Log en la conexion, se
|
||||
// suscribe a los eventos de consola/excepcion/log del navegador y acumula todo
|
||||
// lo que ocurra durante `durationMs` milisegundos, hasta un maximo de
|
||||
// `maxEntries` entradas. Es un SNAPSHOT temporal: captura solo lo emitido dentro
|
||||
// de la ventana, no el historico previo de la pagina. Si durationMs <= 0 usa
|
||||
// 1500ms por defecto; si maxEntries <= 0 usa 200 por defecto.
|
||||
//
|
||||
// Dos defensas contra el backlog de una conexion del pool que lleva rato abierta
|
||||
// con Runtime habilitado (donde Runtime.enable flushea consoleAPICalled rezagados
|
||||
// con timestamps antiguos, y un setInterval verboso puede inundar):
|
||||
// - Filtro por timestamp: se captura `startMs` (wall time, ms epoch) JUSTO antes
|
||||
// de habilitar los dominios y solo se acumulan eventos cuyo timestamp sea >=
|
||||
// startMs. Los eventos `consoleAPICalled`/`exceptionThrown`/`Log.entryAdded`
|
||||
// traen `timestamp` en ms epoch, asi que los rezagados del flush (anteriores
|
||||
// a startMs) se descartan. Eventos sin timestamp (0) se aceptan: no hay forma
|
||||
// de fecharlos y casi siempre son nuevos.
|
||||
// - Cap por cantidad: alcanzado `maxEntries` se dejan de acumular entradas, pero
|
||||
// la funcion NO corta la ventana — sigue durmiendo hasta `durationMs` para no
|
||||
// dejar los dominios CDP en estado raro (handlers a medio drenar). Las entradas
|
||||
// posteriores al cap simplemente se descartan; el flag de truncamiento se
|
||||
// refleja como una ConsoleEntry final de Type "_truncated".
|
||||
//
|
||||
// Eventos capturados y como se mapean a ConsoleEntry.Type:
|
||||
// - Runtime.consoleAPICalled -> el `type` del evento (log/info/warning/error/...)
|
||||
// - Runtime.exceptionThrown -> "exception" (texto = descripcion + stack)
|
||||
// - Log.entryAdded -> el `level` del entry (warning/error del browser)
|
||||
//
|
||||
// Robusta ante silencio: si no llega ningun evento devuelve un slice vacio
|
||||
// (no nil, no error). La conexion debe estar abierta; la funcion no la cierra.
|
||||
func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp collect console: conexion nula")
|
||||
}
|
||||
if durationMs <= 0 {
|
||||
durationMs = 1500
|
||||
}
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = consoleCollectDefaultMax
|
||||
}
|
||||
|
||||
// startMs marca el inicio de la ventana en ms epoch (mismo dominio que el
|
||||
// `timestamp` de los eventos CDP). Eventos anteriores = backlog -> se descartan.
|
||||
startMs := float64(time.Now().UnixMilli())
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries = make([]ConsoleEntry, 0, 16)
|
||||
truncated bool
|
||||
)
|
||||
|
||||
// add intenta acumular una entrada respetando el filtro por timestamp y el cap.
|
||||
// Devuelve sin hacer nada si la entrada es backlog o si ya se alcanzo el tope.
|
||||
add := func(e ConsoleEntry) {
|
||||
// Descartar backlog: eventos fechados antes del inicio de la ventana.
|
||||
// Timestamp 0 (sin fecha) se acepta — no se puede clasificar como viejo.
|
||||
if e.Timestamp != 0 && e.Timestamp < startMs {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if len(entries) >= maxEntries {
|
||||
truncated = true
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
entries = append(entries, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Helpers para extraer campos de map[string]any sin pelearse con cast.
|
||||
str := func(m map[string]any, k string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
num := func(m map[string]any, k string) float64 {
|
||||
if v, ok := m[k]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// argToText convierte un RemoteObject de Runtime a una representacion legible.
|
||||
// Para primitivas usa `value`; para objetos sin value cae a `description` o
|
||||
// `unserializableValue`; ultimo recurso, el `type`.
|
||||
argToText := func(arg map[string]any) string {
|
||||
if v, ok := arg["value"]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
// objetos/arrays serializados por valor -> JSON real.
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
if d := str(arg, "description"); d != "" {
|
||||
return d
|
||||
}
|
||||
if u := str(arg, "unserializableValue"); u != "" {
|
||||
return u
|
||||
}
|
||||
return str(arg, "type")
|
||||
}
|
||||
|
||||
// --- Runtime.consoleAPICalled: console.log / info / warn / error / ... ---
|
||||
cancel1 := c.OnEvent("Runtime.consoleAPICalled", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: str(p, "type"),
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
// Concatenar los args a un texto legible separado por espacios.
|
||||
if rawArgs, ok := p["args"].([]any); ok {
|
||||
parts := make([]string, 0, len(rawArgs))
|
||||
for _, ra := range rawArgs {
|
||||
if am, ok := ra.(map[string]any); ok {
|
||||
parts = append(parts, argToText(am))
|
||||
}
|
||||
}
|
||||
entry.Text = strings.Join(parts, " ")
|
||||
}
|
||||
// stackTrace -> primer frame para URL/linea.
|
||||
if st, ok := p["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
// lineNumber es 0-based en CDP; +1 para ser 1-based legible.
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
// --- Runtime.exceptionThrown: errores JS no capturados ---
|
||||
cancel2 := c.OnEvent("Runtime.exceptionThrown", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: "exception",
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
ed, _ := p["exceptionDetails"].(map[string]any)
|
||||
if ed != nil {
|
||||
// Texto base de la excepcion.
|
||||
text := str(ed, "text")
|
||||
// Si hay un objeto de excepcion con descripcion (stack completo), preferirlo.
|
||||
if exc, ok := ed["exception"].(map[string]any); ok {
|
||||
if desc := str(exc, "description"); desc != "" {
|
||||
if text != "" && !strings.Contains(desc, text) {
|
||||
text = text + ": " + desc
|
||||
} else {
|
||||
text = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Text = text
|
||||
entry.URL = str(ed, "url")
|
||||
// lineNumber 0-based -> 1-based.
|
||||
if ln := int(num(ed, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
// stackTrace top frame como respaldo de URL/linea.
|
||||
if entry.URL == "" {
|
||||
if st, ok := ed["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
if entry.Line == 0 {
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.Text == "" {
|
||||
entry.Text = "uncaught exception"
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
// --- Log.entryAdded: avisos del propio navegador (network, security...) ---
|
||||
cancel3 := c.OnEvent("Log.entryAdded", func(_ string, p map[string]any) {
|
||||
le, _ := p["entry"].(map[string]any)
|
||||
if le == nil {
|
||||
return
|
||||
}
|
||||
// Log.entryAdded reporta `timestamp` en segundos epoch (a diferencia de
|
||||
// consoleAPICalled/exceptionThrown que lo dan en ms). Normalizar a ms para
|
||||
// que el filtro por startMs compare en el mismo dominio. Heurística: si el
|
||||
// valor parece segundos (varios órdenes por debajo de un ms epoch actual),
|
||||
// multiplicar por 1000.
|
||||
ts := num(le, "timestamp")
|
||||
if ts > 0 && ts < startMs/100 {
|
||||
ts *= 1000
|
||||
}
|
||||
entry := ConsoleEntry{
|
||||
Type: str(le, "level"), // verbose|info|warning|error
|
||||
Text: str(le, "text"),
|
||||
URL: str(le, "url"),
|
||||
Line: int(num(le, "lineNumber")),
|
||||
Timestamp: ts,
|
||||
}
|
||||
add(entry)
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
// Habilitar dominios. Runtime.enable provoca un flush de consoleAPICalled
|
||||
// rezagados; Log.enable abre el stream de avisos del navegador.
|
||||
if _, err := c.sendCDP("Runtime.enable", nil); err != nil {
|
||||
return nil, fmt.Errorf("cdp collect console: Runtime.enable: %w", err)
|
||||
}
|
||||
if _, err := c.sendCDP("Log.enable", nil); err != nil {
|
||||
// Log.enable puede no estar disponible en algunos targets; no es fatal,
|
||||
// seguimos capturando Runtime.*. Deshabilitar Runtime no hace falta.
|
||||
_ = err
|
||||
}
|
||||
// No deshabilitamos Runtime al salir: otras funciones (ej. cdp_pick_element_js)
|
||||
// dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui.
|
||||
defer c.sendCDP("Log.disable", nil)
|
||||
|
||||
// Ventana de captura. No hacemos early-return al alcanzar el cap: seguimos
|
||||
// durmiendo la ventana completa para no dejar los dominios CDP a medio drenar.
|
||||
time.Sleep(time.Duration(durationMs) * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
out := make([]ConsoleEntry, len(entries))
|
||||
copy(out, entries)
|
||||
wasTruncated := truncated
|
||||
mu.Unlock()
|
||||
|
||||
// Senal de truncamiento limpia: una entrada final que el caller puede detectar
|
||||
// por Type == "_truncated" sin cambiar la forma del slice.
|
||||
if wasTruncated {
|
||||
out = append(out, ConsoleEntry{
|
||||
Type: "_truncated",
|
||||
Text: fmt.Sprintf("output truncado al alcanzar maxEntries=%d; entradas posteriores descartadas", maxEntries),
|
||||
Timestamp: float64(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_collect_console
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error)"
|
||||
description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500), hasta un maximo de maxEntries entradas (default 200). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo: filtra por timestamp para descartar el backlog de eventos que una conexion del pool acumulo antes de la llamada. Si se alcanza maxEntries deja de acumular pero no corta la ventana; anade una entrada final con Type=_truncated. Robusta ante silencio: devuelve slice vacio si no llega ningun evento."
|
||||
tags: [chrome, cdp, browser, automation, console, devtools, debug, diagnostics, logs, errors, exceptions, flow-replay]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, fmt, strings, sync, time]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn) contra una pestaña Chrome con el target abierto"
|
||||
- name: durationMs
|
||||
desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver. La función duerme la ventana completa aunque se alcance maxEntries antes"
|
||||
- name: maxEntries
|
||||
desc: "tope de entradas a acumular; si <=0 usa 200. Al alcanzarlo se descartan las entradas posteriores (no se corta la ventana) y se añade una entrada final con Type=_truncated. Acota la salida en páginas verbosas (setInterval ruidoso, SPA que loguea sin parar)"
|
||||
output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana (filtrado de backlog previo a la llamada y acotado a maxEntries); si se truncó, la última entrada tiene Type=_truncated; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_collect_console.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
// Captura todo lo que la pagina escriba en consola durante 2 segundos,
|
||||
// hasta un maximo de 100 entradas (descarta el backlog previo de la conexion).
|
||||
entries, err := CdpCollectConsole(conn, 2000, 100)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type == "_truncated" {
|
||||
fmt.Println("...", e.Text) // se alcanzo el cap de 100 entradas
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%s] %s (%s:%d)\n", e.Type, e.Text, e.URL, e.Line)
|
||||
}
|
||||
// Ejemplo de salida:
|
||||
// [error] Uncaught TypeError: x is not a function (https://example.com/app.js:42)
|
||||
// [warning] Mixed Content: requested an insecure resource (https://example.com:0)
|
||||
// [log] app initialized (https://example.com/app.js:5)
|
||||
|
||||
// Cap por defecto (200): pasar maxEntries <= 0.
|
||||
entries, _ = CdpCollectConsole(conn, 1500, 0)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas ver qué errores, warnings o mensajes de consola produce una página justo después de navegar o tras disparar una acción (click, submit). Úsala para depurar por qué un flujo web falla en silencio (excepción JS no capturada, recurso bloqueado por CSP/mixed-content, error de red que solo aparece en consola), para validar que una SPA arrancó sin errores, o como paso de diagnóstico dentro de un flow-replay antes de dar por bueno un replay. Llámala envolviendo la acción que quieres observar: navega/interactúa y deja que la ventana de captura recoja lo que emita.
|
||||
|
||||
## 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.
|
||||
- **Es un snapshot temporal, no histórico — y filtra el backlog.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. La función captura `startMs` (wall time, ms epoch) justo antes de habilitar los dominios y descarta todo evento con `timestamp` anterior a ese inicio. Esto resuelve el problema real con conexiones del pool que llevan rato abiertas con `Runtime` ya habilitado: cuando `Runtime.enable` se reenvía, Chrome flushea `consoleAPICalled` rezagados con timestamps antiguos; esos backlog se descartan por el filtro. Sin el filtro, en una página verbosa o con un `setInterval` la función devolvía cientos de entradas históricas que reventaban el output. **Por qué `OnEvent` no basta:** los handlers de `OnEvent` solo reciben eventos que lleguen al `readLoop` DESPUÉS del registro, pero el flush de `Runtime.enable` llega justo después y arrastra mensajes viejos — de ahí el backlog. El filtro por timestamp es la defensa que lo separa. Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana.
|
||||
- **Eventos sin timestamp se aceptan.** Si un evento llega con `timestamp` 0 (sin fechar) no se puede clasificar como backlog, así que se acumula. En la práctica casi siempre son nuevos.
|
||||
- **`Log.entryAdded` reporta en segundos, no ms.** A diferencia de `consoleAPICalled`/`exceptionThrown` (ms epoch), `Log.entryAdded` da `timestamp` en segundos epoch. La función lo normaliza a ms (heurística: si el valor es varios órdenes menor que un ms epoch actual, lo multiplica por 1000) para que el filtro por `startMs` compare en el mismo dominio.
|
||||
- **Cap por cantidad (`maxEntries`).** Al alcanzar `maxEntries` entradas (default 200) la función deja de acumular y descarta las posteriores, pero **NO corta la ventana** — sigue durmiendo hasta `durationMs` para no dejar los dominios CDP a medio drenar (handlers a medias) ni el estado de la conexión raro. Si se truncó, la **última** entrada del slice tiene `Type == "_truncated"` y un `Text` con el cap alcanzado; el caller debe filtrarla o tratarla como señal, no como un log real.
|
||||
- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos o se alcance el cap. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial).
|
||||
- **`Type` mezcla tres taxonomías.** `consoleAPICalled` usa `log|info|warning|error|debug|...`; `exceptionThrown` siempre marca `exception`; `Log.entryAdded` usa el `level` del navegador (`verbose|info|warning|error`). Filtra por substring (`warn`, `error`) si quieres agrupar severidades; nota que console.warn produce `warning`, no `warn`.
|
||||
- **`Line` es 1-based.** CDP reporta `lineNumber` 0-based; esta función suma 1 para que coincida con lo que muestran las DevTools. Los `Log.entryAdded` se dejan tal cual los da Chrome.
|
||||
- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí.
|
||||
- **`Log.enable` puede no estar disponible** en algunos targets (workers, ciertos contextos). Si falla, la función NO aborta: sigue capturando `Runtime.*` y solo pierde los avisos de `Log.entryAdded`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (16/06/2026) — añade parámetro `maxEntries` (cap, default 200) + filtro de backlog por timestamp. Resuelve bug real: en conexiones del pool con `Runtime` ya habilitado, el flush de `Runtime.enable` arrastraba eventos históricos (cientos en páginas verbosas con `setInterval`) que reventaban el output. Ahora se descarta lo anterior a `startMs` y se acota la salida con señal `_truncated`.
|
||||
|
||||
## Notas
|
||||
|
||||
`ConsoleEntry` se define como tipo simple del package `browser` en el mismo `.go` (igual que `HarEntry`/`HarHeader` en `cdp_har_record.go`), no como tipo del registry — evita import circular y mantiene la firma autosuficiente. La acumulación usa un `sync.Mutex` porque los handlers de `OnEvent` corren en la goroutine del `readLoop` de `CDPConn`, concurrente con la goroutine que duerme la ventana. La conversión de args de `consoleAPICalled` serializa objetos/arrays a JSON real (no la repr `%v` de Go) para que datos estructurados sean parseables.
|
||||
@@ -14,8 +14,16 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cdpCmdTimeout es el tope que sendCDP espera por la respuesta a un comando antes
|
||||
// de rendirse. Sin el, una respuesta que Chrome nunca envia (tab cerrada a media
|
||||
// peticion, proceso colgado) bloquearia la goroutine del tool para siempre — el
|
||||
// agente lo percibe como "lentitud infinita". Con el timeout, el tool falla limpio
|
||||
// y el retry de withConn puede reconectar.
|
||||
const cdpCmdTimeout = 30 * time.Second
|
||||
|
||||
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
|
||||
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
|
||||
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
|
||||
@@ -35,6 +43,21 @@ type CDPConn struct {
|
||||
closed bool
|
||||
handlers map[string][]EventHandler
|
||||
hMu sync.Mutex
|
||||
|
||||
// axEnabled/netEnabled/pageEnabled cachean si ya enviamos el enable de cada
|
||||
// dominio CDP en esta conexion. enable/disable es idempotente pero cuesta un
|
||||
// round-trip; en el hot path del agente (percibir->actuar repetido) re-enviar
|
||||
// Accessibility.enable / Network.enable en cada llamada duplica los RTT.
|
||||
// Habilitar una vez y cachear el flag elimina ese coste por percepcion/espera.
|
||||
axEnabled atomic.Bool
|
||||
netEnabled atomic.Bool
|
||||
pageEnabled atomic.Bool
|
||||
|
||||
// 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 {
|
||||
@@ -244,12 +267,60 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
|
||||
return nil, fmt.Errorf("cdp send %s: %w", method, err)
|
||||
}
|
||||
|
||||
// Esperar respuesta
|
||||
resp := <-ch
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
|
||||
// Esperar respuesta (con timeout para no colgar el tool indefinidamente).
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp.Result, nil
|
||||
case <-time.After(cdpCmdTimeout):
|
||||
c.pendMu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.pendMu.Unlock()
|
||||
return nil, fmt.Errorf("cdp %s: sin respuesta tras %s (conexion colgada?)", method, cdpCmdTimeout)
|
||||
}
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
// ensureAX habilita el dominio Accessibility una sola vez por conexion (necesario
|
||||
// antes de Accessibility.getFullAXTree). Idempotente y cacheado: la segunda y
|
||||
// sucesivas llamadas son no-op, evitando un round-trip por percepcion.
|
||||
func (c *CDPConn) ensureAX() error {
|
||||
if c.axEnabled.Load() {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.axEnabled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureNetwork habilita el dominio Network una sola vez por conexion. Cacheado:
|
||||
// no lo deshabilitamos al terminar una espera (eso borraria el estado y forzaria
|
||||
// el enable de nuevo); los handlers de eventos se desregistran por su cancel().
|
||||
func (c *CDPConn) ensureNetwork() error {
|
||||
if c.netEnabled.Load() {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.sendCDP("Network.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.netEnabled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensurePage habilita el dominio Page una sola vez por conexion (necesario para
|
||||
// recibir Page.loadEventFired y demas eventos de ciclo de vida de la pagina).
|
||||
func (c *CDPConn) ensurePage() error {
|
||||
if c.pageEnabled.Load() {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.pageEnabled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
|
||||
|
||||
@@ -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,298 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fillNodeInfo es el diagnostico que devuelve fillPrepare tras inspeccionar y
|
||||
// preparar el nodo en el contexto JS de la pagina. Replica la logica de
|
||||
// InjectedScript.fill de Playwright sin usar el "native value setter": para los
|
||||
// campos de texto/contenteditable selecciona el contenido previo y deja que el
|
||||
// motor inserte el valor con eventos confiables (ruta needsinput); para los
|
||||
// inputs especiales fija el valor y dispara los eventos (ruta setvalue).
|
||||
type fillNodeInfo struct {
|
||||
// Route es "needsinput" (hay que insertar el valor via Input.insertText),
|
||||
// "setvalue" (ya se fijo el valor + eventos, nada mas que hacer) o "" si hubo error.
|
||||
Route string `json:"route"`
|
||||
// Error describe por que el nodo no se puede rellenar (no editable, readonly,
|
||||
// disabled, oculto, tipo no soportado). Vacio si todo OK.
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// resolveObjectID resuelve un backendDOMNodeId a un Runtime objectId, para poder
|
||||
// ejecutar JS con `this` apuntando a ese nodo concreto via Runtime.callFunctionOn.
|
||||
func resolveObjectID(c *CDPConn, backendNodeID int) (string, error) {
|
||||
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolveNode ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
obj, _ := res["object"].(map[string]any)
|
||||
objID, _ := obj["objectId"].(string)
|
||||
if objID == "" {
|
||||
return "", fmt.Errorf("sin objectId para ref %d", backendNodeID)
|
||||
}
|
||||
return objID, nil
|
||||
}
|
||||
|
||||
// callFunctionOnJSON ejecuta functionDeclaration con `this` = objectId, pasando
|
||||
// args como argumentos posicionales, y deserializa el valor de retorno (por valor)
|
||||
// en out. La funcion JS debe devolver un objeto serializable.
|
||||
func callFunctionOnJSON(c *CDPConn, objectID, functionDeclaration string, args []any, out any) error {
|
||||
callArgs := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
callArgs[i] = map[string]any{"value": a}
|
||||
}
|
||||
res, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{
|
||||
"objectId": objectID,
|
||||
"functionDeclaration": functionDeclaration,
|
||||
"arguments": callArgs,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exc, ok := res["exceptionDetails"]; ok && exc != nil {
|
||||
excMap, _ := exc.(map[string]any)
|
||||
text, _ := excMap["text"].(string)
|
||||
return fmt.Errorf("excepcion JS: %s", text)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
resVal, ok := res["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("resultado inesperado: %v", res)
|
||||
}
|
||||
b, err := json.Marshal(resVal["value"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal valor de retorno: %w", err)
|
||||
}
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
|
||||
// fillPrepareJS es la funcion JS (con `this` = elemento) que valida editabilidad,
|
||||
// detecta el tipo y prepara el nodo. Replica InjectedScript.fill de Playwright:
|
||||
// NO usa el native value setter para text/textarea/contenteditable (selecciona el
|
||||
// valor previo y devuelve "needsinput" para que Input.insertText, con eventos
|
||||
// confiables del motor, haga que React/Vue reconcilien solos). Para inputs
|
||||
// especiales fija el valor y dispara input/change con {bubbles, composed}.
|
||||
//
|
||||
// arg[0] = value (string).
|
||||
const fillPrepareJS = `function(value){
|
||||
var el = this;
|
||||
if (!el || el.nodeType !== 1) return {route:"", error:"el #ref no es un elemento"};
|
||||
// Visibilidad: rect con area + no display:none/visibility:hidden.
|
||||
var rect = el.getBoundingClientRect();
|
||||
var style = el.ownerDocument.defaultView.getComputedStyle(el);
|
||||
if (style.visibility === "hidden" || style.display === "none" || (rect.width === 0 && rect.height === 0))
|
||||
return {route:"", error:"elemento no visible"};
|
||||
var tag = el.nodeName.toLowerCase();
|
||||
if (tag === "input") {
|
||||
var type = (el.type || "text").toLowerCase();
|
||||
if (el.disabled) return {route:"", error:"input deshabilitado"};
|
||||
if (el.readOnly) return {route:"", error:"input es readonly"};
|
||||
var kSetValue = {color:1, date:1, time:1, "datetime-local":1, month:1, range:1, week:1};
|
||||
var kTypeInto = {"":1, email:1, number:1, password:1, search:1, tel:1, text:1, url:1};
|
||||
if (!kTypeInto[type] && !kSetValue[type])
|
||||
return {route:"", error:"input de tipo '"+type+"' no se puede rellenar"};
|
||||
if (type === "number") {
|
||||
value = value.trim();
|
||||
if (value !== "" && isNaN(Number(value)))
|
||||
return {route:"", error:"no se puede escribir texto en input[type=number]"};
|
||||
}
|
||||
if (type === "color") value = value.toLowerCase();
|
||||
if (kSetValue[type]) {
|
||||
value = value.trim();
|
||||
el.focus();
|
||||
el.value = value;
|
||||
if (el.value !== value) return {route:"", error:"valor malformado para input[type="+type+"]"};
|
||||
el.dispatchEvent(new Event("input", {bubbles:true, composed:true}));
|
||||
el.dispatchEvent(new Event("change", {bubbles:true}));
|
||||
return {route:"setvalue", error:""};
|
||||
}
|
||||
// Ruta needsinput: seleccionar el valor previo para que insertText lo reemplace.
|
||||
el.select();
|
||||
el.focus();
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
if (tag === "textarea") {
|
||||
if (el.disabled) return {route:"", error:"textarea deshabilitado"};
|
||||
if (el.readOnly) return {route:"", error:"textarea es readonly"};
|
||||
el.selectionStart = 0;
|
||||
el.selectionEnd = el.value.length;
|
||||
el.focus();
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
if (el.isContentEditable) {
|
||||
el.focus();
|
||||
var range = el.ownerDocument.createRange();
|
||||
range.selectNodeContents(el);
|
||||
var sel = el.ownerDocument.defaultView.getSelection();
|
||||
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
||||
return {route:"needsinput", error:""};
|
||||
}
|
||||
return {route:"", error:"el elemento no es input, textarea ni [contenteditable]"};
|
||||
}`
|
||||
|
||||
// fillVerifyJS lee el valor actual del nodo (input.value/textarea.value o
|
||||
// textContent de contenteditable) para verificar que el fill surtio efecto.
|
||||
// arg[0] = expected (string). Devuelve {ok:bool, got:string, verifiable:bool}.
|
||||
const fillVerifyJS = `function(expected){
|
||||
var el = this;
|
||||
var tag = el.nodeName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") {
|
||||
var type = tag === "input" ? (el.type||"text").toLowerCase() : "text";
|
||||
var got = String(el.value);
|
||||
var exp = expected;
|
||||
if (type === "number" || type === "color" || type === "date" || type === "time" ||
|
||||
type === "datetime-local" || type === "month" || type === "range" || type === "week") {
|
||||
exp = expected.trim();
|
||||
if (type === "color") exp = exp.toLowerCase();
|
||||
}
|
||||
return {ok: got === exp, got: got, verifiable: true};
|
||||
}
|
||||
// contenteditable: no verificable de forma fiable (el motor normaliza el HTML).
|
||||
return {ok: true, got: String(el.textContent||""), verifiable: false};
|
||||
}`
|
||||
|
||||
// CdpFill rellena un campo de texto controlado por frameworks (React/Vue) de
|
||||
// forma robusta, estilo Playwright. backendNodeID es un backendDOMNodeId (el #ref
|
||||
// del AX outline de page_perceive).
|
||||
//
|
||||
// Comportamiento (replica InjectedScript.fill):
|
||||
// 1. Valida visible + enabled + editable (no readonly/disabled) en el contexto JS.
|
||||
// 2. Enfoca el nodo.
|
||||
// 3. Detecta el tipo:
|
||||
// - text/textarea/email/search/url/tel/password/number/contenteditable: ruta
|
||||
// "needsinput" — selecciona el valor previo y luego inserta value con
|
||||
// Input.insertText (eventos input/beforeinput confiables del motor; React/Vue
|
||||
// reconcilian solos). Con value=="" borra la seleccion (Delete) en vez de insertar.
|
||||
// - color/date/time/datetime-local/month/range/week: ruta "setvalue" — fija
|
||||
// el.value y dispara input{bubbles,composed} + change{bubbles}.
|
||||
// 4. Verifica que el.value === value al final (casos verificables); si no, error.
|
||||
//
|
||||
// A diferencia del patron focus+type que concatena al valor existente, CdpFill
|
||||
// reemplaza el contenido entero y es fiable con inputs controlados por frameworks.
|
||||
func CdpFill(c *CDPConn, backendNodeID int, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp fill: conexion nula")
|
||||
}
|
||||
|
||||
objID, err := resolveObjectID(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill: %w", err)
|
||||
}
|
||||
|
||||
// Enfocar el nodo (idempotente; fillPrepareJS tambien enfoca, pero DOM.focus
|
||||
// hace scroll-into-view y deja el activeElement listo para Input.insertText).
|
||||
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||
return fmt.Errorf("cdp fill: focus ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
|
||||
// Validar + preparar el nodo (selecciona valor previo o fija value+eventos).
|
||||
var info fillNodeInfo
|
||||
if err := callFunctionOnJSON(c, objID, fillPrepareJS, []any{value}, &info); err != nil {
|
||||
return fmt.Errorf("cdp fill: preparar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
if info.Error != "" {
|
||||
return fmt.Errorf("cdp fill: ref %d no editable: %s", backendNodeID, info.Error)
|
||||
}
|
||||
|
||||
switch info.Route {
|
||||
case "setvalue":
|
||||
// El valor ya se fijo y se dispararon los eventos en fillPrepareJS.
|
||||
case "needsinput":
|
||||
if value == "" {
|
||||
// Sin valor: borrar la seleccion (el valor previo ya esta seleccionado).
|
||||
// Delete elimina la seleccion sin insertar nada.
|
||||
del := map[string]any{"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||
if _, err := c.sendCDP("Input.dispatchKeyEvent", del); err != nil {
|
||||
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
delUp := map[string]any{"type": "keyUp", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}
|
||||
if _, err := c.sendCDP("Input.dispatchKeyEvent", delUp); err != nil {
|
||||
return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
} else {
|
||||
// Insertar el valor (reemplaza la seleccion previa) en un round-trip.
|
||||
// Input.insertText emite los eventos confiables que React/Vue necesitan.
|
||||
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": value}); err != nil {
|
||||
return fmt.Errorf("cdp fill: insertText ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cdp fill: ruta de preparacion desconocida %q para ref %d", info.Route, backendNodeID)
|
||||
}
|
||||
|
||||
// Verificar que el valor cuajo (solo casos verificables: input/textarea).
|
||||
var ver struct {
|
||||
OK bool `json:"ok"`
|
||||
Got string `json:"got"`
|
||||
Verifiable bool `json:"verifiable"`
|
||||
}
|
||||
if err := callFunctionOnJSON(c, objID, fillVerifyJS, []any{value}, &ver); err != nil {
|
||||
// La verificacion en si fallo (nodo desaparecido, etc.): no enmascarar.
|
||||
return fmt.Errorf("cdp fill: verificar ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
if ver.Verifiable && !ver.OK {
|
||||
return fmt.Errorf("cdp fill: verificacion fallida en ref %d: el campo quedo con %q, se esperaba %q", backendNodeID, ver.Got, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CdpFillSelector resuelve un selector CSS a su backendDOMNodeId (via
|
||||
// DOM.getDocument + DOM.querySelector + DOM.describeNode) y delega en CdpFill.
|
||||
// Util cuando se tiene un selector estable en vez del #ref del AX outline.
|
||||
func CdpFillSelector(c *CDPConn, selector string, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp fill selector: conexion nula")
|
||||
}
|
||||
if strings.TrimSpace(selector) == "" {
|
||||
return fmt.Errorf("cdp fill selector: selector vacio")
|
||||
}
|
||||
|
||||
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.getDocument: %w", err)
|
||||
}
|
||||
root, ok := docRes["root"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: respuesta de DOM.getDocument sin root")
|
||||
}
|
||||
rootNodeID, ok := root["nodeId"].(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: DOM.getDocument sin nodeId raiz")
|
||||
}
|
||||
|
||||
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||
"nodeId": int(rootNodeID),
|
||||
"selector": selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.querySelector %q: %w", selector, err)
|
||||
}
|
||||
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||
if !ok || int(nodeIDVal) == 0 {
|
||||
return fmt.Errorf("cdp fill selector: el selector %q no coincide con ningun elemento", selector)
|
||||
}
|
||||
|
||||
// Resolver el nodeId a backendNodeId (CdpFill opera sobre backendDOMNodeId).
|
||||
descRes, err := c.sendCDP("DOM.describeNode", map[string]any{"nodeId": int(nodeIDVal)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp fill selector: DOM.describeNode %q: %w", selector, err)
|
||||
}
|
||||
node, ok := descRes["node"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp fill selector: DOM.describeNode %q sin node", selector)
|
||||
}
|
||||
backendID, ok := node["backendNodeId"].(float64)
|
||||
if !ok || int(backendID) == 0 {
|
||||
return fmt.Errorf("cdp fill selector: %q sin backendNodeId", selector)
|
||||
}
|
||||
|
||||
return CdpFill(c, int(backendID), value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: cdp_fill
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFill(c *CDPConn, backendNodeID int, value string) error"
|
||||
description: "Rellena un campo de texto de forma robusta estilo Playwright, fiable con inputs controlados por frameworks (React/Vue). Valida visible+enabled+editable, enfoca el nodo, y según el tipo: para text/textarea/email/search/url/tel/password/number/contenteditable selecciona el valor previo y lo reemplaza con Input.insertText (eventos input/beforeinput confiables del motor — React/Vue reconcilian solos); para inputs especiales (color/date/time/range/week/month/datetime-local) fija el.value y dispara input{bubbles,composed}+change{bubbles}. Verifica que el.value===value al final. backendNodeID es el #ref del AX outline. Variante por selector: CdpFillSelector. Reemplaza el patrón frágil focus+type que concatena al valor existente."
|
||||
tags: [cdp, browser, action, ref, fill, form, react, vue, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo (*CDPConn)."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: value
|
||||
desc: "Valor a poner en el campo. Reemplaza el contenido entero (no concatena). value=='' borra el campo. Para input[type=number] debe ser numérico; para color se normaliza a minúsculas."
|
||||
output: "nil si el campo quedó con el valor pedido; error si la conexión es nil, el nodo no es editable (readonly/disabled/oculto), el tipo de input no se puede rellenar, o la verificación final (el.value===value) falla."
|
||||
file_path: "functions/browser/cdp_fill.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve un <input> React con #ref=4521:
|
||||
conn, _ := CdpConnect(9222)
|
||||
|
||||
// Por #ref del AX outline (camino habitual del bucle percibir→actuar):
|
||||
if err := CdpFill(conn, 4521, "ada@example.com"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill):
|
||||
if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Vaciar un campo:
|
||||
_ = CdpFillSelector(conn, "#search", "")
|
||||
|
||||
// Input especial (date): ruta setvalue + eventos input/change:
|
||||
_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar.
|
||||
- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`.
|
||||
- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright.
|
||||
- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan.
|
||||
- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char).
|
||||
- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes.
|
||||
- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera.
|
||||
@@ -0,0 +1,191 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CdpFindByRoleOpts configura el matching del accessible name de CdpFindByRole.
|
||||
// Si Name == "", solo se filtra por role (cualquier name vale).
|
||||
type CdpFindByRoleOpts struct {
|
||||
// Name es el accessible name a matchear. Vacio = no filtra por name.
|
||||
Name string
|
||||
// Exact: true = el name normalizado debe ser igual al buscado.
|
||||
// false (default) = el name normalizado contiene el buscado (substring).
|
||||
Exact bool
|
||||
// Regex: true = Name se interpreta como expresion regular (RE2 de Go).
|
||||
// Tiene prioridad sobre Exact si ambos estan a true.
|
||||
Regex bool
|
||||
// CaseSensitive: false (default) = comparacion insensible a mayusculas.
|
||||
// Para Regex, false añade el flag (?i) a la expresion.
|
||||
CaseSensitive bool
|
||||
}
|
||||
|
||||
// normalizeWhiteSpace replica la regla de Playwright (utils/isomorphic/stringUtils.ts):
|
||||
// elimina el zero-width space (U+200B) y el soft hyphen (U+00AD), recorta extremos y
|
||||
// colapsa cualquier run de whitespace a un unico espacio. Es la normalizacion que
|
||||
// Playwright aplica a ambos lados al comparar el accessible name (getByRole({name})),
|
||||
// para que diferencias de whitespace/caracteres invisibles no rompan el match.
|
||||
func normalizeWhiteSpace(s string) string {
|
||||
// Strip zero-width space y soft hyphen.
|
||||
s = strings.ReplaceAll(s, "", "")
|
||||
s = strings.ReplaceAll(s, "", "")
|
||||
// Colapsar runs de whitespace a un espacio.
|
||||
s = whitespaceRun.ReplaceAllString(s, " ")
|
||||
// Trim de extremos.
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// whitespaceRun matchea uno o mas caracteres de espacio en blanco. Equivale a
|
||||
// `\s+` de la regex de normalizeWhiteSpace de Playwright.
|
||||
var whitespaceRun = regexp.MustCompile(`\s+`)
|
||||
|
||||
// CdpFindByRole localiza el primer elemento por su ROLE ARIA y, opcionalmente, su
|
||||
// accessible name — el equivalente a getByRole de Playwright. Reutiliza el AX tree
|
||||
// que ya pedimos para page_perceive (Accessibility.getFullAXTree) en vez de tocar el
|
||||
// DOM/CSS, lo que la hace robusta a cambios de markup/estilos.
|
||||
//
|
||||
// Recorre los nodos del AX tree y matchea:
|
||||
// - role: igualdad exacta del rol ARIA (ej "button", "link", "textbox").
|
||||
// - name (si opts.Name != ""): el accessible name del nodo contra opts.Name, con
|
||||
// normalizeWhiteSpace aplicado a ambos lados (regla Playwright). Por defecto es
|
||||
// substring; Exact => igualdad; Regex => expresion regular. Insensible a
|
||||
// mayusculas salvo CaseSensitive.
|
||||
//
|
||||
// Retorna (ref, count, error):
|
||||
// - ref: backendDOMNodeId del primer match — el mismo #ref que produce el outline
|
||||
// de page_perceive y que consume CdpClickRef/CdpHoverRef.
|
||||
// - count: numero total de nodos que matchean. count > 1 indica ambiguedad: el
|
||||
// caller decide si refinar (Name mas especifico, Exact, etc.).
|
||||
// - error: conexion nula, role vacio, regex invalida, fallo CDP, o 0 matches.
|
||||
func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error) {
|
||||
if c == nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: conexion nula")
|
||||
}
|
||||
if role == "" {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: role vacio")
|
||||
}
|
||||
|
||||
// Construir el matcher del name una sola vez (compila la regex si aplica).
|
||||
matchName, err := buildNameMatcher(opts)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: %w", err)
|
||||
}
|
||||
|
||||
// Accessibility.enable (idempotente, cacheado) antes de getFullAXTree.
|
||||
if err := c.ensureAX(); err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.enable: %w", err)
|
||||
}
|
||||
|
||||
res, err := c.sendCDP("Accessibility.getFullAXTree", nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: Accessibility.getFullAXTree: %w", err)
|
||||
}
|
||||
|
||||
nodes := axoParseNodes(res)
|
||||
|
||||
firstRef := 0
|
||||
haveFirst := false
|
||||
for _, n := range nodes {
|
||||
if n.ignored {
|
||||
continue
|
||||
}
|
||||
if n.role != role {
|
||||
continue
|
||||
}
|
||||
if opts.Name != "" && !matchName(n.name) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if !haveFirst {
|
||||
// axoRefID prefiere backendDOMNodeID; ese es el ref que consume CdpClickRef.
|
||||
if id, ok := atoiRef(axoRefID(n)); ok {
|
||||
firstRef = id
|
||||
haveFirst = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if opts.Name != "" {
|
||||
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q and name %q", role, opts.Name)
|
||||
}
|
||||
return 0, 0, fmt.Errorf("cdp find by role: no element with role %q", role)
|
||||
}
|
||||
if !haveFirst {
|
||||
// Hubo matches pero ninguno tenia un ref entero usable (backendDOMNodeId
|
||||
// ausente y nodeId no numerico): no podemos devolver un #ref valido.
|
||||
return 0, count, fmt.Errorf("cdp find by role: %d match(es) para role %q pero sin backendDOMNodeId usable", count, role)
|
||||
}
|
||||
return firstRef, count, nil
|
||||
}
|
||||
|
||||
// buildNameMatcher devuelve la funcion que decide si un accessible name candidato
|
||||
// matchea opts.Name, normalizando ambos lados con normalizeWhiteSpace. Si Name == ""
|
||||
// el matcher siempre es true (no se filtra por name). Compila la regex una vez.
|
||||
func buildNameMatcher(opts CdpFindByRoleOpts) (func(candidate string) bool, error) {
|
||||
if opts.Name == "" {
|
||||
return func(string) bool { return true }, nil
|
||||
}
|
||||
|
||||
want := normalizeWhiteSpace(opts.Name)
|
||||
|
||||
if opts.Regex {
|
||||
pat := opts.Name
|
||||
if !opts.CaseSensitive {
|
||||
pat = "(?i)" + pat
|
||||
}
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("regex invalida %q: %w", opts.Name, err)
|
||||
}
|
||||
return func(candidate string) bool {
|
||||
return re.MatchString(normalizeWhiteSpace(candidate))
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !opts.CaseSensitive {
|
||||
want = strings.ToLower(want)
|
||||
}
|
||||
|
||||
return func(candidate string) bool {
|
||||
got := normalizeWhiteSpace(candidate)
|
||||
if !opts.CaseSensitive {
|
||||
got = strings.ToLower(got)
|
||||
}
|
||||
if opts.Exact {
|
||||
return got == want
|
||||
}
|
||||
return strings.Contains(got, want)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// atoiRef convierte el ref string (backendDOMNodeId, ya normalizado a entero-string
|
||||
// por axoStr) a int. Devuelve (0, false) si no es un entero parseable.
|
||||
func atoiRef(s string) (int, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
neg := false
|
||||
i := 0
|
||||
if s[0] == '-' {
|
||||
neg = true
|
||||
i = 1
|
||||
if len(s) == 1 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for ; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, false
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cdp_find_by_role
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)"
|
||||
description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad."
|
||||
tags: [browser]
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexion CDP viva (*CDPConn) del pool. nil => error."
|
||||
- name: role
|
||||
desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')."
|
||||
- name: opts
|
||||
desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)."
|
||||
output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_find_by_role.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
c, _ := browser.CdpConnect(9333) // conexion CDP del pool
|
||||
ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||
Name: "Aceptar", // substring del accessible name, case-insensitive
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err) // ej: no element with role "button" and name "Aceptar"
|
||||
}
|
||||
if count > 1 {
|
||||
log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count)
|
||||
}
|
||||
// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef.
|
||||
_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{})
|
||||
|
||||
// Match exacto + case-sensitive:
|
||||
ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{
|
||||
Name: "Iniciar sesion", Exact: true, CaseSensitive: true,
|
||||
})
|
||||
|
||||
// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"):
|
||||
ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{
|
||||
Name: `^Eliminar \d+ elementos$`, Regex: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol
|
||||
ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un
|
||||
selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole)
|
||||
para encontrar elementos accionables (botones, links, inputs). Combina el `ref`
|
||||
devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por
|
||||
un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua
|
||||
y conviene refinar (Name mas especifico, Exact, o Regex anclada).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `name` que se matchea es el **accessible name computado** por el motor de
|
||||
accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt,
|
||||
title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el
|
||||
texto visible literal, usa `cdp_find_ref_by_text` en su lugar.
|
||||
- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no
|
||||
siempre es el visualmente primero ni el que quieres. Refina la busqueda.
|
||||
- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea
|
||||
"menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` /
|
||||
`cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo.
|
||||
- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden,
|
||||
display:none) puede no aparecer y dar 0 matches.
|
||||
- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM
|
||||
muta entre el find y el click el ref puede quedar obsoleto.
|
||||
@@ -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,411 @@
|
||||
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 (idempotente, cacheado por conexion): necesario antes de
|
||||
// getFullAXTree. Cachear el flag evita un round-trip extra en cada percepcion,
|
||||
// que es la operacion mas frecuente del bucle percibir->actuar del agente.
|
||||
if err := c.ensureAX(); 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
|
||||
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp hover ref: conexión nil")
|
||||
}
|
||||
// Preferir el punto validado por actionability; si no converge, caer al centro.
|
||||
if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil {
|
||||
return CdpMoveMouseHuman(c, x, y, opts)
|
||||
}
|
||||
// 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 hover ref: %w", err)
|
||||
}
|
||||
return CdpMoveMouseHuman(c, cx, cy, opts)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: cdp_hover_ref
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_move_mouse_human_go_browser, cdp_wait_actionable_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: opts
|
||||
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
|
||||
file_path: "functions/browser/cdp_hover_ref.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
|
||||
conn, _ := CdpConnect(9222)
|
||||
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// esperar a que el menú aparezca y re-percibir el outline
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
|
||||
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
|
||||
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
|
||||
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
|
||||
@@ -9,12 +9,22 @@ 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: "auto"/"fast" (rápido), "human" (sigiloso,
|
||||
// también "") o "instant". Controla los defaults de Steps/DurationMs/JitterPx y
|
||||
// la pausa press/release:
|
||||
// - auto/fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
|
||||
// rápido — modo por defecto del MCP para automatización propia).
|
||||
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
|
||||
// - 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 +32,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 "auto", "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", "auto":
|
||||
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/auto).
|
||||
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 +162,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)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CdpPrintPDFOpts configura la generacion del PDF via Page.printToPDF.
|
||||
type CdpPrintPDFOpts struct {
|
||||
// Landscape orienta la pagina en horizontal cuando es true (vertical por defecto).
|
||||
Landscape bool
|
||||
// PrintBackground incluye los graficos de fondo (colores e imagenes CSS) cuando es true.
|
||||
PrintBackground bool
|
||||
// Scale es el factor de escala del renderizado (1.0 = tamano natural).
|
||||
// Si es <= 0 se usa 1.0. Chrome acepta el rango [0.1, 2].
|
||||
Scale float64
|
||||
// PaperWidthIn es el ancho del papel en pulgadas. 0 deja el default del navegador (8.5in).
|
||||
PaperWidthIn float64
|
||||
// PaperHeightIn es el alto del papel en pulgadas. 0 deja el default del navegador (11in).
|
||||
PaperHeightIn float64
|
||||
}
|
||||
|
||||
// CdpPrintPDF genera un PDF de la pagina actual via el metodo CDP Page.printToPDF
|
||||
// y devuelve los bytes del PDF ya decodificados, sin tocar el disco.
|
||||
//
|
||||
// Usa transferMode "ReturnAsBase64" (el default de CDP): Chrome devuelve el PDF
|
||||
// completo como string base64 en el campo "data" de la respuesta, que esta
|
||||
// funcion decodifica a []byte. Es robusto ante paginas grandes porque sendCDP
|
||||
// espera la respuesta completa por el WebSocket antes de decodificar.
|
||||
//
|
||||
// Las opciones se traducen a los params de Page.printToPDF: Landscape,
|
||||
// PrintBackground y Scale siempre se envian (con Scale forzado a 1.0 si opts pide
|
||||
// <= 0). PaperWidthIn/PaperHeightIn solo se envian cuando son > 0, dejando el
|
||||
// tamano de papel por defecto del navegador en caso contrario.
|
||||
//
|
||||
// Es la primitiva reutilizable de impresion a PDF: util para devolver el PDF al
|
||||
// LLM como document content (bytes) o para que un caller lo persista a disco.
|
||||
func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: conexion nula")
|
||||
}
|
||||
|
||||
scale := opts.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"transferMode": "ReturnAsBase64",
|
||||
"landscape": opts.Landscape,
|
||||
"printBackground": opts.PrintBackground,
|
||||
"scale": scale,
|
||||
}
|
||||
if opts.PaperWidthIn > 0 {
|
||||
params["paperWidth"] = opts.PaperWidthIn
|
||||
}
|
||||
if opts.PaperHeightIn > 0 {
|
||||
params["paperHeight"] = opts.PaperHeightIn
|
||||
}
|
||||
|
||||
result, err := c.sendCDP("Page.printToPDF", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: %w", err)
|
||||
}
|
||||
|
||||
dataStr, ok := result["data"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cdp print pdf: campo data ausente en respuesta")
|
||||
}
|
||||
|
||||
pdfData, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: decodificar base64: %w", err)
|
||||
}
|
||||
|
||||
return pdfData, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user