Compare commits
58 Commits
83f1d7c8d3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c1f355ffa5 | |||
| 237f763c19 | |||
| bf67ff3180 | |||
| 03fc0461fa | |||
| a1105dc4c5 | |||
| 3c9e909eda | |||
| 3cf8b21fea | |||
| cbefc82c02 | |||
| fb76b53c17 | |||
| 8e16202935 | |||
| e4a36f1133 | |||
| 295f90afaf | |||
| f85c1a322a | |||
| 32c7336bf6 | |||
| c1071a82b3 | |||
| fac2cceea3 | |||
| f852993412 | |||
| 8328637935 | |||
| 687c72805d | |||
| f415dd56f5 | |||
| b6ad1a3a15 | |||
| 753e16b84c | |||
| b6f4b4eb03 | |||
| 118d5d36d3 | |||
| b410328cec | |||
| 2f184d9dd9 | |||
| b823271eb6 | |||
| 2a279abb15 | |||
| 4b732ca4d3 | |||
| 05d0b71d5d | |||
| 334a71eed1 | |||
| c55bb17d09 | |||
| 9365def3dd | |||
| 251db2bfc5 | |||
| 0e93258974 | |||
| 28a53ee357 | |||
| b569561115 | |||
| 224f714d4a | |||
| 763e06c127 | |||
| 7d100e7f3e | |||
| e7a8edfed8 | |||
| cd87a8c28e | |||
| 6ab85ee701 | |||
| 909290ddbf | |||
| 111ee17bcc | |||
| 0d3118d98d | |||
| f6b9747f11 | |||
| 927437a8d8 | |||
| 7d395f39e5 | |||
| 4187f9b6b1 | |||
| c4ecf871c8 | |||
| 9798aed2cf | |||
| 588d092858 | |||
| a90b7443e4 | |||
| e1e9bb7499 | |||
| 1430039688 | |||
| 935008ec3f | |||
| d89da1292d |
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet.
|
||||||
|
argument-hint: "[texto|sessionId|PID para saltar — vacío = listar la flota]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# /fleet — ver y navegar la flota de Claudes
|
||||||
|
|
||||||
|
Inspecciona la flota de procesos Claude Code vivos de este PC y, opcionalmente, salta con foco a cualquiera de ellos dentro de la interfaz tmux (perfil fleetview).
|
||||||
|
|
||||||
|
Se apoya en el modo CLI de la app `fleetview` (`fleetview list` / `fleetview focus`), que opera sobre el socket tmux del perfil **desde el que se invoca el comando** (`$FLEET_SOCKET`, default `fleet`). Es decir, lista y enfoca solo los Claudes del mismo perfil en el que corres.
|
||||||
|
|
||||||
|
## Binario
|
||||||
|
|
||||||
|
Ruta: `${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview`
|
||||||
|
|
||||||
|
Si el binario no existe, compílalo antes de usarlo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview" && go build -o fleetview .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comportamiento según `$ARGUMENTS`
|
||||||
|
|
||||||
|
### Sin argumentos → listar la flota
|
||||||
|
|
||||||
|
1. Ejecuta:
|
||||||
|
```bash
|
||||||
|
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" list --json
|
||||||
|
```
|
||||||
|
2. Presenta el resultado como una tabla legible para el usuario, una fila por Claude, con: estado (idle/busy/waiting/shell), objetivo (`goal`), `sessionId` corto (primeros 8 caracteres), PID y window tmux.
|
||||||
|
3. Marca con claridad:
|
||||||
|
- el Claude **activo** (`active: true`) — el que está embebido en el pane derecho de la window `console`.
|
||||||
|
- la sesión actual / orquestador si la puedes identificar (su `session_id` coincide con el de quien invoca).
|
||||||
|
4. Si la lista está vacía, indícalo y sugiere que el perfil fleet podría no estar activo (revisar `$FLEET_SOCKET` y que la sesión tmux exista).
|
||||||
|
|
||||||
|
### Con argumentos → saltar con foco
|
||||||
|
|
||||||
|
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID.
|
||||||
|
|
||||||
|
1. Ejecuta:
|
||||||
|
```bash
|
||||||
|
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" focus "$ARGUMENTS"
|
||||||
|
```
|
||||||
|
2. Interpreta el exit code:
|
||||||
|
- `0`: salto hecho. Confirma al usuario a qué Claude saltó (usa la línea `→ ...` de stdout).
|
||||||
|
- `2`: query ambiguo. El binario lista los candidatos por stderr; muéstralos y pide al usuario que afine (por `sessionId` o PID).
|
||||||
|
- `1`: sin match o sin window tmux. Ejecuta `fleetview list` y muestra las opciones disponibles para que elija.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El salto usa el modelo de la TUI: trae el Claude elegido al pane derecho de la window `console` (con el sidebar fleetview siempre visible a la izquierda) y enfoca esa window. No es destructivo — el Claude que estuviera antes se aparca en su propia window, sigue vivo.
|
||||||
|
- El comando opera solo sobre el perfil tmux desde el que se invoca (`$FLEET_SOCKET`). Si pides un Claude que vive en otro perfil/socket, no aparecerá en la lista ni se podrá enfocar desde aquí.
|
||||||
|
- Para reabrir sesiones cerradas (`claude --resume`) usa la TUI fleetview (tecla `u`); este comando solo lista y enfoca Claudes vivos.
|
||||||
+181
-143
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: orquestador
|
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)."
|
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 con un prompt autonomo, aislamiento git impuesto y un DoD-contrato fijo. El humano habla solo con el orquestador, ve a los secundarios y puede saltar a cualquiera. El orquestador vigila la salud de la flota por su DoD (no por 'esta vivo'): consume la cola de eventos del watcher de fleetview, verifica los cierres con un agente comprobador independiente, empuja a los estancados, escala a la persona solo lo que pide decision, 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
|
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||||
@@ -8,20 +8,38 @@ description: "Modo orquestador: el Claude principal NO hace el trabajo pesado
|
|||||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
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
|
**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**
|
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
|
que arranca en su propia terminal, 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
|
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
|
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
||||||
integras cuando terminan.
|
integras cuando terminan.
|
||||||
|
|
||||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
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
|
orquestador` o `fin orquestador`. El hook `hook_fleet_state_inject.sh` reancla tu rol en cada
|
||||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
turno (reinyecta `MODO ORQUESTADOR activo (role=orchestrator).`), así que el modo no depende
|
||||||
re-invocar `/orquestador` para reanclarlo.
|
solo de que este prompt siga en contexto. Si el comportamiento se diluye, el humano puede
|
||||||
|
re-invocar `/orquestador`.
|
||||||
|
|
||||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
## Arranque: márcate `role=orchestrator`
|
||||||
|
|
||||||
|
**Al entrar, ANTES de confirmar, márcate `role=orchestrator`** (paso obligatorio). Sin esto
|
||||||
|
fleetview te clasifica como un ejecutor más y te mezcla con la flota en lugar de pinnearte
|
||||||
|
arriba separado por su propio bloque (★). El pin lo produce el campo `.role` del `goal.json` de
|
||||||
|
tu sesión (`apps/fleetview/cli.go::sortMembers`); nadie lo escribe por ti salvo que el launcher
|
||||||
|
de flota te haya arrancado con `--role orchestrator`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resuelve tu PID por tu sessionId (el del goal de esta sesión) y marca el role.
|
||||||
|
SID="<tu-sessionId>" # el que aparece en el GOAL-TRACKER del prompt / tu goal.json
|
||||||
|
PID=$(grep -l "$SID" ~/.claude/sessions/*.json | head -1 | xargs -n1 basename | sed 's/\.json$//')
|
||||||
|
./fn run mark_claude_role "$PID" orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
`mark_claude_role_py_infra` escribe SOLO la clave `role` en tu `goal.json` preservando el resto
|
||||||
|
(goal, phase, dod, dod_contract). Es idempotente. Tras marcarte, 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.
|
MODO ORQUESTADOR activo (role=orchestrator, pinneado arriba). Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
||||||
@@ -30,18 +48,17 @@ Hay dos cosas con nombre parecido. No las confundas:
|
|||||||
|
|
||||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
| | **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) |
|
| Mecanismo | Lanza Claudes **interactivos** en terminales (flota tmux / 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 |
|
| Visibilidad | El humano **ve y habla** con cada secundario | 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 |
|
| 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` |
|
| 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 |
|
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→…→MEJORAR hasta converger, PR draft |
|
||||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
| Regla de referencia | esta página + `.claude/rules/orchestration.md` | `.claude/rules/autonomous_loop.md` |
|
||||||
|
|
||||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
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
|
**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,
|
Claudes humanos-en-el-loop a la vez. Fan-out autónomo y barato sin mirar → Agent tool o
|
||||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
`/autopilot`; flota de Claudes interactivos que el humano supervisa → este modo.
|
||||||
usa este modo.
|
|
||||||
|
|
||||||
## El ciclo del orquestador (8 pasos)
|
## El ciclo del orquestador (8 pasos)
|
||||||
|
|
||||||
@@ -50,229 +67,250 @@ usa este modo.
|
|||||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
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
|
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
|
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
|
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; frontend
|
||||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
vs backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas en un
|
||||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
secundario, o las serializas, o las das scopes de archivos disjuntos. Si una sub-tarea sigue
|
||||||
|
siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/orchestration.md`).
|
||||||
|
|
||||||
### 2. Lanzar cada secundario
|
### 2. Lanzar cada secundario
|
||||||
|
|
||||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
|
||||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
estás dentro de tmux/una flota, o kitty SOLO cuando de verdad no hay tmux. NUNCA como sub-agente del
|
||||||
prompts de permiso en cada Bash los atascarían:
|
Agent tool (ver paso 8).** La detección de "estoy en una flota" se hace por **`$TMUX`** (señal
|
||||||
|
fiable, vía `detect_fleet_context`), **NO por `$FLEET_SOCKET`** (a veces viene vacía en un claude
|
||||||
|
resumido/relanzado pese a vivir en la flota → te haría caer a kitty por error). El hook
|
||||||
|
`hook_fleet_state_inject.sh` te inyecta cada turno una línea `CONTEXTO FLEET: … socket=<X>` cuando
|
||||||
|
estás dentro de la flota; úsala. Empieza por el bloque de flota tmux; kitty es el fallback solo fuera
|
||||||
|
de tmux.
|
||||||
|
|
||||||
|
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
||||||
|
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
||||||
|
|
||||||
|
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
|
||||||
|
|
||||||
|
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
|
||||||
|
con `$FLEET_SOCKET`), **NO lances kitties sueltas**: lanza cada ejecutor como una **window de la
|
||||||
|
flota tmux** con `spawn_fleet_agent`, para que viva en la flota, se vea en la TUI `fleetview` y sea
|
||||||
|
conmutable con `/fleet focus`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
# spawn_fleet_agent auto-detecta el socket/session de $TMUX — NO hace falta pasar --socket/--session:
|
||||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
./fn run spawn_fleet_agent \
|
||||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
|
||||||
|
--parent "$MI_SESSION_ID"
|
||||||
|
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
|
||||||
|
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
|
||||||
```
|
```
|
||||||
|
|
||||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
- `spawn_fleet_agent_bash_infra` **auto-detecta** socket/session del contexto tmux (`$TMUX`) vía
|
||||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
`detect_fleet_context`; pásalos explícitos solo si quieres otra flota (los explícitos priman).
|
||||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
Crea la window tmux + arranca claude con el prompt autocontenido (o `--skill <name>`), y con
|
||||||
|
`--role executor|orchestrator` marca su `goal.json`. El aislamiento git (sub-repo / worktree /
|
||||||
|
scope) sigue imponiéndose en el prompt.
|
||||||
|
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
|
||||||
|
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
|
||||||
|
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
|
||||||
|
retro-compatible. Ver `.claude/rules/orchestration.md`.
|
||||||
|
|
||||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
#### Fuera de tmux (kitty fallback)
|
||||||
queda en telemetría):
|
|
||||||
|
Solo cuando `detect_fleet_context` reporta `in_tmux=false` (de verdad no hay tmux):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
./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
|
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` lanza el secundario con el
|
||||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
comando canónico (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
|
||||||
prompt_file existan y que kitty esté instalado.
|
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
|
||||||
|
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo cuando NO estás en tmux
|
||||||
|
(`$TMUX` vacía); estando en una flota, kitty fragmenta la flota — usa `spawn_fleet_agent`.
|
||||||
|
|
||||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
### 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
|
**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`,
|
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
|
caso real 06/06/2026). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador
|
||||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
elige cuál y se lo **impone** en el prompt:
|
||||||
cuál y se lo **impone** en el prompt del secundario:
|
|
||||||
|
|
||||||
| Opción | Cómo | Cuándo |
|
| 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. |
|
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno con su `.git` independiente (regla `apps_subrepo.md`) | Sub-tareas en apps/analyses/projects distintos. 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). |
|
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | 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). |
|
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths** (`git add <paths>`, **nunca** `git add -A`) | Último recurso, scopes garantizados disjuntos y sin `git checkout` de por medio. Frágil; prefiere (a) o (b). |
|
||||||
|
|
||||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree principal,
|
||||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
y pásale al secundario el path del worktree como `<dir-aislado>`.
|
||||||
|
|
||||||
### 4. El prompt de cada secundario
|
### 4. El prompt de cada secundario
|
||||||
|
|
||||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**; el
|
||||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||||
|
|
||||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
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.
|
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
|
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree principal
|
||||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
`~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add` de paths
|
||||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin merge a master
|
||||||
merge a master (lo integra el orquestador).
|
(lo integra el orquestador).
|
||||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con evidencia
|
||||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y `dod_quality.md`.
|
||||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
Reports son artefacto local gitignored: se escriben, no se commitean.
|
||||||
commitean.
|
|
||||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
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`,
|
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
||||||
`delegation.md`).
|
`delegation.md`).
|
||||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||||
kitty vea el estado sin abrir el report.
|
terminal vea el estado sin abrir el report.
|
||||||
|
7. **DoD-contrato** — el criterio de aceptación **fijo y verificable** (golden + edge + error path
|
||||||
|
con evidencia ejecutable, `dod_quality.md`), redactado por ti. Va en el prompt Y se escribe en el
|
||||||
|
`goal.json` del secundario con `set_dod_contract` en cuanto conozcas su `sessionId`. Es el blanco
|
||||||
|
estable contra el que el verificador juzgará el cierre. Sin `dod_contract`, el agente es
|
||||||
|
`MAL_LANZADO`. Ver `.claude/rules/orchestration.md`.
|
||||||
|
|
||||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen aislamiento.
|
||||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
|
||||||
|
|
||||||
### 5. Seguir la flota
|
### 5. Seguir la flota
|
||||||
|
|
||||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La maquinaria de seguimiento
|
||||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
(listar la flota tipada con `apps/fleetview/fleetview list`, el tiempo de **actividad** vs vida del
|
||||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
proceso, drenar la cola del watcher) y la **vigilancia reactiva** (clasificación de cada agente,
|
||||||
|
políticas por clasificación, verificador, auto-kill, nudge, splitter, cadencia) viven íntegras en
|
||||||
|
**`.claude/rules/orchestration.md`**. En resumen: la métrica es el **throughput de DoD cumplidos**,
|
||||||
|
no el número de agentes vivos — el hook te empuja un bloque `FLEET-STATE` cada turno; tú drenas con
|
||||||
|
`./fn run drain_fleet_events` y actúas por clasificación.
|
||||||
|
|
||||||
```bash
|
**Vía preferida — tools MCP `fleet_*`:** si la sesión tiene el MCP `orchestrator` conectado (lo
|
||||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
normal: está en `.mcp.json`), usa sus 6 tools — `mcp__orchestrator__fleet_list` / `fleet_drain` /
|
||||||
./fn run list_claude_agents --json # para parsear y decidir
|
`fleet_classify` / `fleet_set_dod` / `fleet_kill` / `fleet_spawn` — en lugar de los `./fn run`
|
||||||
```
|
equivalentes: permisos pre-aprobados y salida estructurada, y `fleet_list` expone `role`/`dod_*`
|
||||||
|
directamente. El `./fn run` (y el binario `fleetview` para el listado) es el fallback CLI. Mapa
|
||||||
|
completo op→tool en `.claude/rules/orchestration.md`.
|
||||||
|
|
||||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
### 6. Parar un ejecutor — NUNCA `pkill`/`killall claude` (canónica)
|
||||||
`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.
|
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||||
Para parar un secundario:
|
Para parar un ejecutor:
|
||||||
|
|
||||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
- **`kill_fleet_agent` (preferido)** tras verificar `met`: SIGTERM al claude + cierra su window tmux,
|
||||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
con guards anti-orquestador y anti-self. Es el auto-kill que libera el slot idle (ver
|
||||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
`.claude/rules/orchestration.md`).
|
||||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`): `kill
|
||||||
|
<PID>`. Verifica que NO es tu `SELF`.
|
||||||
|
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; `--exclude-current`
|
||||||
|
para no tocarte. Dry-run por defecto; `--go` para ejecutar.
|
||||||
|
|
||||||
### 7. Integrar
|
### 7. Integrar
|
||||||
|
|
||||||
Cuando un secundario termina (rama pusheada + report verde):
|
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,
|
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).
|
devuélvele trabajo (el humano puede saltar a su terminal, o tú le mandas otro prompt / nudge).
|
||||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
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
|
`git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo del sub-repo. Para
|
||||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
funciones nuevas del registry padre, sus archivos viajan en la rama y el merge los lleva a master.
|
||||||
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
|
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.
|
sigue, qué se integró, qué falta.
|
||||||
|
|
||||||
### 8. kitty vs Agent tool — cuándo cada uno
|
### 8. Cómo lanzar un agente: SIEMPRE terminal del fleet, NUNCA Agent tool (canónica)
|
||||||
|
|
||||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
|
||||||
**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
|
1. **Dentro de tmux/flota** (`$TMUX` seteada — comprueba con `detect_fleet_context`, NO con `$FLEET_SOCKET`) → `spawn_fleet_agent` (auto-detecta el socket; window de la flota tmux).
|
||||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
2. **Fuera de tmux** (`in_tmux=false`) → kitty con `launch_claude_agent_kitty`.
|
||||||
|
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
|
||||||
|
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
|
||||||
|
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
|
||||||
|
|
||||||
|
Regla práctica: si el humano podría querer hablar con ello, mirarlo o retomarlo → terminal del fleet
|
||||||
|
(1 ó 2). Si es consulta efímera que TÚ haces para decidir y nadie más ve → Agent tool (3). Ante la
|
||||||
|
duda, terminal del fleet.
|
||||||
|
|
||||||
## Reglas duras del modo
|
## Reglas duras del modo
|
||||||
|
|
||||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
- **Responde CONCISO — velocidad de iteración sobre detalle.** Una o dos líneas por turno: estado de
|
||||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
la flota + la decisión que pides o tomas. Nada de análisis largos ni reformular el contexto — eso te
|
||||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
frena cuando gestionas muchos proyectos a la vez. Si te encuentras escribiendo un párrafo largo,
|
||||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
párate: probablemente eso debería ir a un ejecutor.
|
||||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te encuentras
|
||||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
escribiendo tú la feature, párate: ¿no debería ser un secundario? (Va pinneado arriba en el sidebar
|
||||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
por `role=orchestrator` ★, separado de los ejecutores.)
|
||||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
- **Todo agente de trabajo va como terminal del fleet, NUNCA como sub-agente del Agent tool** — ver
|
||||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
paso 8 (canónica). El Agent tool queda solo para utilidades internas read-only tuyas.
|
||||||
|
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree sin
|
||||||
|
worktrees/sub-repos/scopes disjuntos — causa nº1 de commits perdidos. Su prompt lleva SIEMPRE las
|
||||||
|
reglas de aislamiento (dir, qué NO tocar, rama, cómo commitear). Nunca `git add -A` salvo dir
|
||||||
|
exclusivamente suyo (worktree/sub-repo).
|
||||||
|
- **Tope de fan-out: máximo 6 ejecutores `role=executor` activos a la vez** por orquestador. Al
|
||||||
|
alcanzarlo, encola el resto hasta que un slot se libere (ejecutor `met` + `kill_fleet_agent`).
|
||||||
|
Detalle y justificación en `.claude/rules/orchestration.md`.
|
||||||
|
- **Nunca `pkill`/`killall claude`** — ver paso 6 (canónica). Kill dirigido (`kill_fleet_agent`), por
|
||||||
|
PID exacto, o `reboot_all_claudes --exclude-current`.
|
||||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
||||||
|
|
||||||
## Anti-patrones
|
## Anti-patrones
|
||||||
|
|
||||||
| Anti-patrón | Por qué es malo | En su lugar |
|
| 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` |
|
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill dirigido / por PID exacto / `reboot_all_claudes --exclude-current` (paso 6) |
|
||||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
| 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 |
|
| 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>` |
|
| `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`, …) |
|
| Lanzar un agente de trabajo con el Agent tool | Corre invisible (paso 8) | `spawn_fleet_agent` o kitty; Agent tool SOLO para utilidades read-only |
|
||||||
| 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 |
|
| 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) |
|
| 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` |
|
| 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
|
## Ejemplo end-to-end
|
||||||
|
|
||||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
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
|
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
|
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo padre
|
||||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
`fn_registry` (docs). Aislamiento natural distinto para cada una.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Descomponer → 2 secundarios independientes:
|
# 1. Descomponer → 2 secundarios independientes:
|
||||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
||||||
# B) doc capability → worktree del padre (aislamiento (b))
|
# B) doc capability → worktree del padre (aislamiento (b))
|
||||||
|
|
||||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
# 2. Preparar aislamiento de B (A ya está aislado por su sub-repo):
|
||||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
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):
|
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento + DoD-contrato):
|
||||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
|
||||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
|
||||||
# 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):
|
# 4. Lanzar ambos como windows de la flota (estás en tmux → spawn_fleet_agent auto-detecta el socket
|
||||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
# de $TMUX; kitty SOLO si in_tmux=false). Tras conocer su sessionId, escribe su DoD-contrato.
|
||||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
./fn run spawn_fleet_agent --cwd ~/fn_registry/apps/kanban --prompt-file /tmp/orq_health.md --title "kanban · health endpoint" --parent "$MI_SESSION_ID"
|
||||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
./fn run spawn_fleet_agent --cwd /tmp/orq_capdoc --prompt-file /tmp/orq_capdoc.md --title "fn_registry · doc deploy" --parent "$MI_SESSION_ID"
|
||||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
|
||||||
|
|
||||||
# 5. Seguir la flota (cada turno):
|
# 5. Seguir cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
|
||||||
./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):
|
# 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/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 merge --no-ff orq/cap-deploy # repo padre (la doc)
|
||||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
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.
|
||||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
|
||||||
# flota vacía. Tarea grande hecha.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Salida del modo
|
## Salida del modo
|
||||||
|
|
||||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la flota:
|
||||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su terminal para que el
|
||||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
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`.
|
`list_claude_agents` los lista y que para pararlos es kill dirigido / por PID exacto, nunca `pkill`
|
||||||
|
(paso 6).
|
||||||
|
|
||||||
## Relación con otras reglas
|
## Relación con otras reglas
|
||||||
|
|
||||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
- `.claude/rules/orchestration.md` — la maquinaria del modo: seguir la flota, watcher + cola,
|
||||||
lo que este modo **no** es; tenlas claras separadas.
|
clasificación, políticas, verificador, auto-kill, nudge, splitter, cadencia, y el catálogo de
|
||||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
funciones del grupo `orchestration`.
|
||||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es lo
|
||||||
worktree con una app nueva dentro.
|
que este modo **no** es; tenlas claras separadas.
|
||||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*` gitignored):
|
||||||
report con evidencia ejecutable + gaps.
|
el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un worktree.
|
||||||
|
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario.
|
||||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
||||||
registry-first y delegan a `fn-constructor` igual que tú.
|
registry-first y delegan a `fn-constructor` igual que tú.
|
||||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||||
|
|||||||
@@ -42,3 +42,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
| 38 | [orchestration.md](orchestration.md) | Maquinaria del modo `/orquestador`: seguir la flota (fleetview, tiempo de actividad), cola del watcher (events.jsonl, push activo, FLEET-STATE), clasificacion (`classify_fleet_termination`), politicas por clasificacion, verificador adversarial de cierres, auto-kill (`kill_fleet_agent`), nudge, splitter, cadencia + catalogo de funciones del grupo `orchestration`. Tope de fan-out=6. Flow 0012. |
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
## Maquinaria del modo orquestador: vigilancia reactiva de la flota
|
||||||
|
|
||||||
|
Esta regla recoge la **maquinaria estable** del modo `/orquestador` (`.claude/commands/orquestador.md`):
|
||||||
|
cómo se sigue la flota, cómo se consume la cola del watcher, cómo se clasifica cada agente y qué
|
||||||
|
política se aplica a cada clasificación, el verificador adversarial de cierres, el auto-kill, el
|
||||||
|
nudge, el splitter, la cadencia, y el catálogo de funciones del registry del grupo `orchestration`.
|
||||||
|
|
||||||
|
El comando `/orquestador` se queda con la doctrina y el flujo de cada turno; el detalle operativo
|
||||||
|
vive aquí para que el prompt del comando sea corto y la maquinaria no se diluya. El cerebro reactivo
|
||||||
|
de esta regla corresponde al flow 0012.
|
||||||
|
|
||||||
|
### Seguir la flota — listado y tiempo
|
||||||
|
|
||||||
|
La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json`
|
||||||
|
(memoria `claude-session-pid-mapping`). Para listar la flota de Claudes vivos:
|
||||||
|
|
||||||
|
```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>`).
|
||||||
|
|
||||||
|
**Flota tipada (goal/phase/window/age) — usa el binario `fleetview`, NO `fn run`.** La flota con
|
||||||
|
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
|
||||||
|
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nota: **NO** uses `./fn run list_claude_fleet` — `list_claude_fleet_go_infra` es una función Go con
|
||||||
|
tests, así que `fn run` la despacha como `go test` (corre la suite, no imprime la flota). La vía
|
||||||
|
ejecutable es el binario `apps/fleetview/fleetview` (el atajo `/fleet` del humano envuelve este mismo
|
||||||
|
CLI). El JSON de `fleetview list` **ya incluye** `role`/`dod_contract`/`dod_status` (además de
|
||||||
|
`tmux_window`): el binario los serializa directamente (`""` cuando el `goal.json` no los declara,
|
||||||
|
ver `apps/fleetview/cli.go`). El tool MCP `fleet_list` (ver abajo) además rellena los que el binario
|
||||||
|
deje vacíos leyéndolos del sidecar `~/.claude/goals/<session_id>.json`, así que con el MCP nunca te
|
||||||
|
faltan. Ya no hace falta leer el sidecar a mano salvo que uses el binario crudo y el campo venga vacío.
|
||||||
|
|
||||||
|
**Tiempo — usa el de ACTIVIDAD, no el del proceso.** Para "cuánto lleva cada agente" usa la columna
|
||||||
|
`AGE` de `fleetview list` (o `age`/`idle_seconds` en `--json`): es el tiempo desde su última
|
||||||
|
actividad (proxy de cuánto lleva sin avanzar / en su estado), lo útil para detectar estancados. El
|
||||||
|
`etime` de `list_claude_agents` es la **vida del proceso** (cuánto lleva la terminal abierta, p.ej.
|
||||||
|
8h) — NO es el tiempo de la tarea; nunca lo reportes como progreso.
|
||||||
|
|
||||||
|
### Vía preferida: tools MCP `fleet_*` (`orchestrator_mcp`)
|
||||||
|
|
||||||
|
El MCP `orchestrator` (registrado en `.mcp.json` como `orchestrator`, binario
|
||||||
|
`apps/orchestrator_mcp/orchestrator_mcp`) expone la maquinaria de la flota como **6 tools** que
|
||||||
|
envuelven las mismas funciones del registry. **En una sesión con `orchestrator_mcp` conectado,
|
||||||
|
prefiere los tools `mcp__orchestrator__fleet_*` sobre `./fn run`**: tienen permisos pre-aprobados,
|
||||||
|
devuelven salida estructurada y se registran en la telemetría como cualquier MCP (regla
|
||||||
|
`registry_calls.md`). El `./fn run` (o el binario `fleetview` para el listado) sigue siendo el
|
||||||
|
**fallback CLI** cuando el MCP no está conectado. Mapa de cada operación de la flota a su tool:
|
||||||
|
|
||||||
|
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
||||||
|
|---|---|---|
|
||||||
|
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||||
|
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
||||||
|
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
||||||
|
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
||||||
|
| Cerrar dirigido UN ejecutor (auto-kill: SIGTERM + kill-window, con guards) | `mcp__orchestrator__fleet_kill` (`dry_run` para ver el plan) | `./fn run kill_fleet_agent` |
|
||||||
|
| Lanzar un ejecutor como window de la flota tmux (con `parent` para el push) | `mcp__orchestrator__fleet_spawn` | `./fn run spawn_fleet_agent` |
|
||||||
|
|
||||||
|
Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directamente (y rellena los
|
||||||
|
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
||||||
|
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
||||||
|
|
||||||
|
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
||||||
|
|
||||||
|
| 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`).
|
||||||
|
|
||||||
|
### El cerebro reactivo: vigilar la salud por el DoD
|
||||||
|
|
||||||
|
Seguir la flota no es solo "¿quién vive?". Es **vigilar la salud por el DoD**: cada agente termina lo
|
||||||
|
que empieza, o sabes por qué no. La métrica es el **throughput de DoD cumplidos**, no el número de
|
||||||
|
agentes vivos — 30 agentes que no cierran nada no sirven. La fuente es la cola del **watcher embebido
|
||||||
|
en fleetview** (`~/.claude/fleet/events.jsonl`): una línea por **transición** de estado de un agente
|
||||||
|
(edge-triggered, sin ruido de nivel). El orquestador la drena cada vez que actúa y aplica una política
|
||||||
|
por clasificación.
|
||||||
|
|
||||||
|
#### DoD-contrato fijo al lanzar (regla dura)
|
||||||
|
|
||||||
|
Ningún secundario arranca sin **DoD-contrato**: el criterio de aceptación FIJO contra el que se evalúa
|
||||||
|
su terminación. Es distinto del campo `dod` del statusline (texto corto identificativo de la
|
||||||
|
terminal). **Desde 2026-06-21 ese `dod` ya NO se regenera con un LLM en cada turno**: el hook
|
||||||
|
`goal_refine.sh` que lo reescribía con haiku por prompt quedó desactivado (amplificaba el rate-limit
|
||||||
|
compartido). El objetivo+DoD inicial los fija `goal_autogen.sh` **una sola vez** por terminal; a partir
|
||||||
|
de ahí son fijos y el usuario los ajusta a mano con `objetivo: ...` / `dod: ...`. El criterio que
|
||||||
|
clasifica la flota es `dod_contract` + `dod_status` (lo escribe `set_dod_contract`, sin LLM), no ese
|
||||||
|
`dod` móvil. Tras lanzar y conocer el `sessionId`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run set_dod_contract <sessionId> "Golden: <caso feliz+evidencia>. Edge: <2 bordes>. Error: <1 fallo manejado>." pending
|
||||||
|
```
|
||||||
|
|
||||||
|
El contrato sigue `dod_quality.md` (golden + edge + error con evidencia ejecutable), no un checkbox
|
||||||
|
vago. Sin él, el agente es `MAL_LANZADO`.
|
||||||
|
|
||||||
|
#### Push automático: el bloque `FLEET-STATE`
|
||||||
|
|
||||||
|
No hace falta acordarse de drenar para enterarse de un cambio. El hook `UserPromptSubmit`
|
||||||
|
`hook_fleet_state_inject.sh` (registrado en `.claude/settings.local.json`) inyecta en CADA turno del
|
||||||
|
orquestador —solo cuando la sesión es `role=orchestrator`— una línea recordatorio del rol
|
||||||
|
(`MODO ORQUESTADOR activo (role=orchestrator).`, que reancla el modo aunque su prompt se haya
|
||||||
|
diluido del contexto) seguida de un bloque resumen de las transiciones pendientes del watcher:
|
||||||
|
|
||||||
|
```
|
||||||
|
FLEET-STATE: terminados=[<sid>:<goal>…] reclaman=[…] estancados=[…] (drain con ./fn run drain_fleet_events para consumir)
|
||||||
|
```
|
||||||
|
|
||||||
|
Si no hay cambios emite `FLEET-STATE: sin cambios`; si el watcher está caído o el `events.jsonl` no
|
||||||
|
existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo). El bloque es solo un
|
||||||
|
**aviso** (hace peek, no avanza el cursor): para consumir las transiciones y aplicar la política por
|
||||||
|
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
|
||||||
|
sobre el feed del watcher.
|
||||||
|
|
||||||
|
Además, el mismo hook inyecta una línea **`CONTEXTO FLEET`** cuando detecta (vía
|
||||||
|
`detect_fleet_context_bash_infra`, leyendo **`$TMUX`**, no `$FLEET_SOCKET`) que el orquestador vive
|
||||||
|
dentro de una flota tmux:
|
||||||
|
|
||||||
|
```
|
||||||
|
CONTEXTO FLEET: estás dentro de la fleet tmux socket=<X> session=<Y>. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aquí.
|
||||||
|
```
|
||||||
|
|
||||||
|
Es el recordatorio que evita el bug de caer a kitty cuando `$FLEET_SOCKET` viene vacía pese a estar
|
||||||
|
en la flota: la detección de contexto se hace por `$TMUX` (señal fiable que todo proceso dentro de
|
||||||
|
tmux tiene siempre), no por `$FLEET_SOCKET` (a veces ausente en un claude resumido/relanzado). Esta
|
||||||
|
parte del hook no necesita venv ni python (solo bash + tmux) y se emite antes del bloque
|
||||||
|
`FLEET-STATE`; si el detector falta o `$TMUX` está vacía, simplemente no se emite la línea (turno
|
||||||
|
intacto).
|
||||||
|
|
||||||
|
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
|
||||||
|
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
|
||||||
|
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
|
||||||
|
activo** (siguiente apartado) sí está ya ruteado por familia.
|
||||||
|
|
||||||
|
#### Push activo del watcher — send-keys dirigido (routing por `parent_orchestrator`)
|
||||||
|
|
||||||
|
Además del aviso pasivo en cada turno, el **watcher de fleetview** empuja activamente: cuando un
|
||||||
|
ejecutor transita a `DICE_TERMINADO`, hace `tmux send-keys` **directamente al pane del orquestador que
|
||||||
|
lo lanzó**, para que el cierre no espere a tu siguiente turno. El ruteo se resuelve por la clave
|
||||||
|
`parent_orchestrator` del `goal.json` del ejecutor — la que escribe `spawn_fleet_agent --parent
|
||||||
|
<tu-sessionId>`. Por eso **lanza siempre tus ejecutores con `--parent`**: sin esa clave el watcher no
|
||||||
|
sabe a qué pane mandar el aviso y el cierre queda solo en el peek pasivo (toda la flota). Con
|
||||||
|
`--parent`, cada familia de agentes avisa a su propio orquestador y desaparece el ruido cruzado entre
|
||||||
|
orquestadores.
|
||||||
|
|
||||||
|
#### Indicador "idle nuevo sin ver" en la TUI fleetview
|
||||||
|
|
||||||
|
La TUI `fleetview` marca de forma distinguible los ejecutores que **acaban de quedar idle y que aún no
|
||||||
|
has atendido** (idle nuevo sin ver), para que el humano y el orquestador localicen de un vistazo qué
|
||||||
|
agentes reclaman acción frente a los que ya están en seguimiento. Es la señal visual hermana del push
|
||||||
|
del watcher: el push te lo trae a la terminal, el indicador lo resalta en la lista. Úsalo como
|
||||||
|
disparador para drenar la cola y aplicar la política por clasificación (verificar `DICE_TERMINADO`,
|
||||||
|
nudge a `ESTANCADO`).
|
||||||
|
|
||||||
|
### Drenar la cola
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run drain_fleet_events # consume nuevos (avanza cursor), agrupa por clasificación, marca urgentes
|
||||||
|
./fn run drain_fleet_events --advance false # peek sin consumir (inspección)
|
||||||
|
```
|
||||||
|
|
||||||
|
Devuelve `{total_new, events, by_classification, urgent, cursor}`. La clasificación de cada agente la
|
||||||
|
produce `classify_fleet_termination` (pura) desde su estado (status + phase + dod_contract +
|
||||||
|
dod_status + segundos ociosos).
|
||||||
|
|
||||||
|
**No te vigiles a ti mismo.** Al procesar la cola, **ignora** los eventos de tu propia sesión y de
|
||||||
|
cualquier agente con `role=orchestrator`. El `role` ya viene en cada fila de `fleet_list` (y de
|
||||||
|
`fleetview list --json`), así que filtras directamente por ese campo. Solo si usas el binario crudo y
|
||||||
|
la fila trae `role` vacío, cae al sidecar del goal de cada `session_id`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preferido: filtrar por el role que ya trae fleet_list / fleetview list --json.
|
||||||
|
apps/fleetview/fleetview list --json | jq -r '.[] | select((.role // "executor") != "orchestrator") | .session_id'
|
||||||
|
# Fallback solo si el binario dejó role vacío en alguna fila:
|
||||||
|
jq -r '.role // "executor"' ~/.claude/goals/<session_id>.json # "orchestrator" => ignóralo
|
||||||
|
```
|
||||||
|
|
||||||
|
El orquestador no tiene `dod_contract` y aparecería como `MAL_LANZADO` — es ruido, no un ejecutor que
|
||||||
|
vigilar. Solo actúas sobre los **ejecutores** (`role=executor` o sin role).
|
||||||
|
|
||||||
|
### Políticas por clasificación
|
||||||
|
|
||||||
|
| Transición a… | Qué hace el orquestador |
|
||||||
|
|---|---|
|
||||||
|
| `RECLAMA` (urgent) | **Escalar a la persona**: resumen corto de QUÉ decisión se necesita + `/fleet focus <sid>` para llevarla al agente. Si no está presente, `PushNotification`. NUNCA decidir tú por ella en un RECLAMA. |
|
||||||
|
| `DICE_TERMINADO` | Lanzar **verificador independiente** (abajo). No confiar en el autodeclarado. Si `met` → cerrar con `kill_fleet_agent` (auto-kill, libera el slot idle). |
|
||||||
|
| `ESTANCADO` | **Nudge** al agente (abajo). Solo idle; jamás waiting. |
|
||||||
|
| `MAL_LANZADO` | Escribir `dod_contract` retroactivo (`set_dod_contract`) o re-lanzar con DoD. |
|
||||||
|
| `TRABAJANDO` | No molestar. |
|
||||||
|
| `GONE` | Limpiar de la tabla de seguimiento (terminó o murió; si tenía DoD sin cumplir, anótalo). |
|
||||||
|
|
||||||
|
### Verificador — cierre de `DICE_TERMINADO` (cero auto-aprobación)
|
||||||
|
|
||||||
|
Cuando un agente se autodeclara terminado, **no se confía**: lanzas un **verificador independiente**
|
||||||
|
del ejecutor (Agent efímero), que compara el **report** del ejecutor (en `reports/`, con evidencia
|
||||||
|
ejecutable) contra su `dod_contract`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent(subagent_type="general-purpose", prompt:
|
||||||
|
"Verifica de forma ADVERSARIAL si el trabajo cumple su DoD-contrato. NO ejecutaste tú la tarea.
|
||||||
|
DoD-contrato: <contract>
|
||||||
|
Report del ejecutor: <ruta del reports/NNNN-*.md>
|
||||||
|
Comprueba CADA cláusula (golden + edge + error) contra la evidencia citada en el report; re-ejecuta
|
||||||
|
los comandos de verificación si puedes. Devuelve {verdict: met|failed, gaps: [...], evidence: [...]}.
|
||||||
|
Por defecto failed si la evidencia no respalda una cláusula.")
|
||||||
|
```
|
||||||
|
|
||||||
|
El verificador (y el splitter y las búsquedas con `Explore`) son la **única** excepción autorizada al
|
||||||
|
Agent tool dentro del modo: utilidades internas read-only del propio orquestador, que devuelven un
|
||||||
|
resultado y mueren sin que el humano las gestione como agentes de la flota. Jamás se usa el Agent tool
|
||||||
|
para ejecutar una sub-tarea (ver paso 8 del comando).
|
||||||
|
|
||||||
|
- `met` → el orquestador marca `set_dod_contract <sid> "<contract>" met`, informa a la persona y
|
||||||
|
**cierra el ejecutor para liberar el slot idle** con `kill_fleet_agent` (regla de auto-kill, abajo).
|
||||||
|
- `failed` → **nudge** al ejecutor con el gap concreto (no cerrar). `set_dod_contract <sid>
|
||||||
|
"<contract>" failed` (vuelve a pending tras el nudge si reabre trabajo).
|
||||||
|
|
||||||
|
### Auto-kill — cerrar el ejecutor tras verificar `met` (libera el slot idle)
|
||||||
|
|
||||||
|
Un ejecutor verificado `met` **no se deja vivo en reposo**: se cierra de inmediato para que no se
|
||||||
|
acumule en la flota ocupando un slot idle. En cuanto el verificador devuelve `met` y has marcado
|
||||||
|
`set_dod_contract <sid> "<contract>" met`, ciérralo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run kill_fleet_agent <sessionId> --socket "$FLEET_SOCKET"
|
||||||
|
```
|
||||||
|
|
||||||
|
`kill_fleet_agent_bash_infra` manda **SIGTERM** al proceso `claude` del ejecutor (cierre limpio,
|
||||||
|
recuperable luego con `claude --resume <sessionId>`) y cierra su window tmux (`kill-window`). Trae
|
||||||
|
**guards** que lo hacen seguro de invocar programáticamente:
|
||||||
|
|
||||||
|
- **No mata a un `role=orchestrator`** (lo lee del `goal.json`): nunca decapitas la flota por error.
|
||||||
|
- **No se mata a sí mismo**: rechaza el target si es la sesión que invoca (equivalente dirigido de la
|
||||||
|
regla "nunca `pkill claude`", paso 6 del comando).
|
||||||
|
- Acepta el target por `sessionId` (exacto o prefijo) o por PID. Usa `--dry-run` para ver el plan sin
|
||||||
|
tocar nada.
|
||||||
|
|
||||||
|
Esto cierra el ciclo del modo: lanzas con `--parent` → el watcher te avisa del `DICE_TERMINADO` →
|
||||||
|
verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `kill` a pelo para esto:
|
||||||
|
`kill_fleet_agent` resuelve la window y aplica los guards.
|
||||||
|
|
||||||
|
### Nudge — `ESTANCADO`
|
||||||
|
|
||||||
|
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
||||||
|
inyectando en su pane tmux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
||||||
|
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
||||||
|
empujón del bot.
|
||||||
|
|
||||||
|
### Splitter — tarea demasiado grande
|
||||||
|
|
||||||
|
Si una sub-tarea sigue siendo grande para un solo agente, antes de lanzarla pásala por un **splitter**
|
||||||
|
(Agent efímero) que devuelve un plan de sub-tareas atómicas, cada una con su `dod_contract` y sus
|
||||||
|
dependencias:
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent(subagent_type="Plan", prompt:
|
||||||
|
"Descompón esta tarea en sub-tareas ATÓMICAS, cada una cerrable por UN agente en una sesión, con
|
||||||
|
su propio DoD-contrato (golden+edge+error) y dependencias (cuáles son paralelas y cuáles
|
||||||
|
secuenciales). Máximo 6 sub-tareas. Tarea: <...>. Devuelve [{tarea, dod_contract, deps:[...]}].")
|
||||||
|
```
|
||||||
|
|
||||||
|
El orquestador lanza un ejecutor por sub-tarea respetando las dependencias (paralelas a la vez,
|
||||||
|
secuenciales encadenadas), **siempre dentro del tope de fan-out** (ver "Tope de fan-out" abajo).
|
||||||
|
|
||||||
|
### Tope de fan-out (regla dura)
|
||||||
|
|
||||||
|
**Máximo 6 ejecutores `role=executor` activos simultáneos por orquestador.** Si se alcanza el tope,
|
||||||
|
el orquestador NO lanza más: **encola** las sub-tareas restantes y las despacha a medida que un slot
|
||||||
|
se libera — un slot se libera cuando un ejecutor se verifica `met` y se cierra con `kill_fleet_agent`
|
||||||
|
(auto-kill). El conteo es de la **familia propia** (ejecutores con tu `parent_orchestrator`), no de
|
||||||
|
toda la flota; resuélvelo con el routing por `parent_orchestrator`, igual que el push activo.
|
||||||
|
|
||||||
|
Por qué un número duro y no "los que hagan falta": ya hubo el caso de **30 agentes que no cerraban
|
||||||
|
nada** y, al competir todos por el mismo rate-limit compartido, hubo que desactivar `goal_refine`
|
||||||
|
(el hook que reescribía el `dod` con un LLM por prompt). Más ejecutores no es más throughput: el
|
||||||
|
cuello de botella es el rate-limit compartido y los DoD que nadie cierra, no el número de procesos.
|
||||||
|
|
||||||
|
### Cadencia
|
||||||
|
|
||||||
|
El orquestador no hace polling caro: drena la cola **cuando actúa** (cuando la persona le habla) y,
|
||||||
|
para vigilancia desatendida, con un heartbeat largo (`ScheduleWakeup` 20-30 min) o cuando el watcher
|
||||||
|
empuja un urgente. Lo urgente (`RECLAMA`) sube al instante; el resto (cierres, estancados) se procesa
|
||||||
|
en lote.
|
||||||
|
|
||||||
|
## Funciones del registry del 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 |
|
||||||
|
| `set_dod_contract_py_infra` | Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un secundario al lanzarlo |
|
||||||
|
| `drain_fleet_events_py_infra` | Consumir la cola de transiciones del watcher (`~/.claude/fleet/events.jsonl`), agrupada por clasificación + urgentes |
|
||||||
|
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
|
||||||
|
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
|
||||||
|
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
|
||||||
|
| `detect_fleet_context_bash_infra` | Detectar si estás en una flota tmux derivando socket/session de `$TMUX` (señal fiable), con fallback a `$FLEET_SOCKET`. Devuelve JSON `{in_fleet,in_tmux,socket,session,source}`. Lo usan `spawn_fleet_agent` (auto-detección de socket) y el hook (línea `CONTEXTO FLEET`) para no caer a kitty estando en la flota |
|
||||||
|
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty siempre que estés en tmux. **Auto-detecta socket/session de `$TMUX`** (vía `detect_fleet_context`) si no se pasan `--socket`/`--session` (los explícitos priman). `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
|
||||||
|
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
||||||
|
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
||||||
|
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
||||||
|
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
||||||
|
|
||||||
|
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
||||||
|
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
|
||||||
|
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
|
||||||
|
`spawn_fleet_agent`, `detect_fleet_context`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
|
||||||
|
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
|
||||||
|
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
|
||||||
|
mano).
|
||||||
|
|
||||||
|
## Relación con otras reglas
|
||||||
|
|
||||||
|
- `.claude/commands/orquestador.md` — la doctrina y el flujo de cada turno del modo; esta regla es su
|
||||||
|
maquinaria operativa.
|
||||||
|
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es lo
|
||||||
|
que el modo orquestador **no** es.
|
||||||
|
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*` gitignored):
|
||||||
|
el aislamiento natural y el gotcha de `git init` antes de limpiar un worktree con una app nueva.
|
||||||
|
- `.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`.
|
||||||
|
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||||
|
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Hook UserPromptSubmit: inyecta el estado de la flota al Claude orquestador.
|
||||||
|
#
|
||||||
|
# En el modo /orquestador, el Claude principal gestiona una flota de agentes y
|
||||||
|
# necesita enterarse de forma reactiva cuando uno cambia de estado: termina
|
||||||
|
# (DICE_TERMINADO), reclama una decision (RECLAMA) o se estanca (ESTANCADO).
|
||||||
|
# El watcher de fleetview escribe esas transiciones a la cola JSONL
|
||||||
|
# ~/.claude/fleet/events.jsonl. Este hook hace un peek de esa cola en cada turno
|
||||||
|
# y emite un bloque "FLEET-STATE:" para que el orquestador vea los cambios
|
||||||
|
# pendientes sin tener que drenar la cola a mano.
|
||||||
|
#
|
||||||
|
# Entrada (stdin JSON del hook UserPromptSubmit): { session_id, cwd, ... }
|
||||||
|
# El stdout de este script se inyecta como additionalContext en el turno.
|
||||||
|
#
|
||||||
|
# Solo el orquestador recibe el feed: se identifica leyendo el campo `role` de
|
||||||
|
# ~/.claude/goals/<session_id>.json (lo marca `mark_claude_role`). Cualquier
|
||||||
|
# sesion que no sea role=orchestrator termina en silencio (sin stdout).
|
||||||
|
#
|
||||||
|
# El peek usa advance=False: NO mueve el cursor de la cola. El orquestador sigue
|
||||||
|
# viendo los mismos eventos pendientes cada turno hasta que los consume
|
||||||
|
# explicitamente con `./fn run drain_fleet_events` (que si avanza el cursor).
|
||||||
|
#
|
||||||
|
# Degradacion limpia: si falta jq/python/venv, si la cola no existe, o si el
|
||||||
|
# watcher esta caido, el hook nunca rompe el turno (siempre exit 0).
|
||||||
|
set -u
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
|
||||||
|
[ -z "$SESSION_ID" ] && exit 0
|
||||||
|
|
||||||
|
GOAL_FILE="$HOME/.claude/goals/${SESSION_ID}.json"
|
||||||
|
ROLE=""
|
||||||
|
[ -f "$GOAL_FILE" ] && ROLE=$(jq -r '.role // ""' "$GOAL_FILE" 2>/dev/null)
|
||||||
|
|
||||||
|
# Solo el orquestador recibe el feed de la flota. Resto: silencio total.
|
||||||
|
[ "$ROLE" != "orchestrator" ] && exit 0
|
||||||
|
|
||||||
|
# Reanclar el rol en cada turno: el modo /orquestador no debe depender solo de
|
||||||
|
# que su prompt (.claude/commands/orquestador.md) siga en contexto. Este
|
||||||
|
# recordatorio se reinyecta aunque el watcher este caido o falte el venv (la
|
||||||
|
# guarda de abajo saldria con exit 0 sin emitir FLEET-STATE). Se emite SOLO para
|
||||||
|
# role=orchestrator: las sesiones sin goal.json o sin ese rol ya salieron arriba
|
||||||
|
# con exit 0 y stdout vacio, asi que el path limpio queda intacto.
|
||||||
|
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
|
||||||
|
|
||||||
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
|
||||||
|
|
||||||
|
# Contexto de flota: recordarle al orquestador en que socket/sesion tmux vive,
|
||||||
|
# para que lance ejecutores con spawn_fleet_agent (auto-detecta el socket) y
|
||||||
|
# NUNCA caiga a kitty estando dentro de la flota. La deteccion va por $TMUX
|
||||||
|
# (senal fiable), no por $FLEET_SOCKET (a veces vacia en un claude resumido/
|
||||||
|
# relanzado). No necesita venv ni python: solo bash + tmux. Degrada limpio: si
|
||||||
|
# el detector falta o falla, simplemente no se emite la linea (turno intacto).
|
||||||
|
DETECTOR="$PROJECT_DIR/bash/functions/infra/detect_fleet_context.sh"
|
||||||
|
if [ -f "$DETECTOR" ]; then
|
||||||
|
CTX=$(bash "$DETECTOR" 2>/dev/null || true)
|
||||||
|
IN_FLEET=$(printf '%s' "$CTX" | sed -n 's/.*"in_fleet":\(true\|false\).*/\1/p')
|
||||||
|
F_SOCKET=$(printf '%s' "$CTX" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')
|
||||||
|
F_SESSION=$(printf '%s' "$CTX" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')
|
||||||
|
if [ "$IN_FLEET" = "true" ]; then
|
||||||
|
printf 'CONTEXTO FLEET: estas dentro de la fleet tmux socket=%s session=%s. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aqui.\n' "$F_SOCKET" "$F_SESSION"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY="$PROJECT_DIR/python/.venv/bin/python3"
|
||||||
|
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
|
||||||
|
|
||||||
|
OUT=$(FN_PROJECT_DIR="$PROJECT_DIR" timeout 8 "$PY" - <<'PYEOF' 2>/dev/null
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = os.environ.get("FN_PROJECT_DIR", os.path.expanduser("~/fn_registry"))
|
||||||
|
sys.path.insert(0, os.path.join(root, "python", "functions"))
|
||||||
|
events = os.path.join(os.path.expanduser("~"), ".claude", "fleet", "events.jsonl")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from infra.drain_fleet_events import drain_fleet_events
|
||||||
|
from infra.summarize_fleet_transitions import summarize_fleet_transitions
|
||||||
|
|
||||||
|
if not os.path.exists(events):
|
||||||
|
# Watcher nunca arranco o cola borrada: diagnostico explicito.
|
||||||
|
print("FLEET-STATE: cola del watcher no disponible (events.jsonl ausente)")
|
||||||
|
else:
|
||||||
|
drained = drain_fleet_events(advance=False) # peek: NO mueve el cursor
|
||||||
|
print(summarize_fleet_transitions(drained.get("by_classification", {})))
|
||||||
|
except Exception:
|
||||||
|
# Funciones no indexadas, cola corrupta, etc.: degradar sin romper el turno.
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
[ -n "$OUT" ] && printf '%s\n' "$OUT"
|
||||||
|
exit 0
|
||||||
@@ -56,9 +56,13 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fleet_state_inject.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,13 @@
|
|||||||
"command": "./apps/registry_mcp/registry_mcp",
|
"command": "./apps/registry_mcp/registry_mcp",
|
||||||
"args": ["--enable-run", "--enable-write"]
|
"args": ["--enable-run", "--enable-write"]
|
||||||
},
|
},
|
||||||
|
"orchestrator": {
|
||||||
|
"command": "./apps/orchestrator_mcp/orchestrator_mcp",
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "bash",
|
"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,99 @@
|
|||||||
|
---
|
||||||
|
name: check_service_health_via_ssh
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "check_service_health_via_ssh(ssh_host: string, local_url: string, [--token-from-env <remote_env_path> <ENV_VAR>], [--token <literal>], [--expect-status <code>], [--connect-timeout <s>], [--curl-timeout <s>]) -> json"
|
||||||
|
description: "Comprueba la salud de un service HTTP que solo escucha en loopback (127.0.0.1) de un host remoto, entrando por SSH y haciendo curl con bearer token opcional. El token se resuelve dentro del host remoto (leyendo una variable de un .env remoto via grep, o pasado literal) y NUNCA se imprime ni se hardcodea. Emite JSON con http_code y healthy. Reemplaza el patron inline 'ssh host -> grep token .env -> curl -H Authorization: Bearer' repetido en monitorizacion."
|
||||||
|
tags: [ssh, systemd, health, curl, remote, service, bearer, loopback, monitoring, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: ssh_host
|
||||||
|
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: om, organic-machine). Resuelve user/puerto/identityfile del config."
|
||||||
|
- name: local_url
|
||||||
|
desc: "URL del endpoint que el service expone en loopback del host remoto (ej: http://127.0.0.1:8487/agent). No es accesible desde fuera del host."
|
||||||
|
- name: --token-from-env
|
||||||
|
desc: "dos valores: <remote_env_path> <ENV_VAR>. Lee el bearer del .env remoto con grep '^ENV_VAR=' (ej: /home/ubuntu/app/.env AGENTS_API_KEY). El token se resuelve dentro del host, no viaja en argv local."
|
||||||
|
- name: --token
|
||||||
|
desc: "bearer literal (alternativa a --token-from-env). Util para tokens ya en variables de entorno locales; preferir --token-from-env para secretos en disco remoto."
|
||||||
|
- name: --expect-status
|
||||||
|
desc: "codigo HTTP exacto que marca healthy (ej: 200). Si se omite, cualquier 2xx cuenta como healthy."
|
||||||
|
- name: --connect-timeout
|
||||||
|
desc: "timeout de conexion SSH en segundos (default 5)."
|
||||||
|
- name: --curl-timeout
|
||||||
|
desc: "timeout maximo del curl remoto en segundos (default 10)."
|
||||||
|
output: "JSON a stdout: {\"status\":\"ok|error\",\"host\":\"...\",\"url\":\"...\",\"http_code\":NNN,\"healthy\":true|false}. status=error si el SSH fallo sin obtener codigo. healthy=true si http_code coincide con expect-status (o es 2xx por defecto). Exit 0 si healthy, 1 si no, 2 en error de uso."
|
||||||
|
tested: true
|
||||||
|
tests: ["service healthy con token desde env remoto", "service no healthy con http_code 503", "salida JSON nunca filtra el token", "sin token 2xx por defecto es healthy", "falta argumento obligatorio devuelve error de uso", "falta argumento sale con codigo distinto de 0"]
|
||||||
|
test_file_path: "bash/functions/infra/check_service_health_via_ssh_test.sh"
|
||||||
|
file_path: "bash/functions/infra/check_service_health_via_ssh.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/check_service_health_via_ssh.sh
|
||||||
|
|
||||||
|
# 1) Service en loopback del host 'om' con bearer leido de un .env remoto.
|
||||||
|
# Reemplaza el patron inline de monitorizacion del agents_and_robots.
|
||||||
|
result=$(check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||||
|
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
|
||||||
|
--expect-status 200)
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agent","http_code":200,"healthy":true}
|
||||||
|
|
||||||
|
# 2) Sin token (endpoint publico del host pero solo accesible por loopback).
|
||||||
|
check_service_health_via_ssh organic-machine "http://127.0.0.1:8080/healthz"
|
||||||
|
# {"status":"ok","host":"organic-machine","url":"http://127.0.0.1:8080/healthz","http_code":200,"healthy":true}
|
||||||
|
|
||||||
|
# 3) Uso como gate en un script de monitorizacion (exit code).
|
||||||
|
if check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||||
|
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY >/dev/null; then
|
||||||
|
echo "service vivo"
|
||||||
|
else
|
||||||
|
echo "service caido — alertar"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando necesites comprobar si un service HTTP de un host remoto esta sano y ese
|
||||||
|
service **solo escucha en loopback** (127.0.0.1) del host, por lo que no puedes
|
||||||
|
curl-earlo directamente desde tu maquina. Tipico de APIs internas detras de un reverse
|
||||||
|
proxy, daemons con bearer auth, o services systemd que exponen un `/health` privado.
|
||||||
|
Antes de reiniciar un service, en un cron de monitorizacion, o como `e2e_check` de un
|
||||||
|
deploy.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Requiere **SSH por key auth** al host (usa `-o BatchMode=yes`): si el host pide
|
||||||
|
password, falla en vez de colgarse. El alias debe estar en `~/.ssh/config`.
|
||||||
|
- El service objetivo **debe escuchar en loopback del host remoto** — la URL se
|
||||||
|
resuelve *dentro* del host. `http://127.0.0.1:PORT` apunta al host remoto, no a tu PC.
|
||||||
|
- **No requiere sudo**: solo lee un `.env` (grep) y hace curl como el usuario SSH.
|
||||||
|
El usuario SSH debe tener permiso de lectura sobre el `.env` remoto.
|
||||||
|
- El **token nunca se imprime ni se hardcodea**: con `--token-from-env` se resuelve
|
||||||
|
dentro del host y solo se usa en el header `Authorization`. Con `--token <literal>`
|
||||||
|
el secreto queda en el argv del comando ssh local — preferir `--token-from-env`
|
||||||
|
para secretos persistidos en disco.
|
||||||
|
- `grep` del `.env` toma la **primera** linea que matchea `^<ENV_VAR>=` y recorta
|
||||||
|
comillas/espacios. Si la var aparece varias veces o usa interpolacion, revisa el match.
|
||||||
|
- `curl -sf` no sigue redirects: un 3xx cuenta como no-2xx (healthy=false salvo
|
||||||
|
`--expect-status` explicito).
|
||||||
|
- Requiere `curl` instalado en el **host remoto** (no en el local).
|
||||||
|
- El JSON de salida se emite siempre (incluso en fallo); el caller decide por el
|
||||||
|
`exit code` (0 healthy, 1 no healthy, 2 error de uso) o por el campo `healthy`.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Testeable sin red: el runner SSH es inyectable via `CHECK_HEALTH_SSH_BIN` (un stub
|
||||||
|
que emite el `http_code` deseado), por eso los tests no abren conexiones reales.
|
||||||
|
- El snippet remoto normaliza la salida de curl a un unico `http_code` aunque
|
||||||
|
`curl -sf` devuelva error (emite `<curl_rc>:<http_code>` y la funcion extrae el codigo).
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check_service_health_via_ssh — Comprueba la salud de un service HTTP que solo
|
||||||
|
# escucha en loopback de un host remoto, entrando por SSH y haciendo curl con
|
||||||
|
# bearer token opcional (leido de un .env remoto o pasado literal).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
check_service_health_via_ssh() {
|
||||||
|
local ssh_host="" local_url=""
|
||||||
|
local remote_env_path="" env_var=""
|
||||||
|
local token_literal=""
|
||||||
|
local expect_status="" # vacio = aceptar cualquier 2xx
|
||||||
|
local connect_timeout=5
|
||||||
|
local curl_timeout=10
|
||||||
|
|
||||||
|
# --- parseo de args (posicionales + flags) ---
|
||||||
|
local positional=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--token-from-env)
|
||||||
|
remote_env_path="${2:-}"
|
||||||
|
env_var="${3:-}"
|
||||||
|
if [[ -z "$remote_env_path" || -z "$env_var" ]]; then
|
||||||
|
echo "check_service_health_via_ssh: --token-from-env requiere <remote_env_path> <ENV_VAR>" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
shift 3
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
token_literal="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--expect-status)
|
||||||
|
expect_status="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
connect_timeout="${2:-5}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--curl-timeout)
|
||||||
|
curl_timeout="${2:-10}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "check_service_health_via_ssh: flag desconocida '$1'" >&2
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
positional+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ssh_host="${positional[0]:-}"
|
||||||
|
local_url="${positional[1]:-}"
|
||||||
|
|
||||||
|
if [[ -z "$ssh_host" || -z "$local_url" ]]; then
|
||||||
|
echo "check_service_health_via_ssh: uso: check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env_path> <ENV_VAR>] [--token <literal>] [--expect-status 200]" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- construir el snippet remoto que se ejecuta dentro del host via SSH ---
|
||||||
|
# El token NUNCA se imprime: se resuelve dentro del host remoto y se usa
|
||||||
|
# directamente en el header Authorization. El snippet emite SOLO el http_code.
|
||||||
|
#
|
||||||
|
# Casos de token:
|
||||||
|
# 1) --token-from-env: lee el valor de <ENV_VAR>= del .env remoto.
|
||||||
|
# 2) --token <literal>: el literal se inyecta en el snippet (cuidado: queda
|
||||||
|
# en argv del comando ssh local; preferir --token-from-env para secretos).
|
||||||
|
# 3) sin token: curl sin header Authorization.
|
||||||
|
local remote_script
|
||||||
|
if [[ -n "$remote_env_path" ]]; then
|
||||||
|
# grep el valor del .env remoto, recortando posibles comillas y espacios.
|
||||||
|
remote_script=$(cat <<REMOTE
|
||||||
|
set -e
|
||||||
|
TOKEN=\$(grep -E '^[[:space:]]*${env_var}[[:space:]]*=' '${remote_env_path}' 2>/dev/null | head -n1 | cut -d= -f2- | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*\$//' -e 's/^["'\'']//' -e 's/["'\'']\$//')
|
||||||
|
if [ -z "\$TOKEN" ]; then
|
||||||
|
echo "000"
|
||||||
|
exit 7
|
||||||
|
fi
|
||||||
|
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
||||||
|
REMOTE
|
||||||
|
)
|
||||||
|
elif [[ -n "$token_literal" ]]; then
|
||||||
|
remote_script=$(cat <<REMOTE
|
||||||
|
set -e
|
||||||
|
TOKEN='${token_literal}'
|
||||||
|
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
||||||
|
REMOTE
|
||||||
|
)
|
||||||
|
else
|
||||||
|
remote_script=$(cat <<REMOTE
|
||||||
|
set -e
|
||||||
|
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' 2>/dev/null)"
|
||||||
|
REMOTE
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- ejecutar via SSH (o via runner inyectado en tests) ---
|
||||||
|
# CHECK_HEALTH_SSH_BIN permite a los tests sustituir el comando ssh por un
|
||||||
|
# stub que devuelve un http_code fijo, sin tocar la red.
|
||||||
|
local ssh_bin="${CHECK_HEALTH_SSH_BIN:-ssh}"
|
||||||
|
local raw rc=0
|
||||||
|
raw=$("$ssh_bin" -o BatchMode=yes -o ConnectTimeout="$connect_timeout" "$ssh_host" "$remote_script" 2>/dev/null) || rc=$?
|
||||||
|
|
||||||
|
# El snippet remoto, cuando curl -sf falla, emite "<curl_rc>:<http_code>".
|
||||||
|
# Cuando curl tiene exito, emite solo "<http_code>". Normalizamos a http_code.
|
||||||
|
local http_code
|
||||||
|
if [[ "$raw" == *:* ]]; then
|
||||||
|
http_code="${raw##*:}"
|
||||||
|
else
|
||||||
|
http_code="$raw"
|
||||||
|
fi
|
||||||
|
# sanitizar: solo digitos; cualquier otra cosa => 000
|
||||||
|
if [[ ! "$http_code" =~ ^[0-9]+$ ]]; then
|
||||||
|
http_code="000"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Si el SSH en si fallo (conexion, host caido) y no hay codigo util.
|
||||||
|
local status="ok"
|
||||||
|
if [[ "$rc" -ne 0 && "$http_code" == "000" ]]; then
|
||||||
|
status="error"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- decidir healthy ---
|
||||||
|
local healthy="false"
|
||||||
|
if [[ -n "$expect_status" ]]; then
|
||||||
|
[[ "$http_code" == "$expect_status" ]] && healthy="true"
|
||||||
|
else
|
||||||
|
# default: cualquier 2xx
|
||||||
|
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && healthy="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"status":"%s","host":"%s","url":"%s","http_code":%s,"healthy":%s}\n' \
|
||||||
|
"$status" "$ssh_host" "$local_url" "$http_code" "$healthy"
|
||||||
|
|
||||||
|
[[ "$healthy" == "true" ]] && return 0 || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
check_service_health_via_ssh "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests para check_service_health_via_ssh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/check_service_health_via_ssh.sh"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if ! echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected NOT to contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- stub SSH: en vez de conectarse, lee el .env remoto fake (si el snippet lo
|
||||||
|
# referencia) y emite el http_code de la env var STUB_HTTP_CODE. Simula tanto el
|
||||||
|
# caso "curl exito" (solo http_code) como "curl fallo" (<rc>:<http_code>). ---
|
||||||
|
STUB=$(mktemp)
|
||||||
|
chmod +x "$STUB"
|
||||||
|
cat > "$STUB" <<'STUBEOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stub de ssh para tests. Ignora flags -o ... y el host; el ultimo arg es el
|
||||||
|
# script remoto. Emite el codigo segun STUB_HTTP_CODE / STUB_CURL_RC.
|
||||||
|
code="${STUB_HTTP_CODE:-200}"
|
||||||
|
rc="${STUB_CURL_RC:-0}"
|
||||||
|
# Si el script remoto referencia un .env y STUB_TOKEN_EMPTY=1, simular token vacio.
|
||||||
|
if [[ "${STUB_TOKEN_EMPTY:-0}" == "1" ]]; then
|
||||||
|
echo "000"
|
||||||
|
exit 7
|
||||||
|
fi
|
||||||
|
if [[ "$rc" == "0" ]]; then
|
||||||
|
echo "$code"
|
||||||
|
else
|
||||||
|
echo "${rc}:${code}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
STUBEOF
|
||||||
|
chmod +x "$STUB"
|
||||||
|
|
||||||
|
FAKE_ENV=$(mktemp)
|
||||||
|
cat > "$FAKE_ENV" <<'ENVEOF'
|
||||||
|
SOME_OTHER=foo
|
||||||
|
AGENTS_API_KEY=supersecret-token-123
|
||||||
|
ANOTHER=bar
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
trap 'rm -f "$STUB" "$FAKE_ENV"' EXIT
|
||||||
|
|
||||||
|
# --- Test: service healthy con token desde .env remoto (200 esperado) ---
|
||||||
|
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
||||||
|
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||||
|
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
||||||
|
assert_contains "service healthy con token desde env remoto" '"healthy":true' "$result"
|
||||||
|
assert_contains "service healthy con token desde env remoto" '"http_code":200' "$result"
|
||||||
|
assert_contains "service healthy con token desde env remoto" '"status":"ok"' "$result"
|
||||||
|
assert_not_contains "service healthy con token desde env remoto" 'supersecret' "$result"
|
||||||
|
|
||||||
|
# --- Test: service no healthy cuando http_code no coincide con expect-status ---
|
||||||
|
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=503 STUB_CURL_RC=22 \
|
||||||
|
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
||||||
|
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
||||||
|
assert_contains "service no healthy con http_code 503" '"healthy":false' "$result"
|
||||||
|
assert_contains "service no healthy con http_code 503" '"http_code":503' "$result"
|
||||||
|
|
||||||
|
# --- Test: salida JSON nunca filtra el token ---
|
||||||
|
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
||||||
|
check_service_health_via_ssh om "http://127.0.0.1:9000/health" \
|
||||||
|
--token literal-secret-xyz) || true
|
||||||
|
assert_not_contains "salida JSON nunca filtra el token" 'literal-secret-xyz' "$result"
|
||||||
|
assert_contains "salida JSON nunca filtra el token" '"healthy":true' "$result"
|
||||||
|
|
||||||
|
# --- Test: sin token y 2xx por defecto cuenta como healthy ---
|
||||||
|
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=204 \
|
||||||
|
check_service_health_via_ssh om "http://127.0.0.1:8080/ping") || true
|
||||||
|
assert_contains "sin token 2xx por defecto es healthy" '"healthy":true' "$result"
|
||||||
|
assert_contains "sin token 2xx por defecto es healthy" '"http_code":204' "$result"
|
||||||
|
|
||||||
|
# --- Test: falta argumento obligatorio devuelve error de uso ---
|
||||||
|
set +e
|
||||||
|
err=$(check_service_health_via_ssh om 2>&1)
|
||||||
|
ec=$?
|
||||||
|
set -e
|
||||||
|
assert_contains "falta argumento obligatorio devuelve error de uso" 'uso:' "$err"
|
||||||
|
if [[ "$ec" -ne 0 ]]; then
|
||||||
|
echo "PASS: falta argumento sale con codigo distinto de 0"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: falta argumento deberia salir != 0 (got $ec)"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: detect_fleet_context
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "detect_fleet_context() -> JSON {in_fleet,in_tmux,socket,session,source}"
|
||||||
|
description: "Detecta de forma robusta si el proceso corre dentro de una flota tmux FleetView, derivando socket y sesion de $TMUX (senal fiable) en vez de $FLEET_SOCKET (fragil, a veces vacia en un claude resumido/relanzado). Salida JSON con in_fleet/in_tmux/socket/session/source."
|
||||||
|
tags: [orchestration, fleet, tmux, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_go_core
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
file_path: "bash/functions/infra/detect_fleet_context.sh"
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "No recibe argumentos. Lee el entorno ($TMUX, con fallback a $FLEET_SOCKET/$FLEET_SESSION) y consulta el servidor tmux."
|
||||||
|
output: "JSON en stdout: {\"in_fleet\":bool, \"in_tmux\":bool, \"socket\":str, \"session\":str, \"source\":\"tmux|fleet_socket|none\"}. in_tmux=true basta para lanzar una window; in_fleet es la senal semantica de 'estoy en una flota'."
|
||||||
|
---
|
||||||
|
|
||||||
|
# detect_fleet_context
|
||||||
|
|
||||||
|
Detecta el contexto de flota del proceso actual sin depender de `$FLEET_SOCKET`.
|
||||||
|
|
||||||
|
## Por que existe
|
||||||
|
|
||||||
|
La deteccion de "estoy en una flota FleetView" dependia de la variable de
|
||||||
|
entorno `$FLEET_SOCKET`, que `launch_fleetclaude` exporta con
|
||||||
|
`tmux set-environment -g`. Esa variable solo llega a los procesos que tmux
|
||||||
|
arranca **despues** de setearla: un `claude` relanzado o resumido a mano puede
|
||||||
|
no heredarla y `$FLEET_SOCKET` queda vacia, aunque ese claude SI viva en una
|
||||||
|
window de la flota. Cuando eso pasa, el modo orquestador cae al fallback kitty
|
||||||
|
(`launch_claude_agent_kitty`) y lanza ejecutores en terminales sueltas en vez de
|
||||||
|
como windows de la flota.
|
||||||
|
|
||||||
|
La senal **fiable** es `$TMUX`: todo proceso dentro de tmux la tiene SIEMPRE, con
|
||||||
|
el formato `/tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>`. De ahi se extrae
|
||||||
|
el socket (basename del path antes de la primera coma) y, con
|
||||||
|
`tmux -L <socket> display-message -p '#{session_name}'`, la sesion actual.
|
||||||
|
|
||||||
|
## Salida
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Significado |
|
||||||
|
|---|---|
|
||||||
|
| `in_fleet` | Heuristica de "estoy en una flota". `true` si en tmux Y (socket/sesion casan `fleet`, O hay window `fleetview`, O la sesion tiene >= 2 windows). |
|
||||||
|
| `in_tmux` | `true` si el proceso esta dentro de tmux. Basta para lanzar una window (mejor que caer a kitty). |
|
||||||
|
| `socket` | Socket tmux derivado de `$TMUX` (o de `$FLEET_SOCKET` en fallback). |
|
||||||
|
| `session` | Sesion tmux actual resuelta con `display-message` (fallback a `$FLEET_SESSION` o al socket). |
|
||||||
|
| `source` | `tmux` (derivado de `$TMUX`), `fleet_socket` (fallback), o `none`. |
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dentro de una window de la flota fleet3:
|
||||||
|
bash bash/functions/infra/detect_fleet_context.sh
|
||||||
|
# {"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
|
||||||
|
|
||||||
|
# Fuera de tmux, sin FLEET_SOCKET:
|
||||||
|
env -u TMUX -u FLEET_SOCKET bash bash/functions/infra/detect_fleet_context.sh
|
||||||
|
# {"in_fleet":false,"in_tmux":false,"socket":"","session":"","source":"none"}
|
||||||
|
|
||||||
|
# Parsear el socket con jq para pasarlo a spawn_fleet_agent:
|
||||||
|
ctx=$(bash bash/functions/infra/detect_fleet_context.sh)
|
||||||
|
sock=$(printf '%s' "$ctx" | jq -r .socket)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de lanzar un ejecutor de la flota: llama a esta funcion para saber si
|
||||||
|
estas dentro de una flota tmux. Si `in_tmux=true`, lanza con `spawn_fleet_agent`
|
||||||
|
(que ya la usa para auto-detectar el socket); NUNCA caigas a kitty. Tambien la
|
||||||
|
usa el hook `hook_fleet_state_inject.sh` para recordarle al orquestador el socket
|
||||||
|
de su flota cada turno.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es **impura**: consulta el servidor tmux (`display-message`, `list-windows`).
|
||||||
|
No modifica estado.
|
||||||
|
- `in_fleet` es **heuristico** a proposito. Para LANZAR basta `in_tmux=true`
|
||||||
|
(lanzar una window en cualquier tmux supera a una kitty suelta). `in_fleet` es
|
||||||
|
solo la senal semantica que consume el hook y la doctrina.
|
||||||
|
- Fallback `source=fleet_socket`: si `$TMUX` no esta pero `$FLEET_SOCKET` si,
|
||||||
|
devuelve `socket`/`session` de esas vars con `in_tmux=false`. Un
|
||||||
|
`tmux -L <socket> new-window` puede seguir funcionando si el servidor existe,
|
||||||
|
aunque el caller no este attached.
|
||||||
|
- No requiere `jq` ni python: emite el JSON con `printf`, para poder ser el
|
||||||
|
detector base que invocan hooks y otras funciones bash.
|
||||||
|
- Si `tmux` no esta instalado y `$TMUX` esta seteada (raro), `socket` se deriva
|
||||||
|
igual de `$TMUX` pero `session` cae al fallback y `in_fleet` no se puede afinar
|
||||||
|
por windows.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect_fleet_context — detecta de forma robusta si el proceso actual corre
|
||||||
|
# dentro de una sesion tmux de una flota FleetView, derivando el socket y la
|
||||||
|
# sesion de la variable de entorno $TMUX (senal fiable) en vez de depender de
|
||||||
|
# $FLEET_SOCKET (que a veces viene vacia en el entorno de un claude resumido o
|
||||||
|
# relanzado, aunque ese claude SI viva en una window de la flota).
|
||||||
|
#
|
||||||
|
# Por que $TMUX y no $FLEET_SOCKET:
|
||||||
|
# launch_fleetclaude exporta FLEET_SOCKET/FLEET_SESSION con `tmux
|
||||||
|
# set-environment -g`. Esa variable solo llega a los procesos que tmux arranca
|
||||||
|
# DESPUES de setearla; un claude relanzado o resumido a mano puede no heredarla
|
||||||
|
# y entonces $FLEET_SOCKET queda vacia. En cambio, todo proceso que corre
|
||||||
|
# dentro de tmux tiene SIEMPRE $TMUX seteada, con el formato:
|
||||||
|
# /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
|
||||||
|
# De ahi se extrae el socket (basename del path antes de la primera coma) y,
|
||||||
|
# con `tmux -L <socket> display-message -p '#{session_name}'`, la sesion
|
||||||
|
# actual. Eso identifica el contexto fleet sin depender de $FLEET_SOCKET.
|
||||||
|
#
|
||||||
|
# Salida: JSON en stdout con los campos:
|
||||||
|
# in_fleet : true|false — heuristica de "estoy en una flota" (ver criterio).
|
||||||
|
# in_tmux : true|false — estoy dentro de tmux (basta para lanzar una window).
|
||||||
|
# socket : nombre del socket tmux derivado ("" si no hay).
|
||||||
|
# session : nombre de la sesion tmux actual ("" si no se resuelve).
|
||||||
|
# source : "tmux" | "fleet_socket" | "none" — de donde se derivo el contexto.
|
||||||
|
#
|
||||||
|
# Criterio de "flota reconocible" (in_fleet): estar en tmux (in_tmux) Y que se
|
||||||
|
# cumpla al menos uno, de mas fiable a menos:
|
||||||
|
# 1. el socket o la sesion casan el patron de flota (contienen "fleet"), o
|
||||||
|
# 2. existe una window llamada "fleetview" (la TUI de la flota), o
|
||||||
|
# 3. la sesion tiene >= 2 windows (una flota agrupa varios agentes en windows).
|
||||||
|
# Es heuristico a proposito: para LANZAR un ejecutor basta con in_tmux (lanzar
|
||||||
|
# una window en cualquier tmux es mejor que caer a una kitty suelta); in_fleet es
|
||||||
|
# la senal semantica que consume el hook del orquestador y la doctrina.
|
||||||
|
#
|
||||||
|
# Funcion IMPURA: lee el entorno y consulta el servidor tmux (display-message,
|
||||||
|
# list-windows). No modifica estado. Degrada limpio: si tmux no esta o falla
|
||||||
|
# cualquier consulta, devuelve los campos que pueda y nunca aborta con error.
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
detect_fleet_context() {
|
||||||
|
local socket="" session="" source="none"
|
||||||
|
local in_tmux="false" in_fleet="false"
|
||||||
|
|
||||||
|
if [[ -n "${TMUX:-}" ]]; then
|
||||||
|
in_tmux="true"
|
||||||
|
source="tmux"
|
||||||
|
# $TMUX = /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
|
||||||
|
# Socket = basename del path antes de la primera coma.
|
||||||
|
local tmux_path="${TMUX%%,*}"
|
||||||
|
socket="$(basename "$tmux_path" 2>/dev/null || true)"
|
||||||
|
# Sesion actual: tmux resuelve el cliente via $TMUX. -L fija el socket.
|
||||||
|
if command -v tmux >/dev/null 2>&1 && [[ -n "$socket" ]]; then
|
||||||
|
session="$(tmux -L "$socket" display-message -p '#{session_name}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
# Fallback de sesion si display-message no resolvio nada.
|
||||||
|
[[ -z "$session" ]] && session="${FLEET_SESSION:-$socket}"
|
||||||
|
elif [[ -n "${FLEET_SOCKET:-}" ]]; then
|
||||||
|
# No estamos en tmux pero hay FLEET_SOCKET exportada: usarla como ultimo
|
||||||
|
# recurso (un claude que perdio $TMUX pero conserva la env del perfil).
|
||||||
|
in_tmux="false"
|
||||||
|
source="fleet_socket"
|
||||||
|
socket="${FLEET_SOCKET}"
|
||||||
|
session="${FLEET_SESSION:-$socket}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Heuristica in_fleet: solo tiene sentido si estamos en tmux.
|
||||||
|
if [[ "$in_tmux" == "true" && -n "$socket" ]]; then
|
||||||
|
local sl="${socket,,}" sesl="${session,,}"
|
||||||
|
if [[ "$sl" == *fleet* || "$sesl" == *fleet* ]]; then
|
||||||
|
in_fleet="true"
|
||||||
|
elif command -v tmux >/dev/null 2>&1; then
|
||||||
|
# Construir el target de sesion sin trucos de expansion fragiles.
|
||||||
|
local -a tgt=()
|
||||||
|
[[ -n "$session" ]] && tgt=(-t "$session")
|
||||||
|
# window "fleetview" presente => flota.
|
||||||
|
if tmux -L "$socket" list-windows "${tgt[@]}" \
|
||||||
|
-F '#{window_name}' 2>/dev/null | grep -qx 'fleetview'; then
|
||||||
|
in_fleet="true"
|
||||||
|
else
|
||||||
|
# >= 2 windows => agrupacion tipo flota.
|
||||||
|
local nwin
|
||||||
|
nwin="$(tmux -L "$socket" list-windows "${tgt[@]}" \
|
||||||
|
-F x 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
[[ "${nwin:-0}" -ge 2 ]] && in_fleet="true"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# JSON sin dependencias (jq/python no requeridos: este es el detector base).
|
||||||
|
printf '{"in_fleet":%s,"in_tmux":%s,"socket":"%s","session":"%s","source":"%s"}\n' \
|
||||||
|
"$in_fleet" "$in_tmux" "$socket" "$session" "$source"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
detect_fleet_context "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: focus_cdp_tab_window
|
||||||
|
id: focus_cdp_tab_window_bash_infra
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "focus_cdp_tab_window(port: int, [target_id: string]) -> void"
|
||||||
|
description: "Handoff humano de captcha: trae al frente la pestaña (via CDP /json/activate) y la ventana del SO de un Chrome con CDP, para que el humano resuelva el captcha a mano. Promocion del patron inline que acompaña a detect_captcha_go_browser."
|
||||||
|
tags: [browser, captcha, handoff, cdp, wmctrl, xdotool, infra, navegator]
|
||||||
|
params:
|
||||||
|
- name: "port"
|
||||||
|
desc: "Puerto CDP del Chrome (ej. 9333 = Chrome aislado del browser_mcp; 9222 = navegador diario). Obligatorio."
|
||||||
|
- name: "target_id"
|
||||||
|
desc: "Opcional. Target/tab id CDP de la pestaña del captcha. Si se pasa, se activa esa pestaña dentro del browser antes de levantar la ventana del SO. Si se omite, solo se levanta la ventana."
|
||||||
|
output: "Stdout una linea legible y JSON-parseable simple: 'focus_cdp_tab_window: focused win=<wid> pid=<pid> port=<port> tab=<target_id_o_->'. Exit 0 en exito; 2 sin puerto, 3 sin DISPLAY, 4 falta wmctrl/xdotool, 5 no hay chromium en el puerto, 6 sin ventana top-level."
|
||||||
|
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/focus_cdp_tab_window.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Activar la pestaña del captcha (por su target id CDP) y levantar la ventana del Chrome aislado
|
||||||
|
focus_cdp_tab_window 9333 20EF6E28AA792C53AF0D260F34A768B3
|
||||||
|
# -> focus_cdp_tab_window: focused win=0x03a00007 pid=48213 port=9333 tab=20EF6E28AA792C53AF0D260F34A768B3
|
||||||
|
|
||||||
|
# Solo levantar la ventana del Chrome (sin activar tab concreta)
|
||||||
|
focus_cdp_tab_window 9333
|
||||||
|
# -> focus_cdp_tab_window: focused win=0x03a00007 pid=48213 port=9333 tab=-
|
||||||
|
```
|
||||||
|
|
||||||
|
Invocacion canonica via el CLI del registry (despacho bash automatico):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run focus_cdp_tab_window 9333 20EF6E28AA792C53AF0D260F34A768B3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
En el handoff humano de captcha: cuando el `browser_mcp` marca `⚠️ CAPTCHA-DETECTED`
|
||||||
|
(via `detect_captcha_go_browser`), usa esta funcion para traer la pestaña del captcha y la
|
||||||
|
ventana del Chrome al frente para que el humano lo resuelva a mano; luego se le notifica y se
|
||||||
|
para la automatizacion. Pasa el `target_id` de la tab donde se detecto el captcha para activar
|
||||||
|
esa pestaña exacta; omitelo si solo necesitas levantar la ventana del navegador.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura, requiere X11**: necesita un entorno grafico (`$DISPLAY` no vacio) + `wmctrl` + `xdotool`
|
||||||
|
instalados. No sirve headless ni por SSH sin X forwarding — sale con error y exit != 0.
|
||||||
|
- **Match pid->ventana fragil**: resuelve la ventana cruzando el PID del browser principal con la
|
||||||
|
columna PID de `wmctrl -lp`. Puede fallar si el window manager agrupa ventanas o si chromium no
|
||||||
|
expone `_NET_WM_PID` en el main; de ahi el fallback a `xdotool search --pid <pid> --onlyvisible`.
|
||||||
|
- **No reposiciona entre monitores**: solo activa/levanta la ventana donde ya esta; no la mueve a
|
||||||
|
otra pantalla.
|
||||||
|
- **Varias ventanas del mismo Chrome**: si el browser tiene varias ventanas top-level, coge la
|
||||||
|
primera que matchea el PID.
|
||||||
|
- **Activate CDP best-effort**: `curl /json/activate/<target_id>` puede dar 404 si el `target_id`
|
||||||
|
caduco (la tab cambio de id o se cerro). La funcion NO aborta: sigue con el raise de la ventana
|
||||||
|
igualmente.
|
||||||
|
- **Reintento por XFCE**: xfwm pisa el primer `windowactivate`/`windowraise`, por eso se hace el
|
||||||
|
activate+raise dos veces con una espera corta entre medias.
|
||||||
|
- **Identifica el browser process por ausencia de `--type=`**: las lineas de `pgrep` con
|
||||||
|
`--type=renderer/gpu/utility/zygote` son procesos hijos; se descartan para quedarse con el main.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# focus_cdp_tab_window — trae al frente la pestaña + la ventana del SO de un Chrome con CDP
|
||||||
|
#
|
||||||
|
# Handoff humano de captcha: activa la tab del captcha (opcional, via CDP) y levanta
|
||||||
|
# la ventana X11 del proceso browser principal de ese puerto para que un humano resuelva
|
||||||
|
# el captcha a mano. Best-effort y robusto: cada paso continua aunque uno falle.
|
||||||
|
|
||||||
|
focus_cdp_tab_window() {
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
local port="${1:-}"
|
||||||
|
local target_id="${2:-}"
|
||||||
|
|
||||||
|
# 1. Validacion de entorno y dependencias.
|
||||||
|
if [[ -z "$port" ]]; then
|
||||||
|
echo "focus_cdp_tab_window: falta el puerto CDP (uso: focus_cdp_tab_window <port> [target_id])" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${DISPLAY:-}" ]]; then
|
||||||
|
echo "focus_cdp_tab_window: sin entorno grafico (DISPLAY vacio)" >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
if ! command -v wmctrl >/dev/null 2>&1 || ! command -v xdotool >/dev/null 2>&1; then
|
||||||
|
echo "focus_cdp_tab_window: falta wmctrl/xdotool" >&2
|
||||||
|
return 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Activar la tab del captcha dentro del browser (best-effort, no aborta).
|
||||||
|
if [[ -n "$target_id" ]]; then
|
||||||
|
curl -sf "http://127.0.0.1:${port}/json/activate/${target_id}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Encontrar el PID del proceso BROWSER principal de ese puerto.
|
||||||
|
# De las lineas que matchean el flag de debugging, el browser process es el que
|
||||||
|
# NO lleva --type= (los renderers/gpu/utility/zygote son procesos hijos).
|
||||||
|
local browser_pid=""
|
||||||
|
local line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
if [[ "$line" == *"--type="* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# pgrep -af antepone el PID seguido de la cmdline.
|
||||||
|
browser_pid="${line%% *}"
|
||||||
|
break
|
||||||
|
done < <(pgrep -af -- "remote-debugging-port=${port}" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z "$browser_pid" ]]; then
|
||||||
|
echo "focus_cdp_tab_window: no hay chromium en el puerto ${port}" >&2
|
||||||
|
return 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Resolver el window id top-level.
|
||||||
|
# Primero por wmctrl -lp (columna 3 = PID). Fallback xdotool si el main no expone _NET_WM_PID.
|
||||||
|
local wid=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
# Formato: <wid> <desktop> <pid> <host> <title...>
|
||||||
|
local w_id w_pid
|
||||||
|
w_id="$(awk '{print $1}' <<<"$line")"
|
||||||
|
w_pid="$(awk '{print $3}' <<<"$line")"
|
||||||
|
if [[ "$w_pid" == "$browser_pid" ]]; then
|
||||||
|
wid="$w_id"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < <(wmctrl -lp 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -z "$wid" ]]; then
|
||||||
|
wid="$(xdotool search --pid "$browser_pid" --onlyvisible 2>/dev/null | head -n1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$wid" ]]; then
|
||||||
|
echo "focus_cdp_tab_window: no se encontro ventana top-level para pid ${browser_pid} (puerto ${port})" >&2
|
||||||
|
return 6
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Traer al frente con REINTENTO (xfwm de XFCE pisa el primer activate/raise).
|
||||||
|
# Espera no bloqueante con read -t en vez de sleep.
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2; do
|
||||||
|
xdotool windowactivate "$wid" >/dev/null 2>&1 || true
|
||||||
|
read -r -t 0.2 _ < /dev/zero 2>/dev/null || true
|
||||||
|
xdotool windowraise "$wid" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Salida legible y JSON-parseable simple.
|
||||||
|
echo "focus_cdp_tab_window: focused win=${wid} pid=${browser_pid} port=${port} tab=${target_id:--}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecucion directa: focus_cdp_tab_window <port> [target_id]
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
focus_cdp_tab_window "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: kill_fleet_agent
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]"
|
||||||
|
description: "Cierre limpio y dirigido de UN ejecutor de la flota tmux. Dado un sessionId (exacto o prefijo) o un PID, manda SIGTERM al proceso claude del ejecutor (cierre limpio, recuperable con claude --resume) y cierra su window tmux (kill-window) en el socket del perfil FleetView. Lo usa el orquestador para liberar el slot idle de cada ejecutor en cuanto verifica que su DoD-contrato esta met. Guards de seguridad: NUNCA mata a un agente con role=orchestrator (leido de su goal.json) ni a la sesion que invoca la funcion (resuelve su propio PID de claude por los ancestros de /proc). Por defecto EJECUTA; --dry-run imprime el plan sin tocar nada. Es el cierre dirigido a UN agente, frente a reboot_all_claudes que opera sobre toda la flota."
|
||||||
|
tags: [fleet, claude-fleet, orchestration, tmux, kill, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
error_type: error_go_core
|
||||||
|
file_path: "bash/functions/infra/kill_fleet_agent.sh"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "golden: ejecutor por sessionId, PID y prefijo se resuelve y dry-run imprime el plan"
|
||||||
|
- "guard: matar un role=orchestrator devuelve rc=3 y se niega"
|
||||||
|
- "guard: matar la sesion actual (self) devuelve rc=3 y se niega"
|
||||||
|
- "error: target no resuelto rc=2; sin target rc=2"
|
||||||
|
test_file_path: "bash/functions/infra/kill_fleet_agent_test.sh"
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "Primer arg posicional: sessionId del ejecutor (exacto o prefijo) o su PID (todo digitos). Por PID se lee sessions/<pid>.json para el sessionId; por sessionId se busca en sessions/*.json el que case y su archivo da el PID."
|
||||||
|
- name: --socket
|
||||||
|
desc: "Socket tmux del perfil FleetView donde vive la window. Default: $FLEET_SOCKET, o 'fleet' si no esta seteada."
|
||||||
|
- name: --dry-run
|
||||||
|
desc: "Imprime el plan (PID, sessionId, role, window, accion) y NO mata el proceso ni cierra la window. Sin esto, ejecuta."
|
||||||
|
output: "Imprime una linea de plan con PID, sessionId, role, socket y window resueltos, seguida de la accion ejecutada (SIGTERM + kill-window) o, con --dry-run, de DRY-RUN. Exit 0 ok/dry-run; 2 uso incorrecto o target no resuelto a PID; 3 guard (target es un orchestrator o la sesion actual)."
|
||||||
|
---
|
||||||
|
|
||||||
|
# kill_fleet_agent
|
||||||
|
|
||||||
|
Cierra de forma dirigida UN ejecutor de la flota tmux: SIGTERM al proceso `claude` (cierre limpio, recuperable con `claude --resume <sessionId>`) más `kill-window` de su window en el socket del perfil FleetView. Es la pieza que el orquestador usa para **liberar el slot idle** de cada ejecutor en cuanto verifica que su DoD-contrato está `met` — sin esto, los ejecutores terminados se acumulan en reposo en la flota.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cerrar un ejecutor por sessionId (el orquestador lo llama tras verificar `met`):
|
||||||
|
./fn run kill_fleet_agent 32945650-a4e1-472b-90c9-5b38ef60a463 --socket "$FLEET_SOCKET"
|
||||||
|
|
||||||
|
# Por prefijo de sessionId, en el socket por defecto ($FLEET_SOCKET o "fleet"):
|
||||||
|
./fn run kill_fleet_agent 32945650
|
||||||
|
|
||||||
|
# Ver el plan sin matar nada (PID, sessionId, role, window, accion):
|
||||||
|
./fn run kill_fleet_agent 48213 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Úsala desde el modo orquestador justo después de que el verificador independiente devuelva `met` sobre un ejecutor: ciérralo para que no quede ocupando un slot idle en la flota. Resuelve el target por sessionId (exacto o prefijo) o por PID, comprueba los guards y manda SIGTERM + cierra la window. Es el cierre dirigido a **un** agente; para reiniciar/parar **toda** la flota usa `reboot_all_claudes` (con `--exclude-current`). Nunca uses `pkill`/`killall claude` (te matas a ti mismo, el orquestador).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura y destructiva**: manda SIGTERM y cierra una window tmux. Por defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado `met`); usa `--dry-run` para inspeccionar antes.
|
||||||
|
- **Guard anti-orquestador**: si el goal.json del target tiene `role=orchestrator`, rehúsa con exit 3. Evita decapitar la flota por error. El `role` se lee de `~/.claude/goals/<sessionId>.json` (lo escribe `mark_claude_role`).
|
||||||
|
- **Guard anti-self**: resuelve el PID de `claude` de la sesión actual subiendo por los ancestros de `/proc`; si el target coincide, rehúsa con exit 3 ("No me suicido"). Es el equivalente dirigido de la regla "nunca `pkill claude`".
|
||||||
|
- **Resolución de la window**: usa `tmux -L <socket> list-panes -a` y casa `pane_pid == PID`. Funciona porque `spawn_fleet_agent` arranca el ejecutor con `exec claude`, así el `pane_pid` ES el PID de claude. Si no hay socket/tmux, la window queda "(no resuelta)" y solo se manda el SIGTERM (best-effort, no falla).
|
||||||
|
- **SIGTERM, no SIGKILL**: cierre limpio para que Claude Code persista su sesión; el trabajo se puede retomar con `claude --resume <sessionId>`.
|
||||||
|
- **Requiere `jq`** para leer los JSON de sessions/goals.
|
||||||
|
- **Overrides de entorno solo para tests**: `FN_FLEET_SESSIONS_DIR`, `FN_FLEET_GOALS_DIR` y `FN_FLEET_SELF_PID` redirigen los directorios y fuerzan el PID propio; no usarlos en operación normal.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
(v1.0.0 — sin cambios todavía.)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# kill_fleet_agent — cierre limpio y dirigido de UN ejecutor de la flota tmux.
|
||||||
|
#
|
||||||
|
# Dado un sessionId (o prefijo) o un PID, mata el proceso claude del ejecutor con
|
||||||
|
# SIGTERM (cierre limpio) y cierra su window tmux en el socket del perfil
|
||||||
|
# FleetView. Es la pieza que usa el orquestador para liberar el slot idle de cada
|
||||||
|
# ejecutor en cuanto verifica que su DoD-contrato esta `met`: sin esto, los
|
||||||
|
# ejecutores terminados se acumulan en reposo en la flota.
|
||||||
|
#
|
||||||
|
# Guards de seguridad (NO destruye a quien no debe):
|
||||||
|
# - NO mata a un agente con role=orchestrator (leido de su goal.json). Matar un
|
||||||
|
# orquestador por error decapitaria la flota.
|
||||||
|
# - NO se mata a si mismo (la sesion que invoca la funcion): resuelve el PID de
|
||||||
|
# claude actual subiendo por los ancestros de /proc y rechaza el target si
|
||||||
|
# coincide. Es el equivalente dirigido de la regla "nunca pkill claude".
|
||||||
|
#
|
||||||
|
# Funcion IMPURA: manda SIGTERM a un proceso y cierra una window tmux. Por
|
||||||
|
# defecto EJECUTA (es el caso de uso del bot: cerrar un ejecutor ya verificado,
|
||||||
|
# recuperable luego con `claude --resume <sessionId>`). Usa --dry-run para ver el
|
||||||
|
# plan sin tocar nada.
|
||||||
|
#
|
||||||
|
# Overrides de entorno (testabilidad, no para uso normal):
|
||||||
|
# FN_FLEET_SESSIONS_DIR directorio de los sessions JSON. Default ~/.claude/sessions
|
||||||
|
# FN_FLEET_GOALS_DIR directorio de los goal JSON. Default ~/.claude/goals
|
||||||
|
# FN_FLEET_SELF_PID fuerza el PID propio (salta la deteccion por /proc)
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
kill_fleet_agent() {
|
||||||
|
local target="" socket="" dry=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--socket) shift; socket="${1:-}" ;;
|
||||||
|
--dry-run) dry=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
Uso: kill_fleet_agent <sessionId|PID> [--socket <s>] [--dry-run]
|
||||||
|
|
||||||
|
Cierra UN ejecutor de la flota: SIGTERM al proceso claude + kill-window de su
|
||||||
|
window tmux. Resuelve el target por sessionId (exacto o por prefijo) o por PID.
|
||||||
|
|
||||||
|
Guards: NO mata a un role=orchestrator ni a la sesion que invoca la funcion.
|
||||||
|
|
||||||
|
Opciones:
|
||||||
|
--socket <s> Socket tmux del perfil FleetView donde vive la window.
|
||||||
|
Default: $FLEET_SOCKET, o "fleet" si no esta seteada.
|
||||||
|
--dry-run Imprime el plan (PID, sessionId, role, window, accion) y NO
|
||||||
|
mata ni cierra nada.
|
||||||
|
-h, --help Esta ayuda.
|
||||||
|
|
||||||
|
Salida: exit 0 ok (o dry-run); 2 uso incorrecto / target no resuelto; 3 guard
|
||||||
|
(intento de matar a un orquestador o a la sesion actual).
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
kill_fleet_agent 32945650-a4e1-472b-90c9-5b38ef60a463 # por sessionId
|
||||||
|
kill_fleet_agent 32945650 --socket fleet2 # por prefijo de sessionId
|
||||||
|
kill_fleet_agent 48213 --dry-run # por PID, solo ver el plan
|
||||||
|
USAGE
|
||||||
|
return 0 ;;
|
||||||
|
--*)
|
||||||
|
echo "kill_fleet_agent: opcion desconocida '$1' (usa -h)" >&2
|
||||||
|
return 2 ;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
target="$1"
|
||||||
|
else
|
||||||
|
echo "kill_fleet_agent: argumento extra '$1' (target ya es '$target')" >&2
|
||||||
|
return 2
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$target" ]] && {
|
||||||
|
echo "kill_fleet_agent: falta el target (sessionId o PID). Usa -h." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
local sessions_dir="${FN_FLEET_SESSIONS_DIR:-$HOME/.claude/sessions}"
|
||||||
|
local goals_dir="${FN_FLEET_GOALS_DIR:-$HOME/.claude/goals}"
|
||||||
|
[[ -z "$socket" ]] && socket="${FLEET_SOCKET:-fleet}"
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
echo "kill_fleet_agent: jq no esta instalado (necesario para leer los JSON)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver (PID, sessionId) a partir del target.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local pid="" sid=""
|
||||||
|
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
||||||
|
# target = PID. El sessionId sale de sessions/<pid>.json (si existe).
|
||||||
|
pid="$target"
|
||||||
|
local sfile="$sessions_dir/$pid.json"
|
||||||
|
if [[ -f "$sfile" ]]; then
|
||||||
|
sid="$(jq -r '.sessionId // ""' "$sfile" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# target = sessionId (exacto o prefijo). Buscar en sessions/*.json el JSON
|
||||||
|
# cuyo .sessionId case; el nombre del archivo (<pid>.json) da el PID.
|
||||||
|
local f base candidate_sid
|
||||||
|
for f in "$sessions_dir"/*.json; do
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
candidate_sid="$(jq -r '.sessionId // ""' "$f" 2>/dev/null || true)"
|
||||||
|
[[ -z "$candidate_sid" ]] && continue
|
||||||
|
if [[ "$candidate_sid" == "$target" || "$candidate_sid" == "$target"* ]]; then
|
||||||
|
base="$(basename "$f" .json)"
|
||||||
|
pid="$base"
|
||||||
|
sid="$candidate_sid"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -z "$pid" ]] && {
|
||||||
|
echo "kill_fleet_agent: no se pudo resolver el target '$target' a un PID (sessions en $sessions_dir)" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Guard 1 — anti-self: no matar a la sesion que invoca la funcion.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local self_pid="${FN_FLEET_SELF_PID:-}"
|
||||||
|
if [[ -z "$self_pid" ]]; then
|
||||||
|
local walk="$$" guard=0 comm
|
||||||
|
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||||
|
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||||
|
if [[ "$comm" == "claude" ]]; then
|
||||||
|
self_pid="$walk"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||||
|
guard=$((guard + 1))
|
||||||
|
[[ "$guard" -gt 64 ]] && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ -n "$self_pid" && "$pid" == "$self_pid" ]]; then
|
||||||
|
echo "kill_fleet_agent: REHUSADO — el target (PID $pid) es la sesion actual. No me suicido." >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Guard 2 — anti-orquestador: no matar a un role=orchestrator.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local role=""
|
||||||
|
if [[ -n "$sid" ]]; then
|
||||||
|
local gfile="$goals_dir/$sid.json"
|
||||||
|
[[ -f "$gfile" ]] && role="$(jq -r '.role // ""' "$gfile" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ "$role" == "orchestrator" ]]; then
|
||||||
|
echo "kill_fleet_agent: REHUSADO — el target (sessionId ${sid:-?}, PID $pid) tiene role=orchestrator. No se mata al orquestador." >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver la window tmux del PID en el socket (pane_pid == claude por el
|
||||||
|
# `exec claude` de spawn_fleet_agent). Best-effort: vacio si no hay socket.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
local window=""
|
||||||
|
if command -v tmux >/dev/null 2>&1; then
|
||||||
|
window="$(tmux -L "$socket" list-panes -a -F '#{pane_pid} #{window_id}' 2>/dev/null \
|
||||||
|
| awk -v p="$pid" '$1==p {print $2; exit}' || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Plan (se imprime siempre).
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
echo "kill_fleet_agent — target: $target PID: $pid sessionId: ${sid:-?} role: ${role:-executor} socket: $socket window: ${window:-(no resuelta)}"
|
||||||
|
|
||||||
|
if [[ "$dry" -eq 1 ]]; then
|
||||||
|
echo "DRY-RUN: no se ha matado el proceso ni cerrado la window."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Ejecutar: SIGTERM al claude (cierre limpio) + kill-window (idempotente).
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
echo "kill_fleet_agent: SIGTERM enviado a claude PID $pid."
|
||||||
|
else
|
||||||
|
echo "kill_fleet_agent: PID $pid ya no esta vivo (nada que matar)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$window" ]] && command -v tmux >/dev/null 2>&1; then
|
||||||
|
tmux -L "$socket" kill-window -t "$window" 2>/dev/null || true
|
||||||
|
echo "kill_fleet_agent: window $window cerrada en el socket $socket."
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
kill_fleet_agent "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests para kill_fleet_agent. Usa fixtures en dirs temporales (FN_FLEET_*) y
|
||||||
|
# --dry-run para no matar procesos ni cerrar windows reales.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/kill_fleet_agent.sh"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local test_name="$1" needle="$2" haystack="$3"
|
||||||
|
if echo "$haystack" | grep -qF "$needle"; then
|
||||||
|
echo "PASS: $test_name"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||||
|
echo " got: $haystack"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_rc() {
|
||||||
|
local test_name="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
|
echo "PASS: $test_name (rc=$actual)"
|
||||||
|
PASS=$((PASS+1))
|
||||||
|
else
|
||||||
|
echo "FAIL: $test_name — expected rc=$expected, got rc=$actual"
|
||||||
|
FAIL=$((FAIL+1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Fixtures: sessions/<pid>.json + goals/<sid>.json en dirs temporales ---
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
SESS="$TMP/sessions"
|
||||||
|
GOALS="$TMP/goals"
|
||||||
|
mkdir -p "$SESS" "$GOALS"
|
||||||
|
|
||||||
|
# Ejecutor: PID 4242, sessionId executor-aaa-111, role=executor.
|
||||||
|
echo '{"sessionId":"executor-aaa-111","cwd":"/tmp/x"}' > "$SESS/4242.json"
|
||||||
|
echo '{"goal":"hacer X","role":"executor","dod_contract":"golden..."}' > "$GOALS/executor-aaa-111.json"
|
||||||
|
|
||||||
|
# Orquestador: PID 5555, sessionId orchestrator-bbb-222, role=orchestrator.
|
||||||
|
echo '{"sessionId":"orchestrator-bbb-222","cwd":"/tmp/y"}' > "$SESS/5555.json"
|
||||||
|
echo '{"goal":"orquestar","role":"orchestrator"}' > "$GOALS/orchestrator-bbb-222.json"
|
||||||
|
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
export FN_FLEET_SESSIONS_DIR="$SESS"
|
||||||
|
export FN_FLEET_GOALS_DIR="$GOALS"
|
||||||
|
# Forzar self_pid a un valor que NO colisione con los fixtures (salvo el test self).
|
||||||
|
export FN_FLEET_SELF_PID=999999
|
||||||
|
|
||||||
|
# --- Test 1 (golden): resolver ejecutor por sessionId, dry-run imprime plan ---
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent executor-aaa-111 --socket nope --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "golden: ejecutor por sessionId sale 0" 0 "$rc"
|
||||||
|
assert_contains "golden: plan muestra el PID resuelto" "PID: 4242" "$out"
|
||||||
|
assert_contains "golden: plan muestra el sessionId" "executor-aaa-111" "$out"
|
||||||
|
assert_contains "golden: plan muestra role executor" "role: executor" "$out"
|
||||||
|
assert_contains "golden: dry-run no mata" "DRY-RUN" "$out"
|
||||||
|
|
||||||
|
# --- Test 2 (golden por PID + prefijo de sessionId) ---
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent 4242 --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "golden: target por PID sale 0" 0 "$rc"
|
||||||
|
assert_contains "golden: PID resuelve su sessionId" "executor-aaa-111" "$out"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent executor-aaa --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "edge: prefijo de sessionId resuelve" 0 "$rc"
|
||||||
|
assert_contains "edge: prefijo resuelve al PID 4242" "PID: 4242" "$out"
|
||||||
|
|
||||||
|
# --- Test 3 (EDGE guard role): negar matar a un orchestrator ---
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent orchestrator-bbb-222 --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "guard: matar orchestrator devuelve rc=3" 3 "$rc"
|
||||||
|
assert_contains "guard: mensaje menciona role=orchestrator" "role=orchestrator" "$out"
|
||||||
|
|
||||||
|
# --- Test 4 (EDGE guard self): negar matar a la sesion actual ---
|
||||||
|
set +e
|
||||||
|
out=$(FN_FLEET_SELF_PID=4242 kill_fleet_agent executor-aaa-111 --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "guard: matar self devuelve rc=3" 3 "$rc"
|
||||||
|
assert_contains "guard: mensaje self menciona no suicidarse" "No me suicido" "$out"
|
||||||
|
|
||||||
|
# --- Test 5 (ERROR): target no resuelto a un PID ---
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent sesion-inexistente-zzz --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "error: target inexistente devuelve rc=2" 2 "$rc"
|
||||||
|
assert_contains "error: mensaje de no resuelto" "no se pudo resolver" "$out"
|
||||||
|
|
||||||
|
# --- Test 6 (ERROR): falta el target ---
|
||||||
|
set +e
|
||||||
|
out=$(kill_fleet_agent --dry-run 2>&1); rc=$?
|
||||||
|
set -e
|
||||||
|
assert_rc "error: sin target devuelve rc=2" 2 "$rc"
|
||||||
|
assert_contains "error: mensaje falta target" "falta el target" "$out"
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[[ $FAIL -eq 0 ]] || exit 1
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
name: launch_fleetclaude
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.4.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
|
||||||
|
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus 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: "Fija el perfil (socket+sesion tmux comparten nombre) por nombre exacto; reutiliza el existente si ya vive (idempotente sobre ese nombre). Opcional. Sin esta opcion, el perfil se elige automaticamente (primer nombre libre de la secuencia fleet, fleet2, ...)."
|
||||||
|
- name: --reuse
|
||||||
|
desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)."
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Perfil nuevo automatico (fleet la 1a vez; fleet2, fleet3, ... si ya hay uno):
|
||||||
|
launch_fleetclaude
|
||||||
|
|
||||||
|
# Reattach a la flota principal 'fleet' (comportamiento idempotente clasico):
|
||||||
|
launch_fleetclaude --reuse
|
||||||
|
|
||||||
|
# Perfil con nombre fijo y ancho de pane personalizado:
|
||||||
|
launch_fleetclaude --session trabajo --cols 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Tras invocarlo aparece una ventana kitty titulada `FleetView (<perfil>)` con dos
|
||||||
|
panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
||||||
|
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
|
||||||
|
aislados con su propia flota: puedes tener varias FleetView abiertas a la vez.
|
||||||
|
Por defecto, volver a invocarlo abre un perfil NUEVO (no reusa); usa `--reuse`
|
||||||
|
o `--session <nombre>` para volver a una flota concreta.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **Perfiles multiples (default = perfil nuevo)**: sin `--session` ni `--reuse`,
|
||||||
|
cada invocacion abre un perfil NUEVO usando el primer nombre libre de la
|
||||||
|
secuencia `fleet`, `fleet2`, `fleet3`, ... (socket+sesion tmux comparten el
|
||||||
|
nombre del perfil). Asi puedes tener varias FleetView abiertas a la vez, cada
|
||||||
|
una con su flota independiente. Un perfil cerrado libera su nombre: tras matar
|
||||||
|
`fleet`, el siguiente lanzamiento vuelve a `fleet`. Para reattach a una flota
|
||||||
|
concreta: `--reuse` (principal `fleet`) o `--session <nombre>` (idempotente
|
||||||
|
sobre ese nombre, reusa el layout si ya vive).
|
||||||
|
- **Perfil ↔ TUI por entorno**: el launcher inyecta `FLEET_SOCKET`/`FLEET_SESSION`
|
||||||
|
al pane de la TUI (y los fija en el server con `set-environment -g`, para que
|
||||||
|
`respawn-pane` de alt+R y los Claude nuevos hereden el socket). `main.go` los
|
||||||
|
lee con fallback a `fleet`. Por eso cada panel ve SOLO los Claude de su perfil
|
||||||
|
(cruza la lista del sistema con los panes de su socket).
|
||||||
|
- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de
|
||||||
|
una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de
|
||||||
|
nesting); cae a la ruta kitty y abre una ventana nueva. Fuera de tmux y con
|
||||||
|
TTY, reutiliza la terminal actual con `exec tmux attach`.
|
||||||
|
- **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 por perfil (`-L <perfil>`)**: cada perfil vive en su
|
||||||
|
propio server tmux (socket = nombre del perfil), separado del tmux por defecto
|
||||||
|
del usuario y de los demas perfiles. Asi los atajos `bind -n` NO afectan otras
|
||||||
|
sesiones (ej. una sesion `mobile-1` del movil) y matar un perfil no toca los
|
||||||
|
otros: `tmux -L <perfil> kill-server` (o `alt+q` dentro de la TUI).
|
||||||
|
- **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.4.0 (2026-06-18) — **perfiles multiples**. Socket+sesion tmux ya no son el
|
||||||
|
fijo `fleet`: cada perfil tiene los suyos (mismo nombre). Sin `--session`/
|
||||||
|
`--reuse`, cada invocacion abre el primer perfil libre (`fleet`, `fleet2`, ...),
|
||||||
|
asi abrir FleetView con uno ya abierto arranca otra flota en vez de reusarla.
|
||||||
|
Nuevo flag `--reuse` para el reattach idempotente clasico. El launcher inyecta
|
||||||
|
`FLEET_SOCKET`/`FLEET_SESSION` (env + `set-environment -g`) y `main.go` de
|
||||||
|
`fleetview` los lee (fallback `fleet`), de modo que cada panel ve solo su flota.
|
||||||
|
Titulo de kitty `FleetView (<perfil>)`. Guard anti-nesting: invocado dentro de
|
||||||
|
tmux abre ventana kitty nueva en vez de `attach` anidado.
|
||||||
|
- 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,312 @@
|
|||||||
|
#!/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 explicit_session=0 # 1 si el usuario pasó --session <name> a mano
|
||||||
|
local reuse=0 # 1 si el usuario pidió --reuse (reattach al perfil principal)
|
||||||
|
local T="" # socket tmux aislado; se fija al resolver el perfil
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Parseo de argumentos
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--cwd)
|
||||||
|
shift
|
||||||
|
cwd="${1:-}"
|
||||||
|
;;
|
||||||
|
--bin)
|
||||||
|
shift
|
||||||
|
bin="${1:-}"
|
||||||
|
;;
|
||||||
|
--session)
|
||||||
|
shift
|
||||||
|
session="${1:-}"
|
||||||
|
explicit_session=1
|
||||||
|
;;
|
||||||
|
--reuse)
|
||||||
|
reuse=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.
|
||||||
|
|
||||||
|
Cada PERFIL de FleetView es un socket+sesion tmux aislados (su propia flota de
|
||||||
|
Claudes). Sin --session ni --reuse, cada invocacion abre un perfil NUEVO: usa
|
||||||
|
el primer nombre libre de la secuencia fleet, fleet2, fleet3, ... Asi puedes
|
||||||
|
tener varias FleetView abiertas a la vez, cada una con su flota independiente.
|
||||||
|
|
||||||
|
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> Fija el perfil (socket+sesion) por nombre exacto; reutiliza
|
||||||
|
el existente si ya esta vivo. Sin esta opcion, perfil auto.
|
||||||
|
--reuse Reattach al perfil principal 'fleet' en vez de abrir uno
|
||||||
|
nuevo (vuelve al comportamiento idempotente clasico).
|
||||||
|
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
||||||
|
-h, --help Muestra esta ayuda.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...)
|
||||||
|
launch_fleetclaude --reuse # reattach a la flota principal 'fleet'
|
||||||
|
launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo'
|
||||||
|
launch_fleetclaude --cwd ~/fn_registry --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
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Resolver el PERFIL (socket+sesion tmux comparten nombre).
|
||||||
|
#
|
||||||
|
# - --session <name> -> usa ese nombre exacto (reutiliza si ya vive).
|
||||||
|
# - --reuse -> usa 'fleet' (el perfil principal), idempotente.
|
||||||
|
# - sin nada -> perfil NUEVO: primer nombre libre de la secuencia
|
||||||
|
# fleet, fleet2, fleet3, ... Asi abrir FleetView con
|
||||||
|
# uno ya abierto arranca otra flota, no la reusa.
|
||||||
|
#
|
||||||
|
# "Libre" = no hay un server tmux con esa sesion (has-session falla). Un
|
||||||
|
# perfil cerrado libera su nombre, asi que tras cerrar 'fleet' el siguiente
|
||||||
|
# lanzamiento vuelve a 'fleet'.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
if [[ "$explicit_session" -eq 0 && "$reuse" -eq 0 ]]; then
|
||||||
|
local base="$session" n=1 cand
|
||||||
|
while :; do
|
||||||
|
if [[ "$n" -eq 1 ]]; then cand="$base"; else cand="${base}${n}"; fi
|
||||||
|
if ! tmux -L "$cand" has-session -t "$cand" 2>/dev/null; then
|
||||||
|
session="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
echo "launch_fleetclaude: perfil nuevo '$session'."
|
||||||
|
fi
|
||||||
|
# A partir de aqui el socket aislado es el del perfil resuelto.
|
||||||
|
T="tmux -L $session"
|
||||||
|
# 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).
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# La TUI necesita saber a qué perfil pertenece: se lo pasamos por entorno
|
||||||
|
# (FLEET_SOCKET/FLEET_SESSION), que main.go lee con fallback a "fleet".
|
||||||
|
local envpfx
|
||||||
|
envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")"
|
||||||
|
local left_cmd
|
||||||
|
if [[ -x "$bin" ]]; then
|
||||||
|
left_cmd="$envpfx 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 = el ORQUESTADOR de la flota: un Claude que arranca ya en
|
||||||
|
# modo orquestador invocando el skill /orquestador como primer prompt. Es
|
||||||
|
# el Claude con el que el humano habla; vigila la flota por su DoD.
|
||||||
|
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 '/orquestador'" C-m
|
||||||
|
|
||||||
|
# Fijar el ancho del pane izquierdo en columnas.
|
||||||
|
$T resize-pane -t "$left_pane" -x "$cols"
|
||||||
|
|
||||||
|
# Foco inicial en el pane del orquestador (derecha).
|
||||||
|
$T select-pane -t "$right_pane"
|
||||||
|
|
||||||
|
# Marcar el orquestador con role=orchestrator en su goal.json para que la
|
||||||
|
# TUI lo pinee arriba (estrella). El sessionId no se conoce hasta que
|
||||||
|
# Claude escribe sessions/<PID>.json; mark_claude_role resuelve
|
||||||
|
# PID->sessionId esperando ese archivo. En background (no bloquea el
|
||||||
|
# arranque) y con sleep para que el `exec claude` reemplace al shell antes
|
||||||
|
# de leer el pane_pid. Fallo = no-fatal (el orquestador no se pinea).
|
||||||
|
if [[ -x "$repo_root/fn" ]]; then
|
||||||
|
( sleep 1
|
||||||
|
orch_pid=$($T display-message -p -t "$right_pane" '#{pane_pid}' 2>/dev/null || true)
|
||||||
|
[[ -n "$orch_pid" ]] && "$repo_root/fn" run mark_claude_role "$orch_pid" orchestrator >/dev/null 2>&1
|
||||||
|
) >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sembrar 1 ejecutor idle: una window detached con un claude normal,
|
||||||
|
# listo para recibir tarea del orquestador. Aparece en la TUI bajo el
|
||||||
|
# orquestador (role executor por defecto). Hereda FLEET_SOCKET/SESSION
|
||||||
|
# del server (set-environment), asi apunta a este perfil.
|
||||||
|
local idle_pane
|
||||||
|
idle_pane=$($T new-window -d -t "$session" -n claude -c "$cwd" -P -F '#{pane_id}')
|
||||||
|
$T send-keys -t "$idle_pane" "exec claude --dangerously-skip-permissions" C-m
|
||||||
|
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-R send-keys -t "$left_pane" R
|
||||||
|
$T bind -n M-Left send-keys -t "$left_pane" Escape
|
||||||
|
$T bind -n M-q send-keys -t "$left_pane" Q
|
||||||
|
# Entorno del perfil en el server tmux: respawn-pane (alt+R, recompila la TUI)
|
||||||
|
# y los Claude nuevos heredan FLEET_SOCKET/FLEET_SESSION para apuntar al
|
||||||
|
# socket correcto aunque no sea el default "fleet".
|
||||||
|
$T set-environment -g FLEET_SOCKET "$session"
|
||||||
|
$T set-environment -g FLEET_SESSION "$session"
|
||||||
|
# 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:
|
||||||
|
# - Terminal interactiva y FUERA de tmux: 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.
|
||||||
|
# - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir
|
||||||
|
# una ventana kitty nueva desacoplada (setsid). No hacemos `attach`
|
||||||
|
# anidado dentro de otra sesion tmux (rompe / da el warning de nesting).
|
||||||
|
if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then
|
||||||
|
exec tmux -L "$session" attach -t "$session"
|
||||||
|
fi
|
||||||
|
# Ruta ventana-nueva: necesitamos kitty para abrirla.
|
||||||
|
if ! command -v kitty >/dev/null 2>&1; then
|
||||||
|
echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2
|
||||||
|
echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$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: open_doc_onlyoffice
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "open_doc_onlyoffice <ruta_archivo> [--restart]"
|
||||||
|
description: "Abre un documento ofimático (xlsx, docx, pptx, csv, ods, odt, ...) con OnlyOffice Desktop Editors, desacoplado del shell (setsid + background). Localiza el binario por PATH sin hardcodear rutas. Flag --restart cierra toda la app OnlyOffice y la relanza para forzar la recarga desde disco de un archivo regenerado (OnlyOffice cachea en memoria la versión vieja de los documentos abiertos)."
|
||||||
|
tags:
|
||||||
|
- onlyoffice
|
||||||
|
- desktop
|
||||||
|
- office
|
||||||
|
- open
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
error_type: error_go_core
|
||||||
|
params:
|
||||||
|
- name: ruta_archivo
|
||||||
|
desc: "Ruta (relativa o absoluta) del documento ofimático a abrir. Debe existir."
|
||||||
|
- name: --restart
|
||||||
|
desc: "Opcional. Si se pasa, cierra TODA la instancia de OnlyOffice (pkill -x DesktopEditors) antes de relanzar, forzando la recarga desde disco. Cierra cualquier otro documento abierto: usar solo si ninguno tiene cambios sin guardar."
|
||||||
|
output: "Imprime la ruta absoluta abierta. Exit 0 si lanza OnlyOffice; exit 1 si el archivo no existe o el binario no está en PATH; exit 2 en error de uso."
|
||||||
|
file_path: bash/functions/infra/open_doc_onlyoffice.sh
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abrir un documento (lo enfoca si OnlyOffice ya está corriendo)
|
||||||
|
fn run open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx
|
||||||
|
|
||||||
|
# Tras regenerar el archivo en disco, forzar que OnlyOffice lo recargue
|
||||||
|
fn run open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx --restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites abrir o mostrar al usuario un documento ofimático (`.xlsx`, `.docx`, `.pptx`, `.csv`, `.ods`, `.odt`) en su escritorio. Es la forma canónica de abrir documentos en este equipo: el usuario usa OnlyOffice, nunca LibreOffice. Usa `--restart` cuando acabas de regenerar un archivo que probablemente ya está abierto y OnlyOffice muestra la versión cacheada en memoria.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- OnlyOffice es **instancia única**: lanzarlo con un archivo ya abierto reenfoca la pestaña existente con la versión cacheada en memoria, NO recarga desde disco. Por eso existe `--restart`.
|
||||||
|
- `--restart` cierra **toda** la app (`pkill -x DesktopEditors`), no solo la pestaña del archivo. Cualquier otro documento abierto se cierra. No usar si hay documentos con cambios sin guardar.
|
||||||
|
- No hay forma por CLI de cerrar/recargar una sola pestaña: o se acepta la versión cacheada, o se reinicia la app entera.
|
||||||
|
- Usa `setsid` + `&` para que el editor sobreviva al proceso que lo invoca (no muere al cerrar la terminal/sesión).
|
||||||
|
- Localiza el binario con `command -v onlyoffice-desktopeditors`; el proceso real subyacente es `/opt/onlyoffice/desktopeditors/DesktopEditors`.
|
||||||
|
|
||||||
|
## example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx
|
||||||
|
open_doc_onlyoffice ~/Desktop/negocio_dashboards.xlsx --restart # fuerza recarga desde disco
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# open_doc_onlyoffice — abre un documento ofimático con OnlyOffice Desktop Editors.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# open_doc_onlyoffice <ruta_archivo> [--restart]
|
||||||
|
#
|
||||||
|
# Lanza el editor desacoplado del shell (setsid + background) para que sobreviva
|
||||||
|
# al proceso que lo invoca. Localiza el binario por PATH, sin hardcodear rutas.
|
||||||
|
#
|
||||||
|
# --restart cierra toda la instancia de OnlyOffice antes de relanzar, para forzar
|
||||||
|
# la recarga desde disco de un archivo que se regeneró (OnlyOffice mantiene en
|
||||||
|
# memoria la versión vieja de los documentos ya abiertos).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "uso: open_doc_onlyoffice <ruta_archivo> [--restart]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[ $# -ge 1 ] || usage
|
||||||
|
|
||||||
|
doc=""
|
||||||
|
restart=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--restart) restart=1 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) doc="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$doc" ] || usage
|
||||||
|
|
||||||
|
if [ ! -f "$doc" ]; then
|
||||||
|
echo "error: archivo no encontrado: $doc" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bin="$(command -v onlyoffice-desktopeditors || true)"
|
||||||
|
if [ -z "$bin" ]; then
|
||||||
|
echo "error: onlyoffice-desktopeditors no esta en PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ruta absoluta para que OnlyOffice no dependa del directorio de trabajo.
|
||||||
|
doc_abs="$(readlink -f "$doc")"
|
||||||
|
|
||||||
|
if [ "$restart" -eq 1 ]; then
|
||||||
|
# Cierra la app entera para descartar la copia en memoria de los documentos.
|
||||||
|
# pkill -x sobre el comm exacto del proceso real (no -f, para no auto-matar
|
||||||
|
# el propio script si su ruta contiene el patrón).
|
||||||
|
pkill -x DesktopEditors 2>/dev/null || true
|
||||||
|
# Espera (máx ~5s) a que el proceso principal termine antes de relanzar.
|
||||||
|
for _ in $(seq 1 25); do
|
||||||
|
pgrep -x DesktopEditors >/dev/null 2>&1 || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
setsid "$bin" "$doc_abs" >/dev/null 2>&1 &
|
||||||
|
echo "abierto en OnlyOffice: $doc_abs"
|
||||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
|||||||
purity: impure
|
purity: impure
|
||||||
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
|
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."
|
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]
|
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture, orchestration]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: spawn_fleet_agent
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: 1.2.0
|
||||||
|
purity: impure
|
||||||
|
signature: "spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
|
||||||
|
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. --socket/--session son opcionales: si no se pasan se auto-detectan del contexto tmux ($TMUX) via detect_fleet_context (los explicitos tienen prioridad), evitando caer a kitty cuando $FLEET_SOCKET viene vacia. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
|
||||||
|
tags: [fleet, claude-fleet, orchestration, tmux, infra]
|
||||||
|
uses_functions:
|
||||||
|
- mark_claude_role_py_infra
|
||||||
|
- mark_claude_parent_py_infra
|
||||||
|
- detect_fleet_context_bash_infra
|
||||||
|
uses_types: []
|
||||||
|
error_type: error_go_core
|
||||||
|
file_path: "bash/functions/infra/spawn_fleet_agent.sh"
|
||||||
|
tested: false
|
||||||
|
params:
|
||||||
|
- name: --socket
|
||||||
|
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). Opcional: se auto-detecta de $TMUX via detect_fleet_context si no se pasa. El perfil debe estar ya montado (sesion viva)."
|
||||||
|
- name: --session
|
||||||
|
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket). Opcional: se auto-detecta de $TMUX si no se pasa."
|
||||||
|
- name: --cwd
|
||||||
|
desc: "Directorio de trabajo del nuevo Claude. Default: PWD."
|
||||||
|
- name: --prompt-file
|
||||||
|
desc: "Ruta a un archivo cuyo contenido sera el primer prompt del Claude (prompt autocontenido del ejecutor). El cat lo hace el shell del pane, admite multi-linea."
|
||||||
|
- name: --skill
|
||||||
|
desc: "Nombre de un skill a invocar como primer prompt (ej. orquestador -> envia '/orquestador'). Tiene prioridad sobre --prompt-file."
|
||||||
|
- name: --role
|
||||||
|
desc: "Role a escribir en el goal.json del nuevo Claude: orchestrator | executor. Se aplica via mark_claude_role en background. Sin esto, executor implicito."
|
||||||
|
- name: --parent
|
||||||
|
desc: "sessionId del orquestador que lanza este ejecutor. Si se pasa, escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent en background) para que el watcher de fleetview rutee sus avisos al orquestador padre. Opcional; sin esto el aviso no se atribuye a un orquestador concreto."
|
||||||
|
- name: --title
|
||||||
|
desc: "Nombre de la window tmux creada. Default: claude."
|
||||||
|
output: "Imprime por stdout el window_id (ej. @7) de la window tmux creada. Exit 0 ok; 1 error de entorno (tmux ausente, sesion inexistente, prompt-file inexistente); 2 uso incorrecto."
|
||||||
|
---
|
||||||
|
|
||||||
|
# spawn_fleet_agent
|
||||||
|
|
||||||
|
Lanza un Claude dentro de un perfil FleetView (sesion tmux de un socket aislado) como una window nueva, para que forme parte de la flota visible en la TUI `fleetview` y conmutable con `/fleet focus`. Es la pieza que hace que los ejecutores —y el orquestador— vivan en la flota tmux en vez de en kitties dispersas (flow 0012, Fase 3).
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Meter el ORQUESTADOR en la flota actual (arranca en modo + se pinea arriba):
|
||||||
|
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
|
||||||
|
--skill orquestador --role orchestrator --title orquestador
|
||||||
|
|
||||||
|
# Lanzar un EJECUTOR con tarea autocontenida en la misma flota:
|
||||||
|
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
|
||||||
|
--prompt-file /tmp/orq_health.md --title "kanban-health"
|
||||||
|
|
||||||
|
# Ejecutor atribuido a SU orquestador padre (habilita el routing del watcher):
|
||||||
|
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
|
||||||
|
--prompt-file /tmp/orq_health.md --title "kanban-health" \
|
||||||
|
--parent 32945650-a4e1-472b-90c9-5b38ef60a463
|
||||||
|
|
||||||
|
# Sin --socket/--session: auto-detecta el socket de la flota actual ($TMUX).
|
||||||
|
# Forma preferida desde dentro de la flota — no hace falta saber el socket:
|
||||||
|
./fn run spawn_fleet_agent --cwd "$HOME/fn_registry" \
|
||||||
|
--prompt-file /tmp/orq_health.md --title "kanban-health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando el orquestador (o el launcher) necesita arrancar un Claude que debe vivir EN la flota tmux: un ejecutor con tarea, o el propio orquestador. Usala en lugar de `launch_claude_agent_kitty_bash_infra` siempre que ya exista un perfil fleet montado y quieras ver/conmutar el agente desde `fleetview` y `/fleet`. Tras lanzar un ejecutor, escribe su DoD-contrato con `set_dod_contract`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Auto-deteccion de socket/session**: si no pasas `--socket`/`--session`, se derivan de `$TMUX` via `detect_fleet_context`. Los explicitos tienen prioridad. Solo aborta (exit 2) si tras auto-detectar siguen vacios (de verdad no hay tmux). No dependas de `$FLEET_SOCKET`: a veces viene vacia en un claude resumido/relanzado aunque viva en la flota — `$TMUX` es la senal fiable.
|
||||||
|
- El perfil (socket+session) debe estar **ya montado** (`launch_fleetclaude` primero); si la sesion no existe, falla con exit 1.
|
||||||
|
- El `--role` se aplica en **background**: el `sessionId` del nuevo Claude no existe hasta que Claude escribe `~/.claude/sessions/<PID>.json` (unos segundos). `mark_claude_role` espera ese archivo. Si el arranque es muy lento, el role puede tardar en aparecer; es no-fatal (el agente simplemente no se pinea/identifica hasta entonces).
|
||||||
|
- El `--parent` se aplica igual en **background** via `mark_claude_parent` (misma espera del `sessions/<PID>.json`). Cuando se pasan `--role` y `--parent` juntos se encadenan **secuencialmente** en el mismo subshell (primero role, luego parent) para que la segunda escritura lea el goal ya con la primera clave puesta — sin carrera de lectura-modificacion-escritura. Es no-fatal: si el sessions JSON no aparece a tiempo, el `parent_orchestrator` simplemente no se escribe.
|
||||||
|
- `--skill` envia `/<name>` como primer prompt: depende de que Claude Code interprete el primer argumento como invocacion de slash command (verificado con `/orquestador`).
|
||||||
|
- El nuevo Claude hereda `FLEET_SOCKET`/`FLEET_SESSION` del entorno del server tmux (que `launch_fleetclaude` fija con `set-environment`), asi apunta al perfil correcto.
|
||||||
|
- `--dangerously-skip-permissions` siempre (los agentes de la flota trabajan desatendidos); riesgo asumido como en el resto del modo orquestador.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.2.0 (2026-06-21) — `--socket`/`--session` ahora son opcionales: se auto-detectan del contexto tmux (`$TMUX`) via `detect_fleet_context` cuando no se pasan. Elimina el gotcha de caer a kitty cuando `$FLEET_SOCKET` viene vacia pese a estar en la flota. Los valores explicitos siguen primando.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# spawn_fleet_agent — lanza un Claude como window nueva dentro de la sesion tmux
|
||||||
|
# de un perfil FleetView (socket aislado), opcionalmente en modo orquestador
|
||||||
|
# (skill embebida) y marcado con un role en su goal.json.
|
||||||
|
#
|
||||||
|
# Es la forma de que un ejecutor —o el propio orquestador— VIVA en la flota tmux
|
||||||
|
# (visible en la TUI fleetview, conmutable con /fleet focus), en vez de en una
|
||||||
|
# kitty suelta. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de
|
||||||
|
# un perfil fleet ya montado.
|
||||||
|
#
|
||||||
|
# Funcion IMPURA: crea procesos (tmux window + claude) y, si se pide --role,
|
||||||
|
# marca el goal.json del nuevo Claude via mark_claude_role (en background, porque
|
||||||
|
# el sessionId no existe hasta que Claude escribe sessions/<PID>.json).
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$' \t\n'
|
||||||
|
|
||||||
|
spawn_fleet_agent() {
|
||||||
|
local socket="" session="" cwd="" prompt_file="" skill="" role="" parent="" title="claude"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--socket) shift; socket="${1:-}" ;;
|
||||||
|
--session) shift; session="${1:-}" ;;
|
||||||
|
--cwd) shift; cwd="${1:-}" ;;
|
||||||
|
--prompt-file) shift; prompt_file="${1:-}" ;;
|
||||||
|
--skill) shift; skill="${1:-}" ;;
|
||||||
|
--role) shift; role="${1:-}" ;;
|
||||||
|
--parent) shift; parent="${1:-}" ;;
|
||||||
|
--title) shift; title="${1:-claude}" ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
Uso: spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [opciones]
|
||||||
|
|
||||||
|
Lanza un Claude como window nueva en la sesion tmux <session> del socket <socket>
|
||||||
|
(un perfil FleetView ya montado). Imprime el window_id creado.
|
||||||
|
|
||||||
|
--socket/--session son OPCIONALES: si no se pasan, se auto-detectan del contexto
|
||||||
|
tmux actual ($TMUX) via detect_fleet_context. Los valores explicitos tienen
|
||||||
|
prioridad. Aborta solo si tras auto-detectar siguen vacios (no hay tmux).
|
||||||
|
|
||||||
|
Opciones:
|
||||||
|
--prompt-file <f> Primer prompt del Claude = contenido del archivo (prompt
|
||||||
|
autocontenido del ejecutor). El cat lo hace el shell del
|
||||||
|
pane, asi que admite prompts multi-linea.
|
||||||
|
--skill <name> Primer prompt = "/<name>" (invoca un skill al arrancar, ej.
|
||||||
|
--skill orquestador para arrancar en modo orquestador).
|
||||||
|
--role <r> Marca el goal.json del nuevo Claude: orchestrator|executor
|
||||||
|
(via mark_claude_role, en background). Sin esto, executor
|
||||||
|
implicito.
|
||||||
|
--parent <sid> sessionId del orquestador que lanza este ejecutor. Si se
|
||||||
|
pasa, escribe parent_orchestrator en el goal.json del nuevo
|
||||||
|
Claude (via mark_claude_parent, en background) para que el
|
||||||
|
watcher de fleetview rutee sus avisos al orquestador padre.
|
||||||
|
--title <t> Nombre de la window tmux. Default: claude.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
# Orquestador en la flota actual:
|
||||||
|
spawn_fleet_agent --socket fleet2 --session fleet2 --cwd ~/fn_registry \
|
||||||
|
--skill orquestador --role orchestrator --title orquestador
|
||||||
|
# Ejecutor con tarea autocontenida, atribuido a su orquestador padre:
|
||||||
|
spawn_fleet_agent --socket fleet2 --session fleet2 --cwd ~/fn_registry \
|
||||||
|
--prompt-file /tmp/orq_health.md --title "kanban-health" \
|
||||||
|
--parent 32945650-a4e1-472b-90c9-5b38ef60a463
|
||||||
|
USAGE
|
||||||
|
return 0 ;;
|
||||||
|
*)
|
||||||
|
echo "spawn_fleet_agent: opcion desconocida '$1' (usa -h)" >&2
|
||||||
|
return 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# Auto-detectar socket/session del contexto tmux ($TMUX) cuando no se pasan
|
||||||
|
# explicitos. Los --socket/--session explicitos SIEMPRE tienen prioridad.
|
||||||
|
# Esto evita el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
|
||||||
|
# estar dentro de una window de la flota (ver detect_fleet_context).
|
||||||
|
if [[ -z "$socket" || -z "$session" ]]; then
|
||||||
|
local _detector ctx det_socket="" det_session=""
|
||||||
|
_detector="$(dirname "${BASH_SOURCE[0]}")/detect_fleet_context.sh"
|
||||||
|
if [[ -f "$_detector" ]]; then
|
||||||
|
ctx="$(bash "$_detector" 2>/dev/null || true)"
|
||||||
|
# Parseo minimo sin depender de jq: extraer "socket":"..." / "session":"...".
|
||||||
|
det_socket="$(printf '%s' "$ctx" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')"
|
||||||
|
det_session="$(printf '%s' "$ctx" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')"
|
||||||
|
[[ -z "$socket" ]] && socket="$det_socket"
|
||||||
|
[[ -z "$session" ]] && session="$det_session"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -z "$socket" || -z "$session" ]] && {
|
||||||
|
echo "spawn_fleet_agent: no se detecto contexto tmux (\$TMUX vacia) y no se pasaron --socket/--session. Lanza desde dentro de la flota o pasa el socket/session explicito." >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
[[ -z "$cwd" ]] && cwd="$PWD"
|
||||||
|
|
||||||
|
command -v tmux >/dev/null 2>&1 || {
|
||||||
|
echo "spawn_fleet_agent: tmux no esta instalado" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! tmux -L "$socket" has-session -t "$session" 2>/dev/null; then
|
||||||
|
echo "spawn_fleet_agent: la sesion '$session' no existe en el socket '$socket' (lanza la flota primero)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Window nueva detached + claude. send-keys con exec para que el pane_pid sea
|
||||||
|
# el de claude (no el del shell), necesario para mark_claude_role.
|
||||||
|
local win_pane win_id
|
||||||
|
win_pane=$(tmux -L "$socket" new-window -d -t "$session" -n "$title" -c "$cwd" -P -F '#{pane_id}')
|
||||||
|
|
||||||
|
if [[ -n "$skill" ]]; then
|
||||||
|
# Skill como primer prompt: "/<skill>". Claude Code lo interpreta como
|
||||||
|
# invocacion de slash command al arrancar.
|
||||||
|
tmux -L "$socket" send-keys -t "$win_pane" \
|
||||||
|
"exec claude --dangerously-skip-permissions '/$skill'" C-m
|
||||||
|
elif [[ -n "$prompt_file" ]]; then
|
||||||
|
[[ -f "$prompt_file" ]] || {
|
||||||
|
echo "spawn_fleet_agent: --prompt-file '$prompt_file' no existe" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
# El cat lo ejecuta el shell del pane: admite prompts multi-linea.
|
||||||
|
tmux -L "$socket" send-keys -t "$win_pane" \
|
||||||
|
"exec claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"" C-m
|
||||||
|
else
|
||||||
|
tmux -L "$socket" send-keys -t "$win_pane" \
|
||||||
|
"exec claude --dangerously-skip-permissions" C-m
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Marcar role y/o parent_orchestrator en background (no-fatal). El sleep da
|
||||||
|
# tiempo a que el `exec claude` reemplace al shell antes de leer el pane_pid;
|
||||||
|
# mark_claude_role / mark_claude_parent luego esperan a que aparezca
|
||||||
|
# sessions/<PID>.json para resolver el sessionId. Se encadenan SECUENCIALMENTE
|
||||||
|
# en el mismo subshell (primero role, luego parent) para que el segundo lea el
|
||||||
|
# goal ya con la primera clave escrita y no haya carrera de
|
||||||
|
# lectura-modificacion-escritura entre ambos.
|
||||||
|
if [[ -n "$role" || -n "$parent" ]]; then
|
||||||
|
local repo_root fn_bin
|
||||||
|
repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel 2>/dev/null || echo "${FN_REGISTRY_ROOT:-$HOME/fn_registry}")"
|
||||||
|
fn_bin="$repo_root/fn"
|
||||||
|
if [[ -x "$fn_bin" ]]; then
|
||||||
|
( sleep 1
|
||||||
|
pid=$(tmux -L "$socket" display-message -p -t "$win_pane" '#{pane_pid}' 2>/dev/null || true)
|
||||||
|
if [[ -n "$pid" ]]; then
|
||||||
|
[[ -n "$role" ]] && "$fn_bin" run mark_claude_role "$pid" "$role" >/dev/null 2>&1
|
||||||
|
[[ -n "$parent" ]] && "$fn_bin" run mark_claude_parent "$pid" "$parent" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
) >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
win_id=$(tmux -L "$socket" display-message -p -t "$win_pane" '#{window_id}' 2>/dev/null || true)
|
||||||
|
echo "$win_id"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
spawn_fleet_agent "$@"
|
||||||
|
fi
|
||||||
@@ -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
|
||||||
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,177 @@
|
|||||||
|
---
|
||||||
|
name: fleet-orchestrator-dod
|
||||||
|
id: 0012
|
||||||
|
status: pending
|
||||||
|
created: 2026-06-20
|
||||||
|
updated: 2026-06-20
|
||||||
|
priority: high
|
||||||
|
risk: medium
|
||||||
|
related_issues: []
|
||||||
|
apps:
|
||||||
|
- fleetview
|
||||||
|
- fleet_watcher
|
||||||
|
- dag_engine
|
||||||
|
trigger: manual
|
||||||
|
schedule: ""
|
||||||
|
expected_runtime_s: 0
|
||||||
|
tags: [orchestration, fleet, dod, multi-agent, watcher]
|
||||||
|
|
||||||
|
# Contrato de evidencia DoD del sistema completo (las superficies observables que
|
||||||
|
# prueban que el meta-orquestador funciona, no solo que compila).
|
||||||
|
dod_evidence_schema:
|
||||||
|
- id: watcher_events
|
||||||
|
kind: cmd
|
||||||
|
expected: "wc -l ~/.claude/fleet/events.jsonl > 0; cada linea es una TRANSICION de estado (edge), no un nivel repetido"
|
||||||
|
required: true
|
||||||
|
- id: dod_contract_on_spawn
|
||||||
|
kind: cmd
|
||||||
|
expected: "todo agente lanzado por el orquestador tiene dod_contract no vacio en su goal.json; spawn sin dod_contract se rechaza"
|
||||||
|
required: true
|
||||||
|
- id: verifier_verdict
|
||||||
|
kind: log
|
||||||
|
expected: "al cerrar un agente, existe un veredicto del verificador (met|failed) con evidencia citada; el verificador NO es el mismo agente que ejecuto la tarea"
|
||||||
|
required: true
|
||||||
|
- id: human_load_reduction
|
||||||
|
kind: url
|
||||||
|
expected: "con N>=10 agentes vivos, el orquestador presenta UN resumen agrupado por prioridad (no N mensajes sueltos); el humano responde solo lo que requiere decision"
|
||||||
|
required: true
|
||||||
|
- id: push_on_reclama
|
||||||
|
kind: screenshot
|
||||||
|
expected: "un agente que pasa a waiting/preguntando/bloqueado dispara PushNotification al movil en < 1 min"
|
||||||
|
required: true
|
||||||
|
- id: stall_nudge
|
||||||
|
kind: log
|
||||||
|
expected: "un agente idle con dod_contract incompleto y sin actividad N min recibe un nudge automatico (send-keys) registrado en fleet_events; jamas se nudgea a un agente en waiting"
|
||||||
|
required: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Un meta-orquestador que permita a una persona manejar una flota de 20-30 agentes Claude hablando solo con uno. El orquestador no entra nunca en los detalles de cada agente: vigila estados, persigue que cada agente **termine lo que empieza** (cumpla un DoD-contrato fijo), y solo escala a la persona lo que requiere su decisión. La métrica de salud es el **throughput de DoD cumplidos**, no el número de agentes vivos.
|
||||||
|
|
||||||
|
## Problema que resuelve
|
||||||
|
|
||||||
|
Hoy lanzar muchos Claudes produce saturación: N ventanas dispersas, N agentes que quedan idle sin cerrar nada, y la persona como cuello de botella revisando todo. El criterio de "terminado" tampoco existe de forma estable: el campo `dod` del `goal.json` lo reescribe el hook GOAL-TRACKER con cada prompt (es un resumen móvil), así que no hay un blanco fijo contra el que evaluar la terminación. Resultado: 30 agentes vivos que no resuelven nada.
|
||||||
|
|
||||||
|
## Arquitectura: 4 roles
|
||||||
|
|
||||||
|
El orquestador delega, nunca ejecuta (regla `orquestador-delega-no-ejecuta`). Reparto:
|
||||||
|
|
||||||
|
```
|
||||||
|
orquestador (la persona habla SOLO con el; solo vigila, agrupa y escala)
|
||||||
|
├── splitter agente EFIMERO. Tarea grande -> la parte en sub-tareas
|
||||||
|
│ atomicas (paralelas o secuenciales), cada una con su
|
||||||
|
│ dod_contract pequeno y verificable. Tope de fan-out.
|
||||||
|
├── ejecutores Claudes INTERACTIVOS en la flota tmux (los 20-30 que la
|
||||||
|
│ persona ve). Cada uno con UNA tarea y UN dod_contract.
|
||||||
|
└── verificador agente EFIMERO e INDEPENDIENTE del ejecutor. Al cierre:
|
||||||
|
compara lo hecho contra el dod_contract -> met | failed
|
||||||
|
con evidencia citada. Cero auto-aprobacion.
|
||||||
|
```
|
||||||
|
|
||||||
|
Distinción dura: **splitter y verificador son agentes efímeros** (subagentes vía Agent tool / SDK: corren, devuelven un resultado estructurado y mueren). NO ocupan slot en la flota visible. La flota que la persona maneja = solo **ejecutores con tarea**. La maquinaria de verificación y descomposición es invisible para ella.
|
||||||
|
|
||||||
|
El reparto de coste: **el watcher vigila (barato, sin LLM, siempre activo); el orquestador y los agentes efímeros piensan (caro, solo cuando hay algo que decidir).**
|
||||||
|
|
||||||
|
## Modelo de datos: DoD-contrato fijo
|
||||||
|
|
||||||
|
En el `goal.json` de cada agente conviven dos campos distintos:
|
||||||
|
|
||||||
|
- `dod` (ya existe) — resumen móvil que el hook GOAL-TRACKER reescribe con cada prompt. Se queda como está.
|
||||||
|
- `dod_contract` (NUEVO, FIJO) — criterio de aceptación con evidencia ejecutable, escrito UNA vez al lanzar el agente y nunca reescrito por hooks. Es el blanco estable contra el que se evalúa "terminado".
|
||||||
|
- `dod_status` (NUEVO) — `pending | met | failed`, lo actualiza el verificador.
|
||||||
|
|
||||||
|
El hook GOAL-TRACKER debe respetar `dod_contract`/`dod_status` (solo reescribe `dod`). Spawn sin `dod_contract` se rechaza: ningún agente arranca sin saber cuándo habrá terminado.
|
||||||
|
|
||||||
|
## Máquina de terminación (lo que el watcher clasifica, mecánico, sin LLM)
|
||||||
|
|
||||||
|
| Estado del agente | Clasificación | Acción |
|
||||||
|
|---|---|---|
|
||||||
|
| `waiting` / phase `preguntando`/`bloqueado` | RECLAMA | escalar a la persona (push inmediato) |
|
||||||
|
| `idle` + phase `hecho` | DICE-TERMINADO | orquestador lanza verificador contra `dod_contract` |
|
||||||
|
| `idle` + phase≠hecho + sin actividad N min | ESTANCADO | nudge automático: "cierra tu DoD" |
|
||||||
|
| `busy` + phase `haciendo`/`testeando` | TRABAJANDO | no molestar |
|
||||||
|
| sin `dod_contract` | MAL LANZADO | bloquear / re-lanzar con DoD |
|
||||||
|
|
||||||
|
"Dar por terminado al hablar con ellos": cuando la persona se enruta a un ejecutor, lo primero es cerrar su `dod_contract` — si el verificador dice met, se cierra/reasigna; si quedó a medias, se empuja a terminar antes de abrir nada nuevo.
|
||||||
|
|
||||||
|
## Fases de construcción
|
||||||
|
|
||||||
|
### Fase 1 — watcher (cerebro barato, sin LLM) — DENTRO de fleetview [HECHO 2026-06-20]
|
||||||
|
|
||||||
|
Decisión: NO es un daemon aparte. fleetview ya es un proceso vivo que pollea la flota cada segundo y vive mientras la sesión tmux fleet (y por tanto la flota) exista. El watcher se embebe ahí (KISS). En cada refresco de la TUI:
|
||||||
|
1. Snapshot del fleet (`list_claude_fleet`, con `dod_contract`/`dod_status`/`role`).
|
||||||
|
2. Clasifica cada agente con `classify_fleet_termination` (función pura del registry).
|
||||||
|
3. Diff contra el snapshot anterior (en memoria) -> transiciones (edge-triggered, no nivel).
|
||||||
|
4. Escribe un evento por transición en la cola JSONL `~/.claude/fleet/events.jsonl` (sin SQLite/CGO — KISS). Línea: `{ts, session_id, pid, from, to, goal, phase, urgent}`.
|
||||||
|
5. Marca `urgent=true` en transición a RECLAMA. El push real al móvil lo hace el orquestador (Fase 2) leyendo la cola; el watcher solo marca.
|
||||||
|
|
||||||
|
Estado: modelo de datos (`DodContract`/`DodStatus`/`Role` en `ClaudeFleet`) + `classify_fleet_termination` + watcher embebido (`watcher.go`) — construidos y testeados (7 tests del watcher + 34 del clasificador). **Validado en vivo 2026-06-20**: tras relanzar fleetview con el binario nuevo, una transición real (inyectar `dod_status=met` en un agente idle → `MAL_LANZADO`→`DICE_TERMINADO`) quedó escrita como una línea en `~/.claude/fleet/events.jsonl`; el `goal.json` se restauró. Las 3 capas DoD de Fase 1 cumplidas.
|
||||||
|
|
||||||
|
Hallazgo: toda la flota lanzada hasta hoy clasifica `MAL_LANZADO` (ningún `dod_contract` escrito todavía). Es el comportamiento correcto (regla "ningún agente sin DoD") y lo que Fase 3 corrige al escribir `dod_contract` en el spawn.
|
||||||
|
|
||||||
|
DoD Fase 1:
|
||||||
|
- Golden: un agente pasa busy->idle -> aparece 1 evento `DICE-TERMINADO` o `ESTANCADO` en `fleet_events`.
|
||||||
|
- Edge 1: el mismo agente sigue idle 10 ticks -> NO se duplica el evento (edge, no nivel).
|
||||||
|
- Edge 2: un agente pasa a waiting -> evento RECLAMA + push en < 1 min.
|
||||||
|
- Error 1: goal.json corrupto/ausente -> el agente se clasifica MAL LANZADO sin crash del watcher.
|
||||||
|
- Vida: 7 días corriendo, 0 crashes (`journalctl`/log), cola sin huecos.
|
||||||
|
|
||||||
|
### Fase 2 — orquestador-Claude reactivo + verificador + splitter
|
||||||
|
|
||||||
|
Extiende el skill `/orquestador`. NO hace polling. Despierta por: la persona | heartbeat largo (ScheduleWakeup 20-30 min) | push del watcher. Al despertar:
|
||||||
|
1. Vacía `fleet_events`, agrupa por prioridad (RECLAMA > DICE-TERMINADO > ESTANCADO) y por ámbito.
|
||||||
|
2. Para DICE-TERMINADO: lanza un **verificador** (Agent efímero) que compara el output del ejecutor con su `dod_contract` -> met/failed+evidencia. met -> autocierra y reporta; failed -> nudge al ejecutor con el gap o escala.
|
||||||
|
3. Para ESTANCADO: nudge (send-keys) bajo política (solo idle con DoD pendiente; jamás waiting).
|
||||||
|
4. Para RECLAMA: presenta a la persona UN resumen corto con la decisión concreta que se necesita. Usa `/fleet focus` para saltarla al agente elegido.
|
||||||
|
|
||||||
|
DoD Fase 2:
|
||||||
|
- Golden: un agente DICE-TERMINADO con DoD realmente cumplido -> verificador met -> autocierre + reporte, sin intervención humana.
|
||||||
|
- Edge 1: agente DICE-TERMINADO con DoD a medias -> verificador failed -> nudge con el gap, no se cierra.
|
||||||
|
- Edge 2: 10 agentes con eventos a la vez -> un solo resumen agrupado, no 10 mensajes.
|
||||||
|
- Error 1: verificador no puede leer el output -> reporta "no evaluable", escala, no autocierra en falso.
|
||||||
|
- Vida: 7 días gestionando flota real; la persona responde solo decisiones, no enrutamiento.
|
||||||
|
|
||||||
|
Estado [CONSTRUIDO 2026-06-20]: primitivas `drain_fleet_events` (consume la cola, 7 tests) y `set_dod_contract` (escribe el DoD-contrato fijo, 5 tests) creadas, indexadas y validadas (set_dod_contract en vivo). Skill `/orquestador` evolucionado con la sección "Consumo de la cola de la flota": DoD-contrato obligatorio al lanzar, drenar, políticas por clasificación, verificador independiente (lee el report vs dod_contract), splitter con tope de fan-out, cadencia. Pendiente (capa Vida): uso real del modo — el verificador y el splitter son prompts de Agent en el skill, aún no ejercitados en un cierre real (requiere un agente con report terminado).
|
||||||
|
|
||||||
|
### Fase 3 — spawn dentro de la flota + splitter
|
||||||
|
|
||||||
|
Extiende `/orquestador` para lanzar ejecutores con `TmuxNewClaudeWindow` (socket fleet) en vez de kitties sueltas, escribiendo `dod_contract` en el `goal.json` del nuevo agente y un prompt con el DoD claro. Antes de spawnar, si la tarea se estima grande, pasa por el **splitter** (Agent efímero) que devuelve un plan de sub-tareas con dependencias; el orquestador spawna un ejecutor por sub-tarea (paralelas a la vez, secuenciales encadenadas).
|
||||||
|
|
||||||
|
DoD Fase 3:
|
||||||
|
- Golden: una tarea atómica -> 1 ejecutor en la flota con `dod_contract` escrito; `/fleet` lo lista.
|
||||||
|
- Edge 1: una tarea grande -> splitter devuelve >=2 sub-tareas, cada una con su `dod_contract`; se spawnan respetando deps.
|
||||||
|
- Edge 2: tope de fan-out -> el splitter nunca genera más de K sub-agentes de golpe (sin explosión).
|
||||||
|
- Error 1: spawn sin `dod_contract` -> rechazado con mensaje claro.
|
||||||
|
- Vida: 7 días lanzando trabajo real por esta vía.
|
||||||
|
|
||||||
|
Estado [PARCIAL 2026-06-20]: el **launcher** (`launch_fleetclaude`) ya arranca el orquestador con el skill `/orquestador` embebido como primer prompt (validado en vivo: entra en modo), le pone `role=orchestrator` con `mark_claude_role` (la TUI lo pinea arriba con ★, validado), y siembra 1 ejecutor idle inicial en la flota tmux. El `dod_contract` al spawn está en el skill (Fase 2). **Gap 1 CERRADO [2026-06-20]**: `spawn_fleet_agent` lanza ejecutores (y el orquestador) como windows de la flota tmux del perfil (no kitties sueltas), con `--skill` para arrancar en modo, `--prompt-file` para ejecutores autocontenidos y `--role` (via `mark_claude_role`). El skill `/orquestador` (paso 2) ahora prefiere `spawn_fleet_agent` sobre kitty cuando hay `$FLEET_SOCKET`. Validado en vivo: el orquestador arrancó en `fleet2` en modo, `role=orchestrator`, pinneado arriba, sin perder los 9 ejecutores existentes. **Gaps restantes** (capa Vida): el verificador independiente y el splitter están descritos en el skill pero aún no ejercitados en un cierre/descomposición real (requiere un ciclo completo de tarea con report).
|
||||||
|
|
||||||
|
## Pre-requisitos
|
||||||
|
|
||||||
|
- Sesión tmux fleet activa (perfil `launch_fleetclaude`); `/fleet` operativo (flow previo).
|
||||||
|
- PushNotification configurado (Remote Control activo en el móvil).
|
||||||
|
- dag_engine activo para schedule del watcher (regla `dag-engine-over-cron`).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] `dod_contract` fijo escrito al spawn y respetado por el hook GOAL-TRACKER.
|
||||||
|
- [ ] watcher edge-triggered con eventos en `fleet_events` + push en RECLAMA.
|
||||||
|
- [ ] verificador independiente del ejecutor, con veredicto+evidencia.
|
||||||
|
- [ ] splitter con tope de fan-out para tareas grandes.
|
||||||
|
- [ ] orquestador presenta resumen agrupado, no N mensajes; usa `/fleet focus`.
|
||||||
|
- [ ] la persona maneja >=10 agentes respondiendo solo decisiones.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- [ ] **Mecánica**: watcher + funciones del registry compilan, `fn doctor` verde, sin drift `uses_functions`.
|
||||||
|
- [ ] **Cobertura**: cada fase con su golden + >=2 edge + >=1 error path, evidencia ejecutable (ver DoD por fase).
|
||||||
|
- [ ] **Vida útil**: >=7 días de uso real gestionando flota, 0 crashes del watcher, 0 "done" falsos detectados (verificador funciona).
|
||||||
|
- [ ] **Carga humana**: medible reducción — la persona responde decisiones, no enrutamiento ni vigilancia.
|
||||||
|
- [ ] **Secrets**: cero credenciales fuera de pass/vaults; el watcher no loguea contenido de sesiones, solo estados/transiciones.
|
||||||
|
|
||||||
|
## Notas (onboarding)
|
||||||
|
|
||||||
|
Para usarlo: lanzas trabajo por el orquestador (no abres Claudes a mano). Cada tarea recibe un `dod_contract`. El watcher vigila en background y empuja al móvil cuando un agente te reclama. Cuando vuelves, el orquestador te da un resumen agrupado y te lleva (`/fleet focus`) solo a lo que necesita tu decisión; lo demás (verificar cierres, empujar estancados, dividir tareas grandes) lo hace solo con agentes efímeros. La flota que ves = ejecutores con tarea; la maquinaria de verificación/división es invisible.
|
||||||
|
|
||||||
|
Relación con otras reglas: `dod_quality` (las 3 capas + verificador independiente), `orquestador-delega-no-ejecuta` (el orquestador no ejecuta), `dag-engine-over-cron` (schedule del watcher), `autonomous_loop` (fn-orquestador autónomo es el primo no-interactivo de este flujo), y el flow previo de `/fleet`/`fleetview` (la base de datos de estado).
|
||||||
@@ -13,6 +13,7 @@ Tabla de casos de uso multi-app. Mantenida por `/flow create` y `/flow done`.
|
|||||||
| [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 |
|
| [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 |
|
||||||
| [0008](0008-kanban-cpp-and-agent-workflows.md) | kanban-cpp-and-agent-workflows | realtime-loop | kanban_cpp, kanban, skill_tree, agent_runner_api | pending | medium | 0% | 2026-05-18 |
|
| [0008](0008-kanban-cpp-and-agent-workflows.md) | kanban-cpp-and-agent-workflows | realtime-loop | kanban_cpp, kanban, skill_tree, agent_runner_api | pending | medium | 0% | 2026-05-18 |
|
||||||
| [0009](0009-agentes-dispositivos-mesh.md) | agentes-dispositivos-mesh | event-driven | agents_dashboard, agents_and_robots, wg_hub, device_agent | pending | high | 0% | 2026-05-23 |
|
| [0009](0009-agentes-dispositivos-mesh.md) | agentes-dispositivos-mesh | event-driven | agents_dashboard, agents_and_robots, wg_hub, device_agent | pending | high | 0% | 2026-05-23 |
|
||||||
|
| [0012](0012-fleet-orchestrator-dod.md) | fleet-orchestrator-dod | event-driven | fleetview, fleet_watcher, dag_engine | pending | medium | 0% | 2026-06-20 |
|
||||||
|
|
||||||
## Leyenda
|
## Leyenda
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
|
||||||
@@ -40,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 |
|
| [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 |
|
| [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 |
|
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||||
|
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||||
| [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 |
|
| [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 |
|
| [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 |
|
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||||
@@ -50,9 +53,21 @@ 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 |
|
| [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 |
|
| [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 |
|
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||||
| [obsidian](obsidian.md) | 14 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve. Sin app GUI |
|
| [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-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 |
|
| [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 |
|
||||||
|
| [browser-profiles](browser-profiles.md) | 4 | Catalogo de perfiles del navegador Chromium para investigaciones multicuenta OSINT: por perfil guarda que correo/cuentas usar (secret_ref a pass, nunca el password), proposito, persona y nota del vault, y lanza el perfil listo via systemd-run. Fuente de verdad en el service osint_db (tablas browser_profiles + browser_profile_accounts) |
|
||||||
|
| [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 |
|
||||||
|
| [consent](consent.md) | 3 | CMP / IAB TCF / data brokers: detectar el CMP de un sitio (Didomi/OneTrust/Sourcepoint/Quantcast), leer `__tcfapi` para contar vendors y propositos, aceptar el banner (selectores + fallback LLM con haiku que localiza Aceptar/Ver socios), y descargar la GVL de IAB para nominar cada broker y que datos recopila. Nacio de `projects/databrokers/` |
|
||||||
|
| [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) |
|
||||||
|
| [email](email.md) | 21 | Gestionar cuentas de correo por IMAP+SMTP directo (Python stdlib, sin browser ni MCP Gmail): conectar/listar/buscar/leer (imap_*), mutar estado (mark_seen/move/delete/save_draft) por UID, y construir+enviar (email_build_html/smtp_send). Auth user+app-password (NO OAuth; Outlook fuera). Credenciales desde pass, resueltas por la capa app. Complementa al browser (interactivo) — no lo reemplaza |
|
||||||
|
| [eda](eda.md) | 27 | Exploratory Data Analysis por tabla y base con motor DuckDB + PostgreSQL push-down: perfil base SQL (SUMMARIZE + distinct exacto), estadística numérica/categórica, tipo semántico regex, calidad, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK containment + join graph mermaid), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM (dictionary/PII/limpieza/análisis) y generación de notebook. Orquestadores `profile_table` (backend duckdb/postgres, flags run_models/run_llm) y `profile_database` |
|
||||||
|
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||||
|
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Capability: browser-profiles
|
||||||
|
|
||||||
|
Catálogo operativo de los perfiles del navegador Chromium para investigaciones
|
||||||
|
multicuenta OSINT. Por cada perfil de Chromium (un `--profile-directory` dentro
|
||||||
|
de un user-data-dir) guarda **qué correo/cuentas usar, propósito, persona e
|
||||||
|
identidad de la investigación** y la nota del vault que lo documenta, y permite
|
||||||
|
**lanzar el perfil** listo para trabajar mostrando sus cuentas. La fuente de
|
||||||
|
verdad vive en el service `osint_db` (FastAPI + DuckDB, `http://127.0.0.1:8771`),
|
||||||
|
en las tablas `browser_profiles` + `browser_profile_accounts` (schema main,
|
||||||
|
pobladas solo por API, como `network_scans`). Estas funciones son clientes HTTP
|
||||||
|
finos a ese service.
|
||||||
|
|
||||||
|
**Regla de seguridad dura:** una cuenta guarda `secret_ref` — una **referencia**
|
||||||
|
al secreto (ej. `pass show osint/p1/gmail`), NUNCA la contraseña en claro. Ni el
|
||||||
|
service ni estas funciones almacenan o resuelven credenciales: `browser_profile_open`
|
||||||
|
solo expone el `secret_ref` para que el operador (o otra herramienta) lo resuelva
|
||||||
|
con `pass`/keepass.
|
||||||
|
|
||||||
|
Comparte el ecosistema del project `osint` (vault Obsidian + service `osint_db`)
|
||||||
|
con los grupos `recon`, `osint-passive` y `dav`. El perfil real de Chromium vive
|
||||||
|
en `~/.config/chromium-cdp` (user-data-dir con CDP 9222 inyectado por el wrapper
|
||||||
|
`/usr/bin/chromium`); el catálogo NO toca el perfil en disco, solo su metadata.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `browser_profile_register_py_browser` | `browser_profile_register(profile_dir, label="", persona="", purpose="", note_path="", tags=None, notes="", user_data_dir="", status="active", accounts=None, base_url=...) -> dict` | Registra/actualiza un perfil y, opcionalmente, sus cuentas en una sola llamada (1 POST del perfil + 1 POST por cuenta). Idempotente (upsert por `profile_dir` y por `id` de cuenta). `accounts` es una lista de dicts `{service, identity, secret_ref?, role?, status?, notes?}`. |
|
||||||
|
| `browser_profile_list_py_browser` | `browser_profile_list(status=None, base_url=...) -> dict` | Lista los perfiles del catálogo con su nº de cuentas (`n_accounts`). Filtro opcional por `status` (active/archived). Devuelve `{"status":"ok","profiles":[...]}`. |
|
||||||
|
| `browser_profile_show_py_browser` | `browser_profile_show(profile_dir, base_url=...) -> dict` | Muestra un perfil con todas sus cuentas. Devuelve `{"status":"ok","profile":{...},"accounts":[...]}` o error si no existe. Las cuentas traen `secret_ref` (referencia), nunca el password. |
|
||||||
|
| `browser_profile_open_py_browser` | `browser_profile_open(profile_dir, url=None, base_url=..., dry_run=False) -> dict` | Lanza Chromium en el perfil (`--profile-directory`) vía `systemd-run --user --scope` (evita exit-144) y devuelve sus cuentas/`secret_ref` para saber qué usar. `dry_run=True` devuelve el comando sin abrir nada. Compone `browser_profile_show` para leer la metadata. |
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from browser.browser_profile_register import browser_profile_register
|
||||||
|
from browser.browser_profile_list import browser_profile_list
|
||||||
|
from browser.browser_profile_show import browser_profile_show
|
||||||
|
from browser.browser_profile_open import browser_profile_open
|
||||||
|
|
||||||
|
# 1. Registrar un perfil con sus cuentas (secret_ref = referencia a pass, NO el password)
|
||||||
|
browser_profile_register(
|
||||||
|
"osint_01",
|
||||||
|
label="osint_01",
|
||||||
|
persona="sock-puppet Marta R.",
|
||||||
|
purpose="infiltración foros nicho X",
|
||||||
|
tags=["osint", "sockpuppet"],
|
||||||
|
accounts=[
|
||||||
|
{"service": "gmail", "identity": "marta.r.osint@gmail.com", "secret_ref": "pass show osint/osint_01/gmail", "role": "primary"},
|
||||||
|
{"service": "twitter", "identity": "@marta_r_osint", "secret_ref": "pass show osint/osint_01/x", "role": "burner"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Listar el catálogo
|
||||||
|
browser_profile_list() # {"status":"ok","profiles":[{profile_dir, label, n_accounts, ...}]}
|
||||||
|
|
||||||
|
# 3. Ver un perfil con sus cuentas
|
||||||
|
browser_profile_show("osint_01") # {"profile": {...}, "accounts": [{service, identity, secret_ref, role}]}
|
||||||
|
|
||||||
|
# 4. Abrir el perfil listo para trabajar (lanza Chromium + dice qué cuentas usar)
|
||||||
|
browser_profile_open("osint_01", url="https://twitter.com")
|
||||||
|
# -> systemd-run --user --scope -- chromium --profile-directory=osint_01 https://twitter.com
|
||||||
|
# -> accounts: [(gmail, pass show osint/osint_01/gmail), (twitter, pass show osint/osint_01/x)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Vía `fn run` (un id conocido a la vez):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run browser_profile_list
|
||||||
|
./fn run browser_profile_show osint_01
|
||||||
|
./fn run browser_profile_open osint_01 https://twitter.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras (qué NO cubre)
|
||||||
|
|
||||||
|
- **No gestiona el perfil de Chromium en disco** (crear/clonar/extensiones/avatar):
|
||||||
|
eso es `create_chrome_profile_bash_browser`, `list_chrome_profiles_go_browser`,
|
||||||
|
`set_chrome_profile_appearance_bash_browser`. Este grupo solo guarda metadata
|
||||||
|
operativa y lanza un perfil existente.
|
||||||
|
- **No almacena ni resuelve contraseñas.** Solo referencias (`secret_ref`). El
|
||||||
|
password se resuelve aparte con `pass`/keepass.
|
||||||
|
- **No automatiza el login** ni rellena formularios: para eso usa el `browser_mcp`
|
||||||
|
o el grupo `flow-replay` una vez el perfil está abierto.
|
||||||
|
- **Requiere el service `osint_db` vivo** en `:8771`. Si está caído, las funciones
|
||||||
|
devuelven `{"status":"error", ...}` sin lanzar.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `profile_dir` es el nombre del directorio REAL del perfil de Chromium (lo
|
||||||
|
que va en `--profile-directory`): `"Default"`, `"Profile 1"`, `"osint_01"`. NO
|
||||||
|
es el nombre legible (ese es `label`). Verlos con
|
||||||
|
`list_chrome_profiles_go_browser` o el `Local State` del user-data-dir.
|
||||||
|
- `browser_profile_open` por defecto NO pasa `--user-data-dir` (el perfil vive en
|
||||||
|
`~/.config/chromium-cdp`, que el wrapper `/usr/bin/chromium` ya inyecta). Si el
|
||||||
|
perfil está en otro user-data-dir, regístralo con `user_data_dir=<ruta>` y la
|
||||||
|
función lo pasará explícito.
|
||||||
|
- Se lanza vía `systemd-run --user --scope` a propósito: lanzar Chromium directo
|
||||||
|
desde un proceso hijo da exit-144 en este entorno.
|
||||||
|
- `secret_ref` NUNCA es el password. Si te ves tentado a meter la contraseña ahí,
|
||||||
|
para: guárdala en `pass`/keepass y referencia el comando.
|
||||||
@@ -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,57 @@
|
|||||||
|
# consent — CMP / IAB TCF / data brokers
|
||||||
|
|
||||||
|
Operar banners de consentimiento (Consent Management Platforms) y el ecosistema IAB TCF:
|
||||||
|
detectar qué CMP usa un sitio, leer cuántos *vendors* (data brokers) declara, aceptar el
|
||||||
|
banner cuando hace falta y cruzar los IDs de vendor contra la Global Vendor List de IAB para
|
||||||
|
nominar a cada broker y describir qué datos personales recopila.
|
||||||
|
|
||||||
|
Nació de la investigación `projects/databrokers/` (data brokers de la prensa española).
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `extract_cmp_tcf_py_browser` | `extract_cmp_tcf(url, *, port=9222, accept_first=False, llm_fallback=False, ...) -> dict` | Navega a `url` por CDP, detecta el CMP (Didomi/OneTrust/Sourcepoint/Quantcast/otro_tcf), lee `window.__tcfapi` y devuelve nº de vendors, propósitos, muro "pago o consientes" y `vendor_ids`. Con `accept_first` acepta el banner antes de leer; con `llm_fallback` recurre a `find_consent_controls_llm` si el clic por selector falla. |
|
||||||
|
| `find_consent_controls_llm_py_browser` | `find_consent_controls_llm(*, port=9222, max_candidates=40, model="claude-haiku-4-5-20251001") -> dict` | Recolecta los controles clicables del banner (los marca con `data-fnllm="N"`) y pregunta a un LLM (haiku) cuál es Aceptar / Rechazar / Ver socios. Devuelve los selectores. Resuelve CMP con clases dinámicas/texto no estándar sin selectores hardcodeados. |
|
||||||
|
| `fetch_iab_gvl_py_cybersecurity` | `fetch_iab_gvl(out_path="", url="", lang="") -> dict` | Descarga y parsea la Global Vendor List de IAB (catálogo maestro de vendors: nombre, propósitos, `dataDeclaration`, retención, política). Endpoint v3 con fallback v2. |
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
Escanear un medio, contar sus brokers y nombrarlos cruzando con la GVL:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.path.insert(0, "python/functions")
|
||||||
|
from browser.extract_cmp_tcf import extract_cmp_tcf
|
||||||
|
from cybersecurity.fetch_iab_gvl import fetch_iab_gvl
|
||||||
|
|
||||||
|
# 1. Catálogo maestro de vendors (una vez).
|
||||||
|
gvl = fetch_iab_gvl(out_path="/tmp/gvl.json") # {status, vendors:{id:{name,purposes,...}}, ...}
|
||||||
|
|
||||||
|
# 2. Escanear un sitio (Chrome con CDP en el puerto indicado; perfil limpio para que salga el banner).
|
||||||
|
# accept_first acepta el banner; llm_fallback usa haiku si el botón no encaja con selectores fijos.
|
||||||
|
scan = extract_cmp_tcf("https://www.lavanguardia.com", port=9335,
|
||||||
|
accept_first=True, llm_fallback=True)
|
||||||
|
# scan -> {status, cmp:'didomi', n_vendors:1092, vendor_ids:[...], paywall_consent:True, ...}
|
||||||
|
|
||||||
|
# 3. Nominar los brokers de ese medio.
|
||||||
|
nombres = [gvl["vendors"].get(str(v), {}).get("name", f"(vendor {v})") for v in scan["vendor_ids"]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Orquestador completo sobre un censo de dominios: `projects/databrokers/scanner/scan_all.py`
|
||||||
|
(itera → `extract_cmp_tcf` → persiste → cruza con la GVL → Excel).
|
||||||
|
|
||||||
|
## Prerrequisitos
|
||||||
|
|
||||||
|
- Un Chrome/Chromium con remote debugging (CDP) en el puerto usado. Lánzalo aislado del navegador
|
||||||
|
diario (no 9222) con su propio `user_data_dir`. **Perfil limpio**: una vez aceptado el banner,
|
||||||
|
la cookie de consent persiste en el perfil y los re-escaneos ya no muestran banner.
|
||||||
|
- `ask_llm` (grupo `claude-direct`) requiere el token OAuth de Claude Max en `~/.claude/.credentials.json`.
|
||||||
|
|
||||||
|
## Fronteras (lo que el grupo NO cubre)
|
||||||
|
|
||||||
|
- No extrae la lista de vendors de CMP cuyo `getTCData` no rellena `vendor.consents`/`legitimateInterests`
|
||||||
|
por la vía estándar, ni de banners alojados en iframe (Sourcepoint): el clic desde el documento
|
||||||
|
principal no alcanza el iframe.
|
||||||
|
- No interpreta el `tcString` (qué propósitos consintió el usuario en concreto); solo el universo de
|
||||||
|
vendors declarado. Para decodificar el TCString haría falta una pieza aparte.
|
||||||
|
- No es un bloqueador ni un gestor de consentimiento propio: solo observa y mide.
|
||||||
@@ -15,6 +15,40 @@ Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (pro
|
|||||||
| `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`. |
|
| `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. |
|
| `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). |
|
| `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}`. |
|
||||||
|
| `query_osint_db_py_datascience` | `query_osint_db(sql, base_url='http://127.0.0.1:8771', timeout=30) -> dict` | **Cliente HTTP del service `osint_db`**: hace `POST {base_url}/api/query` con `{"sql": sql}` y devuelve `{status, columns, rows, row_count, truncated}` sin lanzar (mismo estilo que `duckdb_query_readonly`). Vía correcta para leer la DuckDB maestra del proyecto `osint` desde otro proceso sin abrir el archivo (respeta el single-writer). Service caído → `{status:'error', error}` claro. Solo stdlib. |
|
||||||
|
|
||||||
|
## 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
|
## Ejemplo canonico
|
||||||
|
|
||||||
@@ -46,7 +80,7 @@ Conversion CSV -> Parquet en una linea:
|
|||||||
|
|
||||||
## Gotchas del grupo
|
## 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.
|
- **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 (`query_osint_db` para `osint_db`). 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.
|
- **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.
|
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# eda — Exploratory Data Analysis por tabla y base
|
||||||
|
|
||||||
|
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
||||||
|
|
||||||
|
Orquestadores one-shot:
|
||||||
|
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
|
||||||
|
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
||||||
|
|
||||||
|
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
### Perfilado base (tabla y columna)
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `summarize_table_duckdb_py_datascience` | impure | Corazón (DuckDB): `SUMMARIZE` push-down + `COUNT DISTINCT` exacto (≤200k filas) → esqueleto del `TableProfile`. |
|
||||||
|
| `summarize_table_pg_py_datascience` | impure | Adaptador PostgreSQL: mismo esqueleto `TableProfile` vía SQL push-down (information_schema + count/distinct/min/max/avg/stddev/percentile_cont). |
|
||||||
|
| `describe_numeric_py_datascience` | pure | Bloque numérico: min/max/mean/median/std/cv, p1-p99, IQR, skew, kurtosis, outliers, distribución, histograma. |
|
||||||
|
| `summarize_categorical_py_datascience` | pure | top-k frecuencias, mode, distinct, entropía, imbalance, longitudes. |
|
||||||
|
| `infer_semantic_type_py_datascience` | pure | Tipo semántico por regex (email/url/ip/uuid/iban/currency/datetime/...). |
|
||||||
|
| `column_quality_score_py_datascience` | pure | Score 0-100 (completeness/validity/consistency) + issues. |
|
||||||
|
| `render_eda_markdown_py_datascience` | pure | `TableProfile` → report markdown con sparklines ASCII. |
|
||||||
|
| `summary_stats_py_datascience` | pure | Descriptiva mínima (n, mean, median, p25, p75). |
|
||||||
|
|
||||||
|
### Correlación / asociación
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `pearson_py_datascience` | pure | Correlación lineal num↔num (preexistente). |
|
||||||
|
| `spearman_corr_py_datascience` | pure | Correlación de rangos (monotónica no lineal) num↔num. |
|
||||||
|
| `cramers_v_py_datascience` | pure | Asociación simétrica cat↔cat (corrección Bergsma-Wicher). |
|
||||||
|
| `theils_u_py_datascience` | pure | Asociación direccional U(a\|b) cat↔cat. |
|
||||||
|
| `correlation_ratio_py_datascience` | pure | η: cuánto explica una categórica a una numérica. |
|
||||||
|
| `mutual_info_columns_py_datascience` | pure | Información mutua (no lineal, general) entre cualquier par. |
|
||||||
|
| `association_matrix_py_datascience` | pure | Matriz unificada: elige métrica por par de tipos + pares fuertes. |
|
||||||
|
| `correlation_matrix_duckdb_py_datascience` | impure | Matriz Pearson push-down (`corr()` SQL) para muchas filas. |
|
||||||
|
|
||||||
|
### Relaciones inter-tabla
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `infer_fk_containment_duckdb_py_datascience` | impure | Infiere FK candidatas por containment de valores (inclusion coefficient). |
|
||||||
|
| `build_join_graph_py_datascience` | pure | FK candidates → grafo (roles fact/dimension) + diagrama Mermaid. |
|
||||||
|
|
||||||
|
### Modelos baratos (flag `run_models`)
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `pca_explained_py_datascience` | pure | PCA: varianza explicada + loadings + proyección. |
|
||||||
|
| `kmeans_segments_py_datascience` | pure | Segmentos naturales, auto-k por silhouette. |
|
||||||
|
| `isolation_forest_outliers_py_datascience` | pure | Outliers multivariante (filas anómalas). |
|
||||||
|
| `normality_tests_py_datascience` | pure | Jarque-Bera + D'Agostino + Shapiro → ¿normal? |
|
||||||
|
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
||||||
|
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
||||||
|
|
||||||
|
### Capa LLM y entrega
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
||||||
|
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
||||||
|
|
||||||
|
### Orquestadores (pipelines)
|
||||||
|
| ID | Qué hace |
|
||||||
|
|---|---|
|
||||||
|
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
|
||||||
|
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||||
|
|
||||||
|
## Contrato de datos
|
||||||
|
|
||||||
|
```
|
||||||
|
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||||
|
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||||
|
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||||
|
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
||||||
|
|
||||||
|
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||||
|
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||||
|
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||||
|
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
||||||
|
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||||
|
|
||||||
|
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
||||||
|
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||||
|
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||||
|
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejemplo canónico
|
||||||
|
|
||||||
|
EDA completo de una tabla (estadística + correlación + modelos + LLM + report):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from pipelines.profile_table import profile_table
|
||||||
|
|
||||||
|
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
||||||
|
prof = r["profile"]
|
||||||
|
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
||||||
|
print(prof["correlations"]["strong"]) # pares correlacionados
|
||||||
|
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
||||||
|
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
||||||
|
```
|
||||||
|
|
||||||
|
EDA de una base entera con relaciones:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pipelines.profile_database import profile_database
|
||||||
|
r = profile_database("/ruta/datos.duckdb") # todas las tablas
|
||||||
|
print(r["db_profile"]["join_graph"]["mermaid"]) # diagrama de relaciones FK
|
||||||
|
```
|
||||||
|
|
||||||
|
Notebook ejecutable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import build_eda_notebook
|
||||||
|
build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_models=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **NO carga la tabla entera a RAM**: metadata SQL + muestra por columna/filas (`sample`, default 5000).
|
||||||
|
- **Distinct exacto hasta 200k filas**; por encima aproximado capado.
|
||||||
|
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
||||||
|
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
||||||
|
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
||||||
|
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Implementado y validado end-to-end (152 tests verdes): perfilado base, correlación/asociación (Pearson/Spearman/Cramér's V/Theil's U/η/MI), relaciones inter-tabla (FK + join graph), modelos baratos (PCA/KMeans/IsolationForest/normalidad/tendencia), capa LLM y generación de notebook.
|
||||||
|
|
||||||
|
Validado sobre PostgreSQL real (tablas del Metabase local del proyecto captacion_clientes).
|
||||||
|
|
||||||
|
Pendiente: adaptador BigQuery; `profile_database` multi-tabla para PostgreSQL (hoy solo DuckDB); perfil fino de columnas datetime (`profile_datetime`); excluir columnas numéricas `possible_id` de la matriz de asociación (hoy solo se excluyen las categóricas id-like).
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Email — Gestionar cuentas de correo por IMAP + SMTP (tecnología propia)
|
||||||
|
|
||||||
|
Tag: `email`. Grupo de funciones Python (solo stdlib: `imaplib`, `smtplib`, `email`) para
|
||||||
|
**leer, hacer CRUD y enviar correo hablando los protocolos directamente** — sin browser CDP
|
||||||
|
y sin el MCP Gmail de claude.ai. Es la base de un sistema multi-proveedor de gestión de
|
||||||
|
cuentas: una conexión IMAP por buzón + SMTP para envío, con las credenciales resueltas desde
|
||||||
|
`pass`/vault por la capa de aplicación.
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="email"`.
|
||||||
|
|
||||||
|
## Cuándo usar este grupo (y cuándo NO)
|
||||||
|
|
||||||
|
| Caso | Vía |
|
||||||
|
|---|---|
|
||||||
|
| Leer/buscar/clasificar/mover/borrar/enviar correo de forma programática y fiable, multi-cuenta | **Este grupo** (IMAP+SMTP directo). |
|
||||||
|
| Leer correo *interactivo* del usuario en su sesión (códigos de verificación al instante en su Gmail logueado) | Browser MCP sobre Gmail web (perfil 9222). Ver memoria `correos-por-browser-no-mcp-gmail`. |
|
||||||
|
| — | El MCP Gmail de `claude.ai` queda descartado en ambos casos (indexa con latencia). |
|
||||||
|
|
||||||
|
IMAP directo **no** sustituye al browser para el flujo interactivo del usuario; lo complementa
|
||||||
|
para automatización fiable con credenciales propias.
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
|
||||||
|
Usuario + **app-password** (NO OAuth). Gmail exige 2FA activado y un App Password de 16 chars
|
||||||
|
(`myaccount.google.com/apppasswords`). Otros proveedores con IMAP/SMTP clásico (Dovecot,
|
||||||
|
dominio propio) aceptan user+pass directo. La credencial se guarda en `pass`
|
||||||
|
(`email/<cuenta>-apppass`) y la resuelve la capa app, **nunca** se hardcodea ni se pasa a
|
||||||
|
estas funciones desde el código del registry.
|
||||||
|
|
||||||
|
**Outlook/Hotmail/Office365 NO entran por aquí**: Microsoft desactivó basic auth para
|
||||||
|
IMAP/SMTP; requieren OAuth2 (pista aparte, no cubierta por este grupo hoy).
|
||||||
|
|
||||||
|
## Servidores comunes
|
||||||
|
|
||||||
|
| Proveedor | IMAP | SMTP |
|
||||||
|
|---|---|---|
|
||||||
|
| Gmail | `imap.gmail.com:993` (SSL) | `smtp.gmail.com:465` (SSL) o `587` (STARTTLS) |
|
||||||
|
| Dominio propio (Dovecot+Postfix) | `mail.<dominio>:993` | `mail.<dominio>:465`/`587` |
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
Núcleo IMAP — el primer argumento `conn` de toda operación es el objeto `imaplib.IMAP4_SSL`
|
||||||
|
vivo que produce `imap_connect`. Todas operan por **UID** (estable), nunca por número de
|
||||||
|
secuencia, y devuelven `dict {"status": "ok"|"error", ...}` sin lanzar.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [imap_connect_py_infra](../../python/functions/infra/imap_connect.md) | `imap_connect(host, port=993, user, password, mailbox='INBOX', use_ssl=True, timeout_s=30) -> dict` | Abre IMAP4_SSL, login + select(mailbox), devuelve el `conn` vivo + `num_messages`. Impura. |
|
||||||
|
| [imap_list_mailboxes_py_infra](../../python/functions/infra/imap_list_mailboxes.md) | `imap_list_mailboxes(conn) -> dict` | Lista carpetas decodificando modified-UTF-7 (Gmail: `[Gmail]/Sent Mail`, etc.). Impura. |
|
||||||
|
| [imap_search_py_infra](../../python/functions/infra/imap_search.md) | `imap_search(conn, criteria='UNSEEN', mailbox='') -> dict` | Busca por criterio IMAP crudo (UNSEEN, FROM, SINCE…) y devuelve UIDs. Impura. |
|
||||||
|
| [imap_fetch_message_py_infra](../../python/functions/infra/imap_fetch_message.md) | `imap_fetch_message(conn, uid, mark_seen=False) -> dict` | Baja y parsea un mensaje (from/to/cc/subject/date/body_text/body_html/attachments). `BODY.PEEK` no marca leído. Impura. |
|
||||||
|
| [imap_mark_seen_py_infra](../../python/functions/infra/imap_mark_seen.md) | `imap_mark_seen(conn, uid, seen=True) -> dict` | Añade/quita la bandera `\Seen`. Impura. |
|
||||||
|
| [imap_move_message_py_infra](../../python/functions/infra/imap_move_message.md) | `imap_move_message(conn, uid, dest_mailbox) -> dict` | Mueve por UID (UID MOVE RFC 6851, fallback COPY+EXPUNGE). Impura. |
|
||||||
|
| [imap_delete_message_py_infra](../../python/functions/infra/imap_delete_message.md) | `imap_delete_message(conn, uid, expunge=True) -> dict` | Marca `\Deleted` y opcionalmente EXPUNGE. Impura. |
|
||||||
|
| [imap_save_draft_py_infra](../../python/functions/infra/imap_save_draft.md) | `imap_save_draft(conn, raw_rfc822, mailbox='[Gmail]/Drafts', flags='\Draft') -> dict` | Guarda un borrador (bytes MIME) vía APPEND. Impura. |
|
||||||
|
|
||||||
|
Construir + enviar (SMTP):
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [email_build_html_py_infra](../../python/functions/infra/email_build_html.md) | `email_build_html(from_addr, to, subject, body_html) -> EmailMessagePy` | Construye un mensaje HTML inmutable. Pura. |
|
||||||
|
| [smtp_send_py_infra](../../python/functions/infra/smtp_send.md) | `smtp_send(cfg, from_addr, to, subject, body_html='', body_text='', cc, bcc, attachments, headers) -> None` | Conecta SMTP, arma MIME y envía en un paso (TLS/STARTTLS/claro). Impura. |
|
||||||
|
|
||||||
|
## Ejemplo canónico end-to-end
|
||||||
|
|
||||||
|
Conectar a Gmail con app-password resuelto desde `pass`, listar no leídos, leer el primero,
|
||||||
|
marcarlo leído, y enviar una respuesta. Las funciones se componen en un heredoc Python que
|
||||||
|
**importa** del registry (no reescribe protocolo):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os, subprocess
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from infra.imap_connect import imap_connect
|
||||||
|
from infra.imap_search import imap_search
|
||||||
|
from infra.imap_fetch_message import imap_fetch_message
|
||||||
|
from infra.imap_mark_seen import imap_mark_seen
|
||||||
|
from infra.smtp_send import smtp_send, SMTPConfigPy
|
||||||
|
|
||||||
|
EMAIL = "gutierenmanuel15@gmail.com"
|
||||||
|
# Credencial desde pass (o usar pass_get_secret del registry). NUNCA hardcodear.
|
||||||
|
PW = subprocess.run(["pass", "show", "email/gmail-enmanuel-apppass"],
|
||||||
|
capture_output=True, text=True).stdout.splitlines()[0]
|
||||||
|
|
||||||
|
# 1. Conectar (IMAP) — el conn vivo viaja dentro del dict
|
||||||
|
c = imap_connect(host="imap.gmail.com", port=993, user=EMAIL, password=PW, mailbox="INBOX")
|
||||||
|
assert c["status"] == "ok", c
|
||||||
|
conn = c["conn"]
|
||||||
|
|
||||||
|
# 2. Buscar no leídos y leer el primero (PEEK: no marca leído)
|
||||||
|
s = imap_search(conn, criteria="UNSEEN")
|
||||||
|
print("no leídos:", s["count"])
|
||||||
|
if s["uids"]:
|
||||||
|
uid = s["uids"][0]
|
||||||
|
m = imap_fetch_message(conn, uid)["message"]
|
||||||
|
print(m["from"], "—", m["subject"])
|
||||||
|
imap_mark_seen(conn, uid) # marcar leído
|
||||||
|
|
||||||
|
# 3. Enviar (SMTP) — mismo app-password
|
||||||
|
smtp_send(
|
||||||
|
SMTPConfigPy(host="smtp.gmail.com", port=465, username=EMAIL, password=PW, tls_mode="tls"),
|
||||||
|
from_addr=EMAIL, to=["dest@example.com"],
|
||||||
|
subject="Probando IMAP+SMTP propios", body_text="Enviado sin browser, protocolo directo.",
|
||||||
|
)
|
||||||
|
conn.logout() # cerrar siempre
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No gestiona la cuenta multi-proveedor**: estas son primitivas de protocolo. El registro
|
||||||
|
de N cuentas (host/port/auth_type por buzón) y la resolución de credenciales desde `pass`
|
||||||
|
son responsabilidad de una **app** (p. ej. `apps/mail_manager`), no de este grupo.
|
||||||
|
- **No hace OAuth**: solo user+app-password. Outlook/Office365 (basic auth muerto) quedan fuera
|
||||||
|
hasta que exista una función `*_oauth_token` dedicada.
|
||||||
|
- **No reemplaza al browser para el flujo interactivo del usuario** (ver tabla arriba).
|
||||||
|
- **`imap_save_draft` no construye el MIME**: recibe bytes RFC822 ya serializados; el caller
|
||||||
|
los arma con `email.message.EmailMessage().as_bytes()` (stdlib) o con `email_build_*` +
|
||||||
|
serialización.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`conn` es un objeto vivo dentro del dict**: estas funciones se componen en heredocs/apps
|
||||||
|
Python, NO por `fn run` (que no puede serializar el socket). Cerrar siempre con `conn.logout()`.
|
||||||
|
- **UID, no número de secuencia**: los seq se renumeran al borrar; los UID son estables
|
||||||
|
mientras no cambie `UIDVALIDITY` del buzón.
|
||||||
|
- **Gmail `\Deleted` ≠ borrar**: marcar `\Deleted` solo quita la etiqueta de la carpeta actual.
|
||||||
|
Para borrar de verdad hay que **mover a `[Gmail]/Trash`** con `imap_move_message`.
|
||||||
|
- **Nombres de carpeta Gmail** llevan prefijo `[Gmail]/` (`[Gmail]/Sent Mail`, `[Gmail]/Drafts`,
|
||||||
|
`[Gmail]/Trash`, `[Gmail]/Spam`).
|
||||||
|
- **App-password requiere 2FA** activado en la cuenta Google; sin 2FA no se puede generar.
|
||||||
|
- **Charsets**: `imap_fetch_message` decodifica RFC 2047 en cabeceras y respeta el charset de
|
||||||
|
cada parte del cuerpo; aun así correos malformados pueden traer texto degradado.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- `python/.venv` (solo stdlib, sin dependencias nuevas).
|
||||||
|
- App-password de cada cuenta guardado en `pass` (`email/<cuenta>-apppass`).
|
||||||
|
- 2FA activado en las cuentas Google.
|
||||||
@@ -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,98 @@
|
|||||||
|
---
|
||||||
|
group: img-to-3d
|
||||||
|
description: "Convertir una imagen 2D en un modelo 3D: estimacion de profundidad monocular (Depth-Anything-V2) + reconstruccion de una malla de relieve texturizada exportada a glTF binario (.glb)."
|
||||||
|
---
|
||||||
|
|
||||||
|
# img-to-3d — Capability Group
|
||||||
|
|
||||||
|
Cluster de funciones Python (dominio `datascience`) para el flujo **imagen 2D → modelo 3D**. A
|
||||||
|
partir de una sola foto se estima un mapa de profundidad monocular con un modelo de vision y se
|
||||||
|
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
||||||
|
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
||||||
|
|
||||||
|
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
|
||||||
|
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
|
||||||
|
de fondo con los dos pasos de reconstruccion:
|
||||||
|
|
||||||
|
```
|
||||||
|
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma corta | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
|
||||||
|
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
||||||
|
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
|
||||||
|
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||||
|
|
||||||
|
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||||
|
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
||||||
|
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
|
||||||
|
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||||
|
|
||||||
|
## Ejemplo canonico (end-to-end imagen → glb)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Requiere un venv con torch + transformers + trimesh + pillow + numpy.
|
||||||
|
# Import PLANO: el paquete datascience.__init__ arrastra deps de otros dominios (bs4, duckdb...)
|
||||||
|
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions/datascience")
|
||||||
|
from remove_background import remove_background
|
||||||
|
from estimate_image_depth import estimate_image_depth
|
||||||
|
from depth_to_relief_glb import depth_to_relief_glb
|
||||||
|
|
||||||
|
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
||||||
|
OUT = "/tmp/cats_relief.glb"
|
||||||
|
|
||||||
|
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
|
||||||
|
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
|
||||||
|
assert cut["status"] == "ok"
|
||||||
|
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
|
||||||
|
|
||||||
|
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
||||||
|
assert est["status"] == "ok"
|
||||||
|
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
||||||
|
|
||||||
|
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
|
||||||
|
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
||||||
|
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
||||||
|
```
|
||||||
|
|
||||||
|
O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run build_relief_glb_from_image_py_pipelines apps/img_to_3d_webapp/samples/cats.jpg /tmp/cats_relief.glb
|
||||||
|
# {"status": "ok", "glb_path": "/tmp/cats_relief.glb", "vertices": 36300, "faces": 71832, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **Es relieve 2.5D, no reconstruccion volumetrica.** Deforma un plano segun la profundidad
|
||||||
|
(heightmap); no recupera caras ocultas ni el volumen trasero del objeto. Para 3D real
|
||||||
|
multivista/fotogrametria, NSP/Gaussian Splatting, esto NO aplica.
|
||||||
|
- **Profundidad relativa, no metrica.** Depth-Anything devuelve disparidad normalizada a [0,1];
|
||||||
|
no comparable entre imagenes ni en unidades del mundo real.
|
||||||
|
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
||||||
|
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
||||||
|
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
||||||
|
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
|
||||||
|
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
|
||||||
|
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
|
||||||
|
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
|
||||||
|
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
|
||||||
|
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
|
||||||
|
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
|
||||||
|
en cada `.md`.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
||||||
|
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
|
||||||
|
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
|
||||||
|
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
|
||||||
|
`remove_background` cae al umbral NumPy).
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Capability: local-hub
|
||||||
|
|
||||||
|
Exponer los procesos locales de la maquina como subdominios `*.localhost` (via Caddy) y reunirlos
|
||||||
|
en una pantalla principal (Glance) con estado online/offline en vivo, refrescada a diario por
|
||||||
|
`dag_engine`. Cubre el ciclo: descubrir servicios -> renderizar config de proxy -> renderizar
|
||||||
|
config de dashboard -> recargar y reiniciar (pipeline `refresh_local_hub`).
|
||||||
|
|
||||||
|
Fuente de verdad de los servicios: `apps/local_hub/local_services.yaml`.
|
||||||
|
|
||||||
|
## Por que existe
|
||||||
|
|
||||||
|
Una maquina con muchos procesos locales (Metabase :3030, Portainer :9000, Grafana, Jupyter,
|
||||||
|
dag_engine, registry_api...) obliga a recordar puerto por puerto. Este grupo los pone detras de
|
||||||
|
nombres legibles (`metabase.localhost`, `portainer.localhost`) sin tocar DNS ni `/etc/hosts`
|
||||||
|
(systemd-resolved resuelve `*.localhost` a 127.0.0.1 por defecto, RFC 6761) y los lista en una
|
||||||
|
sola pagina con su salud en vivo.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Que hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `discover_local_services_py_infra` | `discover_local_services(manifest_path, include_registry=True) -> list[dict]` | Lee el manifiesto `local_services.yaml`, normaliza cada servicio (name, subdomain, port, health_path, title, icon, category) y resuelve `up` por chequeo TCP. Con `include_registry` anade los servicios del registry (via `fn doctor services-spec`) deduplicados. |
|
||||||
|
| `render_caddyfile_py_infra` | `render_caddyfile(services, dashboard=None) -> str` | PURA. Convierte la lista de servicios en el texto de un fragmento de Caddyfile (`http://<sub>.localhost { reverse_proxy 127.0.0.1:<port> }`), ordenado y determinista. El dashboard va primero. |
|
||||||
|
| `render_glance_config_py_infra` | `render_glance_config(services, title="Procesos locales", host_suffix="localhost") -> str` | PURA. Convierte la lista en YAML de Glance: una pagina con un widget `monitor` por categoria, cada site apuntando a `http://<sub>.localhost`. |
|
||||||
|
| `refresh_local_hub_py_pipelines` | `refresh_local_hub(manifest_path=..., reload=True) -> dict` | PIPELINE. Compone las 3 anteriores: descubre, renderiza Caddyfile + Glance, los escribe (`/etc/caddy/conf.d/local_hub.caddy` via ACL + `apps/local_hub/glance/glance.yml`), recarga Caddy (admin API :2019, sin sudo) y reinicia la user-unit `glance`. |
|
||||||
|
|
||||||
|
## Ejemplo canonico
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Refrescar todo el hub (descubrir + regenerar configs + recargar):
|
||||||
|
./fn run refresh_local_hub
|
||||||
|
|
||||||
|
# Acceder a un servicio por su subdominio (cualquier navegador del host):
|
||||||
|
# http://metabase.localhost
|
||||||
|
# http://portainer.localhost
|
||||||
|
# http://home.localhost <- la pantalla principal (Glance)
|
||||||
|
|
||||||
|
# Anadir un servicio nuevo: editar el manifiesto y refrescar
|
||||||
|
$EDITOR apps/local_hub/local_services.yaml # name, subdomain, port, health_path, title, icon, category
|
||||||
|
./fn run refresh_local_hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Composicion ad-hoc (heredoc) si se necesita solo una parte:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from infra.discover_local_services import discover_local_services
|
||||||
|
from infra.render_caddyfile import render_caddyfile
|
||||||
|
|
||||||
|
services = discover_local_services("apps/local_hub/local_services.yaml")
|
||||||
|
print(render_caddyfile(services, dashboard={"subdomain": "home", "port": 8585}))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Infraestructura (one-time, ya provisionada)
|
||||||
|
|
||||||
|
- **Caddy** (`apt`, systemd system service, puerto 80): `/etc/caddy/Caddyfile` hace
|
||||||
|
`import /etc/caddy/conf.d/*.caddy`. El usuario tiene ACL de escritura sobre `conf.d/` para que
|
||||||
|
el pipeline regenere sin sudo. Reload via admin API en `localhost:2019`.
|
||||||
|
- **Glance** (binario nativo en `~/.local/bin/glance`, systemd user service `glance.service`,
|
||||||
|
`127.0.0.1:8585`). Corre como binario del host —no contenedor— para que `*.localhost` resuelva
|
||||||
|
igual que en el resto del sistema.
|
||||||
|
- **dag_engine**: DAG `refresh_local_hub` diario que ejecuta el pipeline.
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **NO gestiona TLS**: sirve HTTP plano (`http://`) porque es trafico loopback. Para HTTPS con CA
|
||||||
|
interna habria que quitar el prefijo `http://` en `render_caddyfile` y dejar que Caddy emita
|
||||||
|
certs internos.
|
||||||
|
- **NO arranca ni para los servicios** que expone: asume que ya corren. Solo crea el mapeo de
|
||||||
|
subdominio y los lista. Encender/apagar un servicio es trabajo de su propia unit / `systemd`.
|
||||||
|
- **NO hace el health-check en vivo**: eso lo hace Glance client-side. El pipeline solo resuelve
|
||||||
|
un `up/down` puntual al regenerar (snapshot del momento).
|
||||||
|
- **Servicios no-HTTP** (Postgres :5433, etc.) quedan fuera del proxy y del dashboard: Caddy y el
|
||||||
|
widget `monitor` de Glance son HTTP.
|
||||||
|
- **Solo loopback / un PC**: el manifiesto y los subdominios son locales a la maquina. No expone
|
||||||
|
nada a la red. Para acceso remoto se usa SSH port-forward o el grupo `ssh`/`wireguard`.
|
||||||
@@ -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).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Capability: metabase
|
# Capability: metabase
|
||||||
|
|
||||||
Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 106 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`).
|
Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth` con email/password, `metabase_client_from_pass` cargando la credencial desde `pass` — sesión o API-key), CRUD de cards/dashboards/collections/snippets/permissions/databases, ejecucion de queries (`metabase_execute_card`, `metabase_query`), refresh metadata + result_metadata, listado y archivado, gestion de pulses, y composiciones (`init_metabase`, `setup_metabase_volume`). 108 funciones Go+Py. Cliente reutilizable: `MetabaseClient` (Go: `metabase_client_go_infra`; Py: `MetabaseClient_py_infra`) — el cliente Py detecta el prefijo `mb_` y autentica por header `X-API-KEY`.
|
||||||
|
|
||||||
## Funciones
|
## Funciones
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ Operar Metabase 100% via API REST. Cubre: auth (`metabase_auth`), CRUD de cards/
|
|||||||
| `metabase_archive_snippet_py_infra` | `def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict` | Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True. |
|
| `metabase_archive_snippet_py_infra` | `def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict` | Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True. |
|
||||||
| `metabase_auth_go_infra` | `func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session. |
|
| `metabase_auth_go_infra` | `func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session. |
|
||||||
| `metabase_auth_py_infra` | `def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session. |
|
| `metabase_auth_py_infra` | `def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient` | Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session. |
|
||||||
|
| `metabase_client_from_pass_py_infra` | `def metabase_client_from_pass(pass_key: str, base_url: str, mode: str = 'auto') -> MetabaseClient \| dict` | Construye un `MetabaseClient` autenticado leyendo la credencial desde `pass`. `mode='session'` (secreto multi-línea: L1 password, línea `email:`) usa `metabase_auth`; `mode='api_key'` (secreto de una línea tipo `mb_...`) autentica por header; `mode='auto'` detecta por la forma del secreto. Compone `pass_get_secret` + `parse_metabase_secret` + `metabase_auth`. Devuelve el cliente o `{status:'error', error}` sin lanzar. Cubre Aurgi (API-key) y captación (sesión) sin reescribir la carga de credenciales. |
|
||||||
|
| `parse_metabase_secret_py_infra` | `def parse_metabase_secret(secret_text: str, mode: str = 'auto') -> dict` | Núcleo **puro** y testeable de `metabase_client_from_pass`: parsea el texto del secreto de `pass` y devuelve `{mode, email, password}` (sesión) o `{mode, api_key}` (API-key). `mode='auto'` clasifica: una sola línea sin `email:`/`login:` → api_key; multi-línea con email → session. Sin I/O. |
|
||||||
| `metabase_copy_card_py_infra` | `def metabase_copy_card(client: MetabaseClient, card_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None) -> dict` | Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia. |
|
| `metabase_copy_card_py_infra` | `def metabase_copy_card(client: MetabaseClient, card_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None) -> dict` | Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia. |
|
||||||
| `metabase_copy_dashboard_py_infra` | `def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None, is_deep_copy: bool = False) -> dict` | Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas. |
|
| `metabase_copy_dashboard_py_infra` | `def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str \| None = None, collection_id: int \| None = None, description: str \| None = None, is_deep_copy: bool = False) -> dict` | Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas. |
|
||||||
| `metabase_copy_dashcard_mappings_py_infra` | `def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]` | Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping. |
|
| `metabase_copy_dashcard_mappings_py_infra` | `def metabase_copy_dashcard_mappings(client: MetabaseClient, *, dashboard_id: int, source_card_id: int, dest_card_id: int) -> list[dict]` | Copia los parameter_mappings del primer dashcard con source_card_id al card destino (dest_card_id), devolviendo una lista nueva de mappings sin mutar el original. Util para replicar filtros de dashboard a cards nuevas sin copiar manualmente cada mapping. |
|
||||||
@@ -134,6 +136,22 @@ dash = metabase_get_dashboard(client, dashboard_id=42)
|
|||||||
cards = metabase_list_cards(client, collection_id=dash["collection_id"])
|
cards = metabase_list_cards(client, collection_id=dash["collection_id"])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cliente autenticado desde `pass` (sin manejar credenciales a mano)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))
|
||||||
|
from metabase import metabase_client_from_pass, metabase_get_dashboard
|
||||||
|
|
||||||
|
# Aurgi: API-key de una línea en pass (mb_...)
|
||||||
|
client = metabase_client_from_pass("metabase/aurgi-api-key", "https://reports.autingo.es", mode="api_key")
|
||||||
|
|
||||||
|
# Captación: secreto multi-línea (password + email:) → sesión
|
||||||
|
# client = metabase_client_from_pass("captacion/metabase", "http://localhost:3030", mode="session")
|
||||||
|
|
||||||
|
dash = metabase_get_dashboard(client, dashboard_id=734)
|
||||||
|
```
|
||||||
|
|
||||||
### Crear card + dashboard + ejecutar (Go)
|
### Crear card + dashboard + ejecutar (Go)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Capability: obsidian
|
# Capability: obsidian
|
||||||
|
|
||||||
CRUD headless de vaults y notas de Obsidian, tratadas como Markdown plano con frontmatter YAML y wikilinks `[[...]]`. NO depende de la app GUI de Obsidian ni de su URI scheme — manipula los archivos `.md` directamente en disco. Scriptable, rapido, con telemetria del registry.
|
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`.
|
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`.
|
||||||
|
|
||||||
@@ -19,6 +19,16 @@ Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan e
|
|||||||
| `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}]}]`. |
|
| `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}]`. |
|
| `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. |
|
| `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
|
## Ejemplo canonico
|
||||||
|
|
||||||
@@ -68,10 +78,11 @@ Para una sola operacion con un id conocido, `fn run` tambien sirve:
|
|||||||
|
|
||||||
## Fronteras (que NO cubre)
|
## Fronteras (que NO cubre)
|
||||||
|
|
||||||
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
|
- **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 resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
|
||||||
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
|
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
|
||||||
- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
|
- **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
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -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,71 @@
|
|||||||
|
# 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`). |
|
||||||
|
| `resolve_pg_dsn_py_infra` | `resolve_pg_dsn(project) -> dict` | Resuelve el DSN de Postgres de un proyecto conocido (`captacion`/`captacion_clientes` vía `CAPTACION_DSN`, `seo`/`seo_analytics` vía `SEO_DSN`) en este orden: (1) variable de entorno, (2) línea `<ENV_VAR>=` del `.env` del proyecto, (3) construido desde `pass` en runtime. Devuelve `{status, project, dsn, source}` (`source` = `env`\|`dotenv`\|`pass`) sin lanzar. Mapa de proyectos explícito en el código — añadir un proyecto = editar `_PROJECTS`. Nunca hardcodea el password. |
|
||||||
|
| `query_project_pg_py_pipelines` | `query_project_pg(project, sql, max_rows=10000) -> dict` | **Pipeline one-shot**: compone `resolve_pg_dsn` + `pg_query`. Lee el DSN del proyecto y ejecuta el SELECT en un solo paso, sin que el caller toque el DSN. Devuelve lo de `pg_query` (`{status, columns, rows, row_count, truncated}`) o propaga el error de resolución. Reemplaza el patrón inline de resolver el DSN a mano antes de consultar. |
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Atajo de un paso — consultar un proyecto conocido sin tocar el DSN (resuelto desde `.env`/`pass`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/enmanuel/fn_registry
|
||||||
|
./fn run query_project_pg captacion "SELECT COUNT(*) AS n FROM product_opportunities"
|
||||||
|
# {"status":"ok","columns":["n"],"rows":[{"n":19}],"row_count":1,"truncated":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Camino completo — 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`), o mejor con `resolve_pg_dsn(project)` que centraliza la convención por proyecto. No imprimas el DSN en logs. Para proyectos no mapeados en `resolve_pg_dsn`, pasa el DSN a `pg_query` directamente.
|
||||||
|
- **`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`.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Capability: seo
|
||||||
|
|
||||||
|
SEO orientado a datos sobre Google Search Console (GSC): autenticar contra la Search Console
|
||||||
|
API con una cuenta de servicio, extraer Search Analytics (impresiones, clicks, CTR, posición
|
||||||
|
por query y página) y aterrizarlo en DuckDB (verdad acumulada) + Postgres (espejo para
|
||||||
|
Metabase). Es la cadena de ingesta del proyecto `seo_analytics`.
|
||||||
|
|
||||||
|
La tesis del grupo: el SEO deja de hacerse a ciegas y se convierte en un problema de datos
|
||||||
|
con loop medible — el dashboard señala la oportunidad (striking distance, CTR bajo, content
|
||||||
|
decay), se aplica el cambio y se mide el impacto en la siguiente ingesta.
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| ID | Firma | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `gsc_auth_py_infra` | `gsc_auth(credentials_path="", subject="") -> service` | Autentica contra la Search Console API v1 con una service account JSON (scope `webmasters.readonly`). Fallback a env `GSC_SA_JSON`. Devuelve el `service` de googleapiclient listo para consultar. |
|
||||||
|
| `pull_gsc_search_analytics_py_datascience` | `pull_gsc_search_analytics(service, site_url, start_date, end_date, dimensions=None, row_limit=25000, max_total_rows=0, search_type="web") -> list[dict]` | Extrae Search Analytics paginando (startRow) hasta agotar. Aplana cada fila (keys → nombres de dimensión + clicks/impressions/ctr/position). `dimensions` por defecto `["query","page"]`. |
|
||||||
|
| `ingest_gsc_search_analytics_py_pipelines` | `ingest_gsc_search_analytics(site_url="", duckdb_path="", pg_dsn="", start_date="", end_date="", lookback_days=5, credentials_path="") -> dict` | Pipeline: auth → pull (dims date,query,page) → upsert idempotente en DuckDB → espejo a Postgres (`mode=replace`). Resuelve defaults de env (`GSC_SITE_URL`, `SEO_DSN`, `GSC_SA_JSON`). Lo invoca el DAG `seo-gsc-daily`. |
|
||||||
|
|
||||||
|
## Ejemplo canónico (end-to-end)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Greenfield: ver projects/seo_analytics/docs/SETUP.md para crear la service account,
|
||||||
|
# verificar la propiedad en Search Console y darle acceso a la SA.
|
||||||
|
|
||||||
|
# 1. Variables (el .env del proyecto las agrupa)
|
||||||
|
export GSC_SITE_URL="sc-domain:ejemplo.com"
|
||||||
|
export SEO_DSN="postgresql://captacion:PASS@localhost:5433/seo"
|
||||||
|
export GSC_SA_JSON="$HOME/.config/seo/gsc-sa.json"
|
||||||
|
|
||||||
|
# 2. Ingesta diaria (auth + pull + DuckDB + espejo Postgres) — la corre el DAG seo-gsc-daily
|
||||||
|
python/.venv/bin/python3 python/functions/pipelines/ingest_gsc_search_analytics.py
|
||||||
|
|
||||||
|
# 3. Dashboards en Metabase (una vez): añade la DB seo + 4 cards + dashboard
|
||||||
|
SEO_PG_PASS=... METABASE_USER=... METABASE_PASS=... \
|
||||||
|
python/.venv/bin/python3 projects/seo_analytics/setup_metabase.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Uso desde Python, componiendo las tres:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.path.insert(0, "python/functions")
|
||||||
|
from infra import gsc_auth
|
||||||
|
from datascience import pull_gsc_search_analytics
|
||||||
|
|
||||||
|
svc = gsc_auth() # lee GSC_SA_JSON
|
||||||
|
rows = pull_gsc_search_analytics(svc, "sc-domain:ejemplo.com",
|
||||||
|
"2026-05-01", "2026-05-28",
|
||||||
|
dimensions=["date", "query", "page"])
|
||||||
|
print(len(rows), rows[0])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **NO hace keyword research ni rank tracking externo**. GSC dice por qué keywords ya apareces
|
||||||
|
en Google; descubrir keywords nuevas o medir SERP de competidores es otro trabajo (scrapers).
|
||||||
|
- **NO escribe los dashboards**. Las cards/dashboard de Metabase los construye el script del
|
||||||
|
proyecto `setup_metabase.py` componiendo el grupo `metabase`. Este grupo solo ingiere datos.
|
||||||
|
- **NO gestiona el scheduling**. Eso es `dag_engine` (DAG `seo-gsc-daily`, grupo `scheduler`).
|
||||||
|
- **NO cubre Bing/otros buscadores**. Solo Google Search Console.
|
||||||
|
|
||||||
|
## Gotchas del grupo
|
||||||
|
|
||||||
|
- Los datos de GSC llegan con **~2-3 días de lag**. El pipeline pide hasta hoy menos 3 días.
|
||||||
|
- Google **anonimiza queries de baja frecuencia** (privacy threshold): la suma por query no
|
||||||
|
cuadra con el total del sitio. Es esperado, no un bug.
|
||||||
|
- El formato de `site_url` importa: `sc-domain:ejemplo.com` (propiedad de dominio) vs URL
|
||||||
|
completa con esquema (propiedad de prefijo).
|
||||||
|
- La service account accede porque su email está **añadido como usuario en Search Console**
|
||||||
|
(Settings > Users), no por domain-wide delegation. El JSON de la SA es un secreto.
|
||||||
|
- **DuckDB es la verdad** (upsert idempotente, acumula histórico); **Postgres es un espejo**
|
||||||
|
que se regenera por `replace` en cada sync. No acumular en Postgres directamente.
|
||||||
|
|
||||||
|
## Prerequisitos
|
||||||
|
|
||||||
|
- Sitio verificado en Search Console + service account con acceso (ver SETUP.md del proyecto).
|
||||||
|
- Stack Postgres + Metabase del proyecto `captacion_clientes` (contenedores `captacion-postgres`
|
||||||
|
:5433 y `captacion-metabase` :3030), con la DB `seo` creada.
|
||||||
|
- Deps Python `google-api-python-client` + `google-auth` (ya en el venv del registry).
|
||||||
@@ -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_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 |
|
| [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 |
|
| [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
|
## Ejemplo canonico
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
|
|||||||
| ID | Firma | Que hace |
|
| ID | Firma | Que hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `audit_ssh_config_bash_cybersecurity` | `audit_ssh_config(config_path: string) -> void` | Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual. |
|
| `audit_ssh_config_bash_cybersecurity` | `audit_ssh_config(config_path: string) -> void` | Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual. |
|
||||||
|
| `check_service_health_via_ssh_bash_infra` | `check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env> <VAR>] [--token <literal>] [--expect-status 200]` | Comprueba la salud de un service HTTP que solo escucha en loopback de un host remoto: entra por SSH, lee opcionalmente un bearer token de un `.env` remoto, y hace `curl` al endpoint local con `Authorization: Bearer`. Emite JSON (`{status, host, url, http_code, healthy}`), exit 0 si sano. El token nunca se imprime; prefiere `--token-from-env` sobre `--token` (este deja el secreto en argv local). |
|
||||||
| `docker_compose_remote_deploy_bash_infra` | `docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json` | Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion. |
|
| `docker_compose_remote_deploy_bash_infra` | `docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json` | Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion. |
|
||||||
| `rsync_deploy_bash_infra` | `rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json` | Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe. |
|
| `rsync_deploy_bash_infra` | `rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json` | Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe. |
|
||||||
| `setup_registry_api_bash_infra` | `setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json` | Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check. |
|
| `setup_registry_api_bash_infra` | `setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json` | Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check. |
|
||||||
@@ -50,6 +51,15 @@ Operar hosts remotos via SSH. Cubre: CRUD de `~/.ssh/config` (`ssh_config_add_en
|
|||||||
./fn run wait_for_http https://myapp.example.com/health 30
|
./fn run wait_for_http https://myapp.example.com/health 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Health-check de un service que solo escucha en loopback del host remoto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run check_service_health_via_ssh om "http://127.0.0.1:8487/agents" \
|
||||||
|
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
|
||||||
|
--expect-status 200
|
||||||
|
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agents","http_code":200,"healthy":true}
|
||||||
|
```
|
||||||
|
|
||||||
## Fronteras
|
## Fronteras
|
||||||
|
|
||||||
- **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`.
|
- **NO genera ni rota llaves SSH automaticamente**. Asume llave ya generada con `ssh-keygen`.
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
package browser
|
package browser
|
||||||
|
|
||||||
import "fmt"
|
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
|
// 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).
|
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
|
||||||
@@ -37,6 +46,13 @@ func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
|||||||
if opts.Mode == "instant" {
|
if opts.Mode == "instant" {
|
||||||
return clickRefViaJS(c, backendNodeID)
|
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)
|
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ purity: impure
|
|||||||
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
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."
|
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]
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
uses_functions: [cdp_click_xy_human_go_browser]
|
uses_functions: [cdp_click_xy_human_go_browser, cdp_wait_actionable_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -9,6 +9,10 @@ func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("cdp hover ref: conexión 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)
|
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ purity: impure
|
|||||||
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
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."
|
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]
|
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||||
uses_functions: [cdp_move_mouse_human_go_browser]
|
uses_functions: [cdp_move_mouse_human_go_browser, cdp_wait_actionable_go_browser]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CdpNewTabBackground abre una pestaña nueva via Target.createTarget con el
|
||||||
|
// parametro "background": true, de forma que la pestaña se crea SIN activarse y
|
||||||
|
// SIN elevar la ventana del navegador (no roba el foco del WM).
|
||||||
|
//
|
||||||
|
// Es el drop-in sin-foco de CdpNewTab: misma firma, mismo CdpTab de retorno.
|
||||||
|
// La diferencia tecnica es el mecanismo:
|
||||||
|
// - CdpNewTab usa el endpoint HTTP PUT /json/new, que NO admite background y
|
||||||
|
// por tanto SIEMPRE eleva la ventana (roba foco al usuario).
|
||||||
|
// - Aqui usamos el comando CDP browser-level Target.createTarget con
|
||||||
|
// "background": true, que en Linux/Chromium crea la pestaña en segundo plano.
|
||||||
|
//
|
||||||
|
// host vacio = "localhost". startURL vacio = "about:blank".
|
||||||
|
func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error) {
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
if startURL == "" {
|
||||||
|
startURL = "about:blank"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target.createTarget debe ejecutarse contra el browser target (no una page),
|
||||||
|
// por eso resolvemos el webSocketDebuggerUrl browser-level via /json/version.
|
||||||
|
wsURL, err := cdpGetWSURL(port)
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := cdpConnectWS(wsURL, port)
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: conectar: %w", err)
|
||||||
|
}
|
||||||
|
// Soltar solo el WebSocket; dejar el navegador vivo.
|
||||||
|
defer CdpDisconnect(conn)
|
||||||
|
|
||||||
|
res, err := conn.sendCDP("Target.createTarget", map[string]any{
|
||||||
|
"url": startURL,
|
||||||
|
"background": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, _ := res["targetId"].(string)
|
||||||
|
if targetID == "" {
|
||||||
|
return CdpTab{}, fmt.Errorf("cdp new tab background: createTarget no devolvio targetId")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el CdpTab completo (con webSocketDebuggerUrl, title, etc.) buscando
|
||||||
|
// el target recien creado en /json.
|
||||||
|
tabs, err := CdpListTabs(host, port)
|
||||||
|
if err == nil {
|
||||||
|
for _, t := range tabs {
|
||||||
|
if t.ID == targetID {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback en caso de carrera (el target aun no aparece en /json): devolvemos
|
||||||
|
// un CdpTab minimo con el id, tipo y URL inicial conocidos.
|
||||||
|
return CdpTab{ID: targetID, Type: "page", URL: startURL}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: cdp_new_tab_background
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: 1.0.0
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpNewTabBackground(host string, port int, startURL string) (CdpTab, error)"
|
||||||
|
description: "Abre una pestaña nueva via CDP Target.createTarget con background:true, sin activarla ni elevar la ventana del navegador (no roba el foco del WM). Drop-in sin-foco de CdpNewTab: misma firma y mismo CdpTab de retorno, pero usando el comando CDP browser-level en lugar del endpoint HTTP /json/new (que SI roba foco)."
|
||||||
|
tags: [browser, cdp, tabs, spawn, background, no-focus]
|
||||||
|
uses_functions: [cdp_list_tabs_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt]
|
||||||
|
example: |
|
||||||
|
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("nueva tab en segundo plano id=", tab.ID)
|
||||||
|
}
|
||||||
|
tested: true
|
||||||
|
tests: ["TestCdpNewTabBackground_closedPort", "TestCdpNewTabBackground_emptyStartURLClosedPort"]
|
||||||
|
test_file_path: "functions/browser/cdp_new_tab_background_test.go"
|
||||||
|
file_path: "functions/browser/cdp_new_tab_background.go"
|
||||||
|
notes: |
|
||||||
|
- Usa los helpers privados del paquete: cdpGetWSURL (browser-level WS),
|
||||||
|
cdpConnectWS, (*CDPConn).sendCDP y CdpListTabs. No reescribe el transporte CDP.
|
||||||
|
- El cierre del WebSocket se hace con CdpDisconnect (solo suelta la sesion, deja
|
||||||
|
el navegador vivo).
|
||||||
|
- Resuelve el CdpTab completo via CdpListTabs buscando por targetId; si hay
|
||||||
|
carrera y aun no aparece, devuelve un CdpTab minimo (id, type, url) como fallback.
|
||||||
|
documentation: |
|
||||||
|
Alternativa a CdpNewTab cuando NO quieres que la ventana del navegador robe el
|
||||||
|
foco del window manager — por ejemplo, mientras el usuario escribe en otra
|
||||||
|
ventana. El endpoint HTTP /json/new no admite el parametro background, asi que
|
||||||
|
CdpNewTab siempre eleva la ventana; esta funcion usa Target.createTarget con
|
||||||
|
"background": true para crear la pestaña en segundo plano.
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "Host CDP donde escucha el navegador (vacio = localhost)."
|
||||||
|
- name: port
|
||||||
|
desc: "Puerto remote-debugging de Chrome/Chromium (ej. 9333)."
|
||||||
|
- name: startURL
|
||||||
|
desc: "URL inicial de la pestaña. Vacio = about:blank."
|
||||||
|
output: "CdpTab del target recien creado (id, webSocketDebuggerUrl, title, url, ...). Error si /json/version o el comando CDP fallan."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Abrir una pestaña en segundo plano sin robar el foco del usuario.
|
||||||
|
tab, err := browser.CdpNewTabBackground("localhost", 9333, "https://example.com")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("pestaña creada en background:", tab.ID, tab.URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando abras una pestaña por CDP y NO quieras que la ventana del navegador robe
|
||||||
|
el foco del WM (el usuario esta escribiendo en otra ventana). Alternativa
|
||||||
|
sin-foco a `CdpNewTab` / endpoint HTTP `/json/new`, que siempre eleva la ventana.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Funcion impura: abre un WebSocket al navegador y manda un comando CDP. Falla si
|
||||||
|
el puerto no responde o el comando no devuelve `targetId`.
|
||||||
|
- El parametro `background` de `Target.createTarget` no aplica en MacOS (alli la
|
||||||
|
pestaña se activa igual). Esto esta pensado para Linux/Chromium.
|
||||||
|
- Requiere conexion **browser-level** (`/json/version`), no page-level: por eso usa
|
||||||
|
`cdpGetWSURL` y no la primera tab `page`.
|
||||||
|
- Si el navegador corre headless, el foco es irrelevante — `CdpNewTab` y esta
|
||||||
|
funcion son equivalentes en ese caso.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCdpNewTabBackground_closedPort(t *testing.T) {
|
||||||
|
// Sin Chrome escuchando esperamos error de red al resolver /json/version,
|
||||||
|
// pero NO panic ni nil-deref. Puerto 1 garantizado cerrado.
|
||||||
|
_, err := CdpNewTabBackground("", 1, "https://example.com")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error talking to closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCdpNewTabBackground_emptyStartURLClosedPort(t *testing.T) {
|
||||||
|
// startURL vacio debe normalizarse a about:blank sin romper; con puerto
|
||||||
|
// cerrado seguimos esperando error de red, no panic.
|
||||||
|
_, err := CdpNewTabBackground("localhost", 1, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error talking to closed port")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: cdp_print_pdf
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error)"
|
||||||
|
description: "Genera un PDF de la pagina actual via el metodo CDP Page.printToPDF y devuelve los bytes ya decodificados, sin tocar el disco. Usa transferMode ReturnAsBase64 (Chrome devuelve el PDF como base64 en el campo data) y lo decodifica a []byte. Aplica las opciones a los params: Landscape, PrintBackground y Scale siempre (Scale forzado a 1.0 si opts pide <= 0); PaperWidthIn/PaperHeightIn solo cuando son > 0, dejando el tamano de papel por defecto del navegador en caso contrario. Robusto ante paginas grandes. Primitiva reutilizable para devolver el PDF al LLM como document content o persistirlo a disco."
|
||||||
|
tags: [chrome, cdp, browser, automation, pdf, print, printToPDF, devtools, document, navegator]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [encoding/base64, fmt]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "conexión CDP activa (*CDPConn) contra Chrome con el target abierto"
|
||||||
|
- name: opts
|
||||||
|
desc: "opciones de impresión (Landscape, PrintBackground, Scale, PaperWidthIn, PaperHeightIn en pulgadas)"
|
||||||
|
output: "bytes del PDF decodificados desde base64, o error si falla la generación o la decodificación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_print_pdf.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
CdpNavigate(conn, "https://example.com")
|
||||||
|
|
||||||
|
pdfData, err := CdpPrintPDF(conn, CdpPrintPDFOpts{
|
||||||
|
Landscape: false,
|
||||||
|
PrintBackground: true,
|
||||||
|
Scale: 1.0,
|
||||||
|
PaperWidthIn: 8.27, // A4
|
||||||
|
PaperHeightIn: 11.69, // A4
|
||||||
|
})
|
||||||
|
// pdfData: bytes del PDF listos para escribir a disco o devolver al LLM
|
||||||
|
// os.WriteFile("example.pdf", pdfData, 0644)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesitas el PDF de la página actual en memoria: para devolverlo al LLM como document content (bytes), para archivar el render de una página (factura, informe, dashboard) o como primitiva sobre la que un caller compone la escritura a disco. Úsala tras `CdpNavigate` + espera de carga (`CdpWaitIdle`) para asegurar que el contenido está renderizado antes de imprimir.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Solo en modo headless completo de impresión**: `Page.printToPDF` funciona de forma fiable en Chrome headless. En modo headed (con UI), algunas builds de Chrome devuelven `PrintToPDF is not implemented`; si lo necesitas con UI, lanza Chrome con `--headless=new`.
|
||||||
|
- **Scale fuera de rango**: Chrome acepta `scale` en `[0.1, 2]`. Esta función fuerza `1.0` cuando `opts.Scale <= 0`, pero no recorta valores válidos fuera de rango — si pasas `5.0`, Chrome puede rechazar el comando con error.
|
||||||
|
- **Paper en pulgadas**: `PaperWidthIn`/`PaperHeightIn` son pulgadas (la unidad nativa de CDP), no mm. A4 ≈ 8.27 × 11.69 in, Letter = 8.5 × 11 in. `0` deja el default del navegador (Letter).
|
||||||
|
- **Contenido lazy-load / dinámico**: `printToPDF` captura el DOM en el instante de la llamada. Si la página carga contenido al hacer scroll o por JS diferido, espera a que termine (scroll + `CdpWaitIdle`) antes de imprimir.
|
||||||
|
- **PrintBackground apagado por defecto**: igual que el diálogo de impresión de Chrome, los fondos CSS (colores e imágenes) no salen salvo que pongas `PrintBackground: true`.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Adición al dominio `browser` (estilo CDP del paquete): el `.go` vive junto a las demás funciones `cdp_*.go` en el mismo paquete `browser`. El struct `CdpPrintPDFOpts` se define en el mismo archivo. Chrome retorna el PDF como base64 (`transferMode: "ReturnAsBase64"`, el default de CDP); esta función lo decodifica a `[]byte` y lo devuelve sin escribir a disco — el caller decide el destino. Patrón gemelo de `CdpScreenshotBytes` para el caso de impresión a PDF.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo).
|
||||||
|
type CdpDropdownOpts struct {
|
||||||
|
// Exact: true = el texto de la opcion debe ser igual (tras normalizar) a
|
||||||
|
// optionText. false (default) = match por substring. La comparacion siempre
|
||||||
|
// es case-insensitive y sobre el texto normalizado (trim + colapsar espacios).
|
||||||
|
Exact bool
|
||||||
|
// TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la
|
||||||
|
// opcion aparezca visible. <=0 usa el default 3000.
|
||||||
|
TimeoutMs int
|
||||||
|
// OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto).
|
||||||
|
// Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc.
|
||||||
|
OptionRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox
|
||||||
|
// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un
|
||||||
|
// <select> nativo NO aplica y por tanto CdpSelectOption no sirve.
|
||||||
|
//
|
||||||
|
// El patron replica como Playwright compone la accion (no tiene API para custom
|
||||||
|
// dropdowns): click(trigger) -> esperar apertura -> getByRole('option', {name}) ->
|
||||||
|
// click(option). Pasos:
|
||||||
|
//
|
||||||
|
// 1. Localiza el trigger por triggerSelector (CSS) y hace CLICK REAL (mouse
|
||||||
|
// mousePressed/mouseReleased sobre el centro del bbox, no element.click() JS):
|
||||||
|
// muchos dropdowns escuchan 'mousedown', no 'click'.
|
||||||
|
// 2. Espera la apertura (polling hasta TimeoutMs): el trigger pasa a
|
||||||
|
// aria-expanded="true", O aparece un [role=listbox]/[role=menu] visible, O hay
|
||||||
|
// elementos con el rol de opcion (OptionRole / li[role] / menuitem) con rect>0.
|
||||||
|
// No avanza hasta que haya opciones visibles.
|
||||||
|
// 3. Localiza la opcion cuyo texto normalizado (trim + colapsar espacios)
|
||||||
|
// coincide con optionText (substring si Exact=false, igualdad si Exact=true),
|
||||||
|
// entre las opciones con rol visibles. Error claro si no aparece en el timeout.
|
||||||
|
// 4. CLICK REAL en el centro de esa opcion.
|
||||||
|
// 5. Verifica el cierre/seleccion: aria-expanded vuelve a false O el trigger
|
||||||
|
// refleja el texto elegido; si la verificacion es ambigua, intenta Enter como
|
||||||
|
// fallback suave. No falla duro si el click se hizo pero la verificacion queda
|
||||||
|
// incierta.
|
||||||
|
//
|
||||||
|
// purity: impure (DOM + input real + tiempo). Devuelve error si el trigger no
|
||||||
|
// existe, si el dropdown no abre en el timeout, o si la opcion no aparece.
|
||||||
|
func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: conexion nula")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(triggerSelector) == "" {
|
||||||
|
return fmt.Errorf("cdp select dropdown: triggerSelector vacio")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(optionText) == "" {
|
||||||
|
return fmt.Errorf("cdp select dropdown: optionText vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutMs := opts.TimeoutMs
|
||||||
|
if timeoutMs <= 0 {
|
||||||
|
timeoutMs = 3000
|
||||||
|
}
|
||||||
|
optionRole := strings.TrimSpace(opts.OptionRole)
|
||||||
|
if optionRole == "" {
|
||||||
|
optionRole = "option"
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||||
|
|
||||||
|
// 1. Click REAL en el trigger.
|
||||||
|
if err := dropdownClickSelector(c, triggerSelector); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: click trigger %q: %w", triggerSelector, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Esperar apertura (opciones visibles).
|
||||||
|
if err := dropdownWaitOpen(c, triggerSelector, optionRole, deadline); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 + 4. Localizar la opcion por texto y click REAL en su centro.
|
||||||
|
cx, cy, err := dropdownFindOptionCenter(c, optionRole, optionText, opts.Exact, deadline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: %w", err)
|
||||||
|
}
|
||||||
|
if err := CdpClickXYHuman(c, cx, cy, MouseHumanOpts{Mode: "auto"}); err != nil {
|
||||||
|
return fmt.Errorf("cdp select dropdown: click opcion %q: %w", optionText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verificacion suave: dar un instante a que se cierre/refleje, y si sigue
|
||||||
|
// abierto intentar Enter (algunos comboboxes confirman con Enter sobre la
|
||||||
|
// opcion activa). No es fatal si la verificacion queda ambigua.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
if dropdownStillOpen(c, triggerSelector, optionRole) {
|
||||||
|
_ = CdpPressKey(c, "Enter")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownClickSelector resuelve el bbox del elemento (por selector CSS) y hace
|
||||||
|
// click real sobre su centro. Hace scroll si hace falta. Cae a element.click() JS
|
||||||
|
// solo si el nodo no tiene geometria (display:contents, area 0).
|
||||||
|
func dropdownClickSelector(c *CDPConn, selector string) error {
|
||||||
|
// Centro del bbox del elemento via getBoundingClientRect en el contexto JS.
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
el.scrollIntoView({block:'center', inline:'center'});
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return '__NO_BOX__';
|
||||||
|
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||||
|
})()`, jsString(selector))
|
||||||
|
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolver bbox: %w", err)
|
||||||
|
}
|
||||||
|
res = strings.Trim(res, `"`)
|
||||||
|
switch res {
|
||||||
|
case "__NO_EL__":
|
||||||
|
return fmt.Errorf("trigger no encontrado para selector %q", selector)
|
||||||
|
case "__NO_BOX__":
|
||||||
|
// Sin geometria: fallback a element.click() JS (no dispara mousedown real).
|
||||||
|
return dropdownClickViaJS(c, selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
x, y, ok := parseXY(res)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("bbox invalido %q", res)
|
||||||
|
}
|
||||||
|
return CdpClickXYHuman(c, x, y, MouseHumanOpts{Mode: "auto"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownClickViaJS es el fallback sin geometria: element.click() en el contexto JS.
|
||||||
|
func dropdownClickViaJS(c *CDPConn, selector string) error {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
el.click();
|
||||||
|
return '__OK__';
|
||||||
|
})()`, jsString(selector))
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Trim(res, `"`) != "__OK__" {
|
||||||
|
return fmt.Errorf("element.click() JS fallo (%s)", strings.Trim(res, `"`))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownWaitOpen hace polling hasta deadline esperando que el dropdown este
|
||||||
|
// abierto: trigger con aria-expanded="true", O un [role=listbox]/[role=menu]
|
||||||
|
// visible, O algun elemento con el rol de opcion (rect>0). Error si no abre.
|
||||||
|
func dropdownWaitOpen(c *CDPConn, triggerSelector, optionRole string, deadline time.Time) error {
|
||||||
|
for {
|
||||||
|
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("el dropdown no abrio (sin opciones visibles) tras el timeout para trigger %q", triggerSelector)
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownIsOpen comprueba una vez si el dropdown esta abierto.
|
||||||
|
func dropdownIsOpen(c *CDPConn, triggerSelector, optionRole string) (bool, error) {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var trigger = document.querySelector(%s);
|
||||||
|
if (trigger && trigger.getAttribute('aria-expanded') === 'true') return 'open';
|
||||||
|
function visible(el){
|
||||||
|
if (!el) return false;
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
var cs = getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Un contenedor listbox/menu visible cuenta como abierto.
|
||||||
|
var containers = document.querySelectorAll('[role=listbox],[role=menu]');
|
||||||
|
for (var i=0;i<containers.length;i++){ if (visible(containers[i])) return 'open'; }
|
||||||
|
// O al menos una opcion (por rol o por li[role]) visible.
|
||||||
|
var role = %s;
|
||||||
|
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||||
|
var opts = document.querySelectorAll(sel);
|
||||||
|
for (var j=0;j<opts.length;j++){ if (visible(opts[j])) return 'open'; }
|
||||||
|
return 'closed';
|
||||||
|
})()`, jsString(triggerSelector), jsString(optionRole))
|
||||||
|
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("comprobar apertura: %w", err)
|
||||||
|
}
|
||||||
|
return strings.Trim(res, `"`) == "open", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownStillOpen es una comprobacion best-effort para la verificacion final;
|
||||||
|
// nunca propaga error (un fallo aqui no debe invalidar el click ya hecho).
|
||||||
|
func dropdownStillOpen(c *CDPConn, triggerSelector, optionRole string) bool {
|
||||||
|
open, err := dropdownIsOpen(c, triggerSelector, optionRole)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdownFindOptionCenter localiza, entre las opciones visibles del dropdown, la
|
||||||
|
// que matchea optionText (substring si exact=false, igualdad si exact=true; ambas
|
||||||
|
// case-insensitive sobre texto normalizado) y devuelve el centro de su bbox. Hace
|
||||||
|
// polling hasta deadline para tolerar listas virtualizadas que montan tarde.
|
||||||
|
func dropdownFindOptionCenter(c *CDPConn, optionRole, optionText string, exact bool, deadline time.Time) (float64, float64, error) {
|
||||||
|
js := fmt.Sprintf(`(function(){
|
||||||
|
var role = %s;
|
||||||
|
var want = %s;
|
||||||
|
var exact = %t;
|
||||||
|
function norm(v){ return (v||'').replace(/\s+/g,' ').trim().toLowerCase(); }
|
||||||
|
function visible(el){
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) return false;
|
||||||
|
var cs = getComputedStyle(el);
|
||||||
|
if (cs.visibility === 'hidden' || cs.display === 'none') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var target = norm(want);
|
||||||
|
var sel = '[role=' + role + '],li[role],[role=menuitem]';
|
||||||
|
var nodes = document.querySelectorAll(sel);
|
||||||
|
for (var i=0;i<nodes.length;i++){
|
||||||
|
var el = nodes[i];
|
||||||
|
if (!visible(el)) continue;
|
||||||
|
var t = norm(el.innerText || el.textContent || '');
|
||||||
|
var ok = exact ? (t === target) : (t.indexOf(target) >= 0);
|
||||||
|
if (ok){
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '__NO_OPTION__';
|
||||||
|
})()`, jsString(optionRole), jsString(optionText), exact)
|
||||||
|
|
||||||
|
for {
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("buscar opcion: %w", err)
|
||||||
|
}
|
||||||
|
res = strings.Trim(res, `"`)
|
||||||
|
if res != "__NO_OPTION__" {
|
||||||
|
if x, y, ok := parseXY(res); ok {
|
||||||
|
return x, y, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, 0, fmt.Errorf("option %q not found in dropdown", optionText)
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseXY extrae x/y de un JSON {"x":..,"y":..} que llega ya des-escapado de
|
||||||
|
// CdpEvaluate (que devuelve el JSON.stringify como string). Hace un parse ligero
|
||||||
|
// sin importar encoding/json de nuevo en el hot path: busca los numeros tras x/y.
|
||||||
|
func parseXY(s string) (float64, float64, bool) {
|
||||||
|
// CdpEvaluate devuelve la cadena producida por JSON.stringify; las comillas
|
||||||
|
// internas vienen escapadas como \" tras pasar por el unmarshal de Go.
|
||||||
|
s = strings.ReplaceAll(s, `\"`, `"`)
|
||||||
|
var x, y float64
|
||||||
|
n, err := fmt.Sscanf(s, `{"x":%g,"y":%g}`, &x, &y)
|
||||||
|
if err != nil || n != 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return x, y, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: cdp_select_dropdown
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpSelectDropdown(c *CDPConn, triggerSelector string, optionText string, opts CdpDropdownOpts) error"
|
||||||
|
description: "Selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox ARIA, react-select, MUI Select, headlessui, select2) — esos donde un <select> nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey."
|
||||||
|
tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools]
|
||||||
|
uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, strings, time]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "conexion CDP activa (*CDPConn)"
|
||||||
|
- name: triggerSelector
|
||||||
|
desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)"
|
||||||
|
- name: optionText
|
||||||
|
desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true"
|
||||||
|
- name: opts
|
||||||
|
desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}"
|
||||||
|
output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_select_dropdown.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
CdpNavigate(conn, "https://mui.com/material-ui/react-select/")
|
||||||
|
|
||||||
|
// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y
|
||||||
|
// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox
|
||||||
|
// este visible y entonces clica la opcion "Twenty".
|
||||||
|
err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-select / headlessui: trigger por clase + match exacto + timeout amplio
|
||||||
|
// para listas que tardan en montar.
|
||||||
|
err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{
|
||||||
|
Exact: true,
|
||||||
|
TimeoutMs: 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem.
|
||||||
|
err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{
|
||||||
|
OptionRole: "menuitem",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando el desplegable NO es un `<select>` nativo: comboboxes/listboxes ARIA,
|
||||||
|
react-select, MUI Select, headlessui, select2, Ant Design, o cualquier menu hecho
|
||||||
|
con `<div>`/`<li>` + JS donde elegir = clicar el trigger y luego clicar la opcion
|
||||||
|
del menu desplegado. Es el equivalente al patron de Playwright
|
||||||
|
`click(trigger) -> getByRole('option', {name}) -> click(option)`, con la espera de
|
||||||
|
apertura ya resuelta. Para un `<select>` nativo de HTML usa `CdpSelectOption` (setea
|
||||||
|
`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese
|
||||||
|
caso.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown`
|
||||||
|
(no `click`), por eso esta funcion despacha eventos de raton reales sobre el
|
||||||
|
centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria.
|
||||||
|
- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la
|
||||||
|
opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura
|
||||||
|
(`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu
|
||||||
|
dropdown anima muy lento, sube `TimeoutMs`.
|
||||||
|
- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones
|
||||||
|
en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca
|
||||||
|
se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en
|
||||||
|
el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz
|
||||||
|
scroll dentro del listbox primero.
|
||||||
|
- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el
|
||||||
|
menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir).
|
||||||
|
- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es
|
||||||
|
substring (`Exact=false`). Si varias opciones comparten substring, elige la
|
||||||
|
primera visible en orden de documento — usa `Exact=true` para desambiguar.
|
||||||
|
- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa
|
||||||
|
`menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera
|
||||||
|
`[role=menu]` y `li[role]` para cubrir patrones comunes.
|
||||||
|
- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa
|
||||||
|
`Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede
|
||||||
|
confirmar inequivocamente pero el click se hizo — comprueba el estado resultante
|
||||||
|
(texto del trigger, valor del formulario) si necesitas certeza.
|
||||||
|
- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown
|
||||||
|
dentro de un iframe necesitarias el contexto del frame (no cubierto aqui).
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
|
||||||
|
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
|
||||||
|
//
|
||||||
|
// Orden de matching de value contra cada <option>, en este orden:
|
||||||
|
// 1. value exacto: option.value === value.
|
||||||
|
// 2. label/texto exacto: option.label === value (sin normalizar).
|
||||||
|
// 3. label/texto NORMALIZADO: normalizeWhiteSpace(option.label) === normalizeWhiteSpace(value),
|
||||||
|
// donde normalizar = quitar zero-width space (U+200B) y soft hyphen (U+00AD),
|
||||||
|
// trim, y colapsar cualquier secuencia de whitespace a un solo espacio.
|
||||||
|
// 4. label/texto por substring NORMALIZADO: la primera option cuyo label normalizado
|
||||||
|
// contenga el value normalizado (fallback para etiquetas largas).
|
||||||
|
// 5. fallback por indice: solo si value es un entero (>= 0) y existe esa posicion.
|
||||||
|
//
|
||||||
|
// Sobre la option encontrada hace focus del select, setea option.selected = true
|
||||||
|
// (no solo select.value, para que funcione tambien con <select multiple>) y despacha
|
||||||
|
// 'input' {bubbles:true, composed:true} seguido de 'change' {bubbles:true}, en ese
|
||||||
|
// orden, para que frameworks (React/Vue/Angular) y shadow DOM reaccionen al cambio.
|
||||||
|
//
|
||||||
|
// Si el selector apunta a un <label for=...>, sigue la referencia hasta su control
|
||||||
|
// (retarget follow-label) antes de validar que sea un <select>.
|
||||||
|
//
|
||||||
|
// Devuelve error claro si:
|
||||||
|
// - el selector no encuentra elemento ("element not found"),
|
||||||
|
// - el elemento no es un <select> ("element is not a <select> ..."),
|
||||||
|
// - ninguna option coincide ("option not found in <select>").
|
||||||
|
func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp select option: conexion nula")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script JS alineado con Playwright. Devuelve centinelas en string:
|
||||||
|
// __OK__:<value> cuando selecciona; el resto son codigos de error claros.
|
||||||
|
// Usamos jsString para inyectar selector/value de forma segura (anti-inyeccion).
|
||||||
|
js := fmt.Sprintf(`(function() {
|
||||||
|
function normWS(t) {
|
||||||
|
return (t == null ? '' : String(t))
|
||||||
|
.replace(/[]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
var el = document.querySelector(%s);
|
||||||
|
if (!el) return '__NO_EL__';
|
||||||
|
// retarget follow-label: si es un <label for>, salta a su control.
|
||||||
|
if (el.nodeName.toLowerCase() === 'label') {
|
||||||
|
var labelled = null;
|
||||||
|
var forId = el.getAttribute('for');
|
||||||
|
if (forId) labelled = document.getElementById(forId);
|
||||||
|
if (!labelled) labelled = el.querySelector('select, input, textarea');
|
||||||
|
if (labelled) el = labelled;
|
||||||
|
}
|
||||||
|
if (el.nodeName.toLowerCase() !== 'select') return '__NOT_SELECT__';
|
||||||
|
var sel = el;
|
||||||
|
var want = %s;
|
||||||
|
var wantNorm = normWS(want);
|
||||||
|
var opts = Array.prototype.slice.call(sel.options);
|
||||||
|
var match = null;
|
||||||
|
|
||||||
|
// 1. value exacto.
|
||||||
|
for (var i = 0; i < opts.length && !match; i++) {
|
||||||
|
if (opts[i].value === want) match = opts[i];
|
||||||
|
}
|
||||||
|
// 2. label/texto exacto.
|
||||||
|
if (!match) {
|
||||||
|
for (var j = 0; j < opts.length && !match; j++) {
|
||||||
|
if (opts[j].label === want || (opts[j].textContent || '') === want) match = opts[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. label/texto normalizado exacto.
|
||||||
|
if (!match && wantNorm !== '') {
|
||||||
|
for (var k = 0; k < opts.length && !match; k++) {
|
||||||
|
var ln = normWS(opts[k].label || opts[k].textContent);
|
||||||
|
if (ln === wantNorm) match = opts[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. label/texto por substring normalizado.
|
||||||
|
if (!match && wantNorm !== '') {
|
||||||
|
for (var m = 0; m < opts.length && !match; m++) {
|
||||||
|
var ln2 = normWS(opts[m].label || opts[m].textContent);
|
||||||
|
if (ln2.indexOf(wantNorm) !== -1) match = opts[m];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. fallback por indice: solo si want es un entero >= 0 valido.
|
||||||
|
if (!match && /^[0-9]+$/.test(want)) {
|
||||||
|
var idx = parseInt(want, 10);
|
||||||
|
if (idx >= 0 && idx < opts.length) match = opts[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) return '__NO_OPTION__';
|
||||||
|
|
||||||
|
try { sel.focus(); } catch (e) {}
|
||||||
|
// option.selected en vez de solo select.value: necesario para <select multiple>
|
||||||
|
// y mas fiel a como un usuario elige una entrada concreta.
|
||||||
|
if (!sel.multiple) {
|
||||||
|
for (var n = 0; n < opts.length; n++) opts[n].selected = false;
|
||||||
|
}
|
||||||
|
match.selected = true;
|
||||||
|
sel.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||||
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return '__OK__:' + match.value;
|
||||||
|
})()`, jsString(selector), jsString(value))
|
||||||
|
|
||||||
|
res, err := CdpEvaluate(c, js)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp select option: evaluar selector %q: %w", selector, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = strings.Trim(res, `"`)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(res, "__OK__"):
|
||||||
|
return nil
|
||||||
|
case res == "__NO_EL__":
|
||||||
|
return fmt.Errorf("cdp select option: element not found para selector %q", selector)
|
||||||
|
case res == "__NOT_SELECT__":
|
||||||
|
return fmt.Errorf("cdp select option: element %q is not a <select> (use cdp_select_dropdown / click el trigger+option para dropdowns custom)", selector)
|
||||||
|
case res == "__NO_OPTION__":
|
||||||
|
return fmt.Errorf("cdp select option: option %q not found in <select> %q", value, selector)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsString convierte un string Go en un literal JS seguro (entre comillas dobles,
|
||||||
|
// con escapes para comillas, backslashes y saltos de linea). Evita la inyeccion
|
||||||
|
// de codigo al interpolar selectores/valores arbitrarios en el script JS.
|
||||||
|
func jsString(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('"')
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '"':
|
||||||
|
b.WriteString(`\"`)
|
||||||
|
case '\\':
|
||||||
|
b.WriteString(`\\`)
|
||||||
|
case '\n':
|
||||||
|
b.WriteString(`\n`)
|
||||||
|
case '\r':
|
||||||
|
b.WriteString(`\r`)
|
||||||
|
case '\t':
|
||||||
|
b.WriteString(`\t`)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('"')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
name: cdp_select_option
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.1.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
|
||||||
|
description: "Selecciona una <option> de un <select> nativo (localizado por selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions). Match por value exacto, luego label/texto exacto, luego label normalizado (whitespace-collapse + strip zero-width/soft-hyphen), luego substring normalizado, y por ultimo indice si value es entero. Setea option.selected (soporta <select multiple>), hace focus, y despacha 'input' {bubbles,composed} + 'change' {bubbles}. Valida que el elemento sea <select> (error claro si no) y sigue <label for>. Via Runtime.evaluate, reusa CdpEvaluate."
|
||||||
|
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
|
||||||
|
uses_functions: [cdp_evaluate_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, strings]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "conexión CDP activa"
|
||||||
|
- name: selector
|
||||||
|
desc: "selector CSS del elemento <select> a modificar"
|
||||||
|
- name: value
|
||||||
|
desc: "criterio de seleccion. Se prueba en orden: value exacto → label/texto exacto → label normalizado (whitespace-collapse + strip U+200B/U+00AD) → label por substring normalizado → indice (si value es un entero)"
|
||||||
|
output: "error si el selector no encuentra elemento (\"element not found\"), si el elemento no es un <select> (\"element is not a <select> ...\"), o si ninguna option coincide (\"option not found in <select>\"); nil si la selección y los eventos se despacharon correctamente"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_select_option.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
CdpNavigate(conn, "https://example.com/form")
|
||||||
|
|
||||||
|
// Seleccionar por value
|
||||||
|
if err := CdpSelectOption(conn, "#country", "ES"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seleccionar por texto visible cuando no se conoce el value interno
|
||||||
|
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seleccionar por indice (3a opcion) cuando ni value ni texto son estables
|
||||||
|
if err := CdpSelectOption(conn, "#size", "2"); err != nil { // index 2 = 3a option
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
|
||||||
|
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
|
||||||
|
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
|
||||||
|
de un click sobre la option, setea `option.selected` y dispara `input`+`change`,
|
||||||
|
que es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
|
||||||
|
formulario despues. Si no conoces el `value` interno, pasa el texto visible (se
|
||||||
|
normaliza el whitespace) o el indice numerico de la option.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo `<select>` nativos.** Si el elemento no es un `<select>` retorna error
|
||||||
|
claro `element is not a <select> ...`. Dropdowns custom hechos con `<div>` + JS
|
||||||
|
(react-select, headlessui, Radix, etc.) NO son `<select>` reales: para esos usa
|
||||||
|
`cdp_select_dropdown` (cuando exista) o clica el trigger con `CdpClickRef` y
|
||||||
|
luego la opcion del menu desplegado (`CdpFindRefByText` + `CdpClickRef`). NO uses
|
||||||
|
esta funcion para ellos.
|
||||||
|
- **Orden de matching del `value` recibido** (se prueba en este orden y para en el
|
||||||
|
primer match):
|
||||||
|
1. `option.value` exacto (`===`).
|
||||||
|
2. `option.label` / `textContent` exacto (sin normalizar).
|
||||||
|
3. label/texto NORMALIZADO exacto: se quita zero-width space (U+200B) y soft
|
||||||
|
hyphen (U+00AD), se hace `trim`, y se colapsa cualquier whitespace (`\s+`) a un
|
||||||
|
solo espacio — igual que `normalizeWhiteSpace` de Playwright.
|
||||||
|
4. label/texto por SUBSTRING normalizado (primera option cuyo label normalizado
|
||||||
|
contenga el value normalizado). Util para etiquetas largas; cuidado con
|
||||||
|
ambiguedad (gana la primera en orden de documento).
|
||||||
|
5. fallback por INDICE: solo si `value` es un entero `>= 0` valido (`"2"` → 3a
|
||||||
|
option). Por eso un `value` que casualmente sea numerico puede caer aqui si no
|
||||||
|
hubo ningun match textual antes — preferi el `value` real cuando exista.
|
||||||
|
El matching es case-sensitive en todos los pasos (no se hace lowercase).
|
||||||
|
- **`<select multiple>` soportado:** setea `option.selected = true` sobre la option
|
||||||
|
encontrada sin tocar el resto de selecciones. En un `<select>` simple deselecciona
|
||||||
|
las demas antes de marcar la elegida. (La version 1.0.0 solo seteaba `select.value`
|
||||||
|
y reseteaba el multiple — corregido.)
|
||||||
|
- **Eventos:** dispara `input` con `{bubbles:true, composed:true}` (el `composed`
|
||||||
|
permite cruzar shadow DOM, p.ej. web components que envuelven el `<select>`) y
|
||||||
|
luego `change` con `{bubbles:true}`, en ese orden. Hace `focus()` del select antes.
|
||||||
|
- No hace scroll ni verifica visibilidad/enabled: opera sobre el DOM directamente.
|
||||||
|
Si el `<select>` o la `<option>` estan `disabled`, la seleccion se aplica igual
|
||||||
|
pero la UI puede ignorarla segun el framework (Playwright aqui devolveria
|
||||||
|
`optionnotenabled`; esta funcion no chequea enabled — mantiene KISS).
|
||||||
|
- Si el elemento aun no existe (carga dinamica), retorna `element not found` sin
|
||||||
|
esperar — combinar con `CdpWaitElement` para elementos diferidos.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-16) — alineada con Playwright `injectedScript.selectOptions`:
|
||||||
|
valida que el elemento sea `<select>` (error claro si no, apuntando a dropdowns
|
||||||
|
custom), sigue `<label for>`, matching multi-criterio (value → label exacto →
|
||||||
|
label normalizado whitespace-collapse → substring → indice), usa
|
||||||
|
`option.selected` en vez de solo `select.value` (soporta `<select multiple>`),
|
||||||
|
añade `composed:true` al evento `input` (cruza shadow DOM) y `focus()` previo.
|
||||||
|
Firma intacta (no rompe el caller del MCP `dom_select_option`).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CdpSetFileInput sube archivos a un <input type="file"> identificado por el
|
||||||
|
// selector CSS. Resuelve el nodo via DOM.getDocument + DOM.querySelector y luego
|
||||||
|
// asigna los archivos con DOM.setFileInputFiles. Util para automatizar formularios
|
||||||
|
// de subida sin simular el dialogo nativo de seleccion de archivos.
|
||||||
|
//
|
||||||
|
// Cada path de paths se valida con os.Stat ANTES de enviar el comando: si alguno
|
||||||
|
// no existe (o no es accesible) se devuelve error inmediato sin tocar el DOM. Los
|
||||||
|
// paths deben ser absolutos y accesibles por el proceso de Chrome (ver Gotchas en
|
||||||
|
// el .md): Chrome lee los archivos desde su propio contexto, no desde el de este
|
||||||
|
// programa.
|
||||||
|
func CdpSetFileInput(c *CDPConn, selector string, paths []string) error {
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("cdp set file input: conexion nula")
|
||||||
|
}
|
||||||
|
if selector == "" {
|
||||||
|
return fmt.Errorf("cdp set file input: selector vacio")
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return fmt.Errorf("cdp set file input: lista de paths vacia")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que cada path exista en disco antes de mandar nada a Chrome.
|
||||||
|
for _, p := range paths {
|
||||||
|
if p == "" {
|
||||||
|
return fmt.Errorf("cdp set file input: path vacio en la lista")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cdp set file input: el archivo no existe: %q", p)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cdp set file input: no se puede acceder al archivo %q: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener el nodo raiz del documento.
|
||||||
|
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp set file input: DOM.getDocument: %w", err)
|
||||||
|
}
|
||||||
|
root, ok := docRes["root"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cdp set file input: respuesta de DOM.getDocument sin root")
|
||||||
|
}
|
||||||
|
rootNodeID, ok := root["nodeId"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cdp set file input: DOM.getDocument sin nodeId raiz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el input por selector.
|
||||||
|
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||||
|
"nodeId": int(rootNodeID),
|
||||||
|
"selector": selector,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cdp set file input: DOM.querySelector %q: %w", selector, err)
|
||||||
|
}
|
||||||
|
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||||
|
if !ok || int(nodeIDVal) == 0 {
|
||||||
|
return fmt.Errorf("cdp set file input: el selector %q no coincide con ningun elemento", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar los archivos al input.
|
||||||
|
files := make([]any, len(paths))
|
||||||
|
for i, p := range paths {
|
||||||
|
files[i] = p
|
||||||
|
}
|
||||||
|
if _, err := c.sendCDP("DOM.setFileInputFiles", map[string]any{
|
||||||
|
"files": files,
|
||||||
|
"nodeId": int(nodeIDVal),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("cdp set file input: DOM.setFileInputFiles en %q: %w", selector, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: cdp_set_file_input
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpSetFileInput(c *CDPConn, selector string, paths []string) error"
|
||||||
|
description: "Sube archivos a un <input type=\"file\"> identificado por selector CSS, sin abrir el dialogo nativo de seleccion de archivos. Resuelve el nodo via DOM.getDocument + DOM.querySelector y asigna los archivos con DOM.setFileInputFiles. Valida con os.Stat que cada path exista en disco antes de tocar el DOM."
|
||||||
|
tags: [chrome, cdp, browser, automation, upload, file, input, form, dom, devtools]
|
||||||
|
uses_functions: [cdp_connect_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "conexión CDP activa (*CDPConn)"
|
||||||
|
- name: selector
|
||||||
|
desc: "selector CSS del <input type=\"file\"> destino (ej. 'input[type=file]', '#avatar')"
|
||||||
|
- name: paths
|
||||||
|
desc: "rutas absolutas de los archivos a subir; cada una debe existir y ser accesible por el proceso Chrome"
|
||||||
|
output: "error si algún path no existe, si el selector no coincide con ningún nodo, o si falla el comando CDP; nil si los archivos quedaron asignados al input"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/browser/cdp_set_file_input.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
CdpNavigate(conn, "https://example.com/upload")
|
||||||
|
|
||||||
|
// Subir un solo archivo
|
||||||
|
err := CdpSetFileInput(conn, "input[type=file]", []string{"/home/enmanuel/docs/cv.pdf"})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subir varios archivos a un input con multiple
|
||||||
|
err = CdpSetFileInput(conn, "#gallery", []string{
|
||||||
|
"/home/enmanuel/fotos/1.jpg",
|
||||||
|
"/home/enmanuel/fotos/2.jpg",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando automatices un formulario web de subida de archivos y necesites rellenar un
|
||||||
|
`<input type="file">` sin poder interactuar con el dialogo nativo del sistema
|
||||||
|
operativo (que CDP no puede manejar haciendo click). Llamala despues de navegar a
|
||||||
|
la pagina y de que el input exista en el DOM; combina con `CdpWaitElement` si el
|
||||||
|
input aparece de forma dinamica.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Los paths deben ser ABSOLUTOS y accesibles por el proceso de Chrome**, no por
|
||||||
|
este programa. Chrome lee los archivos desde su propio contexto/usuario; un path
|
||||||
|
relativo o un archivo en un directorio que Chrome no puede leer fallara en el
|
||||||
|
navegador aunque `os.Stat` pase localmente (caso tipico: Chrome corriendo en otro
|
||||||
|
usuario, contenedor o maquina remota via CDP).
|
||||||
|
- La validacion `os.Stat` se ejecuta en la maquina donde corre esta funcion. Si el
|
||||||
|
Chrome del CDP esta en otra maquina/contenedor, que `os.Stat` pase NO garantiza
|
||||||
|
que Chrome encuentre el archivo. En ese escenario los paths deben ser validos en
|
||||||
|
el filesystem de Chrome.
|
||||||
|
- El selector debe apuntar a un `<input type="file">` real. Apuntar a un boton o
|
||||||
|
label que dispara el dialogo nativo no funciona: hay que resolver el input
|
||||||
|
subyacente.
|
||||||
|
- Asignar mas de un archivo requiere que el input tenga el atributo `multiple`; si
|
||||||
|
no lo tiene, Chrome puede rechazar o quedarse solo con el primero.
|
||||||
|
- No dispara automaticamente el submit del formulario ni eventos `change`
|
||||||
|
personalizados mas alla de los que el propio CDP emite al asignar los archivos;
|
||||||
|
si la pagina depende de listeners adicionales, comprueba el comportamiento.
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actionableBackoff es el calendario de espera entre reintentos del bucle de
|
||||||
|
// actionability, copiado del _retryAction de Playwright (waitTime [0,20,100,100,500]).
|
||||||
|
// Tras agotar la tabla, se mantiene en el ultimo valor (500ms) hasta el timeout.
|
||||||
|
// El primer intento es inmediato (0ms): muchas veces el elemento ya esta listo.
|
||||||
|
var actionableBackoff = []time.Duration{
|
||||||
|
0,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
100 * time.Millisecond,
|
||||||
|
100 * time.Millisecond,
|
||||||
|
500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionableScrollAligns rota la alineacion block de scrollIntoView entre
|
||||||
|
// reintentos. Cyclar las alineaciones (center/start/end) destraba casos donde un
|
||||||
|
// header position:sticky o un footer fijo tapa el punto al alinear de una sola
|
||||||
|
// forma — replica el scrollOptions cycling de _retryPointerAction de Playwright.
|
||||||
|
var actionableScrollAligns = []string{"center", "start", "end"}
|
||||||
|
|
||||||
|
// actionableResult es el veredicto que el JS inyectado devuelve por iteracion.
|
||||||
|
// state describe el primer estado que fallo (para el mensaje de error final);
|
||||||
|
// x,y son el punto central listo para el pointer cuando ok==true.
|
||||||
|
type actionableResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
State string `json:"state"` // "visible" | "stable" | "enabled" | "inviewport" | "intercepted" | "notconnected"
|
||||||
|
Detail string `json:"detail"` // descripcion del interceptor u otro detalle
|
||||||
|
X float64 `json:"x"` // punto central viewport (CSS px)
|
||||||
|
Y float64 `json:"y"` //
|
||||||
|
PageX float64 `json:"pageX"` // punto central en coords de pagina (scroll incluido)
|
||||||
|
PageY float64 `json:"pageY"` //
|
||||||
|
}
|
||||||
|
|
||||||
|
// CdpWaitActionable bloquea hasta que el elemento identificado por backendNodeID
|
||||||
|
// sea accionable (listo para recibir un click/hover fiable) o expire timeout.
|
||||||
|
// Reproduce el modelo de actionability de Playwright: en cada iteracion comprueba
|
||||||
|
// que el elemento esta visible, estable (mismo rect en dos requestAnimationFrame
|
||||||
|
// consecutivos), opcionalmente enabled, dentro del viewport tras scrollIntoView,
|
||||||
|
// y que el hit-test (document.elementFromPoint subiendo por shadow DOM) apunta al
|
||||||
|
// propio nodo o a un descendiente. Si algo falla, espera con backoff
|
||||||
|
// [0,20,100,100,500]ms (luego 500ms constante) y reintenta, rotando la alineacion
|
||||||
|
// del scroll para destrabar overlays sticky.
|
||||||
|
//
|
||||||
|
// Devuelve el punto central (x,y) en coordenadas de viewport (CSS px), listo para
|
||||||
|
// Input.dispatchMouseEvent. Al expirar, el error indica QUE estado fallo en el
|
||||||
|
// ultimo intento (not visible / not stable / disabled / outside viewport /
|
||||||
|
// intercepted by other element).
|
||||||
|
//
|
||||||
|
// needEnabled controla si se exige el estado enabled (no `disabled`,
|
||||||
|
// `aria-disabled="true"`, ni dentro de un <fieldset disabled>). Pasar false para
|
||||||
|
// elementos no interactivos (texto, contenedores) donde enabled no aplica.
|
||||||
|
func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: conexión nil")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver el backendNodeID a un objectId una sola vez. El objectId apunta al
|
||||||
|
// nodo DOM vivo y se reutiliza en cada iteracion via Runtime.callFunctionOn,
|
||||||
|
// evitando un resolveNode por reintento.
|
||||||
|
res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: resolveNode ref %d: %w", backendNodeID, err)
|
||||||
|
}
|
||||||
|
obj, _ := res["object"].(map[string]any)
|
||||||
|
objID, _ := obj["objectId"].(string)
|
||||||
|
if objID == "" {
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: sin objectId para ref %d (nodo inexistente)", backendNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var last actionableResult
|
||||||
|
last.State = "visible" // estado por defecto si nunca llegamos a evaluar
|
||||||
|
|
||||||
|
for retry := 0; ; retry++ {
|
||||||
|
// Espera con backoff antes de reintentar (el primer intento es inmediato).
|
||||||
|
if retry > 0 {
|
||||||
|
wait := actionableBackoff[len(actionableBackoff)-1]
|
||||||
|
if retry-1 < len(actionableBackoff) {
|
||||||
|
wait = actionableBackoff[retry-1]
|
||||||
|
}
|
||||||
|
if wait > 0 {
|
||||||
|
// No dormir mas alla del deadline.
|
||||||
|
if remaining := time.Until(deadline); remaining < wait {
|
||||||
|
wait = remaining
|
||||||
|
}
|
||||||
|
if wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
align := actionableScrollAligns[retry%len(actionableScrollAligns)]
|
||||||
|
r, evalErr := evalActionable(c, objID, needEnabled, align)
|
||||||
|
if evalErr != nil {
|
||||||
|
// Un error de protocolo (tab cerrada, nodo liberado) es terminal: no
|
||||||
|
// tiene sentido reintentar sobre un objectId muerto.
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d: %w", backendNodeID, evalErr)
|
||||||
|
}
|
||||||
|
last = r
|
||||||
|
|
||||||
|
if r.OK {
|
||||||
|
return r.X, r.Y, nil
|
||||||
|
}
|
||||||
|
if r.State == "notconnected" {
|
||||||
|
// El nodo dejo de estar conectado al DOM — reintentar no lo revivira.
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d desconectado del DOM", backendNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, 0, fmt.Errorf("cdp wait actionable: ref %d no accionable tras %s: %s", backendNodeID, timeout, describeActionableFailure(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeActionableFailure traduce el estado fallido a un mensaje humano.
|
||||||
|
func describeActionableFailure(r actionableResult) string {
|
||||||
|
switch r.State {
|
||||||
|
case "visible":
|
||||||
|
return "not visible (display:none, visibility:hidden, opacity:0 o tamaño 0)"
|
||||||
|
case "stable":
|
||||||
|
return "not stable (el rect sigue cambiando entre frames; animación o layout en curso)"
|
||||||
|
case "enabled":
|
||||||
|
return "disabled (atributo disabled, aria-disabled=true o <fieldset disabled>)"
|
||||||
|
case "inviewport":
|
||||||
|
return "outside of the viewport (scrollIntoView no logró revelarlo)"
|
||||||
|
case "intercepted":
|
||||||
|
if r.Detail != "" {
|
||||||
|
return "intercepted by other element: " + r.Detail
|
||||||
|
}
|
||||||
|
return "intercepted by other element (overlay capta el pointer en el punto central)"
|
||||||
|
case "notconnected":
|
||||||
|
return "not connected to the DOM"
|
||||||
|
default:
|
||||||
|
if r.State != "" {
|
||||||
|
return "not " + r.State
|
||||||
|
}
|
||||||
|
return "estado desconocido"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalActionable corre una iteracion completa de chequeos en el contexto JS de la
|
||||||
|
// pagina, sobre el nodo apuntado por objID. Devuelve el veredicto serializado.
|
||||||
|
//
|
||||||
|
// El JS hace, en orden y cortocircuitando al primer fallo:
|
||||||
|
// 1. visible: tiene client rects y computed style no lo oculta.
|
||||||
|
// 2. stable: getBoundingClientRect identico en dos requestAnimationFrame seguidos.
|
||||||
|
// 3. enabled (si needEnabled): no disabled / aria-disabled=true / dentro de
|
||||||
|
// <fieldset disabled> (subiendo por la jerarquia, como getAriaDisabled).
|
||||||
|
// 4. scrollIntoView con la alineacion dada + comprobacion de que el centro cae
|
||||||
|
// dentro del viewport.
|
||||||
|
// 5. hit-test: elementFromPoint en el punto central, subiendo por shadow roots
|
||||||
|
// (assignedSlot / parentNode.host) y comprobando que el elemento golpeado es
|
||||||
|
// el target o uno de sus descendientes.
|
||||||
|
func evalActionable(c *CDPConn, objID string, needEnabled bool, scrollAlign string) (actionableResult, error) {
|
||||||
|
params := map[string]any{
|
||||||
|
"objectId": objID,
|
||||||
|
"functionDeclaration": actionableJS,
|
||||||
|
"arguments": []any{
|
||||||
|
map[string]any{"value": needEnabled},
|
||||||
|
map[string]any{"value": scrollAlign},
|
||||||
|
},
|
||||||
|
"awaitPromise": true,
|
||||||
|
"returnByValue": true,
|
||||||
|
}
|
||||||
|
result, err := c.sendCDP("Runtime.callFunctionOn", params)
|
||||||
|
if err != nil {
|
||||||
|
return actionableResult{}, err
|
||||||
|
}
|
||||||
|
if exc, ok := result["exceptionDetails"]; ok && exc != nil {
|
||||||
|
excMap, _ := exc.(map[string]any)
|
||||||
|
text, _ := excMap["text"].(string)
|
||||||
|
return actionableResult{}, fmt.Errorf("excepción JS en chequeo de actionability: %s", text)
|
||||||
|
}
|
||||||
|
resVal, ok := result["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return actionableResult{}, fmt.Errorf("resultado inesperado: %v", result)
|
||||||
|
}
|
||||||
|
raw, ok := resVal["value"]
|
||||||
|
if !ok {
|
||||||
|
return actionableResult{}, fmt.Errorf("chequeo de actionability sin valor de retorno")
|
||||||
|
}
|
||||||
|
// returnByValue=true entrega el objeto JS ya deserializado a map[string]any;
|
||||||
|
// lo re-marshalamos para decodificar en el struct tipado de forma robusta.
|
||||||
|
b, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return actionableResult{}, fmt.Errorf("marshal resultado: %w", err)
|
||||||
|
}
|
||||||
|
var out actionableResult
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return actionableResult{}, fmt.Errorf("unmarshal resultado %q: %w", string(b), err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionableJS es la funcion ejecutada sobre el nodo (this) via callFunctionOn.
|
||||||
|
// Devuelve una Promise<actionableResult>. La logica replica checkElementStates +
|
||||||
|
// _checkElementIsStable + expectHitTarget del injected script de Playwright,
|
||||||
|
// adaptada a un solo paso autocontenido (sin caches ni dependencias externas).
|
||||||
|
const actionableJS = `function(needEnabled, scrollAlign) {
|
||||||
|
var target = this;
|
||||||
|
var fail = function(state, detail) { return {ok:false, state:state, detail:detail||"", x:0, y:0, pageX:0, pageY:0}; };
|
||||||
|
|
||||||
|
if (!target || !target.isConnected) return Promise.resolve(fail("notconnected"));
|
||||||
|
if (target.nodeType !== 1) {
|
||||||
|
// Si el nodo no es un Element (ej. texto), intentar su elemento padre.
|
||||||
|
target = target.parentElement;
|
||||||
|
if (!target) return Promise.resolve(fail("notconnected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) VISIBLE: rect con area + computed style no oculto.
|
||||||
|
var isVisible = function(el) {
|
||||||
|
if (!el || !el.isConnected) return false;
|
||||||
|
var rects = el.getClientRects();
|
||||||
|
if (!rects || rects.length === 0) return false;
|
||||||
|
var st = (el.ownerDocument && el.ownerDocument.defaultView)
|
||||||
|
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
|
||||||
|
if (st) {
|
||||||
|
if (st.visibility === "hidden" || st.display === "none") return false;
|
||||||
|
if (parseFloat(st.opacity || "1") === 0) return false;
|
||||||
|
}
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
};
|
||||||
|
if (!isVisible(target)) return Promise.resolve(fail("visible"));
|
||||||
|
|
||||||
|
// 2) ENABLED (opcional): disabled nativo, aria-disabled o <fieldset disabled>.
|
||||||
|
if (needEnabled) {
|
||||||
|
var isDisabled = function(el) {
|
||||||
|
var native = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"];
|
||||||
|
var n = el;
|
||||||
|
while (n) {
|
||||||
|
if (n.nodeType === 1) {
|
||||||
|
var tag = (n.tagName || "").toUpperCase();
|
||||||
|
if (native.indexOf(tag) !== -1 && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||||
|
// fieldset disabled deshabilita a sus controles (salvo dentro del legend).
|
||||||
|
if (tag === "FIELDSET" && n.hasAttribute && n.hasAttribute("disabled")) return true;
|
||||||
|
var ad = n.getAttribute && n.getAttribute("aria-disabled");
|
||||||
|
if (ad && ad.toLowerCase() === "true") return true;
|
||||||
|
}
|
||||||
|
// Subir por DOM y cruzar shadow boundaries.
|
||||||
|
n = n.parentElement || (n.parentNode && n.parentNode.host) || (n.assignedSlot || null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (isDisabled(target)) return Promise.resolve(fail("enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) SCROLL INTO VIEW con la alineacion rotada por el caller.
|
||||||
|
try { target.scrollIntoView({block: scrollAlign, inline: scrollAlign, behavior: "instant"}); }
|
||||||
|
catch (e) { try { target.scrollIntoView(); } catch (e2) {} }
|
||||||
|
|
||||||
|
// 3) STABLE: comparar getBoundingClientRect en dos requestAnimationFrame seguidos.
|
||||||
|
var rectOf = function(el) {
|
||||||
|
var r = el.getBoundingClientRect();
|
||||||
|
return {x: r.left, y: r.top, w: r.width, h: r.height};
|
||||||
|
};
|
||||||
|
var rafTwice = function() {
|
||||||
|
return new Promise(function(res) {
|
||||||
|
requestAnimationFrame(function() { requestAnimationFrame(function() { res(); }); });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var first = rectOf(target);
|
||||||
|
return rafTwice().then(function() {
|
||||||
|
if (!target.isConnected) return fail("notconnected");
|
||||||
|
var second = rectOf(target);
|
||||||
|
var same = first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||||
|
if (!same) return fail("stable");
|
||||||
|
|
||||||
|
var r = second;
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
var cx = r.x + r.w / 2;
|
||||||
|
var cy = r.y + r.h / 2;
|
||||||
|
|
||||||
|
// 4b) IN VIEWPORT: el punto central debe caer dentro del viewport tras el scroll.
|
||||||
|
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return fail("inviewport");
|
||||||
|
|
||||||
|
// 5) HIT-TEST: elementFromPoint subiendo por shadow roots; el golpeado debe ser
|
||||||
|
// el target o un descendiente suyo (cruzando shadow boundaries).
|
||||||
|
var enclosingRoot = function(el) {
|
||||||
|
var node = el;
|
||||||
|
while (node && node.parentNode) node = node.parentNode;
|
||||||
|
if (node && (node.nodeType === 11 || node.nodeType === 9)) return node;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
var parentOrHost = function(el) {
|
||||||
|
if (el.parentElement) return el.parentElement;
|
||||||
|
if (el.parentNode && el.parentNode.nodeType === 11 && el.parentNode.host) return el.parentNode.host;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recolectar roots desde el target hacia arriba (document u shadow roots).
|
||||||
|
var roots = [];
|
||||||
|
var p = target;
|
||||||
|
while (p) {
|
||||||
|
var root = enclosingRoot(p);
|
||||||
|
if (!root) break;
|
||||||
|
roots.push(root);
|
||||||
|
if (root.nodeType === 9) break;
|
||||||
|
p = root.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit en cada root debe apuntar al siguiente root; en el ultimo, al target/descendiente.
|
||||||
|
var hit = null;
|
||||||
|
for (var i = roots.length - 1; i >= 0; i--) {
|
||||||
|
var rt = roots[i];
|
||||||
|
var inner = rt.elementFromPoint ? rt.elementFromPoint(cx, cy) : null;
|
||||||
|
if (!inner) break;
|
||||||
|
hit = inner;
|
||||||
|
if (i && roots[i - 1] && inner !== roots[i - 1].host) break;
|
||||||
|
}
|
||||||
|
if (!hit) return fail("intercepted", "ningún elemento en el punto central");
|
||||||
|
|
||||||
|
// Subir desde el hit hasta el target (composed tree: assignedSlot primero).
|
||||||
|
var cur = hit;
|
||||||
|
while (cur && cur !== target) {
|
||||||
|
cur = cur.assignedSlot || parentOrHost(cur);
|
||||||
|
}
|
||||||
|
if (cur !== target) {
|
||||||
|
var desc = hit.tagName ? hit.tagName.toLowerCase() : "node";
|
||||||
|
if (hit.id) desc += "#" + hit.id;
|
||||||
|
else if (hit.className && typeof hit.className === "string" && hit.className.trim())
|
||||||
|
desc += "." + hit.className.trim().split(/\s+/)[0];
|
||||||
|
return fail("intercepted", desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sx = window.scrollX || window.pageXOffset || 0;
|
||||||
|
var sy = window.scrollY || window.pageYOffset || 0;
|
||||||
|
return {ok:true, state:"ok", detail:"", x:cx, y:cy, pageX:cx + sx, pageY:cy + sy};
|
||||||
|
});
|
||||||
|
}`
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: cdp_wait_actionable
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func CdpWaitActionable(c *CDPConn, backendNodeID int, needEnabled bool, timeout time.Duration) (x float64, y float64, err error)"
|
||||||
|
description: "Bloquea hasta que el elemento del #ref sea accionable (listo para un click/hover fiable) o expire timeout. Reproduce el modelo de actionability de Playwright: en bucle con backoff [0,20,100,100,500]ms comprueba visible (client rects + computed style), stable (mismo getBoundingClientRect en dos requestAnimationFrame seguidos), enabled opcional (disabled / aria-disabled / fieldset disabled subiendo la jerarquía), scroll into view rotando alineación block (center/start/end), y hit-test (elementFromPoint subiendo por shadow DOM apunta al target o descendiente). Devuelve el punto central (x,y) en coords de viewport listo para Input.dispatchMouseEvent. Al expirar, el error indica qué estado falló (not visible / not stable / disabled / outside viewport / intercepted by other element)."
|
||||||
|
tags: [cdp, browser, action, ref, actionability, browser-actionability, 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."
|
||||||
|
- name: backendNodeID
|
||||||
|
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||||
|
- name: needEnabled
|
||||||
|
desc: "Si true, exige también el estado enabled (no disabled, no aria-disabled=true, no dentro de <fieldset disabled>). Pasar false para elementos no interactivos (texto, contenedores) donde enabled no aplica."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Tiempo máximo de espera antes de rendirse. <=0 usa 5s por defecto. El bucle de reintento nunca duerme más allá de este deadline."
|
||||||
|
output: "(x, y) punto central del elemento en coordenadas de viewport (CSS px), listo para despachar el pointer, cuando todos los chequeos pasan; error si la conexión es nil, el nodo no resuelve a objectId, se desconecta del DOM, o expira el timeout (con el estado que falló al final)."
|
||||||
|
file_path: "functions/browser/cdp_wait_actionable.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Tras un page_perceive que devuelve outline con #ref=1234, esperar a que el
|
||||||
|
// elemento sea accionable y luego clicar el punto exacto que devuelve:
|
||||||
|
conn, _ := CdpConnect(9222)
|
||||||
|
x, y, err := CdpWaitActionable(conn, 1234, true, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("no accionable: %v", err) // ej: "intercepted by other element: div#cookie-banner"
|
||||||
|
}
|
||||||
|
// x,y ya están en viewport, estables y sin overlay encima: click fiable.
|
||||||
|
_ = CdpClickXYHuman(conn, x, y, MouseHumanOpts{})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de CUALQUIER click/hover/type que deba ser fiable sobre un #ref del outline.
|
||||||
|
Llamarla justo después de `page_perceive` y antes de `cdp_click_ref` /
|
||||||
|
`cdp_click_xy_human` / `dom_*_ref` para evitar los fallos clásicos del navegador:
|
||||||
|
clicar un botón que aún se está animando hacia su posición, un elemento tapado por
|
||||||
|
un banner de cookies / modal / spinner, o un control todavía `disabled`. Es la
|
||||||
|
puerta de actionability que separa "el nodo existe en el DOM" de "el nodo está
|
||||||
|
listo para recibir el evento ahí donde lo voy a despachar". Usar `needEnabled=true`
|
||||||
|
para botones/inputs/enlaces; `needEnabled=false` para hover sobre texto o medir un
|
||||||
|
contenedor.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Coste de polling.** Es síncrona y bloqueante: hace un `Runtime.callFunctionOn`
|
||||||
|
por iteración + 2 `requestAnimationFrame` por chequeo de estabilidad. En el peor
|
||||||
|
caso poll-ea hasta `timeout` con backoff creciente (0,20,100,100,500ms → 500ms).
|
||||||
|
No la metas en un bucle apretado sobre N elementos sin necesidad; una sola
|
||||||
|
llamada por acción es lo correcto. Timeouts altos sobre elementos que nunca
|
||||||
|
llegan (genuinamente ocultos) cuestan el timeout entero.
|
||||||
|
- **Shadow DOM.** El hit-test sube por shadow roots (`assignedSlot` /
|
||||||
|
`parentNode.host`) y por eso funciona con web components con shadow root
|
||||||
|
*abierto*. Con shadow roots **cerrados** `elementFromPoint` no expone el interior
|
||||||
|
y el hit-test puede reportar `intercepted` erróneamente; en ese caso usar el
|
||||||
|
click vía `element.click()` (modo instant de `cdp_click_ref`), que no depende del
|
||||||
|
hit-test geométrico.
|
||||||
|
- **iframes.** Opera sobre el contexto de la página/frame al que apunta el
|
||||||
|
`*CDPConn`. Un `backendNodeID` de otro frame no resuelve aquí: hay que tener la
|
||||||
|
conexión/contexto del frame correcto (ver `cdp_eval_in_frame`). Las coordenadas
|
||||||
|
devueltas son relativas al viewport de ESE documento, no compuestas con el offset
|
||||||
|
del iframe en la página padre.
|
||||||
|
- **Estabilidad vs animaciones infinitas.** Un elemento con una animación CSS
|
||||||
|
perpetua que mueve su rect (spinner que se desplaza, marquee) nunca pasará el
|
||||||
|
chequeo `stable` y agotará el timeout con "not stable". Es comportamiento
|
||||||
|
correcto (no es accionable de forma fiable), pero conviene saberlo.
|
||||||
|
- **El punto devuelto es (x,y) de viewport**, no de página. Es lo que
|
||||||
|
`Input.dispatchMouseEvent` espera. Si necesitas coords de página (con scroll),
|
||||||
|
el JS interno ya las calcula (`pageX/pageY`) pero la firma pública expone solo
|
||||||
|
las de viewport para encajar con el dispatch de pointer.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectCaptchaJS es la unica evaluacion que DetectCaptcha corre en el top frame.
|
||||||
|
// Detecta reCAPTCHA, hCaptcha y Cloudflare Turnstile por la presencia de sus
|
||||||
|
// iframes/widgets (los iframe[src] son legibles desde el top aunque su contenido
|
||||||
|
// sea cross-origin) y el JS-challenge de Cloudflare por texto en innerText.
|
||||||
|
// Siempre retorna un JSON serializable; en caso de excepcion devuelve detected=false
|
||||||
|
// con un campo "error" para que el caller no rompa (best-effort).
|
||||||
|
const detectCaptchaJS = `(function(){
|
||||||
|
try {
|
||||||
|
var sigs = [];
|
||||||
|
var q = function(s){ return document.querySelector(s); };
|
||||||
|
if (q('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"], .g-recaptcha, #recaptcha')) sigs.push('recaptcha');
|
||||||
|
if (q('iframe[src*="hcaptcha.com"], .h-captcha')) sigs.push('hcaptcha');
|
||||||
|
if (q('iframe[src*="challenges.cloudflare.com"], .cf-turnstile')) sigs.push('turnstile');
|
||||||
|
var t = ((document.body && document.body.innerText) || '').toLowerCase().slice(0, 4000);
|
||||||
|
if (/checking your browser|verify(ing)? you are human|i'?m not a robot|are you a robot|unusual traffic|complete the security check|press and hold/.test(t)) sigs.push('challenge');
|
||||||
|
var seen = {}, uniq = [];
|
||||||
|
for (var i=0;i<sigs.length;i++){ if(!seen[sigs[i]]){seen[sigs[i]]=1;uniq.push(sigs[i]);} }
|
||||||
|
return JSON.stringify({detected: uniq.length>0, types: uniq, url: location.href});
|
||||||
|
} catch(e){ return JSON.stringify({detected:false, types:[], url: (location&&location.href)||'', error:String(e)}); }
|
||||||
|
})()`
|
||||||
|
|
||||||
|
// captchaResult es el shape del JSON que produce detectCaptchaJS.
|
||||||
|
type captchaResult struct {
|
||||||
|
Detected bool `json:"detected"`
|
||||||
|
Types []string `json:"types"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCaptchaSignals parsea el JSON que produce detectCaptchaJS. Es puro y
|
||||||
|
// testeable sin navegador. Si el JSON trae un campo "error" (excepcion JS en la
|
||||||
|
// pagina) se trata como detected=false best-effort, no como fallo. types es
|
||||||
|
// siempre un slice no nulo (vacio si no hay senales). Solo retorna error si el
|
||||||
|
// JSON es invalido / no parseable.
|
||||||
|
func parseCaptchaSignals(raw string) (detected bool, types []string, url string, err error) {
|
||||||
|
var r captchaResult
|
||||||
|
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("parse captcha signals: json invalido: %w", err)
|
||||||
|
}
|
||||||
|
if r.Types == nil {
|
||||||
|
r.Types = []string{}
|
||||||
|
}
|
||||||
|
return r.Detected, r.Types, r.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectCaptcha detecta si la pagina actual presenta un captcha o challenge
|
||||||
|
// anti-bot. Corre UNA evaluacion JS en el top frame y parsea el resultado.
|
||||||
|
// NO resuelve ni notifica nada — solo detecta. Una responsabilidad.
|
||||||
|
//
|
||||||
|
// Retorna detected=true si hay al menos una senal, junto con los tipos
|
||||||
|
// detectados (subconjunto de: "recaptcha", "hcaptcha", "turnstile",
|
||||||
|
// "challenge") y la URL del top frame. Best-effort: una excepcion JS en la
|
||||||
|
// pagina se trata como "no detectado" sin romper.
|
||||||
|
func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error) {
|
||||||
|
if c == nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: conexion nula")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := CdpEvaluate(c, detectCaptchaJS)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detected, types, url, err = parseCaptchaSignals(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, "", fmt.Errorf("detect captcha: %w", err)
|
||||||
|
}
|
||||||
|
return detected, types, url, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: detect_captcha
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: browser
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DetectCaptcha(c *CDPConn) (detected bool, types []string, url string, err error)"
|
||||||
|
description: "Detecta captchas y challenges anti-bot en la pagina actual via CDP: reCAPTCHA, hCaptcha, Cloudflare Turnstile (por iframe/widget) y el JS-challenge de Cloudflare (por texto). Solo detecta — no resuelve ni notifica. Una responsabilidad."
|
||||||
|
tags: [captcha, browser, cdp, antibot, detection, perception]
|
||||||
|
uses_functions: [cdp_evaluate_go_browser]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [encoding/json, fmt]
|
||||||
|
params:
|
||||||
|
- name: c
|
||||||
|
desc: "Conexion CDP activa a una tab de Chrome de tipo 'page'. La evaluacion corre en el top frame."
|
||||||
|
output: "Tupla (detected, types, url, err). detected=true si hay al menos una senal anti-bot. types es el subconjunto de senales detectadas (de: 'recaptcha', 'hcaptcha', 'turnstile', 'challenge'), siempre slice no nulo (vacio si nada). url es la location.href del top frame. err si la conexion es nula, falla el eval CDP, o el JSON resultante es invalido. Una excepcion JS en la pagina se trata como detected=false best-effort, sin error."
|
||||||
|
tested: true
|
||||||
|
tests: ["recaptcha detectado", "hcaptcha detectado", "turnstile detectado", "challenge por texto", "multiples senales", "ninguno", "campo error best-effort no rompe", "types ausente se normaliza a slice vacio", "json invalido devuelve error"]
|
||||||
|
test_file_path: "functions/browser/detect_captcha_test.go"
|
||||||
|
file_path: "functions/browser/detect_captcha.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Conectar a un Chrome con CDP abierto (mismo patron que cdp_get_text)
|
||||||
|
conn, err := CdpConnect(9222)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer CdpDisconnect(conn)
|
||||||
|
|
||||||
|
// Tras navegar y esperar la carga, comprobar si la pagina puso un captcha
|
||||||
|
detected, types, url, err := DetectCaptcha(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if detected {
|
||||||
|
fmt.Printf("captcha detectado en %s: %v\n", url, types)
|
||||||
|
// p.ej. -> "captcha detectado en https://x.test/login: [recaptcha]"
|
||||||
|
} else {
|
||||||
|
fmt.Println("sin captcha, seguir clicando")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Tras navegar o esperar la carga de una pagina, para saber si esta puso un captcha o challenge anti-bot antes de seguir clicando o enviando formularios. La usa el `browser_mcp` en sus handlers de navegacion para decidir el handoff humano: si `DetectCaptcha` devuelve `detected=true`, el flujo automatico se detiene y avisa para resolucion manual en vez de chocar contra el muro.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo top frame**: la evaluacion corre en el frame principal. Un captcha incrustado en un iframe anidado profundo cuyo `src` no matchee los patrones no se detecta.
|
||||||
|
- **Iframes cross-origin**: el contenido de los iframes de reCAPTCHA/hCaptcha/Turnstile NO se lee (politica same-origin), pero SI se detectan por su `src` y por las clases del widget host (`.g-recaptcha`, `.h-captcha`, `.cf-turnstile`), que viven en el top document.
|
||||||
|
- **Falsos positivos posibles**: la senal `challenge` viene de regex sobre `innerText` (p.ej. "verify you are human", "unusual traffic"). Una pagina con ese texto en otro contexto (un articulo, una FAQ sobre bots) puede dar `detected=true` sin haber captcha real.
|
||||||
|
- **No detecta captchas custom**: solo cubre los proveedores listados (reCAPTCHA, hCaptcha, Turnstile) + el JS-challenge de Cloudflare. Captchas propios o de otros vendors no se reconocen.
|
||||||
|
- **Depende de innerText**: la pagina debe haber pintado el body. En una tab aun cargando (`document.body` nulo o vacio) la senal `challenge` puede no dispararse — esperar con `cdp_wait_load` antes de detectar si el contenido es dinamico.
|
||||||
|
- **Impura**: hace un round-trip CDP (I/O de red). Requiere conexion activa a una tab de tipo `page`.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCaptchaSignals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantDetected bool
|
||||||
|
wantTypes []string
|
||||||
|
wantURL string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "recaptcha detectado",
|
||||||
|
raw: `{"detected":true,"types":["recaptcha"],"url":"https://x.test/login"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"recaptcha"},
|
||||||
|
wantURL: "https://x.test/login",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hcaptcha detectado",
|
||||||
|
raw: `{"detected":true,"types":["hcaptcha"],"url":"https://y.test/signup"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"hcaptcha"},
|
||||||
|
wantURL: "https://y.test/signup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "turnstile detectado",
|
||||||
|
raw: `{"detected":true,"types":["turnstile"],"url":"https://z.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"turnstile"},
|
||||||
|
wantURL: "https://z.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge por texto",
|
||||||
|
raw: `{"detected":true,"types":["challenge"],"url":"https://cf.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"challenge"},
|
||||||
|
wantURL: "https://cf.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiples senales",
|
||||||
|
raw: `{"detected":true,"types":["turnstile","challenge"],"url":"https://cf.test/"}`,
|
||||||
|
wantDetected: true,
|
||||||
|
wantTypes: []string{"turnstile", "challenge"},
|
||||||
|
wantURL: "https://cf.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ninguno",
|
||||||
|
raw: `{"detected":false,"types":[],"url":"https://clean.test/"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://clean.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campo error best-effort no rompe",
|
||||||
|
raw: `{"detected":false,"types":[],"url":"https://err.test/","error":"boom"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://err.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "types ausente se normaliza a slice vacio",
|
||||||
|
raw: `{"detected":false,"url":"https://n.test/"}`,
|
||||||
|
wantDetected: false,
|
||||||
|
wantTypes: []string{},
|
||||||
|
wantURL: "https://n.test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json invalido devuelve error",
|
||||||
|
raw: `not-json`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
detected, types, url, err := parseCaptchaSignals(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("esperaba error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error inesperado: %v", err)
|
||||||
|
}
|
||||||
|
if detected != tt.wantDetected {
|
||||||
|
t.Errorf("detected: got %v, want %v", detected, tt.wantDetected)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(types, tt.wantTypes) {
|
||||||
|
t.Errorf("types: got %v, want %v", types, tt.wantTypes)
|
||||||
|
}
|
||||||
|
if url != tt.wantURL {
|
||||||
|
t.Errorf("url: got %q, want %q", url, tt.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
// Termination labels returned by ClassifyFleetTermination. They describe the
|
||||||
|
// mechanical termination state of a Claude fleet agent so a cheap (LLM-free)
|
||||||
|
// watcher can decide what to do with it.
|
||||||
|
const (
|
||||||
|
// TerminationReclama means the agent is asking for human input and must be
|
||||||
|
// attended first, above any other consideration.
|
||||||
|
TerminationReclama = "RECLAMA"
|
||||||
|
// TerminationMalLanzado means the agent was launched without a DoD contract
|
||||||
|
// — no agent should run without an acceptance criterion.
|
||||||
|
TerminationMalLanzado = "MAL_LANZADO"
|
||||||
|
// TerminationDiceTerminado means the agent claims it is finished (idle and
|
||||||
|
// either phase "hecho" or its DoD status is "met").
|
||||||
|
TerminationDiceTerminado = "DICE_TERMINADO"
|
||||||
|
// TerminationEstancado means the agent is idle, not finished, and has been
|
||||||
|
// inactive at or beyond the stall threshold.
|
||||||
|
TerminationEstancado = "ESTANCADO"
|
||||||
|
// TerminationTrabajando means the agent is still working (busy, or idle
|
||||||
|
// recently below the stall threshold).
|
||||||
|
TerminationTrabajando = "TRABAJANDO"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifyFleetTermination mechanically classifies the termination state of a
|
||||||
|
// fleet agent. It is pure and deterministic: no I/O, no clock, no global state.
|
||||||
|
//
|
||||||
|
// Inputs:
|
||||||
|
// - status: process state from sessions.json — "idle" | "busy" | "waiting".
|
||||||
|
// - phase: work phase from goal.json — "investigando", "planificando",
|
||||||
|
// "haciendo", "testeando", "puliendo", "pendiente_revision", "preguntando",
|
||||||
|
// "bloqueado", "en_pausa", "hecho", "iterando" or "".
|
||||||
|
// - dodContract: fixed acceptance criterion ("" means none was defined).
|
||||||
|
// - dodStatus: "pending" | "met" | "failed" | "".
|
||||||
|
// - idleSeconds: seconds since the session's last activity.
|
||||||
|
// - stallThresholdSeconds: threshold to consider the agent stalled.
|
||||||
|
//
|
||||||
|
// Precedence (evaluated top to bottom; the first match wins):
|
||||||
|
// 1. RECLAMA — the agent is asking for human input (status "waiting" OR phase
|
||||||
|
// "preguntando"/"bloqueado"). This dominates everything, even a missing DoD
|
||||||
|
// contract: if it asks for input, that is the first thing to handle.
|
||||||
|
// 2. MAL_LANZADO — it does not reclaim input but has no DoD contract; no agent
|
||||||
|
// should run without one.
|
||||||
|
// 3. DICE_TERMINADO — idle AND (phase "hecho" OR dodStatus "met").
|
||||||
|
// 4. ESTANCADO — idle AND phase not "hecho" AND idleSeconds >= stall threshold.
|
||||||
|
// 5. TRABAJANDO — everything else (busy, or idle recently below the threshold).
|
||||||
|
func ClassifyFleetTermination(status, phase, dodContract, dodStatus string, idleSeconds, stallThresholdSeconds int) string {
|
||||||
|
// 1. RECLAMA dominates: a request for human input is handled first.
|
||||||
|
if status == "waiting" || phase == "preguntando" || phase == "bloqueado" {
|
||||||
|
return TerminationReclama
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MAL_LANZADO: running without a DoD contract is invalid.
|
||||||
|
if dodContract == "" {
|
||||||
|
return TerminationMalLanzado
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DICE_TERMINADO: idle and self-reporting completion.
|
||||||
|
if status == "idle" && (phase == "hecho" || dodStatus == "met") {
|
||||||
|
return TerminationDiceTerminado
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ESTANCADO: idle, not finished, inactive at/beyond the stall threshold.
|
||||||
|
if status == "idle" && phase != "hecho" && idleSeconds >= stallThresholdSeconds {
|
||||||
|
return TerminationEstancado
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. TRABAJANDO: busy, or idle recently below the threshold.
|
||||||
|
return TerminationTrabajando
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: classify_fleet_termination
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "func ClassifyFleetTermination(status, phase, dodContract, dodStatus string, idleSeconds, stallThresholdSeconds int) string"
|
||||||
|
description: "Clasifica MECANICAMENTE el estado de terminacion de un agente Claude de la flota para que un watcher barato sin LLM decida que hacer. Pura y determinista. Devuelve una de RECLAMA, MAL_LANZADO, DICE_TERMINADO, ESTANCADO o TRABAJANDO segun precedencia fija: RECLAMA (pide input humano) manda sobre todo, luego MAL_LANZADO (sin DoD-contrato), luego DICE_TERMINADO, ESTANCADO y TRABAJANDO."
|
||||||
|
tags: [fleet, claude-fleet, classification, watcher, termination, orchestrator, pure, infra, orchestration]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: status
|
||||||
|
desc: "estado del proceso Claude leido de sessions.json: idle | busy | waiting"
|
||||||
|
- name: phase
|
||||||
|
desc: "fase de trabajo del goal.json: investigando | planificando | haciendo | testeando | puliendo | pendiente_revision | preguntando | bloqueado | en_pausa | hecho | iterando | (vacio)"
|
||||||
|
- name: dodContract
|
||||||
|
desc: "criterio de aceptacion fijo del agente; cadena vacia significa que no se definio DoD-contrato (agente mal lanzado)"
|
||||||
|
- name: dodStatus
|
||||||
|
desc: "estado de cumplimiento del DoD: pending | met | failed | (vacio)"
|
||||||
|
- name: idleSeconds
|
||||||
|
desc: "segundos transcurridos desde la ultima actividad de la sesion"
|
||||||
|
- name: stallThresholdSeconds
|
||||||
|
desc: "umbral en segundos a partir del cual un agente idle no terminado se considera ESTANCADO (comparacion >=, inclusiva)"
|
||||||
|
output: "una etiqueta string: RECLAMA | MAL_LANZADO | DICE_TERMINADO | ESTANCADO | TRABAJANDO (constantes exportadas TerminationReclama, TerminationMalLanzado, TerminationDiceTerminado, TerminationEstancado, TerminationTrabajando)"
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "waiting reclama input"
|
||||||
|
- "phase preguntando reclama"
|
||||||
|
- "phase bloqueado reclama"
|
||||||
|
- "waiting manda aunque sin dodContract"
|
||||||
|
- "preguntando manda aunque sin dodContract"
|
||||||
|
- "bloqueado manda aunque idle estancado"
|
||||||
|
- "sin dodContract busy"
|
||||||
|
- "sin dodContract idle reciente"
|
||||||
|
- "sin dodContract idle estancado"
|
||||||
|
- "sin dodContract phase hecho"
|
||||||
|
- "sin dodContract dodStatus met"
|
||||||
|
- "idle phase hecho"
|
||||||
|
- "idle dodStatus met"
|
||||||
|
- "idle hecho y met"
|
||||||
|
- "idle met aunque estancado por tiempo"
|
||||||
|
- "idle hecho aunque estancado por tiempo"
|
||||||
|
- "idle no hecho en umbral exacto"
|
||||||
|
- "idle no hecho por encima del umbral"
|
||||||
|
- "idle iterando estancado"
|
||||||
|
- "idle dodStatus failed estancado"
|
||||||
|
- "idle en_pausa estancado"
|
||||||
|
- "busy trabajando"
|
||||||
|
- "busy aunque idleSeconds alto"
|
||||||
|
- "idle reciente bajo umbral"
|
||||||
|
- "idle reciente cero segundos"
|
||||||
|
- "idle no hecho justo bajo umbral"
|
||||||
|
- "busy phase vacia con dodContract"
|
||||||
|
- "umbral cero idle no hecho => estancado"
|
||||||
|
- "idle hecho con umbral cero => dice terminado"
|
||||||
|
- "dodStatus met con status busy NO termina"
|
||||||
|
- "phase hecho con status busy NO termina"
|
||||||
|
- "idle pendiente_revision bajo umbral trabajando"
|
||||||
|
- "idle pendiente_revision sobre umbral estancado"
|
||||||
|
- "waiting con dodStatus met sigue reclamando"
|
||||||
|
test_file_path: "functions/infra/classify_fleet_termination_test.go"
|
||||||
|
file_path: "functions/infra/classify_fleet_termination.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Agente idle que dice estar terminado.
|
||||||
|
label := ClassifyFleetTermination("idle", "hecho", "tests verdes + indexado", "met", 30, 600)
|
||||||
|
// label == "DICE_TERMINADO" (== TerminationDiceTerminado)
|
||||||
|
|
||||||
|
// Agente idle, no terminado, parado 12 minutos con umbral de 10 minutos.
|
||||||
|
label = ClassifyFleetTermination("idle", "haciendo", "tests verdes", "pending", 720, 600)
|
||||||
|
// label == "ESTANCADO"
|
||||||
|
|
||||||
|
// Agente que reclama input humano: manda sobre todo, aunque le falte DoD.
|
||||||
|
label = ClassifyFleetTermination("waiting", "haciendo", "", "", 5, 600)
|
||||||
|
// label == "RECLAMA"
|
||||||
|
|
||||||
|
switch label {
|
||||||
|
case TerminationReclama:
|
||||||
|
// notificar al humano
|
||||||
|
case TerminationMalLanzado:
|
||||||
|
// matar y relanzar con DoD-contrato
|
||||||
|
case TerminationDiceTerminado:
|
||||||
|
// pasar a verificacion de DoD
|
||||||
|
case TerminationEstancado:
|
||||||
|
// empujar / reiniciar / escalar
|
||||||
|
case TerminationTrabajando:
|
||||||
|
// dejar trabajar
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala en el watcher del meta-orquestador de flota (dev/flows/0012-fleet-orchestrator-dod.md) cuando barras cada agente Claude y necesites decidir mecanicamente, sin gastar LLM, en que cubo cae (reclama input / mal lanzado / dice terminado / estancado / trabajando) a partir de sessions.json + goal.json. Es el paso de triaje barato antes de cualquier accion costosa.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Precedencia estricta de arriba a abajo.** RECLAMA gana siempre, incluso si el agente no tiene `dodContract` o ya esta idle y estancado. Si tu logica espera que MAL_LANZADO domine, esta funcion no hace eso a proposito: un agente que pide input se atiende primero.
|
||||||
|
- **El umbral es inclusivo (`>=`).** `idleSeconds == stallThresholdSeconds` ya cuenta como ESTANCADO. Con `stallThresholdSeconds == 0` cualquier agente idle no terminado es ESTANCADO al instante.
|
||||||
|
- **`status == "busy"` nunca termina ni se estanca.** Aunque `dodStatus == "met"` o `phase == "hecho"`, si el proceso esta busy se clasifica como TRABAJANDO (la condicion de terminacion/estancamiento exige idle).
|
||||||
|
- **Strings exactos, case-sensitive.** Valores fuera de los esperados (ej. "Idle", "WAITING", una phase desconocida) caen a TRABAJANDO salvo que disparen otra rama. Normaliza las entradas antes de llamar si tus fuentes no garantizan el casing.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClassifyFleetTermination(t *testing.T) {
|
||||||
|
const stall = 600
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
phase string
|
||||||
|
dodC string
|
||||||
|
dodS string
|
||||||
|
idle int
|
||||||
|
thresh int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// --- RECLAMA (precedencia 1) ---
|
||||||
|
{"waiting reclama input", "waiting", "haciendo", "criterio", "pending", 10, stall, TerminationReclama},
|
||||||
|
{"phase preguntando reclama", "busy", "preguntando", "criterio", "pending", 10, stall, TerminationReclama},
|
||||||
|
{"phase bloqueado reclama", "idle", "bloqueado", "criterio", "pending", 9999, stall, TerminationReclama},
|
||||||
|
{"waiting manda aunque sin dodContract", "waiting", "haciendo", "", "", 10, stall, TerminationReclama},
|
||||||
|
{"preguntando manda aunque sin dodContract", "idle", "preguntando", "", "", 10, stall, TerminationReclama},
|
||||||
|
{"bloqueado manda aunque idle estancado", "idle", "bloqueado", "criterio", "pending", 10000, stall, TerminationReclama},
|
||||||
|
|
||||||
|
// --- MAL_LANZADO (precedencia 2) ---
|
||||||
|
{"sin dodContract busy", "busy", "haciendo", "", "pending", 10, stall, TerminationMalLanzado},
|
||||||
|
{"sin dodContract idle reciente", "idle", "haciendo", "", "", 5, stall, TerminationMalLanzado},
|
||||||
|
{"sin dodContract idle estancado", "idle", "haciendo", "", "pending", 10000, stall, TerminationMalLanzado},
|
||||||
|
{"sin dodContract phase hecho", "idle", "hecho", "", "met", 10, stall, TerminationMalLanzado},
|
||||||
|
{"sin dodContract dodStatus met", "idle", "testeando", "", "met", 10, stall, TerminationMalLanzado},
|
||||||
|
|
||||||
|
// --- DICE_TERMINADO (precedencia 3) ---
|
||||||
|
{"idle phase hecho", "idle", "hecho", "criterio", "pending", 10, stall, TerminationDiceTerminado},
|
||||||
|
{"idle dodStatus met", "idle", "testeando", "criterio", "met", 10, stall, TerminationDiceTerminado},
|
||||||
|
{"idle hecho y met", "idle", "hecho", "criterio", "met", 10, stall, TerminationDiceTerminado},
|
||||||
|
{"idle met aunque estancado por tiempo", "idle", "puliendo", "criterio", "met", 10000, stall, TerminationDiceTerminado},
|
||||||
|
{"idle hecho aunque estancado por tiempo", "idle", "hecho", "criterio", "pending", 10000, stall, TerminationDiceTerminado},
|
||||||
|
|
||||||
|
// --- ESTANCADO (precedencia 4) ---
|
||||||
|
{"idle no hecho en umbral exacto", "idle", "haciendo", "criterio", "pending", stall, stall, TerminationEstancado},
|
||||||
|
{"idle no hecho por encima del umbral", "idle", "investigando", "criterio", "pending", stall + 1, stall, TerminationEstancado},
|
||||||
|
{"idle iterando estancado", "idle", "iterando", "criterio", "failed", 5000, stall, TerminationEstancado},
|
||||||
|
{"idle dodStatus failed estancado", "idle", "testeando", "criterio", "failed", 700, stall, TerminationEstancado},
|
||||||
|
{"idle en_pausa estancado", "idle", "en_pausa", "criterio", "pending", 601, stall, TerminationEstancado},
|
||||||
|
|
||||||
|
// --- TRABAJANDO (precedencia 5, todo lo demas) ---
|
||||||
|
{"busy trabajando", "busy", "haciendo", "criterio", "pending", 0, stall, TerminationTrabajando},
|
||||||
|
{"busy aunque idleSeconds alto", "busy", "investigando", "criterio", "pending", 99999, stall, TerminationTrabajando},
|
||||||
|
{"idle reciente bajo umbral", "idle", "haciendo", "criterio", "pending", stall - 1, stall, TerminationTrabajando},
|
||||||
|
{"idle reciente cero segundos", "idle", "planificando", "criterio", "pending", 0, stall, TerminationTrabajando},
|
||||||
|
{"idle no hecho justo bajo umbral", "idle", "testeando", "criterio", "failed", 599, stall, TerminationTrabajando},
|
||||||
|
{"busy phase vacia con dodContract", "busy", "", "criterio", "", 10, stall, TerminationTrabajando},
|
||||||
|
|
||||||
|
// --- Bordes y combinaciones ---
|
||||||
|
{"umbral cero idle no hecho => estancado", "idle", "haciendo", "criterio", "pending", 0, 0, TerminationEstancado},
|
||||||
|
{"idle hecho con umbral cero => dice terminado", "idle", "hecho", "criterio", "pending", 0, 0, TerminationDiceTerminado},
|
||||||
|
{"dodStatus met con status busy NO termina", "busy", "haciendo", "criterio", "met", 10, stall, TerminationTrabajando},
|
||||||
|
{"phase hecho con status busy NO termina", "busy", "hecho", "criterio", "pending", 10, stall, TerminationTrabajando},
|
||||||
|
{"idle pendiente_revision bajo umbral trabajando", "idle", "pendiente_revision", "criterio", "pending", 100, stall, TerminationTrabajando},
|
||||||
|
{"idle pendiente_revision sobre umbral estancado", "idle", "pendiente_revision", "criterio", "pending", 1000, stall, TerminationEstancado},
|
||||||
|
{"waiting con dodStatus met sigue reclamando", "waiting", "hecho", "criterio", "met", 10, stall, TerminationReclama},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := ClassifyFleetTermination(c.status, c.phase, c.dodC, c.dodS, c.idle, c.thresh)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("ClassifyFleetTermination(%q,%q,%q,%q,%d,%d) = %q, want %q",
|
||||||
|
c.status, c.phase, c.dodC, c.dodS, c.idle, c.thresh, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
// ClaudeFleet describes a single Claude Code session process on the local
|
||||||
|
// machine, cross-joining the live process state (/proc) with the session and
|
||||||
|
// goal metadata that Claude Code persists under ~/.claude.
|
||||||
|
//
|
||||||
|
// It is the data record consumed by the fleetview TUI. Every field is derived
|
||||||
|
// from a single ~/.claude/sessions/<PID>.json entry plus its optional
|
||||||
|
// ~/.claude/goals/<sessionId>.json sidecar and the process' own /proc entry.
|
||||||
|
type ClaudeFleet struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
KittyPID int `json:"kitty_pid"` // KITTY_PID from the process environ; 0 if not applicable (e.g. remote tmux)
|
||||||
|
SessionID string `json:"session_id"` // Claude Code sessionId (UUID)
|
||||||
|
Rename string `json:"rename"` // display name: short goal if present, else basename(cwd)
|
||||||
|
Target string `json:"target"` // sessionId[:8] + "@" + basename(cwd)
|
||||||
|
Goal string `json:"goal"` // from goals/<sessionId>.json .goal ("" if absent)
|
||||||
|
Phase string `json:"phase"` // from goals/<sessionId>.json .phase ("" if absent)
|
||||||
|
DodContract string `json:"dod_contract"` // from goals .dod_contract: fixed acceptance criterion ("" if absent)
|
||||||
|
DodStatus string `json:"dod_status"` // from goals .dod_status: pending|met|failed ("" if absent)
|
||||||
|
Role string `json:"role"` // from goals .role: orchestrator|executor ("" if absent; defaults to executor in consumers)
|
||||||
|
Emojis string `json:"emojis"` // 3 emojis representing the task (from goals .emojis; "" if absent)
|
||||||
|
Name string `json:"name"` // manual rename of the terminal (from goals .rename; "" if none)
|
||||||
|
Status string `json:"status"` // idle|busy|waiting (from sessions/<pid>.json)
|
||||||
|
Cwd string `json:"cwd"` // working directory of the session
|
||||||
|
TmuxWindow string `json:"tmux_window"` // window_id (@N) of the pane: REAL current position, used for focus/send-keys; migrates when the pane is swapped between windows
|
||||||
|
PaneID string `json:"pane_id"` // pane_id (%N) of the pane: STABLE identity for the pane's whole life, immune to window swaps; "" if not resolvable. Prefer this as the agent's identifier over TmuxWindow
|
||||||
|
Alive bool `json:"alive"` // process alive AND procStart matches (guards against PID recycling)
|
||||||
|
UpdatedAt int64 `json:"updated_at"` // from sessions/<pid>.json .updatedAt (epoch millis)
|
||||||
|
CtxPct int `json:"ctx_pct"` // context window used %, from runtime/<sessionId>.json; -1 if unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionFile mirrors the on-disk shape of ~/.claude/sessions/<PID>.json
|
||||||
|
// written by Claude Code 2.1.x. Only the fields we consume are declared.
|
||||||
|
type sessionFile struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
ProcStart string `json:"procStart"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// goalFile mirrors the on-disk shape of ~/.claude/goals/<sessionId>.json.
|
||||||
|
type goalFile struct {
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Emojis string `json:"emojis"`
|
||||||
|
Rename string `json:"rename"`
|
||||||
|
DodContract string `json:"dod_contract"`
|
||||||
|
DodStatus string `json:"dod_status"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeFile mirrors ~/.claude/runtime/<sessionId>.json written by statusline.sh
|
||||||
|
// with the live context-window usage of that session.
|
||||||
|
type runtimeFile struct {
|
||||||
|
CtxPct int `json:"ctx_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
||||||
|
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
||||||
|
// ListClaudeFleetFrom resolving the home directory, plus it populates each
|
||||||
|
// member's PaneID ("%N") by resolving it against the fleet tmux socket.
|
||||||
|
//
|
||||||
|
// The socket comes from $FLEET_SOCKET, defaulting to "fleet". Resolution is
|
||||||
|
// best-effort: if tmux/the socket is unavailable, every PaneID is left "" and
|
||||||
|
// the fleet is still returned. PaneID is only populated here (the public
|
||||||
|
// registry entry point), not in ListClaudeFleetFrom, so consumers that call the
|
||||||
|
// core directly in a hot loop (and the unit tests) pay no tmux cost.
|
||||||
|
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||||
|
}
|
||||||
|
fleet, err := ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
populatePaneIDs(fleet)
|
||||||
|
return fleet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// populatePaneIDs resolves each alive member's pane_id ("%N") against the fleet
|
||||||
|
// tmux socket ($FLEET_SOCKET, default "fleet") and writes it into PaneID. It
|
||||||
|
// mutates fleet in place. Best-effort: tmux/socket down -> every PaneID stays ""
|
||||||
|
// (ResolvePaneIDs returns an empty map), no crash. Only alive PIDs are queried;
|
||||||
|
// a dead PID has no pane to resolve.
|
||||||
|
func populatePaneIDs(fleet []ClaudeFleet) {
|
||||||
|
socket := os.Getenv("FLEET_SOCKET")
|
||||||
|
if socket == "" {
|
||||||
|
socket = "fleet"
|
||||||
|
}
|
||||||
|
pids := make([]int, 0, len(fleet))
|
||||||
|
for _, f := range fleet {
|
||||||
|
if f.Alive {
|
||||||
|
pids = append(pids, f.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byPID := ResolvePaneIDs(socket, pids)
|
||||||
|
for i := range fleet {
|
||||||
|
fleet[i].PaneID = byPID[fleet[i].PID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
||||||
|
// Claude Code sessions. It reads sessions/*.json, joins each against its
|
||||||
|
// goals/<sessionId>.json sidecar, validates liveness against /proc (guarding
|
||||||
|
// against PID recycling), and derives the display fields.
|
||||||
|
//
|
||||||
|
// Every session that produced a parseable JSON is returned; the Alive flag
|
||||||
|
// reflects whether the underlying process is actually running. The caller is
|
||||||
|
// expected to filter on Alive as needed. Records are ordered by status
|
||||||
|
// (idle, waiting, busy, other) and within a status by UpdatedAt descending.
|
||||||
|
func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) {
|
||||||
|
sessionsDir := filepath.Join(claudeDir, "sessions")
|
||||||
|
goalsDir := filepath.Join(claudeDir, "goals")
|
||||||
|
runtimeDir := filepath.Join(claudeDir, "runtime")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []ClaudeFleet{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fleet := make([]ClaudeFleet, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sess sessionFile
|
||||||
|
if json.Unmarshal(raw, &sess) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sess.PID == 0 || sess.SessionID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f := ClaudeFleet{
|
||||||
|
PID: sess.PID,
|
||||||
|
SessionID: sess.SessionID,
|
||||||
|
Status: sess.Status,
|
||||||
|
Cwd: sess.Cwd,
|
||||||
|
UpdatedAt: sess.UpdatedAt,
|
||||||
|
TmuxWindow: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liveness + anti-PID-recycling: the process must exist AND its
|
||||||
|
// /proc starttime must match the procStart recorded in the JSON.
|
||||||
|
f.Alive = procIsAlive(sess.PID, sess.ProcStart)
|
||||||
|
|
||||||
|
// KITTY_PID from the process environ (0 if unreadable / absent).
|
||||||
|
f.KittyPID = readKittyPID(sess.PID)
|
||||||
|
|
||||||
|
// Join goal/phase/emojis/name + DoD contract/status from
|
||||||
|
// goals/<sessionId>.json (optional).
|
||||||
|
f.Goal, f.Phase, f.Emojis, f.Name, f.DodContract, f.DodStatus, f.Role = readGoal(goalsDir, sess.SessionID)
|
||||||
|
|
||||||
|
// Context usage from runtime/<sessionId>.json (written by statusline).
|
||||||
|
f.CtxPct = readCtxPct(runtimeDir, sess.SessionID)
|
||||||
|
|
||||||
|
// Derived display fields.
|
||||||
|
f.Target = deriveTarget(sess.SessionID, sess.Cwd)
|
||||||
|
f.Rename = deriveRename(f.Goal, sess.Cwd)
|
||||||
|
|
||||||
|
fleet = append(fleet, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
sortFleet(fleet)
|
||||||
|
return fleet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// procIsAlive reports whether pid is running and its kernel starttime matches
|
||||||
|
// procStartJSON. An empty procStartJSON only requires the process to exist.
|
||||||
|
func procIsAlive(pid int, procStartJSON string) bool {
|
||||||
|
real, ok := procStartTime(pid)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if procStartJSON == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real)
|
||||||
|
}
|
||||||
|
|
||||||
|
// procStartTime returns field 22 (starttime, in clock ticks) of
|
||||||
|
// /proc/<pid>/stat. The comm field (field 2) is wrapped in parentheses and may
|
||||||
|
// itself contain spaces and ')' characters, so we parse the portion after the
|
||||||
|
// LAST ')' and index from there: starttime is index 20 of that remainder
|
||||||
|
// (fields 3..n), which is field 22 globally.
|
||||||
|
func procStartTime(pid int) (string, bool) {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s := string(data)
|
||||||
|
close := strings.LastIndex(s, ")")
|
||||||
|
if close < 0 || close+1 >= len(s) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := strings.Fields(s[close+1:])
|
||||||
|
// rest[0] = state (field 3); starttime (field 22) is index 19 here:
|
||||||
|
// field N maps to rest[N-3]. 22 - 3 = 19.
|
||||||
|
const startTimeIdx = 19
|
||||||
|
if len(rest) <= startTimeIdx {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest[startTimeIdx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKittyPID parses /proc/<pid>/environ (NUL-separated KEY=VALUE pairs) and
|
||||||
|
// returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key
|
||||||
|
// is absent, or the value is not an integer.
|
||||||
|
func readKittyPID(pid int) int {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, kv := range strings.Split(string(data), "\x00") {
|
||||||
|
if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok {
|
||||||
|
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if convErr != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGoal reads goals/<sessionID>.json and returns its goal, phase, emojis,
|
||||||
|
// manual rename, DoD contract, DoD status and role. If the file is absent or
|
||||||
|
// unparseable, all are "".
|
||||||
|
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename, dodContract, dodStatus, role string) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json"))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", "", "", ""
|
||||||
|
}
|
||||||
|
var g goalFile
|
||||||
|
if json.Unmarshal(raw, &g) != nil {
|
||||||
|
return "", "", "", "", "", "", ""
|
||||||
|
}
|
||||||
|
return g.Goal, g.Phase, g.Emojis, g.Rename, g.DodContract, g.DodStatus, g.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCtxPct reads runtime/<sessionID>.json and returns the context-window used
|
||||||
|
// percentage. Returns -1 if the file is absent or unparseable (unknown).
|
||||||
|
func readCtxPct(runtimeDir, sessionID string) int {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json"))
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
var r runtimeFile
|
||||||
|
if json.Unmarshal(raw, &r) != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r.CtxPct
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is
|
||||||
|
// shorter than 8 runes it is used whole.
|
||||||
|
func deriveTarget(sessionID, cwd string) string {
|
||||||
|
short := sessionID
|
||||||
|
if r := []rune(sessionID); len(r) >= 8 {
|
||||||
|
short = string(r[:8])
|
||||||
|
}
|
||||||
|
return short + "@" + filepath.Base(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveRename returns goal truncated to 48 runes if non-empty, else
|
||||||
|
// basename(cwd).
|
||||||
|
func deriveRename(goal, cwd string) string {
|
||||||
|
if goal != "" {
|
||||||
|
return truncateRunes(goal, 48)
|
||||||
|
}
|
||||||
|
return filepath.Base(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes returns s capped at max runes (no ellipsis).
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortFleet orders the fleet by status rank then by UpdatedAt descending.
|
||||||
|
func sortFleet(fleet []ClaudeFleet) {
|
||||||
|
rank := func(status string) int {
|
||||||
|
switch status {
|
||||||
|
case "idle":
|
||||||
|
return 0
|
||||||
|
case "waiting":
|
||||||
|
return 1
|
||||||
|
case "busy":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(fleet, func(i, j int) bool {
|
||||||
|
ri, rj := rank(fleet[i].Status), rank(fleet[j].Status)
|
||||||
|
if ri != rj {
|
||||||
|
return ri < rj
|
||||||
|
}
|
||||||
|
return fleet[i].UpdatedAt > fleet[j].UpdatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: list_claude_fleet
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) | func ListClaudeFleet() ([]ClaudeFleet, error)"
|
||||||
|
description: "Lista la flota de procesos Claude Code de la maquina local (Linux). Escanea ~/.claude/sessions/*.json, cruza cada PID vivo contra /proc para validar liveness (anti-PID-reciclado via procStart == campo 22 de /proc/<pid>/stat), une el goal/phase de ~/.claude/goals/<sessionId>.json, extrae KITTY_PID del environ y deriva los campos de display (Target, Rename). Devuelve todas las sesiones ordenadas por status (idle, waiting, busy, otro) y por updatedAt desc; el caller filtra por Alive. Pieza de datos de la app TUI fleetview."
|
||||||
|
tags: [claude-fleet, infra, claude, session, proc, fleet, tui, orchestration]
|
||||||
|
uses_functions: [resolve_pane_ids_go_infra]
|
||||||
|
uses_types: [claude_fleet_go_infra]
|
||||||
|
returns: [claude_fleet_go_infra]
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "claudeDir"
|
||||||
|
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListClaudeFleetFrom lo recibe explicito (testeable con t.TempDir()); ListClaudeFleet lo resuelve via os.UserHomeDir() + .claude."
|
||||||
|
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), PaneID, Alive y UpdatedAt. ListClaudeFleet() puebla PaneID (\"%N\", identificador estable del pane) cruzando cada PID vivo con los panes del socket $FLEET_SOCKET (default \"fleet\") via resolve_pane_ids_go_infra; ListClaudeFleetFrom() deja PaneID \"\" (no hace tmux). Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||||
|
tested: true
|
||||||
|
tests: ["TestListClaudeFleetFrom", "TestListClaudeFleetFromMissingDir"]
|
||||||
|
test_file_path: "functions/infra/list_claude_fleet_test.go"
|
||||||
|
file_path: "functions/infra/list_claude_fleet.go"
|
||||||
|
notes: "Misma fuente de verdad que reboot_all_claudes_bash_infra (~/.claude/sessions/<PID>.json de Claude Code 2.1.x: pid, sessionId, cwd, procStart, status, updatedAt). Solo LEE y valida — no relanza ni mata nada. La validacion anti-PID-reciclado replica la del bash (procStart del JSON vs campo 22 de /proc/<pid>/stat) pero parseando de forma robusta el comm (campo 2 entre parentesis, que puede contener espacios y ')'): se toma lo que hay tras el ULTIMO ')' y starttime es el indice 19 de ese resto. TmuxWindow queda \"\" (se rellena en una fase posterior). Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fleet, err := infra.ListClaudeFleet() // escanea ~/.claude
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for _, c := range fleet {
|
||||||
|
if !c.Alive {
|
||||||
|
continue // el caller filtra las sesiones muertas
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %-20s pid=%d kitty=%d %s\n",
|
||||||
|
c.Status, c.Rename, c.PID, c.KittyPID, c.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||||
|
fleet, _ := infra.ListClaudeFleetFrom("/home/enmanuel/.claude")
|
||||||
|
fmt.Println(len(fleet), "sesiones conocidas")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites enumerar las sesiones de Claude Code vivas en la maquina local para mostrarlas, monitorizarlas o actuar sobre ellas (TUI fleetview, dashboards, automatizaciones). Da el join PID -> sessionId -> cwd -> goal/phase ya resuelto y validado contra /proc, en lugar de reimplementarlo a mano cada vez. Usa `ListClaudeFleetFrom` en tests (inyectando un directorio con fixtures) y `ListClaudeFleet` en runtime real.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca mata ni relanza procesos.
|
||||||
|
- **Anti-PID-reciclado.** `Alive` solo es true si el proceso existe Y su starttime (campo 22 de `/proc/<pid>/stat`) coincide con el `procStart` del JSON. Un JSON huerfano cuyo PID fue reasignado a otro proceso se marca `Alive=false` aunque ese PID este vivo. Si el JSON no trae `procStart`, basta con que el proceso exista.
|
||||||
|
- **Parseo del `comm` en /proc/<pid>/stat.** El campo 2 (comm) va entre parentesis y puede contener espacios y el caracter ')'. La funcion parsea tomando lo que hay tras el ULTIMO ')'; un split ingenuo por espacios daria un starttime equivocado.
|
||||||
|
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` y `/proc/<pid>/environ` (Linux). En macOS/BSD no funciona tal cual.
|
||||||
|
- **environ ilegible -> KittyPID=0.** Si `/proc/<pid>/environ` no es legible (permisos, proceso de otro usuario, o el proceso ya murio entre el ReadDir y el ReadFile) `KittyPID` cae a 0 sin error. Tambien es 0 legitimamente cuando claude no corre bajo kitty (ej. tmux remoto).
|
||||||
|
- **Devuelve TODAS las sesiones con JSON parseable**, vivas o muertas. El caller decide filtrar por `Alive`. Archivos no-`.json` y JSON corrupto se ignoran silenciosamente.
|
||||||
|
- **TmuxWindow siempre "".** Esta funcion no resuelve el window_id (@N); lo rellena el consumidor (fleetview) cuando lo necesita para el focus.
|
||||||
|
- **PaneID lo puebla solo `ListClaudeFleet()`, no `ListClaudeFleetFrom()`.** La variante con directorio (usada en tests y en bucles de render calientes) no llama a tmux: deja `PaneID` "". La publica resuelve el pane_id ("%N") contra `$FLEET_SOCKET` (default "fleet") via `resolve_pane_ids_go_infra`. Si el socket no existe o tmux no responde, todos los `PaneID` quedan "" sin error. El pane_id es estable de por vida del pane (inmune a los swaps de window que mueve el focus), a diferencia de `TmuxWindow`.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//go:build !windows && linux
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readOwnProcStart reads field 22 (starttime) of /proc/<pid>/stat for the
|
||||||
|
// current test process, so a fixture can be marked Alive deterministically.
|
||||||
|
func readOwnProcStart(t *testing.T, pid int) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read own /proc/%d/stat: %v", pid, err)
|
||||||
|
}
|
||||||
|
s := string(data)
|
||||||
|
close := strings.LastIndex(s, ")")
|
||||||
|
if close < 0 {
|
||||||
|
t.Fatalf("malformed stat line: %q", s)
|
||||||
|
}
|
||||||
|
rest := strings.Fields(s[close+1:])
|
||||||
|
const startTimeIdx = 19 // field 22 == rest[22-3]
|
||||||
|
if len(rest) <= startTimeIdx {
|
||||||
|
t.Fatalf("stat has too few fields after comm: %d", len(rest))
|
||||||
|
}
|
||||||
|
return rest[startTimeIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListClaudeFleetFrom(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sessions := filepath.Join(tmp, "sessions")
|
||||||
|
goals := filepath.Join(tmp, "goals")
|
||||||
|
|
||||||
|
livePID := os.Getpid()
|
||||||
|
liveProcStart := readOwnProcStart(t, livePID)
|
||||||
|
|
||||||
|
const deadPID = 2147480000 // implausibly high; no such process
|
||||||
|
|
||||||
|
// Session A: alive (own PID), with a goal -> rename = truncated goal,
|
||||||
|
// status idle. cwd basename = fn_registry.
|
||||||
|
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", livePID)),
|
||||||
|
fmt.Sprintf(`{"pid":%d,"sessionId":"aaaaaaaa-1111-2222-3333-444444444444","cwd":"/home/enmanuel/fn_registry","procStart":%q,"status":"idle","updatedAt":1000}`,
|
||||||
|
livePID, liveProcStart))
|
||||||
|
writeFile(t, filepath.Join(goals, "aaaaaaaa-1111-2222-3333-444444444444.json"),
|
||||||
|
`{"goal":"Recomendar stack tecnologico para la nueva app de inventario y validar dependencias","phase":"investigando","history":["haciendo","investigando"],"dod_contract":"build verde + tests pasan + tipo expuesto","dod_status":"pending"}`)
|
||||||
|
|
||||||
|
// Session B: alive (own PID again — same process, valid procStart), no
|
||||||
|
// goal sidecar -> rename = basename(cwd) = projectx, status busy.
|
||||||
|
writeFile(t, filepath.Join(sessions, "b.json"),
|
||||||
|
fmt.Sprintf(`{"pid":%d,"sessionId":"bbbbbbbb-5555","cwd":"/var/tmp/projectx","procStart":%q,"status":"busy","updatedAt":2000}`,
|
||||||
|
livePID, liveProcStart))
|
||||||
|
|
||||||
|
// Session C: dead PID -> Alive=false, status waiting, has goal.
|
||||||
|
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", deadPID)),
|
||||||
|
fmt.Sprintf(`{"pid":%d,"sessionId":"cccccccc-9999-0000","cwd":"/srv/work/zeta","procStart":"99999999","status":"waiting","updatedAt":3000}`,
|
||||||
|
deadPID))
|
||||||
|
writeFile(t, filepath.Join(goals, "cccccccc-9999-0000.json"),
|
||||||
|
`{"goal":"limpiar logs","phase":"haciendo"}`)
|
||||||
|
|
||||||
|
// Noise files that must be ignored.
|
||||||
|
writeFile(t, filepath.Join(sessions, "notjson.txt"), "ignore me")
|
||||||
|
writeFile(t, filepath.Join(sessions, "broken.json"), "{ this is not json")
|
||||||
|
|
||||||
|
fleet, err := ListClaudeFleetFrom(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListClaudeFleetFrom: %v", err)
|
||||||
|
}
|
||||||
|
if len(fleet) != 3 {
|
||||||
|
t.Fatalf("expected 3 sessions, got %d: %+v", len(fleet), fleet)
|
||||||
|
}
|
||||||
|
|
||||||
|
by := map[string]ClaudeFleet{}
|
||||||
|
for _, f := range fleet {
|
||||||
|
by[f.SessionID] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session A assertions ---
|
||||||
|
a := by["aaaaaaaa-1111-2222-3333-444444444444"]
|
||||||
|
if !a.Alive {
|
||||||
|
t.Errorf("session A: expected Alive=true (own PID + matching procStart)")
|
||||||
|
}
|
||||||
|
if a.Goal != "Recomendar stack tecnologico para la nueva app de inventario y validar dependencias" {
|
||||||
|
t.Errorf("session A: goal join failed, got %q", a.Goal)
|
||||||
|
}
|
||||||
|
if a.Phase != "investigando" {
|
||||||
|
t.Errorf("session A: phase join failed, got %q", a.Phase)
|
||||||
|
}
|
||||||
|
if a.DodContract != "build verde + tests pasan + tipo expuesto" {
|
||||||
|
t.Errorf("session A: dod_contract join failed, got %q", a.DodContract)
|
||||||
|
}
|
||||||
|
if a.DodStatus != "pending" {
|
||||||
|
t.Errorf("session A: dod_status join failed, got %q", a.DodStatus)
|
||||||
|
}
|
||||||
|
// Rename = goal truncated to 48 runes.
|
||||||
|
wantRename := string([]rune(a.Goal)[:48])
|
||||||
|
if a.Rename != wantRename {
|
||||||
|
t.Errorf("session A: rename = %q, want truncated goal %q", a.Rename, wantRename)
|
||||||
|
}
|
||||||
|
if len([]rune(a.Rename)) != 48 {
|
||||||
|
t.Errorf("session A: rename should be 48 runes, got %d", len([]rune(a.Rename)))
|
||||||
|
}
|
||||||
|
if a.Target != "aaaaaaaa@fn_registry" {
|
||||||
|
t.Errorf("session A: target = %q, want %q", a.Target, "aaaaaaaa@fn_registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session B assertions: no goal -> fallback rename = basename(cwd) ---
|
||||||
|
b := by["bbbbbbbb-5555"]
|
||||||
|
if b.Goal != "" || b.Phase != "" {
|
||||||
|
t.Errorf("session B: expected empty goal/phase, got goal=%q phase=%q", b.Goal, b.Phase)
|
||||||
|
}
|
||||||
|
if b.DodContract != "" || b.DodStatus != "" {
|
||||||
|
t.Errorf("session B: expected empty dod fields (no sidecar), got contract=%q status=%q", b.DodContract, b.DodStatus)
|
||||||
|
}
|
||||||
|
if b.Rename != "projectx" {
|
||||||
|
t.Errorf("session B: rename = %q, want basename(cwd) %q", b.Rename, "projectx")
|
||||||
|
}
|
||||||
|
if b.Target != "bbbbbbbb@projectx" {
|
||||||
|
t.Errorf("session B: target = %q, want %q", b.Target, "bbbbbbbb@projectx")
|
||||||
|
}
|
||||||
|
if !b.Alive {
|
||||||
|
t.Errorf("session B: expected Alive=true (own PID + matching procStart)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session C assertions: dead PID ---
|
||||||
|
c := by["cccccccc-9999-0000"]
|
||||||
|
if c.Alive {
|
||||||
|
t.Errorf("session C: expected Alive=false for dead PID %d", deadPID)
|
||||||
|
}
|
||||||
|
if c.Target != "cccccccc@zeta" {
|
||||||
|
t.Errorf("session C: target = %q, want %q", c.Target, "cccccccc@zeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ordering: status rank idle(0) < waiting(1) < busy(2) ---
|
||||||
|
// A=idle, C=waiting, B=busy => expected order A, C, B.
|
||||||
|
wantOrder := []string{
|
||||||
|
"aaaaaaaa-1111-2222-3333-444444444444",
|
||||||
|
"cccccccc-9999-0000",
|
||||||
|
"bbbbbbbb-5555",
|
||||||
|
}
|
||||||
|
for i, want := range wantOrder {
|
||||||
|
if fleet[i].SessionID != want {
|
||||||
|
t.Errorf("order[%d] = %q (status %q), want %q", i, fleet[i].SessionID, fleet[i].Status, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListClaudeFleetFromMissingDir(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
fleet, err := ListClaudeFleetFrom(filepath.Join(tmp, "nope"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error for missing sessions dir, got %v", err)
|
||||||
|
}
|
||||||
|
if len(fleet) != 0 {
|
||||||
|
t.Fatalf("expected empty fleet, got %d", len(fleet))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifyDesktop sends a desktop notification on Linux via the `notify-send`
|
||||||
|
// binary (libnotify). It is impure: it shells out to an external program.
|
||||||
|
//
|
||||||
|
// Degradation is intentional and silent: if `notify-send` is not on the PATH,
|
||||||
|
// the function returns nil without error. A machine without a notification
|
||||||
|
// server is not a failure condition for the caller — the notification is simply
|
||||||
|
// skipped. Only a real execution failure of an existing `notify-send` is
|
||||||
|
// returned (wrapped with context).
|
||||||
|
//
|
||||||
|
// When `notify-send` is present it runs:
|
||||||
|
//
|
||||||
|
// notify-send --app-name=fleetview --urgency=normal -- <title> <body>
|
||||||
|
//
|
||||||
|
// The `--` separator guarantees that a title or body starting with "-" is
|
||||||
|
// treated as positional text, not as a flag. An empty title falls back to a
|
||||||
|
// sensible default; an empty body is accepted by notify-send as-is.
|
||||||
|
func NotifyDesktop(title, body string) error {
|
||||||
|
bin, err := exec.LookPath("notify-send")
|
||||||
|
if err != nil {
|
||||||
|
// No notification server / binary on this machine: skip silently.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
title = "Notificación"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, "--app-name=fleetview", "--urgency=normal", "--", title, body)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("notify-send failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: notify_desktop
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func NotifyDesktop(title, body string) error"
|
||||||
|
description: "Lanza una notificacion de escritorio en Linux via el binario notify-send (libnotify). Degradacion limpia: si notify-send no esta en el PATH devuelve nil sin error (no es fallo que la maquina no tenga servidor de notificaciones). Cuando existe ejecuta: notify-send --app-name=fleetview --urgency=normal -- <title> <body>, usando -- para que un texto que empiece por - no se interprete como flag. title vacio cae a un default; body puede ir vacio."
|
||||||
|
tags: [orchestration, notify, infra, desktop, libnotify]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["fmt", "os/exec"]
|
||||||
|
params:
|
||||||
|
- name: title
|
||||||
|
desc: "titulo de la notificacion; si es cadena vacia usa el default 'Notificación'"
|
||||||
|
- name: body
|
||||||
|
desc: "cuerpo de la notificacion; puede ir vacio (notify-send lo acepta)"
|
||||||
|
output: "error: nil si la notificacion se mostro o si notify-send no esta instalado (degradacion silenciosa); error envuelto con contexto solo si la ejecucion real de notify-send falla"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/infra/notify_desktop.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Avisar al usuario en el escritorio de que un agente termino.
|
||||||
|
err := infra.NotifyDesktop("✅ Agente terminó", "EDA dataset X — revísalo")
|
||||||
|
if err != nil {
|
||||||
|
// notify-send existe pero fallo al ejecutarse
|
||||||
|
log.Printf("no se pudo notificar: %v", err)
|
||||||
|
}
|
||||||
|
// En una maquina sin notify-send, err es nil y la notificacion se omite.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala para avisar al usuario en el escritorio cuando un proceso largo o un agente termina su trabajo (fin de un EDA, build, deploy, o tarea desatendida del orquestador). Es el toque final tras una operacion que el humano no esta mirando en directo: dispara la notificacion y sigue, sin preocuparte de si la maquina destino tiene servidor de notificaciones.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo Linux con servidor de notificaciones (libnotify).** Depende del binario `notify-send`; en otros SO no aplica.
|
||||||
|
- **Headless / sin DBUS no muestra nada pero NO falla.** Si `notify-send` no esta en el PATH, devuelve `nil` (degradacion silenciosa): el caller no se rompe por carecer de notificaciones.
|
||||||
|
- **Requiere sesion grafica activa.** Aunque `notify-send` exista, sin una sesion grafica con DBUS la notificacion puede no aparecer; en ese caso `Run()` puede devolver error real, que se devuelve envuelto.
|
||||||
|
- **`--` antes de los argumentos posicionales** evita que un `title`/`body` que empiece por `-` se interprete como flag. No lo quites.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolvePaneIDs crosses the PID of each Claude process with the panes of the
|
||||||
|
// given isolated tmux socket (tmux -L <socket> list-panes -a) and returns a
|
||||||
|
// claudePID -> pane_id ("%N") map.
|
||||||
|
//
|
||||||
|
// The pane_id is STABLE for the pane's whole life: it identifies a Claude even
|
||||||
|
// when its window_id (@N) migrates with the focus swap (break-pane + join-pane
|
||||||
|
// move the pane between windows). That is why it is the correct stable handle
|
||||||
|
// for an agent, as opposed to the window_id which changes by design.
|
||||||
|
//
|
||||||
|
// For each claudePID it climbs the process tree (PPID in /proc/<pid>/stat) until
|
||||||
|
// it finds a PID that is a pane_pid of the socket; that pane is the one hosting
|
||||||
|
// the Claude. Normally the pane_pid IS the claudePID because the pane runs
|
||||||
|
// `exec claude`, but a shell that launched claude as a child is covered by the
|
||||||
|
// ascent.
|
||||||
|
//
|
||||||
|
// Best-effort and crash-free: an empty socket, no PIDs, or a tmux failure
|
||||||
|
// (socket down, tmux absent) all yield an empty map; a Claude with no resolvable
|
||||||
|
// pane is simply omitted from the result (callers degrade it to ""). It reads
|
||||||
|
// /proc, hence the //go:build !windows tag.
|
||||||
|
func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string {
|
||||||
|
if socket == "" || len(claudePIDs) == 0 {
|
||||||
|
return map[int]string{}
|
||||||
|
}
|
||||||
|
out, _, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{pane_id}")
|
||||||
|
if err != nil {
|
||||||
|
return map[int]string{}
|
||||||
|
}
|
||||||
|
return resolvePaneIDsFrom(out, procPPID, claudePIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePaneIDsFrom is the testable core of ResolvePaneIDs: it parses the
|
||||||
|
// `<pane_pid> <pane_id>` lines produced by tmux and, for each claudePID, climbs
|
||||||
|
// the process tree via ppidOf until it lands on a pane_pid, returning that
|
||||||
|
// pane's pane_id. ppidOf is injected so the ascent can be tested without real
|
||||||
|
// processes. Lines that do not parse are skipped; a PID with no pane ancestor is
|
||||||
|
// omitted.
|
||||||
|
func resolvePaneIDsFrom(tmuxOut string, ppidOf func(int) int, claudePIDs []int) map[int]string {
|
||||||
|
panePaneID := map[int]string{}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(tmuxOut), "\n") {
|
||||||
|
f := strings.Fields(strings.TrimSpace(line))
|
||||||
|
if len(f) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pp, e := strconv.Atoi(f[0])
|
||||||
|
if e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
panePaneID[pp] = f[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make(map[int]string, len(claudePIDs))
|
||||||
|
for _, pid := range claudePIDs {
|
||||||
|
if paneID, ok := paneAncestor(pid, panePaneID, ppidOf); ok {
|
||||||
|
res[pid] = paneID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// paneAncestor climbs the process tree from pid until it finds a PID that is a
|
||||||
|
// pane_pid (a key of panePaneID), returning its pane_id. The ascent is bounded
|
||||||
|
// (64 hops, stop at pid<=1) so a malformed /proc or a cycle cannot hang it.
|
||||||
|
// Returns ("", false) when no ancestor is a pane. ppidOf is injected for tests.
|
||||||
|
func paneAncestor(pid int, panePaneID map[int]string, ppidOf func(int) int) (string, bool) {
|
||||||
|
for i := 0; pid > 1 && i < 64; i++ {
|
||||||
|
if paneID, ok := panePaneID[pid]; ok {
|
||||||
|
return paneID, true
|
||||||
|
}
|
||||||
|
pid = ppidOf(pid)
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: resolve_pane_ids
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string"
|
||||||
|
description: "Resuelve el pane_id (\"%N\") de tmux de cada proceso dado en un socket aislado (tmux -L <socket>), devolviendo un mapa claudePID -> pane_id. Lista los panes con `list-panes -a -F '#{pane_pid} #{pane_id}'` y, para cada PID, sube por el arbol de procesos (PPID en /proc/<pid>/stat) hasta dar con un pane_pid del socket; ese pane es el que aloja al proceso (normalmente pane_pid == PID porque el pane corre `exec claude`, pero un shell que lanzo el proceso como hijo se cubre con el ascenso). El pane_id es estable de por vida del pane, inmune a los swaps de window que mueve el focus de la flota, por eso es el identificador correcto de un agente frente al window_id (@N). Best-effort: socket vacio, sin PIDs o fallo de tmux -> mapa vacio; un PID sin pane resoluble se omite. Capa de control tmux de fleetview / orquestador."
|
||||||
|
tags: [claude-fleet, infra, tmux, pane, fleet, orchestration]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "socket"
|
||||||
|
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En la flota suele ser 'fleet'/'fleet3' ($FLEET_SOCKET). Escanea TODOS los panes del servidor de ese socket (list-panes -a). \"\" -> mapa vacio."
|
||||||
|
- name: "claudePIDs"
|
||||||
|
desc: "PIDs de los procesos (normalmente claude) cuyo pane_id se quiere resolver. Vacio/nil -> mapa vacio sin llamar a tmux."
|
||||||
|
output: "map[int]string con clave = PID de entrada y valor = pane_id ('%N') del pane que lo aloja. Un PID sin pane resoluble en su ascendencia se omite (el caller lo degrada a \"\"). Mapa vacio (sin panic, sin error) si socket viene vacio, claudePIDs viene vacio, o `tmux list-panes -a` falla (socket caido, tmux ausente)."
|
||||||
|
tested: true
|
||||||
|
tests: ["TestResolvePaneIDsFrom", "TestResolvePaneIDsFromUnresolvable", "TestResolvePaneIDsFromMalformedLines", "TestResolvePaneIDsEmptyInputs", "TestPaneAncestorBounded"]
|
||||||
|
test_file_path: "functions/infra/resolve_pane_ids_test.go"
|
||||||
|
file_path: "functions/infra/resolve_pane_ids.go"
|
||||||
|
notes: "Build tag //go:build !windows (depende de /proc y de tmux, no portable a Windows). Comparte runTmux (tmux_new_claude_window) y procPPID (tmux_map_claude_panes) con el resto de la capa tmux del paquete infra. El nucleo resolvePaneIDsFrom(tmuxOut, ppidOf, pids) es testeable inyectando la salida de tmux y el resolvedor de PPID, sin procesos reales. El ascenso por el arbol esta acotado (64 saltos, corte en pid<=1) para no colgarse ante un /proc malformado o un ciclo. Hermana de tmux_map_claude_panes_go_infra: aquella mapea PID -> window_id (@N, posicion operativa que migra con el swap); esta mapea PID -> pane_id (%N, identidad estable). Pensada para que list_claude_fleet_go_infra (y consumidores como el orquestador) identifiquen a cada agente por un handle que no baila al hacer focus."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Resolver el pane_id estable de un par de procesos claude en el socket fleet.
|
||||||
|
byPID := infra.ResolvePaneIDs("fleet", []int{3637133, 3640001})
|
||||||
|
for pid, paneID := range byPID {
|
||||||
|
fmt.Printf("pid=%d -> pane=%s\n", pid, paneID) // ej. pid=3637133 -> pane=%8
|
||||||
|
}
|
||||||
|
// Un PID sin pane resoluble simplemente no aparece en el mapa.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Patron tipico: cruzar la flota con su pane_id estable.
|
||||||
|
fleet, _ := infra.ListClaudeFleet()
|
||||||
|
pids := make([]int, 0, len(fleet))
|
||||||
|
for _, c := range fleet {
|
||||||
|
pids = append(pids, c.PID)
|
||||||
|
}
|
||||||
|
panes := infra.ResolvePaneIDs("fleet", pids) // map[pid]"%N"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites un identificador ESTABLE de un agente de la flota a partir de su PID: el pane_id ("%N") de tmux no cambia durante toda la vida del pane, aunque el pane migre de window al hacer focus (break-pane + join-pane). Usala en vez de referirte al window_id (`@N`, `TmuxWindow`), que baila cada vez que el agente entra/sale de la console. La consume `list_claude_fleet_go_infra` para poblar `ClaudeFleet.PaneID`, y el orquestador para referirse a un ejecutor por un handle que no se confunde de agente.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura: ejecuta `tmux` y lee `/proc`.** No es determinista entre llamadas (la flota cambia). Solo lectura — no mueve ni mata panes.
|
||||||
|
- **Best-effort, nunca crashea.** Socket vacio, lista de PIDs vacia, o un `tmux list-panes -a` que falla (socket caido, tmux no instalado) devuelven un mapa vacio. Un agente sin pane resoluble (proceso huerfano, pane cerrado) se omite del mapa; el caller lo degrada a "".
|
||||||
|
- **Sube por el arbol de procesos.** Si el pane corre un shell que lanzo claude como hijo (en vez de `exec claude`), el pane_pid no es el claude PID; el ascenso por PPID lo cubre. El ascenso esta acotado a 64 saltos (corte en pid<=1) para no colgarse ante un ciclo o un /proc raro.
|
||||||
|
- **Parseo del `comm` en /proc.** El PPID se saca de `/proc/<pid>/stat` tomando lo que hay tras el ULTIMO ')' (el comm va entre parentesis y puede contener espacios y ')'). Reutiliza el `procPPID` robusto del paquete.
|
||||||
|
- **/proc + tmux no portables.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) y del binario `tmux`.
|
||||||
|
- **Opera SIEMPRE sobre el socket aislado** (`tmux -L <socket>`), escaneando todos sus panes con `list-panes -a`. No mira el servidor tmux por defecto del usuario.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build !windows && linux
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePPID builds a ppidOf closure from a child->parent map. Unknown PIDs map to
|
||||||
|
// 1 (init), which terminates the ascent without matching any pane.
|
||||||
|
func fakePPID(tree map[int]int) func(int) int {
|
||||||
|
return func(pid int) int {
|
||||||
|
if p, ok := tree[pid]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePaneIDsFrom(t *testing.T) {
|
||||||
|
// Two panes. Pane %3 runs claude directly (pane_pid == claude PID 100).
|
||||||
|
// Pane %7 runs a shell (pane_pid 200) that launched claude as a child (300).
|
||||||
|
tmuxOut := "100 %3\n200 %7\n"
|
||||||
|
tree := map[int]int{
|
||||||
|
300: 200, // claude (300) -> shell (200) which is the pane_pid of %7
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolvePaneIDsFrom(tmuxOut, fakePPID(tree), []int{100, 300})
|
||||||
|
want := map[int]string{
|
||||||
|
100: "%3", // direct: pane_pid IS the claude PID
|
||||||
|
300: "%7", // ascent: claude's parent is the pane_pid
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("resolvePaneIDsFrom = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePaneIDsFromUnresolvable(t *testing.T) {
|
||||||
|
// PID 999 has no pane in its ancestry -> omitted (caller degrades to "").
|
||||||
|
tmuxOut := "100 %3\n"
|
||||||
|
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{999})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("expected empty map for unresolvable PID, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePaneIDsFromMalformedLines(t *testing.T) {
|
||||||
|
// Garbage / short / non-numeric lines are skipped without crashing; the one
|
||||||
|
// valid line still resolves.
|
||||||
|
tmuxOut := "\n \nnotapid %9\n42\n100 %3\n"
|
||||||
|
got := resolvePaneIDsFrom(tmuxOut, fakePPID(map[int]int{}), []int{100})
|
||||||
|
want := map[int]string{100: "%3"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("resolvePaneIDsFrom (malformed) = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePaneIDsEmptyInputs(t *testing.T) {
|
||||||
|
// Empty socket or no PIDs -> empty map, no tmux call attempted.
|
||||||
|
if got := ResolvePaneIDs("", []int{1, 2}); len(got) != 0 {
|
||||||
|
t.Errorf("empty socket: expected empty map, got %v", got)
|
||||||
|
}
|
||||||
|
if got := ResolvePaneIDs("fleet", nil); len(got) != 0 {
|
||||||
|
t.Errorf("nil pids: expected empty map, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaneAncestorBounded(t *testing.T) {
|
||||||
|
// A cycle in the process tree must not hang: the 64-hop bound cuts it.
|
||||||
|
cycle := func(pid int) int {
|
||||||
|
if pid == 500 {
|
||||||
|
return 501
|
||||||
|
}
|
||||||
|
return 500 // 501 -> 500 -> 501 ... never reaches a pane
|
||||||
|
}
|
||||||
|
if id, ok := paneAncestor(500, map[int]string{100: "%3"}, cycle); ok {
|
||||||
|
t.Fatalf("expected no resolution for cyclic tree, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResumableClaude describes a CLOSED Claude Code session that still has a saved
|
||||||
|
// goal and can therefore be reopened with `claude --resume <SessionID>`. The
|
||||||
|
// fleetview TUI consumes these for its "resume" picker.
|
||||||
|
type ResumableClaude struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Goal string `json:"goal"` // from goals/<id>.json .goal ("" if absent)
|
||||||
|
Emojis string `json:"emojis"` // from goals/<id>.json .emojis ("" if absent)
|
||||||
|
Name string `json:"name"` // from goals/<id>.json .rename ("" if absent)
|
||||||
|
LastActive int64 `json:"last_active"` // mtime of the goal.json file, epoch seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxResumable caps the number of resumable sessions returned, keeping only the
|
||||||
|
// most recently touched ones.
|
||||||
|
const maxResumable = 40
|
||||||
|
|
||||||
|
// ListResumableClaudes scans the current user's ~/.claude directory and returns
|
||||||
|
// the closed sessions that can be reopened with `claude --resume`. It is a thin
|
||||||
|
// wrapper over ListResumableClaudesFrom resolving the home directory.
|
||||||
|
func ListResumableClaudes() ([]ResumableClaude, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||||
|
}
|
||||||
|
return ListResumableClaudesFrom(filepath.Join(home, ".claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListResumableClaudesFrom scans claudeDir (e.g. ~/.claude) and returns the
|
||||||
|
// sessions that have a goal (goals/<id>.json) whose process is NOT alive — i.e.
|
||||||
|
// candidates to reopen with `claude --resume <SessionID>`.
|
||||||
|
//
|
||||||
|
// A session is considered live (and thus excluded) when sessions/<PID>.json
|
||||||
|
// reports a PID whose /proc starttime matches the recorded procStart, using the
|
||||||
|
// exact same liveness criterion as ListClaudeFleetFrom (procIsAlive). Goals
|
||||||
|
// without a non-empty goal string are skipped. Results are ordered by
|
||||||
|
// LastActive descending and capped at maxResumable.
|
||||||
|
func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) {
|
||||||
|
sessionsDir := filepath.Join(claudeDir, "sessions")
|
||||||
|
goalsDir := filepath.Join(claudeDir, "goals")
|
||||||
|
|
||||||
|
// 1. Build the set of LIVE sessionIds from sessions/*.json.
|
||||||
|
live := liveSessionIDs(sessionsDir)
|
||||||
|
|
||||||
|
// 2. Scan goals/*.json.
|
||||||
|
entries, err := os.ReadDir(goalsDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []ResumableClaude{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read goals dir %q: %w", goalsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ResumableClaude, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(name, ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimSuffix(name, ".json")
|
||||||
|
if sessionID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip sessions that are alive (already in the fleet, not resumable).
|
||||||
|
if live[sessionID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(goalsDir, name)
|
||||||
|
raw, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var g goalFile
|
||||||
|
if json.Unmarshal(raw, &g) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No real work to resume without a goal.
|
||||||
|
if strings.TrimSpace(g.Goal) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, statErr := os.Stat(path)
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, ResumableClaude{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Goal: g.Goal,
|
||||||
|
Emojis: g.Emojis,
|
||||||
|
Name: g.Rename,
|
||||||
|
LastActive: info.ModTime().Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Order by LastActive descending (most recent first).
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return out[i].LastActive > out[j].LastActive
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Cap at maxResumable.
|
||||||
|
if len(out) > maxResumable {
|
||||||
|
out = out[:maxResumable]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveSessionIDs scans sessionsDir (sessions/*.json) and returns the set of
|
||||||
|
// sessionIds whose process is currently alive, applying the same anti-PID-
|
||||||
|
// recycling check as ListClaudeFleetFrom (procIsAlive matches /proc starttime
|
||||||
|
// against the recorded procStart). Missing or unparseable files are ignored.
|
||||||
|
func liveSessionIDs(sessionsDir string) map[string]bool {
|
||||||
|
live := make(map[string]bool)
|
||||||
|
entries, err := os.ReadDir(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sess sessionFile
|
||||||
|
if json.Unmarshal(raw, &sess) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sess.PID == 0 || sess.SessionID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if procIsAlive(sess.PID, sess.ProcStart) {
|
||||||
|
live[sess.SessionID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return live
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: list_resumable_claudes
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) | func ListResumableClaudes() ([]ResumableClaude, error)"
|
||||||
|
description: "Lista las sesiones de Claude Code CERRADAS que se pueden reabrir con `claude --resume <sessionId>` (Linux). Escanea ~/.claude/sessions/*.json para construir el conjunto de sessionIds VIVOS (mismo criterio anti-PID-reciclado que list_claude_fleet: procStart == campo 22 de /proc/<pid>/stat), luego recorre ~/.claude/goals/*.json y devuelve cada sesion cuyo proceso NO esta vivo y que tiene un goal no vacio. Cada entrada lleva session_id, goal, emojis y name (rename) del goal.json, y last_active = mtime del goal.json. Ordenadas por last_active desc y limitadas a 40. Pieza de datos del picker de resume de la app TUI fleetview."
|
||||||
|
tags: [claude-fleet, infra, claude, session, resume, proc, tui]
|
||||||
|
uses_functions: [list_claude_fleet_go_infra]
|
||||||
|
uses_types: [resumable_claude_go_infra]
|
||||||
|
returns: [resumable_claude_go_infra]
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "claudeDir"
|
||||||
|
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListResumableClaudesFrom lo recibe explicito (testeable con t.TempDir()); ListResumableClaudes lo resuelve via os.UserHomeDir() + .claude."
|
||||||
|
output: "Slice de ResumableClaude (resumable_claude_go_infra), una entrada por sesion CERRADA con goal en goals/<id>.json. Cada entrada lleva SessionID (basename del goal.json sin .json), Goal, Emojis, Name (rename) y LastActive (mtime del goal.json en epoch segundos). Excluye las sesiones cuyo proceso sigue vivo (ya en la flota) y las que no tienen goal. Ordenado por LastActive descendente y capado a 40 resultados. Devuelve slice vacio (sin error) si la carpeta goals/ no existe; error si no se puede leer por otra causa."
|
||||||
|
tested: true
|
||||||
|
tests: ["TestListResumableClaudesFrom"]
|
||||||
|
test_file_path: "functions/infra/resumable_claude_test.go"
|
||||||
|
file_path: "functions/infra/resumable_claude.go"
|
||||||
|
notes: "Complementaria de list_claude_fleet_go_infra: aquella lista las sesiones VIVAS, esta las CERRADAS-pero-resumibles. Reutiliza los helpers procIsAlive/procStartTime del mismo paquete infra (definidos en functions/infra/list_claude_fleet.go) — no los redefine. El conjunto de vivos se construye desde sessions/*.json; el catalogo de candidatas desde goals/*.json. El sessionId de una candidata es el basename del goal.json (no hay sessions/<PID>.json para ella porque su proceso ya murio). LastActive es el mtime del archivo, no la actividad real de la conversacion. Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
resumables, err := infra.ListResumableClaudes() // escanea ~/.claude
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for _, r := range resumables {
|
||||||
|
fmt.Printf("%s %-40s claude --resume %s\n", r.Emojis, r.Goal, r.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||||
|
resumables, _ := infra.ListResumableClaudesFrom("/home/enmanuel/.claude")
|
||||||
|
fmt.Println(len(resumables), "sesiones reabribles")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites poblar un picker de "reanudar" en la TUI fleetview (o cualquier UI/automatizacion equivalente): te da las sesiones de Claude Code que ya cerraste pero que tenian un objetivo guardado, listas para `claude --resume <session_id>`. Excluye las que siguen vivas (esas ya estan en la flota, las lista `list_claude_fleet_go_infra`). Usa `ListResumableClaudesFrom` en tests (inyectando un directorio con fixtures) y `ListResumableClaudes` en runtime real.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca relanza ni mata nada.
|
||||||
|
- **El statusline purga goals viejos.** Las sesiones de mas de ~7 dias suelen tener su `goals/<id>.json` purgado por el statusline, asi que dejan de aparecer aqui aunque `claude --resume` siga pudiendo reabrirlas. Esta funcion solo ve lo que queda en `goals/`.
|
||||||
|
- **PID reciclado.** El conjunto de "vivos" usa el mismo guardado anti-PID-reciclado que `list_claude_fleet`: un PID reasignado a otro proceso NO marca la sesion como viva (procStart != campo 22 de /proc/<pid>/stat), por lo que su goal seguira saliendo como resumible correctamente.
|
||||||
|
- **Orden por mtime, no por actividad real.** `LastActive` es el `mtime` del `goal.json`, que se toca cuando el statusline reescribe el objetivo/fase — no es el instante exacto del ultimo mensaje de la conversacion. Es una aproximacion "lo mas reciente arriba", no un timestamp exacto de actividad.
|
||||||
|
- **Cap a 40.** Solo se devuelven las 40 mas recientes; si hay mas goals cerrados, los antiguos se omiten.
|
||||||
|
- **Goals sin goal o ilegibles se omiten** silenciosamente. Un `goal.json` con `goal` vacio (o solo espacios) no es resumible (no hay trabajo que reanudar). Archivos no-`.json` y JSON corrupto se ignoran.
|
||||||
|
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) para decidir que sesiones estan vivas.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
//go:build !windows && linux
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeJSON marshals v and writes it to path, failing the test on error.
|
||||||
|
func writeJSON(t *testing.T, path string, v any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||||
|
t.Fatalf("write %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touch sets the mtime of path to the given unix epoch seconds.
|
||||||
|
func touch(t *testing.T, path string, epoch int64) {
|
||||||
|
t.Helper()
|
||||||
|
mt := time.Unix(epoch, 0)
|
||||||
|
if err := os.Chtimes(path, mt, mt); err != nil {
|
||||||
|
t.Fatalf("chtimes %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListResumableClaudesFrom(t *testing.T) {
|
||||||
|
t.Run("excluye sesion viva, incluye muertas con goal ordenadas por LastActive", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionsDir := filepath.Join(dir, "sessions")
|
||||||
|
goalsDir := filepath.Join(dir, "goals")
|
||||||
|
|
||||||
|
// A LIVE session: real running PID (this test process) + its real
|
||||||
|
// /proc starttime as procStart, so procIsAlive returns true.
|
||||||
|
livePID := os.Getpid()
|
||||||
|
liveStart, ok := procStartTime(livePID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("could not read procStartTime for self pid %d", livePID)
|
||||||
|
}
|
||||||
|
const liveSession = "11111111-aaaa-bbbb-cccc-000000000001"
|
||||||
|
writeJSON(t, filepath.Join(sessionsDir, "9001.json"), sessionFile{
|
||||||
|
PID: livePID,
|
||||||
|
SessionID: liveSession,
|
||||||
|
Cwd: "/tmp/live",
|
||||||
|
ProcStart: liveStart,
|
||||||
|
Status: "busy",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A goal for the live session: must be EXCLUDED (already in fleet).
|
||||||
|
liveGoal := filepath.Join(goalsDir, liveSession+".json")
|
||||||
|
writeJSON(t, liveGoal, goalFile{Goal: "trabajo en curso", Emojis: "🔥", Rename: "vivo"})
|
||||||
|
touch(t, liveGoal, 5000)
|
||||||
|
|
||||||
|
// A DEAD session with a goal: must be INCLUDED. No sessions/ entry,
|
||||||
|
// so it can never be live.
|
||||||
|
const deadOld = "22222222-aaaa-bbbb-cccc-000000000002"
|
||||||
|
oldGoal := filepath.Join(goalsDir, deadOld+".json")
|
||||||
|
writeJSON(t, oldGoal, goalFile{Goal: "objetivo antiguo", Emojis: "🛠️", Rename: "viejo"})
|
||||||
|
touch(t, oldGoal, 1000)
|
||||||
|
|
||||||
|
// Another DEAD session with a goal, more recent: must come FIRST.
|
||||||
|
const deadNew = "33333333-aaaa-bbbb-cccc-000000000003"
|
||||||
|
newGoal := filepath.Join(goalsDir, deadNew+".json")
|
||||||
|
writeJSON(t, newGoal, goalFile{Goal: "objetivo reciente", Rename: "nuevo"})
|
||||||
|
touch(t, newGoal, 4000)
|
||||||
|
|
||||||
|
// A DEAD session WITHOUT a goal string: must be OMITTED.
|
||||||
|
const deadEmpty = "44444444-aaaa-bbbb-cccc-000000000004"
|
||||||
|
emptyGoal := filepath.Join(goalsDir, deadEmpty+".json")
|
||||||
|
writeJSON(t, emptyGoal, goalFile{Goal: " ", Emojis: "💤"})
|
||||||
|
touch(t, emptyGoal, 6000)
|
||||||
|
|
||||||
|
got, err := ListResumableClaudesFrom(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d resumable, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by LastActive desc: deadNew (4000) before deadOld (1000).
|
||||||
|
if got[0].SessionID != deadNew {
|
||||||
|
t.Errorf("got[0].SessionID = %q, want %q", got[0].SessionID, deadNew)
|
||||||
|
}
|
||||||
|
if got[1].SessionID != deadOld {
|
||||||
|
t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, deadOld)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live session must not appear.
|
||||||
|
for _, r := range got {
|
||||||
|
if r.SessionID == liveSession {
|
||||||
|
t.Errorf("live session %q must be excluded", liveSession)
|
||||||
|
}
|
||||||
|
if r.SessionID == deadEmpty {
|
||||||
|
t.Errorf("session without goal %q must be omitted", deadEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field mapping for the most-recent record.
|
||||||
|
if got[0].Goal != "objetivo reciente" {
|
||||||
|
t.Errorf("got[0].Goal = %q", got[0].Goal)
|
||||||
|
}
|
||||||
|
if got[0].Name != "nuevo" {
|
||||||
|
t.Errorf("got[0].Name = %q, want \"nuevo\"", got[0].Name)
|
||||||
|
}
|
||||||
|
if got[0].LastActive != 4000 {
|
||||||
|
t.Errorf("got[0].LastActive = %d, want 4000", got[0].LastActive)
|
||||||
|
}
|
||||||
|
if got[1].Emojis != "🛠️" {
|
||||||
|
t.Errorf("got[1].Emojis = %q", got[1].Emojis)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dir de goals inexistente retorna slice vacio sin error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir() // no goals/ subdir
|
||||||
|
got, err := ListResumableClaudesFrom(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cap a 40 resultados mas recientes", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
goalsDir := filepath.Join(dir, "goals")
|
||||||
|
// 50 dead sessions with goals, mtimes 1..50.
|
||||||
|
for i := 1; i <= 50; i++ {
|
||||||
|
id := uuidLike(i)
|
||||||
|
p := filepath.Join(goalsDir, id+".json")
|
||||||
|
writeJSON(t, p, goalFile{Goal: "objetivo", Rename: id})
|
||||||
|
touch(t, p, int64(i))
|
||||||
|
}
|
||||||
|
got, err := ListResumableClaudesFrom(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 40 {
|
||||||
|
t.Fatalf("got %d, want 40 (capped)", len(got))
|
||||||
|
}
|
||||||
|
// Most recent first: LastActive should be 50 then descending.
|
||||||
|
if got[0].LastActive != 50 {
|
||||||
|
t.Errorf("got[0].LastActive = %d, want 50", got[0].LastActive)
|
||||||
|
}
|
||||||
|
if got[39].LastActive != 11 {
|
||||||
|
t.Errorf("got[39].LastActive = %d, want 11", got[39].LastActive)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidLike builds a deterministic, unique filename stem for index i.
|
||||||
|
func uuidLike(i int) string {
|
||||||
|
const hex = "0123456789abcdef"
|
||||||
|
b := []byte("00000000-0000-0000-0000-000000000000")
|
||||||
|
// Fill the last 3 chars with i (i <= 50 fits in 2 hex digits, keep simple).
|
||||||
|
b[len(b)-1] = hex[i%16]
|
||||||
|
b[len(b)-2] = hex[(i/16)%16]
|
||||||
|
b[len(b)-3] = hex[(i/256)%16]
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TmuxMapClaudePanes devuelve un mapa claudePID -> window_id de todos los panes
|
||||||
|
// del socket cuyo proceso de pane (o algun descendiente directo) sea un proceso
|
||||||
|
// `claude`. Permite a la TUI saber que Claude de su lista ya vive en la sesion
|
||||||
|
// fleet (y por tanto es conmutable) y en que window.
|
||||||
|
//
|
||||||
|
// Como cada pane que corre Claude lo hace con `exec claude ...`, el #{pane_pid}
|
||||||
|
// del pane normalmente ES el PID de claude (comm == "claude"). Por robustez, si
|
||||||
|
// el propio pane_pid no es claude (p.ej. un shell que lanzo claude como hijo),
|
||||||
|
// se recorren sus descendientes directos buscando el primer comm == "claude".
|
||||||
|
// Si no se encuentra claude bajo un pane, ese pane se omite.
|
||||||
|
//
|
||||||
|
// Opera SIEMPRE sobre el socket aislado pasado como parametro (tmux -L <socket>)
|
||||||
|
// y lee /proc (no portable a Windows; de ahi el build tag //go:build !windows).
|
||||||
|
func TmuxMapClaudePanes(socket string) (map[int]string, error) {
|
||||||
|
if socket == "" {
|
||||||
|
return nil, fmt.Errorf("tmux_map_claude_panes: socket vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, stderr, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{window_id}")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tmux_map_claude_panes: list-panes -a: %w (%s)", err, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[int]string)
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
panePID, convErr := strconv.Atoi(fields[0])
|
||||||
|
if convErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
windowID := fields[1]
|
||||||
|
|
||||||
|
claudePID, ok := findClaudePID(panePID)
|
||||||
|
if !ok {
|
||||||
|
continue // no hay claude bajo este pane
|
||||||
|
}
|
||||||
|
result[claudePID] = windowID
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findClaudePID devuelve el PID de un proceso `claude` que sea el propio pid o
|
||||||
|
// un hijo directo suyo. Devuelve (pid, true) si lo encuentra; (0, false) si no.
|
||||||
|
func findClaudePID(pid int) (int, bool) {
|
||||||
|
if procComm(pid) == "claude" {
|
||||||
|
return pid, true
|
||||||
|
}
|
||||||
|
for _, child := range procChildren(pid) {
|
||||||
|
if procComm(child) == "claude" {
|
||||||
|
return child, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// procComm lee el nombre del comando (comm) de /proc/<pid>/comm. Devuelve ""
|
||||||
|
// si el proceso no existe o no se puede leer.
|
||||||
|
func procComm(pid int) string {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// procChildren devuelve los PIDs de los hijos DIRECTOS de <pid>. Intenta primero
|
||||||
|
// /proc/<pid>/task/<pid>/children (rapido, requiere CONFIG_PROC_CHILDREN); si no
|
||||||
|
// esta disponible, cae a escanear /proc/*/stat por PPID (campo 4).
|
||||||
|
func procChildren(pid int) []int {
|
||||||
|
if kids := procChildrenFromTask(pid); kids != nil {
|
||||||
|
return kids
|
||||||
|
}
|
||||||
|
return procChildrenFromScan(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// procChildrenFromTask agrega /proc/<pid>/task/<tid>/children sobre TODOS los
|
||||||
|
// hilos (tasks) del proceso. Cada `children` lista solo los hijos parenteados
|
||||||
|
// a ESE task, asi que un proceso multihilo (un shell que hizo fork desde un
|
||||||
|
// hilo no principal, o el propio test runner de Go) puede tener hijos repartidos
|
||||||
|
// entre varios tasks. Devuelve nil si el directorio task/ no existe o ningun
|
||||||
|
// task expone `children` (kernel sin CONFIG_PROC_CHILDREN), para que el caller
|
||||||
|
// use el fallback de scan por PPID.
|
||||||
|
func procChildrenFromTask(pid int) []int {
|
||||||
|
taskDir := fmt.Sprintf("/proc/%d/task", pid)
|
||||||
|
tasks, err := os.ReadDir(taskDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var kids []int
|
||||||
|
supported := false
|
||||||
|
for _, task := range tasks {
|
||||||
|
tid := task.Name()
|
||||||
|
data, err := os.ReadFile(filepath.Join(taskDir, tid, "children"))
|
||||||
|
if err != nil {
|
||||||
|
continue // este task no expone children; probar el resto
|
||||||
|
}
|
||||||
|
supported = true
|
||||||
|
for _, tok := range strings.Fields(string(data)) {
|
||||||
|
if k, err := strconv.Atoi(tok); err == nil {
|
||||||
|
kids = append(kids, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
return nil // kernel sin CONFIG_PROC_CHILDREN -> fallback a scan
|
||||||
|
}
|
||||||
|
// Distinguir "sin hijos" (slice vacio no-nil) de "sin soporte" (nil arriba).
|
||||||
|
if kids == nil {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
return kids
|
||||||
|
}
|
||||||
|
|
||||||
|
// procChildrenFromScan escanea /proc/*/stat buscando procesos cuyo PPID (campo
|
||||||
|
// 4 de stat, indice 1 tras el comm entre parentesis) sea <pid>.
|
||||||
|
func procChildrenFromScan(parent int) []int {
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var kids []int
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childPID, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue // no es un directorio de PID
|
||||||
|
}
|
||||||
|
if procPPID(childPID) == parent {
|
||||||
|
kids = append(kids, childPID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kids
|
||||||
|
}
|
||||||
|
|
||||||
|
// procPPID extrae el PPID (campo 4 de /proc/<pid>/stat). El comm (campo 2) va
|
||||||
|
// entre parentesis y puede contener espacios y ')', asi que se parsea tomando
|
||||||
|
// lo que hay tras el ULTIMO ')'. Tras el comm, los campos son: state(0) ppid(1)
|
||||||
|
// pgrp(2)... -> el PPID es el indice 1 de ese resto.
|
||||||
|
func procPPID(pid int) int {
|
||||||
|
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
s := string(data)
|
||||||
|
close := strings.LastIndex(s, ")")
|
||||||
|
if close < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
rest := strings.Fields(s[close+1:])
|
||||||
|
const ppidIdx = 1 // state=rest[0], ppid=rest[1]
|
||||||
|
if len(rest) <= ppidIdx {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
ppid, err := strconv.Atoi(rest[ppidIdx])
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return ppid
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user