Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd9f0d8437 | |||
| 207c08c3b7 | |||
| 01bc2aeb14 | |||
| 9ec7751f6f | |||
| fef86250a0 | |||
| 472b6092bb | |||
| ea5c94fc8a | |||
| a8b09ad154 | |||
| 6aa874f2b6 | |||
| 93352a7780 | |||
| 0ffae6daa4 | |||
| 74b58cf0d0 | |||
| 9752fb106a | |||
| 8cb0121573 | |||
| 90115270d2 | |||
| 11e6e27ad1 | |||
| a59b12d467 | |||
| fe4320af89 | |||
| f71e0f4c9a | |||
| 46b4385331 | |||
| 580238b32e | |||
| ed767360c1 | |||
| 5bac05ce13 | |||
| d0ceea6f3d | |||
| 0f905b78e0 |
@@ -27,6 +27,8 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||
|
||||
**Slash commands:** `/commands` lista todos los slash commands del repo agrupados por namespace (global + projects). Project commands viven en `projects/<p>/.claude/commands/` y se exponen como `/<project>:<cmd>` via symlink. Ver `.claude/rules/project_commands.md`.
|
||||
|
||||
**Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`.
|
||||
|
||||
---
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../projects/aurgi/.claude/commands
|
||||
@@ -1,121 +1,36 @@
|
||||
# /autonomous-task — Lanza fn-orquestador (Fase 6 del ciclo reactivo)
|
||||
|
||||
Lanza el meta-orquestador autonomo que recorre el bucle CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR sobre un issue, sin intervencion humana, hasta convergencia / estancamiento / timeout / limite de iteraciones.
|
||||
|
||||
Issue 0069. Pre-condiciones obligatorias (chequear ANTES de despachar):
|
||||
|
||||
1. Migration `fn_operations/migrations/006_task_runs.sql` aplicada.
|
||||
2. Subagentes `fn-constructor`, `fn-executor`, `fn-recopilador`, `fn-analizador`, `fn-mejorador`, `fn-orquestador` presentes en `.claude/agents/`.
|
||||
3. `dev/autonomous_protected_paths.json` existe.
|
||||
4. `master` local up-to-date con `origin/master`.
|
||||
5. Branch `auto/<issue_id>` NO existe ya.
|
||||
6. `gh auth status` OK (necesario para PR draft al converger).
|
||||
7. Tipo de tarea soportado: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`.
|
||||
|
||||
Si alguna pre-condicion falla → ABORT con razon. NO improvisar.
|
||||
|
||||
---
|
||||
description: "DEPRECADO 2026-05-19 — usa /autopilot. Wrapper directo a fn-orquestador conservado solo como debug primitive."
|
||||
---
|
||||
|
||||
## Argumento
|
||||
# /autonomous-task — DEPRECADO (sustituido por `/autopilot`)
|
||||
|
||||
`$ARGUMENTS` — `<issue_id>` o `<task_spec_path>` + flags opcionales.
|
||||
**ESTADO:** deprecado 2026-05-19. Usa `/autopilot <NNNN>` en su lugar.
|
||||
|
||||
```
|
||||
/autonomous-task 0070
|
||||
/autonomous-task 0070 --max-iterations 15 --max-minutes 90
|
||||
/autonomous-task 0070 --auto-apply-proposals safe
|
||||
/autonomous-task 0070 --dry-run
|
||||
/autonomous-task path/to/spec.yaml --branch auto/custom-name
|
||||
```
|
||||
## Por que deprecado
|
||||
|
||||
Flags:
|
||||
- `--max-iterations N` tope de iteraciones (default 10)
|
||||
- `--max-minutes M` timeout total (default 60)
|
||||
- `--auto-apply-proposals` `none|safe|aggressive` (default `safe`)
|
||||
- `--branch NAME` rama TBD (default `auto/<issue_id>`)
|
||||
- `--dry-run` simula, NO aplica
|
||||
`/autopilot` (v2, 2026-05-19) absorbe la funcionalidad y anade:
|
||||
- Pre-flight DoD readiness check (gate STOP — no arranca sin DoD).
|
||||
- Detector issue vs flow.
|
||||
- Reporte estructurado al humano post-delegate.
|
||||
- Self-Q&A migrado a fn-orquestador.
|
||||
|
||||
---
|
||||
Behaviour orquestador-side es identico. La unica diferencia es que `/autopilot` valida antes de delegar; `/autonomous-task` delegaba ciego.
|
||||
|
||||
## Comportamiento
|
||||
## Sustitucion 1:1
|
||||
|
||||
1. **Verificar pre-condiciones** con script bash (ver arriba). Si alguna falla, reportar y salir.
|
||||
2. **Despachar a `fn-orquestador`** via Agent tool con `subagent_type=fn-orquestador`. Pasar:
|
||||
- `issue_id` o `task_spec`
|
||||
- flags resueltos
|
||||
- paths protegidos (leidos de `dev/autonomous_protected_paths.json`)
|
||||
3. **El subagente:**
|
||||
- Crea worktree aislado `/tmp/fn_orq_<issue>_<ts>/` desde `master`.
|
||||
- Persiste estado en `task_runs` (operations.db del app target o repo root).
|
||||
- Despacha por fases a los 5 subagentes especializados.
|
||||
- Aplica proposals filtradas por `--auto-apply-proposals`.
|
||||
- Termina con: `converged` (PR draft creado) | `stalled` | `timeout` | `iterations_exhausted` | `needs_human` | `aborted`.
|
||||
4. **Reportar resultado al humano** con:
|
||||
- `status`, `iterations / max`, `duration / max`
|
||||
- `branch`, `worktree`, `PR draft url` si converged
|
||||
- `proposals creadas / aplicadas`
|
||||
- `last run_id` y status
|
||||
- Resumen iter-por-iter del `progress_json`
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15 --max-minutes 90` | `/autopilot 0070 --max-iterations 15 --max-minutes 90` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
| `/autonomous-task 0070 --auto-apply-proposals safe` | `/autopilot 0070 --auto-apply-proposals safe` |
|
||||
|
||||
---
|
||||
## Modo debug
|
||||
|
||||
## Reglas duras (no negociables)
|
||||
Si `/autopilot` falla en pre-flight pero quieres forzar dispatch sin DoD check (debug / experimentos), puedes seguir usando `/autonomous-task` que va directo a `fn-orquestador` sin validar. NO RECOMENDADO para uso normal.
|
||||
|
||||
- Sandbox de rama EN WORKTREE — nunca toca master ni el working tree del humano.
|
||||
- No merge automatico — PR draft siempre.
|
||||
- No `--no-verify`, no `--force`, no skip hooks.
|
||||
- Paths protegidos via `dev/autonomous_protected_paths.json`.
|
||||
- Watchdog: 2 iteraciones con mismo set de fails → `status=stalled`.
|
||||
- Auditoria total en `task_runs.progress_json`.
|
||||
- No self-modification: NO toca `.claude/agents/` ni `.claude/commands/`.
|
||||
## Migration deadline
|
||||
|
||||
---
|
||||
Sin deadline duro — `/autonomous-task` seguira funcionando hasta que un commit lo elimine. Pero NO se anaden nuevas features aqui; cualquier mejora va a `/autopilot`.
|
||||
|
||||
## Integracion con call_monitor (issue 0085)
|
||||
|
||||
El orquestador puede leer `projects/fn_monitoring/apps/call_monitor/operations.db` para:
|
||||
|
||||
- Consultar `function_stats` antes de decidir que funciones usar/reusar.
|
||||
- Filtrar proposals existentes via `mcp__registry__fn_proposal --status pending` para evitar duplicados.
|
||||
- Loggear sus invocaciones via el hook PostToolUse (automatico).
|
||||
|
||||
Tras converger, el `call_monitor propose` ejecutado por el humano (o futuro cron) absorbera las nuevas violations / copied_code / fails para alimentar la siguiente ronda.
|
||||
|
||||
---
|
||||
|
||||
## Tipos NO soportados
|
||||
|
||||
- Diseño arquitectura nuevo (humano decide).
|
||||
- Decisiones UX subjetivas.
|
||||
- Cambios BD productiva.
|
||||
- Cualquier cosa que toque secrets/credenciales.
|
||||
- Self-modification del propio orquestador.
|
||||
|
||||
Si el issue contiene criterios no-verificables programaticamente, ABORT con `status=needs_human`.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autonomous-task: 0070 ===
|
||||
status: converged
|
||||
iterations: 7 / 10
|
||||
duration: 23 min / 60
|
||||
branch: auto/0070
|
||||
worktree: /tmp/fn_orq_0070_1731612345
|
||||
PR draft: https://github.com/.../pull/123
|
||||
proposals: 3 creadas, 2 auto-aplicadas
|
||||
last run_id: e2e_run_abc123 (status: pass)
|
||||
|
||||
Iter:
|
||||
1. construir → ok (2 funciones nuevas)
|
||||
2. ejecutar → ok
|
||||
3. analizar → fail (2/8 checks)
|
||||
4. mejorar → 3 proposals (2 auto-applicadas)
|
||||
5. construir → ok (re-build tras patches)
|
||||
6. analizar → pass
|
||||
7. recopilador → ok (operations.db integra)
|
||||
|
||||
Siguiente: revisar PR draft + fn proposal list -s pending --target-id 0070
|
||||
```
|
||||
Ver `.claude/commands/autopilot.md` para spec completa.
|
||||
|
||||
+132
-260
@@ -1,238 +1,68 @@
|
||||
---
|
||||
name: autopilot
|
||||
description: Modo full-auto self-Q&A. Toma issue o flow con DoD definido, valida readiness, ejecuta hasta cerrarlo sin interaccion humana. Ante cada decision se autoformula la pregunta, se autoresponde con razonamiento explicito, y avanza. Spawnea subagentes. Para.
|
||||
description: Modo full-auto. Pre-flight DoD check, detecta issue vs flow, SIEMPRE delega a fn-orquestador (worktree aislado + PR Gitea). Sin Path inline. Sustituye a /autonomous-task.
|
||||
---
|
||||
|
||||
# /autopilot — Modo autonomo end-to-end con self-Q&A
|
||||
# /autopilot — Comando autonomo unificado
|
||||
|
||||
Ejecuta un issue o flow **hasta cierre** sin intervencion humana. Ante cada decision, Claude **se formula la pregunta a si mismo y se la responde** (self-Q&A) con razonamiento trazable, en vez de abortar. Auto-prefiere la opcion **Recomendada** cuando exista; cuando no, decide en base a evidencia del codigo, registry, y reglas. Cada Q&A queda persistido en `task_runs.events_json[]` para auditoria. Spawnea subagentes en paralelo y persiste estado en `task_runs`.
|
||||
Comando UNICO para ejecutar issue o flow autonomo end-to-end. Sustituye a `/autonomous-task` (deprecado). Hace dos cosas:
|
||||
|
||||
Diferencia con comandos relacionados:
|
||||
1. **Pre-flight DoD readiness check** — sin DoD claro, no arranca.
|
||||
2. **Delega SIEMPRE a `fn-orquestador`** via Agent tool — worktree aislado en `/tmp/fn_orq_<NNNN>_<ts>/`, branch `auto/<NNNN>-<slug>`, PR draft Gitea al converger.
|
||||
|
||||
| Comando | Que hace |
|
||||
|---|---|
|
||||
| `/autonomous-task <issue>` | Wrapper directo de `fn-orquestador` (registry-bound, bucle 5 fases) |
|
||||
| `/fix-issue <issue>` | Flujo guiado humano: rama + tasks + tests + version + close (pregunta cuando duda) |
|
||||
| `/flow run <NNNN>` | Runner manual de flows (fase 2 — no implementado) |
|
||||
| `/autopilot <target>` | **Meta-dispatcher**. Detecta issue vs flow, valida DoD, dispatch correcto, auto-defaults SIEMPRE |
|
||||
NO ejecuta nada inline. NO muta cwd del shell del humano. NO duplica worktrees. Toda la complejidad de bucle + paths protegidos + sanity check vive en `fn-orquestador`.
|
||||
|
||||
---
|
||||
## Por que solo delegar
|
||||
|
||||
Historico: versiones anteriores de `/autopilot` tenian Path A (delegate a orquestador), Path B (registry-only inline), Path C (flow inline). Los Path B/C reimplementaban lo que ya hace `fn-orquestador` (worktree, branch, PR) y arrastraban un bug: `cd` en Bash de Claude Code PERSISTE entre llamadas → si autopilot hace `cd "$WT"`, todos los Bash subsiguientes operan en branch incorrecta. Solucion: NO hacer Path inline, delegar siempre.
|
||||
|
||||
`fn-orquestador` ahora soporta dos `task_type`:
|
||||
- `issue` — flujo CONSTRUIR→EJECUTAR→RECOPILAR→ANALIZAR→MEJORAR (default).
|
||||
- `flow` — parsea `dev/flows/<NNNN>-*.md` ## Flow y ejecuta steps (Path C absorbido).
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/autopilot <NNNN> # issue NNNN (default si no hay prefijo)
|
||||
/autopilot issue:<NNNN> # issue NNNN explicito
|
||||
/autopilot i:<NNNN> # alias issue
|
||||
/autopilot issue:<NNNN> # issue explicito
|
||||
/autopilot i:<NNNN> # alias
|
||||
/autopilot flow:<NNNN> # flow NNNN
|
||||
/autopilot f:<NNNN> # alias flow
|
||||
/autopilot check <target> # solo audita DoD readiness, no ejecuta
|
||||
/autopilot <target> --max-iterations N --max-minutes M
|
||||
/autopilot <target> --dry-run
|
||||
/autopilot f:<NNNN> # alias
|
||||
/autopilot check <target> # solo audita DoD readiness, no delega
|
||||
/autopilot <target> --max-iterations N --max-minutes M --dry-run
|
||||
```
|
||||
|
||||
Detector:
|
||||
- `^\d{4}[a-z]?$` -> issue (sin prefijo = issue por defecto).
|
||||
- `^(issue|i):\d{4}[a-z]?$` -> issue.
|
||||
- `^(flow|f):\d{4}$` -> flow.
|
||||
- Otra cosa -> ABORT con error de sintaxis.
|
||||
- `^\d{4}[a-z]?$` → issue (sin prefijo = issue por defecto).
|
||||
- `^(issue|i):\d{4}[a-z]?$` → issue.
|
||||
- `^(flow|f):\d{4}$` → flow.
|
||||
- Otra cosa → ABORT con error de sintaxis.
|
||||
|
||||
---
|
||||
## Pre-flight DoD readiness check (OBLIGATORIO)
|
||||
|
||||
## Pre-flight: DoD readiness check (OBLIGATORIO)
|
||||
|
||||
Sin DoD claro, autopilot no arranca. La verificacion es STOP-gate, no se rellena por inferencia.
|
||||
Sin DoD claro, autopilot no delega. Verificacion es STOP-gate.
|
||||
|
||||
### Issue (`dev/issues/<NNNN>-*.md`)
|
||||
|
||||
Lee el .md. Debe cumplir **todos** estos:
|
||||
|
||||
1. Archivo existe en `dev/issues/` (no en `completed/`).
|
||||
2. Frontmatter valido (`status`, `priority`).
|
||||
3. **Al menos UNA** de:
|
||||
- Seccion `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- Seccion `## Acceptance` con checkboxes `[ ]`.
|
||||
- Seccion `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo soportado por `/autonomous-task` si va a delegar a orquestador (`feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`). Si no entra en estos, autopilot intenta ruta `/fix-issue` simplificada (registry-only changes sin rama).
|
||||
5. **NO** contiene criterios no-verificables: "queda bonito", "es intuitivo", "UX mejor". Heuristica grep simple — si match -> warning + ABORT.
|
||||
|
||||
Si falla -> ABORT con tabla:
|
||||
|
||||
```
|
||||
=== autopilot check 0107c ===
|
||||
status: NOT READY
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance/Tests
|
||||
- Frontmatter sin priority
|
||||
fix:
|
||||
- Anadir `## DoD` con 3-5 bullets verificables programaticamente
|
||||
- Anadir `priority: medium` al frontmatter
|
||||
```
|
||||
2. Frontmatter con `status`, `priority`.
|
||||
3. Al menos UNA de:
|
||||
- `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- `## Acceptance` con checkboxes `[ ]`.
|
||||
- `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo declarado/inferible soportado por `fn-orquestador`: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`, `feature_registry_only`.
|
||||
5. NO contiene criterios no-verificables ("queda bonito", "intuitivo", "UX mejor"). Grep simple; si match → ABORT con warning.
|
||||
|
||||
### Flow (`dev/flows/<NNNN>-*.md`)
|
||||
|
||||
1. Archivo existe en `dev/flows/` (no en `completed/`).
|
||||
1. Archivo existe en `dev/flows/`.
|
||||
2. Frontmatter valido.
|
||||
3. Seccion `## Acceptance` con >=1 checkbox `[ ]` (o ya `[x]` — significa parcialmente progresado).
|
||||
4. Seccion `## Flow` no vacia.
|
||||
5. Pre-requisitos declarados (incluso si vacio explicito).
|
||||
6. Tabla de funciones recomendadas presente — sin `FALTA: crear <id>` no resuelto (si hay `FALTA`, ABORT con lista de funciones a crear primero).
|
||||
3. `## Acceptance` con >=1 checkbox.
|
||||
4. `## Flow` no vacio.
|
||||
5. Pre-requisitos declarados.
|
||||
6. Tabla de funciones recomendadas sin `FALTA: crear <id>` (si los hay → ABORT salvo `--allow-construct-missing`).
|
||||
|
||||
Si `FALTA` esta presente: opcionalmente autopilot ofrece spawnear `fn-constructor` para cada FALTA — pero ESO solo si `--allow-construct-missing`. Por defecto ABORT y reportar.
|
||||
|
||||
---
|
||||
|
||||
## Modo autonomo (reglas duras de comportamiento)
|
||||
|
||||
Durante toda la ejecucion de `/autopilot`:
|
||||
|
||||
1. **NO invocar `AskUserQuestion` al humano**. En su lugar, **self-Q&A loop**: cuando surja una decision, Claude la formaliza como `Question -> Options -> Reasoning -> Choice` y persiste el bloque en `task_runs.events_json[]`. Ver seccion "Self-Q&A loop" mas abajo. Solo ABORTA con `status=needs_human` si la decision toca: (a) destructivo sin rollback (`--force`, `git reset --hard`, `DROP TABLE`), (b) credenciales/secrets, (c) paths protegidos, (d) contradice DoD explicito del issue. En esos casos, NUNCA self-answer — escala al humano.
|
||||
2. **Auto-pick "Recommended"** en cualquier flag con opciones (mocks vs prod, default branch, etc.) — usar primer item etiquetado como recomendado en el archivo / convencion del proyecto. Si no hay marcado, self-Q&A con justificacion.
|
||||
3. **Acciones destructivas prohibidas sin flag explicito**: `git reset --hard`, `git push --force`, `rm -rf` fuera de `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`. Si una accion las requiere -> ABORT.
|
||||
4. **Hooks NO se saltan**. Si pre-commit falla, fix raiz; si excede scope, ABORT (NO `--no-verify`).
|
||||
5. **Paths protegidos** de `dev/autonomous_protected_paths.json` se respetan exactamente.
|
||||
6. **Rama dedicada + worktree aislado SIEMPRE — sin excepciones**. Autopilot opera en `worktrees/auto-<NNNN>-<slug>/` (rama `auto/<NNNN>-<slug>`) para NO bloquear el working tree principal del humano. Aplica a issues, flows, registry-only y apps. Master NUNCA recibe commits directos en modo autopilot. Pre-flight obligatorio desde el working tree principal:
|
||||
```bash
|
||||
git fetch origin master
|
||||
git -C <main_repo> rev-parse --is-clean # tolerante: solo confirmar master rebased
|
||||
WT=worktrees/auto-<NNNN>-<slug>
|
||||
git worktree add -b auto/<NNNN>-<slug> "$WT" master
|
||||
cd "$WT" # todo el trabajo posterior aqui
|
||||
```
|
||||
Path del worktree:
|
||||
- **Dentro del repo**: `worktrees/auto-<NNNN>-<slug>/` (gitignored). Permite que herramientas con `FN_REGISTRY_ROOT` apunten al worktree y no a master.
|
||||
- Alternativa `/tmp/fn_autopilot_<NNNN>_<ts>/` si la app necesita aislamiento total del filesystem del repo (raro).
|
||||
Reanudacion idempotente: si el worktree ya existe -> `cd` a el + `git rebase master`. Si la rama `auto/<NNNN>-<slug>` existe pero el worktree no -> `git worktree add "$WT" auto/<NNNN>-<slug>`. Conflicto en rebase -> ABORT.
|
||||
Cierre exitoso: merge `--no-ff` a master desde el repo principal (`git -C <main> merge --no-ff auto/<NNNN>-<slug>`) solo tras tests verde + DoD 100%. Luego `git worktree remove <WT>` + `git branch -d auto/<NNNN>-<slug>`.
|
||||
Cierre fallido: worktree y rama quedan vivos para inspeccion humana. Master intacto.
|
||||
**Garantia**: el humano puede seguir editando en el repo principal mientras autopilot avanza — sin colisiones de checkout, sin index lockings, sin "uncommitted changes blocks branch switch".
|
||||
7. **Watchdog**: si la metrica de progreso (`acceptance_done / acceptance_total` para flows; `tests_pass / tests_total` o `checks_pass / checks_total` para issues) NO sube en 3 iteraciones consecutivas -> ABORT con `status=stalled`.
|
||||
8. **Timeout** default 60 min. Override con `--max-minutes`.
|
||||
9. **Idempotencia**: re-lanzar `/autopilot <target>` sobre el mismo target reanuda desde el ultimo `task_run` exitoso (lookup por `issue_id` o `flow_id` en `task_runs`).
|
||||
10. **No self-modification**: NUNCA tocar `.claude/agents/`, `.claude/commands/`, `.claude/rules/`, `.claude/scripts/`, `.claude/CLAUDE.md`.
|
||||
11. **Trazabilidad**: cada decision se persiste en `task_runs.events_json[]` con `{ts, agent, action, evidence, diff_summary, auto_choice, self_qa?}`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Q&A loop (corazon del modo)
|
||||
|
||||
Cuando aparece una decision sin Recomendado explicito, **NO abortar y NO preguntar al humano**. Claude:
|
||||
|
||||
1. **Formula la pregunta** en una frase. Una sola pregunta por bloque, especifica, contestable.
|
||||
2. **Lista opciones** (2-4). Misma forma que `AskUserQuestion` interno: `label + description`. Si solo hay una opcion viable, indicalo (`Options: [A] only viable`).
|
||||
3. **Razona en 1-3 lineas** apoyandote en: registry (`mcp__registry__fn_search`), reglas (`.claude/rules/`), tests previos, archivos del repo, convenciones del proyecto.
|
||||
4. **Elige** y marca `confidence: high|med|low`. Si `low` y la accion no es trivialmente reversible -> ABORT con `status=needs_human` y adjunta el bloque Q&A.
|
||||
5. **Persiste** en `events_json[]` con shape:
|
||||
```json
|
||||
{
|
||||
"ts": "...",
|
||||
"agent": "autopilot",
|
||||
"action": "self_qa",
|
||||
"self_qa": {
|
||||
"question": "Crear el flag enabled=false o ya enabled=true?",
|
||||
"options": [
|
||||
{"label": "enabled=false", "rationale": "TBD doctrina (feature_flags.md): merge codigo terminado pero NO expuesto"},
|
||||
{"label": "enabled=true", "rationale": "feature ya tiene tests verde y DoD 100%"}
|
||||
],
|
||||
"choice": "enabled=false",
|
||||
"confidence": "high",
|
||||
"reasoning": "feature_flags.md regla: 'cuando se activa: cambiar enabled:true y rellenar enabled_at'. Activar va en commit posterior."
|
||||
}
|
||||
}
|
||||
```
|
||||
6. **Avanza** sin esperar.
|
||||
|
||||
**Tope de self-Q&A**: `--max-self-answers` (default 20). Si se excede -> ABORT `status=overdeliberating` con dump de todas las Q&A. Una iteracion del bucle que necesita >5 Q&A es señal de DoD vago — abortar.
|
||||
|
||||
**Cuando NO usar self-Q&A (ABORT en vez de auto-responder)**:
|
||||
|
||||
| Caso | Razon |
|
||||
|---|---|
|
||||
| Destructivo sin rollback (`git reset --hard`, `rm -rf` fuera `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`) | Coste de error infinito |
|
||||
| Credenciales/tokens/secrets | Riesgo de exfiltracion |
|
||||
| Paths protegidos (`dev/autonomous_protected_paths.json`) | Regla dura del orquestador |
|
||||
| Contradiccion explicita con DoD del issue | DoD es contrato |
|
||||
| Decision arquitectonica multi-app (renombrar tabla compartida, romper API publica) | Blast radius > 1 artefacto |
|
||||
| `confidence: low` + accion no reversible | Self-Q&A no garantiza acierto sin oraculo |
|
||||
|
||||
---
|
||||
|
||||
## Dispatch logic
|
||||
|
||||
### Path A: issue compatible con `fn-orquestador`
|
||||
|
||||
Si el issue declara o se infiere tipo en `(feature_app_simple, bugfix_with_repro, refactor_safe, add_e2e_check)` Y toca apps/modules/framework:
|
||||
|
||||
- Delega a `fn-orquestador` via `Agent(subagent_type="fn-orquestador", ...)` pasando:
|
||||
- `issue_id`, `--auto-apply-proposals safe`, `--max-iterations`, `--max-minutes`, paths protegidos.
|
||||
- Espera resultado, reenvia `task_run_id` + PR draft URL al humano.
|
||||
|
||||
### Path B: issue registry-only (functions/types/docs/rules)
|
||||
|
||||
- **Rama + worktree `worktrees/auto-<NNNN>-<slug>/`** desde master actualizado (regla dura 6). Humano sigue trabajando en el repo principal en paralelo.
|
||||
- Politica `apps_tbd.md` permite push directo a master para registry-only en modo humano, pero `/autopilot` NO lo usa: el aislamiento por rama+worktree es la garantia de rollback + paralelismo en modo autonomo.
|
||||
- Plan inline con TaskCreate:
|
||||
1. Pre-flight worktree (`git fetch` + `git worktree add -b auto/<NNNN>-<slug> worktrees/auto-<NNNN>-<slug> master` + `cd` a el).
|
||||
2. Leer issue + extraer DoD.
|
||||
3. Search registry para piezas existentes (registry-first).
|
||||
4. Si falta funcion -> spawn `fn-constructor` paralelo.
|
||||
5. `fn index`.
|
||||
6. Tests (`go test`, `pytest`, `bash -n`, segun stack).
|
||||
7. Si toco modulos/framework -> `/version` correspondiente.
|
||||
8. Mover `dev/issues/<NNNN>-*.md` a `dev/issues/completed/`.
|
||||
9. Actualizar `dev/issues/README.md` (si existe).
|
||||
10. Commit atomico por bloque logico en la rama.
|
||||
11. Solo si TODOS los tests pasan + DoD 100%: merge `--no-ff` a master + push + delete rama. Si algo falla -> rama queda viva, master intacto.
|
||||
- Verificacion final: DoD checkboxes -> todos marcados.
|
||||
|
||||
### Path C: flow
|
||||
|
||||
Runner inline (fase 2 manual, mientras `/flow run` no exista):
|
||||
|
||||
1. **Rama + worktree `worktrees/auto-flow-<NNNN>-<slug>/`** desde master (regla dura 6) — incluso para flows que solo ejecutan funciones sin escribir codigo, asi los side-effects en `dev/flows/<NNNN>-*.md` (checkboxes) y `dev/flows/runs/*.jsonl` se commitean en rama, no en master. Humano puede seguir editando el repo principal en paralelo.
|
||||
2. Parsea `## Flow` del .md.
|
||||
3. Cada paso tipo `function: <id>` -> `./fn run <id> [args]`.
|
||||
4. Cada paso tipo `cmd: <bash>` -> Bash tool (con guardas destructivas).
|
||||
5. Paso "MANUAL: ..." -> si tiene equivalente automatico (ej. "abrir Chrome y loguearse" vs `cdp_extract_recipe`) usa el automatico; si no tiene equivalente -> ABORT con `status=needs_human` y razon.
|
||||
6. Tras cada paso, evalua `## Acceptance` checkboxes via heuristicas:
|
||||
- "X runs en data_factory" -> `sqlite3` count.
|
||||
- "DAG corre 2 veces consecutivas" -> consultar `dag_engine` logs.
|
||||
- Otros -> dejar como `[ ]` y reportar.
|
||||
7. Persiste run en `dev/flows/runs/<NNNN>-<ts>.jsonl`.
|
||||
8. Si todos `[ ]` -> `[x]` -> commit en rama + merge `--no-ff` a master + `/flow done <NNNN>` + delete rama.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autopilot 0107c ===
|
||||
target: issue 0107c (refactor data_table)
|
||||
path: B (registry-only)
|
||||
status: done
|
||||
iterations: 3 / 10
|
||||
duration: 18 min / 60
|
||||
dod_checks: 5/5 pass
|
||||
proposals: 2 creadas, 1 auto-aplicada
|
||||
self_qa: 7 (6 high / 1 med / 0 low)
|
||||
agents_spawned: fn-constructor x2, fn-recopilador x1
|
||||
commits: 4 (3 feat + 1 refactor)
|
||||
branch: master (registry-only, push directo)
|
||||
|
||||
Trace:
|
||||
1. construir → ok (2 funciones nuevas, 1 split)
|
||||
2. tests → ok (43/43)
|
||||
3. version → /version modules/data_table major "..."
|
||||
4. close → mv to completed/, push
|
||||
|
||||
Siguiente: ningun paso humano requerido. Verificar con: fn doctor modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sub-comando: `/autopilot check <target>`
|
||||
|
||||
Solo audita readiness — **no** ejecuta nada.
|
||||
Si falla:
|
||||
|
||||
```
|
||||
=== /autopilot check 0125 ===
|
||||
@@ -240,12 +70,10 @@ status: NOT READY
|
||||
target: issue 0125 (skill-tree-dashboard-panel)
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance
|
||||
- Frontmatter sin priority
|
||||
non_verifiable_criteria:
|
||||
- "UX intuitiva" (linea 47)
|
||||
- "UX intuitiva" linea 47 — no verificable
|
||||
fix:
|
||||
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
||||
- Reemplazar "UX intuitiva" por criterio medible
|
||||
- Reemplazar criterios subjetivos por mediciones concretas
|
||||
```
|
||||
|
||||
Si OK:
|
||||
@@ -255,86 +83,130 @@ Si OK:
|
||||
status: READY
|
||||
target: issue 0107c (refactor data_table)
|
||||
dod_items: 5 checkboxes
|
||||
path_inferred: B (registry-only — modules/)
|
||||
task_type: refactor_safe
|
||||
estimated_iter: 3-5
|
||||
```
|
||||
|
||||
---
|
||||
## Dispatch a fn-orquestador
|
||||
|
||||
Tras pre-flight OK, ejecuta:
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type="fn-orquestador",
|
||||
prompt="""
|
||||
Issue/Flow: <path al .md>
|
||||
Modo: REAL (o --dry-run)
|
||||
task_type: <issue|flow>
|
||||
Pre-condiciones verificadas: 7/7 verde
|
||||
Master: <sha> sync con origin
|
||||
Working tree principal: limpio (baseline)
|
||||
Max iter: N
|
||||
Max min: M
|
||||
Auto-apply proposals: safe
|
||||
Token Gitea: pass gitea/dataforge-git-token
|
||||
DB task_runs: apps/deploy_server/operations.db (schema task_id)
|
||||
Reglas duras: autonomous_loop.md (11 reglas)
|
||||
""",
|
||||
run_in_background=true
|
||||
)
|
||||
```
|
||||
|
||||
Cuando termine, reporta al humano con output canonico del orquestador:
|
||||
|
||||
```
|
||||
=== /autopilot 0121b ===
|
||||
target: issue 0121b (fn doctor e2e-coverage)
|
||||
delegated_to: fn-orquestador
|
||||
status: converged
|
||||
iterations: 1 / 8
|
||||
duration: 4 min / 30
|
||||
task_run_id: task_d285372493cce2e6
|
||||
branch: auto/0121b-orquestador
|
||||
worktree: /tmp/fn_orq_0121b_1779147778
|
||||
PR draft: https://gitea-.../dataforge/fn_registry/pulls/3
|
||||
|
||||
Siguiente: revisar PR, mergear, mover issue a completed/
|
||||
```
|
||||
|
||||
## Reglas duras (autopilot-level)
|
||||
|
||||
1. **Cero cwd mutation**. Autopilot NUNCA hace `cd`. Usa `git -C <repo>` siempre si necesita inspeccionar.
|
||||
2. **Cero ejecucion inline de bucle**. Todo va via `fn-orquestador`. Si autopilot necesita ejecutar algo (pre-flight scripts), es read-only.
|
||||
3. **Cero AskUserQuestion**. Self-pick "Recommended". Si no hay, ABORT con `status=needs_human`.
|
||||
4. **DoD es contrato**. Si DoD no se cumple al final, `task_run.status` queda `partial` y autopilot reporta NOT_DONE — humano decide.
|
||||
5. **Worktree gestion delegada al orquestador**. Autopilot NO crea worktrees propios. NO toca branches.
|
||||
6. **Trazabilidad**: cada decision pre-delegate (especialmente abort de DoD check) se persiste en `task_runs.events_json[]` con `agent: autopilot`.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Que hace |
|
||||
|---|---|---|
|
||||
| `--max-iterations N` | 10 | Tope de iteraciones del bucle |
|
||||
| `--max-minutes M` | 60 | Timeout total |
|
||||
| `--dry-run` | off | Plan + dispatch simulado, no aplica cambios |
|
||||
| `--allow-construct-missing` | off | Si flow tiene `FALTA: crear <id>`, spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado a fn-orquestador en Path A |
|
||||
| `--max-self-answers N` | 20 | Tope de bloques Self-Q&A por run. Excedido -> ABORT `overdeliberating` |
|
||||
|
||||
---
|
||||
| `--max-iterations N` | 10 | Pasado al orquestador |
|
||||
| `--max-minutes M` | 60 | Pasado al orquestador |
|
||||
| `--dry-run` | off | Pasado al orquestador |
|
||||
| `--allow-construct-missing` | off | Flow con `FALTA: crear <id>` → spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado al orquestador |
|
||||
|
||||
## Errores canonicos
|
||||
|
||||
| Codigo | Significado | Accion |
|
||||
|---|---|---|
|
||||
| `NOT_READY` | DoD insuficiente | Humano edita .md y relanza |
|
||||
| `needs_human` | Decision sin Recomendado | Humano resuelve y relanza |
|
||||
| `stalled` | 3 iteraciones sin progreso | Humano revisa `events_json` |
|
||||
| `timeout` | Excedido `--max-minutes` | Aumentar timeout o partir issue |
|
||||
| `aborted_protected_path` | Cambio en path protegido | Humano revisa intent |
|
||||
| `iterations_exhausted` | Excedido `--max-iterations` | Humano evalua si vale subir tope |
|
||||
| `sandbox_breach` | Diff fuera del worktree | ABORT critico, audit |
|
||||
| `overdeliberating` | Excedido `--max-self-answers` | DoD probablemente vago — humano refina criterios |
|
||||
| `low_confidence_abort` | Self-Q&A devolvio `confidence: low` en accion no reversible | Humano valida la decision concreta |
|
||||
|
||||
---
|
||||
| `needs_human` | Decision ambigua | Humano resuelve y relanza |
|
||||
| `delegated_failed` | fn-orquestador devolvio fail/stall/timeout | Humano lee `task_runs.events_json` |
|
||||
| (resto) | Heredados del orquestador (stalled/timeout/aborted_protected_path/...) | Idem |
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| `/autopilot` sin pre-check DoD | Trabajar sin criterio de exito = bucle infinito |
|
||||
| Auto-relleno de DoD inventada | Criterios falsos -> falso "done" |
|
||||
| Merge a master sin tests verde | Master no deployable |
|
||||
| `AskUserQuestion` al humano | Rompe el contrato autonomo — usa self-Q&A loop |
|
||||
| Self-Q&A sin razonamiento explicito | Decision opaca, no auditable |
|
||||
| Self-Q&A con `confidence: high` en accion destructiva sin oraculo | Confianza injustificada — escalar |
|
||||
| Salto de hooks (`--no-verify`) | Encubre bugs reales |
|
||||
| Tocar mas issues que el target | Scope creep silencioso |
|
||||
| Borrar archivos sin backup en events_json | Pierde auditoria |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog).
|
||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||
- [[registry_calls]] — invocaciones canonicas (MCP / fn run / heredoc).
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador como gate de Path A.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
---
|
||||
| Hacer Path B/C inline | Mismo bug de cwd mutation que paso 2026-05-19 |
|
||||
| Saltar pre-flight DoD | Trabajar sin contrato = bucle infinito |
|
||||
| Mergear sin tests verde | fn-orquestador ya impide esto, NO bypaseas |
|
||||
| `AskUserQuestion` desde autopilot | Rompe contrato autonomo |
|
||||
| Crear worktree propio en autopilot | Duplica + colision con orquestador (paso 2026-05-19) |
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```bash
|
||||
# Issue registry-only con DoD claro
|
||||
# Issue con DoD claro
|
||||
/autopilot 0107c
|
||||
/autopilot i:0107c # equivalente con prefijo explicito
|
||||
|
||||
# Issue app que requiere orquestador
|
||||
/autopilot issue:0070 --max-iterations 15 --max-minutes 90
|
||||
|
||||
# Flow con piezas faltantes — autoriza creacion antes
|
||||
/autopilot flow:0008 --allow-construct-missing
|
||||
|
||||
# Solo audit, no ejecutar
|
||||
# Solo audit
|
||||
/autopilot check 0125
|
||||
/autopilot check flow:0008
|
||||
|
||||
# Dry run
|
||||
/autopilot 0107c --dry-run
|
||||
```
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog). fn-orquestador la aplica.
|
||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||
- [[registry_calls]] — invocaciones canonicas.
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
## Migracion desde `/autonomous-task`
|
||||
|
||||
`/autonomous-task` queda DEPRECADO. Sustitucion 1:1:
|
||||
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15` | `/autopilot 0070 --max-iterations 15` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
|
||||
`/autopilot` anade pre-flight DoD check + detect flow. Behaviour orquestador-side idem.
|
||||
|
||||
## Historico
|
||||
|
||||
- v1 (2026-05-15): introducido con Path A/B/C inline + self-Q&A.
|
||||
- v2 (2026-05-19): simplificado tras incidente cwd mutation en piloto 0121b. Solo delega a fn-orquestador. Self-Q&A movido al orquestador. Sustituye a `/autonomous-task`.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
description: "Lista todos los slash commands disponibles en el repo: globales de fn_registry + namespaced de cada project. Filtra por substring o por namespace."
|
||||
---
|
||||
|
||||
# /commands — Catalogo de slash commands del repo
|
||||
|
||||
Inventario unificado. Lista los `.md` bajo `.claude/commands/` (recursivo, sigue symlinks) y agrupa por namespace.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/commands # listado completo agrupado por namespace
|
||||
/commands <substring> # filtra por substring en nombre o descripcion
|
||||
/commands --ns <namespace> # solo un namespace (global, aurgi, ...)
|
||||
/commands --json # salida JSON para agentes
|
||||
```
|
||||
|
||||
## Implementacion
|
||||
|
||||
Bash + awk. Parsea frontmatter `description:` de cada `.md`. Agrupa por subdirectorio (subdir = namespace, root = `global`).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="${FN_REGISTRY_ROOT:-/home/egutierrez/fn_registry}"
|
||||
CMD_DIR="$ROOT/.claude/commands"
|
||||
|
||||
# Recolecta: ns|name|description
|
||||
collect() {
|
||||
find -L "$CMD_DIR" -type f -name '*.md' | while read -r f; do
|
||||
rel="${f#$CMD_DIR/}"
|
||||
case "$rel" in
|
||||
*/*) ns="${rel%%/*}"; name="${rel#*/}"; name="${name%.md}" ;;
|
||||
*) ns="global"; name="${rel%.md}" ;;
|
||||
esac
|
||||
desc=$(awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$f")
|
||||
printf '%s|%s|%s\n' "$ns" "$name" "${desc:-(sin descripcion)}"
|
||||
done | sort
|
||||
}
|
||||
|
||||
collect | awk -F'|' '
|
||||
{
|
||||
if ($1 != prev_ns) {
|
||||
if (prev_ns) print ""
|
||||
if ($1 == "global") print "## global (/<cmd>)"
|
||||
else print "## " $1 " (/" $1 ":<cmd>)"
|
||||
prev_ns = $1
|
||||
}
|
||||
printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3
|
||||
}'
|
||||
```
|
||||
|
||||
Filtros:
|
||||
|
||||
- Substring: `grep -i "<substring>"` sobre stdout.
|
||||
- `--ns X`: filtrar antes del `awk` por `$1 == "X"`.
|
||||
- `--json`: reemplazar el `awk` por `jq -Rsn` que construya array `{namespace, name, description, invocation}`.
|
||||
|
||||
## Salida (formato humano)
|
||||
|
||||
```
|
||||
## global (/<cmd>)
|
||||
- /app — Crear, configurar y desplegar apps del registry
|
||||
- /autopilot — Modo full-auto...
|
||||
- /commands — Catalogo de slash commands del repo
|
||||
...
|
||||
|
||||
## aurgi (/aurgi:<cmd>)
|
||||
- /aurgi:anadir_contexto_aurgi — Anade o modifica contexto...
|
||||
- /aurgi:aumentar_task — Enriquece tarea Aurgi con preguntas...
|
||||
- /aurgi:contexto_aurgi — Aprende el contexto de Aurgi...
|
||||
```
|
||||
|
||||
## Cuando usarlo
|
||||
|
||||
- Sesion nueva: ver de un vistazo que slash commands hay disponibles.
|
||||
- Antes de inventar logica inline: comprobar si ya existe un command.
|
||||
- Auditoria: verificar que los projects exponen sus commands correctamente.
|
||||
- Onboarding: nuevo PC clonado, descubrir capacidades del repo sin abrir N archivos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Sigue symlinks (`find -L`). Si un symlink apunta a directorio inexistente, devuelve vacio para esa rama — verificar con `ls -L .claude/commands/<ns>/`.
|
||||
- Solo escanea `<root>/.claude/commands/`. Commands user-global en `~/.claude/commands/` NO entran (son personales, fuera del repo).
|
||||
- Namespace = nombre del subdirectorio bajo `.claude/commands/`. Coincide con el project pero no por mecanismo — por convencion. Ver `.claude/rules/project_commands.md`.
|
||||
- Para que un command de project aparezca aqui desde la raiz, hace falta el symlink (`.claude/commands/<project>` -> `../../projects/<project>/.claude/commands`).
|
||||
@@ -37,3 +37,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 |
|
||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
## Slash commands por project (namespaced)
|
||||
|
||||
Cada `projects/<p>/` puede tener su propio `.claude/commands/*.md`. Para invocarlos desde la raiz de `fn_registry` sin que pisen los comandos globales, se exponen via **symlink namespaced** en `fn_registry/.claude/commands/<project>/`.
|
||||
|
||||
### Patron canonico
|
||||
|
||||
```
|
||||
projects/aurgi/.claude/commands/foo.md # archivo real (viaja con el sub-repo del project)
|
||||
fn_registry/.claude/commands/aurgi -> symlink -> ../../projects/aurgi/.claude/commands
|
||||
```
|
||||
|
||||
Resultado:
|
||||
|
||||
| cwd | Invocacion |
|
||||
|---|---|
|
||||
| `cd projects/aurgi && claude` | `/foo` (sin namespace) |
|
||||
| `cd fn_registry && claude` | `/aurgi:foo` (namespaced, no colisiona con `/foo` global) |
|
||||
|
||||
Subdirs dentro de `.claude/commands/` se exponen como namespace en el slash command. Por eso `aurgi/foo.md` -> `/aurgi:foo`.
|
||||
|
||||
### Como anadir un project nuevo
|
||||
|
||||
1. `mkdir -p projects/<p>/.claude/commands/`.
|
||||
2. Crear `<comando>.md` con frontmatter `description:` + cuerpo.
|
||||
3. Symlink: `ln -sf ../../projects/<p>/.claude/commands /home/egutierrez/fn_registry/.claude/commands/<p>`.
|
||||
4. Versionar el `.claude/commands/` del project en su propio sub-repo (NO en fn_registry — projects estan gitignored).
|
||||
5. Versionar SOLO el symlink en fn_registry (`git add .claude/commands/<p>`).
|
||||
|
||||
### Reglas
|
||||
|
||||
- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/<p>` como desde la raiz.
|
||||
- El symlink en fn_registry da acceso global con namespace — sin colision con commands del registry.
|
||||
- NO duplicar contenido: archivo real solo en `projects/<p>/.claude/commands/`. fn_registry solo guarda el symlink.
|
||||
- Si el project se mueve/elimina, borrar el symlink en fn_registry.
|
||||
|
||||
### Listado actual
|
||||
|
||||
| Project | Symlink | Commands disponibles desde fn_registry |
|
||||
|---|---|---|
|
||||
| aurgi | `.claude/commands/aurgi` | `/aurgi:aumentar_task`, `/aurgi:contexto_aurgi`, `/aurgi:anadir_contexto_aurgi` |
|
||||
|
||||
Anadir filas aqui al introducir un project nuevo con commands.
|
||||
|
||||
### Catalogo dinamico
|
||||
|
||||
Para listado en tiempo real (sin tener que actualizar esta tabla a mano): `/commands` escanea `.claude/commands/` recursivo y agrupa por namespace. Filtros: `/commands <substring>`, `/commands --ns <ns>`, `/commands --json`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Claude Code lista los commands disponibles al inicio de sesion. Si un symlink apunta a un directorio inexistente, los commands no aparecen — verificar con `ls -L .claude/commands/<project>/`.
|
||||
- El namespace usa el nombre del subdirectorio (`aurgi/`), no del project en `projects/`. Mantenerlos iguales para evitar confusion.
|
||||
- Los commands del project se ejecutan con el cwd de la sesion actual. Un `/aurgi:aumentar_task` invocado desde `fn_registry/` corre con cwd `fn_registry/` — paths relativos en el `.md` deben asumir esto (siempre usar paths relativos al repo, ej. `projects/aurgi/vaults/...`).
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: mas_client_register
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json"
|
||||
description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios."
|
||||
tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: ssh_host
|
||||
desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth."
|
||||
- name: container
|
||||
desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml."
|
||||
- name: config_file
|
||||
desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml."
|
||||
- name: dry_run
|
||||
desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861."
|
||||
output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)."
|
||||
tested: true
|
||||
tests:
|
||||
- "help flag emite JSON parseable"
|
||||
- "args faltantes retornan JSON de error sin ssh"
|
||||
- "jq disponible en host local"
|
||||
test_file_path: "bash/functions/infra/mas_client_register_test.sh"
|
||||
file_path: "bash/functions/infra/mas_client_register.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dry-run: verificar que clients se aplicarian correctamente
|
||||
source bash/functions/infra/mas_client_register.sh
|
||||
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \
|
||||
--dry-run
|
||||
|
||||
# Aplicar sync real (con --prune para eliminar clients viejos)
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml
|
||||
```
|
||||
|
||||
Salida esperada (sync OK):
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"applied": true,
|
||||
"clients_total": 6,
|
||||
"clients_diff": ["synced client element-web", "synced client synapse-admin", "..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
Salida dry-run:
|
||||
```json
|
||||
{
|
||||
"status": "dry-run",
|
||||
"applied": false,
|
||||
"clients_total": 42,
|
||||
"clients_diff": ["clients:", " - client_id: element-web", " ..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion.
|
||||
- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS.
|
||||
- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec <container> mas-cli --version`.
|
||||
- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script.
|
||||
- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh <alias>` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes.
|
||||
- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores.
|
||||
- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide).
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS)
|
||||
# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH.
|
||||
set -euo pipefail
|
||||
|
||||
mas_client_register() {
|
||||
local ssh_host=""
|
||||
local container=""
|
||||
local config_file=""
|
||||
local dry_run=false
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--container)
|
||||
container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--config-file)
|
||||
config_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync
|
||||
|
||||
Usage:
|
||||
mas_client_register --ssh-host <host> --container <name> --config-file <path> [--dry-run]
|
||||
|
||||
Options:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml)
|
||||
--dry-run Solo valida config y muestra diff, sin aplicar cambios
|
||||
|
||||
Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr
|
||||
USAGE
|
||||
# emit minimal valid JSON so callers that parse stdout don't break
|
||||
echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_client_register: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validar argumentos obligatorios
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$container" ]] && errors+=("--container es obligatorio")
|
||||
[[ -z "$config_file" ]] && errors+=("--config-file es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar dependencias locales
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2
|
||||
|
||||
# La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS)
|
||||
local container_config="/data/config.yaml"
|
||||
|
||||
# ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ----
|
||||
echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2
|
||||
local check_stdout check_stderr check_exit
|
||||
check_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true)
|
||||
check_exit=$?
|
||||
check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_check_stderr_$$
|
||||
|
||||
if [[ $check_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config check falló (exit=$check_exit)" >&2
|
||||
echo "$check_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: config check OK" >&2
|
||||
|
||||
# ---- PASO 2: dry-run o sync ----
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
# Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria
|
||||
echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2
|
||||
local dump_stdout dump_stderr dump_exit
|
||||
dump_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true)
|
||||
dump_exit=$?
|
||||
dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_dump_stderr_$$
|
||||
|
||||
if [[ $dump_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2
|
||||
echo "$dump_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extraer listado de clients del dump (buscar lineas con client_id o type: client)
|
||||
local clients_diff_raw
|
||||
clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \
|
||||
sed 's/^[[:space:]]*//' | head -50 || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \
|
||||
|| echo '["(jq parse error — ver stderr)"]')
|
||||
|
||||
local escaped_dump_stderr
|
||||
escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson stderr_str "$escaped_dump_stderr" \
|
||||
'{
|
||||
status: "dry-run",
|
||||
applied: false,
|
||||
clients_total: ($diff | length),
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ---- PASO 3: sync real ----
|
||||
echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2
|
||||
local sync_stdout sync_stderr sync_exit
|
||||
sync_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config sync --config ${container_config} --prune" \
|
||||
2>/tmp/mas_sync_stderr_$$ || true)
|
||||
sync_exit=$?
|
||||
sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_sync_stderr_$$
|
||||
|
||||
echo "mas_client_register: sync exit=$sync_exit" >&2
|
||||
if [[ -n "$sync_stderr" ]]; then
|
||||
echo "mas_client_register stderr: $sync_stderr" >&2
|
||||
fi
|
||||
|
||||
if [[ $sync_exit -ne 0 ]]; then
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parsear output del sync para extraer lineas con cambios aplicados
|
||||
local diff_lines
|
||||
diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \
|
||||
|| echo '[]')
|
||||
|
||||
local clients_count
|
||||
clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0)
|
||||
|
||||
local escaped_sync_stderr
|
||||
escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: sync completado con exito" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson total "$clients_count" \
|
||||
--argjson stderr_str "$escaped_sync_stderr" \
|
||||
'{
|
||||
status: "ok",
|
||||
applied: true,
|
||||
clients_total: $total,
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_client_register "$@"
|
||||
fi
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_client_register
|
||||
# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
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++))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle', got: $haystack"
|
||||
((FAIL++))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_parseable() {
|
||||
local test_name="$1" json="$2"
|
||||
if command -v jq &>/dev/null; then
|
||||
if echo "$json" | jq . >/dev/null 2>&1; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no es JSON valido: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
if [[ "$json" == \{* ]]; then
|
||||
echo "PASS: $test_name (jq no disponible, verificacion basica OK)"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no parece JSON: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: help flag emite JSON parseable
|
||||
# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true
|
||||
output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_help_$$
|
||||
assert_json_parseable "help flag emite JSON parseable" "$output_help"
|
||||
|
||||
# Test: args faltantes retornan JSON de error sin ssh
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true
|
||||
output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_noargs_$$
|
||||
assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs"
|
||||
assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs"
|
||||
|
||||
# Test: jq disponible en host local
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "PASS: jq disponible en host local"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: jq disponible en host local — instalar: apt install jq"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: mas_syn2mas_migration
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_syn2mas_migration --ssh-host <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--max-conflicts N] [--apply]"
|
||||
description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply."
|
||||
tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas]
|
||||
params:
|
||||
- name: ssh-host
|
||||
desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)"
|
||||
- name: mas-container
|
||||
desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)"
|
||||
- name: synapse-config-path
|
||||
desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount."
|
||||
- name: log-dir
|
||||
desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)."
|
||||
- name: max-conflicts
|
||||
desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)."
|
||||
- name: apply
|
||||
desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold."
|
||||
output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "aborta con error cuando faltan args obligatorios"
|
||||
- "help no devuelve error"
|
||||
- "argumento desconocido retorna exit 1"
|
||||
- "max-conflicts invalido retorna exit 1"
|
||||
test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh"
|
||||
file_path: "bash/functions/infra/mas_syn2mas_migration.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada)
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0
|
||||
|
||||
# Salida esperada (si hay 0 conflicts):
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}
|
||||
|
||||
# Revisar el log antes de continuar:
|
||||
# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log
|
||||
|
||||
# Paso 2: tras revisar el log dry-run, aplicar la migracion real
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0 \
|
||||
--apply
|
||||
|
||||
# Salida esperada tras migracion exitosa:
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar.
|
||||
- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162.
|
||||
- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1.
|
||||
- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861.
|
||||
- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos.
|
||||
- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect <container> | jq '.[].Mounts'`.
|
||||
- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos.
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas.
|
||||
# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold,
|
||||
# y solo ejecuta la migracion real cuando se pasa --apply.
|
||||
#
|
||||
# Usage:
|
||||
# mas_syn2mas_migration --ssh-host <host> --mas-container <name> \
|
||||
# --synapse-config-path <path-on-host> --log-dir <local-path> \
|
||||
# [--max-conflicts N] [--apply]
|
||||
#
|
||||
# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mas_syn2mas_migration() {
|
||||
local ssh_host=""
|
||||
local mas_container=""
|
||||
local synapse_config_path=""
|
||||
local log_dir=""
|
||||
local max_conflicts=0
|
||||
local do_apply=false
|
||||
|
||||
# ---- Parse args ----
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mas-container)
|
||||
mas_container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--synapse-config-path)
|
||||
synapse_config_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--log-dir)
|
||||
log_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--max-conflicts)
|
||||
max_conflicts="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
do_apply=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS)
|
||||
|
||||
Usage:
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host <host> \
|
||||
--mas-container <name> \
|
||||
--synapse-config-path <path-on-host> \
|
||||
--log-dir <local-path> \
|
||||
[--max-conflicts N] \
|
||||
[--apply]
|
||||
|
||||
Opciones:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--synapse-config-path Ruta en el VPS al homeserver.yaml
|
||||
(ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml)
|
||||
--log-dir Directorio local donde archivar logs dry-run y apply
|
||||
--max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0)
|
||||
--apply Ejecutar migracion real. Sin esta flag: solo dry-run.
|
||||
|
||||
Comportamiento:
|
||||
1. Siempre ejecuta dry-run primero y archiva el log.
|
||||
2. Si conflicts > max-conflicts -> status=aborted, exit 2.
|
||||
3. Sin --apply -> status=ok (dry-run completado), exit 0.
|
||||
4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion.
|
||||
|
||||
Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N}
|
||||
USAGE
|
||||
echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_syn2mas_migration: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- Validar argumentos obligatorios ----
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio")
|
||||
[[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio")
|
||||
[[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar que max_conflicts es un entero no negativo
|
||||
if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Dependencias locales ----
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Crear log-dir con permisos restringidos ----
|
||||
mkdir -p "$log_dir"
|
||||
chmod 0700 "$log_dir"
|
||||
|
||||
local ts
|
||||
ts=$(date +%s)
|
||||
|
||||
local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log"
|
||||
local apply_log_path="null"
|
||||
local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log"
|
||||
|
||||
# La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config
|
||||
# MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real
|
||||
# puede variar — usamos la ruta tal como existe en el host (montada via volume).
|
||||
# El comando real esperado: docker exec <container> mas-cli syn2mas --synapse-config <path>
|
||||
# donde <path> es la ruta tal como el container la ve (via volume mount).
|
||||
# Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container.
|
||||
local container_config="/data/homeserver.yaml"
|
||||
|
||||
echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 1: DRY-RUN obligatorio
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando dry-run..." >&2
|
||||
|
||||
local dry_exit=0
|
||||
# Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing
|
||||
local dry_output
|
||||
dry_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}' \
|
||||
--dry-run" \
|
||||
2>&1) || dry_exit=$?
|
||||
|
||||
# Archivar log con timestamp + header informativo
|
||||
{
|
||||
echo "# mas_syn2mas_migration dry-run"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${dry_exit}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$dry_output"
|
||||
} > "$dry_run_log"
|
||||
chmod 0600 "$dry_run_log"
|
||||
|
||||
echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2
|
||||
|
||||
if [[ $dry_exit -ne 0 ]]; then
|
||||
# Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad)
|
||||
echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2
|
||||
local escaped_out
|
||||
escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.')
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 2: Parsear conflicts del dry-run
|
||||
# =========================================================================
|
||||
# Regex sobre lineas tipo:
|
||||
# "Conflict:", "Skipping:", "Error processing user", "conflict"
|
||||
# También contamos líneas que indiquen usuarios problemáticos.
|
||||
local conflicts=0
|
||||
local conflict_lines
|
||||
conflict_lines=$(printf '%s\n' "$dry_output" | \
|
||||
grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true)
|
||||
|
||||
# grep -c devuelve string; convertir a int defensivamente
|
||||
if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then
|
||||
conflicts=$conflict_lines
|
||||
else
|
||||
# Parser falló de forma inesperada — abortar defensivamente
|
||||
echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 3: Verificar threshold de conflicts
|
||||
# =========================================================================
|
||||
if [[ $conflicts -gt $max_conflicts ]]; then
|
||||
echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2
|
||||
echo "Revisar: ${dry_run_log}" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado)
|
||||
# =========================================================================
|
||||
if [[ "$do_apply" == "false" ]]; then
|
||||
echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 5: Migracion REAL (--apply)
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2
|
||||
local apply_start
|
||||
apply_start=$(date +%s)
|
||||
|
||||
local apply_exit=0
|
||||
local apply_output
|
||||
apply_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}'" \
|
||||
2>&1) || apply_exit=$?
|
||||
|
||||
local apply_end
|
||||
apply_end=$(date +%s)
|
||||
local duration_s=$(( apply_end - apply_start ))
|
||||
|
||||
# Archivar log de apply
|
||||
{
|
||||
echo "# mas_syn2mas_migration apply"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${apply_exit} duration_s=${duration_s}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$apply_output"
|
||||
} > "$apply_log_file"
|
||||
chmod 0600 "$apply_log_file"
|
||||
|
||||
apply_log_path="$apply_log_file"
|
||||
echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2
|
||||
|
||||
if [[ $apply_exit -ne 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2
|
||||
|
||||
local mas_user_count=0
|
||||
local synapse_user_count=0
|
||||
local users_migrated=0
|
||||
local post_status="ok"
|
||||
|
||||
# Contar usuarios en MAS via mas-cli admin user list
|
||||
local mas_count_raw
|
||||
mas_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
mas_user_count=$mas_count_raw
|
||||
else
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2
|
||||
post_status="ok" # No abortar, solo advertir
|
||||
fi
|
||||
|
||||
# Contar usuarios locales en Synapse via psql (excluyendo bots/AS)
|
||||
# Intentamos obtener el count; si falla, continuamos sin abortar
|
||||
local synapse_count_raw
|
||||
synapse_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
synapse_user_count=$synapse_count_raw
|
||||
fi
|
||||
|
||||
users_migrated=$mas_user_count
|
||||
|
||||
# Si tenemos ambos counts y difieren significativamente, marcar como warning en log
|
||||
if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2
|
||||
post_status="error"
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 7: Emitir JSON final
|
||||
# =========================================================================
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
|
||||
echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_syn2mas_migration "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_syn2mas_migration
|
||||
# Verifica arg parsing sin conectar al VPS real.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/mas_syn2mas_migration.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_exit() {
|
||||
local test_name="$1" expected_exit="$2"
|
||||
shift 2
|
||||
local actual_exit=0
|
||||
set +e
|
||||
"$@" >/dev/null 2>&1
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if [[ "$actual_exit" == "$expected_exit" ]]; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_stdout_contains() {
|
||||
local test_name="$1" needle="$2"
|
||||
shift 2
|
||||
local output actual_exit=0
|
||||
set +e
|
||||
output=$("$@" 2>/dev/null)
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if echo "$output" | grep -q "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: aborta con error cuando faltan args obligatorios
|
||||
assert_exit "aborta con error cuando faltan args obligatorios" 1 \
|
||||
mas_syn2mas_migration
|
||||
|
||||
# Test: help no devuelve error
|
||||
assert_exit "help no devuelve error" 0 \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: argumento desconocido retorna exit 1
|
||||
assert_exit "argumento desconocido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration --unknown-flag
|
||||
|
||||
# Test: max-conflicts invalido retorna exit 1
|
||||
assert_exit "max-conflicts invalido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$" \
|
||||
--max-conflicts "not-a-number"
|
||||
|
||||
# Test: help emite JSON valido con status=help
|
||||
assert_stdout_contains "help emite JSON con status help" '"status":"help"' \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: falta --ssh-host emite JSON con status=error
|
||||
assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$"
|
||||
|
||||
# Test: falta --log-dir emite JSON con status=error
|
||||
assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml
|
||||
|
||||
# Limpieza
|
||||
rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -530,8 +530,26 @@ if(EXISTS ${_PROCESS_EXPLORER_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_PROCESS_EXPLORER_DIR} ${CMAKE_BINARY_DIR}/apps/process_explorer)
|
||||
endif()
|
||||
|
||||
# --- agents_dashboard (lives in projects/element_agents/apps/) ---
|
||||
set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/agents_dashboard)
|
||||
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
||||
endif()
|
||||
|
||||
# --- kanban_cpp (lives in apps/, issue 0096) ---
|
||||
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
|
||||
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
|
||||
endif()
|
||||
|
||||
# --- data_table_bench (lives in apps/, issue 0133) ---
|
||||
# Requires SQLite3 dev libs. Skip silently when not available (e.g. cross-windows build).
|
||||
set(_DATA_TABLE_BENCH_DIR ${CMAKE_SOURCE_DIR}/../apps/data_table_bench)
|
||||
if(EXISTS ${_DATA_TABLE_BENCH_DIR}/CMakeLists.txt)
|
||||
find_package(SQLite3 QUIET)
|
||||
if(SQLite3_FOUND)
|
||||
add_subdirectory(${_DATA_TABLE_BENCH_DIR} ${CMAKE_BINARY_DIR}/apps/data_table_bench)
|
||||
else()
|
||||
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
Submodule
+1
Submodule cpp/apps/chart_demo added at 026f514bb7
Submodule
+1
Submodule cpp/apps/shaders_lab added at dc9a970aff
@@ -0,0 +1,250 @@
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Paleta xterm-16 en ABGR (little-endian: R,G,B,A en memoria = RGBA8888 en lectura).
|
||||
// Index 0-7 colores normales, 8-15 brillantes, 16 = default.
|
||||
const uint32_t kPalette16[17] = {
|
||||
0xFF000000, // 0 black
|
||||
0xFF0000AA, // 1 red
|
||||
0xFF00AA00, // 2 green
|
||||
0xFF00AAAA, // 3 yellow (dark)
|
||||
0xFFAA0000, // 4 blue
|
||||
0xFFAA00AA, // 5 magenta
|
||||
0xFFAAAA00, // 6 cyan
|
||||
0xFFAAAAAA, // 7 white (light grey)
|
||||
0xFF555555, // 8 bright black (dark grey)
|
||||
0xFF5555FF, // 9 bright red
|
||||
0xFF55FF55, // 10 bright green
|
||||
0xFF55FFFF, // 11 bright yellow
|
||||
0xFFFF5555, // 12 bright blue
|
||||
0xFFFF55FF, // 13 bright magenta
|
||||
0xFFFFFF55, // 14 bright cyan
|
||||
0xFFFFFFFF, // 15 bright white
|
||||
0xFFCCCCCC, // 16 default (light grey)
|
||||
};
|
||||
|
||||
AnsiParser::AnsiParser() {
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::reset() {
|
||||
state_ = State::Ground;
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
process_byte(static_cast<unsigned char>(data[i]), cb);
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::flush_param() {
|
||||
if (param_count_ < kMaxParams) {
|
||||
params_[param_count_++] = cur_param_;
|
||||
}
|
||||
cur_param_ = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::apply_sgr(const std::function<void(const AnsiEvent&)>& /*cb*/) {
|
||||
// Si no hay params → reset (SGR 0).
|
||||
int n = (param_count_ == 0) ? 1 : param_count_;
|
||||
const int* p = (param_count_ == 0) ? nullptr : params_;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int code = (p ? p[i] : 0);
|
||||
if (code == 0) {
|
||||
// Reset todo
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
} else if (code == 1) {
|
||||
cur_bold_ = 1;
|
||||
} else if (code == 22) {
|
||||
cur_bold_ = 0;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 30);
|
||||
} else if (code == 39) {
|
||||
cur_fg_ = kColorDefault;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 40);
|
||||
} else if (code == 49) {
|
||||
cur_bg_ = kColorDefault;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 90 + 8);
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 100 + 8);
|
||||
}
|
||||
// Otros códigos ignorados silenciosamente (v1 anti-scope).
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
AnsiEvent ev;
|
||||
int p0 = (param_count_ > 0) ? params_[0] : 0;
|
||||
int p1 = (param_count_ > 1) ? params_[1] : 0;
|
||||
|
||||
switch (final_byte) {
|
||||
case 'H': case 'f': {
|
||||
// CUP: ESC [ row ; col H (1-based → convertir a 0-based)
|
||||
ev.type = AnsiEventType::CursorAbsolute;
|
||||
ev.cursor_abs.row = (p0 > 0 ? p0 - 1 : 0);
|
||||
ev.cursor_abs.col = (p1 > 0 ? p1 - 1 : 0);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Up;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'B': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Down;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'C': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Forward;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'D': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Back;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'J': {
|
||||
// ED: erase in display. Solo param=2 (clear screen) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseDisplay;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'K': {
|
||||
// EL: erase in line. Solo param=2 (clear entire line) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseLine;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'm': {
|
||||
// SGR: select graphic rendition.
|
||||
apply_sgr(cb);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Secuencia CSI desconocida — ignorar silenciosamente.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
switch (state_) {
|
||||
|
||||
case State::Ground:
|
||||
if (c == 0x1B) {
|
||||
state_ = State::Escape;
|
||||
} else if (c == '\r') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::CarriageReturn; cb(ev);
|
||||
} else if (c == '\n') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Newline; cb(ev);
|
||||
} else if (c == '\x08') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Backspace; cb(ev);
|
||||
} else if (c >= 0x20 && c < 0x7F) {
|
||||
// ASCII imprimible.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = static_cast<char32_t>(c);
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0xC0) {
|
||||
// Inicio de secuencia UTF-8 multi-byte.
|
||||
// En v1 mapeamos todo >= 0x80 a '?' para evitar complejidad Unicode.
|
||||
// TODO(0132): soporte Unicode completo en v2.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = U'?';
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0x80 && c < 0xC0) {
|
||||
// Continuation byte de UTF-8 → ignorar (fragmento de multi-byte).
|
||||
}
|
||||
// Otros control bytes (0x00-0x1F excl \r\n\x08\x1B) → ignorar.
|
||||
break;
|
||||
|
||||
case State::Escape:
|
||||
if (c == '[') {
|
||||
state_ = State::CsiEntry;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
} else {
|
||||
// Secuencia ESC desconocida (no-CSI) → volver a Ground.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiEntry:
|
||||
// Primer byte del CSI: puede ser un dígito, ';' o el final byte.
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = c - '0';
|
||||
state_ = State::CsiParam;
|
||||
} else if (c == ';') {
|
||||
// Parámetro vacío → valor 0.
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
state_ = State::CsiParam;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final inmediato sin parámetros.
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else if (c == '?') {
|
||||
// Modos privados (e.g. ESC[?25l cursor hide) → ignorar hasta final byte.
|
||||
// Permanecemos en CsiEntry esperando el final byte.
|
||||
} else {
|
||||
// Byte inesperado → abortar CSI.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiParam:
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = cur_param_ * 10 + (c - '0');
|
||||
} else if (c == ';') {
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final: flush último param y despachar.
|
||||
flush_param();
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else {
|
||||
// Byte inesperado → abortar.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
// ansi_parser — parser ANSI/VT100 minimo, byte-a-byte, sin heap allocs por evento.
|
||||
//
|
||||
// Soporta:
|
||||
// SGR: colores FG/BG 16 colores (30-37, 40-47, 90-97, 100-107), bold (1), reset (0).
|
||||
// CUP (H): cursor absolute position row,col.
|
||||
// CUU (A), CUD (B), CUF (C), CUB (D): cursor relative moves.
|
||||
// ED (J): erase in display (param=2 → clear screen).
|
||||
// EL (K): erase in line (param=2 → clear line).
|
||||
// Carriage Return (\r), Newline (\n), Backspace (\x08).
|
||||
// Text: caracteres imprimibles (excl. control bytes).
|
||||
//
|
||||
// No soportado (v1, anti-scope):
|
||||
// 256/24-bit color, italics, underline, Unicode wide, OSC, DCS, SOS, PM, APC,
|
||||
// CSI sequences > 16 parametros, character sets (SI/SO), private modes.
|
||||
//
|
||||
// Uso:
|
||||
// fn_term::AnsiParser p;
|
||||
// p.feed(data, n, [](const fn_term::AnsiEvent& ev) { /* handle */ });
|
||||
//
|
||||
// Thread-safety: NO. Cada instancia debe usarse desde un solo hilo.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Codigos de color ANSI → index 0-15 en paleta CGA/xterm-16.
|
||||
// 0-7: colores normales (black, red, green, yellow, blue, magenta, cyan, white)
|
||||
// 8-15: colores brillantes (idem + bright)
|
||||
// 16: color por defecto (FG o BG)
|
||||
static constexpr uint8_t kColorDefault = 16;
|
||||
|
||||
// Paleta xterm-16 en RGBA8888 (A=0xFF), misma que la mayoria de terminales.
|
||||
// Acceso: kPalette16[index], index in [0,15].
|
||||
extern const uint32_t kPalette16[17]; // [16] = color "default" (blanco/negro)
|
||||
|
||||
// Una celda del terminal virtual.
|
||||
struct AnsiCell {
|
||||
char32_t ch = U' '; // codepoint Unicode (solo BMP en v1)
|
||||
uint8_t fg = kColorDefault; // indice paleta 0-16 (16 = default)
|
||||
uint8_t bg = kColorDefault;
|
||||
uint8_t bold = 0;
|
||||
uint8_t _pad = 0;
|
||||
};
|
||||
|
||||
// Tipos de evento emitidos por el parser.
|
||||
enum class AnsiEventType : uint8_t {
|
||||
Char, // un caracter imprimible (AnsiEvent.cell.ch valido)
|
||||
CursorMove, // AnsiEvent.row / .col delta o absoluto segun subtype
|
||||
CursorAbsolute, // CUP: posicion absoluta 0-based (row, col)
|
||||
EraseDisplay, // ED(2): limpiar pantalla completa
|
||||
EraseLine, // EL(2): limpiar linea actual completa
|
||||
CarriageReturn, // \r
|
||||
Newline, // \n
|
||||
Backspace, // \x08
|
||||
};
|
||||
|
||||
// Subtipos de CursorMove.
|
||||
enum class CursorDir : uint8_t { Up, Down, Forward, Back };
|
||||
|
||||
struct AnsiEvent {
|
||||
AnsiEventType type;
|
||||
union {
|
||||
AnsiCell cell; // type == Char
|
||||
struct {
|
||||
CursorDir dir;
|
||||
int n; // pasos (>= 1)
|
||||
} cursor_rel; // type == CursorMove
|
||||
struct {
|
||||
int row; // 0-based
|
||||
int col; // 0-based
|
||||
} cursor_abs; // type == CursorAbsolute
|
||||
// EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace: sin datos extra.
|
||||
};
|
||||
|
||||
AnsiEvent() : type(AnsiEventType::Char), cell{} {}
|
||||
};
|
||||
|
||||
// Clase principal. Stateful — mantiene el estado del parser entre llamadas a feed().
|
||||
class AnsiParser {
|
||||
public:
|
||||
AnsiParser();
|
||||
~AnsiParser() = default;
|
||||
AnsiParser(const AnsiParser&) = delete;
|
||||
AnsiParser& operator=(const AnsiParser&) = delete;
|
||||
|
||||
// Procesa `n` bytes de `data`. Emite eventos via `cb` en orden.
|
||||
// cb puede ser llamada 0 o más veces por feed().
|
||||
// Sin alloc heap por byte ni por evento.
|
||||
void feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
|
||||
// Resetea el estado del parser (útil al limpiar pantalla).
|
||||
void reset();
|
||||
|
||||
// Atributos SGR actuales (se actualizan al procesar secuencias SGR).
|
||||
uint8_t current_fg() const { return cur_fg_; }
|
||||
uint8_t current_bg() const { return cur_bg_; }
|
||||
uint8_t current_bold() const { return cur_bold_; }
|
||||
|
||||
private:
|
||||
enum class State : uint8_t {
|
||||
Ground, // estado normal: procesar texto
|
||||
Escape, // recibido ESC
|
||||
CsiEntry, // recibido ESC [
|
||||
CsiParam, // acumulando parametros CSI
|
||||
};
|
||||
|
||||
State state_ = State::Ground;
|
||||
uint8_t cur_fg_ = kColorDefault;
|
||||
uint8_t cur_bg_ = kColorDefault;
|
||||
uint8_t cur_bold_ = 0;
|
||||
|
||||
// Buffer de parametros CSI (max 16 params de 4 digitos cada uno).
|
||||
static constexpr int kMaxParams = 16;
|
||||
int params_[kMaxParams];
|
||||
int param_count_ = 0;
|
||||
int cur_param_ = 0; // valor del param que se esta acumulando
|
||||
|
||||
void process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void flush_param();
|
||||
void dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void apply_sgr(const std::function<void(const AnsiEvent&)>& cb);
|
||||
};
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: ansi_parser
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "class fn_term::AnsiParser { void feed(const char* data, size_t n, const std::function<void(const fn_term::AnsiEvent&)>& cb); void reset(); uint8_t current_fg() const; uint8_t current_bg() const; uint8_t current_bold() const; }"
|
||||
description: "Parser ANSI/VT100 minimo byte-a-byte sin alloc heap por evento. Soporta SGR colores FG/BG 16-color + bold + reset, cursor moves (CUP/CUU/CUD/CUF/CUB), erase display/line (ED 2, EL 2), CR/LF/BS. Statemachine simple con 4 estados. Emite AnsiEvent via callback."
|
||||
tags: [ansi, vt100, terminal, parser, pure, state-machine, cpp-dashboard-viz]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [cstddef, cstdint, functional]
|
||||
tested: true
|
||||
tests:
|
||||
- "SGR reset sets default colors"
|
||||
- "SGR fg color 31 sets red"
|
||||
- "SGR bg color 44 sets blue background"
|
||||
- "SGR bright fg 91 sets bright red"
|
||||
- "SGR bold sets bold flag"
|
||||
- "cursor CUU moves up N"
|
||||
- "cursor CUF moves forward N"
|
||||
- "cursor CUP absolute position"
|
||||
- "erase display ED 2"
|
||||
- "erase line EL 2"
|
||||
- "mixed text and SGR sequence"
|
||||
- "newline and carriage return"
|
||||
test_file_path: "cpp/tests/test_ansi_parser.cpp"
|
||||
file_path: "cpp/functions/core/ansi_parser.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: data
|
||||
desc: "Puntero al buffer de bytes a procesar (output crudo de PTY/ConPTY)"
|
||||
- name: n
|
||||
desc: "Numero de bytes en data"
|
||||
- name: cb
|
||||
desc: "Callback invocado por cada evento emitido. Sin alloc — el AnsiEvent vive en el stack del parser"
|
||||
output: "Sin retorno directo. Eventos emitidos via callback: AnsiEventType::Char (caracter + atributos SGR actuales), CursorMove (relativo), CursorAbsolute (CUP), EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace"
|
||||
notes: "Usado por terminal_panel_cpp_viz como paso de parseo del output PTY. Anti-scope v1: sin 256/24-bit color, sin italics/underline, sin Unicode wide, sin OSC/DCS. UTF-8 multi-byte se mapea a '?' en v1."
|
||||
---
|
||||
|
||||
# ansi_parser
|
||||
|
||||
Parser ANSI/VT100 minimo para el modulo `terminal_panel`. Sin heap allocs por byte procesado — la maquina de estados vive en el objeto y los `AnsiEvent` se emiten por callback en el stack del caller.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
fn_term::AnsiParser parser;
|
||||
std::string output;
|
||||
|
||||
// Procesar output crudo de PTY:
|
||||
parser.feed(pty_buf, bytes_read, [&](const fn_term::AnsiEvent& ev) {
|
||||
if (ev.type == fn_term::AnsiEventType::Char) {
|
||||
// ev.cell.ch = codepoint, ev.cell.fg = color index 0-16
|
||||
output += static_cast<char>(ev.cell.ch);
|
||||
} else if (ev.type == fn_term::AnsiEventType::Newline) {
|
||||
output += '\n';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando procesas output crudo de un PTY (Linux forkpty) o ConPTY (Windows) y necesitas extraer texto + atributos de color para renderizar en ImGui con `PushStyleColor`. Es la capa de parseo de `terminal_panel`.
|
||||
|
||||
## Secuencias soportadas (v1)
|
||||
|
||||
| Tipo | Secuencia | AnsiEventType |
|
||||
|------|-----------|---------------|
|
||||
| Texto ASCII | bytes 0x20-0x7E | Char |
|
||||
| CR | `\r` (0x0D) | CarriageReturn |
|
||||
| LF | `\n` (0x0A) | Newline |
|
||||
| BS | `\x08` | Backspace |
|
||||
| SGR reset | `ESC[0m` o `ESC[m` | (actualiza estado interno) |
|
||||
| SGR bold | `ESC[1m` | (actualiza estado interno) |
|
||||
| SGR FG 16 | `ESC[30-37m`, `ESC[90-97m` | (actualiza estado interno) |
|
||||
| SGR BG 16 | `ESC[40-47m`, `ESC[100-107m` | (actualiza estado interno) |
|
||||
| Cursor UP | `ESC[nA` | CursorMove (Up, n) |
|
||||
| Cursor DOWN | `ESC[nB` | CursorMove (Down, n) |
|
||||
| Cursor FWD | `ESC[nC` | CursorMove (Forward, n) |
|
||||
| Cursor BACK | `ESC[nD` | CursorMove (Back, n) |
|
||||
| CUP | `ESC[r;cH` | CursorAbsolute (0-based) |
|
||||
| ED(2) | `ESC[2J` | EraseDisplay |
|
||||
| EL(2) | `ESC[2K` | EraseLine |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Anti-scope v1: no 256-color (`ESC[38;5;Nm`), no 24-bit color, no italics/underline, no curses pesados.
|
||||
- UTF-8 multi-byte: bytes de continuacion 0x80-0xBF ignorados; inicio 0xC0+ emite `?`. Soporte completo en v2.
|
||||
- No thread-safe: cada instancia debe usarse desde un solo hilo (el reader thread del PTY).
|
||||
- `kPalette16[16]` es el color "default" (gris claro). El caller decide si usar el color del tema o la paleta fija.
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "compute_column_stats.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -353,6 +355,59 @@ struct VizPanel {
|
||||
mutable ViewMode last_non_table = ViewMode::Bar;
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// StringPool — interning de strings para columnas de texto (issue 0133).
|
||||
// Una instancia por State (NOT global) para aislar tablas independientes.
|
||||
//
|
||||
// intern(sv) devuelve un indice uint32_t estable para la vida del rebuild.
|
||||
// El pool se limpia (clear()) al inicio de cada rebuild de snapshot columnar.
|
||||
//
|
||||
// Invariante de invalidacion de string_view:
|
||||
// - El vector `strings` se reserva con reserve() ANTES del primer intern()
|
||||
// para evitar reallocs que invalidarian los string_view del mapa.
|
||||
// Si la estimacion es insuficiente (columna con mas unicos de lo esperado),
|
||||
// el mapa se reconstruye post-push_back: intern() verifica cap antes de
|
||||
// insertar en el map para cubrir este caso.
|
||||
// ----------------------------------------------------------------------------
|
||||
struct StringPool {
|
||||
std::vector<std::string> strings; // strings unicos, por indice
|
||||
std::unordered_map<std::string_view, uint32_t> index; // sv→id (sv apunta a strings[i])
|
||||
|
||||
void clear() {
|
||||
strings.clear();
|
||||
index.clear();
|
||||
}
|
||||
|
||||
// intern: inserta si no existe. Devuelve indice estable.
|
||||
// INVARIANTE: reserve() ANTES del primer intern() por columna para evitar
|
||||
// reallocs que invalidarian los string_view del mapa. Si la estimacion fue
|
||||
// insuficiente, forzamos reserve(size+1) ANTES de emplace_back para que
|
||||
// la realloc ocurra antes de que cualquier sv del mapa apunte al buffer
|
||||
// viejo — y reconstruimos el mapa desde cero tras la realloc.
|
||||
uint32_t intern(std::string_view sv) {
|
||||
auto it = index.find(sv);
|
||||
if (it != index.end()) return it->second;
|
||||
uint32_t id = (uint32_t)strings.size();
|
||||
if (strings.size() == strings.capacity()) {
|
||||
// Realloc inminente: hacerlo ANTES de insertar en index para que
|
||||
// los string_view existentes no queden dangling. Tras el reserve,
|
||||
// reconstruimos el index desde cero porque los punteros cambiaron.
|
||||
strings.reserve(strings.capacity() == 0 ? 64 : strings.capacity() * 2);
|
||||
index.clear();
|
||||
for (uint32_t i = 0; i < (uint32_t)strings.size(); ++i)
|
||||
index.emplace(std::string_view(strings[i]), i);
|
||||
}
|
||||
strings.emplace_back(sv);
|
||||
// string_view apunta al almacenamiento interno (strings[id]), estable
|
||||
// porque acabamos de garantizar capacidad suficiente.
|
||||
index.emplace(std::string_view(strings[id]), id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const std::string& at(uint32_t id) const { return strings[id]; }
|
||||
bool empty() const { return strings.empty(); }
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// State: stage pipeline + viz globales.
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -419,6 +474,11 @@ struct State {
|
||||
std::vector<DrillStep> drill_back;
|
||||
std::vector<DrillStep> drill_forward;
|
||||
|
||||
// String interning pool (issue 0133, Change 2).
|
||||
// Limpiado y repoblado en cada rebuild del snapshot columnar.
|
||||
// NOT global — una instancia por State para aislar tablas independientes.
|
||||
StringPool string_pool;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
|
||||
@@ -269,8 +269,21 @@ Response request(const Request& req) {
|
||||
}
|
||||
|
||||
cmd << ' ' << sh_q(req.url)
|
||||
<< " -o " << sh_q(tmp_body_out)
|
||||
<< " 2>&1";
|
||||
<< " -o " << sh_q(tmp_body_out);
|
||||
|
||||
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
|
||||
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
|
||||
// extra positional arg to curl, which treats it as a second URL → "Bad
|
||||
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
|
||||
#ifndef _WIN32
|
||||
cmd << " 2>&1";
|
||||
#endif
|
||||
|
||||
if (std::getenv("FN_HTTP_DEBUG")) {
|
||||
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
|
||||
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
|
||||
req.url.c_str(), req.url.size());
|
||||
}
|
||||
|
||||
// Capture stderr (curl prints transport errors to stderr with -sS).
|
||||
std::string curl_stderr;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// secret_store.cpp — implementation of fn_secret (issue 0129).
|
||||
//
|
||||
// See secret_store.h for API docs and platform notes.
|
||||
|
||||
#include "infra/secret_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
# include <wincrypt.h>
|
||||
# pragma comment(lib, "crypt32.lib")
|
||||
#endif
|
||||
|
||||
namespace fn_secret {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 helpers (no external deps, RFC 4648 alphabet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const char kB64Chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static std::string base64_encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
uint32_t b = (uint32_t)data[i] << 16;
|
||||
if (i + 1 < len) b |= (uint32_t)data[i + 1] << 8;
|
||||
if (i + 2 < len) b |= (uint32_t)data[i + 2];
|
||||
out += kB64Chars[(b >> 18) & 63];
|
||||
out += kB64Chars[(b >> 12) & 63];
|
||||
out += (i + 1 < len) ? kB64Chars[(b >> 6) & 63] : '=';
|
||||
out += (i + 2 < len) ? kB64Chars[(b) & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> base64_decode(const std::string& s) {
|
||||
auto decode_char = [](char c) -> int {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
};
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(s.size() * 3 / 4);
|
||||
for (size_t i = 0; i + 3 < s.size(); i += 4) {
|
||||
int a = decode_char(s[i]);
|
||||
int b = decode_char(s[i + 1]);
|
||||
int c = decode_char(s[i + 2]);
|
||||
int d = decode_char(s[i + 3]);
|
||||
if (a < 0 || b < 0) break;
|
||||
out.push_back((uint8_t)((a << 2) | (b >> 4)));
|
||||
if (c >= 0) out.push_back((uint8_t)((b << 4) | (c >> 2)));
|
||||
if (d >= 0) out.push_back((uint8_t)((c << 2) | d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linux fallback: XOR with a stable per-user key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#ifndef _WIN32
|
||||
static std::vector<uint8_t> linux_key() {
|
||||
// Key = first 32 bytes of SHA-256-like mixing of LOGNAME + HOSTNAME.
|
||||
// Good enough to prevent casual plaintext inspection; NOT crypto-secure.
|
||||
const char* user = getenv("LOGNAME");
|
||||
const char* host = getenv("HOSTNAME");
|
||||
if (!user) user = "user";
|
||||
if (!host) host = "localhost";
|
||||
std::string seed = std::string(user) + "@" + host + ":fn_agents_dashboard_key_v1";
|
||||
std::vector<uint8_t> key(32, 0);
|
||||
for (size_t i = 0; i < seed.size(); i++) {
|
||||
key[i % 32] ^= (uint8_t)seed[i];
|
||||
key[(i + 7) % 32] += (uint8_t)(seed[i] * 31 + i);
|
||||
key[(i + 13) % 32] ^= (uint8_t)(seed[i] + i * 7);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool is_strong() {
|
||||
#ifdef _WIN32
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encrypt(const std::string& plaintext) {
|
||||
#ifdef _WIN32
|
||||
DATA_BLOB in_blob { (DWORD)plaintext.size(),
|
||||
(BYTE*)const_cast<char*>(plaintext.data()) };
|
||||
DATA_BLOB out_blob {};
|
||||
if (!CryptProtectData(&in_blob, L"fn_agents_dashboard", nullptr,
|
||||
nullptr, nullptr, 0, &out_blob)) {
|
||||
return {};
|
||||
}
|
||||
std::vector<uint8_t> result(out_blob.pbData,
|
||||
out_blob.pbData + out_blob.cbData);
|
||||
LocalFree(out_blob.pbData);
|
||||
return result;
|
||||
#else
|
||||
// Linux: 1-byte magic + XOR
|
||||
std::vector<uint8_t> key = linux_key();
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(1 + plaintext.size());
|
||||
out.push_back(0xAF); // magic marker
|
||||
for (size_t i = 0; i < plaintext.size(); i++) {
|
||||
out.push_back((uint8_t)plaintext[i] ^ key[i % key.size()]);
|
||||
}
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string decrypt(const std::vector<uint8_t>& blob) {
|
||||
if (blob.empty()) return {};
|
||||
#ifdef _WIN32
|
||||
DATA_BLOB in_blob { (DWORD)blob.size(),
|
||||
(BYTE*)const_cast<uint8_t*>(blob.data()) };
|
||||
DATA_BLOB out_blob {};
|
||||
if (!CryptUnprotectData(&in_blob, nullptr, nullptr,
|
||||
nullptr, nullptr, 0, &out_blob)) {
|
||||
return {};
|
||||
}
|
||||
std::string result(reinterpret_cast<char*>(out_blob.pbData),
|
||||
out_blob.cbData);
|
||||
LocalFree(out_blob.pbData);
|
||||
return result;
|
||||
#else
|
||||
// Linux: check magic, XOR decode
|
||||
if (blob[0] != 0xAF) return {};
|
||||
std::vector<uint8_t> key = linux_key();
|
||||
std::string out;
|
||||
out.reserve(blob.size() - 1);
|
||||
for (size_t i = 1; i < blob.size(); i++) {
|
||||
out += (char)(blob[i] ^ key[(i - 1) % key.size()]);
|
||||
}
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string encrypt_b64(const std::string& plaintext) {
|
||||
auto blob = encrypt(plaintext);
|
||||
if (blob.empty()) return {};
|
||||
return base64_encode(blob.data(), blob.size());
|
||||
}
|
||||
|
||||
std::string decrypt_b64(const std::string& b64) {
|
||||
auto blob = base64_decode(b64);
|
||||
return decrypt(blob);
|
||||
}
|
||||
|
||||
} // namespace fn_secret
|
||||
@@ -0,0 +1,37 @@
|
||||
// secret_store.h — encrypt/decrypt sensitive strings for local storage.
|
||||
//
|
||||
// Windows: uses DPAPI (CryptProtectData / CryptUnprotectData).
|
||||
// The encrypted blob is bound to the current user account on the local
|
||||
// machine. Key never leaves the machine. The blob can be stored in
|
||||
// SQLite as a BLOB column.
|
||||
//
|
||||
// Linux/WSL fallback: XOR-encode with a stable per-user key derived from
|
||||
// username + hostname. NOT cryptographically strong — but prevents
|
||||
// plaintext credentials sitting in SQLite and shows a warning in the UI.
|
||||
// Production use should switch to libsecret / KDE Wallet on Linux.
|
||||
//
|
||||
// Part of issue 0129 (agents_dashboard credential storage).
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_secret {
|
||||
|
||||
// Encrypt `plaintext` into an opaque blob suitable for storage in a BLOB column.
|
||||
// Returns empty vector on failure; never throws.
|
||||
std::vector<uint8_t> encrypt(const std::string& plaintext);
|
||||
|
||||
// Decrypt a blob produced by `encrypt()`.
|
||||
// Returns empty string on failure (wrong key, corrupted data, etc.).
|
||||
std::string decrypt(const std::vector<uint8_t>& blob);
|
||||
|
||||
// Convenience: encrypt returns base64 string for TEXT storage.
|
||||
std::string encrypt_b64(const std::string& plaintext);
|
||||
std::string decrypt_b64(const std::string& b64);
|
||||
|
||||
// Returns true if running with strong DPAPI encryption (Windows).
|
||||
// Returns false on Linux fallback — callers may show a warning.
|
||||
bool is_strong();
|
||||
|
||||
} // namespace fn_secret
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: secret_store
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "fn_secret::encrypt(plaintext) -> vector<uint8_t>; fn_secret::decrypt(blob) -> string; fn_secret::is_strong() -> bool"
|
||||
description: "Encrypt/decrypt sensitive strings for local SQLite storage. Windows: DPAPI (user-bound, machine-local, cryptographically strong). Linux/WSL fallback: XOR with per-user seed key (not crypto-secure, shows warning). Used by agents_dashboard to store API keys."
|
||||
tags: [security, credentials, dpapi, encrypt, infra, agents]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [infra/secret_store.h]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/infra/secret_store.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: plaintext
|
||||
desc: "Sensitive string to encrypt (API key, password, token)."
|
||||
- name: blob
|
||||
desc: "Opaque byte vector returned by encrypt(), stored as SQLite BLOB column."
|
||||
output: "encrypt returns vector<uint8_t> blob (empty on failure). decrypt returns plaintext string (empty on failure). is_strong() returns true on Windows (DPAPI), false on Linux (XOR fallback)."
|
||||
---
|
||||
|
||||
# secret_store
|
||||
|
||||
Encrypt/decrypt sensitive credentials for local SQLite storage.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "infra/secret_store.h"
|
||||
|
||||
// Store API key encrypted:
|
||||
std::vector<uint8_t> blob = fn_secret::encrypt("my-api-key-here");
|
||||
// Insert blob into SQLite BLOB column via sqlite3_bind_blob()...
|
||||
|
||||
// Recover:
|
||||
std::string key = fn_secret::decrypt(blob);
|
||||
|
||||
// Base64 helpers for TEXT columns:
|
||||
std::string b64 = fn_secret::encrypt_b64("my-api-key-here");
|
||||
std::string back = fn_secret::decrypt_b64(b64);
|
||||
|
||||
// Platform check (show warning on Linux):
|
||||
if (!fn_secret::is_strong()) {
|
||||
fn_log::warn("[security] apikey stored with weak Linux fallback encryption");
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de guardar una API key, token o contrasena en SQLite local. Siempre usar `fn::local_path("app.db")` para la DB. En Windows (DPAPI) la clave nunca sale de la maquina. En Linux, mostrar aviso en UI de que la proteccion es basica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **DPAPI is Windows-only**: el blob cifrado en Windows NO se puede descifrar en Linux y viceversa. Si el usuario mueve la DB entre plataformas, las credenciales se pierden — debe reingresar la apikey.
|
||||
- **Linux fallback NO es criptograficamente seguro**: XOR con semilla derivada de username+hostname. Previene lectura casual pero no protege contra atacante con acceso al sistema.
|
||||
- **CryptProtectData es sincrono**: no llamar desde el thread principal con datos grandes. Para una apikey (tipicamente <200 bytes) el coste es despreciable.
|
||||
- Linkear `crypt32.lib` en Windows: el `.cpp` tiene `#pragma comment(lib, "crypt32.lib")` — no necesita entry en CMakeLists para MSVC. Con MinGW se enlaza automaticamente si se incluye `wincrypt.h`.
|
||||
@@ -0,0 +1,308 @@
|
||||
// terminal_panel.cpp — render + process_output + shared logic.
|
||||
// Los backends (open/close/send) viven en terminal_panel_linux.cpp
|
||||
// y terminal_panel_windows.cpp respectivamente.
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
#include "core/tokens.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
// Convierte índice de color fn_term (0-16) a ImU32 RGBA para ImGui.
|
||||
// Usa la paleta kPalette16; fg=16 (default) → color de texto del tema ImGui.
|
||||
ImU32 color_to_imu32(uint8_t idx, bool is_fg) {
|
||||
if (idx == kColorDefault) {
|
||||
// Usar color del tema: FG → Text, BG → transparente.
|
||||
if (is_fg) return ImGui::GetColorU32(ImGuiCol_Text);
|
||||
return IM_COL32(0, 0, 0, 0); // transparente
|
||||
}
|
||||
// kPalette16 está en formato ABGR (little-endian), ImU32 también es ABGR en ImGui.
|
||||
return static_cast<ImU32>(kPalette16[idx]);
|
||||
}
|
||||
|
||||
// Renderiza una línea del scrollback con colores.
|
||||
// Toma la línea como vector<AnsiCell> y escribe chunks de mismo color.
|
||||
void render_line(const TermLine& line) {
|
||||
if (line.empty()) {
|
||||
ImGui::NewLine();
|
||||
return;
|
||||
}
|
||||
|
||||
// Agrupar celdas consecutivas con mismo fg/bg/bold y emitir como texto.
|
||||
// Usamos un buffer temporal de la pila para evitar alloacs por línea.
|
||||
static char buf[4096];
|
||||
|
||||
size_t i = 0;
|
||||
while (i < line.size()) {
|
||||
uint8_t fg = line[i].fg;
|
||||
uint8_t bg = line[i].bg;
|
||||
// uint8_t bold = line[i].bold; // TODO(0132): bold rendering v2
|
||||
|
||||
// Acumular chars con mismo estilo.
|
||||
size_t j = i;
|
||||
int pos = 0;
|
||||
while (j < line.size() && line[j].fg == fg && line[j].bg == bg) {
|
||||
char32_t ch = line[j].ch;
|
||||
if (ch >= 0x20 && ch < 0x7F && pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = static_cast<char>(ch);
|
||||
} else if (ch != U' ' && pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = '?'; // no-ASCII en v1
|
||||
} else if (pos < (int)sizeof(buf) - 2) {
|
||||
buf[pos++] = ' ';
|
||||
}
|
||||
j++;
|
||||
}
|
||||
buf[pos] = '\0';
|
||||
|
||||
// Push color FG.
|
||||
ImU32 fg_col = color_to_imu32(fg, true);
|
||||
bool has_fg = (fg != kColorDefault);
|
||||
if (has_fg) ImGui::PushStyleColor(ImGuiCol_Text, fg_col);
|
||||
|
||||
// Fondo: si BG definido, usar InvisibleButton + DrawList rect antes del texto.
|
||||
// En v1 simplificamos: solo coloreamos el texto (FG). BG requiere DrawList.
|
||||
// TODO(0132): renderizar celdas BG con InvisibleButton + DrawList en v2.
|
||||
|
||||
ImGui::TextUnformatted(buf, buf + pos);
|
||||
|
||||
if (has_fg) ImGui::PopStyleColor();
|
||||
|
||||
// Continuar en la misma línea si hay más celdas.
|
||||
if (j < line.size()) ImGui::SameLine(0.0f, 0.0f);
|
||||
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TerminalPanel::TerminalPanel() {
|
||||
// Reservar una línea inicial vacía.
|
||||
lines.emplace_back();
|
||||
}
|
||||
|
||||
TerminalPanel::~TerminalPanel() {
|
||||
if (is_open()) close(*this);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process_output — llamado desde el reader thread.
|
||||
// Parsea los bytes via AnsiParser y actualiza el scrollback buffer.
|
||||
// ---------------------------------------------------------------------------
|
||||
void process_output(TerminalPanel& panel, const char* data, size_t n) {
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
|
||||
panel.parser.feed(data, n, [&](const AnsiEvent& ev) {
|
||||
switch (ev.type) {
|
||||
case AnsiEventType::Char: {
|
||||
// Asegurar que tenemos al menos cur_row+1 filas.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
TermLine& line = panel.lines[panel.cur_row];
|
||||
// Asegurar que la fila tiene al menos cur_col+1 celdas.
|
||||
while ((int)line.size() <= panel.cur_col)
|
||||
line.push_back(AnsiCell{});
|
||||
line[panel.cur_col] = ev.cell;
|
||||
panel.cur_col++;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::Newline: {
|
||||
panel.cur_row++;
|
||||
// Scrollback circular: si excede el límite, eliminar la primera fila.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
if ((int)panel.lines.size() > panel.scrollback_lines) {
|
||||
int excess = (int)panel.lines.size() - panel.scrollback_lines;
|
||||
panel.lines.erase(panel.lines.begin(),
|
||||
panel.lines.begin() + excess);
|
||||
panel.cur_row -= excess;
|
||||
if (panel.cur_row < 0) panel.cur_row = 0;
|
||||
}
|
||||
panel.scroll_to_bottom = true;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CarriageReturn: {
|
||||
panel.cur_col = 0;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::Backspace: {
|
||||
if (panel.cur_col > 0) panel.cur_col--;
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CursorAbsolute: {
|
||||
panel.cur_row = std::max(0, ev.cursor_abs.row);
|
||||
panel.cur_col = std::max(0, ev.cursor_abs.col);
|
||||
// Extender líneas si necesario.
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::CursorMove: {
|
||||
switch (ev.cursor_rel.dir) {
|
||||
case CursorDir::Up:
|
||||
panel.cur_row = std::max(0, panel.cur_row - ev.cursor_rel.n);
|
||||
break;
|
||||
case CursorDir::Down:
|
||||
panel.cur_row += ev.cursor_rel.n;
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
break;
|
||||
case CursorDir::Forward:
|
||||
panel.cur_col += ev.cursor_rel.n;
|
||||
break;
|
||||
case CursorDir::Back:
|
||||
panel.cur_col = std::max(0, panel.cur_col - ev.cursor_rel.n);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::EraseDisplay: {
|
||||
panel.lines.clear();
|
||||
panel.lines.emplace_back();
|
||||
panel.cur_row = 0;
|
||||
panel.cur_col = 0;
|
||||
panel.parser.reset();
|
||||
break;
|
||||
}
|
||||
case AnsiEventType::EraseLine: {
|
||||
while ((int)panel.lines.size() <= panel.cur_row)
|
||||
panel.lines.emplace_back();
|
||||
panel.lines[panel.cur_row].clear();
|
||||
panel.cur_col = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render — debe llamarse dentro de un frame ImGui activo.
|
||||
// ---------------------------------------------------------------------------
|
||||
void render(TerminalPanel& panel) {
|
||||
// --- Toolbar ---
|
||||
ImGui::PushID("##term_toolbar");
|
||||
|
||||
if (ImGui::SmallButton("Clear")) {
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
panel.lines.clear();
|
||||
panel.lines.emplace_back();
|
||||
panel.cur_row = 0;
|
||||
panel.cur_col = 0;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::SmallButton("Copy")) {
|
||||
// Copiar todo el scrollback como texto plano al portapapeles.
|
||||
std::string text;
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
for (const auto& line : panel.lines) {
|
||||
for (const auto& cell : line) {
|
||||
if (cell.ch >= 0x20 && cell.ch < 0x7F)
|
||||
text += static_cast<char>(cell.ch);
|
||||
else if (cell.ch != U' ')
|
||||
text += '?';
|
||||
else
|
||||
text += ' ';
|
||||
}
|
||||
text += '\n';
|
||||
}
|
||||
ImGui::SetClipboardText(text.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::SmallButton("Reset") && panel.is_open()) {
|
||||
fn_term::close(panel);
|
||||
fn_term::open(panel);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
bool lock = !panel.scroll_to_bottom;
|
||||
if (ImGui::Checkbox("Lock scroll", &lock)) {
|
||||
panel.scroll_to_bottom = !lock;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
// Indicador de estado del proceso.
|
||||
if (!panel.is_open()) {
|
||||
ImGui::TextDisabled("[closed]");
|
||||
} else if (panel.process_exited.load()) {
|
||||
ImGui::TextDisabled("[exited %d]", panel.exit_code);
|
||||
} else {
|
||||
ImGui::TextDisabled("[running]");
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// --- Scrollback area — fondo negro con texto gris claro ---
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
|
||||
// Reservar hueco para el input prompt si no es readonly.
|
||||
// GetFrameHeightWithSpacing() cubre una línea de InputText + padding.
|
||||
const float input_reserve = (!panel.readonly)
|
||||
? (ImGui::GetFrameHeightWithSpacing() + 6.0f)
|
||||
: 0.0f;
|
||||
float child_h = std::max(avail.y - input_reserve, 32.0f);
|
||||
|
||||
// Estilos del area terminal: fondo casi negro + texto gris claro.
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(10, 10, 10, 255));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(220, 220, 220, 255));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f));
|
||||
|
||||
ImGui::BeginChild("##term_scroll", ImVec2(0, child_h),
|
||||
ImGuiChildFlags_Borders,
|
||||
ImGuiWindowFlags_HorizontalScrollbar);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(panel.buf_mutex);
|
||||
// Usar un clipper para evitar renderizar líneas fuera de vista.
|
||||
ImGuiListClipper clipper;
|
||||
clipper.Begin((int)panel.lines.size());
|
||||
while (clipper.Step()) {
|
||||
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) {
|
||||
render_line(panel.lines[i]);
|
||||
}
|
||||
}
|
||||
clipper.End();
|
||||
}
|
||||
|
||||
if (panel.scroll_to_bottom && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::PopStyleVar(); // WindowPadding
|
||||
ImGui::PopStyleColor(2); // ChildBg + Text
|
||||
|
||||
// --- Input prompt (visible siempre que readonly=false) ---
|
||||
if (!panel.readonly) {
|
||||
// Mostrar un prefijo "$ " antes del input box.
|
||||
ImGui::TextUnformatted("$ ");
|
||||
ImGui::SameLine(0.0f, 4.0f);
|
||||
|
||||
static char s_input[1024] = {};
|
||||
ImGui::SetNextItemWidth(-1.0f);
|
||||
|
||||
// Si el shell está cerrado, desactivar el input.
|
||||
if (!panel.is_open()) ImGui::BeginDisabled();
|
||||
bool enter = ImGui::InputText("##term_input", s_input, sizeof(s_input),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
if (!panel.is_open()) ImGui::EndDisabled();
|
||||
|
||||
if (enter && panel.is_open()) {
|
||||
std::string cmd = std::string(s_input) + "\n";
|
||||
fn_term::send(panel, cmd);
|
||||
s_input[0] = '\0';
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
// terminal_panel — emulador TTY embebible en ImGui.
|
||||
//
|
||||
// Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows) y
|
||||
// renderiza su output en un child window ImGui con soporte basico de ANSI:
|
||||
// colores FG/BG 16-color, bold, cursor pos, clear screen/line.
|
||||
//
|
||||
// Uso basico:
|
||||
// static fn_term::TerminalPanel term;
|
||||
// term.shell = "/bin/bash";
|
||||
//
|
||||
// if (!term.is_open()) fn_term::open(term);
|
||||
// fn_term::render(term);
|
||||
// if (!term.readonly) fn_term::send(term, "ls\n");
|
||||
// // Al cerrar:
|
||||
// fn_term::close(term);
|
||||
//
|
||||
// Thread-safety: open/render/send/close deben llamarse desde el hilo ImGui.
|
||||
// El reader thread interno es gestionado por la implementacion.
|
||||
//
|
||||
// Plataformas:
|
||||
// Linux/macOS: terminal_panel_linux.cpp (forkpty + read no-blocking en thread)
|
||||
// Windows: terminal_panel_windows.cpp (ConPTY CreatePseudoConsole)
|
||||
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Una linea del scrollback: vector de celdas ya parseadas.
|
||||
using TermLine = std::vector<AnsiCell>;
|
||||
|
||||
// Configuracion y estado del panel.
|
||||
struct TerminalPanel {
|
||||
// --- Config (set antes de open(), no cambiar en vivo) ---
|
||||
std::string shell; // "" → auto-detect (/bin/bash linux, cmd.exe windows)
|
||||
std::string cwd; // "" → directorio actual del proceso padre
|
||||
std::vector<std::string> env; // KEY=VAL adicionales al entorno heredado
|
||||
int scrollback_lines = 5000; // max filas en el ring buffer
|
||||
bool readonly = false; // si true, no reenvía input del teclado
|
||||
|
||||
// --- Estado interno (gestionado por open/close/render) ---
|
||||
// No modificar directamente.
|
||||
|
||||
// Proceso hijo
|
||||
int child_pid = -1; // Linux: PID del hijo; -1 si no abierto
|
||||
int master_fd = -1; // Linux: fd del extremo master del PTY
|
||||
void* proc_handle = nullptr; // Windows: HANDLE del proceso hijo (HANDLE)
|
||||
void* pty_handle = nullptr; // Windows: HPCON (ConPTY handle)
|
||||
void* pipe_read = nullptr; // Windows: HANDLE pipe de lectura
|
||||
void* pipe_write = nullptr; // Windows: HANDLE pipe de escritura (→ stdin del hijo)
|
||||
|
||||
// Reader thread
|
||||
std::thread reader_thread;
|
||||
std::atomic<bool> reader_running{false};
|
||||
|
||||
// Scrollback buffer (protegido por mutex)
|
||||
mutable std::mutex buf_mutex;
|
||||
std::vector<TermLine> lines; // buffer circular de lineas
|
||||
int cur_row = 0; // fila del cursor dentro de `lines`
|
||||
int cur_col = 0; // columna del cursor
|
||||
bool scroll_to_bottom = true;
|
||||
|
||||
// Parser ANSI (solo lo toca el reader thread)
|
||||
AnsiParser parser;
|
||||
|
||||
// Flag: proceso hijo terminó
|
||||
std::atomic<bool> process_exited{false};
|
||||
int exit_code = 0;
|
||||
|
||||
// ctor/dtor
|
||||
TerminalPanel();
|
||||
~TerminalPanel();
|
||||
TerminalPanel(const TerminalPanel&) = delete;
|
||||
TerminalPanel& operator=(const TerminalPanel&) = delete;
|
||||
|
||||
bool is_open() const { return master_fd >= 0 || pipe_read != nullptr; }
|
||||
};
|
||||
|
||||
// Abre el proceso hijo y arranca el reader thread.
|
||||
// Llama una sola vez antes del primer render.
|
||||
// Si falla, loguea via fn_log::log_error y deja is_open() == false.
|
||||
void open(TerminalPanel& panel);
|
||||
|
||||
// Renderiza el terminal en el area disponible de ImGui.
|
||||
// Debe llamarse dentro de un frame ImGui activo.
|
||||
// Dibuja toolbar (clear, copy, reset, scroll-lock) + scrollback + input.
|
||||
void render(TerminalPanel& panel);
|
||||
|
||||
// Envía texto al stdin del proceso hijo.
|
||||
// No-op si !is_open() o readonly.
|
||||
void send(TerminalPanel& panel, const std::string& text);
|
||||
|
||||
// Cierra el proceso hijo, espera al reader thread y libera recursos.
|
||||
void close(TerminalPanel& panel);
|
||||
|
||||
// ---- Internals usados por los backends Linux/Windows ----
|
||||
// (No llamar directamente desde apps.)
|
||||
|
||||
// Procesa un chunk de bytes del PTY y los añade al scrollback.
|
||||
// Llamado desde el reader thread. Thread-safe via buf_mutex.
|
||||
void process_output(TerminalPanel& panel, const char* data, size_t n);
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: terminal_panel
|
||||
kind: component
|
||||
lang: cpp
|
||||
domain: viz
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "void fn_term::open(fn_term::TerminalPanel& panel); void fn_term::render(fn_term::TerminalPanel& panel); void fn_term::send(fn_term::TerminalPanel& panel, const std::string& text); void fn_term::close(fn_term::TerminalPanel& panel);"
|
||||
description: "Emulador TTY embebible en ImGui. Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows 10 v1809+), renderiza el scrollback con colores ANSI 16-color, toolbar (clear/copy/reset/scroll-lock) e input box. Scrollback circular configurable. Soporte readonly para tail-only."
|
||||
tags: [terminal, pty, conpty, imgui, viz, ansi, shell, cpp-dashboard-viz]
|
||||
uses_functions: [ansi_parser_cpp_core, logger_cpp_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [atomic, functional, mutex, string, thread, vector]
|
||||
tested: true
|
||||
tests:
|
||||
- "smoke: spawn echo hello and exit, scrollback contains hello"
|
||||
test_file_path: "cpp/tests/test_terminal_panel_smoke.cpp"
|
||||
file_path: "cpp/functions/viz/terminal_panel/terminal_panel.cpp"
|
||||
framework: imgui
|
||||
params:
|
||||
- name: panel
|
||||
desc: "Struct TerminalPanel con config (shell, cwd, env, scrollback_lines, readonly) y estado interno gestionado por open/close/render"
|
||||
output: "render() dibuja toolbar + scrollback con colores ANSI + input box en el area ImGui disponible. open() arranca el proceso hijo y el reader thread. send() escribe texto al stdin del hijo. close() mata el proceso y libera recursos."
|
||||
notes: "Linux: requiere -lutil (libutil) para forkpty. Windows: requiere Windows SDK >= 17763 (v1809) para ConPTY. Si el SDK es anterior, open() loguea error y deja is_open()==false. Anti-scope v1: sin tabs multiples, sin SSH, sin curses pesados (vim/htop)."
|
||||
---
|
||||
|
||||
# terminal_panel
|
||||
|
||||
Emulador TTY embebible en ImGui. Util para: tail de logs en una app de monitoring, ejecutar comandos shell desde un panel de kanban, ver output de compilaciones, consola de debug de agentes.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
|
||||
static fn_term::TerminalPanel s_term;
|
||||
|
||||
void render_panel() {
|
||||
// Abrir al primer frame.
|
||||
if (!s_term.is_open()) {
|
||||
s_term.shell = "/bin/bash";
|
||||
s_term.scrollback_lines = 2000;
|
||||
fn_term::open(s_term);
|
||||
}
|
||||
fn_term::render(s_term);
|
||||
}
|
||||
|
||||
// Tail readonly de un log:
|
||||
static fn_term::TerminalPanel s_log_tail;
|
||||
|
||||
void render_log_tail() {
|
||||
if (!s_log_tail.is_open()) {
|
||||
s_log_tail.shell = "/bin/bash";
|
||||
s_log_tail.readonly = true;
|
||||
fn_term::open(s_log_tail);
|
||||
fn_term::send(s_log_tail, "tail -f /tmp/agent.log\n");
|
||||
}
|
||||
fn_term::render(s_log_tail);
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas ver output crudo de un proceso (shell, compilacion, curl, tail) sin salir de la app ImGui. Alternativa a abrir un terminal externo. Especialmente util en apps de monitoring (services_monitor, agents_dashboard) y kanban panels de build.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Linux**: el CMakeLists del consumidor debe linkar `-lutil` (o `target_link_libraries(... util)`) para resolver `forkpty`.
|
||||
- **Windows**: requiere Windows 10 v1809+ (SDK >= 17763). Si el SDK es anterior, `open()` deja el panel cerrado y loguea error — no hay panic ni crash.
|
||||
- **Anti-scope v1**: sin soporte de curses pesados (vim, htop, top). El parser ANSI maneja SGR color + cursor básico; programas que usen el modo altscreen o muchas secuencias de cursor se verán mal.
|
||||
- **Scrollback circular**: cuando `lines.size() > scrollback_lines`, se elimina la primera fila. Esto puede causar saltos visuales si el contenido se está acumulando muy rápido (ej. `yes "x"`). En v1 el target es 60fps con scrollback de 5000 líneas.
|
||||
- **Thread safety**: `render()` toma el `buf_mutex` por el tiempo del render de cada frame. El reader thread también lo toma al actualizar el buffer. En condiciones normales no hay contención significativa.
|
||||
- **readonly**: si `true`, no se renderiza el input box y `send()` es no-op. Útil para `tail -f` o procesos que no necesitan stdin.
|
||||
@@ -0,0 +1,180 @@
|
||||
// terminal_panel_linux.cpp — backend PTY para Linux/macOS.
|
||||
// Compilado solo en plataformas no-Windows.
|
||||
//
|
||||
// Implementacion: forkpty() crea el proceso hijo con un PTY maestro/esclavo.
|
||||
// Un thread de lectura en background lee del fd maestro de forma no-bloqueante
|
||||
// y llama process_output() para actualizar el scrollback buffer.
|
||||
|
||||
#ifndef _WIN32
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <pty.h> // forkpty — requiere -lutil en Linux
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
// Detecta el shell por defecto: $SHELL o /bin/bash como fallback.
|
||||
std::string default_shell() {
|
||||
const char* sh = std::getenv("SHELL");
|
||||
return sh ? sh : "/bin/bash";
|
||||
}
|
||||
|
||||
// Thread de lectura: lee del fd maestro del PTY en bloques y
|
||||
// llama process_output. Termina cuando el proceso hijo cierra el PTY
|
||||
// (read devuelve 0 o EIO) o cuando reader_running se pone a false.
|
||||
void reader_thread_fn(TerminalPanel* panel) {
|
||||
char buf[4096];
|
||||
while (panel->reader_running.load()) {
|
||||
ssize_t n = ::read(panel->master_fd, buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
process_output(*panel, buf, static_cast<size_t>(n));
|
||||
} else if (n == 0) {
|
||||
// EOF: el proceso hijo cerró el PTY.
|
||||
break;
|
||||
} else {
|
||||
// EIO ocurre cuando el proceso hijo sale y cierra el esclavo.
|
||||
if (errno == EIO || errno == EBADF) break;
|
||||
if (errno == EINTR) continue;
|
||||
// Otro error transitorio: esperar un poco y reintentar.
|
||||
usleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Recolectar el código de salida del hijo.
|
||||
if (panel->child_pid > 0) {
|
||||
int status = 0;
|
||||
::waitpid(panel->child_pid, &status, WNOHANG);
|
||||
if (WIFEXITED(status))
|
||||
panel->exit_code = WEXITSTATUS(status);
|
||||
else if (WIFSIGNALED(status))
|
||||
panel->exit_code = -WTERMSIG(status);
|
||||
}
|
||||
panel->process_exited.store(true);
|
||||
panel->reader_running.store(false);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void open(TerminalPanel& panel) {
|
||||
if (panel.is_open()) return;
|
||||
|
||||
std::string sh = panel.shell.empty() ? default_shell() : panel.shell;
|
||||
|
||||
// Construir argv.
|
||||
const char* argv[] = {sh.c_str(), nullptr};
|
||||
|
||||
// Construir envp: heredar entorno + extras.
|
||||
// Para simplicidad en v1, pasamos nullptr (hereda el entorno completo)
|
||||
// y añadimos las variables extra via setenv antes del fork.
|
||||
// TODO(0132): construir envp completo en v2.
|
||||
|
||||
struct winsize ws;
|
||||
ws.ws_row = 24;
|
||||
ws.ws_col = 80;
|
||||
ws.ws_xpixel = 0;
|
||||
ws.ws_ypixel = 0;
|
||||
|
||||
int master_fd = -1;
|
||||
pid_t pid = forkpty(&master_fd, nullptr, nullptr, &ws);
|
||||
|
||||
if (pid < 0) {
|
||||
fn_log::log_error("terminal_panel: forkpty failed: %s", strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// Proceso hijo.
|
||||
// Aplicar variables de entorno extra.
|
||||
for (const auto& kv : panel.env) {
|
||||
const auto eq = kv.find('=');
|
||||
if (eq != std::string::npos) {
|
||||
std::string key = kv.substr(0, eq);
|
||||
std::string val = kv.substr(eq + 1);
|
||||
::setenv(key.c_str(), val.c_str(), 1);
|
||||
}
|
||||
}
|
||||
// Cambiar directorio de trabajo si se especificó.
|
||||
if (!panel.cwd.empty()) {
|
||||
if (::chdir(panel.cwd.c_str()) != 0) {
|
||||
// No es fatal — continuar desde el cwd heredado.
|
||||
}
|
||||
}
|
||||
::execvp(sh.c_str(), const_cast<char* const*>(argv));
|
||||
// Si execvp falla, el hijo muere.
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
// Proceso padre.
|
||||
// Poner el fd maestro en modo no-bloqueante.
|
||||
int flags = ::fcntl(master_fd, F_GETFL, 0);
|
||||
::fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
panel.master_fd = master_fd;
|
||||
panel.child_pid = pid;
|
||||
panel.process_exited.store(false);
|
||||
panel.reader_running.store(true);
|
||||
panel.reader_thread = std::thread(reader_thread_fn, &panel);
|
||||
|
||||
fn_log::log_info("terminal_panel: opened shell '%s' pid=%d", sh.c_str(), pid);
|
||||
}
|
||||
|
||||
void send(TerminalPanel& panel, const std::string& text) {
|
||||
if (!panel.is_open() || panel.readonly) return;
|
||||
if (text.empty()) return;
|
||||
const char* p = text.c_str();
|
||||
ssize_t rem = static_cast<ssize_t>(text.size());
|
||||
while (rem > 0) {
|
||||
ssize_t n = ::write(panel.master_fd, p, static_cast<size_t>(rem));
|
||||
if (n <= 0) {
|
||||
if (errno == EINTR) continue;
|
||||
fn_log::log_error("terminal_panel: write to pty failed: %s", strerror(errno));
|
||||
break;
|
||||
}
|
||||
p += n;
|
||||
rem -= n;
|
||||
}
|
||||
}
|
||||
|
||||
void close(TerminalPanel& panel) {
|
||||
// Señalar al reader thread que pare.
|
||||
panel.reader_running.store(false);
|
||||
|
||||
// Cerrar el fd maestro del PTY; esto hace que el hijo reciba HUP.
|
||||
if (panel.master_fd >= 0) {
|
||||
::close(panel.master_fd);
|
||||
panel.master_fd = -1;
|
||||
}
|
||||
|
||||
// Matar al hijo si sigue vivo.
|
||||
if (panel.child_pid > 0) {
|
||||
::kill(panel.child_pid, SIGTERM);
|
||||
int status = 0;
|
||||
// Esperar hasta 200 ms; si no terminó, SIGKILL.
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (::waitpid(panel.child_pid, &status, WNOHANG) > 0) break;
|
||||
usleep(10000);
|
||||
}
|
||||
::kill(panel.child_pid, SIGKILL);
|
||||
::waitpid(panel.child_pid, &status, 0);
|
||||
panel.child_pid = -1;
|
||||
}
|
||||
|
||||
// Esperar al reader thread.
|
||||
if (panel.reader_thread.joinable()) panel.reader_thread.join();
|
||||
|
||||
fn_log::log_info("terminal_panel: closed");
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
|
||||
#endif // !_WIN32
|
||||
@@ -0,0 +1,244 @@
|
||||
// terminal_panel_windows.cpp — backend ConPTY para Windows.
|
||||
// Compilado solo en plataformas Windows (_WIN32).
|
||||
//
|
||||
// Implementacion: CreatePseudoConsole (ConPTY, Windows 10 v1809+) +
|
||||
// CreateProcess + ReadFile en thread de lectura.
|
||||
//
|
||||
// Si ConPTY no está disponible (Windows < 10 v1809), cae a un stub que
|
||||
// reporta error y deja is_open() == false.
|
||||
//
|
||||
// TODO(0132): fallback CreatePipe sin PTY para Windows < v1809.
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
#include "core/logger.h"
|
||||
|
||||
// Incluir Windows.h con defines minimos para evitar conflictos con ImGui.
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
// ConPTY: disponible en Windows SDK >= 17763 (v1809).
|
||||
// Si el SDK no tiene ConPTY, definimos stubs minimos para que compile.
|
||||
#if defined(NTDDI_WIN10_RS5) && NTDDI_VERSION >= NTDDI_WIN10_RS5
|
||||
# define FN_CONPTY_AVAILABLE 1
|
||||
# include <consoleapi3.h>
|
||||
# include <processthreadsapi.h>
|
||||
#else
|
||||
# define FN_CONPTY_AVAILABLE 0
|
||||
// Stub para evitar errores de compilacion en SDKs viejos.
|
||||
typedef VOID* HPCON;
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string default_shell_windows() {
|
||||
// Preferir PowerShell si está disponible; fallback a cmd.exe.
|
||||
char buf[MAX_PATH] = {};
|
||||
if (ExpandEnvironmentStringsA("%COMSPEC%", buf, sizeof(buf)) > 0 && buf[0] != '\0')
|
||||
return buf;
|
||||
return "cmd.exe";
|
||||
}
|
||||
|
||||
#if FN_CONPTY_AVAILABLE
|
||||
|
||||
// Thread de lectura: lee del pipe de salida del ConPTY en bloques.
|
||||
DWORD WINAPI reader_thread_fn(LPVOID param) {
|
||||
auto* panel = static_cast<TerminalPanel*>(param);
|
||||
char buf[4096];
|
||||
DWORD bytes_read = 0;
|
||||
while (panel->reader_running.load()) {
|
||||
BOOL ok = ReadFile(static_cast<HANDLE>(panel->pipe_read),
|
||||
buf, sizeof(buf), &bytes_read, nullptr);
|
||||
if (ok && bytes_read > 0) {
|
||||
process_output(*panel, buf, static_cast<size_t>(bytes_read));
|
||||
} else {
|
||||
DWORD err = GetLastError();
|
||||
if (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) break;
|
||||
if (!ok) {
|
||||
fn_log::log_error("terminal_panel: ReadFile error %lu", err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recolectar código de salida.
|
||||
if (panel->proc_handle) {
|
||||
DWORD exit_code = 0;
|
||||
GetExitCodeProcess(static_cast<HANDLE>(panel->proc_handle), &exit_code);
|
||||
panel->exit_code = static_cast<int>(exit_code);
|
||||
}
|
||||
panel->process_exited.store(true);
|
||||
panel->reader_running.store(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // FN_CONPTY_AVAILABLE
|
||||
|
||||
} // namespace
|
||||
|
||||
void open(TerminalPanel& panel) {
|
||||
if (panel.is_open()) return;
|
||||
|
||||
#if !FN_CONPTY_AVAILABLE
|
||||
fn_log::log_error("terminal_panel: ConPTY not available on this Windows SDK version");
|
||||
// TODO(0132): fallback a CreatePipe sin PTY
|
||||
return;
|
||||
#else
|
||||
std::string sh = panel.shell.empty() ? default_shell_windows() : panel.shell;
|
||||
|
||||
// Crear dos pares de pipes: una para PTY→app (lectura) y otra para app→PTY (escritura).
|
||||
HANDLE hPipeIn_Read = nullptr; // PTY lee desde aqui (stdin del proceso hijo)
|
||||
HANDLE hPipeIn_Write = nullptr; // app escribe aqui
|
||||
HANDLE hPipeOut_Read = nullptr; // app lee desde aqui (stdout del proceso hijo)
|
||||
HANDLE hPipeOut_Write= nullptr; // PTY escribe aqui
|
||||
|
||||
SECURITY_ATTRIBUTES sa;
|
||||
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
|
||||
sa.bInheritHandle = FALSE;
|
||||
sa.lpSecurityDescriptor = nullptr;
|
||||
|
||||
if (!CreatePipe(&hPipeIn_Read, &hPipeIn_Write, &sa, 0) ||
|
||||
!CreatePipe(&hPipeOut_Read, &hPipeOut_Write, &sa, 0)) {
|
||||
fn_log::log_error("terminal_panel: CreatePipe failed: %lu", GetLastError());
|
||||
return;
|
||||
}
|
||||
|
||||
// Crear ConPTY.
|
||||
COORD consoleSize;
|
||||
consoleSize.X = 80;
|
||||
consoleSize.Y = 24;
|
||||
HPCON hPC = nullptr;
|
||||
HRESULT hr = CreatePseudoConsole(consoleSize, hPipeIn_Read, hPipeOut_Write, 0, &hPC);
|
||||
if (FAILED(hr)) {
|
||||
fn_log::log_error("terminal_panel: CreatePseudoConsole failed: hr=0x%08lX", hr);
|
||||
CloseHandle(hPipeIn_Read);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
CloseHandle(hPipeOut_Write);
|
||||
return;
|
||||
}
|
||||
|
||||
// Los extremos del ConPTY (hPipeIn_Read + hPipeOut_Write) ya no los necesitamos.
|
||||
CloseHandle(hPipeIn_Read);
|
||||
CloseHandle(hPipeOut_Write);
|
||||
|
||||
// Preparar STARTUPINFOEX con el ConPTY.
|
||||
SIZE_T attrListSize = 0;
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &attrListSize);
|
||||
auto* attrList = static_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(
|
||||
HeapAlloc(GetProcessHeap(), 0, attrListSize));
|
||||
if (!attrList || !InitializeProcThreadAttributeList(attrList, 1, 0, &attrListSize)) {
|
||||
fn_log::log_error("terminal_panel: InitializeProcThreadAttributeList failed");
|
||||
ClosePseudoConsole(hPC);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
if (attrList) HeapFree(GetProcessHeap(), 0, attrList);
|
||||
return;
|
||||
}
|
||||
UpdateProcThreadAttribute(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
|
||||
hPC, sizeof(HPCON), nullptr, nullptr);
|
||||
|
||||
STARTUPINFOEXA siEx = {};
|
||||
siEx.StartupInfo.cb = sizeof(STARTUPINFOEXA);
|
||||
siEx.lpAttributeList = attrList;
|
||||
|
||||
PROCESS_INFORMATION pi = {};
|
||||
// cmd es la cadena de comando (mutable, CreateProcessA la modifica en algunos casos).
|
||||
std::string cmd = sh;
|
||||
if (!CreateProcessA(nullptr, &cmd[0], nullptr, nullptr, FALSE,
|
||||
EXTENDED_STARTUPINFO_PRESENT, nullptr,
|
||||
panel.cwd.empty() ? nullptr : panel.cwd.c_str(),
|
||||
&siEx.StartupInfo, &pi)) {
|
||||
fn_log::log_error("terminal_panel: CreateProcess failed: %lu", GetLastError());
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
HeapFree(GetProcessHeap(), 0, attrList);
|
||||
ClosePseudoConsole(hPC);
|
||||
CloseHandle(hPipeIn_Write);
|
||||
CloseHandle(hPipeOut_Read);
|
||||
return;
|
||||
}
|
||||
|
||||
// El thread handle del hijo no lo necesitamos.
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
HeapFree(GetProcessHeap(), 0, attrList);
|
||||
|
||||
panel.pty_handle = static_cast<void*>(hPC);
|
||||
panel.pipe_read = static_cast<void*>(hPipeOut_Read);
|
||||
panel.pipe_write = static_cast<void*>(hPipeIn_Write);
|
||||
panel.proc_handle = static_cast<void*>(pi.hProcess);
|
||||
panel.process_exited.store(false);
|
||||
panel.reader_running.store(true);
|
||||
|
||||
// Arrancar el reader thread via CreateThread (evitamos std::thread con WINAPI).
|
||||
HANDLE hThread = CreateThread(nullptr, 0, reader_thread_fn, &panel, 0, nullptr);
|
||||
if (!hThread) {
|
||||
fn_log::log_error("terminal_panel: CreateThread failed: %lu", GetLastError());
|
||||
// No fatal — el panel queda en estado parcial; close() limpiará.
|
||||
} else {
|
||||
// Convertir el HANDLE a std::thread via native_handle trick no es portable.
|
||||
// Para integración con std::thread::join(), usamos un wrapper.
|
||||
// En v1: detachamos el thread y usamos el atomic reader_running como señal.
|
||||
CloseHandle(hThread);
|
||||
// TODO(0132): migrar a std::thread para poder join() correctamente.
|
||||
}
|
||||
|
||||
fn_log::log_info("terminal_panel: opened shell '%s' pid=%lu",
|
||||
sh.c_str(), static_cast<unsigned long>(pi.dwProcessId));
|
||||
#endif // FN_CONPTY_AVAILABLE
|
||||
}
|
||||
|
||||
void send(TerminalPanel& panel, const std::string& text) {
|
||||
#if !FN_CONPTY_AVAILABLE
|
||||
(void)panel; (void)text;
|
||||
#else
|
||||
if (!panel.is_open() || panel.readonly || text.empty()) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(static_cast<HANDLE>(panel.pipe_write),
|
||||
text.c_str(), static_cast<DWORD>(text.size()), &written, nullptr);
|
||||
#endif
|
||||
}
|
||||
|
||||
void close(TerminalPanel& panel) {
|
||||
panel.reader_running.store(false);
|
||||
|
||||
#if FN_CONPTY_AVAILABLE
|
||||
if (panel.pipe_write) {
|
||||
CloseHandle(static_cast<HANDLE>(panel.pipe_write));
|
||||
panel.pipe_write = nullptr;
|
||||
}
|
||||
if (panel.pipe_read) {
|
||||
CloseHandle(static_cast<HANDLE>(panel.pipe_read));
|
||||
panel.pipe_read = nullptr;
|
||||
}
|
||||
if (panel.proc_handle) {
|
||||
TerminateProcess(static_cast<HANDLE>(panel.proc_handle), 0);
|
||||
WaitForSingleObject(static_cast<HANDLE>(panel.proc_handle), 500);
|
||||
CloseHandle(static_cast<HANDLE>(panel.proc_handle));
|
||||
panel.proc_handle = nullptr;
|
||||
}
|
||||
if (panel.pty_handle) {
|
||||
ClosePseudoConsole(static_cast<HPCON>(panel.pty_handle));
|
||||
panel.pty_handle = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Esperar al reader thread si está joinable.
|
||||
if (panel.reader_thread.joinable()) panel.reader_thread.join();
|
||||
|
||||
fn_log::log_info("terminal_panel: closed (windows)");
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -316,3 +316,20 @@ add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp
|
||||
add_fn_test(test_sse_client test_sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
||||
|
||||
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
|
||||
add_fn_test(test_ansi_parser test_ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
|
||||
|
||||
# --- Issue 0132 — terminal_panel smoke: spawn real PTY (Linux only) ---
|
||||
# En Windows: todos los casos se skipean via SKIP(). En Linux necesita -lutil.
|
||||
# Linkamos fn_framework para obtener logger.cpp (fn_log) + imgui + implot.
|
||||
add_fn_test(test_terminal_panel_smoke test_terminal_panel_smoke.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_linux.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_windows.cpp)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE fn_framework)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE util)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""E2E tests for terminal_panel demos in primitives_gallery.
|
||||
|
||||
Lanza primitives_gallery en modo --capture, captura el demo "terminal_panel"
|
||||
como PNG y verifica que la region del terminal tiene fondo oscuro (fix del
|
||||
issue 0132: fondo negro + prompt input).
|
||||
|
||||
Uso desde la raiz del registry:
|
||||
python/.venv/bin/python3 -m pytest cpp/tests/e2e/test_terminal_panel_e2e.py -v
|
||||
|
||||
Requisitos:
|
||||
- primitives_gallery compilado (Linux o Windows .exe).
|
||||
- WSL2 con interop habilitado para el path Windows.
|
||||
- Pillow instalado en el venv del registry (python/.venv).
|
||||
|
||||
En entornos sin GL (CI headless), el binario sale != 0 y el test se skipea
|
||||
automaticamente (SKIP, no FAIL).
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de localizacion del binario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REGISTRY_ROOT = Path(__file__).resolve().parents[3] # fn_registry/
|
||||
|
||||
|
||||
def _find_binary() -> Path | None:
|
||||
"""Devuelve el primer primitives_gallery encontrado (Linux o Windows)."""
|
||||
# Paths fijos conocidos primero.
|
||||
candidates = [
|
||||
REGISTRY_ROOT / "cpp" / "build" / "apps" / "primitives_gallery" / "primitives_gallery",
|
||||
REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps" / "primitives_gallery" / "primitives_gallery",
|
||||
REGISTRY_ROOT / "cpp" / "build" / "windows" / "apps" / "primitives_gallery" / "primitives_gallery.exe",
|
||||
# Desktop de Windows (deploy anterior)
|
||||
Path("/mnt/c/Users/lucas/Desktop/apps/primitives_gallery/primitives_gallery.exe"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p
|
||||
# Busqueda amplia como fallback.
|
||||
for pattern in ("primitives_gallery", "primitives_gallery.exe"):
|
||||
for found in (REGISTRY_ROOT / "cpp" / "build").rglob(pattern):
|
||||
if found.is_file():
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: captura PNG del demo terminal_panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def terminal_png(tmp_path_factory) -> Path:
|
||||
"""Lanza primitives_gallery --capture y devuelve el PNG generado."""
|
||||
binary = _find_binary()
|
||||
if binary is None:
|
||||
pytest.skip("primitives_gallery binary not found — build it first")
|
||||
|
||||
out_dir = tmp_path_factory.mktemp("terminal_capture")
|
||||
|
||||
# En WSL, un .exe Windows necesita invocarse como proceso Windows.
|
||||
# En Linux, se invoca directamente con LIBGL_ALWAYS_SOFTWARE=1.
|
||||
env = os.environ.copy()
|
||||
is_windows_exe = binary.suffix == ".exe"
|
||||
|
||||
if is_windows_exe:
|
||||
# Convertir el out_dir a path Windows via wslpath.
|
||||
wslpath_result = subprocess.run(
|
||||
["wslpath", "-w", str(out_dir)],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if wslpath_result.returncode != 0:
|
||||
pytest.skip("wslpath not available — can't convert path for Windows exe")
|
||||
win_out_dir = wslpath_result.stdout.strip()
|
||||
cmd = [str(binary), "--capture", win_out_dir]
|
||||
else:
|
||||
env["LIBGL_ALWAYS_SOFTWARE"] = "1"
|
||||
cmd = [str(binary), "--capture", str(out_dir)]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=str(REGISTRY_ROOT),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Sin GL o sin display — skip en lugar de FAIL.
|
||||
pytest.skip(
|
||||
f"primitives_gallery --capture exited {result.returncode} "
|
||||
f"(no GL context?). stdout: {result.stdout[-200:]} "
|
||||
f"stderr: {result.stderr[-200:]}"
|
||||
)
|
||||
|
||||
png_path = out_dir / "terminal_panel.png"
|
||||
if not png_path.exists():
|
||||
pytest.skip(f"terminal_panel.png not generated in {out_dir}. "
|
||||
f"stdout: {result.stdout[-300:]}")
|
||||
|
||||
return png_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_terminal_panel_png_exists(terminal_png: Path):
|
||||
"""El PNG del demo terminal_panel debe existir despues del capture."""
|
||||
assert terminal_png.exists(), f"PNG not found: {terminal_png}"
|
||||
assert terminal_png.stat().st_size > 1000, "PNG sospechosamente pequeño"
|
||||
|
||||
|
||||
def test_terminal_panel_not_all_white(terminal_png: Path):
|
||||
"""La imagen no debe ser completamente blanca (render vacio)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
total = w * h
|
||||
white_count = sum(
|
||||
1
|
||||
for y in range(h)
|
||||
for x in range(w)
|
||||
if px[x, y][0] > 240 and px[x, y][1] > 240 and px[x, y][2] > 240 # type: ignore[index]
|
||||
)
|
||||
white_ratio = white_count / total
|
||||
|
||||
assert white_ratio < 0.95, (
|
||||
f"Image is {white_ratio:.1%} white — terminal render likely failed. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_panel_dark_background(terminal_png: Path):
|
||||
"""La region central del terminal debe ser mayormente oscura (fondo negro fix 0132)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
w, h = img.size
|
||||
|
||||
# Recortar la region central-inferior (donde vive el scrollback del terminal).
|
||||
# El demo header ocupa ~15% superior; el resto deberia ser el area del terminal.
|
||||
# Ajustar: top=20%, bottom=85%, left=10%, right=90%.
|
||||
left = int(w * 0.10)
|
||||
right = int(w * 0.90)
|
||||
top = int(h * 0.20)
|
||||
bottom = int(h * 0.85)
|
||||
|
||||
region = img.crop((left, top, right, bottom))
|
||||
rw, rh = region.size
|
||||
total = rw * rh
|
||||
|
||||
if total == 0:
|
||||
pytest.skip("Crop region empty — image too small?")
|
||||
|
||||
rpx = region.load()
|
||||
# Pixel oscuro: todos los canales RGB < 60.
|
||||
dark_count = sum(
|
||||
1
|
||||
for y in range(rh)
|
||||
for x in range(rw)
|
||||
if rpx[x, y][0] < 60 and rpx[x, y][1] < 60 and rpx[x, y][2] < 60 # type: ignore[index]
|
||||
)
|
||||
dark_ratio = dark_count / total
|
||||
|
||||
assert dark_ratio >= 0.30, (
|
||||
f"Terminal region has only {dark_ratio:.1%} dark pixels (expected >= 30%). "
|
||||
f"The black background fix (issue 0132) may not be active. "
|
||||
f"Region: ({left},{top})-({right},{bottom}) in {w}x{h} image. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_panel_has_light_text_on_dark(terminal_png: Path):
|
||||
"""Debe haber pixels claros (texto/toolbar) sobre fondo oscuro — render activo.
|
||||
|
||||
En modo --capture el PTY reader es async y puede no entregar output en los
|
||||
primeros frames. Verificamos que al menos la toolbar (Clear/Copy/Reset) y el
|
||||
borde del child tienen pixels no-negros (> 0.3% de la imagen total), lo que
|
||||
confirma que el panel se renderizo.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pytest.skip("Pillow not installed — run: pip install Pillow")
|
||||
|
||||
img = Image.open(terminal_png).convert("RGB")
|
||||
pixels = img.load()
|
||||
w, h = img.size
|
||||
total = w * h
|
||||
|
||||
# Contar pixels con al menos un canal > 60 en toda la imagen.
|
||||
# Incluye la toolbar (botones), bordes, prompt "$ " y cualquier output.
|
||||
light_count = sum(
|
||||
1
|
||||
for y in range(h)
|
||||
for x in range(w)
|
||||
if max(pixels[x, y]) > 60 # type: ignore[index]
|
||||
)
|
||||
light_ratio = light_count / total
|
||||
|
||||
# Umbral conservador: > 0.3% — basta con que la toolbar sea visible.
|
||||
# En modo interactivo con PTY output el ratio sera mucho mayor (> 5%).
|
||||
assert light_ratio >= 0.003, (
|
||||
f"Image has only {light_ratio:.2%} non-dark pixels — "
|
||||
f"terminal panel may not be rendering at all. "
|
||||
f"Check that fn_term::render is called and ImGui window is visible. "
|
||||
f"({terminal_png})"
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
// test_ansi_parser.cpp — tests unitarios para fn_term::AnsiParser.
|
||||
//
|
||||
// Logica pura: no requiere ImGui ni contexto GL. Cubre:
|
||||
// - SGR: reset, FG color, BG color, bright colors, bold
|
||||
// - Cursor moves: CUU/CUD/CUF/CUB, CUP
|
||||
// - ED(2) erase display, EL(2) erase line
|
||||
// - Texto normal + secuencias mixtas
|
||||
// - CR, LF, BS
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_term;
|
||||
|
||||
// Helper: parsea una cadena y colecta los eventos.
|
||||
static std::vector<AnsiEvent> parse(const std::string& s) {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) {
|
||||
evs.push_back(ev);
|
||||
});
|
||||
return evs;
|
||||
}
|
||||
|
||||
// Helper: obtiene estados SGR después de parsear (sin eventos de salida).
|
||||
struct SgrState { uint8_t fg; uint8_t bg; uint8_t bold; };
|
||||
static SgrState parse_sgr(const std::string& s) {
|
||||
AnsiParser p;
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
return {p.current_fg(), p.current_bg(), p.current_bold()};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SGR tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SGR reset sets default colors", "[ansi_parser][sgr]") {
|
||||
// Primero ponemos FG rojo, luego reset.
|
||||
auto st = parse_sgr("\x1b[31m\x1b[0m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
REQUIRE(st.bg == kColorDefault);
|
||||
REQUIRE(st.bold == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR fg color 31 sets red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[31m");
|
||||
REQUIRE(st.fg == 1); // rojo = index 1
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bg color 44 sets blue background", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[44m");
|
||||
REQUIRE(st.bg == 4); // azul = index 4
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bright fg 91 sets bright red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[91m");
|
||||
REQUIRE(st.fg == 9); // bright red = index 8+1 = 9
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bold sets bold flag", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[1m");
|
||||
REQUIRE(st.bold == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR reset via bare ESC[m", "[ansi_parser][sgr]") {
|
||||
// ESC [ m sin parametro = reset
|
||||
auto st = parse_sgr("\x1b[31m\x1b[m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor move tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("cursor CUU moves up N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[3A");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Up);
|
||||
REQUIRE(evs[0].cursor_rel.n == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUF moves forward N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[5C");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Forward);
|
||||
REQUIRE(evs[0].cursor_rel.n == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUB moves back 1 when no param", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[D");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Back);
|
||||
REQUIRE(evs[0].cursor_rel.n == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP absolute position", "[ansi_parser][cursor]") {
|
||||
// ESC[5;10H → row=4, col=9 (0-based)
|
||||
auto evs = parse("\x1b[5;10H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 4);
|
||||
REQUIRE(evs[0].cursor_abs.col == 9);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP default params (ESC[H) = origin", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 0);
|
||||
REQUIRE(evs[0].cursor_abs.col == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erase tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("erase display ED 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2J");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseDisplay);
|
||||
}
|
||||
|
||||
TEST_CASE("erase line EL 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2K");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseLine);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control chars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("newline and carriage return", "[ansi_parser][control]") {
|
||||
auto evs = parse("\r\n");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CarriageReturn);
|
||||
REQUIRE(evs[1].type == AnsiEventType::Newline);
|
||||
}
|
||||
|
||||
TEST_CASE("backspace emits Backspace event", "[ansi_parser][control]") {
|
||||
auto evs = parse("\x08");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Backspace);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text + mixed sequences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("plain text emits Char events", "[ansi_parser][text]") {
|
||||
auto evs = parse("hi");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'h');
|
||||
REQUIRE(evs[1].cell.ch == U'i');
|
||||
}
|
||||
|
||||
TEST_CASE("mixed text and SGR sequence", "[ansi_parser][mixed]") {
|
||||
// "A" con FG rojo, luego reset, luego "B".
|
||||
auto evs = parse("\x1b[31mA\x1b[0mB");
|
||||
// Debemos tener exactamente 2 eventos Char: A (fg=1) y B (fg=default).
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'A');
|
||||
REQUIRE(evs[0].cell.fg == 1); // rojo
|
||||
REQUIRE(evs[1].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[1].cell.ch == U'B');
|
||||
REQUIRE(evs[1].cell.fg == kColorDefault);
|
||||
}
|
||||
|
||||
TEST_CASE("char inherits current SGR attrs", "[ansi_parser][sgr]") {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
// Poner BG azul, luego emitir texto.
|
||||
std::string s = "\x1b[44mX";
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) { evs.push_back(ev); });
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].cell.ch == U'X');
|
||||
REQUIRE(evs[0].cell.bg == 4); // azul
|
||||
}
|
||||
|
||||
TEST_CASE("unknown CSI final byte ignored silently", "[ansi_parser][robustness]") {
|
||||
// ESC [ Z es desconocido — no debe emitir nada ni crashear.
|
||||
auto evs = parse("a\x1b[Zb");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].cell.ch == U'a');
|
||||
REQUIRE(evs[1].cell.ch == U'b');
|
||||
}
|
||||
|
||||
TEST_CASE("incomplete escape at end of buffer", "[ansi_parser][robustness]") {
|
||||
// Buffer termina a mitad de una secuencia — no debe crashear.
|
||||
AnsiParser p;
|
||||
std::string s1 = "\x1b[3";
|
||||
std::string s2 = "1m";
|
||||
p.feed(s1.c_str(), s1.size(), [](const AnsiEvent&) {});
|
||||
p.feed(s2.c_str(), s2.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1); // FG rojo aplicado correctamente
|
||||
}
|
||||
|
||||
TEST_CASE("reset() clears state", "[ansi_parser][reset]") {
|
||||
AnsiParser p;
|
||||
std::string s = "\x1b[31m"; // FG rojo
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1);
|
||||
p.reset();
|
||||
REQUIRE(p.current_fg() == kColorDefault);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// test_terminal_panel_smoke.cpp — smoke test para terminal_panel.
|
||||
//
|
||||
// Prueba real del PTY en Linux: spawn "echo hello && exit 0",
|
||||
// espera output, verifica que el scrollback contiene "hello".
|
||||
//
|
||||
// En Windows: test skipped (ConPTY require DISPLAY y proceso vivo — CI).
|
||||
// En Linux sin forkpty: verifica que el build es correcto al menos.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
// En Windows en CI, skipeamos el smoke del proceso real.
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
SKIP("Smoke PTY test skipped on Windows CI");
|
||||
}
|
||||
#else
|
||||
|
||||
// Helper: concatena todas las celdas del scrollback como texto plano.
|
||||
static std::string scrollback_text(fn_term::TerminalPanel& p) {
|
||||
std::lock_guard<std::mutex> lk(p.buf_mutex);
|
||||
std::string result;
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& cell : line) {
|
||||
if (cell.ch >= 0x20 && cell.ch < 0x7F)
|
||||
result += static_cast<char>(cell.ch);
|
||||
}
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 100;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// Enviar el comando y esperar a que el proceso salga.
|
||||
fn_term::send(term, "echo hello && exit 0\n");
|
||||
|
||||
// Esperar máximo 2 segundos a que el proceso termine.
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
// Dar 100ms adicionales para que el reader thread procese el último output.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("hello") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: process exits cleanly", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
fn_term::send(term, "exit 0\n");
|
||||
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
REQUIRE(term.process_exited.load());
|
||||
REQUIRE(term.exit_code == 0);
|
||||
|
||||
fn_term::close(term);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: readonly panel ignores send", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.readonly = true;
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// send() no debe hacer nada (readonly).
|
||||
fn_term::send(term, "echo should_not_appear\n");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
// "should_not_appear" no debería estar en el scrollback porque send es no-op.
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("should_not_appear") == std::string::npos);
|
||||
}
|
||||
|
||||
#endif // !_WIN32
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: matrix-client-pc
|
||||
id: 0010
|
||||
status: pending
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
priority: high
|
||||
risk: medium
|
||||
related_issues: [0147, 0148, 0149, 0150, 0151, 0152, 0153, 0162, 0163]
|
||||
related_flows: [0009, 0011]
|
||||
apps: [matrix_client_pc]
|
||||
projects: [element_agents]
|
||||
vaults: []
|
||||
capability_groups: [matrix-client, livekit-calls, e2ee, widgets]
|
||||
trigger: manual
|
||||
schedule: ""
|
||||
expected_runtime_s: 0
|
||||
tags: [matrix, element, wails, react, mantine, livekit, e2ee, widgets, agents]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Cliente Matrix propio para PC (Win/Linux/macOS) construido con Wails (Go backend) + React+Mantine+`@fn_library` frontend. Replica capacidades actuales de Element Web (chat, E2EE, calls LiveKit) y se abre a mejoras propias: mini-webapps embebidas en conversaciones gestionadas por agentes del project `element_agents`, paneles especiales para llamadas, integracion directa con `agents_and_robots` + `agents_dashboard` + `device_agent` + futuro mesh WireGuard (flow 0009).
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Synapse + MAS + LiveKit funcionando en `organic-machine.com` (app `element_matrix_chat` ya desplegada, 5+ semanas uptime).
|
||||
- `livekit-jwt` container vivo para generar tokens (ver `docker-compose.livekit.yml`).
|
||||
- Sygnal push gateway (Synapse) — TBD si no existe, anadir container para push notifs PC + Android.
|
||||
- Cuenta Matrix de test (`@dev-pc:matrix-af2f3d.organic-machine.com`) registrada via MAS.
|
||||
- Go 1.22+ + Wails CLI v2 instalado (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`).
|
||||
- pnpm + Node 20+ (ya en el repo para `frontend/`).
|
||||
|
||||
## Funciones del registry recomendadas
|
||||
|
||||
| Rol | Funcion candidata | Estado |
|
||||
|---|---|---|
|
||||
| Matrix client init (Go) | `matrix_client_init_go_infra` | FALTA: wrapper sobre `mautrix-go` (login MAS OIDC, sync, store SQLite) |
|
||||
| LiveKit token gen (Go) | `livekit_token_gen_go_infra` | FALTA: JWT con `livekit-server-sdk-go` |
|
||||
| Matrix room subscribe SSE (Go) | `matrix_room_subscribe_go_infra` | FALTA: stream eventos Synapse -> frontend Wails via SSE/IPC |
|
||||
| Matrix message send (Go) | `matrix_message_send_go_infra` | FALTA: text + markdown + reply + edit + reaction |
|
||||
| Matrix E2EE bootstrap (Go) | `matrix_e2ee_bootstrap_go_infra` | FALTA: cross-signing keys, recovery passphrase |
|
||||
| Matrix device verify (Go) | `matrix_device_verify_go_infra` | FALTA: SAS verification flow |
|
||||
| LiveKit room hook (TS) | `livekit_room_ts_ui` | FALTA: hook React wrapper sobre `livekit-client` |
|
||||
| Widget host iframe (TS) | `widget_host_ts_ui` | FALTA: iframe sandbox + postMessage Matrix Widget API v2 |
|
||||
| Matrix timeline hook (TS) | `useMatrixTimeline_ts_ui` | FALTA: hook React con pagination, dedupe, optimistic UI |
|
||||
| Markdown render (TS) | reuse existing `markdown_render_ts_ui` si existe, sino crear | check |
|
||||
| HTTP client (Go) | `http_json_client_go_infra` | OK (reusar) |
|
||||
| SQLite open (Go) | `sqlite_open_go_infra` | OK (reusar) |
|
||||
| HTTP server SSE | `http_sse_server_go_infra` | OK (reusar) |
|
||||
| Notify (impure) | `notify_desktop_go_infra` | FALTA: Win/Linux/mac notifications nativas |
|
||||
|
||||
## Apps tocadas
|
||||
|
||||
- `projects/element_agents/apps/matrix_client_pc` (nueva — Wails + React).
|
||||
- `projects/element_agents/apps/element_matrix_chat` (backend ya activo; quiza anadir sygnal container).
|
||||
- `projects/element_agents/apps/agents_and_robots` (consumidor — el cliente PC dialoga con agentes via rooms Matrix).
|
||||
- `projects/element_agents/apps/agents_dashboard` (referencia UI — algunos paneles se reusan).
|
||||
|
||||
## Projects relacionados
|
||||
|
||||
- `element_agents` (root project — agrupa todo).
|
||||
|
||||
## Vaults / storage
|
||||
|
||||
- Local del PC: `~/.matrix_client_pc/store.db` (sync state + crypto store SQLite).
|
||||
- Cache media: `~/.matrix_client_pc/media/`.
|
||||
|
||||
## Capability groups consultados
|
||||
|
||||
- `matrix-client` (a crear: documenta wrappers `mautrix-go`).
|
||||
- `livekit-calls` (a crear: token gen + room join + UI calls).
|
||||
- `e2ee` (a crear: bootstrap + verification + recovery).
|
||||
- `widgets` (a crear: Matrix Widget API v2 host + sandbox + permisos).
|
||||
|
||||
## Flow
|
||||
|
||||
Pasos numerados. Cada paso = issue propio (ver `related_issues`).
|
||||
|
||||
1. **0147 — Scaffold Wails + login MAS.** Crear app `matrix_client_pc/` con Wails init, conectar a Synapse via MAS OIDC, mostrar perfil del usuario logueado. Persistencia tokens en `pass` o keychain del SO.
|
||||
2. **0148 — Rooms list + timeline.** Sidebar con rooms (DMs + spaces + grupos), panel central timeline con pagination scroll-up, dedupe, optimistic UI. Reusar layout `AppShell` Mantine.
|
||||
3. **0149 — Composer + interacciones.** Composer markdown, replies, edits, reactions, threads, upload media (imagenes, files, voice msg). Drag&drop. Slash commands placeholder.
|
||||
4. **0150 — E2EE.** `mautrix-go` con crypto store SQLite. Cross-signing setup, recovery passphrase, SAS verification de devices, key backup. UI para verificar otros usuarios.
|
||||
5. **0151 — Calls LiveKit.** Boton call en room -> token JWT desde Go backend -> join LiveKit room -> UI con tiles participantes, mute/cam/screen/hangup. 1:1 + grupales hasta 16 (limite actual del config).
|
||||
6. **0152 — Mini-webapps embebidas.** Implementar Matrix Widget API v2: iframe sandbox + postMessage handshake + permisos (capabilities `m.always_on_screen`, `org.matrix.msc2762.send.event`, etc.). Lanzar webapps desde slash command `/widget <url>` o desde state event `m.widget`. Agentes pueden publicar widgets en su room (ej. dashboard de telemetria, formulario, kanban inline).
|
||||
7. **0153 — Agent integration.** Paneles especiales para rooms operados por agentes de `agents_and_robots`: timeline + panel lateral con estado del agente (uptime, cola de tasks, last_error). Reusar SSE del `agents_dashboard`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App Wails compila y arranca en Win+Linux con binario standalone.
|
||||
- [ ] Login MAS OIDC completo, token persistido entre arranques.
|
||||
- [ ] Sync incremental con Synapse funciona; reconexion automatica tras red caida.
|
||||
- [ ] E2EE: enviar/recibir mensajes cifrados con otro cliente (Element Web o Android).
|
||||
- [ ] Call 1:1 con video+audio funcional via LiveKit.
|
||||
- [ ] Widget de prueba (HTML estatico servido por `agents_and_robots`) se carga en iframe sandbox y postMessage handshake completa.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica (pre-requisito)
|
||||
|
||||
- `go build -tags wails` verde para Win + Linux.
|
||||
- `pnpm build` frontend verde.
|
||||
- `fn doctor cpp-apps` no aplica; `fn doctor services` confirma backend Matrix sano.
|
||||
- `app.md` con `uses_functions` declarando todas las dependencias del registry.
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: login + recibir mensaje E2EE | e2e | `e2e/test_login_and_receive.sh` | mensaje aparece en timeline en <2s, descifrado OK |
|
||||
| Edge: red cae 30s, vuelve | e2e | `e2e/test_reconnect.sh` | sync se reanuda sin perder mensajes |
|
||||
| Edge: 2000 mensajes en 1 room | e2e | `e2e/test_perf_timeline.sh` | scroll a 60fps, memoria <500MB |
|
||||
| Edge: device nuevo no verificado envia msg | e2e | `e2e/test_unverified_device.sh` | warning visible en UI, msg cifra a este device solo si user confirma |
|
||||
| Error: token MAS expira | e2e | `e2e/test_token_refresh.sh` | refresh automatico, sin logout visible |
|
||||
| Error: LiveKit SFU caido | e2e | `e2e/test_livekit_down.sh` | error claro en UI, no crash de la app |
|
||||
|
||||
### Vida util validada (>=7 dias uso real)
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes proceso PC | `0` | `journalctl --user -u matrix_client_pc` (Linux) / Event Viewer (Win) | 7 dias |
|
||||
| Latencia send msg | `p95 < 500ms` | panel propio de la app + `call_monitor` | 7 dias |
|
||||
| Calls fallidas | `< 5%` | counter en app + logs LiveKit | 7 dias |
|
||||
| Uso real diario | `>= 4 dias/semana` | `last_active_at` en store local | 7 dias |
|
||||
| Onboarding nuevo usuario | `< 5min hasta primer msg E2EE` | screencast operador | 1 sesion |
|
||||
|
||||
### Anti-criterios
|
||||
|
||||
- NO marcar done si E2EE se silent-falla (mensajes no se descifran y la UI no lo dice).
|
||||
- NO marcar done si la app solo funciona en `home-wsl` y peta en `aurgi-pc`.
|
||||
- NO marcar done si widget host carga `javascript:` URLs (XSS).
|
||||
- NO marcar done si calls grupales >3 participantes lagean con audio cortado.
|
||||
|
||||
## Notas
|
||||
|
||||
**Onboarding rapido:**
|
||||
1. `cd projects/element_agents/apps/matrix_client_pc`
|
||||
2. `wails dev` para desarrollo con hot-reload.
|
||||
3. `wails build -platform linux/amd64,windows/amd64` para release.
|
||||
4. Tokens MAS guardados via `keyring` (Go bindings al keychain del SO).
|
||||
5. Para probar E2EE: crear segundo usuario en Synapse Admin, abrir Element Web como segundo cliente, intercambiar verifications.
|
||||
|
||||
**Camino futuro (post-DoD):**
|
||||
- Push notifs nativas via `sygnal` + APNs/FCM-equivalent desktop (Win Action Center, Linux notify-send).
|
||||
- Mini-webapp catalog: registry de widgets internos (`projects/element_agents/widgets/`) publicables a rooms con un comando.
|
||||
- Threads UI mejorado (vs Element que es plano).
|
||||
- Integracion `agents_and_robots`: panel embebido que muestra logs del agente del room actual.
|
||||
- Cuando flow 0009 (mesh wireguard) este vivo: este cliente PC habla con `device_agent` de cada PC del mesh via su room Matrix.
|
||||
|
||||
**Decisiones clave (justificacion en hilo Claude 2026-05-24):**
|
||||
- Wails > Tauri: Go es stack principal del registry, reusa funciones existentes, `mautrix-go` es el SDK Matrix mas maduro en Go.
|
||||
- React+Vite+Mantine+`@fn_library`: defaults del proyecto, ver `frontend_theming.md`.
|
||||
- 2 codebases (PC Wails + Android Kotlin nativo): tradeoff aceptado por calidad nativa Android + reuso Go en PC. Contrato compartido en `docs/client_contract.md` (TBD).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — baseline (flow creado).
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: matrix-client-android
|
||||
id: 0011
|
||||
status: pending
|
||||
created: 2026-05-24
|
||||
updated: 2026-05-24
|
||||
priority: high
|
||||
risk: medium
|
||||
related_issues: [0154, 0155, 0156, 0157, 0158, 0159, 0160, 0161, 0162, 0163]
|
||||
related_flows: [0009, 0010]
|
||||
apps: [matrix_client_android]
|
||||
projects: [element_agents]
|
||||
vaults: []
|
||||
capability_groups: [matrix-client, livekit-calls, e2ee, widgets, android-native]
|
||||
trigger: manual
|
||||
schedule: ""
|
||||
expected_runtime_s: 0
|
||||
tags: [matrix, element, android, kotlin, compose, livekit, e2ee, widgets, agents, fcm, push]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Cliente Matrix Android nativo (Kotlin + Jetpack Compose) que comparte contrato con el cliente PC (flow 0010) pero usa SDKs nativos para calidad superior: `matrix-rust-sdk` Kotlin bindings (E2EE rust, mejor), `livekit-android` (codecs HW, audio focus, AEC), FCM push directo via `sygnal`, foreground service para calls en background. Replica capacidades de Element Android + abre mini-webapps embebidas (Matrix Widget API v2 dentro de WebView) gestionadas por agentes del project `element_agents`.
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Stack Synapse + MAS + LiveKit ya activo en `organic-machine.com` (flow 0010 compartido).
|
||||
- Container `sygnal` corriendo en VPS (anadir si no existe — issue 0159 lo cubre).
|
||||
- Firebase project con FCM activado + service account JSON. Hosting gratuito.
|
||||
- Android Studio Iguana+, NDK r26+, Kotlin 1.9+.
|
||||
- `init_kotlin_app_bash_pipelines` (ya existe, ver issues 0073/0074/0075/0078 completados) para scaffold inicial.
|
||||
- Device fisico o emulator Android 9+ (API 28+) para test.
|
||||
- Capability del usuario operador: instalar APK debug + microphone/camera/notification grants.
|
||||
|
||||
## Funciones del registry recomendadas
|
||||
|
||||
| Rol | Funcion candidata | Estado |
|
||||
|---|---|---|
|
||||
| Kotlin app scaffold | `init_kotlin_app_bash_pipelines` | OK (reusar) |
|
||||
| Matrix rust-sdk wrapper (Kotlin) | `matrix_client_kotlin_infra` | FALTA: facade sobre `matrix-rust-sdk` Kotlin bindings |
|
||||
| LiveKit Android wrapper | `livekit_call_kotlin_infra` | FALTA: wrapper `io.livekit:livekit-android` |
|
||||
| FCM token register | `fcm_register_kotlin_infra` | FALTA: registrar device en sygnal via Synapse pusher API |
|
||||
| Sygnal pusher add | `sygnal_pusher_add_go_infra` | FALTA: Go helper para configurar push gateway |
|
||||
| Compose Room list | `RoomListScreen_kotlin_ui` | FALTA |
|
||||
| Compose Timeline | `TimelineScreen_kotlin_ui` | FALTA |
|
||||
| Compose Composer | `Composer_kotlin_ui` | FALTA |
|
||||
| Compose CallScreen | `CallScreen_kotlin_ui` | FALTA |
|
||||
| Compose WidgetHost | `WidgetHost_kotlin_ui` | FALTA: WebView + JS bridge Widget API |
|
||||
| Foreground service call | `CallForegroundService_kotlin_infra` | FALTA |
|
||||
| ICE permissions helper | `permissions_request_kotlin_core` | FALTA: mic/cam/notif/foreground service grants |
|
||||
| Local DB Room | reusar `androidx.room` directo | OK |
|
||||
|
||||
## Apps tocadas
|
||||
|
||||
- `projects/element_agents/apps/matrix_client_android` (nueva — Kotlin+Compose).
|
||||
- `projects/element_agents/apps/element_matrix_chat` (anadir sygnal container — issue 0159).
|
||||
- `projects/element_agents/apps/agents_and_robots` (consumidor agent panels).
|
||||
|
||||
## Projects relacionados
|
||||
|
||||
- `element_agents`.
|
||||
|
||||
## Vaults / storage
|
||||
|
||||
- Local Android: `/data/data/com.fnregistry.matrix_client_android/databases/` (room DB encriptada via SQLCipher).
|
||||
- Crypto store de matrix-rust-sdk: gestionado por el SDK en `files/matrix/<userId>/`.
|
||||
|
||||
## Capability groups consultados
|
||||
|
||||
- `matrix-client` (compartido con flow 0010).
|
||||
- `livekit-calls` (compartido).
|
||||
- `e2ee` (compartido).
|
||||
- `widgets` (compartido — contrato Widget API igual).
|
||||
- `android-native` (a crear: foreground service, FCM, MediaSession para calls).
|
||||
|
||||
## Flow
|
||||
|
||||
1. **0154 — Scaffold Kotlin + Compose + login MAS.** App `matrix_client_android/` con `init_kotlin_app`, Material 3 + tema propio acorde a `frontend_theming.md` (paleta equivalente). Login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences.
|
||||
2. **0155 — Rooms list + Timeline.** Compose UI con `LazyColumn` virtualizado, sync via `matrix-rust-sdk` (corrutinas). Pagination, optimistic UI, swipe-to-react.
|
||||
3. **0156 — Composer.** Markdown, replies, edits, reactions, media (camara + galeria + voice msg con `MediaRecorder` opus).
|
||||
4. **0157 — E2EE rust-sdk.** Cross-signing setup, SAS verification (emoji), recovery passphrase, key backup. UI dialog verificacion.
|
||||
5. **0158 — Calls LiveKit Android nativo.** `livekit-android` SDK con codecs HW (H.264/VP9 hardware decoder), audio focus, echo cancellation, noise suppression. PiP mode Android nativo.
|
||||
6. **0159 — Push FCM via sygnal.** Anadir container `sygnal` al stack `element_matrix_chat`. Registrar FCM token via Synapse Pusher API. Handle push payload -> open room / wake up para incoming call.
|
||||
7. **0160 — Mini-webapps en WebView.** `WebView` con `WebViewClient` + JS bridge implementando Matrix Widget API v2. Sandbox via `setAllowFileAccess(false)`, `setAllowContentAccess(false)`, CSP estricta. Mismo contrato widgets que cliente PC.
|
||||
8. **0161 — Foreground service para calls + lifecycle.** `CallForegroundService` con notification ongoing, audio routing (speaker/earpiece/bluetooth), MediaSession para controls en lockscreen, wakelock controlado.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] APK debug instala + arranca en Android 9+ (API 28).
|
||||
- [ ] Login MAS via Chrome Custom Tabs, token persistido en EncryptedSharedPreferences.
|
||||
- [ ] Sync incremental funciona; reconexion automatica tras avion mode toggle.
|
||||
- [ ] E2EE: mensaje enviado desde PC (Wails) se descifra en Android (y al reves).
|
||||
- [ ] Call 1:1 con video+audio nativos, calidad superior a WebView.
|
||||
- [ ] Push FCM despierta app para incoming msg / call.
|
||||
- [ ] Widget de prueba se carga en WebView sandbox con bridge funcional.
|
||||
- [ ] Foreground service mantiene call viva con app en background + pantalla bloqueada.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica (pre-requisito)
|
||||
|
||||
- `./gradlew assembleDebug` verde.
|
||||
- `./gradlew test` verde.
|
||||
- `./gradlew connectedAndroidTest` verde en emulator API 31+ (instrumented).
|
||||
- `app.md` con `uses_functions` declarando dependencias del registry.
|
||||
|
||||
### Cobertura de comportamiento
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: login + E2EE msg | instrumented | `./gradlew connectedAndroidTest --tests *LoginE2EE*` | msg descifrado en <2s, shield green |
|
||||
| Edge: avion mode 30s | instrumented | `./gradlew connectedAndroidTest --tests *Reconnect*` | sync resume, sin perder msgs |
|
||||
| Edge: 1000 msgs en room | benchmark | `./gradlew :app:benchmark` | scroll a 60fps, RAM <300MB |
|
||||
| Edge: incoming call, pantalla apagada | manual + screencast | apagar pantalla + recibir call desde PC | notif full-screen + ring, accept funciona |
|
||||
| Error: FCM token rotation | instrumented | `./gradlew connectedAndroidTest --tests *FCMRotation*` | re-register automatico en sygnal |
|
||||
| Error: WebView widget malicioso | instrumented | `./gradlew connectedAndroidTest --tests *WidgetSandbox*` | bloqueado, no escape |
|
||||
| Battery: call 30min | manual + dumpsys batterystats | call 30min | drain <15%, sin OOM |
|
||||
|
||||
### Vida util validada (>=7 dias uso real)
|
||||
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes (ANRs/forced close) | `0` | `adb logcat -e FATAL` + Play Console (si publicado) | 7 dias |
|
||||
| Push latency (msg enviado -> notif visible) | `p95 < 3s` | log custom en app + sygnal | 7 dias |
|
||||
| Call drops in-pocket (lockscreen) | `< 5%` | counter app | 7 dias |
|
||||
| Battery drain idle | `< 2%/h` | dumpsys batterystats | 7 dias |
|
||||
| Uso real diario | `>= 5 dias/semana` | last_active en local DB | 7 dias |
|
||||
|
||||
### Anti-criterios
|
||||
|
||||
- NO marcar done si E2EE silent-falla.
|
||||
- NO marcar done si call con pantalla bloqueada se corta a los <5min (battery optimization mata el service).
|
||||
- NO marcar done si WebView de widget permite acceso a `file://` o cookies del browser host.
|
||||
- NO marcar done si la app solo funciona en el device del operador y peta en Android < 11.
|
||||
- NO marcar done sin probar en Android 9 (legacy, muchos dispositivos antiguos siguen vivos).
|
||||
|
||||
## Notas
|
||||
|
||||
**Onboarding rapido:**
|
||||
1. `cd projects/element_agents/apps/matrix_client_android`
|
||||
2. `./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk`
|
||||
3. Para hot-reload UI: `./gradlew :app:installDebug` + Android Studio Compose preview.
|
||||
4. Para test push: enviar msg desde Element Web a la cuenta del Android; debe llegar notif via FCM en <3s.
|
||||
|
||||
**Decisiones clave:**
|
||||
- `matrix-rust-sdk` Kotlin bindings > matrix-android-sdk2 (deprecated). Rust-sdk es el futuro oficial de matrix.org.
|
||||
- `livekit-android` nativo > WebRTC.org directo. SDK oficial mantiene mejor performance + features.
|
||||
- Jetpack Compose > XML views. Encaja mejor con reactive model + menos boilerplate.
|
||||
- EncryptedSharedPreferences para tokens MAS. NO usar SharedPreferences plain.
|
||||
- Material 3 con tema propio (paleta similar a Mantine accent del cliente PC para coherencia visual).
|
||||
|
||||
**Camino futuro (post-DoD):**
|
||||
- Wear OS companion app (notifs + quick reply).
|
||||
- Android Auto integration (read msgs voice + reply voice).
|
||||
- Conversation shortcuts API (Android 11+) para que cada room aparezca en share sheet.
|
||||
- Bubble notifications (Android 11+) para conversaciones favoritas.
|
||||
|
||||
**Compartido con flow 0010:**
|
||||
- Contrato `m.widget` y Widget API v2 IDENTICO. Mismo widget html funciona en ambos.
|
||||
- Contrato `m.agent.metadata` para detectar rooms de agentes IDENTICO.
|
||||
- Cuando flow 0009 (mesh) este vivo, ambos clientes hablan a `device_agent` igual.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — baseline.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "0123"
|
||||
title: "/flow run + fn-meta-orquestador: ejecutar flows + paralelo issues autonomos"
|
||||
title: "fn-meta-orquestador + fn-priorizador + fn doctor issues/flows"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
@@ -15,10 +15,13 @@ related:
|
||||
- "0069"
|
||||
- "0102"
|
||||
created: 2026-05-18
|
||||
updated: 2026-05-18
|
||||
tags: [flows, runner, meta-orquestador, paralelo, priorizador]
|
||||
updated: 2026-05-19
|
||||
tags: [meta-orquestador, paralelo, priorizador, doctor]
|
||||
---
|
||||
|
||||
**REVISION 2026-05-19:** `/flow run` y `/fix-flow` se ELIMINAN del scope. Absorbidos por `/autopilot` v2 (delega a fn-orquestador que ganara modo `task_type=flow`). Issue queda con 3 piezas: meta-orquestador + priorizador + doctor.
|
||||
|
||||
|
||||
# 0123 — Flows ejecutables + meta-orquestador paralelo
|
||||
|
||||
## Problema
|
||||
@@ -27,29 +30,32 @@ tags: [flows, runner, meta-orquestador, paralelo, priorizador]
|
||||
2. `parallel-fix-issues` lanza N agentes Claude vanilla en worktrees. `fn-orquestador` lanza 1 issue autonomo en worktree. NO existe combinacion: N issues autonomos coordinados respetando dep-graph.
|
||||
3. `/work today` prioriza con regla fija (prio+deps+DoD%). NO usa errores e2e, blast radius ni huerfanas para reordenar.
|
||||
|
||||
## Decision
|
||||
## Decision (revision 2026-05-19)
|
||||
|
||||
Tres piezas:
|
||||
|
||||
1. **`/flow run <NNNN>`**: ejecuta Acceptance checkboxes como steps. Cada step = `./fn run <id>` o subagent call. Logea en `data_factory.runs` + `e2e_runs`.
|
||||
2. **`/fix-flow <NNNN>`**: simetrico a `/fix-issue`. Cierra DoD del flow ejecutando Acceptance + abriendo issues si falla algun step.
|
||||
3. **`fn-meta-orquestador`** (subagente nuevo): lee `dev/issues/` con `status=pendiente` + dep-graph + telemetria. Spawn N `fn-orquestador` en worktrees paralelos respetando deps. Reusa `parallel-fix-issues/scripts/setup-worktrees.sh`.
|
||||
4. **`fn-priorizador`** (subagente nuevo): lee issues + telemetria call_monitor (error_rate, blast radius, huerfanas, violations). Output: top-N reordenado para `/work today`.
|
||||
5. **`fn doctor issues` + `fn doctor flows`**: valida TAXONOMY allowlist + DoD presente + user-facing surface declarada.
|
||||
1. **`fn-meta-orquestador`** (subagente nuevo): lee `dev/issues/` con `status=pendiente` + dep-graph + telemetria. Spawn N `fn-orquestador` en worktrees paralelos respetando deps. Reusa `parallel-fix-issues/scripts/setup-worktrees.sh`.
|
||||
2. **`fn-priorizador`** (subagente nuevo): lee issues + telemetria call_monitor (error_rate, blast radius, huerfanas, violations). Output: top-N reordenado para `/work today`.
|
||||
3. **`fn doctor issues` + `fn doctor flows`**: valida TAXONOMY allowlist + DoD presente + user-facing surface declarada.
|
||||
|
||||
**ELIMINADAS del scope original (absorbidas por `/autopilot` v2):**
|
||||
- `/flow run <NNNN>` — ahora `/autopilot flow:<NNNN>` delega a `fn-orquestador` con `task_type=flow`.
|
||||
- `/fix-flow <NNNN>` — mismo, fusion en `/autopilot`.
|
||||
|
||||
Implica que `fn-orquestador` necesita ganar soporte `task_type=flow` (parsear `## Flow` + ejecutar steps). Sub-tarea trackeada en 0123 reducido o issue propio (decidir).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Implementar `/flow run` en `.claude/commands/flow.md` + parser de Acceptance + dispatcher de steps.
|
||||
2. Implementar `/fix-flow` espejando `/fix-issue` adaptado al frontmatter de flows.
|
||||
3. Escribir `.claude/agents/fn-meta-orquestador/SKILL.md` con dep-graph resolver + spawner paralelo.
|
||||
4. Escribir `.claude/agents/fn-priorizador/SKILL.md` que consulta `call_monitor.operations.db` + `task_runs`.
|
||||
5. Anadir subcomandos `fn doctor issues` + `fn doctor flows` con funciones auxiliares via `fn-constructor`.
|
||||
6. Test: lanzar `/fix-flow 0001` (hn-top-stories) end-to-end + verificar acceptance.
|
||||
1. Anadir soporte `task_type=flow` a `fn-orquestador/SKILL.md` (parser `## Flow` + ejecutor steps + evaluator `## Acceptance` checkboxes via heuristicas).
|
||||
2. Escribir `.claude/agents/fn-meta-orquestador/SKILL.md` con dep-graph resolver + spawner paralelo.
|
||||
3. Escribir `.claude/agents/fn-priorizador/SKILL.md` que consulta `call_monitor.operations.db` + `task_runs`.
|
||||
4. Anadir subcomandos `fn doctor issues` + `fn doctor flows` con funciones auxiliares via `fn-constructor`.
|
||||
5. Test: lanzar `/autopilot flow:0001` (hn-top-stories) end-to-end + verificar acceptance.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `/flow run 0001` ejecuta cada step y reporta pass/fail por step.
|
||||
- [ ] `/fix-flow 0001` cierra DoD verde y mueve a `dev/flows/completed/`.
|
||||
- [ ] `/autopilot flow:0001` ejecuta cada step y reporta pass/fail por step (delegado a fn-orquestador con task_type=flow).
|
||||
- [ ] `/autopilot flow:0001` cierra DoD verde y mueve a `dev/flows/completed/`.
|
||||
- [ ] `fn-meta-orquestador` lanza N orquestadores paralelos sobre issues sin dep entre si.
|
||||
- [ ] `fn-priorizador` output incluye senal de telemetria (no solo prio+deps).
|
||||
- [ ] `fn doctor issues --json` detecta drift TAXONOMY.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
id: "0130"
|
||||
title: Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui
|
||||
status: pendiente
|
||||
type: epic
|
||||
domain:
|
||||
- cpp-stack
|
||||
- apps-infra
|
||||
- dev-ux
|
||||
scope: multi-app
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0112"
|
||||
- "0119"
|
||||
tags:
|
||||
- kanban
|
||||
- cpp
|
||||
- imgui
|
||||
- dev_ux
|
||||
- issues
|
||||
- flows
|
||||
created: "2026-05-22"
|
||||
updated: "2026-05-22"
|
||||
---
|
||||
|
||||
# 0130 — Kanban C++ v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
La v1 (`apps/kanban_cpp` borrada el 2026-05-22) mezclaba paneles ajenos al dominio kanban (agent runs, DoD, worktrees, calendar) y un backend que no era reutilizable. Para gestionar los 98 issues activos + 12 flows del proyecto necesitamos una vista board nativa, sin web, con edicion bidireccional de los archivos markdown.
|
||||
|
||||
## Que entrega
|
||||
|
||||
App kanban_cpp v2 con dos piezas:
|
||||
|
||||
1. **Backend Go** (`apps/kanban_cpp/backend/`) — service HTTP en puerto 8487.
|
||||
- Parser bidireccional MD <-> SQLite (cache).
|
||||
- Watcher fsnotify sobre `dev/issues/` (+ `completed/`) y `dev/flows/`.
|
||||
- Endpoints REST: `/api/issues`, `/api/issues/{id}` (GET/PATCH), `/api/flows`, `/api/flows/{id}`, `/api/meta`, `/api/sse`.
|
||||
- PATCH a issue reescribe el frontmatter en disco preservando body + orden de campos.
|
||||
|
||||
2. **Frontend C++ ImGui** (`apps/kanban_cpp/`) sobre el framework `fn::run_app`.
|
||||
- Panel **Board**: columnas por status (pendiente / in-progress / bloqueado / completado). Drag-drop = PATCH status.
|
||||
- Panel **Flows**: lista de flows con detalle.
|
||||
- Panel **Filtros** (Aside): multi-select domain, scope, priority, tags.
|
||||
- Panel **Detalle**: edicion de campos frontmatter de un issue (status, priority, scope, tags, depends, blocks).
|
||||
- SSE para refrescar tras cambios externos en disco.
|
||||
|
||||
## Sub-issues
|
||||
|
||||
- **0130a** — parser MD + scan dirs (funciones registry).
|
||||
- **0130b** — backend Go: schema + handlers + watcher + SSE.
|
||||
- **0130c** — frontend C++: paneles + http client.
|
||||
|
||||
Cada sub-issue mergeable independiente en su rama corta TBD.
|
||||
|
||||
## Reusa del registry
|
||||
|
||||
Backend Go:
|
||||
- `sqlite_open_go_infra`, `sqlite_apply_migrations_go_infra`
|
||||
- `http_router_go_infra`, `http_serve_go_infra`, `http_middleware_chain_go_infra`
|
||||
- `http_cors_middleware_go_infra`, `http_logger_middleware_go_infra`
|
||||
- `http_json_response_go_infra`, `http_error_response_go_infra`, `http_parse_body_go_infra`
|
||||
- `random_hex_id_go_core`
|
||||
|
||||
Frontend C++:
|
||||
- `http_request_cpp_core`
|
||||
- `sse_client_cpp_core`
|
||||
- `data_table_cpp_viz` (lista flows)
|
||||
- `kpi_card_cpp_viz` (contadores por status)
|
||||
|
||||
## Crea (delegadas a fn-constructor en 0130a)
|
||||
|
||||
- `parse_issue_md_go_infra` — lee .md → struct (frontmatter YAML + body).
|
||||
- `write_issue_md_go_infra` — escribe struct → .md preservando body + orden de campos.
|
||||
- `scan_issues_dir_go_infra` — walk `dev/issues/` + `dev/issues/completed/`.
|
||||
- `scan_flows_dir_go_infra` — walk `dev/flows/`.
|
||||
- `watch_dir_fsnotify_go_infra` (si no existe) — events channel.
|
||||
|
||||
## DoD
|
||||
|
||||
- `fn doctor` verde para ambas apps (artefacts + e2e).
|
||||
- `e2e_checks` en ambos `app.md` (build + health + self-test).
|
||||
- Drag-drop en frontend reescribe el `.md` correspondiente y `git diff` lo muestra (solo frontmatter, body intacto).
|
||||
- Trio obligatorio (`description` + `icon.phosphor` + `icon.accent`) en ambos `app.md`.
|
||||
- Sub-repos Gitea creados (`dataforge/kanban_cpp` reactivado o nuevo, mismo nombre).
|
||||
|
||||
dod_evidence_schema:
|
||||
- id: backend_health
|
||||
kind: cmd
|
||||
expected: "curl -fsS http://localhost:8487/api/health == 200"
|
||||
required: true
|
||||
- id: api_issues_count
|
||||
kind: cmd
|
||||
expected: "curl -fsS http://localhost:8487/api/issues | jq 'length' >= 90"
|
||||
required: true
|
||||
- id: patch_writes_md
|
||||
kind: cmd
|
||||
expected: "PATCH /api/issues/0130 status=in-progress reescribe dev/issues/0130-*.md (git diff muestra solo status)"
|
||||
required: true
|
||||
- id: frontend_self_test
|
||||
kind: cmd
|
||||
expected: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test exit 0"
|
||||
required: true
|
||||
- id: board_screenshot
|
||||
kind: screenshot
|
||||
expected: "kanban_cpp Board panel con 4 columnas pobladas con issues reales"
|
||||
required: true
|
||||
|
||||
## Anti-scope
|
||||
|
||||
NO incluye en esta version:
|
||||
- Grafo de dependencias (depends/blocks/related visual).
|
||||
- Edicion de body MD desde la app (solo frontmatter).
|
||||
- Multi-PC sync (backend es local).
|
||||
- Crear issues nuevos desde la UI (solo editar existentes).
|
||||
- DoD evidence panel, agent runs, calendar, worktrees (la v1 los mezclaba — fuera).
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: 0130a
|
||||
title: 'Funciones registry: parser MD + scan dirs + writer + watcher'
|
||||
status: pendiente
|
||||
type: infra
|
||||
domain:
|
||||
- registry-quality
|
||||
- dev-ux
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks:
|
||||
- 0130b
|
||||
related:
|
||||
- "0130"
|
||||
tags:
|
||||
- registry
|
||||
- go
|
||||
- parser
|
||||
- frontmatter
|
||||
- fsnotify
|
||||
flow: "0130"
|
||||
created: "2026-05-22"
|
||||
updated: "2026-05-22"
|
||||
---
|
||||
|
||||
# 0130a — Funciones registry para kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
El backend de kanban_cpp v2 necesita parsear/escribir frontmatter YAML de los `.md` de `dev/issues/` y `dev/flows/`. Estas piezas son reusables (cualquier app del registry puede operar sobre issues/flows), asi que viven en el registry, no en el backend de la app.
|
||||
|
||||
## Funciones a crear (delegar a fn-constructor en paralelo)
|
||||
|
||||
| ID | Firma | Pureza |
|
||||
|---|---|---|
|
||||
| `parse_issue_md_go_infra` | `(path string) (Issue, []byte body, error)` | impure (FS) |
|
||||
| `write_issue_md_go_infra` | `(path string, issue Issue, body []byte) error` | impure (FS) |
|
||||
| `scan_issues_dir_go_infra` | `(root string) ([]Issue, error)` | impure (FS) |
|
||||
| `scan_flows_dir_go_infra` | `(root string) ([]Flow, error)` | impure (FS) |
|
||||
| `watch_dir_fsnotify_go_infra` | `(ctx, root) (<-chan FsEvent, error)` | impure (FS, async) |
|
||||
|
||||
Tipos:
|
||||
- `Issue_go_infra` — struct con campos del frontmatter (id, title, status, type, domain, scope, priority, depends, blocks, related, flow, tags, created, updated, file_path, mtime_ns).
|
||||
- `Flow_go_infra` — struct equivalente para flows.
|
||||
- `FsEvent_go_infra` — `{path, op}` con `op in {create, write, remove, rename}`.
|
||||
|
||||
## Notas de implementacion
|
||||
|
||||
- Usar `gopkg.in/yaml.v3` para parsing (preserva orden de keys via `yaml.Node`).
|
||||
- Writer DEBE preservar:
|
||||
- Orden de campos del frontmatter original.
|
||||
- Body MD intacto (todo lo que va despues del segundo `---`).
|
||||
- Comentarios YAML (libre, best-effort).
|
||||
- `parse_issue_md` debe ser tolerante: si falta un campo opcional, default empty.
|
||||
- `watch_dir_fsnotify` recursivo, debounce 200ms.
|
||||
|
||||
## DoD
|
||||
|
||||
- 5 pares `.go` + `.md` en `functions/infra/`.
|
||||
- Tests unitarios:
|
||||
- parse → write → parse round-trip preserva struct.
|
||||
- scan_issues_dir devuelve >=90 issues actuales.
|
||||
- watcher detecta creacion + modificacion + borrado.
|
||||
- `fn index` registra los 5 IDs + 3 tipos.
|
||||
- `fn doctor uses-functions` limpio.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
NO incluye en esta tanda:
|
||||
- Markdown rendering del body (eso lo hace el frontend si quiere).
|
||||
- Validacion contra TAXONOMY (existe `fn doctor issues`).
|
||||
- CRUD de issues nuevos (write_issue cubre el caso, pero crear file = scope del backend).
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: "0130b"
|
||||
title: "Backend Go kanban_cpp v2: schema + handlers + watcher + SSE"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- apps-infra
|
||||
- dev-ux
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends:
|
||||
- "0130a"
|
||||
blocks:
|
||||
- "0130c"
|
||||
related:
|
||||
- "0130"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [service, kanban, go, sqlite, sse]
|
||||
flow: "0130"
|
||||
---
|
||||
|
||||
# 0130b — Backend Go kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
Servicio HTTP local que sirve los issues + flows del proyecto al frontend C++. Es un wrapper fino sobre las funciones del registry de 0130a + SQLite cache + watcher.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
apps/kanban_cpp/backend/
|
||||
app.md # tag service
|
||||
go.mod
|
||||
main.go # entry: flags + run
|
||||
db.go # open + apply migrations + upsert helpers
|
||||
handlers.go # endpoints REST
|
||||
sse_hub.go # broadcaster
|
||||
watcher.go # bind a watch_dir_fsnotify + re-ingesta + emit SSE
|
||||
ingest.go # scan → upsert; usa 0130a
|
||||
migrations/
|
||||
001_init.sql
|
||||
operations.db # creada en runtime
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Verbo | Path | Notas |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | `{ok:true, version, count_issues, count_flows}` |
|
||||
| GET | `/api/issues` | filtros: `status`, `domain`, `priority`, `tag`, `scope` |
|
||||
| GET | `/api/issues/{id}` | issue + body |
|
||||
| PATCH | `/api/issues/{id}` | partial update frontmatter → `write_issue_md` + re-ingesta + SSE |
|
||||
| GET | `/api/flows` | filtros: `status`, `kind` |
|
||||
| GET | `/api/flows/{id}` | flow + body |
|
||||
| GET | `/api/meta` | enums leidos de `dev/TAXONOMY.md` |
|
||||
| GET | `/api/sse` | stream `{type, id, path}` |
|
||||
|
||||
CORS abierto local (`*`). Logger middleware.
|
||||
|
||||
## Schema (migrations/001_init.sql)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
type TEXT,
|
||||
scope TEXT,
|
||||
priority TEXT,
|
||||
domain_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
depends_json TEXT NOT NULL DEFAULT '[]',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
related_json TEXT NOT NULL DEFAULT '[]',
|
||||
flow_id TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
completed INTEGER NOT NULL DEFAULT 0 -- 1 si vive en completed/
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flows (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT,
|
||||
kind TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## DoD
|
||||
|
||||
- `curl http://localhost:8487/api/health` devuelve 200 + counts.
|
||||
- `curl http://localhost:8487/api/issues | jq 'length' >= 90`.
|
||||
- `curl -X PATCH /api/issues/0130 -d '{"status":"in-progress"}'` reescribe `dev/issues/0130-*.md` (status updated, body intacto).
|
||||
- Despues del PATCH, suscriptor SSE recibe evento `{type:"updated", id:"0130"}`.
|
||||
- Tras `mv dev/issues/0130-*.md dev/issues/completed/`, watcher actualiza fila (`completed=1`).
|
||||
- `go test ./...` verde.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- No expone proposals ni capabilities (eso es MCP registry).
|
||||
- No autentica (local-only por ahora).
|
||||
- No persiste estado UI (eso lo hace el frontend).
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "0130c"
|
||||
title: "Frontend C++ ImGui kanban_cpp v2: board + flows + filtros + detalle"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- cpp-stack
|
||||
- dev-ux
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends:
|
||||
- "0130b"
|
||||
blocks: []
|
||||
related:
|
||||
- "0130"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, kanban, frontend]
|
||||
flow: "0130"
|
||||
---
|
||||
|
||||
# 0130c — Frontend C++ ImGui kanban_cpp v2
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
UI nativa sobre el backend 0130b. Aprovecha el framework `fn::run_app` (menubar, layouts, settings, about, log) y los componentes del registry (`data_table`, `kpi_card`, `http_request`, `sse_client`).
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
apps/kanban_cpp/
|
||||
app.md
|
||||
appicon.ico
|
||||
CMakeLists.txt
|
||||
main.cpp # fn::run_app + cfg.panels
|
||||
data.h / data.cpp # http client + state global (issues, flows, filters)
|
||||
panel_board.cpp # 4 columnas + drag-drop
|
||||
panel_flows.cpp # tabla via data_table_cpp_viz
|
||||
panel_filters.cpp # Aside con multi-select
|
||||
panel_detail.cpp # form editable del issue seleccionado
|
||||
panels.h
|
||||
```
|
||||
|
||||
## Trio obligatorio (`app.md`)
|
||||
|
||||
```yaml
|
||||
description: "Kanban C++ v2 para gestionar dev/issues y dev/flows del registry"
|
||||
icon:
|
||||
phosphor: "kanban"
|
||||
accent: "#a855f7"
|
||||
```
|
||||
|
||||
## Paneles
|
||||
|
||||
1. **Board** (`TI_KANBAN " Board"`) — 4 columnas (pendiente / in-progress / bloqueado / completado). Cada card: id + title (trunc 60) + priority badge + first domain chip. Drag-drop con `ImGui::BeginDragDropSource/Target` -> PATCH status.
|
||||
2. **Flows** (`TI_FLOW " Flows"`) — `data_table_cpp_viz` con columnas id/title/status/kind. Click fila → carga detail.
|
||||
3. **Filters** (`TI_FUNNEL " Filters"`) — AppShell.Aside-equivalente (panel lateral fijo). Multi-select por domain, scope, priority, tags. Estado local; rebuild request query.
|
||||
4. **Detail** (`TI_INFO " Detail"`) — modal/panel lateral con form: status (combo), priority (combo), scope (combo), tags (chips editables), depends/blocks (listas), body (read-only multiline).
|
||||
|
||||
## HTTP client (data.cpp)
|
||||
|
||||
- `fetch_issues(filters)` → GET con query string → parse JSON → vector<Issue>.
|
||||
- `fetch_flows()` → similar.
|
||||
- `patch_issue(id, partial)` → PATCH JSON → recibe issue actualizado.
|
||||
- `subscribe_sse()` thread aparte → push events a queue mutex → consumir en main loop → re-fetch afectados.
|
||||
|
||||
Usa `http_request_cpp_core` + `sse_client_cpp_core`. JSON via `nlohmann/json` (ya en cpp/vendor o sacar al header-only).
|
||||
|
||||
## DoD
|
||||
|
||||
- `cmake --build cpp/build/linux --target kanban_cpp -j` verde.
|
||||
- `./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test` exit 0:
|
||||
- inicializa contexto ImGui sin display.
|
||||
- parsea respuesta JSON sintetica.
|
||||
- no toca red salvo si `--backend http://...` se pasa.
|
||||
- e2e_checks en `app.md`: build + self_test + backend_health (corre backend en background) + smoke (drag-drop reescribe MD).
|
||||
- Captura screenshot board con 4 columnas pobladas → guardar en `dod_evidence/board_screenshot.png`.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- Sin grafo de dependencias (epic 0130 lo describe como anti-scope v1).
|
||||
- Sin crear issues nuevos (solo editar existentes).
|
||||
- Sin edicion de body MD (solo frontmatter).
|
||||
- Sin syntax highlighting markdown.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: "0133"
|
||||
title: "data_table: optimizar para 10M filas sin caida de FPS (finalize modulo)"
|
||||
status: pendiente
|
||||
type: refactor
|
||||
domain:
|
||||
- cpp-stack
|
||||
- data-ingest
|
||||
scope: app-scoped
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related:
|
||||
- "0081"
|
||||
- "0097"
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, performance, data_table, finalize]
|
||||
flow: ""
|
||||
---
|
||||
|
||||
# 0133 — data_table 10M rows sin caida FPS
|
||||
|
||||
**Status:** pendiente
|
||||
|
||||
## Por que
|
||||
|
||||
`data_table_cpp_viz` (modulo `fn_module_data_table` / `fn_table_viz`) actualmente maneja decenas de miles de filas con `ImGuiListClipper` y rinde bien. Apps reales (call_monitor con telemetria, services_monitor con escalado, futuro graph_explorer con nodos) ya nos llevan a millones de filas. Objetivo: cerrar el modulo con benchmark estable de **10M filas a >=60fps** en hardware tipico (Ryzen 5 / i5 8th gen + 16GB).
|
||||
|
||||
## Que entrega
|
||||
|
||||
Refactor del modulo manteniendo API publica + un benchmark suite.
|
||||
|
||||
### Cambios tecnicos
|
||||
|
||||
1. **Storage columnar** — hoy `std::vector<std::vector<Cell>>` row-major. Cambiar a column-major (`Column { type; vector<T> data }`) para localidad de cache + iteracion. Las celdas se materializan solo para las filas visibles.
|
||||
2. **String interning** — columnas de tipo string usan tabla de strings global con `uint32_t` indices. 10M filas con 50% strings repetidas → ahorra 60-70% RAM.
|
||||
3. **Lazy filter/sort indices** — en vez de re-ordenar el storage, mantener `vector<uint32_t> visible_rows` que apunta al storage subyacente. Filter/sort solo reescribe ese vector.
|
||||
4. **Computed columns en bloques** — `compute_stage_cpp_core` ahora corre por cell; cambiar a procesar bloques de 1024 filas con SIMD via `OpenMP` (ya esta linkeado en fn_framework).
|
||||
5. **Render path** — `ImGuiListClipper` sigue siendo el frontend, pero el callback de render no debe asignar memoria por fila. Pre-formatear strings de display en `column.display_cache[row_idx]` con LRU de 100k entradas; resto se formatea on-the-fly.
|
||||
6. **Color rules** — `data_table_color_rules_cpp_viz` se evalua hoy por celda visible. Cachear el rule_id resuelto por row_idx tras primer paint.
|
||||
7. **Stats** — `compute_column_stats_cpp_core` solo se recalcula cuando cambia el filtro, no cada frame.
|
||||
|
||||
### Benchmark suite
|
||||
|
||||
`cpp/apps/data_table_bench/`:
|
||||
- Genera dataset sintetico 10M filas x 20 cols (mix int/float/string/timestamp).
|
||||
- Mide FPS sostenido durante:
|
||||
- scroll lineal full range (down → bottom).
|
||||
- filter por string match (`LIKE %foo%`).
|
||||
- sort por columna numerica.
|
||||
- color rule `value > p95`.
|
||||
- Output: `fps_p50`, `fps_p1`, `mem_rss_mb`, `cpu_pct`.
|
||||
- Asercion DoD: `fps_p1 >= 60` en cada escenario.
|
||||
|
||||
## DoD
|
||||
|
||||
- Refactor entregado sin romper apps consumidoras (call_monitor, services_monitor, graph_explorer, navegator_dashboard, kanban_cpp future).
|
||||
- Benchmark suite ejecutable: `./data_table_bench --rows 10000000 --duration 30`.
|
||||
- Resultados de benchmark guardados en `apps/data_table_bench/operations.db` con assertion `fps_p1 >= 60`.
|
||||
- `e2e_checks` corriendo benchmark con dataset reducido (100k filas) en CI; full bench manual.
|
||||
- Modulo marcado `version: 1.0.0` y `tags: [stable]` en su `.md`.
|
||||
- Guia "porting old call sites" si la API publica cambia (en `cpp/functions/viz/data_table/MIGRATION.md`).
|
||||
|
||||
## Anti-scope
|
||||
|
||||
- Sin GPU rendering (sigue siendo CPU + ImGui).
|
||||
- Sin paginacion remota (sigue todo in-memory).
|
||||
- Sin streaming append-while-rendering (snapshot al frame inicio).
|
||||
- Sin virtualizacion horizontal (todas las cols se renderizan; assumed N_cols <= 100).
|
||||
|
||||
## Notas
|
||||
|
||||
Issue 0081 introdujo la migracion inline → modulo. Issue 0097 cerro el wrapping en fn_module/fn_table_viz. Esta issue es el **finalize**: lo deja `1.0.0` con benchmark + suficiente performance para que las apps de telemetria/graph no necesiten paginar manual.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: "0147"
|
||||
title: "matrix-client-pc scaffold: Wails + React+Mantine + login MAS"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0148", "0162"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, wails, react, mantine, mas, oidc, scaffold]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear el esqueleto de la app `projects/element_agents/apps/matrix_client_pc/` con Wails v2 (Go) + React+Vite+Mantine+`@fn_library` y dejar funcionando el login MAS OIDC contra `mas-...organic-machine.com`. Resultado: arrancar binario -> redirect navegador a MAS -> volver con token -> mostrar perfil del usuario.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `wails init -n matrix_client_pc -t react-ts` dentro de `projects/element_agents/apps/`.
|
||||
2. Sub-repo Gitea: `git init -b master` + crear repo `dataforge/matrix_client_pc` + push inicial.
|
||||
3. `app.md` con frontmatter (lang=go, framework=wails, tags incluyen `matrix` + `service`? — NO, es app cliente, sin tag service).
|
||||
4. `go.mod` con deps: `wails/v2`, `mautrix-go`, `keyring`.
|
||||
5. Reemplazar template frontend por React+Mantine+`@fn_library`. Symlink `frontend/src/fn_library` -> `../../../../../frontend/functions/ui/` (o copia si symlink no funciona en build).
|
||||
6. Backend Go (`backend/`):
|
||||
- `wails.json` con `bindings` para `MatrixService`.
|
||||
- `MatrixService.Login() -> URL` (devuelve URL MAS OIDC).
|
||||
- `MatrixService.HandleCallback(code) -> User`.
|
||||
- `MatrixService.GetSession() -> *Session` (lee de keyring).
|
||||
- `MatrixService.Logout()`.
|
||||
7. Frontend React: layout `AppShell` Mantine, pagina `Login.tsx` con boton "Sign in with Matrix" -> abre URL MAS en navegador del SO.
|
||||
8. Persistencia tokens en keyring SO (`github.com/zalando/go-keyring`).
|
||||
9. Loopback HTTP local (`127.0.0.1:0`, puerto libre aleatorio) para recibir callback OIDC.
|
||||
10. Test e2e basico: arrancar app, login con `@dev-pc:matrix-af2f3d.organic-machine.com`, ver perfil.
|
||||
|
||||
## Funciones del registry a crear (delegar a fn-constructor)
|
||||
|
||||
- `matrix_client_init_go_infra` — `mautrix.NewClient(homeserver, userID, accessToken) -> *Client, error`. Wrapper que configura SQLite store + crypto store.
|
||||
- `mas_oidc_flow_go_infra` — `StartFlow(masURL) -> authURL, codeVerifier, state`. `ExchangeCode(code, codeVerifier) -> *Token`.
|
||||
- `keyring_save_token_go_infra` / `keyring_load_token_go_infra` — wrappers `go-keyring`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Binario Wails compila para linux/amd64 + windows/amd64.
|
||||
- [ ] `wails dev` arranca con hot-reload.
|
||||
- [ ] Login MAS OIDC end-to-end: boton -> navegador -> consent -> callback -> perfil visible.
|
||||
- [ ] Token persistido entre re-arranques (no re-login si token vigente).
|
||||
- [ ] `app.md` con `uses_functions` que apunta a las 3 funciones nuevas.
|
||||
- [ ] Sub-repo `dataforge/matrix_client_pc` creado con commit inicial.
|
||||
|
||||
## Notas
|
||||
|
||||
- MAS URL: leerla de `.well-known/matrix/client` del homeserver para no hardcodear.
|
||||
- Refresh token: MAS usa OAuth 2.0 estandar — implementar refresh proactivo (~5min antes de expiry).
|
||||
- Gotcha: en Windows, `wails dev` requiere WebView2 instalado.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: "0148"
|
||||
title: "matrix-client-pc rooms list + timeline con sync incremental"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0147", "0149"]
|
||||
dependencies: ["0147"]
|
||||
tags: [matrix, sync, timeline, rooms, react, mantine, sse]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Sidebar con rooms (DMs + spaces + grupos) + panel central con timeline del room activo. Sync incremental con Synapse via long-poll `/sync`. Stream eventos backend -> frontend via SSE (`http_sse_server_go_infra`). Pagination scroll-up (cargar mensajes anteriores). Optimistic UI al enviar.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.StartSync()` — long-poll `/sync` con since token persistido.
|
||||
- `MatrixService.SubscribeEvents() -> chan Event` — broadcaster events a frontend.
|
||||
- SSE endpoint `http://127.0.0.1:<puerto>/events` (autenticado con cookie session local).
|
||||
- Persistir state en SQLite (`store.db`): rooms, members, last_event_id por room.
|
||||
2. Frontend React:
|
||||
- Hook `useMatrixRooms()` — devuelve `Room[]` ordenadas por last_activity.
|
||||
- Hook `useMatrixTimeline(roomId, limit=50)` — devuelve eventos + `loadMore()`.
|
||||
- Componente `RoomList` (sidebar con avatar, nombre, last_msg preview, unread badge).
|
||||
- Componente `Timeline` con `react-virtuoso` para scroll perf con miles de msgs.
|
||||
- Componente `EventBubble` (text, image, file, redacted, reaction agregada).
|
||||
- Reconnect automatico si SSE/sync cae (exponential backoff).
|
||||
3. Tests:
|
||||
- `e2e/test_sync_basic.sh` — login + verificar que 3 rooms aparecen en sidebar.
|
||||
- `e2e/test_pagination.sh` — scroll-up carga mensajes anteriores sin gap.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_room_subscribe_go_infra` — SSE wrapper: subscribe events de Synapse y push a clientes.
|
||||
- `useMatrixTimeline_ts_ui` — hook React con dedupe + pagination + optimistic.
|
||||
- `useMatrixRooms_ts_ui` — hook React rooms list.
|
||||
- `RoomList_ts_ui` — componente sidebar Mantine.
|
||||
- `EventBubble_ts_ui` — componente burbuja msg.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Sidebar lista rooms del usuario test, ordenados por actividad.
|
||||
- [ ] Click en room muestra timeline ultimos 50 msgs.
|
||||
- [ ] Scroll arriba carga msgs anteriores sin duplicar.
|
||||
- [ ] Mensaje enviado desde Element Web aparece en <2s en la timeline.
|
||||
- [ ] Cerrar app + abrir: state restaurado desde SQLite, no re-sync completo.
|
||||
- [ ] Network kill + restore: sync se reanuda sin perder mensajes.
|
||||
|
||||
## Notas
|
||||
|
||||
- DMs vs rooms grupales: detectar via `m.direct` account data.
|
||||
- Spaces (`m.space`): mostrar como grupos colapsables en sidebar.
|
||||
- Edits + redactions: aplicar in-place, no duplicar bubble.
|
||||
- Read receipts: TBD en otro issue, no bloquea este.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: "0149"
|
||||
title: "matrix-client-pc composer: markdown, reply, edit, reactions, media"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0148", "0150"]
|
||||
dependencies: ["0148"]
|
||||
tags: [matrix, composer, markdown, media, reactions, threads]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Composer del room: markdown rendering, replies con quote, edits, reactions emoji, threads (Matrix MSC3440), upload de media (imagenes, files, voice msg). Drag&drop archivos. Slash commands placeholder (`/me`, `/shrug`, `/widget` — este ultimo para issue 0152).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.SendMessage(roomID, body, format)` — text + markdown -> HTML via `goldmark`.
|
||||
- `MatrixService.SendReply(roomID, parentEventID, body)`.
|
||||
- `MatrixService.EditMessage(roomID, eventID, newBody)`.
|
||||
- `MatrixService.SendReaction(roomID, eventID, key)`.
|
||||
- `MatrixService.UploadMedia(roomID, filePath) -> mxc://`.
|
||||
- `MatrixService.SendThreadReply(roomID, threadRootID, body)`.
|
||||
2. Frontend React:
|
||||
- Componente `Composer` con Mantine `Textarea` + toolbar markdown.
|
||||
- Hotkeys: Cmd+B/I/K, Cmd+Enter para enviar, Esc cancel edit.
|
||||
- Drag&drop zone over Composer + paste image desde clipboard.
|
||||
- `EmojiPicker` (reusar `@emoji-mart/react` o componente propio `@fn_library`).
|
||||
- `ReactionBar` debajo de EventBubble con aggregates.
|
||||
- Thread panel lateral (abrir click en evento "X replies").
|
||||
- Voice messages: graba con `MediaRecorder` (opus codec), upload + send con `org.matrix.msc3245.voice` flag.
|
||||
3. Tests:
|
||||
- `e2e/test_send_markdown.sh` — `**bold**` aparece negrita en otro cliente.
|
||||
- `e2e/test_edit_message.sh` — edicion aparece in-place en Element Web.
|
||||
- `e2e/test_reaction.sh` — reaccion emoji propagada bidireccional.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `markdown_to_matrix_html_go_core` — `goldmark` con sanitizer Matrix-compatible.
|
||||
- `Composer_ts_ui` — componente Mantine + dropzone.
|
||||
- `EmojiPicker_ts_ui` — wrapper picker emoji.
|
||||
- `ReactionBar_ts_ui` — componente reactions aggregadas.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Mensaje markdown `**negrita** _cursiva_` se ve formateado en Element Web.
|
||||
- [ ] Reply quote aparece referenciando el msg padre.
|
||||
- [ ] Edit cambia el msg in-place en ambos clientes.
|
||||
- [ ] Reaccion emoji con click aparece como counter agregado.
|
||||
- [ ] Upload imagen (PNG 2MB) se ve thumbnail + click abre full.
|
||||
- [ ] Voice msg grabado 5s reproduce OK en Element Web.
|
||||
- [ ] Thread: 5 replies anidados se muestran en panel lateral.
|
||||
|
||||
## Notas
|
||||
|
||||
- Sanitizer HTML: usar allowlist Matrix (b, i, em, strong, a[href], code, pre, blockquote, ul, ol, li, br, p, h1-h6). NO permitir `<script>`, `<iframe>`, event handlers.
|
||||
- mxc:// uploads: validar size limit (Synapse default 50MB).
|
||||
- Voice msg: encode opus 32kbps, max 5min.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "0150"
|
||||
title: "matrix-client-pc E2EE: cross-signing, SAS verification, recovery"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0149", "0151"]
|
||||
dependencies: ["0149"]
|
||||
tags: [matrix, e2ee, olm, megolm, cross-signing, recovery, security]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Encriptacion end-to-end con `mautrix-go` (Olm/Megolm). Cross-signing keys (master/self-signing/user-signing), SAS verification de devices (emoji + decimal), recovery passphrase + key backup en Synapse, manejo de devices no verificados con warning visible. Mensajes en rooms encriptados se envian y descifran correctamente.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.BootstrapCrossSigning(passphrase)` — genera master/self/user keys, sube a Synapse cifradas con passphrase-derived key.
|
||||
- `MatrixService.RecoverFromPassphrase(passphrase)` — descarga keys de Synapse y descifra.
|
||||
- `MatrixService.StartVerification(userID, deviceID) -> *VerificationSession`.
|
||||
- `MatrixService.VerifyEmoji(sessionID, accepted bool)`.
|
||||
- `MatrixService.ListDevices() -> []Device` (con verified flag).
|
||||
- `MatrixService.BackupMegolmKeys()` — key backup server-side.
|
||||
- Crypto store SQLite separado del state store (mejor para integridad).
|
||||
2. Frontend React:
|
||||
- Wizard onboarding E2EE: pasos (1) generar passphrase, (2) backup, (3) verificar device.
|
||||
- Panel `Settings > Security & Privacy`:
|
||||
- Lista devices propios con verified state.
|
||||
- Boton "Verify new device" + dialog SAS con emoji grid.
|
||||
- "Reset cross-signing" (destructive, requiere confirmacion).
|
||||
- "Restore from passphrase" (login en device nuevo).
|
||||
- `EventBubble` muestra shield: green (verified), amber (encrypted, device unverified), red (decryption failed).
|
||||
- Banner room: "X devices are not verified" si algun miembro tiene devices unverified.
|
||||
3. Tests:
|
||||
- `e2e/test_e2ee_send_receive.sh` — msg enviado en room encriptado se descifra en Element Web.
|
||||
- `e2e/test_cross_signing.sh` — bootstrap + verificar device desde Element Web.
|
||||
- `e2e/test_recovery.sh` — login en device nuevo + recover keys con passphrase.
|
||||
- `e2e/test_unverified_warning.sh` — device nuevo aparece como warning en otros clientes.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_e2ee_bootstrap_go_infra` — wrapper cross-signing bootstrap.
|
||||
- `matrix_device_verify_go_infra` — SAS verification flow.
|
||||
- `matrix_key_backup_go_infra` — server-side key backup wrapper.
|
||||
- `passphrase_derive_key_go_infra` — PBKDF2/scrypt para derivar key de passphrase.
|
||||
- `VerificationDialog_ts_ui` — componente emoji grid SAS.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Bootstrap cross-signing crea 3 keys + las sube a Synapse cifradas.
|
||||
- [ ] Msg enviado a room encriptado se descifra en Element Web (y al reves).
|
||||
- [ ] SAS verification con emoji grid funciona contra Element Web (ambos lados muestran 7 emojis iguales).
|
||||
- [ ] Login en device nuevo + restore con passphrase recupera msgs historicos.
|
||||
- [ ] Device no verificado dispara shield amber en EventBubble.
|
||||
- [ ] Decryption failure (key no disponible) muestra shield rojo + boton "Request key".
|
||||
|
||||
## Notas
|
||||
|
||||
**Critico — anti-criterio:**
|
||||
- NO marcar done si E2EE silent-falla (msg muestra "** Unable to decrypt **" sin shield rojo claro).
|
||||
- NO marcar done si recovery passphrase queda en plain text en disco (debe vivir solo en keyring/memoria).
|
||||
|
||||
**Decisiones:**
|
||||
- Olm/Megolm via `mautrix-go/crypto` (Go port estable de libolm).
|
||||
- Alternativa rust-crypto via CGo: descartada, mantiene complejidad build.
|
||||
- Passphrase format: 4 palabras Diceware o 12-byte base32. Usuario elige al bootstrap.
|
||||
|
||||
**Gotchas:**
|
||||
- Key rotation: rooms encriptados rotan megolm cada 1 semana o 100 msgs (default). Manejar refresh.
|
||||
- Olm sessions max 100 mensajes: rotar prekey bundles automaticamente.
|
||||
- Cuando arrancas device nuevo sin passphrase, los msgs pre-existentes NO se descifran — UI debe ser clara.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: "0151"
|
||||
title: "matrix-client-pc calls LiveKit: 1:1 + grupales, mic/cam/screen"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0150", "0152"]
|
||||
dependencies: ["0150"]
|
||||
tags: [matrix, livekit, calls, webrtc, video, audio, screen-share]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Llamadas via LiveKit SFU (ya activo en `organic-machine.com:7880-7882`). Backend Go genera JWT con `livekit-server-sdk-go`. Frontend React usa `livekit-client` JS para join room, manejar tracks (mic/cam/screen), UI con tiles participantes, controles. Soporta 1:1 + grupales hasta 16 (limite config actual).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.RequestCallToken(matrixRoomID) -> (token, livekitRoomURL)`.
|
||||
- Mapea Matrix roomID -> LiveKit room name (hash determinista).
|
||||
- Genera JWT con claim `room`, `identity` (matrix userID), `ttl 30min`.
|
||||
- Permisos: `canPublish=true, canSubscribe=true, canPublishData=true`.
|
||||
- Publicar event Matrix `m.call.member` para sincronizar quien esta en call (MSC3401).
|
||||
2. Frontend React:
|
||||
- Hook `useLiveKitCall(matrixRoomID)`:
|
||||
- Pide token al backend.
|
||||
- Conecta `Room` de `livekit-client`.
|
||||
- Expone participants, tracks, localTracks, state.
|
||||
- Auto-publish microfono on connect (mute default).
|
||||
- Componente `CallPanel`:
|
||||
- Grid tiles participantes (1, 2, 4, 9, 16 layout).
|
||||
- Tile principal con speaker activo (active-speaker detection del SDK).
|
||||
- Controles bottom: mic, cam, screen share, raise hand, leave.
|
||||
- PiP mode: cuando minimizado, tile flotante en esquina.
|
||||
- Boton "Start call" en header del room (icono telefono).
|
||||
- Boton "Join call" si hay call activa (segun `m.call.member` events).
|
||||
- Notifs ring incoming call: audio + desktop notif.
|
||||
3. Backend ICE/TURN:
|
||||
- Verificar LiveKit config tiene TURN configurado (NAT traversal). Si no, anadir coturn container.
|
||||
4. Tests:
|
||||
- `e2e/test_call_1to1.sh` — 2 clientes (Wails + Element Web), 30s call, audio+video flow.
|
||||
- `e2e/test_call_screen_share.sh` — compartir pantalla, otro cliente ve el track.
|
||||
- `e2e/test_call_4_participants.sh` — 4 clientes simultaneos, no crash.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `livekit_token_gen_go_infra` — JWT generator con `livekit-server-sdk-go`.
|
||||
- `matrix_call_member_go_infra` — wrapper para publicar/leer `m.call.member` state events.
|
||||
- `useLiveKitCall_ts_ui` — hook React.
|
||||
- `CallPanel_ts_ui` — componente UI completo de call.
|
||||
- `CallTile_ts_ui` — tile individual con video + nombre + speaker indicator.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Boton "Start call" en room DM con otro user.
|
||||
- [ ] Otro cliente (Element Web) ve ring + acepta -> 2 tiles con video+audio.
|
||||
- [ ] Mute mic + apagar cam funciona y se refleja en el otro lado.
|
||||
- [ ] Screen share: tile separado aparece para todos los participantes.
|
||||
- [ ] 4 participantes simultaneos sin crash ni audio cortado.
|
||||
- [ ] Hangup limpia recursos (no tracks fantasma, no peer connections abiertas).
|
||||
|
||||
## Notas
|
||||
|
||||
- LiveKit room name: `sha256(matrix_room_id + secret)` truncado a 32 chars. Asi cualquier cliente que conozca el matrix_room_id puede computar el room name (no es secret).
|
||||
- Token TTL 30min, refresh proactivo a los 25min.
|
||||
- Codecs: H.264 + VP8 fallback para compatibilidad navegadores. Audio: Opus 32kbps.
|
||||
- E2EE en calls: LiveKit soporta E2EE simetrico (insertable streams API). TBD para version posterior — flow inicial usa SRTP only (cifrado SFU<->client, no e2e).
|
||||
- Sygnal push para incoming calls: enviar VoIP push con TTL bajo para wake-up moviles (relevante para issue 0158 Android).
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: "0152"
|
||||
title: "matrix-client-pc mini-webapps embebidas: Matrix Widget API v2"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010"]
|
||||
related_issues: ["0151", "0153"]
|
||||
dependencies: ["0151"]
|
||||
tags: [matrix, widgets, webapps, iframe, sandbox, agents, postmessage]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar host de widgets segun Matrix Widget API v2 (MSC2762, MSC2871, MSC2974). Cada room puede tener widgets activos publicados como state events `m.widget`. Los widgets son URLs cargadas en iframes sandboxed con bridge postMessage que da capabilities controladas (leer eventos del room, enviar eventos, mostrar UI overlay, etc.). Agentes de `agents_and_robots` pueden publicar widgets en sus rooms (ej. dashboard telemetria, formulario, kanban inline, panel de control del agente).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.ListWidgets(roomID) -> []Widget` — lee state events `m.widget` del room.
|
||||
- `MatrixService.AddWidget(roomID, widget Widget)` — publica state event.
|
||||
- `MatrixService.RemoveWidget(roomID, widgetID)`.
|
||||
- `MatrixService.GenerateWidgetURL(widget Widget, userID) -> string` — substituye `$matrix_user_id`, `$matrix_room_id`, `$matrix_display_name`, `$matrix_avatar_url`, `$matrix_widget_id`, `$theme` en la URL del widget.
|
||||
- Slash command `/widget <url>` handler en composer (issue 0149) que crea state event con widget temporal.
|
||||
- `MatrixService.MintWidgetScopedToken(widgetID, userID) -> string` — token efimero con scope reducido (solo el room donde esta el widget).
|
||||
2. Frontend React:
|
||||
- Hook `useWidgets(roomID)` — lista widgets activos.
|
||||
- Componente `WidgetPanel`:
|
||||
- Tabs por widget activo + boton "+" para anadir.
|
||||
- Cada widget en iframe con `sandbox="allow-scripts allow-same-origin allow-forms allow-popups-to-escape-sandbox"`.
|
||||
- `iframe.referrerpolicy="no-referrer"`.
|
||||
- CSP: `frame-src https: data: blob:`.
|
||||
- `WidgetBridge` — clase JS que escucha `postMessage` del iframe e implementa Widget API v2:
|
||||
- `capabilities` handshake: el widget declara que necesita, el host pide consentimiento usuario (dialog Mantine).
|
||||
- `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`, etc.
|
||||
- Whitelist estricta de capabilities concedidas. Audit log de mensajes en `store.db`.
|
||||
- Layout: widgets se abren en panel lateral derecho (toggleable) o en modal fullscreen.
|
||||
3. Widgets internos primer batch (proof of concept):
|
||||
- `widget-jitsi-fallback` — si LiveKit falla, fallback a Jitsi via widget (URL config).
|
||||
- `widget-agent-panel` — panel de control de agente: estado, ultima ejecucion, restart, view logs. Servido por `agents_and_robots` HTTP API (issue 0113 ya creando agent runner API).
|
||||
- `widget-kanban` — kanban inline embebido para tasks del room. Reusa `apps/kanban` (Go) servido en LAN.
|
||||
- `widget-issue-tracker` — widget que abre issue API (`0109m`).
|
||||
4. Tests:
|
||||
- `e2e/test_widget_capabilities.sh` — widget pide capability, dialog aparece, deniega/acepta funciona.
|
||||
- `e2e/test_widget_send_event.sh` — widget con capability `send_event` envia msg al room.
|
||||
- `e2e/test_widget_sandbox.sh` — widget malicioso (intenta `top.location =`) es bloqueado por sandbox.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_widget_state_go_infra` — CRUD state events `m.widget`.
|
||||
- `widget_url_template_go_core` — substituye placeholders en URL.
|
||||
- `widget_token_mint_go_infra` — token scoped a un widget+room+user.
|
||||
- `WidgetBridge_ts_ui` — clase postMessage bridge Widget API v2 completa.
|
||||
- `WidgetPanel_ts_ui` — UI tabs + iframes + permisos.
|
||||
- `CapabilityConsentDialog_ts_ui` — dialog Mantine para consentimiento.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `/widget https://my.app` crea state event y abre iframe.
|
||||
- [ ] Widget declara capability `m.send_event` -> dialog Mantine pide consentimiento.
|
||||
- [ ] Widget concedido envia msg al room que aparece en timeline.
|
||||
- [ ] Widget malicioso `<script>top.location='evil.com'</script>` bloqueado por sandbox.
|
||||
- [ ] `agents_and_robots` publica widget panel y se ve embebido en el room del agente.
|
||||
- [ ] Widget kanban inline funciona: drag&drop card persiste en DB del kanban.
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO permitir `javascript:` ni `data:text/html` URLs (XSS).
|
||||
- NO conceder capabilities sin consentimiento explicito del usuario (auditable).
|
||||
- NO compartir el access_token Matrix del usuario al widget — usar siempre tokens scoped efimeros.
|
||||
|
||||
**Decisiones:**
|
||||
- Widget API v2 (no v1) — soporta capabilities + tokens scoped.
|
||||
- iframe sandbox sin `allow-top-navigation` (previene escape).
|
||||
- CSP `frame-src https:` + permitir `data:`/`blob:` solo para widgets internos firmados.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Widget marketplace interno: `widget-catalog` en `agents_and_robots` con widgets internos descubribles.
|
||||
- Widget templates: un agente publica un widget HTML estatico subido al room (`mxc://`) y el cliente lo renderiza desde la URL `mxc -> http`.
|
||||
- Cross-room widgets: widget que persiste entre rooms (TBD, requiere MSC propio).
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: "0153"
|
||||
title: "matrix-client-pc agent integration: paneles para rooms operados por agentes"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0009"]
|
||||
related_issues: ["0152"]
|
||||
dependencies: ["0152"]
|
||||
tags: [matrix, agents, agents_and_robots, dashboard, sse, device_agent]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Integracion nativa con `agents_and_robots` + `agents_dashboard` + futuro `device_agent` (flow 0009 mesh). Detectar que un room esta operado por un agente Matrix conocido (via state event custom `m.agent.metadata`) y mostrar panel lateral con info del agente: uptime, ultima ejecucion, cola de tasks, last_error, boton restart, view logs en vivo (SSE). Atajos: enviar slash commands del agente (`/agent restart`, `/agent skill <name>`).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend Go:
|
||||
- `MatrixService.GetAgentMetadata(roomID) -> *AgentMetadata` — lee state event `m.agent.metadata` que el agente publica al arrancar.
|
||||
- `MatrixService.SubscribeAgentLogs(agentID) -> chan LogLine` — SSE proxy al endpoint `agents_and_robots /api/agents/<id>/logs` ya existente (issue 0113).
|
||||
- Llamadas REST proxy a `agents_and_robots`: `RestartAgent(agentID)`, `ListSkills(agentID)`, `TriggerSkill(agentID, skill, args)`.
|
||||
2. Frontend React:
|
||||
- Hook `useAgentMetadata(roomID)` — devuelve `null` si no es room de agente.
|
||||
- Componente `AgentPanel` (panel lateral colapsable, solo visible si hay agentMetadata):
|
||||
- Card con avatar, nombre, version, uptime, status (running/stopped/error).
|
||||
- Tabs: "Logs" (live SSE), "Skills" (lista de skills disponibles + boton trigger), "Config" (read-only del config.yaml del agente).
|
||||
- Boton restart con confirmacion.
|
||||
- Componente `LogStream` — termtinal-like log viewer con auto-scroll + filtro grep.
|
||||
- Slash commands custom: `/agent restart`, `/agent skill <name> <args>`, `/agent logs`.
|
||||
3. Cuando flow 0009 (mesh) este vivo:
|
||||
- Detectar `device_agent` rooms (state event `m.device.metadata` con tipo `device_agent`).
|
||||
- Panel especifico `DevicePanel`: hostname, OS, kernel, IP mesh WG, capabilities firmadas, ultimo heartbeat.
|
||||
- Slash commands: `/device shell <cmd>` (si capability permite), `/device fs ls <path>`, `/device camera capture`.
|
||||
4. Tests:
|
||||
- `e2e/test_agent_panel_basic.sh` — entrar a room de `welcome-bot`, panel agente visible con info correcta.
|
||||
- `e2e/test_agent_logs_live.sh` — boton "view logs" stream logs en tiempo real (5s).
|
||||
- `e2e/test_agent_restart.sh` — restart desde panel + verificar agente vuelve online.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_agent_metadata_go_infra` — leer/publicar state event `m.agent.metadata`.
|
||||
- `agents_and_robots_client_go_infra` — wrapper REST + SSE del API de `agents_and_robots`.
|
||||
- `AgentPanel_ts_ui` — panel lateral Mantine con tabs.
|
||||
- `LogStream_ts_ui` — viewer logs SSE.
|
||||
- `DevicePanel_ts_ui` — panel device_agent (cuando flow 0009 vivo).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Room operado por agente conocido muestra `AgentPanel` automatico.
|
||||
- [ ] Logs en vivo del agente aparecen en panel (SSE).
|
||||
- [ ] Restart desde panel funciona end-to-end.
|
||||
- [ ] Slash `/agent skill greet` ejecuta skill remota y respuesta llega como msg al room.
|
||||
- [ ] Room NO operado por agente: panel oculto (no clutter).
|
||||
|
||||
## Notas
|
||||
|
||||
- State event `m.agent.metadata` format: `{ agent_id, version, capabilities[], owner, repo_url }`. Documentar en `projects/element_agents/docs/agent_metadata.md`.
|
||||
- SSE proxy: el cliente PC habla a `agents_and_robots` via su DNS publica (`agents.organic-machine.com`) con auth Bearer (token del usuario Matrix + scope `agent_panel`).
|
||||
- Permisos: solo el `owner` declarado en el agente puede ejecutar restart/trigger. Otros users del room solo leen.
|
||||
- Gotcha: si el agente se rebuilds y cambia `agent_id`, el state event queda obsoleto — necesita TTL o heartbeat.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: "0154"
|
||||
title: "matrix-client-android scaffold: Kotlin + Compose + login MAS"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0155", "0162"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, android, kotlin, compose, mas, oidc, scaffold]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear `projects/element_agents/apps/matrix_client_android/` con `init_kotlin_app` (pipeline ya existente del registry). Configurar Compose + Material 3 + tema propio. Implementar login MAS OIDC via Chrome Custom Tabs. Tokens persistidos en EncryptedSharedPreferences. Resultado: APK debug que abre Custom Tab al MAS, retorna con token y muestra perfil del usuario.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `./fn run init_kotlin_app matrix_client_android` — usa pipeline existente del registry (ver issues completados 0073-0078).
|
||||
2. Sub-repo Gitea: `git init -b master` + crear `dataforge/matrix_client_android` + push inicial. **Antes** de salir del worktree (ver `apps_subrepo.md`).
|
||||
3. `app.md` con frontmatter:
|
||||
- `lang: kotlin`, `framework: jetpack-compose`, `dir_path: projects/element_agents/apps/matrix_client_android`.
|
||||
- `tags: [matrix, android, kotlin, compose]`.
|
||||
- `uses_functions: []` (irlo rellenando issue a issue).
|
||||
4. `build.gradle.kts`:
|
||||
- `compileSdk = 34`, `minSdk = 28`, `targetSdk = 34`.
|
||||
- Compose BOM `2024.x`.
|
||||
- `matrix-rust-sdk` Kotlin bindings (`org.matrix.rustcomponents:sdk-android:0.x`).
|
||||
- `androidx.security:security-crypto` para EncryptedSharedPreferences.
|
||||
- `androidx.browser:browser` para Chrome Custom Tabs.
|
||||
5. Login MAS:
|
||||
- `LoginActivity` con boton "Sign in with Matrix".
|
||||
- Generar PKCE code_verifier + state.
|
||||
- Abrir Chrome Custom Tab a `<mas_url>/oauth/authorize?...`.
|
||||
- `MainActivity` con intent-filter para `matrix-client-android://callback` redirect.
|
||||
- Intercambiar code -> access_token + refresh_token.
|
||||
- Guardar en EncryptedSharedPreferences (`SecurityCryptoUserPrefs`).
|
||||
6. `HomeScreen` Compose con `Text("Hola @<userId>")` + boton Logout.
|
||||
7. Tema Material 3 propio (paleta accent acorde a flow 0010 cliente PC para coherencia).
|
||||
8. Test instrumented: `LoginInstrumentedTest` que mocka MAS y verifica flow callback -> token saved.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_client_kotlin_infra` — facade sobre `matrix-rust-sdk` (init, login, sync, logout).
|
||||
- `mas_oidc_kotlin_infra` — Chrome Custom Tabs + PKCE + callback handler.
|
||||
- `encrypted_prefs_kotlin_core` — wrapper EncryptedSharedPreferences (idempotente, generic put/get).
|
||||
- `LoginScreen_kotlin_ui` — Compose screen Material 3.
|
||||
- `HomeScreen_kotlin_ui` — Compose screen perfil + logout.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `./gradlew assembleDebug` produce APK valido.
|
||||
- [ ] APK instala en Android 9+ y arranca.
|
||||
- [ ] Login: boton -> Custom Tab MAS -> consent -> callback -> perfil visible.
|
||||
- [ ] Token persiste entre re-aperturas (no re-login si vigente).
|
||||
- [ ] `app.md` con frontmatter completo + 5 `uses_functions`.
|
||||
- [ ] Sub-repo `dataforge/matrix_client_android` con commit inicial.
|
||||
- [ ] Test instrumented `LoginInstrumentedTest` pasa en emulator API 31.
|
||||
|
||||
## Notas
|
||||
|
||||
- Chrome Custom Tabs > WebView para OAuth (security: comparte cookies con browser principal del user, mejor UX).
|
||||
- Refresh token: implementar refresh proactivo 5min antes de expiry (corutina + WorkManager periodic).
|
||||
- Gotcha conocido (ver issue 0074): `local.properties` con `sdk.dir` obligatorio en setup nuevo. El scaffolder lo crea.
|
||||
- Gotcha (issue 0075): Material 3 sin AppCompat — usar `MaterialTheme` directamente, no `Theme.AppCompat.*`.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: "0155"
|
||||
title: "matrix-client-android rooms list + timeline Compose"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0154", "0156"]
|
||||
dependencies: ["0154"]
|
||||
tags: [matrix, android, compose, sync, timeline, rooms]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
UI Compose con `Scaffold` que muestre sidebar drawer con rooms y panel principal con timeline. Sync via `matrix-rust-sdk` (corrutinas + Flow). `LazyColumn` virtualizado para timeline (perf con miles de mensajes). Swipe-to-react en mensajes. Optimistic UI al enviar (en issue 0156).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModels:
|
||||
- `RoomsViewModel(matrixClient)` — expone `StateFlow<List<RoomSummary>>`. Ordenado por `lastActivity`.
|
||||
- `TimelineViewModel(matrixClient, roomId)` — expone `StateFlow<List<TimelineEvent>>` + `loadMore()`.
|
||||
- Persistencia local con Room DB (`androidx.room`) — store rooms + last sync token.
|
||||
2. Compose:
|
||||
- `MainScreen` con `ModalNavigationDrawer`:
|
||||
- Drawer: `RoomList` (LazyColumn con `RoomItem`: avatar, name, last preview, unread badge).
|
||||
- Content: `TimelineScreen(roomId)`.
|
||||
- `TimelineScreen`:
|
||||
- `LazyColumn` con `reverseLayout = true` (mensajes recientes abajo).
|
||||
- `key = { it.eventId }` para evitar re-composiciones.
|
||||
- `LaunchedEffect` con `LazyListState` -> al llegar al top, `viewModel.loadMore()`.
|
||||
- `EventBubble` composables segun tipo (text, image, file, redacted).
|
||||
- `Avatar` composable reusable con cache de imagenes (`Coil`).
|
||||
3. Sync engine:
|
||||
- `MatrixSyncService` (corrutina supervisor scope) que mantiene `client.syncStream()`.
|
||||
- Si pasa a background sin call activa, sync se pausa hasta que vuelve foreground (lifecycle-aware).
|
||||
- Errores de red: backoff exponencial (1s, 2s, 4s ... 60s max).
|
||||
4. Tests:
|
||||
- Instrumented `RoomsListTest` — 3 rooms aparecen en drawer.
|
||||
- Instrumented `TimelinePaginationTest` — scroll-up carga 50 msgs anteriores.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_room_summary_kotlin_infra` — extract `RoomSummary` de matrix-rust-sdk.
|
||||
- `matrix_timeline_kotlin_infra` — Flow de eventos paginados.
|
||||
- `RoomListScreen_kotlin_ui` — Compose drawer rooms.
|
||||
- `TimelineScreen_kotlin_ui` — Compose timeline virtualizado.
|
||||
- `EventBubble_kotlin_ui` — composable burbuja msg.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Drawer lista rooms del usuario test.
|
||||
- [ ] Click en room muestra timeline ultimos 50 msgs.
|
||||
- [ ] Swipe arriba carga msgs anteriores sin gap.
|
||||
- [ ] Msg enviado desde PC (Wails) aparece en Android en <2s.
|
||||
- [ ] Avion mode + restore: sync resume, no msgs perdidos.
|
||||
- [ ] Cerrar app + reopen: state restaurado desde Room DB, no full re-sync.
|
||||
|
||||
## Notas
|
||||
|
||||
- `matrix-rust-sdk` ya gestiona persistencia interna (SQLite + crypto store). Room DB local solo para datos UI-rapidos (room summaries, unread counters).
|
||||
- Read receipts: TBD otro issue.
|
||||
- DMs detectados via `m.direct` account data.
|
||||
- Spaces: `RoomItem` con icono diferente, colapsable.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: "0156"
|
||||
title: "matrix-client-android composer: markdown, replies, edits, reactions, media"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0155", "0157"]
|
||||
dependencies: ["0155"]
|
||||
tags: [matrix, android, compose, composer, markdown, media, voice]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Composer Compose con markdown shortcuts, replies, edits, reactions emoji, threads, upload media (camara nativa, galeria, voice msg con `MediaRecorder` opus). Drag&drop archivos compartidos via share sheet Android.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `ComposerViewModel(matrixClient, roomId)` — methods `sendText`, `sendReply`, `editMessage`, `sendReaction`, `uploadMedia`, `recordVoice`.
|
||||
2. Compose:
|
||||
- `Composer` con `OutlinedTextField` + toolbar (markdown shortcuts B/I/code).
|
||||
- Hotkeys soft keyboard: Send action en IME.
|
||||
- `AttachmentMenu`: botones camara, galeria, file, voice.
|
||||
- `EmojiPicker` overlay (reusar libreria existente o componente propio).
|
||||
- `ReactionBar` debajo de `EventBubble` con aggregates.
|
||||
- `ThreadScreen` — nueva pantalla full para thread (no panel lateral como en PC, por screen real estate movil).
|
||||
- Voice recording UI: hold-to-record con waveform preview + cancelar al deslizar.
|
||||
3. Backend:
|
||||
- Upload media: comprimir imagenes si >2MB antes de upload (`androidx.exifinterface` para preservar orientacion).
|
||||
- Voice: `MediaRecorder` con OPUS, 32kbps, ogg container.
|
||||
- Markdown -> HTML local con `markwon` library (lightweight, no Goldmark equivalente).
|
||||
4. Share intent:
|
||||
- `IntentFilter` para `android.intent.action.SEND` + tipos image/video/text/file -> abre composer del room seleccionado.
|
||||
5. Tests:
|
||||
- Instrumented `SendMarkdownTest` — `**bold**` formateado en Element Web.
|
||||
- Instrumented `EditMessageTest` — edicion in-place propagada.
|
||||
- Instrumented `VoiceMsgTest` — graba 5s + upload + play en Element Web.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `markdown_to_matrix_html_kotlin_core` — wrapper markwon con sanitizer.
|
||||
- `image_compress_kotlin_core` — resize + recompress JPEG.
|
||||
- `voice_record_kotlin_infra` — MediaRecorder opus wrapper.
|
||||
- `Composer_kotlin_ui` — Compose composer + toolbar + attachment menu.
|
||||
- `ReactionBar_kotlin_ui` — composable reactions.
|
||||
- `ThreadScreen_kotlin_ui` — pantalla thread.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Mensaje markdown se ve formateado en Element Web.
|
||||
- [ ] Reply con quote del msg padre.
|
||||
- [ ] Edit in-place propagado en ambos clientes.
|
||||
- [ ] Reaccion emoji bidireccional.
|
||||
- [ ] Upload imagen 5MB -> compresion a ~1MB -> envio + thumbnail OK.
|
||||
- [ ] Voice msg 5s reproducible en Element Web.
|
||||
- [ ] Share intent desde galeria abre composer con imagen pre-cargada.
|
||||
|
||||
## Notas
|
||||
|
||||
- Sanitizer HTML server-side delegado a matrix-rust-sdk (mismo allowlist que cliente PC).
|
||||
- Voice msg: encode opus 32kbps, max 5min.
|
||||
- Markwon vs goldmark: ambos cumplen el rol equivalente en su stack. Salida HTML compatible Matrix.
|
||||
- Drag&drop: en Android = share sheet o picker, no drag&drop nativo como en PC.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: "0157"
|
||||
title: "matrix-client-android E2EE rust-sdk: cross-signing, SAS, recovery"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0156", "0158"]
|
||||
dependencies: ["0156"]
|
||||
tags: [matrix, android, e2ee, rust-sdk, cross-signing, sas, security]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Encriptacion end-to-end con `matrix-rust-sdk` Kotlin bindings (mejor impl Olm/Megolm disponible). Cross-signing keys, SAS verification con emoji, recovery passphrase, key backup server-side. UI para verificar otros usuarios + manejar devices propios.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `SecurityViewModel(matrixClient)`:
|
||||
- `bootstrapCrossSigning(passphrase)`.
|
||||
- `recoverFromPassphrase(passphrase)`.
|
||||
- `startVerification(userId, deviceId) -> VerificationSession`.
|
||||
- `verifyEmoji(sessionId, accepted)`.
|
||||
- `listOwnDevices() -> Flow<List<Device>>`.
|
||||
- `backupMegolmKeys()`.
|
||||
2. Compose:
|
||||
- `OnboardingE2EEScreen` — wizard 3 pasos: generar passphrase, backup, verify primer device.
|
||||
- `SettingsSecurityScreen`:
|
||||
- Lista devices propios con badge verified/unverified.
|
||||
- Dialog SAS con emoji grid 7x1 cuando hay verificacion en curso.
|
||||
- Boton "Reset cross-signing" (destructive, requiere typing "RESET").
|
||||
- Boton "Restore from passphrase".
|
||||
- `EventBubble` con icono shield (green/amber/red).
|
||||
- Banner room con "X devices not verified" si aplica.
|
||||
3. Crypto store:
|
||||
- `matrix-rust-sdk` gestiona internamente. Solo asegurar que `applicationContext.filesDir` es persistente entre upgrades.
|
||||
- Backup local del store (export encriptado) antes de uninstall: feature opcional via "Export to file" en settings.
|
||||
4. Tests:
|
||||
- Instrumented `BootstrapCrossSigningTest`.
|
||||
- Instrumented `VerificationSASTest` con mock peer.
|
||||
- Instrumented `RecoveryFromPassphraseTest`.
|
||||
- E2E manual con Element Web: enviar/recibir msg E2EE, verificar device cross-platform.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `matrix_e2ee_kotlin_infra` — wrapper rust-sdk encryption module.
|
||||
- `passphrase_derive_key_kotlin_core` — PBKDF2 wrapper.
|
||||
- `VerificationDialog_kotlin_ui` — Compose emoji grid SAS.
|
||||
- `OnboardingE2EEScreen_kotlin_ui` — wizard.
|
||||
- `SettingsSecurityScreen_kotlin_ui` — devices + verification UI.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Bootstrap crea cross-signing keys + sube cifradas.
|
||||
- [ ] Msg enviado en room E2EE se descifra en Element Web + cliente PC Wails (y al reves).
|
||||
- [ ] SAS verification con emoji grid vs Element Web: ambos 7 emojis iguales, accept funciona.
|
||||
- [ ] Login device nuevo + restore passphrase recupera msgs historicos.
|
||||
- [ ] Device no verificado dispara shield amber en EventBubble.
|
||||
- [ ] Decryption failure muestra shield rojo + boton "Request key".
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO marcar done si E2EE silent-falla (mensaje no descifrado pero sin warning visible).
|
||||
- NO marcar done si passphrase queda en plain text en disco.
|
||||
- NO marcar done si cross-signing no funciona contra cliente PC Wails (interop critica).
|
||||
|
||||
**Decisiones:**
|
||||
- `matrix-rust-sdk` >> matrix-android-sdk2 (deprecated). Olm/Megolm en Rust = mejor perf + sin memory leaks.
|
||||
- Passphrase format igual que cliente PC (4 palabras Diceware o 12-byte base32).
|
||||
|
||||
**Gotchas:**
|
||||
- Key rotation Megolm: rust-sdk lo gestiona, pero monitorizar logs en primera semana de uso real.
|
||||
- Olm sessions max: rust-sdk auto-rotate, no accion manual.
|
||||
- Devices nuevos sin passphrase: msgs pre-existentes NO se descifran. UI debe ser clara.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "0158"
|
||||
title: "matrix-client-android calls LiveKit nativo: mic/cam/screen + PiP"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0157", "0159", "0161"]
|
||||
dependencies: ["0157"]
|
||||
tags: [matrix, android, livekit, calls, webrtc, pip, audio-focus]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Llamadas nativas via `io.livekit:livekit-android` SDK oficial. Codecs HW (H.264/VP9 hardware decoder), audio focus + AEC/NS nativos, MediaSession para controls en lockscreen, Picture-in-Picture mode Android nativo. Soporta 1:1 + grupales (limite 16 del LiveKit config actual).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Backend (compartido con cliente PC):
|
||||
- Reusar `livekit_token_gen_go_infra` que esta en flow 0010.
|
||||
- Cliente Android pide token al mismo endpoint `/api/call/token` que el cliente PC.
|
||||
2. ViewModel:
|
||||
- `CallViewModel(matrixClient, roomId)`:
|
||||
- `joinCall()` — pide token + conecta `Room.connect()`.
|
||||
- `toggleMic()`, `toggleCamera()`, `toggleScreenShare()`.
|
||||
- `hangup()`.
|
||||
- `Flow<CallState>` con participants, tracks, connection state.
|
||||
3. Compose:
|
||||
- `CallScreen` fullscreen:
|
||||
- Grid tiles participantes (`Flow` layout responsive 1/2/4/9/16).
|
||||
- Tile principal: active speaker (track audio level del SDK).
|
||||
- Controles bottom: mic, cam, screen, raise hand, hangup.
|
||||
- `IncomingCallScreen` fullscreen con accept/decline (system overlay activity).
|
||||
- `CallTile` composable con `VideoView` (SurfaceViewRenderer del SDK).
|
||||
4. PiP (Picture-in-Picture):
|
||||
- `Activity` con `setPictureInPictureParams()`.
|
||||
- Auto-enter PiP al minimizar la app durante call.
|
||||
- PiP tile: video remoto + boton hangup.
|
||||
5. Audio routing:
|
||||
- `AudioFocusRequest` (Android 8+) — focus exclusivo durante call.
|
||||
- Switch speaker/earpiece/bluetooth via `AudioManager.setSpeakerphoneOn()` + connection state listeners para audifonos BT.
|
||||
- Echo cancellation + noise suppression: SDK los habilita por defecto, verificar.
|
||||
6. ICE/TURN: igual que cliente PC, depende del LiveKit config server-side.
|
||||
7. Tests:
|
||||
- Instrumented `Call1to1Test` con emulator + segundo cliente (PC) — connect, video, hangup.
|
||||
- Manual `ScreenShareTest` con device fisico.
|
||||
- Manual `4ParticipantsTest`.
|
||||
- Manual `PiPTest` — call activa + Home button -> PiP aparece.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `livekit_call_kotlin_infra` — wrapper `Room` SDK + permission helpers.
|
||||
- `audio_routing_kotlin_infra` — speaker/earpiece/BT switching.
|
||||
- `CallScreen_kotlin_ui` — fullscreen call UI.
|
||||
- `CallTile_kotlin_ui` — tile con VideoView.
|
||||
- `IncomingCallScreen_kotlin_ui` — accept/decline overlay activity.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Start call desde Android -> PC Wails recibe y conecta.
|
||||
- [ ] 30s call con video+audio nativo (verificar HW codec via `adb shell dumpsys media.codec`).
|
||||
- [ ] Mute mic + apagar cam refleja en otro cliente.
|
||||
- [ ] Screen share desde Android (con `MediaProjection`) visible en PC.
|
||||
- [ ] PiP: minimizar app durante call -> tile flotante con video remoto.
|
||||
- [ ] Bluetooth headphones: cambio automatico al conectar/desconectar.
|
||||
- [ ] Battery: call 30min con AC + WiFi <15% drain.
|
||||
|
||||
## Notas
|
||||
|
||||
- Permissions runtime: `RECORD_AUDIO`, `CAMERA`, `POST_NOTIFICATIONS` (Android 13+), `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROJECTION` (Android 14+).
|
||||
- Foreground service requerido para mantener call con app en background (issue 0161).
|
||||
- E2EE en call (insertable streams): TBD post-DoD, igual que en cliente PC.
|
||||
- Connection service Android (sistema): TBD, opcional. Permite integracion con dialer system + Bluetooth Car. Valorar coste/beneficio.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: "0159"
|
||||
title: "matrix-client-android push FCM via sygnal + Firebase setup"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0158", "0160"]
|
||||
dependencies: ["0154"]
|
||||
tags: [matrix, android, push, fcm, firebase, sygnal, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Notificaciones push moviles via FCM (Firebase Cloud Messaging) usando `sygnal` (push gateway oficial de Matrix). Sygnal recibe push events de Synapse, traduce a payload FCM, enviado a Firebase, entregado al device. La app despierta para mostrar notificacion del mensaje, o trigger ringer para incoming calls. App en background o muerta tambien recibe.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Infra (modifica `element_matrix_chat` app):
|
||||
- Anadir container `sygnal` al `docker-compose.yml`. Config en `configs/sygnal.yaml`.
|
||||
- Service account JSON de Firebase en `configs/firebase-sa.json` (gitignored, instalado en VPS via secrets).
|
||||
- Synapse config: pushers habilitados (ya por defecto).
|
||||
- Reverse proxy: `https://push-<hash>.organic-machine.com/_matrix/push/v1/notify` -> sygnal:5000.
|
||||
- Documentar setup en `projects/element_agents/apps/element_matrix_chat/docs/sygnal_setup.md`.
|
||||
2. Firebase:
|
||||
- Crear proyecto `fn-registry-matrix-push` en Firebase console.
|
||||
- Habilitar Cloud Messaging.
|
||||
- Generar service account JSON.
|
||||
- Anadir `google-services.json` al modulo Android (`app/google-services.json`).
|
||||
3. Android app:
|
||||
- `build.gradle`: `com.google.gms:google-services`, `com.google.firebase:firebase-messaging`.
|
||||
- `FirebaseMessagingService` subclass:
|
||||
- `onNewToken(token)` -> registrar en sygnal via Synapse Pusher API `POST /_matrix/client/v3/pushers/set`.
|
||||
- `onMessageReceived(message)` -> parse data payload + mostrar notif.
|
||||
- Notification channels (Android 8+):
|
||||
- `messages` — IMPORTANCE_HIGH, sonido.
|
||||
- `calls` — IMPORTANCE_HIGH, full-screen intent (despertar pantalla).
|
||||
- `silent` — IMPORTANCE_LOW.
|
||||
- VoIP push para calls: payload con `prio=high`, `event_id_only=false` (incluir event para mostrar caller info sin sync completo).
|
||||
4. Tests:
|
||||
- Instrumented `FCMTokenRegistrationTest` — mock Firebase, verificar pusher creado en Synapse.
|
||||
- Manual `PushDeliveryTest` — enviar msg desde Element Web a Android offline -> push aparece <3s.
|
||||
- Manual `PushCallTest` — start call desde PC -> Android offline despierta + ring.
|
||||
- Manual `PushBatterySaverTest` — Android en battery saver + Doze mode + push sigue llegando.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `sygnal_setup_bash_infra` — script setup container sygnal en VPS.
|
||||
- `sygnal_config_template_go_infra` — generador `sygnal.yaml` con Firebase SA.
|
||||
- `fcm_register_kotlin_infra` — onNewToken + register en Synapse Pusher API.
|
||||
- `synapse_pusher_set_go_infra` — Go helper REST `POST /pushers/set` (reutilizable PC + Android).
|
||||
- `NotificationBuilder_kotlin_ui` — helper notification channels + actions.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Container `sygnal` activo en VPS, health check `:5000/_matrix/push/v1/notify` HEAD 200.
|
||||
- [ ] Firebase project creado + SA JSON instalada en VPS.
|
||||
- [ ] App Android registra FCM token + crea pusher en Synapse al primer login.
|
||||
- [ ] Msg desde Element Web a Android (app cerrada por user) -> push notif en <3s.
|
||||
- [ ] Start call desde cliente PC -> Android offline despierta + ring 30s.
|
||||
- [ ] Battery saver activo: push sigue llegando (FCM high priority bypasses Doze).
|
||||
- [ ] Multiple users: pusher por device, no se cruzan.
|
||||
|
||||
## Notas
|
||||
|
||||
**Gotcha critico:** FCM no entrega push si:
|
||||
- App ha sido force-stopped por user (system requirement).
|
||||
- Device tiene "Restricted background usage" en battery settings.
|
||||
- Account Google no esta sincronizada en el device.
|
||||
Documentar en onboarding para que el user lo entienda.
|
||||
|
||||
**Privacy:** payload FCM no debe contener contenido del msg en claro (Synapse E2EE). Solo: `room_id`, `event_id`, `unread_count`, `prio`. App hace sync interno al recibir push para obtener msg cifrado y descifrar local.
|
||||
|
||||
**Coste:** FCM gratis para hosting Firebase. Sygnal CPU/RAM despreciable (<50MB).
|
||||
|
||||
**Alternativas exploradas:**
|
||||
- UnifiedPush + ntfy: open-source, sin Google. Pro: privacy. Con: requiere infraestructura propia + onboarding mas duro. Post-DoD considerar como segunda opcion para users sin Google Play.
|
||||
|
||||
**Decisiones futuras (post-DoD):**
|
||||
- iOS equivalent: APNs via sygnal mismo gateway. Cuando llegue cliente iOS.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: "0160"
|
||||
title: "matrix-client-android mini-webapps: WebView + Widget API v2 bridge"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0159", "0161"]
|
||||
dependencies: ["0159"]
|
||||
tags: [matrix, android, webview, widgets, agents, sandbox]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Host de widgets en Android equivalente al cliente PC (issue 0152). Mismo contrato Widget API v2. WebView con sandbox estricto + bridge JS-Kotlin implementa capabilities API. Widgets de los rooms operados por agentes (`agents_and_robots`) se ven embebidos: dashboard, formulario, kanban inline, control del agente.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. ViewModel:
|
||||
- `WidgetsViewModel(matrixClient, roomId)`:
|
||||
- `Flow<List<Widget>>` desde state events `m.widget` del room.
|
||||
- `addWidget(widget)`, `removeWidget(widgetId)`.
|
||||
- `generateUrl(widget) -> String` — substituye placeholders Matrix Widget API.
|
||||
- `mintScopedToken(widgetId) -> String` — token efimero scope room+widget.
|
||||
2. Compose:
|
||||
- `WidgetsPanel` (drawer lateral o bottom sheet en movil):
|
||||
- Tabs con widgets activos del room.
|
||||
- Cada tab = `WidgetView` que envuelve un `WebView`.
|
||||
- `WidgetView` composable:
|
||||
- `WebView` configurado:
|
||||
- `settings.javaScriptEnabled = true`.
|
||||
- `settings.allowFileAccess = false`.
|
||||
- `settings.allowContentAccess = false`.
|
||||
- `settings.allowFileAccessFromFileURLs = false`.
|
||||
- `settings.allowUniversalAccessFromFileURLs = false`.
|
||||
- `settings.mixedContentMode = MIXED_CONTENT_NEVER_ALLOW`.
|
||||
- `webViewClient` con CSP injection + URL allowlist.
|
||||
- `addJavascriptInterface(WidgetBridge, "MatrixWidgetBridge")` — bridge expone Widget API v2.
|
||||
- `CapabilityConsentDialog` Compose — pide consentimiento usuario para capabilities.
|
||||
3. WidgetBridge (Kotlin):
|
||||
- Implementa capabilities handshake postMessage (igual contrato que cliente PC):
|
||||
- `read_events`, `send_event`, `send_to_device`, `get_openid`, `m.always_on_screen`.
|
||||
- Audit log mensajes JS<->Kotlin en local DB.
|
||||
- Whitelist estricta de capabilities concedidas.
|
||||
4. Widgets internos primer batch (compartidos con cliente PC):
|
||||
- `widget-agent-panel` — control del agente.
|
||||
- `widget-kanban` — kanban inline.
|
||||
- `widget-issue-tracker`.
|
||||
5. Tests:
|
||||
- Instrumented `WidgetCapabilitiesTest` — dialog aparece + accept/decline funciona.
|
||||
- Instrumented `WidgetSandboxTest` — widget malicioso (intenta `window.location='file:///etc/passwd'`) bloqueado.
|
||||
- Instrumented `WidgetSendEventTest` — widget con capability envia msg.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `WidgetView_kotlin_ui` — Compose WebView wrapper sandboxed.
|
||||
- `widget_bridge_kotlin_infra` — JavascriptInterface implementando Widget API v2.
|
||||
- `widget_url_template_kotlin_core` — substituyente placeholders (puede compartirse logica con la Go version del PC, contrato identico).
|
||||
- `CapabilityConsentDialog_kotlin_ui` — Compose dialog.
|
||||
- `widget_audit_log_kotlin_infra` — append-only audit log en Room DB.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Widget publicado desde cliente PC se ve embebido en Android (mismo room).
|
||||
- [ ] Capability handshake: widget pide `send_event` -> dialog Compose -> accept -> widget envia msg.
|
||||
- [ ] Sandbox: widget intenta `XMLHttpRequest` a `file:///` -> bloqueado.
|
||||
- [ ] Widget agent-panel funcional: muestra logs en vivo del agente + boton restart.
|
||||
- [ ] Audit log persiste en Room DB con timestamp + capability + accept/deny.
|
||||
|
||||
## Notas
|
||||
|
||||
**Critico:**
|
||||
- Mismo contrato Widget API v2 que cliente PC. Widget HTML escrito una vez funciona en ambos.
|
||||
- WebView Android moderno (Chromium 100+) soporta WebRTC + WebGL + service workers. Suficiente para widgets ricos.
|
||||
|
||||
**Gotcha:**
|
||||
- `WebView.addJavascriptInterface` solo seguro en Android 4.2+ (API 17+, ya minSdk=28). Pero validar todo input desde JS — nunca confiar.
|
||||
- `setAllowFileAccessFromFileURLs(false)` solo aplica si la URL del widget es `file://`. Nuestros widgets son `https://` -> hardcode CSP estricta.
|
||||
- Memory: WebView por tab + 5 widgets activos = ~200MB facil. Limitar a max 3 widgets simultaneos activos.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Widget marketplace catalog accesible via menu.
|
||||
- "Add to home screen" PWA mode para widgets favoritos (Android shortcut + launcher icon dedicado).
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: "0161"
|
||||
title: "matrix-client-android foreground service: calls + lifecycle + lockscreen"
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-24
|
||||
related_flows: ["0011"]
|
||||
related_issues: ["0158", "0160"]
|
||||
dependencies: ["0158"]
|
||||
tags: [matrix, android, foreground-service, lifecycle, mediasession, wakelock]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
`CallForegroundService` que mantiene call activa con app en background o pantalla bloqueada. Notification ongoing visible mientras dura la call. `MediaSession` para integrar con lockscreen controls + Bluetooth Car (mute, hangup desde audio device). Wakelock controlado para evitar drain excesivo. Notificaciones full-screen intent para incoming calls (despiertan pantalla).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. `CallForegroundService` (`android.app.Service`):
|
||||
- `START_FOREGROUND_SERVICE` con type `MEDIA_PROJECTION` o `PHONE_CALL` (Android 14+ requiere type explicito).
|
||||
- `Notification.Builder` channel `calls` con:
|
||||
- Custom view con caller name, duration, mute/hangup buttons.
|
||||
- `setOngoing(true)`.
|
||||
- `setCategory(CATEGORY_CALL)`.
|
||||
- Lifecycle: `START_STICKY` para reiniciar si OS lo mata (raro con foreground).
|
||||
2. `MediaSession` integration:
|
||||
- `MediaSessionCompat` con play/pause/stop actions mapeados a mute/unmute/hangup.
|
||||
- Bluetooth Car media controls.
|
||||
- Lockscreen controls visibles si dispositivo lo soporta.
|
||||
3. Wakelock:
|
||||
- `PowerManager.PARTIAL_WAKE_LOCK` durante call activa.
|
||||
- `WAKE_LOCK_KEY = "matrix_client:call"` para audit en `dumpsys power`.
|
||||
- Liberar inmediato al hangup.
|
||||
- Proximity wakelock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`) si call solo audio + telefono pegado a oreja.
|
||||
4. Incoming call full-screen intent:
|
||||
- `Notification` con `setFullScreenIntent(pendingIntent, true)`.
|
||||
- Activity `IncomingCallActivity` con `showWhenLocked(true)` + `turnScreenOn(true)`.
|
||||
- Compose UI fullscreen con accept/decline.
|
||||
5. Doze mode handling:
|
||||
- `ACTION_IGNORE_BATTERY_OPTIMIZATIONS` solicitar al user en onboarding (no obligatorio, solo para calls fiables).
|
||||
- Documentar tradeoff en pantalla onboarding.
|
||||
6. Battery monitoring:
|
||||
- Log custom: call duration + battery_drain_pct al hangup.
|
||||
- Visible en `Settings > Diagnostics` para debug.
|
||||
7. Tests:
|
||||
- Manual `CallBackgroundTest` — start call + Home button -> notif visible + audio sigue.
|
||||
- Manual `CallLockscreenTest` — call + power button -> pantalla apaga + audio sigue + lockscreen controls visibles.
|
||||
- Manual `IncomingFullScreenTest` — device en lockscreen + incoming call -> pantalla despierta + UI accept/decline.
|
||||
- Manual `BluetoothCarTest` — Bluetooth Car connected + call active + mute desde steering wheel funciona.
|
||||
- Manual `BatteryTest` — call 30min en background + WiFi + AC -> drain <15%.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `CallForegroundService_kotlin_infra` — service completo.
|
||||
- `media_session_kotlin_infra` — wrapper MediaSessionCompat.
|
||||
- `wakelock_manager_kotlin_infra` — adquirir/liberar wakelocks de forma idempotente.
|
||||
- `IncomingCallActivity_kotlin_ui` — Compose fullscreen activity.
|
||||
- `battery_monitor_kotlin_infra` — log drain por session.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Call activa + Home -> notif ongoing visible + audio sigue 30s.
|
||||
- [ ] Call + power button -> lockscreen muestra controls + audio sigue.
|
||||
- [ ] Incoming call con pantalla apagada -> despierta + UI accept/decline.
|
||||
- [ ] Bluetooth Car: mute/hangup desde steering wheel funciona.
|
||||
- [ ] Hangup libera wakelocks (verificar con `dumpsys power | grep matrix_client`).
|
||||
- [ ] Battery saver activo: call no se corta (foreground service exempt).
|
||||
- [ ] Call 30min background: drain <15% con WiFi+AC.
|
||||
|
||||
## Notas
|
||||
|
||||
**Anti-criterios:**
|
||||
- NO marcar done si call se corta a los 5min en background (battery optimization kill).
|
||||
- NO marcar done si wakelock queda colgado tras hangup (battery leak).
|
||||
- NO marcar done si lockscreen no muestra controls (UX critico para calls largas).
|
||||
|
||||
**Gotchas Android 14+:**
|
||||
- Foreground service type DEBE declararse en manifest + runtime: `phoneCall|mediaProjection`.
|
||||
- `POST_NOTIFICATIONS` runtime permission (Android 13+).
|
||||
- `USE_FULL_SCREEN_INTENT` runtime permission (Android 14+) — pedir explicito.
|
||||
|
||||
**Decisiones:**
|
||||
- Telecom framework (ConnectionService): NO en esta iteracion. Pro: integracion dialer nativo. Con: bug-prone, requiere CALL_PHONE permission con justificacion Play Store. Post-DoD considerar.
|
||||
- Audio focus exclusivo durante call (issue 0158 ya lo cubre).
|
||||
|
||||
**Battery optimization onboarding:**
|
||||
- Pantalla en primer launch: explicar por que pedimos exempt battery optimization (calls fiables).
|
||||
- Boton "Open settings" -> `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`.
|
||||
- Si user declina: app funciona pero documentar que calls largas pueden cortarse.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
id: "0162"
|
||||
title: "Matrix: migrar Synapse a MAS como unico auth provider (MSC3861)"
|
||||
status: pending
|
||||
priority: critical
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0011"]
|
||||
related_issues: ["0147", "0154", "0163"]
|
||||
dependencies: []
|
||||
tags: [matrix, mas, synapse, msc3861, auth, oidc, migration, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Activar `matrix_authentication_service` en Synapse para que TODO login pase por MAS (Matrix Authentication Service) via MSC3861. Estado actual: MAS corre 6 semanas pero esta en pie sin clients registrados. Synapse usa login password legacy + application_service. Element Web, Synapse-Admin y clientes nuevos (flows 0010 + 0011) deben autenticarse exclusivamente contra MAS via OIDC.
|
||||
|
||||
Bloquea flows 0010 (matrix-client-pc) + 0011 (matrix-client-android) porque ambos asumen MAS funcional.
|
||||
|
||||
## Estado actual
|
||||
|
||||
```yaml
|
||||
# synapse_data/homeserver.yaml — comentado, NO activo:
|
||||
# matrix_authentication_service:
|
||||
# enabled: true
|
||||
# endpoint: "http://mas:8080/"
|
||||
# secret: "<shared_secret>"
|
||||
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
msc4354_enabled: true
|
||||
# msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# mas/config.yaml
|
||||
clients: [] # vacio
|
||||
public_base: https://auth-af2f3d.organic-machine.com/
|
||||
```
|
||||
|
||||
```
|
||||
GET /_matrix/client/v3/login -> {"flows":[{"type":"m.login.password"},{"type":"m.login.application_service"}]}
|
||||
GET /.well-known/matrix/client -> sin org.matrix.msc2965.authentication
|
||||
```
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Pre-migracion: backup completo**
|
||||
- Snapshot postgres Synapse: `docker exec element_matrix_chat-postgres-1 pg_dump -U synapse synapse > /backup/synapse_$(date +%Y%m%d).sql`.
|
||||
- Snapshot postgres MAS: idem `mas-postgres`.
|
||||
- Snapshot `synapse_data/` + `mas/config.yaml`.
|
||||
- Guardar backups en VPS local + descargar copia a PC.
|
||||
|
||||
2. **Registrar clients en MAS** (`mas/config.yaml`):
|
||||
- Cliente para Synapse (admin/internal): `client_id` + `client_secret` o `client_auth_method: client_secret_basic`.
|
||||
- Cliente para Element Web: `redirect_uris: [https://element-a05ae4.organic-machine.com/]`.
|
||||
- Cliente para nuevo admin panel (issue 0163): `redirect_uris: [<admin_panel_url>]`.
|
||||
- Cliente para matrix_client_pc (flow 0010): `redirect_uris: [http://127.0.0.1:*]` (loopback dinamico).
|
||||
- Cliente para matrix_client_android (flow 0011): `redirect_uris: [matrix-client-android://callback]`.
|
||||
- Aplicar: `docker exec element_matrix_chat-mas-1 mas-cli config sync`.
|
||||
|
||||
3. **Activar MSC3861 en Synapse**:
|
||||
- Editar `synapse_data/homeserver.yaml`:
|
||||
```yaml
|
||||
matrix_authentication_service:
|
||||
enabled: true
|
||||
endpoint: "http://mas:8080/"
|
||||
secret: "<shared_secret_matching_mas_config>"
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: true
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
msc4354_enabled: true
|
||||
msc4108_delegation_endpoint: "https://auth-af2f3d.organic-machine.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous"
|
||||
# Disable legacy password login:
|
||||
password_config:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
4. **Migrar usuarios existentes Synapse -> MAS**:
|
||||
- `docker exec element_matrix_chat-mas-1 mas-cli syn2mas --synapse-config /data/homeserver.yaml --dry-run` primero.
|
||||
- Revisar log (conflictos, usuarios huerfanos).
|
||||
- Ejecutar real: `mas-cli syn2mas --synapse-config /data/homeserver.yaml`.
|
||||
- Verificar: contar usuarios `mas-postgres` vs `synapse-postgres`, deben coincidir.
|
||||
|
||||
5. **Actualizar well-known** (`/.well-known/matrix/client`):
|
||||
- Servido por `element_matrix_chat-wellknown-1` (nginx).
|
||||
- Anadir:
|
||||
```json
|
||||
"org.matrix.msc2965.authentication": {
|
||||
"issuer": "https://auth-af2f3d.organic-machine.com/",
|
||||
"account": "https://auth-af2f3d.organic-machine.com/account"
|
||||
}
|
||||
```
|
||||
- Reload nginx.
|
||||
|
||||
6. **Restart ordenado**:
|
||||
- `docker compose restart mas` -> verificar logs sin errores 30s.
|
||||
- `docker compose restart synapse` -> verificar `_matrix/client/v3/login` ahora devuelve `m.login.sso` con `identity_providers` apuntando a MAS.
|
||||
- `docker compose restart element` (recarga config).
|
||||
|
||||
7. **Reconfigurar Element Web** (`element-config.json`):
|
||||
- Activar `oidc_native_flow: true` (Element Web soporta MSC3861 desde v1.11.50+).
|
||||
- Verificar version Element Web (`docker exec element_matrix_chat-element-1 cat /etc/nginx/conf.d/element.json | head` o image tag) >= v1.11.50.
|
||||
- Si version vieja: bump container image.
|
||||
|
||||
8. **Verificar end-to-end**:
|
||||
- Logout completo navegador.
|
||||
- Abrir Element Web -> debe redirigir a MAS para login.
|
||||
- Login con cuenta existente migrada -> redirect back a Element -> sesion activa.
|
||||
- Comprobar rooms historicos siguen visibles + msgs E2EE descifrados (las cross-signing keys NO se re-bootstrappean si la migracion va bien).
|
||||
|
||||
9. **Plan rollback** (escribir en `docs/mas_migration_rollback.md`):
|
||||
- Restaurar postgres Synapse desde dump.
|
||||
- Comentar bloque `matrix_authentication_service:` en homeserver.yaml.
|
||||
- `password_config.enabled: true`.
|
||||
- Restart Synapse.
|
||||
- MAS sigue vivo idle (no destruir).
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `mas_client_register_bash_infra` — `mas-cli config sync` wrapper + validacion idempotente.
|
||||
- `synapse_msc3861_enable_go_infra` — edita `homeserver.yaml` con bloque MAS + experimental_features.
|
||||
- `mas_syn2mas_migration_bash_infra` — wrapper migracion con dry-run obligatorio + log archive.
|
||||
- `wellknown_oidc_patch_go_infra` — anade `org.matrix.msc2965.authentication` al well-known JSON servido por nginx.
|
||||
- `synapse_login_flows_check_go_infra` — health-check post-migracion (espera ver `m.login.sso` en flows).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `GET /_matrix/client/v3/login` devuelve `m.login.sso` con identity provider MAS.
|
||||
- [ ] `GET /.well-known/matrix/client` contiene `org.matrix.msc2965.authentication.issuer`.
|
||||
- [ ] Element Web redirige a MAS para login (no muestra form propio).
|
||||
- [ ] Login con cuenta existente funciona post-migracion.
|
||||
- [ ] Rooms historicos + msgs E2EE siguen visibles tras re-login.
|
||||
- [ ] `password_config.enabled: false` no rompe nada (todo va por MAS).
|
||||
- [ ] Backup pre-migracion subido + documentado.
|
||||
- [ ] `docs/mas_migration_rollback.md` escrito + probado en staging (ver Notas).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica
|
||||
- `docker compose ps` muestra todos los containers healthy.
|
||||
- `mas-cli config check` exit 0.
|
||||
- `synapse curl /health` 200.
|
||||
- Tests humo: login + send msg + recibe msg propagado a otra cuenta.
|
||||
|
||||
### Cobertura
|
||||
|
||||
| Escenario | Comando / evidencia | Resultado |
|
||||
|---|---|---|
|
||||
| Golden: login Element Web via MAS | navegador Incognito -> ` element-a05ae4.organic-machine.com` | redirect MAS -> login -> sesion activa |
|
||||
| Edge: usuario migrado con E2EE setup previo | post-login en Element Web | rooms cifrados se descifran sin re-bootstrap |
|
||||
| Edge: app servicio (bot) usa application_service token | bot envia msg | sigue funcionando (AS no pasa por MAS) |
|
||||
| Edge: device verification cross-platform | Element Web verifica device PC Wails (post flow 0010) | OK |
|
||||
| Error: token MAS expira mid-session | esperar TTL (default 5min refresh) | refresh automatico, no logout |
|
||||
| Error: MAS cae (kill container) | matar `mas-1` 60s | Synapse rechaza nuevos logins; sessiones activas siguen (access_token cached); restart MAS -> recovery |
|
||||
|
||||
### Vida util validada (7 dias post-migracion)
|
||||
|
||||
| Metrica | Umbral | Donde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Login failures (causa MAS) | `< 1%` | `mas` logs + sentry-like | 7 dias |
|
||||
| Latency `/oauth2/token` | `p95 < 500ms` | nginx access log VPS | 7 dias |
|
||||
| Crashes MAS / Synapse | `0` | `docker logs --since` | 7 dias |
|
||||
| Users migrados activos | `>= 95%` | `mas-cli admin user list` vs sesiones activas | 7 dias |
|
||||
|
||||
### Anti-criterios
|
||||
- NO marcar done si algun usuario migrado pierde acceso a rooms cifrados.
|
||||
- NO marcar done si Element Web sigue mostrando form de password (legacy flow).
|
||||
- NO marcar done si rollback documentado no se ha probado al menos una vez en staging.
|
||||
|
||||
## Notas
|
||||
|
||||
**Staging recomendado:** levantar stack identico en VPS test o WSL local con docker-compose + datos fake antes de tocar prod. organic-machine.com lleva 6 semanas viva.
|
||||
|
||||
**Element Call (LiveKit):** ya usa OIDC del homeserver para tokens via `livekit-jwt` container -> migracion debe verificar que tokens siguen emitiendose contra el MAS auth.
|
||||
|
||||
**Synapse-Admin compat:** synapse-admin v0.10+ soporta MSC3861. Verificar version corriendo. Si vieja, bump O reemplazar por panel propio (issue 0163).
|
||||
|
||||
**Gotcha critico — shared_secret:**
|
||||
- `mas/config.yaml` tiene `matrix.secret` que debe matchear `homeserver.yaml.matrix_authentication_service.secret`.
|
||||
- Generar con `openssl rand -hex 32` si no existe.
|
||||
- Si no matchean: Synapse rechaza requests MAS con 401.
|
||||
|
||||
**Gotcha — application_service tokens:**
|
||||
- Los AS (bridges, bots) NO pasan por MAS. Siguen usando `as_token`/`hs_token` de su registration.
|
||||
- `agents_and_robots` usa application_service? Verificar antes — si SI, no afecta. Si usa password login normal, tendra que pasar por MAS (re-config).
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Habilitar `device_code` grant en MAS para login CLI futuro.
|
||||
- Habilitar QR-code login (MSC4108) ya pre-config con `msc4108_delegation_endpoint`.
|
||||
- Multi-factor (TOTP) en MAS — config available.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — issue creada.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
id: "0163"
|
||||
title: "Matrix admin panel propio: users, rooms, devices, sessions (sustituye synapse-admin)"
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-05-24
|
||||
related_flows: ["0010", "0011"]
|
||||
related_issues: ["0162", "0147"]
|
||||
dependencies: ["0162"]
|
||||
tags: [matrix, admin, panel, react, mantine, mas, synapse, infra]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Panel admin propio que reemplaza `https://admin-0cc4d3.organic-machine.com/#/users` (synapse-admin actual). Funciones equivalentes: gestionar usuarios (crear, deactivate, reset password, list devices, list rooms), gestionar rooms (list, members, kick, force-leave, delete), ver sesiones activas + revoke, ver media (storage usage por user). Auth via MAS OIDC con scope admin. Stack: React+Vite+Mantine+`@fn_library` (consistente con flows 0010/0011 + resto del registry).
|
||||
|
||||
## Por que reemplazar synapse-admin
|
||||
|
||||
- **Auth legacy**: synapse-admin usa admin token + password admin directo. Tras issue 0162 (MAS obligatorio) esto chirria. Mejor consume MAS OIDC + Synapse Admin API.
|
||||
- **UI ajena**: stack distinto al resto del registry. Sin theming propio, sin `@fn_library`, sin coherencia visual con cliente PC (flow 0010).
|
||||
- **Sin agentes**: no podemos integrar paneles especiales para `agents_and_robots`, devices del mesh (flow 0009), policies de widgets.
|
||||
- **No extensible**: anadir "ver telemetria de calls LiveKit" o "audit log MAS" requiere fork pesado.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Scaffold app**:
|
||||
- `projects/element_agents/apps/matrix_admin_panel/`.
|
||||
- Stack: React+Vite+TS+Mantine+`@fn_library`+`@tabler/icons-react`.
|
||||
- Backend: Go con `mautrix-go` admin client + MAS OIDC client + `livekit-server-sdk-go` (para sesiones de call).
|
||||
- Empaquetado: backend Go sirve frontend estatico embebido (`embed.FS`).
|
||||
- Deploy: container Docker en `element_matrix_chat` stack o como service standalone via `deploy_server`.
|
||||
|
||||
2. **Auth flow MAS**:
|
||||
- Cliente registrado en MAS (issue 0162 paso 2) con scope `urn:synapse:admin:*`.
|
||||
- Login Web: OIDC redirect a MAS.
|
||||
- Token guardado en httpOnly cookie + CSRF token.
|
||||
|
||||
3. **Modulos UI**:
|
||||
- **Users**:
|
||||
- Tabla virtualizada con `data-table` (cuando exista TS equivalente) o `mantine-react-table`.
|
||||
- Columnas: localpart, displayname, avatar, admin, deactivated, last_seen, device_count.
|
||||
- Acciones por row: view detail, deactivate/reactivate, reset password (force MAS link), list devices.
|
||||
- Filtros: deactivated, admin, search.
|
||||
- **User detail**:
|
||||
- Sub-tabs: Profile, Devices (list + revoke individual), Rooms (membership list), Media (uploads + size), Sessions (MAS active sessions + revoke), Audit log (MAS).
|
||||
- **Rooms**:
|
||||
- Tabla: room_id, name, alias, members_count, encrypted, public, federated, state_events.
|
||||
- Acciones: view detail, force-leave usuarios, delete room (purge), shutdown notif.
|
||||
- **Room detail**:
|
||||
- Members + roles, state events viewer (read-only JSON), media in room, widgets activos (interop con flow 0010 widget API).
|
||||
- **Sessions** (MAS):
|
||||
- Lista sesiones activas global.
|
||||
- Filtro por user, IP, device, last_used.
|
||||
- Revoke individual o bulk.
|
||||
- **Federation**:
|
||||
- Estado federation (Synapse `federation_handler`).
|
||||
- Allowlist/blocklist servers.
|
||||
- **Stats**:
|
||||
- Resumen: users count, rooms count, mensajes/dia (ultima semana), media storage, calls activas (via LiveKit `RoomService.ListRooms`).
|
||||
- Graficas con `@mantine/charts` o `recharts`.
|
||||
|
||||
4. **Capability groups en panel**:
|
||||
- Reusa `AgentPanel` (flow 0010 issue 0153) para mostrar info de agentes registrados.
|
||||
- Reusa `DevicePanel` (cuando flow 0009 vivo) para devices del mesh.
|
||||
- Slot "Widgets policy": ver/aprobar capabilities concedidas globalmente, audit log.
|
||||
|
||||
5. **API endpoints backend Go**:
|
||||
- `GET /api/users` -> proxy a Synapse `/_synapse/admin/v2/users` con auth MAS.
|
||||
- `POST /api/users/<id>/deactivate`.
|
||||
- `GET /api/rooms`, `POST /api/rooms/<id>/delete`.
|
||||
- `GET /api/mas/sessions`, `POST /api/mas/sessions/<id>/revoke` (MAS admin API).
|
||||
- `GET /api/livekit/rooms` (active calls).
|
||||
- `GET /api/stats/summary`.
|
||||
|
||||
6. **Permisos**:
|
||||
- Solo users con flag `admin: true` (Synapse) o scope MAS admin claim.
|
||||
- Backend valida claim/flag en cada request.
|
||||
- UI muestra "Access denied" si user logueado no es admin.
|
||||
|
||||
7. **Deploy**:
|
||||
- Anadir container al `docker-compose.yml` de `element_matrix_chat`.
|
||||
- O bien standalone via `deploy_server` (registry function existente).
|
||||
- URL: `admin-af2f3d.organic-machine.com` o reusar `admin-0cc4d3.organic-machine.com` cuando se retire synapse-admin.
|
||||
|
||||
8. **Migracion synapse-admin -> panel propio**:
|
||||
- Coexistencia 2 semanas: ambos vivos, MAS audita uso de cada uno.
|
||||
- Cuando uso de synapse-admin = 0 durante 7 dias seguidos: detener container.
|
||||
- Documentar en `docs/admin_panel_migration.md`.
|
||||
|
||||
9. **Tests**:
|
||||
- `e2e/test_admin_login.sh` — MAS OIDC + scope admin valido -> acceso.
|
||||
- `e2e/test_admin_login_denied.sh` — user no-admin recibe 403.
|
||||
- `e2e/test_user_deactivate.sh` — flow completo deactivate + verify can't login.
|
||||
- `e2e/test_room_purge.sh` — purge room + verify gone en Synapse.
|
||||
- `e2e/test_session_revoke.sh` — revoke sesion MAS + user perdiendo acceso en <30s.
|
||||
|
||||
## Funciones del registry a crear
|
||||
|
||||
- `synapse_admin_client_go_infra` — wrapper Synapse Admin API.
|
||||
- `mas_admin_client_go_infra` — wrapper MAS admin API (`/api/admin/v1/...`).
|
||||
- `livekit_admin_client_go_infra` — `RoomService.ListRooms`, kick participant, etc.
|
||||
- `oidc_admin_middleware_go_infra` — middleware Go que valida scope admin en cookie/Bearer.
|
||||
- `UsersTable_ts_ui` — componente Mantine con virtualization + filtros.
|
||||
- `RoomDetail_ts_ui` — componente con tabs Members/State/Media/Widgets.
|
||||
- `SessionsList_ts_ui` — lista sesiones + revoke action.
|
||||
- `StatsSummary_ts_ui` — componente con `@mantine/charts`.
|
||||
- `FederationStatusPanel_ts_ui` — componente federation diag.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App compila + arranca como container Docker.
|
||||
- [ ] Login via MAS OIDC con scope admin funciona.
|
||||
- [ ] User no-admin recibe 403 al intentar entrar.
|
||||
- [ ] Tabla users con 50+ rows + filtros + actions.
|
||||
- [ ] Deactivate user end-to-end (verify cannot login despues).
|
||||
- [ ] Room detail muestra members + state events JSON.
|
||||
- [ ] Sessions MAS listadas + revoke individual.
|
||||
- [ ] Stats: counts + media usage + active calls visibles.
|
||||
- [ ] Tema visual coherente con cliente PC (flow 0010).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
### Mecanica
|
||||
- `go build` + `pnpm build` verde.
|
||||
- Container Docker `<150MB` (Alpine + binary + static).
|
||||
- Health endpoint `/health` 200.
|
||||
- E2E suite pasa.
|
||||
|
||||
### Cobertura
|
||||
|
||||
| Escenario | Evidencia | Resultado |
|
||||
|---|---|---|
|
||||
| Golden: admin login + ver users | `e2e/test_admin_full_flow.sh` | tabla con users reales, actions visibles |
|
||||
| Edge: 5000 users en tabla | benchmark scroll | 60fps, <300MB RAM |
|
||||
| Edge: user sin admin entra | request directo | 403 + audit log |
|
||||
| Edge: room con 200 members | view detail | render < 1s, paginacion OK |
|
||||
| Error: Synapse Admin API caida | mock 500 | UI muestra error claro, no crash |
|
||||
| Error: MAS session revoke fails | mock 500 | retry + toast error |
|
||||
|
||||
### Vida util (>=7 dias)
|
||||
|
||||
| Metrica | Umbral | Donde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Crashes container | `0` | docker logs | 7 dias |
|
||||
| Uso real | `>= 2 sesiones/semana` (operador) | nginx access log | 7 dias |
|
||||
| Latency p95 endpoint /api/users | `< 800ms` (Synapse Admin paginado) | metrics | 7 dias |
|
||||
| Acciones destructivas auditadas | `100%` (cada delete/revoke con audit row) | local audit DB | continuo |
|
||||
|
||||
### Anti-criterios
|
||||
- NO marcar done si admin panel acepta token sin claim/flag admin.
|
||||
- NO marcar done si delete room no purga media en DB Synapse.
|
||||
- NO marcar done si UI deja al operador sin confirmacion en acciones destructivas (deactivate, purge, revoke).
|
||||
- NO marcar done si lookalike de synapse-admin sin features propias (mejor mantener synapse-admin entonces).
|
||||
|
||||
## Notas
|
||||
|
||||
**Ventajas reales sobre synapse-admin:**
|
||||
1. Coherencia visual + Mantine + theme propio.
|
||||
2. Integracion con `agents_and_robots` (panel agente embedded).
|
||||
3. Integracion con widgets policy (audit + override capabilities).
|
||||
4. Integracion con LiveKit calls (ver rooms activos, force-end).
|
||||
5. Audit log local SQLite con todas las acciones admin (synapse-admin no lo tiene).
|
||||
6. Extensible — anadir tabs para mesh devices (flow 0009), telemetria, etc.
|
||||
|
||||
**Onboarding:**
|
||||
1. `cd projects/element_agents/apps/matrix_admin_panel`.
|
||||
2. `make dev` (Go backend + Vite frontend hot reload).
|
||||
3. Visitar `http://127.0.0.1:8090` -> login MAS dev.
|
||||
4. Deploy prod: ver `deploy/README.md`.
|
||||
|
||||
**Decisiones:**
|
||||
- Backend Go > Python/Node: alinea con `mautrix-go` + reusa funciones del registry. Binario pequeno, deploy facil.
|
||||
- Embedded static (Go `embed.FS`): un binario, sin docker multi-stage compleja.
|
||||
- Audit log local SQLite > Postgres: panel admin no necesita HA, suficiente con SQLite local + backup periodico.
|
||||
|
||||
**Gotchas:**
|
||||
- Synapse Admin API requiere `Bearer <admin_token>` — el panel intercambia OIDC token + admin claim por admin_token (con MAS admin API o con cuenta admin shared).
|
||||
- MAS admin API esta en `/api/admin/v1/` — version unstable, monitorizar breaking changes.
|
||||
- Federation tab: si federation deshabilitada (caso actual, ver `homeserver.yaml`), tab muestra "disabled" en vez de error.
|
||||
|
||||
**Roadmap post-DoD:**
|
||||
- Bulk actions (mass deactivate, mass invite).
|
||||
- Export reports CSV.
|
||||
- Slack/email alerts en eventos criticos (server cae, MAS down, federation block).
|
||||
- Multi-tenancy si llegan mas homeservers.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-24) — issue creada.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
id: "0128"
|
||||
title: "agents_and_robots: HTTP API + SSE + apikey + TLS subdominio"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- agents
|
||||
- infra
|
||||
- deploy
|
||||
scope: app
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks:
|
||||
- "0129"
|
||||
related: []
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [agents_and_robots, http, sse, apikey, traefik, systemd]
|
||||
dod_evidence_schema:
|
||||
- id: build_ok
|
||||
kind: cmd
|
||||
expected: "cd projects/element_agents/apps/agents_and_robots && go build -tags goolm ./cmd/launcher → exit 0"
|
||||
required: true
|
||||
- id: api_list_authorized
|
||||
kind: cmd
|
||||
expected: "curl -fsS -H 'Authorization: Bearer $AGENTS_API_KEY' https://agents.organic-machine.com/agents devuelve JSON con N>=7 agentes"
|
||||
required: true
|
||||
- id: api_list_unauthorized_401
|
||||
kind: cmd
|
||||
expected: "curl -s -o /dev/null -w '%{http_code}' https://agents.organic-machine.com/agents == 401"
|
||||
required: true
|
||||
- id: api_start_stop_roundtrip
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/stop → POST /agents/test-bot/start: status running confirmado via GET /agents/test-bot tras 2s"
|
||||
required: true
|
||||
- id: sse_logs_streaming
|
||||
kind: cmd
|
||||
expected: "curl -N -H 'Authorization: Bearer $KEY' https://agents.organic-machine.com/sse/agents/assistant-bot/logs entrega >=1 line en 5s con agente activo"
|
||||
required: true
|
||||
- id: sse_status_broadcast
|
||||
kind: cmd
|
||||
expected: "curl -N /sse/status recibe evento {agent_id, old_status, new_status} tras stop/start manual"
|
||||
required: true
|
||||
- id: systemd_active
|
||||
kind: cmd
|
||||
expected: "ssh organic-machine.com 'systemctl is-active agents_and_robots.service' == active"
|
||||
required: true
|
||||
- id: traefik_route
|
||||
kind: url
|
||||
expected: "agents.organic-machine.com resuelve y devuelve cert LE valido (curl -vI muestra subject CN=agents.organic-machine.com)"
|
||||
required: true
|
||||
- id: app_md_drift_fixed
|
||||
kind: cmd
|
||||
expected: "fn doctor services-spec apps/element_agents/apps/agents_and_robots reporta OK (sin drift runtime/systemd)"
|
||||
required: true
|
||||
---
|
||||
|
||||
# 0128 — agents_and_robots HTTP API + SSE + apikey + TLS
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoy `agents_and_robots` solo expone control via `agentctl` CLI local (filesystem-based, `shell/process.Manager`). No hay forma remota de gestionar agentes.
|
||||
|
||||
Necesitamos backend HTTP seguro para que un frontend local C++ (issue 0129) pueda listar, start/stop/restart agentes, y streamear logs/status en vivo.
|
||||
|
||||
## Decision
|
||||
|
||||
**Integrar daemon HTTP DENTRO de `cmd/launcher`** como goroutine. Comparte `process.Manager` + acceso a `shell/memory/*.db` + Matrix clients. Un solo proceso, sin drift entre daemon y supervisor.
|
||||
|
||||
**Auth:** `Authorization: Bearer <AGENTS_API_KEY>` con `subtle.ConstantTimeCompare`. Clave 32 bytes hex en `.env` (`AGENTS_API_KEY`). 401 sin header o key invalida.
|
||||
|
||||
**TLS:** Traefik en VPS organic-machine.com con LE cert auto. Subdominio `agents.organic-machine.com` (DNS A record nuevo → IP del VPS). Ruta Traefik `agents.organic-machine.com → 127.0.0.1:8487`.
|
||||
|
||||
**SSE in-memory pubsub.** NATS OFF de momento (1 cliente local, broker = overhead). Documentar TODO en app.md para anadir bus si llega 2do consumidor.
|
||||
|
||||
## Scope v0.1 (lean)
|
||||
|
||||
| Verbo | Path | Wrap |
|
||||
|---|---|---|
|
||||
| GET | `/health` | 200 OK sin auth (liveness) |
|
||||
| GET | `/agents` | `Scan` + `StatusAll` + `msg_count_24h` (query `shell/memory/*.db`) |
|
||||
| GET | `/agents/{id}` | detail + config + `LogTail(200)` |
|
||||
| POST | `/agents/{id}/start` | `Manager.Start` |
|
||||
| POST | `/agents/{id}/stop` | `Manager.Stop` |
|
||||
| POST | `/agents/{id}/restart` | Stop+Start con espera health |
|
||||
| GET | `/agents/{id}/logs?n=200` | `LogTail` snapshot |
|
||||
|
||||
**SSE:**
|
||||
- `GET /sse/status` — broadcast cambios de status (poll cada 2s + diff)
|
||||
- `GET /sse/agents/{id}/logs` — tail -f del logfile, emite line events
|
||||
|
||||
**Fuera de scope v0.1** (queda v0.2):
|
||||
- POST `/agents/{id}/message` (send Matrix message)
|
||||
- PUT `/agents/{id}/config` (config edit)
|
||||
- SSE messages stream
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Nuevo paquete `internal/api`** con server HTTP (stdlib `net/http`, sin gin/echo).
|
||||
- `api.New(mgr *process.Manager, apiKey string, port int) *Server`
|
||||
- `Server.Run(ctx) error` arranca y bloquea hasta ctx done.
|
||||
- Middleware: log + auth + recover.
|
||||
2. **Handlers REST** sobre `process.Manager`. Tests unitarios con mock manager.
|
||||
3. **SSE pubsub in-memory** (`internal/api/pubsub.go`):
|
||||
- `Bus` con `Subscribe(topic) <-chan event` + `Publish(topic, event)`.
|
||||
- Poller goroutine que llama `StatusAll` cada 2s y publica diffs.
|
||||
- Tail goroutine por logfile (`file_tail_follow` — buscar en registry o crear).
|
||||
4. **Integrar en launcher** — `cmd/launcher/main.go` arranca `api.Server` en goroutine si `--api-port > 0`.
|
||||
5. **Crear systemd unit** `/etc/systemd/system/agents_and_robots.service` con `Restart=always`, `EnvironmentFile=.env`, `ExecStart=.../bin/launcher --log-level info --api-port 8487`.
|
||||
6. **Traefik route + DNS:**
|
||||
- Anadir `agents.organic-machine.com` en DNS (A record).
|
||||
- Anadir config Traefik (label en docker-compose del stack o file provider) apuntando a `127.0.0.1:8487`.
|
||||
7. **Fix drift app.md** — `runtime: systemd-system` ahora es verdad. Verificar con `fn doctor services-spec`.
|
||||
8. **Tests:**
|
||||
- Go: pkg `internal/api` con httptest.
|
||||
- e2e: `e2e_checks` en `app.md` con curl smoke.
|
||||
9. **Deploy:**
|
||||
- `rsync_deploy_bash_infra` o `deploy_server` target nuevo.
|
||||
- Generar `AGENTS_API_KEY` con `openssl rand -hex 32` y escribir `.env` remoto.
|
||||
- `systemctl enable --now agents_and_robots.service`.
|
||||
|
||||
## Funciones del registry a usar / proponer
|
||||
|
||||
Buscar antes de codear:
|
||||
|
||||
- `mcp__registry__fn_search query="tail follow file" lang="go"` — ¿existe `file_tail_follow_go_infra`? Si no, delegar a fn-constructor.
|
||||
- `mcp__registry__fn_search query="http auth bearer" lang="go"` — middleware auth.
|
||||
- `mcp__registry__fn_search query="sse server" lang="go"` — helper SSE.
|
||||
- `systemd_generate_unit_go_infra` + `systemd_install_go_infra` — generar/instalar unit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `curl -fsS -H 'Authorization: Bearer $KEY' https://agents.organic-machine.com/agents` devuelve lista correcta.
|
||||
- [ ] Sin header → 401. Con key invalida → 401. Key valida → 200.
|
||||
- [ ] Start/Stop/Restart cambian estado real del proceso (verificable con `ps`).
|
||||
- [ ] SSE logs entrega lineas en menos de 1s de aparecer en el archivo.
|
||||
- [ ] SSE status broadcast tras stop/start manual.
|
||||
- [ ] systemd unit activo y reinicia tras kill -9.
|
||||
- [ ] `fn doctor services-spec` reporta OK.
|
||||
- [ ] Tests Go pasan.
|
||||
|
||||
## DoD humano
|
||||
|
||||
- **Donde:** terminal local → `curl https://agents.organic-machine.com/agents`. SSE verificable con `curl -N`.
|
||||
- **Latencia:** SSE log lag < 1s. REST list < 200ms.
|
||||
- **Onboarding:** README de agents_and_robots actualizado con seccion "HTTP API" + ejemplos curl.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- DNS propagation puede tardar (configurar con TTL bajo).
|
||||
- Traefik en este VPS: verificar si esta gestionado por Coolify o standalone — anadir ruta donde corresponda.
|
||||
- `LogTail` actual solo lee snapshot — necesitamos `tail -f` real para SSE. Si no existe en el registry, ronda previa.
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
id: "0129"
|
||||
title: "agents_dashboard: C++ ImGui frontend para gestionar agentes Matrix"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- agents
|
||||
- tui
|
||||
scope: app
|
||||
priority: alta
|
||||
depends:
|
||||
- "0128"
|
||||
blocks: []
|
||||
related: []
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, agents, dashboard, sse, http-client]
|
||||
dod_evidence_schema:
|
||||
- id: scaffold_ok
|
||||
kind: cmd
|
||||
expected: "ls projects/element_agents/apps/agents_dashboard/{app.md,main.cpp,CMakeLists.txt,.git} todos existen"
|
||||
required: true
|
||||
- id: build_windows
|
||||
kind: cmd
|
||||
expected: "cmake --build cpp/build/windows --target agents_dashboard -j → exit 0"
|
||||
required: true
|
||||
- id: appicon_embedded
|
||||
kind: cmd
|
||||
expected: "x86_64-w64-mingw32-objdump -h cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe | grep .rsrc"
|
||||
required: true
|
||||
- id: hub_card_visible
|
||||
kind: screenshot
|
||||
expected: "App Hub muestra tarjeta agents_dashboard con icono robot violeta + description correcta"
|
||||
required: true
|
||||
- id: connection_flow
|
||||
kind: screenshot
|
||||
expected: "Panel Connection con base_url + apikey input, LED verde tras handshake exitoso con backend"
|
||||
required: true
|
||||
- id: agents_table_populated
|
||||
kind: screenshot
|
||||
expected: "Tabla Agents muestra >=7 filas con id/status/uptime/msg_24h + botones accion"
|
||||
required: true
|
||||
- id: start_stop_works
|
||||
kind: screenshot
|
||||
expected: "Click stop sobre test-bot lo apaga (status cambia a stopped en menos de 2s); click start lo reinicia"
|
||||
required: true
|
||||
- id: logs_sse_streaming
|
||||
kind: screenshot
|
||||
expected: "Panel Logs streamea lineas en vivo de assistant-bot (lineas nuevas aparecen sin pulsar refresh)"
|
||||
required: true
|
||||
- id: apikey_encrypted_local
|
||||
kind: cmd
|
||||
expected: "strings cpp/build/windows/apps/agents_dashboard/local_files/agents_dashboard.db | grep -v '<plaintext apikey>' (apikey no aparece en claro)"
|
||||
required: true
|
||||
- id: e2e_self_test
|
||||
kind: cmd
|
||||
expected: "agents_dashboard.exe --self-test exit 0 (verifica subsistemas: GL loader, http client, SSE client, DB local)"
|
||||
required: true
|
||||
---
|
||||
|
||||
# 0129 — agents_dashboard C++ ImGui frontend
|
||||
|
||||
## Contexto
|
||||
|
||||
Cuando 0128 cierre, el backend `agents_and_robots` expondra HTTPS API + SSE en `agents.organic-machine.com` con apikey. Necesitamos frontend local C++ ImGui que consuma esa API y permita gestionar agentes sin SSH ni terminal.
|
||||
|
||||
## Decision
|
||||
|
||||
C++ ImGui app en `projects/element_agents/apps/agents_dashboard/`. Sub-repo Gitea `dataforge/agents_dashboard`. Integrada en App Hub con icono propio.
|
||||
|
||||
Scope v0.1 = lo que 0128 expone: list + start/stop/restart + logs SSE. v0.2 anade send-message + config-edit cuando backend los exponga.
|
||||
|
||||
## Tareas
|
||||
|
||||
### 1. Scaffold (REGLA: scaffolder canonico, NUNCA a mano)
|
||||
|
||||
```bash
|
||||
./fn run init_cpp_app agents_dashboard \
|
||||
--project element_agents \
|
||||
--desc "Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS+apikey, SSE para logs/status en vivo"
|
||||
```
|
||||
|
||||
Tras scaffold:
|
||||
- `git init` dentro de `projects/element_agents/apps/agents_dashboard/` (regla `apps_subrepo.md`).
|
||||
- Trio `app.md`: `description` + `icon.phosphor: "robot"` + `icon.accent: "#8b5cf6"`.
|
||||
- `./fn run regenerate_app_icons agents_dashboard`.
|
||||
- `./fn run refresh_app_hub` para que aparezca en el hub.
|
||||
|
||||
### 2. Funciones del registry — buscar primero
|
||||
|
||||
| Necesidad | Buscar en registry | Si falta |
|
||||
|---|---|---|
|
||||
| HTTP client C++ (sync GET/POST + Bearer + JSON body) | `mcp__registry__fn_search query="http client" lang="cpp"` | Delegar `fn-constructor`: `http_client_cpp_infra` con libcurl |
|
||||
| SSE client C++ | `sse_client_cpp_core` (FRESH 7d) | ✓ reuso directo |
|
||||
| JSON parse/serialize C++ | buscar nlohmann wrapper | Si falta, vendoring `cpp/vendor/json.hpp` (single-header) |
|
||||
| Data table | `data_table_cpp_viz` | ✓ reuso |
|
||||
| Secret store local (DPAPI Windows) | buscar | Si falta: `secret_store_cpp_infra` (DPAPI wrap, base64 fallback Linux) |
|
||||
| Ring buffer C++ | buscar | Si falta: `ring_buffer_cpp_core` |
|
||||
|
||||
Delegacion paralela: **una sola llamada Agent con N tool_use blocks paralelos** para las que falten (regla `delegation.md`).
|
||||
|
||||
### 3. Paneles UI
|
||||
|
||||
- **Connection** — `base_url` input + apikey input (mask) + boton "Test" → GET /health + GET /agents. LED estado SSE (gris/amarillo/verde/rojo). Save credentials en `local_files/agents_dashboard.db` encriptadas via secret_store.
|
||||
- **Agents** — `data_table_cpp_viz` con cols:
|
||||
- id (texto)
|
||||
- status (icono colored: running=green, stopped=gray, crashed=red)
|
||||
- uptime (humanized)
|
||||
- msg_24h (numero)
|
||||
- actions (botones `▶ ⏹ ↻` por fila)
|
||||
- Filtro por substring + sort por col.
|
||||
- **Logs** — selector agente (combo) + tail viewport (ring buffer 5000 lineas) + autoscroll toggle + boton "Pause". Stream via `/sse/agents/{id}/logs`.
|
||||
- **Status feed** — panel collapsible con eventos del `/sse/status` (timeline reciente).
|
||||
|
||||
### 4. Persistencia local
|
||||
|
||||
- `<exe_dir>/local_files/agents_dashboard.db` (SQLite via funciones del registry o sqlite3 directo).
|
||||
- Schema migraciones en `migrations/001_init.sql`:
|
||||
```sql
|
||||
CREATE TABLE connections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
apikey_encrypted BLOB NOT NULL,
|
||||
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
```
|
||||
- `app_settings.ini` via `fn_ui::settings_*` (theme, layout).
|
||||
- apikey cifrada con DPAPI Windows (clave nunca abandona la maquina).
|
||||
|
||||
### 5. Build + deploy local
|
||||
|
||||
- CMake target `agents_dashboard` en `cpp/CMakeLists.txt` (auto via scaffolder).
|
||||
- Build Windows: `cmake --build cpp/build/windows --target agents_dashboard -j`.
|
||||
- Deploy local: `./fn run redeploy_cpp_app_windows agents_dashboard projects/element_agents/apps/agents_dashboard --build`.
|
||||
- Icono via windres (gestionado por `add_imgui_app`).
|
||||
|
||||
### 6. Tests + e2e_checks
|
||||
|
||||
```yaml
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "cmake --build cpp/build/windows --target agents_dashboard -j"
|
||||
timeout_s: 180
|
||||
- id: self_test
|
||||
cmd: "./cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe --self-test"
|
||||
timeout_s: 30
|
||||
- id: pytest_mock
|
||||
cmd: "cd projects/element_agents/apps/agents_dashboard/tests && python3 -m pytest -x -q"
|
||||
timeout_s: 60
|
||||
```
|
||||
|
||||
Mock server pytest emula 0128 (list/start/stop + SSE) y verifica que la app C++ conecta + popula tabla + start/stop funciona en headless con `--capture` mode.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App arranca, muestra Connection panel.
|
||||
- [ ] Tras meter apikey valida → tabla Agents populated con datos reales de VPS.
|
||||
- [ ] Stop/Start desde UI cambia estado real del agente en VPS.
|
||||
- [ ] Logs streamea lineas nuevas sin polling.
|
||||
- [ ] Cerrar y reabrir app → credentials persisten (cifradas).
|
||||
- [ ] Sin red / apikey invalida → error visible, app no crashea.
|
||||
- [ ] `--self-test` exit 0.
|
||||
- [ ] Visible en App Hub con icono + description correctos.
|
||||
|
||||
## DoD humano
|
||||
|
||||
- **Donde:** Windows Desktop → App Hub → Click "agents_dashboard".
|
||||
- **Latencia:** logs SSE < 1s lag. Lista agents < 200ms tras handshake.
|
||||
- **Onboarding:** First-run wizard pide base_url + apikey; tooltip explica donde obtener la key (gestor de secretos del VPS).
|
||||
|
||||
## Riesgos
|
||||
|
||||
- libcurl en Windows mingw-w64: cross-compile setup. Si `http_client_cpp_infra` no existe, dedicar tiempo al wrapper antes de UI.
|
||||
- DPAPI solo Windows: fallback Linux puede ser texto plano con permisos 0600 + warning visible en UI.
|
||||
- SSE reconnect logic: backoff exponencial + indicador de estado claro.
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
id: "0131"
|
||||
title: "agents v0.2: control per-agent unified mode + uptime/msg_24h + data_table_cpp_viz + clear/cache actions"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- agents
|
||||
- tui
|
||||
- infra
|
||||
scope: app
|
||||
priority: alta
|
||||
depends:
|
||||
- "0128"
|
||||
- "0129"
|
||||
blocks: []
|
||||
related: []
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [agents_and_robots, agents_dashboard, http, unified-mode, data-table, control]
|
||||
dod_evidence_schema:
|
||||
# Backend: agents_and_robots
|
||||
- id: build_backend
|
||||
kind: cmd
|
||||
expected: "cd projects/element_agents/apps/agents_and_robots && go build -tags goolm ./... → exit 0"
|
||||
required: true
|
||||
- id: tests_backend
|
||||
kind: cmd
|
||||
expected: "cd projects/element_agents/apps/agents_and_robots && go test -tags goolm -count=1 ./internal/api/... → exit 0"
|
||||
required: true
|
||||
- id: stop_unified_works
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/stop devuelve {status:stopped}; GET /agents/test-bot → running=false en <2s"
|
||||
required: true
|
||||
- id: start_unified_works
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/start tras stop devuelve {status:started}; GET /agents/test-bot → running=true en <5s"
|
||||
required: true
|
||||
- id: restart_unified_works
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/restart sobre agente running deja running=true en <8s sin error"
|
||||
required: true
|
||||
- id: clear_memory_endpoint
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/clear_memory devuelve {status:cleared, messages_deleted:N}; SELECT COUNT(*) FROM messages WHERE agent_id='test-bot' == 0"
|
||||
required: true
|
||||
- id: delete_cache_endpoint
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/delete_cache devuelve {status:cleared, paths_deleted:[...]}; verificar que crypto.db cache borrado"
|
||||
required: true
|
||||
- id: uptime_exposed
|
||||
kind: cmd
|
||||
expected: "GET /agents incluye campo uptime_seconds:int >0 para agents running"
|
||||
required: true
|
||||
- id: msg_24h_exposed
|
||||
kind: cmd
|
||||
expected: "GET /agents incluye campo messages_24h:int (puede ser 0) calculado de tabla messages"
|
||||
required: true
|
||||
# Frontend: agents_dashboard
|
||||
- id: build_frontend
|
||||
kind: cmd
|
||||
expected: "cmake --build cpp/build/windows --target agents_dashboard -j → exit 0"
|
||||
required: true
|
||||
- id: data_table_cpp_viz_used
|
||||
kind: cmd
|
||||
expected: "grep -E 'BeginTable|EndTable' projects/element_agents/apps/agents_dashboard/main.cpp devuelve 0 lineas (migrado a data_table_cpp_viz); grep data_table_cpp_viz app.md uses_functions = 1"
|
||||
required: true
|
||||
- id: per_agent_buttons_rendered
|
||||
kind: screenshot
|
||||
expected: "Tabla Agents muestra >=5 botones por fila: Start, Stop, Restart, Clear Memory, Delete Cache (puede iconos+tooltip)"
|
||||
required: true
|
||||
- id: uptime_visible
|
||||
kind: screenshot
|
||||
expected: "Tabla Agents columna uptime muestra valor humanizado (ej 12h, 3d) para agents running"
|
||||
required: true
|
||||
- id: msg_24h_visible
|
||||
kind: screenshot
|
||||
expected: "Tabla Agents columna msg/24h muestra contador real (no 'instances' como hack)"
|
||||
required: true
|
||||
# E2E: pytest
|
||||
- id: e2e_tests_pass
|
||||
kind: cmd
|
||||
expected: "AGENTS_API_KEY=... pytest tests/test_connect_e2e.py → todos PASS (>=20 tests)"
|
||||
required: true
|
||||
- id: e2e_control_roundtrip
|
||||
kind: cmd
|
||||
expected: "Nuevo test_control_roundtrip: stop → poll running=false → start → poll running=true → restart → poll running=true. Todo dentro de 30s."
|
||||
required: true
|
||||
- id: e2e_clear_memory
|
||||
kind: cmd
|
||||
expected: "Nuevo test_clear_memory: insert filas en messages → POST /clear_memory → COUNT == 0"
|
||||
required: true
|
||||
---
|
||||
|
||||
# 0131 — agents v0.2: full per-agent control + data_table + nuevos botones
|
||||
|
||||
## Contexto
|
||||
|
||||
v0.1 (issues 0128+0129) entrego:
|
||||
- HTTP API + apikey + TLS + SSE
|
||||
- C++ frontend con Connection/Agents/Logs/Status feed
|
||||
- Tabla agents con `running` derivado de backend
|
||||
|
||||
**Gaps detectados durante uso real:**
|
||||
1. **Control individual roto en unified mode** — Manager.Start/Stop esperan PID files por agente; en unified mode no existen → endpoints devuelven errores confusos ("not running" sobre agente que SI corre).
|
||||
2. **No hay uptime ni msg_24h reales** — backend no expone esos campos. UI muestra `instances` como hack para msg_24h.
|
||||
3. **Faltan acciones de gestion** — clear memory (mensajes en SQLite), delete cache (crypto E2EE), reset state.
|
||||
4. **Tabla manual** — `ImGui::BeginTable` inline en main.cpp. El registry tiene `data_table_cpp_viz` (funcion canonica). Migrar.
|
||||
|
||||
## Scope v0.2
|
||||
|
||||
### Backend (`projects/element_agents/apps/agents_and_robots/`)
|
||||
|
||||
**1. Control per-agent en unified mode**
|
||||
|
||||
Hoy launcher arranca todos los agents como goroutines bajo 1 PID via mode "unified". `Manager.Start/Stop/Restart` actuales solo funcionan en mode multi-process (PID por agente).
|
||||
|
||||
Anadir registro de cancel-context por agente en el launcher:
|
||||
- Por cada agente que arranca como goroutine, guardar `context.CancelFunc` en `Manager.unifiedCancels map[string]context.CancelFunc`.
|
||||
- `Manager.StopUnifiedAgent(id)` llama cancel del agente especifico.
|
||||
- `Manager.StartUnifiedAgent(id)` re-arranca solo ese agente sin restart del launcher entero.
|
||||
- `Manager.RestartUnifiedAgent(id)` = Stop + Start.
|
||||
|
||||
Handlers `handleStart/Stop/Restart` autodetectan via `IsUnifiedRunning()` y delegan a las nuevas variantes unified.
|
||||
|
||||
**2. Uptime real**
|
||||
|
||||
- `Manager.startedAt map[string]time.Time` poblado al arrancar cada goroutine.
|
||||
- En `AgentStatus.UptimeSeconds`, calcular `time.Since(startedAt[id]).Seconds()` si running, else 0.
|
||||
- Exponer en `agentResponse` como `uptime_seconds: int`.
|
||||
|
||||
**3. Messages_24h**
|
||||
|
||||
Cada agent persiste mensajes en su SQLite (`agents/<id>/data/memory.db`). El handler `handleListAgents` debe agregar por agente:
|
||||
- Abrir DB del agente readonly
|
||||
- `SELECT COUNT(*) FROM messages WHERE created_at > datetime('now', '-24 hours')`
|
||||
- Cache 30s para no abrir DB en cada request
|
||||
|
||||
Exponer como `messages_24h: int`.
|
||||
|
||||
**4. Endpoint `POST /agents/{id}/clear_memory`**
|
||||
|
||||
- Stop agent (si running)
|
||||
- Open agent's memory.db
|
||||
- `DELETE FROM messages` + `DELETE FROM facts`
|
||||
- Optionally start back si estaba running (deber `?restart=true` opcional)
|
||||
- Return `{status:"cleared", messages_deleted:N, facts_deleted:M}`
|
||||
|
||||
**5. Endpoint `POST /agents/{id}/delete_cache`**
|
||||
|
||||
- Stop agent (si running)
|
||||
- Delete `agents/<id>/data/crypto/` directory (E2EE cache; agent re-init on next start)
|
||||
- Delete `agents/<id>/data/cache/*` si existe
|
||||
- Return `{status:"cleared", paths_deleted:[...]}`
|
||||
- Optionally start back si estaba running (`?restart=true`)
|
||||
|
||||
NOTA: delete_cache fuerza re-verificacion E2EE. El agente debe re-autenticarse via SSSS recovery key on next start. Documentar.
|
||||
|
||||
### Frontend (`projects/element_agents/apps/agents_dashboard/`)
|
||||
|
||||
**1. Migrar a `data_table_cpp_viz`**
|
||||
|
||||
Hoy main.cpp usa `ImGui::BeginTable` inline. Sustituir por `data_table::Table` del registry (funcion `data_table_cpp_viz`). Anadir a `app.md::uses_functions`. Verificar via `fn doctor cpp-apps` que la app pasa de `CANDIDATE` a limpio.
|
||||
|
||||
**2. Columnas tabla:**
|
||||
- id
|
||||
- status icon (running=green, stopped=gray, disabled=yellow, crashed=red)
|
||||
- uptime (humanized via `human_duration_secs`)
|
||||
- msg/24h (numero real, NO instances)
|
||||
- actions (5 botones agrupados):
|
||||
- `▶ Start` (disabled si running)
|
||||
- `⏹ Stop` (disabled si !running)
|
||||
- `↻ Restart`
|
||||
- `🧠 Clear Memory` (confirmacion modal)
|
||||
- `🗑 Delete Cache` (confirmacion modal)
|
||||
|
||||
**3. Sort + filter** mantener via data_table_cpp_viz API.
|
||||
|
||||
### E2E (`tests/`)
|
||||
|
||||
Anadir 7 tests nuevos:
|
||||
- `test_control_roundtrip` — stop → poll → start → poll → restart → poll. Usa `test-bot`.
|
||||
- `test_clear_memory` — POST clear_memory, verifica COUNT(*) FROM messages == 0.
|
||||
- `test_delete_cache` — POST delete_cache, verifica crypto/ borrado.
|
||||
- `test_uptime_field_present` — /agents response incluye uptime_seconds key
|
||||
- `test_msg_24h_field_present` — /agents response incluye messages_24h key
|
||||
- `test_unified_stop_does_not_kill_launcher` — tras stop de 1 agente, otros siguen running.
|
||||
- `test_clear_memory_requires_apikey` — sin Bearer → 401
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase A — Backend (agents_and_robots)
|
||||
|
||||
1. Agregar `unifiedCancels map[string]context.CancelFunc` + `startedAt map[string]time.Time` + mutex a `shell/process.Manager`.
|
||||
2. Hook en `launcher` runtime para registrar/desregistrar cancels al arrancar/parar cada agent goroutine.
|
||||
3. Implementar `StopUnifiedAgent`, `StartUnifiedAgent`, `RestartUnifiedAgent` (Stop+Start).
|
||||
4. Refactor handlers `handleStartAgent/Stop/Restart` para autodetect unified vs multi.
|
||||
5. Anadir `uptime_seconds` y `messages_24h` a `AgentResponse`. Implementar query 24h con cache 30s.
|
||||
6. Implementar handlers `handleClearMemory`, `handleDeleteCache`.
|
||||
7. Anadir rutas en `server.go`.
|
||||
8. Tests Go unit `internal/api/*_test.go`.
|
||||
|
||||
### Fase B — Frontend (agents_dashboard)
|
||||
|
||||
1. Cambiar `parse_agents` para leer `uptime_seconds` y `messages_24h` del backend.
|
||||
2. Migrar tabla a `data_table_cpp_viz`. Mantener filter + sort.
|
||||
3. Anadir 5 botones por fila (Start/Stop/Restart/Clear/Delete).
|
||||
4. Confirmacion modal para Clear/Delete.
|
||||
5. Actualizar app.md::uses_functions con `data_table_cpp_viz`.
|
||||
|
||||
### Fase C — E2E + verify
|
||||
|
||||
1. Anadir 7 pytest tests.
|
||||
2. Run all e2e from registry venv. >=20 tests pass.
|
||||
3. Rebuild .exe + redeploy Windows.
|
||||
4. Visual confirm: botones, uptime, msg_24h.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] All 14 DoD items green (cmd + screenshots).
|
||||
- [ ] >=20 e2e tests passing.
|
||||
- [ ] App C++ deployed to Windows Desktop, visible buttons + working roundtrip.
|
||||
- [ ] Backend unit tests pass.
|
||||
- [ ] No regression: 0128 + 0129 funcionalidad existente intacta (curl smoke del v0.1 sigue green).
|
||||
|
||||
## DoD humano
|
||||
|
||||
- **Donde**: Windows Desktop → agents_dashboard.exe → tabla Agents.
|
||||
- **Latencia**: stop → running=false reflected in UI within 2s (via SSE status diff). msg/24h refresh cada 30s ok.
|
||||
- **Onboarding**: tooltip en boton "Clear Memory" explica que borra mensajes; "Delete Cache" explica que el agente tendra que re-autenticar via SSSS al volver a arrancar.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- Refactor de Manager unified-mode toca el ciclo de vida del launcher (paso ~7 del create_agent pipeline). Tests existentes deben pasar.
|
||||
- delete_cache borra crypto store; agente debe poder re-verify via env var `SSSS_RECOVERY_KEY_<NORM>`. Si esa env var no esta, agente queda en estado degradado. Validar antes de borrar.
|
||||
- data_table_cpp_viz puede tener limites de API que ImGui inline no tiene (sort custom, alignment). Verificar antes de migrar.
|
||||
@@ -40,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
|
||||
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# kanban — Parser/writer de issues y flows del registry
|
||||
|
||||
Cluster de funciones para leer, escribir y vigilar los archivos `dev/issues/*.md` y `dev/flows/*.md`. Base del backend de `kanban_cpp v2` (issue 0130b) y de cualquier herramienta que opere sobre el board de desarrollo.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace |
|
||||
|---|---|---|
|
||||
| `parse_issue_md_go_infra` | `(path) → (Issue, []byte, error)` | Lee un .md de issue, extrae frontmatter YAML + body |
|
||||
| `write_issue_md_go_infra` | `(path, Issue, body) → error` | Serializa Issue a YAML y reescribe el .md preservando body |
|
||||
| `scan_issues_dir_go_infra` | `(root) → ([]Issue, error)` | Escanea dev/issues/ + completed/, devuelve todos los Issues ordenados |
|
||||
| `scan_flows_dir_go_infra` | `(root) → ([]Flow, error)` | Escanea dev/flows/, devuelve todos los Flows ordenados |
|
||||
| `watch_dir_fsnotify_go_infra` | `(ctx, root) → (<-chan FsEvent, error)` | Watcher recursivo con debounce 200ms, emite FsEvent por cambio |
|
||||
|
||||
## Tipos
|
||||
|
||||
| ID | Que es |
|
||||
|---|---|
|
||||
| `issue_go_infra` | Frontmatter de dev/issues/*.md: id, title, status, domain, priority, depends, blocks… |
|
||||
| `flow_go_infra` | Frontmatter de dev/flows/*.md: id, name/title, status, kind, tags |
|
||||
| `fs_event_go_infra` | Evento de watcher: {Path, Op} donde Op ∈ {create, write, remove, rename} |
|
||||
|
||||
## Ejemplo canónico — arrancar el backend de kanban_cpp
|
||||
|
||||
```go
|
||||
import "fn-registry/functions/infra"
|
||||
|
||||
const (
|
||||
issuesDir = "/home/lucas/fn_registry/dev/issues"
|
||||
flowsDir = "/home/lucas/fn_registry/dev/flows"
|
||||
)
|
||||
|
||||
// 1. Carga inicial
|
||||
issues, _ := infra.ScanIssuesDir(issuesDir)
|
||||
flows, _ := infra.ScanFlowsDir(flowsDir)
|
||||
fmt.Printf("%d issues, %d flows cargados\n", len(issues), len(flows))
|
||||
|
||||
// 2. Actualizar status in-place
|
||||
iss, body, _ := infra.ParseIssueMd(issuesDir + "/0130-kanban-cpp-v2.md")
|
||||
iss.Status = "in-progress"
|
||||
iss.Updated = "2026-05-22"
|
||||
infra.WriteIssueMd(iss.FilePath, iss, body)
|
||||
|
||||
// 3. Vigilar cambios externos (editor de texto, otro agente)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch, _ := infra.WatchDirFsnotify(ctx, issuesDir)
|
||||
for ev := range ch {
|
||||
if strings.HasSuffix(ev.Path, ".md") {
|
||||
updated, _, _ := infra.ParseIssueMd(ev.Path)
|
||||
cache.Upsert(updated) // invalidar cache SQLite
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO incluye markdown rendering del body (eso lo hace el frontend).
|
||||
- NO valida campos contra TAXONOMY (existe `fn doctor issues`).
|
||||
- NO crea ni borra archivos de issue (solo lee/escribe los existentes).
|
||||
- NO incluye endpoints HTTP ni SSE (eso es el backend de la app, issue 0130b).
|
||||
|
||||
## Notas
|
||||
|
||||
- `parse_issue_md` + `write_issue_md` son el par CRUD atómico. Siempre usarlos juntos.
|
||||
- `scan_issues_dir` llama a `parse_issue_md` internamente — no reimplementar el walk.
|
||||
- `watch_dir_fsnotify` emite eventos para cualquier archivo, no solo `.md`. Filtrar por extensión en el consumidor.
|
||||
- El watcher y el writer pueden producir loops: el writer dispara un evento `write` que el watcher emite. El backend debe ignorar eventos generados por sus propios writes (comparar path + timestamp).
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
group: matrix-mas
|
||||
description: "Migración y operación de Synapse con Matrix Authentication Service (MAS). Cubre habilitación de MSC3861, verificación de login flows, parche .well-known OIDC, registro de clientes MAS y migración syn2mas."
|
||||
tags: [matrix, mas, synapse, migration]
|
||||
functions:
|
||||
- synapse_login_flows_check_go_infra
|
||||
- synapse_msc3861_enable_go_infra
|
||||
- wellknown_oidc_patch_go_infra
|
||||
- mas_client_register_bash_infra
|
||||
- mas_syn2mas_migration_bash_infra
|
||||
---
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `synapse_login_flows_check_go_infra` | `SynapseLoginFlowsCheck(cfg) (result, error)` | Polling de `/_matrix/client/v3/login` hasta confirmar SSO/MAS activo y password desactivado |
|
||||
| `synapse_msc3861_enable_go_infra` | `SynapseMsc3861Enable(cfg) (result, error)` | Habilita MSC3861 en `homeserver.yaml` vía SSH y reinicia Synapse |
|
||||
| `wellknown_oidc_patch_go_infra` | `WellknownOidcPatch(cfg) (result, error)` | Parchea `.well-known/matrix/client` para añadir el bloque `m.authentication` de MAS |
|
||||
| `mas_client_register_bash_infra` | `mas_client_register(ssh_host, container, config_file, dry_run)` | Registra un cliente OAuth2 en MAS vía `mas-cli manage register-client` |
|
||||
| `mas_syn2mas_migration_bash_infra` | `mas_syn2mas_migration --ssh-host ... --mas-container ... --synapse-config-path ...` | Ejecuta la migración syn2mas de usuarios y sesiones de Synapse a MAS |
|
||||
|
||||
## Ejemplo canónico — verificar post-migración (issue 0162, paso 6)
|
||||
|
||||
```go
|
||||
// 1. Habilitar MSC3861 en homeserver.yaml y reiniciar Synapse
|
||||
resCfg := SynapseMsc3861Config{
|
||||
SSHHost: "organic-machine",
|
||||
HomserverPath: "/etc/synapse/homeserver.yaml",
|
||||
RestartCommand: "systemctl restart matrix-synapse",
|
||||
}
|
||||
_, err := SynapseMsc3861Enable(resCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("enable MSC3861: %v", err)
|
||||
}
|
||||
|
||||
// 2. Parchar .well-known con bloque m.authentication
|
||||
patchCfg := WellknownOidcPatchConfig{
|
||||
WellknownPath: "/var/www/.well-known/matrix/client",
|
||||
IssuerURL: "https://mas.organic-machine.com/",
|
||||
}
|
||||
_, err = WellknownOidcPatch(patchCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("well-known patch: %v", err)
|
||||
}
|
||||
|
||||
// 3. Verificar que login flows ya no exponen m.login.password
|
||||
checkCfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 3,
|
||||
}
|
||||
res, err := SynapseLoginFlowsCheck(checkCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("login flows check: %v\nlast response: %s", err, res.LastResponseJSON)
|
||||
}
|
||||
fmt.Printf("MAS confirmed after %d attempt(s). SSO: %v, Password: %v\n",
|
||||
res.AttemptsUsed, res.SsoPresent, res.PasswordEnabled)
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- Este grupo cubre la **migración y validación** de Synapse→MAS. No cubre la configuración inicial de MAS ni la gestión de usuarios post-migración.
|
||||
- Las funciones bash (`mas_client_register`, `mas_syn2mas_migration`) operan vía SSH sobre el host remoto — requieren acceso SSH configurado en `~/.ssh/config`.
|
||||
- Las funciones Go (`synapse_login_flows_check`, `synapse_msc3861_enable`, `wellknown_oidc_patch`) pueden correr localmente o en pipelines CI.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Acceso SSH al host donde corre Synapse (alias en `~/.ssh/config`).
|
||||
- MAS desplegado y accesible antes de ejecutar la migración.
|
||||
- `ExpectedSsoIdpID` verificado contra `mas/config.yaml` → `clients[].id` del homeserver Synapse.
|
||||
|
||||
## Orden recomendado (issue 0162)
|
||||
|
||||
1. `mas_client_register` — registrar Synapse como cliente OAuth2 en MAS.
|
||||
2. `synapse_msc3861_enable` — habilitar MSC3861 + reiniciar.
|
||||
3. `wellknown_oidc_patch` — actualizar `.well-known`.
|
||||
4. `synapse_login_flows_check` — confirmar convergencia post-restart.
|
||||
5. `mas_syn2mas_migration` — migrar usuarios y sesiones existentes.
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package infra
|
||||
|
||||
// Flow representa el frontmatter de un archivo Markdown de flow en dev/flows/.
|
||||
// Los campos de runtime (FilePath, MtimeNs) no se serializaran en YAML.
|
||||
type Flow struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title,omitempty"`
|
||||
Status string `yaml:"status,omitempty"`
|
||||
Kind string `yaml:"kind,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
|
||||
// Para flows con formato name/status por separado (ej. hn-top-stories).
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Priority string `yaml:"priority,omitempty"`
|
||||
|
||||
// Campos de runtime — NO se serializan en YAML.
|
||||
FilePath string `yaml:"-"`
|
||||
MtimeNs int64 `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package infra
|
||||
|
||||
// FsEvent representa un evento del watcher de sistema de archivos.
|
||||
// Op es uno de: "create", "write", "remove", "rename".
|
||||
type FsEvent struct {
|
||||
Path string // ruta absoluta del archivo afectado
|
||||
Op string // "create" | "write" | "remove" | "rename"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package infra
|
||||
|
||||
// Issue representa el frontmatter de un archivo Markdown de issue en dev/issues/.
|
||||
// Los campos de runtime (FilePath, MtimeNs, Completed) no se serialiaran en YAML.
|
||||
type Issue struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title"`
|
||||
Status string `yaml:"status"`
|
||||
Type string `yaml:"type"`
|
||||
Domain []string `yaml:"domain"`
|
||||
Scope string `yaml:"scope"`
|
||||
Priority string `yaml:"priority"`
|
||||
Depends []string `yaml:"depends"`
|
||||
Blocks []string `yaml:"blocks"`
|
||||
Related []string `yaml:"related"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
Created string `yaml:"created"`
|
||||
Updated string `yaml:"updated"`
|
||||
|
||||
// Campos de runtime — NO se serializan en YAML.
|
||||
FilePath string `yaml:"-"`
|
||||
MtimeNs int64 `yaml:"-"`
|
||||
Completed bool `yaml:"-"` // true si el archivo vive en dev/issues/completed/
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MCPHTTPAuthFunc validates an incoming HTTP request and returns an enriched
|
||||
// context (e.g. with user_id) or an error. When it returns an error the
|
||||
// handler replies 401 Unauthorized without invoking the tool handler.
|
||||
// If nil, no auth is performed.
|
||||
type MCPHTTPAuthFunc func(r *http.Request) (context.Context, error)
|
||||
|
||||
// MCPHTTPOpts configures the Streamable HTTP MCP handler.
|
||||
type MCPHTTPOpts struct {
|
||||
Name string // server name reported to the client in initialize
|
||||
Version string // server version reported to the client in initialize
|
||||
Tools []MCPToolDef // reuses MCPToolDef from mcp_server_stdio.go
|
||||
Handler MCPToolHandler // reuses MCPToolHandler from mcp_server_stdio.go
|
||||
Auth MCPHTTPAuthFunc // optional; if nil, no auth
|
||||
Logger io.Writer // optional log sink; discards when nil
|
||||
}
|
||||
|
||||
const mcpHTTPBodyLimit = 1 << 20 // 1 MiB
|
||||
|
||||
// MCPHTTPHandler returns an http.Handler that implements the Streamable HTTP
|
||||
// MCP transport (spec 2025-03-26).
|
||||
//
|
||||
// Mount at any single path (e.g. /mcp). Handles POST for client→server
|
||||
// JSON-RPC 2.0 requests. GET and DELETE return 405 Method Not Allowed (SSE
|
||||
// server→client streaming is not implemented — see Gotchas in the .md).
|
||||
//
|
||||
// The handler is safe for concurrent use; it carries no shared mutable state.
|
||||
func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler {
|
||||
logf := func(format string, args ...any) {
|
||||
if opts.Logger != nil {
|
||||
fmt.Fprintf(opts.Logger, "[mcp-http] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
// handled below
|
||||
case http.MethodGet, http.MethodDelete:
|
||||
// SSE server→client and session close not implemented yet.
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional auth: validate request and (optionally) enrich context.
|
||||
ctx := r.Context()
|
||||
if opts.Auth != nil {
|
||||
enriched, err := opts.Auth(r)
|
||||
if err != nil {
|
||||
logf("auth rejected: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx = enriched
|
||||
}
|
||||
|
||||
// Read body with size limit (anti-DoS).
|
||||
limitedBody := http.MaxBytesReader(w, r.Body, mcpHTTPBodyLimit)
|
||||
body, err := io.ReadAll(limitedBody)
|
||||
if err != nil {
|
||||
// MaxBytesReader wraps the error; treat any read failure as 413.
|
||||
logf("body read error: %v", err)
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
logf("recv: %s", body)
|
||||
|
||||
// Parse JSON-RPC request. On parse failure respond HTTP 200 with
|
||||
// JSON-RPC error -32700 (per MCP spec — not HTTP 400).
|
||||
var req jsonrpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
logf("json parse error: %v", err)
|
||||
writeJSONRPCError(w, nil, -32700, "parse error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Notifications (no "id" key in raw JSON) → 202 Accepted, no body.
|
||||
isNotification := !jsonHasKey(body, "id")
|
||||
if isNotification {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch method.
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result := map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{
|
||||
"name": opts.Name,
|
||||
"version": opts.Version,
|
||||
},
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, result)
|
||||
|
||||
case "initialized":
|
||||
// Should not arrive as a non-notification, but handle gracefully.
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{})
|
||||
|
||||
case "tools/list":
|
||||
tools := opts.Tools
|
||||
if tools == nil {
|
||||
tools = []MCPToolDef{}
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{"tools": tools})
|
||||
|
||||
case "tools/call":
|
||||
var p mcpCallParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
writeJSONRPCError(w, req.ID, -32602, "invalid params: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
args := p.Arguments
|
||||
if args == nil {
|
||||
args = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
toolResult, isErr, handlerErr := opts.Handler(ctx, p.Name, args)
|
||||
if handlerErr != nil {
|
||||
logf("handler error for %q: %v", p.Name, handlerErr)
|
||||
writeJSONRPCError(w, req.ID, -32603, handlerErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resultText, _ := json.Marshal(toolResult)
|
||||
callResult := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": string(resultText),
|
||||
},
|
||||
},
|
||||
"isError": isErr,
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, callResult)
|
||||
|
||||
case "ping":
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{})
|
||||
|
||||
default:
|
||||
logf("unknown method %q", req.Method)
|
||||
writeJSONRPCError(w, req.ID, -32601, "method not found: "+req.Method)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONRPCResult writes a JSON-RPC 2.0 success response.
|
||||
func writeJSONRPCResult(w http.ResponseWriter, id any, result any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(jsonrpcResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONRPCError writes a JSON-RPC 2.0 error response with HTTP 200.
|
||||
// Per the MCP Streamable HTTP spec, protocol errors still use HTTP 200 so
|
||||
// the client can parse the JSON-RPC error object (not HTTP status codes).
|
||||
func writeJSONRPCError(w http.ResponseWriter, id any, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(jsonrpcResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &jsonrpcError{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: mcp_server_http
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler"
|
||||
description: "Devuelve un http.Handler que implementa el Streamable HTTP transport del protocolo MCP (spec 2025-03-26). Acepta POST con un mensaje JSON-RPC 2.0 unico y despacha initialize/tools/list/tools/call/ping al handler del usuario. Soporta auth opcional via MCPHTTPAuthFunc que enriquece el context antes de invocar el handler de tools."
|
||||
tags: [mcp, http, rpc, json-rpc, tools, server, protocol, claude, backends]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- context
|
||||
- encoding/json
|
||||
- fmt
|
||||
- io
|
||||
- net/http
|
||||
tested: true
|
||||
tests:
|
||||
- "Initialize retorna serverInfo con Name y Version correctos"
|
||||
- "ToolsList retorna las tools registradas"
|
||||
- "ToolsCall invoca handler y retorna content[0].text con el resultado"
|
||||
- "BadAuth retorna 401 cuando opts.Auth devuelve error"
|
||||
- "BodyTooLarge retorna 413 cuando el body supera 1 MiB"
|
||||
- "ParseError retorna HTTP 200 con error JSON-RPC -32700 para body invalido"
|
||||
- "Notification retorna 202 Accepted sin body cuando falta el campo id"
|
||||
- "MethodNotAllowed retorna 405 para GET y DELETE"
|
||||
test_file_path: "functions/infra/mcp_server_http_test.go"
|
||||
file_path: "functions/infra/mcp_server_http.go"
|
||||
params:
|
||||
- name: opts.Name
|
||||
desc: "Nombre del servidor reportado al cliente en la respuesta de initialize (serverInfo.name)."
|
||||
- name: opts.Version
|
||||
desc: "Version del servidor reportada al cliente en initialize (serverInfo.version)."
|
||||
- name: opts.Tools
|
||||
desc: "Lista de MCPToolDef (nombre, descripcion, JSON Schema del input) que el servidor expone. Mismo tipo que MCPServerOpts de mcp_server_stdio_go_infra."
|
||||
- name: opts.Handler
|
||||
desc: "Dispatcher unico para todas las tools. Recibe ctx (posiblemente enriquecido por Auth), nombre de la tool y arguments JSON crudo. Devuelve result, isError y err."
|
||||
- name: opts.Auth
|
||||
desc: "Funcion opcional de autenticacion. Recibe el *http.Request, devuelve un context enriquecido (p.ej. con user_id) o un error. Si devuelve error, el handler responde 401 sin invocar Handler. Si es nil no se hace auth."
|
||||
- name: opts.Logger
|
||||
desc: "Writer opcional para log de debug (p.ej. os.Stderr). Si es nil los mensajes se descartan."
|
||||
output: "http.Handler listo para montarse en un mux. El handler es seguro para uso concurrente (no tiene estado mutable compartido)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tools := []infra.MCPToolDef{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Devuelve el mensaje tal cual",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {"msg": {"type": "string"}},
|
||||
"required": ["msg"]
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
toolHandler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
|
||||
if name == "echo" {
|
||||
var args struct{ Msg string `json:"msg"` }
|
||||
if err := json.Unmarshal(input, &args); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return map[string]string{"result": args.Msg}, false, nil
|
||||
}
|
||||
return nil, true, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
// AuthFunc Bearer simple: extrae el token, busca en DB, inyecta user_id en ctx.
|
||||
// El kanban lo implementa asi, buscando en la tabla mcp_tokens.
|
||||
authFn := func(r *http.Request) (context.Context, error) {
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("missing token")
|
||||
}
|
||||
// ... validar token en DB ...
|
||||
ctx := context.WithValue(r.Context(), "user_id", "u_123")
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
mcpH := infra.MCPHTTPHandler(infra.MCPHTTPOpts{
|
||||
Name: "my-app-mcp",
|
||||
Version: "1.0.0",
|
||||
Tools: tools,
|
||||
Handler: toolHandler,
|
||||
Auth: authFn,
|
||||
Logger: os.Stderr,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcpH)
|
||||
|
||||
fmt.Println("MCP HTTP server en :8300/mcp")
|
||||
if err := http.ListenAndServe(":8300", mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para un cliente MCP HTTP, la configuracion en `.mcp.json` usa `type: http`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-app": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8300/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites exponer tools MCP a Claude o a un agente via HTTP en lugar de via stdio — es decir, cuando el servidor MCP es un proceso separado (no un subproceso del cliente) o cuando varios clientes deben compartir el mismo servidor. Tipico en apps con backend Go ya existente (kanban, sqlite_api, registry_api) que quieren aceptar llamadas MCP sin lanzar un subproceso nuevo por sesion.
|
||||
|
||||
Usa `mcp_server_stdio_go_infra` si el cliente lanza el servidor como subproceso (Claude Desktop, `claude -p`). Usa esta funcion si el servidor ya esta corriendo y el cliente se conecta via URL.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Sin sesiones MCP (Mcp-Session-Id)**: la spec 2025-03-26 define un header `Mcp-Session-Id` para multiplexar sesiones sobre el mismo endpoint. Esta implementacion no lo emite ni lo exige — cada POST es independiente. TODO: implementar sesiones cuando se necesiten mas de un cliente concurrente con estado de sesion separado.
|
||||
- **GET (SSE server→client) no implementado**: POST cubre el 100% de los casos de uso actuales (tools call). El canal SSE (server-initiated notifications) se puede anadir montando un segundo handler GET en el mismo path. Devuelve 405 hasta entonces.
|
||||
- **DELETE (close session) no implementado**: 405. Sin sesiones, no hay nada que cerrar.
|
||||
- **Body limit 1 MiB**: requests mas grandes reciben 413. Si tus tools reciben inputs grandes (imagenes en base64, JSONs voluminosos), ajusta `mcpHTTPBodyLimit` en el .go o recibe los datos por referencia (URL/path) en vez de inline.
|
||||
- **CORS no incluido**: monta `http_cors_middleware_go_infra` en el mux antes del handler si el cliente MCP es un frontend web o viene de origen diferente.
|
||||
- **Errores de protocolo usan HTTP 200**: segun el spec MCP, los errores JSON-RPC se devuelven con HTTP 200 para que el cliente pueda parsear el objeto error. Solo `401` y `413` son codigos HTTP de error.
|
||||
@@ -0,0 +1,201 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
func newTestMCPHandler(auth MCPHTTPAuthFunc) http.Handler {
|
||||
tools := []MCPToolDef{
|
||||
{
|
||||
Name: "greet",
|
||||
Description: "Returns a greeting",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`),
|
||||
},
|
||||
}
|
||||
handler := func(_ context.Context, name string, _ json.RawMessage) (any, bool, error) {
|
||||
if name == "greet" {
|
||||
return map[string]string{"hello": "world"}, false, nil
|
||||
}
|
||||
return nil, true, errors.New("unknown tool")
|
||||
}
|
||||
return MCPHTTPHandler(MCPHTTPOpts{
|
||||
Name: "test-server",
|
||||
Version: "0.0.1",
|
||||
Tools: tools,
|
||||
Handler: handler,
|
||||
Auth: auth,
|
||||
})
|
||||
}
|
||||
|
||||
func postMCP(h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// --- tests ------------------------------------------------------------------
|
||||
|
||||
func TestMCPHTTPHandler_Initialize(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result, ok := resp.Result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result is not map: %T", resp.Result)
|
||||
}
|
||||
if _, ok := result["protocolVersion"]; !ok {
|
||||
t.Error("missing protocolVersion in result")
|
||||
}
|
||||
si, ok := result["serverInfo"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing serverInfo")
|
||||
}
|
||||
if si["name"] != "test-server" {
|
||||
t.Errorf("serverInfo.name = %v, want test-server", si["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ToolsList(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected rpc error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result := resp.Result.(map[string]any)
|
||||
tools, ok := result["tools"].([]any)
|
||||
if !ok || len(tools) == 0 {
|
||||
t.Fatalf("expected non-empty tools array, got %v", result["tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ToolsCall(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{}}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected rpc error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result := resp.Result.(map[string]any)
|
||||
content, ok := result["content"].([]any)
|
||||
if !ok || len(content) == 0 {
|
||||
t.Fatalf("expected content array, got %v", result["content"])
|
||||
}
|
||||
first := content[0].(map[string]any)
|
||||
if first["text"] != `{"hello":"world"}` {
|
||||
t.Errorf("content[0].text = %q, want {\"hello\":\"world\"}", first["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_BadAuth(t *testing.T) {
|
||||
auth := func(_ *http.Request) (context.Context, error) {
|
||||
return nil, errors.New("bad token")
|
||||
}
|
||||
h := newTestMCPHandler(auth)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_BodyTooLarge(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
big := strings.Repeat("x", mcpHTTPBodyLimit+1)
|
||||
body := `{"jsonrpc":"2.0","id":5,"method":"initialize","params":{"x":"` + big + `"}}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("expected 413, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ParseError(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `not valid json`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200 for parse error, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error, got nil")
|
||||
}
|
||||
if resp.Error.Code != -32700 {
|
||||
t.Errorf("error code = %d, want -32700", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_Notification(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
// A notification has no "id" key at all.
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","method":"initialized","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 for notification, got %d", w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for notification, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_MethodNotAllowed(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
r := httptest.NewRequest(method, "/mcp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s: expected 405, got %d", method, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
|
||||
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
|
||||
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
|
||||
// Completed se deduce del path (contiene "/completed/").
|
||||
func ParseIssueMd(path string) (Issue, []byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
fm, body, err := splitFrontmatter(data)
|
||||
if err != nil {
|
||||
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
|
||||
}
|
||||
|
||||
var iss Issue
|
||||
if err := yaml.Unmarshal(fm, &iss); err != nil {
|
||||
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
|
||||
}
|
||||
|
||||
iss.FilePath = path
|
||||
iss.MtimeNs = info.ModTime().UnixNano()
|
||||
iss.Completed = strings.Contains(path, "/completed/")
|
||||
|
||||
return iss, body, nil
|
||||
}
|
||||
|
||||
// splitFrontmatter divide el contenido en bloque YAML y body.
|
||||
// Espera formato: "---\n<yaml>\n---\n<body>".
|
||||
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
|
||||
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
sep := []byte("---")
|
||||
newline := []byte("\n")
|
||||
|
||||
// El archivo debe empezar con "---\n"
|
||||
if !bytes.HasPrefix(data, append(sep, '\n')) {
|
||||
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
|
||||
}
|
||||
|
||||
// Buscar el segundo "---" (en su propia linea)
|
||||
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
|
||||
|
||||
idx := -1
|
||||
for i := 0; i <= len(rest)-len(sep); i++ {
|
||||
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
|
||||
atLineStart := i == 0 || rest[i-1] == '\n'
|
||||
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
|
||||
// El separador debe ir seguido de '\n' o EOF
|
||||
end := i + len(sep)
|
||||
if end == len(rest) || rest[end] == '\n' {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if idx == -1 {
|
||||
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
|
||||
}
|
||||
|
||||
fm := rest[:idx]
|
||||
// El body empieza despues del segundo "---\n"
|
||||
bodyStart := idx + len(sep)
|
||||
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
|
||||
bodyStart++
|
||||
}
|
||||
body := rest[bodyStart:]
|
||||
|
||||
_ = newline
|
||||
return fm, body, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: parse_issue_md
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ParseIssueMd(path string) (Issue, []byte, error)"
|
||||
description: "Lee un archivo Markdown de issue (dev/issues/*.md), extrae el frontmatter YAML en un struct Issue y devuelve el body tal como esta en disco. Rellena FilePath, MtimeNs y Completed (deduce de si el path contiene /completed/)."
|
||||
tags: [issue, parser, frontmatter, yaml, kanban, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [issue_go_infra]
|
||||
returns: [issue_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "fmt", "os", "strings", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta absoluta o relativa al archivo .md del issue (ej: dev/issues/0130-kanban-cpp-v2.md)"
|
||||
output: "Struct Issue con todos los campos del frontmatter, byte slice con el body MD, y error si el archivo no existe o el YAML es invalido"
|
||||
tested: true
|
||||
tests:
|
||||
- "parsea 0130-kanban-cpp-v2 correctamente"
|
||||
- "completed flag se deduce del path"
|
||||
- "error en archivo inexistente"
|
||||
- "fixture preserva campos"
|
||||
test_file_path: "functions/infra/parse_issue_md_test.go"
|
||||
file_path: "functions/infra/parse_issue_md.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("ID=%s Status=%s Domain=%v\n", iss.ID, iss.Status, iss.Domain)
|
||||
// body contiene el Markdown despues del segundo ---
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites leer el frontmatter de un issue del registry para mostrarlo, modificarlo o indexarlo. Usar como base de `scan_issues_dir_go_infra` (que la llama por cada archivo) o cuando necesites acceso al body MD ademas del struct.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El body devuelto incluye el `\n` inmediatamente posterior al segundo `---`. No se normaliza.
|
||||
- Si el archivo tiene un solo `---` (sin segundo delimitador), retorna error. Issues sin frontmatter no son validos.
|
||||
- `Completed` se infiere del path, no del campo `status` del YAML — un issue con `status: completado` que vive en `dev/issues/` (no en `completed/`) tendra `Completed=false`.
|
||||
- Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` son `[]string` — si el YAML los omite quedan como `nil`, no slice vacio.
|
||||
@@ -0,0 +1,101 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func registryRoot() string {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(thisFile), "..", "..")
|
||||
}
|
||||
|
||||
func TestParseIssueMd(t *testing.T) {
|
||||
root := registryRoot()
|
||||
|
||||
t.Run("parsea 0130-kanban-cpp-v2 correctamente", func(t *testing.T) {
|
||||
path := filepath.Join(root, "dev", "issues", "0130-kanban-cpp-v2.md")
|
||||
iss, body, err := ParseIssueMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if iss.ID != "0130" {
|
||||
t.Errorf("ID: got %q, want %q", iss.ID, "0130")
|
||||
}
|
||||
if !strings.Contains(iss.Title, "Kanban C++ v2") {
|
||||
t.Errorf("Title %q does not contain 'Kanban C++ v2'", iss.Title)
|
||||
}
|
||||
if iss.Status != "pendiente" {
|
||||
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
|
||||
}
|
||||
if len(iss.Domain) < 3 {
|
||||
t.Errorf("Domain: got %d items, want >=3: %v", len(iss.Domain), iss.Domain)
|
||||
}
|
||||
if iss.FilePath != path {
|
||||
t.Errorf("FilePath: got %q, want %q", iss.FilePath, path)
|
||||
}
|
||||
if iss.MtimeNs == 0 {
|
||||
t.Error("MtimeNs should be non-zero")
|
||||
}
|
||||
if iss.Completed {
|
||||
t.Error("Completed should be false for non-completed issue")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Error("body should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completed flag se deduce del path", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
completedDir := filepath.Join(t.TempDir(), "completed")
|
||||
if err := os.MkdirAll(completedDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
completedPath := filepath.Join(completedDir, "9999-fixture.md")
|
||||
if err := os.WriteFile(completedPath, data, 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
iss, _, err := ParseIssueMd(completedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if !iss.Completed {
|
||||
t.Error("Completed should be true for path with /completed/")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en archivo inexistente", func(t *testing.T) {
|
||||
_, _, err := ParseIssueMd("/nonexistent/path/issue.md")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture preserva campos", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
iss, body, err := ParseIssueMd(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if iss.ID != "9999" {
|
||||
t.Errorf("ID: got %q, want %q", iss.ID, "9999")
|
||||
}
|
||||
if iss.Flow != "0001" {
|
||||
t.Errorf("Flow: got %q, want %q", iss.Flow, "0001")
|
||||
}
|
||||
if len(iss.Depends) != 1 || iss.Depends[0] != "0001" {
|
||||
t.Errorf("Depends: got %v, want [0001]", iss.Depends)
|
||||
}
|
||||
if !strings.Contains(string(body), "Este es el body") {
|
||||
t.Errorf("body should contain fixture text, got: %s", string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ScanFlowsDir escanea el directorio root (dev/flows/) y devuelve todos los Flows
|
||||
// encontrados en *.md directos.
|
||||
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
|
||||
// Los flows se devuelven ordenados por ID ascendente.
|
||||
func ScanFlowsDir(root string) ([]Flow, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(root, "*.md"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan_flows_dir: glob: %w", err)
|
||||
}
|
||||
|
||||
var flows []Flow
|
||||
for _, path := range matches {
|
||||
base := filepath.Base(path)
|
||||
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") || strings.EqualFold(base, "AGENT_GUIDE.md") {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := parseFlowMd(path)
|
||||
if err != nil {
|
||||
log.Printf("scan_flows_dir: warning: skip %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
|
||||
sort.Slice(flows, func(i, j int) bool {
|
||||
return flows[i].ID < flows[j].ID
|
||||
})
|
||||
|
||||
return flows, nil
|
||||
}
|
||||
|
||||
// parseFlowMd parsea el frontmatter de un archivo dev/flows/*.md en un struct Flow.
|
||||
func parseFlowMd(path string) (Flow, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
fm, _, err := splitFrontmatter(data)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
var f Flow
|
||||
if err := yaml.Unmarshal(fm, &f); err != nil {
|
||||
return Flow{}, fmt.Errorf("yaml %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Algunos flows usan "name" y no "title" — normalizar
|
||||
if f.Title == "" && f.Name != "" {
|
||||
f.Title = f.Name
|
||||
}
|
||||
// Algunos flows usan entero como ID en el YAML — yaml.v3 lo convierte a string OK
|
||||
|
||||
f.FilePath = path
|
||||
f.MtimeNs = info.ModTime().UnixNano()
|
||||
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: scan_flows_dir
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ScanFlowsDir(root string) ([]Flow, error)"
|
||||
description: "Escanea el directorio dev/flows/ (root) y devuelve todos los Flows encontrados en *.md directos. Skippea INDEX.md, README.md y AGENT_GUIDE.md. Si un archivo falla al parsearse emite warning y continua. Resultado ordenado por ID ascendente."
|
||||
tags: [flow, scanner, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [flow_go_infra]
|
||||
returns: [flow_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: root
|
||||
desc: "Ruta al directorio dev/flows/ (absoluta o relativa)."
|
||||
output: "Slice de Flow ordenado por ID asc con FilePath y MtimeNs rellenados. Flows con YAML malformado se omiten con warning."
|
||||
tested: true
|
||||
tests:
|
||||
- "scan devuelve al menos 5 flows"
|
||||
- "flow 0001 esta presente"
|
||||
- "flows tienen FilePath y MtimeNs"
|
||||
- "flows ordenados por ID asc"
|
||||
test_file_path: "functions/infra/scan_flows_dir_test.go"
|
||||
file_path: "functions/infra/scan_flows_dir.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
flows, err := infra.ScanFlowsDir("/home/lucas/fn_registry/dev/flows")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Total flows: %d\n", len(flows))
|
||||
for _, f := range flows {
|
||||
fmt.Printf(" %s [%s] %s\n", f.ID, f.Status, f.Title)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al arrancar el backend de kanban_cpp para cargar el panel Flows. Tambien util para dashboards de estado del proyecto que necesiten listar flujos activos/pendientes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El struct `Flow` tiene campos `Name` y `Title` porque algunos flows del registry usan `name:` y otros `title:` en el frontmatter. `parseFlowMd` normaliza: si `Title` esta vacio pero `Name` no, copia `Name` a `Title`.
|
||||
- No tiene subdirectorio `completed/` equivalente — todos los flows activos e historicos viven en el mismo directorio raiz.
|
||||
- La funcion `parseFlowMd` es interna (no exportada). Si necesitas parsear un flow individual, usa directamente `yaml.Unmarshal` o expone una funcion separada.
|
||||
@@ -0,0 +1,66 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanFlowsDir(t *testing.T) {
|
||||
root := registryRoot()
|
||||
flowsDir := filepath.Join(root, "dev", "flows")
|
||||
|
||||
t.Run("scan devuelve al menos 5 flows", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
if len(flows) < 5 {
|
||||
t.Errorf("expected >= 5 flows, got %d", len(flows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flow 0001 esta presente", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range flows {
|
||||
if f.ID == "0001" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("flow 0001 not found in scan results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flows tienen FilePath y MtimeNs", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
for _, f := range flows {
|
||||
if f.FilePath == "" {
|
||||
t.Errorf("flow %q has empty FilePath", f.ID)
|
||||
}
|
||||
if f.MtimeNs == 0 {
|
||||
t.Errorf("flow %q has zero MtimeNs", f.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flows ordenados por ID asc", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
for i := 1; i < len(flows); i++ {
|
||||
if flows[i].ID < flows[i-1].ID {
|
||||
t.Errorf("not sorted at index %d: %q < %q", i, flows[i].ID, flows[i-1].ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanIssuesDir escanea el directorio root (dev/issues/) y devuelve todos los Issues
|
||||
// encontrados en *.md directos y en completed/*.md.
|
||||
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
|
||||
// Los issues se devuelven ordenados por ID ascendente.
|
||||
func ScanIssuesDir(root string) ([]Issue, error) {
|
||||
// Verificar que el directorio raiz existe.
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, fmt.Errorf("scan_issues_dir: root dir %s: %w", root, err)
|
||||
}
|
||||
|
||||
var issues []Issue
|
||||
|
||||
// Patterns a escanear: archivos directos y completed/
|
||||
patterns := []string{
|
||||
filepath.Join(root, "*.md"),
|
||||
filepath.Join(root, "completed", "*.md"),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan_issues_dir: glob %s: %w", pattern, err)
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
// Saltar INDEX.md y README.md
|
||||
base := filepath.Base(path)
|
||||
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") {
|
||||
continue
|
||||
}
|
||||
// Verificar que es un archivo regular
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
iss, _, err := ParseIssueMd(path)
|
||||
if err != nil {
|
||||
log.Printf("scan_issues_dir: warning: skip %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, iss)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: scan_issues_dir
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ScanIssuesDir(root string) ([]Issue, error)"
|
||||
description: "Escanea el directorio dev/issues/ (root) y devuelve todos los Issues encontrados en *.md directos y en completed/*.md. Si un archivo falla al parsearse emite un warning al log y continua. Resultado ordenado por ID ascendente."
|
||||
tags: [issue, scanner, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: [parse_issue_md_go_infra]
|
||||
uses_types: [issue_go_infra]
|
||||
returns: [issue_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings"]
|
||||
params:
|
||||
- name: root
|
||||
desc: "Ruta al directorio dev/issues/ (absoluta o relativa). Debe existir o retorna error."
|
||||
output: "Slice de Issue ordenado por ID asc. Incluye issues de completed/ con Completed=true. Issues con YAML malformado se omiten con warning."
|
||||
tested: true
|
||||
tests:
|
||||
- "scan devuelve al menos 90 issues"
|
||||
- "issue 0130 esta presente"
|
||||
- "issues ordenados por ID asc"
|
||||
- "completed issues tienen Completed=true"
|
||||
- "directorio inexistente retorna error"
|
||||
test_file_path: "functions/infra/scan_issues_dir_test.go"
|
||||
file_path: "functions/infra/scan_issues_dir.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
issues, err := infra.ScanIssuesDir("/home/lucas/fn_registry/dev/issues")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Total issues: %d\n", len(issues))
|
||||
for _, iss := range issues {
|
||||
fmt.Printf(" %s [%s] %s\n", iss.ID, iss.Status, iss.Title)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al arrancar el backend de kanban_cpp para poblar la cache SQLite inicial. Tambien util para cualquier herramienta que necesite un snapshot completo de todos los issues del proyecto (stats, dashboards, fn doctor).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Skippea automaticamente `INDEX.md` y `README.md` — no son issues.
|
||||
- Si `completed/` no existe (no hay issues completados), no retorna error — devuelve los issues directos.
|
||||
- La ordenacion es lexicografica por ID string, no numerica. `"0099" < "0100"` funciona bien con el formato de 4 digitos del registry.
|
||||
- Un issue con YAML invalido no aborta el scan entero — solo ese archivo se omite con un `log.Printf` warning. Si necesitas comportamiento strict (abort en primer error), parsea manualmente con `ParseIssueMd`.
|
||||
@@ -0,0 +1,74 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanIssuesDir(t *testing.T) {
|
||||
root := registryRoot()
|
||||
issuesDir := filepath.Join(root, "dev", "issues")
|
||||
|
||||
t.Run("scan devuelve al menos 90 issues", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
if len(issues) < 90 {
|
||||
t.Errorf("expected >= 90 issues, got %d", len(issues))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issue 0130 esta presente", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, iss := range issues {
|
||||
if iss.ID == "0130" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("issue 0130 not found in scan results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issues ordenados por ID asc", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
for i := 1; i < len(issues); i++ {
|
||||
if issues[i].ID < issues[i-1].ID {
|
||||
t.Errorf("not sorted at index %d: %q < %q", i, issues[i].ID, issues[i-1].ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completed issues tienen Completed=true", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
completedCount := 0
|
||||
for _, iss := range issues {
|
||||
if iss.Completed {
|
||||
completedCount++
|
||||
}
|
||||
}
|
||||
if completedCount == 0 {
|
||||
t.Error("expected at least some completed issues")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directorio inexistente retorna error", func(t *testing.T) {
|
||||
_, err := ScanIssuesDir("/nonexistent/dev/issues")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SynapseLoginFlowsCheckConfig holds the parameters for polling the Synapse
|
||||
// login-flows endpoint and verifying that the MAS (Matrix Authentication
|
||||
// Service) SSO flow is active.
|
||||
type SynapseLoginFlowsCheckConfig struct {
|
||||
HomeserverURL string // Public URL of the homeserver (e.g. https://matrix.example.com)
|
||||
ExpectedSsoIdpID string // IdP id to find in m.login.sso.identity_providers[].id (empty = only check SSO presence)
|
||||
MaxRetries int // Number of attempts before giving up (default: 10)
|
||||
RetryDelaySeconds int // Seconds to wait between attempts (default: 3)
|
||||
HttpTimeoutSeconds int // Per-request HTTP timeout in seconds (default: 5)
|
||||
}
|
||||
|
||||
// SynapseLoginFlowsCheckResult contains the parsed state of the login-flows
|
||||
// endpoint after the last successful (or final failed) attempt.
|
||||
type SynapseLoginFlowsCheckResult struct {
|
||||
Flows []string // All flow types returned (e.g. ["m.login.sso"])
|
||||
SsoPresent bool // true if "m.login.sso" is in Flows
|
||||
IdpFound bool // true if ExpectedSsoIdpID was found (or ExpectedSsoIdpID is empty and SsoPresent)
|
||||
PasswordEnabled bool // true if "m.login.password" is in Flows
|
||||
LastResponseJSON string // Raw JSON body from the last HTTP response
|
||||
AttemptsUsed int // Number of HTTP attempts made
|
||||
}
|
||||
|
||||
// loginFlowsResponse is the structure returned by
|
||||
// GET /_matrix/client/v3/login
|
||||
type loginFlowsResponse struct {
|
||||
Flows []loginFlow `json:"flows"`
|
||||
}
|
||||
|
||||
type loginFlow struct {
|
||||
Type string `json:"type"`
|
||||
IdentityProviders []idpProvider `json:"identity_providers,omitempty"`
|
||||
}
|
||||
|
||||
type idpProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SynapseLoginFlowsCheck polls GET {HomeserverURL}/_matrix/client/v3/login
|
||||
// and checks that the SSO/MAS flow is present and password login is disabled.
|
||||
// It retries up to MaxRetries times with RetryDelaySeconds delay between each.
|
||||
//
|
||||
// Success condition:
|
||||
// - "m.login.sso" is present in flows
|
||||
// - ExpectedSsoIdpID found in identity_providers (skipped when empty)
|
||||
// - "m.login.password" is NOT present
|
||||
//
|
||||
// Returns the result from the last attempt. On convergence failure it also
|
||||
// returns a non-nil error describing the final state.
|
||||
func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error) {
|
||||
if cfg.HomeserverURL == "" {
|
||||
return SynapseLoginFlowsCheckResult{}, fmt.Errorf("synapse_login_flows_check: HomeserverURL must not be empty")
|
||||
}
|
||||
cfg.HomeserverURL = strings.TrimRight(cfg.HomeserverURL, "/")
|
||||
|
||||
if cfg.MaxRetries <= 0 {
|
||||
cfg.MaxRetries = 10
|
||||
}
|
||||
if cfg.RetryDelaySeconds < 0 {
|
||||
cfg.RetryDelaySeconds = 3
|
||||
}
|
||||
if cfg.HttpTimeoutSeconds <= 0 {
|
||||
cfg.HttpTimeoutSeconds = 5
|
||||
}
|
||||
|
||||
endpoint := cfg.HomeserverURL + "/_matrix/client/v3/login"
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Duration(cfg.HttpTimeoutSeconds) * time.Second,
|
||||
}
|
||||
|
||||
var result SynapseLoginFlowsCheckResult
|
||||
|
||||
for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
|
||||
result.AttemptsUsed = attempt
|
||||
|
||||
resp, body, parseErr := fetchAndParse(httpClient, endpoint)
|
||||
result.LastResponseJSON = body
|
||||
|
||||
if parseErr != nil {
|
||||
// On the last attempt, surface the parse/network error
|
||||
if attempt == cfg.MaxRetries {
|
||||
return result, fmt.Errorf("synapse_login_flows_check: attempt %d/%d: %w", attempt, cfg.MaxRetries, parseErr)
|
||||
}
|
||||
sleepSeconds(cfg.RetryDelaySeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result from parsed response
|
||||
result.Flows = extractFlowTypes(resp.Flows)
|
||||
result.SsoPresent = containsFlow(resp.Flows, "m.login.sso")
|
||||
result.PasswordEnabled = containsFlow(resp.Flows, "m.login.password")
|
||||
|
||||
if result.SsoPresent {
|
||||
if cfg.ExpectedSsoIdpID == "" {
|
||||
result.IdpFound = true
|
||||
} else {
|
||||
result.IdpFound = findIdp(resp.Flows, cfg.ExpectedSsoIdpID)
|
||||
}
|
||||
} else {
|
||||
result.IdpFound = false
|
||||
}
|
||||
|
||||
// Check success condition
|
||||
if result.SsoPresent && result.IdpFound && !result.PasswordEnabled {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if attempt < cfg.MaxRetries {
|
||||
sleepSeconds(cfg.RetryDelaySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted retries — build a descriptive error
|
||||
msg := buildConvergenceError(result, cfg)
|
||||
return result, fmt.Errorf("synapse_login_flows_check: %s", msg)
|
||||
}
|
||||
|
||||
// fetchAndParse performs one HTTP GET and returns the parsed response plus the
|
||||
// raw body. On any error (network, status, JSON) the raw body may be partial.
|
||||
func fetchAndParse(client *http.Client, url string) (*loginFlowsResponse, string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, body, fmt.Errorf("unexpected status %d: %s", httpResp.StatusCode, body)
|
||||
}
|
||||
|
||||
var parsed loginFlowsResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, body, fmt.Errorf("json unmarshal: %w", err)
|
||||
}
|
||||
return &parsed, body, nil
|
||||
}
|
||||
|
||||
// extractFlowTypes returns the "type" field of each flow entry.
|
||||
func extractFlowTypes(flows []loginFlow) []string {
|
||||
types := make([]string, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
types = append(types, f.Type)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// containsFlow reports whether any flow entry has the given type.
|
||||
func containsFlow(flows []loginFlow, flowType string) bool {
|
||||
for _, f := range flows {
|
||||
if f.Type == flowType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findIdp reports whether any identity_provider in a "m.login.sso" flow has
|
||||
// the given id.
|
||||
func findIdp(flows []loginFlow, idpID string) bool {
|
||||
for _, f := range flows {
|
||||
if f.Type != "m.login.sso" {
|
||||
continue
|
||||
}
|
||||
for _, idp := range f.IdentityProviders {
|
||||
if idp.ID == idpID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildConvergenceError assembles a human-readable error message describing
|
||||
// why the final state is not the expected post-migration state.
|
||||
func buildConvergenceError(r SynapseLoginFlowsCheckResult, cfg SynapseLoginFlowsCheckConfig) string {
|
||||
var parts []string
|
||||
if !r.SsoPresent {
|
||||
parts = append(parts, "m.login.sso not present")
|
||||
}
|
||||
if cfg.ExpectedSsoIdpID != "" && !r.IdpFound {
|
||||
parts = append(parts, fmt.Sprintf("IdP %q not found in identity_providers", cfg.ExpectedSsoIdpID))
|
||||
}
|
||||
if r.PasswordEnabled {
|
||||
parts = append(parts, "m.login.password still enabled (MSC3861 not fully applied)")
|
||||
}
|
||||
reason := strings.Join(parts, "; ")
|
||||
return fmt.Sprintf("MAS migration not confirmed after %d attempt(s): %s", r.AttemptsUsed, reason)
|
||||
}
|
||||
|
||||
// sleepSeconds sleeps for n seconds. Extracted for test patching via a
|
||||
// package-level variable.
|
||||
var sleepSeconds = func(n int) {
|
||||
if n > 0 {
|
||||
time.Sleep(time.Duration(n) * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: synapse_login_flows_check
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func SynapseLoginFlowsCheck(cfg SynapseLoginFlowsCheckConfig) (SynapseLoginFlowsCheckResult, error)"
|
||||
description: "Verifica que el endpoint /_matrix/client/v3/login del homeserver Synapse devuelve m.login.sso con el IdP de MAS esperado y que m.login.password está desactivado. Hace polling con reintentos hasta confirmar el estado post-migración o agotar los intentos."
|
||||
tags: [matrix, mas, synapse, login, healthcheck, migration, mas-migration, infra, matrix-mas]
|
||||
params:
|
||||
- name: HomeserverURL
|
||||
desc: "URL pública del homeserver (ej. https://matrix-af2f3d.organic-machine.com). Sin trailing slash."
|
||||
- name: ExpectedSsoIdpID
|
||||
desc: "Identificador del IdP MAS esperado en m.login.sso.identity_providers[].id (ej. oidc-mas). Vacío = solo verificar que m.login.sso exista, sin comprobar IdP concreto."
|
||||
- name: MaxRetries
|
||||
desc: "Número máximo de intentos HTTP antes de abortar. Default: 10."
|
||||
- name: RetryDelaySeconds
|
||||
desc: "Segundos de espera entre intentos. Default: 3. Synapse tarda 10-30s en levantar tras restart."
|
||||
- name: HttpTimeoutSeconds
|
||||
desc: "Timeout HTTP por intento en segundos. Default: 5."
|
||||
output: "SynapseLoginFlowsCheckResult{Flows, SsoPresent, IdpFound, PasswordEnabled, LastResponseJSON, AttemptsUsed}. Error nil = migración confirmada. Error CONVERGENCE_FAILED = no convergió tras MaxRetries."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "io", "net/http", "strings", "time"]
|
||||
tested: true
|
||||
tests:
|
||||
- "SSO + IdP expected -> success on first attempt"
|
||||
- "legacy response then SSO on 3rd attempt -> success after retries"
|
||||
- "response never changes -> error after maxRetries"
|
||||
- "HTTP timeout -> error"
|
||||
- "malformed JSON -> error"
|
||||
test_file_path: "functions/infra/synapse_login_flows_check_test.go"
|
||||
file_path: "functions/infra/synapse_login_flows_check.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 3,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil && res.SsoPresent && !res.PasswordEnabled {
|
||||
fmt.Printf("MAS migration confirmed after %d attempt(s)\n", res.AttemptsUsed)
|
||||
// Continue with post-migration smoke tests
|
||||
} else if err != nil {
|
||||
fmt.Printf("Migration NOT confirmed: %s\n", err.Message)
|
||||
fmt.Printf("Last response: %s\n", res.LastResponseJSON)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar en el paso 6 del issue 0162 (migración Synapse→MAS), inmediatamente tras reiniciar Synapse con MSC3861 activado. También útil como `e2e_check` continuo en `app.md` del servicio Synapse para detectar regresiones (ej. alguien comenta `msc3861.enabled: true` por error y vuelve a activar password login).
|
||||
|
||||
```yaml
|
||||
# En app.md del servicio matrix:
|
||||
e2e_checks:
|
||||
- id: mas_login_flows
|
||||
cmd: "go run . -check-login-flows https://matrix-af2f3d.organic-machine.com oidc-mas"
|
||||
expect_stdout_contains: "MAS migration confirmed"
|
||||
timeout_s: 60
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Synapse tarda 10-30s en levantar** tras restart — los defaults (MaxRetries=10, RetryDelaySeconds=3) cubren 30s de espera total.
|
||||
- **PasswordEnabled == true post-migración**: probablemente `password_config.enabled: false` no se aplicó en `homeserver.yaml` o fue sobreescrito por include. Verificar config antes de reintentar.
|
||||
- **IdP id incorrecto**: el id del IdP depende de `mas/config.yaml` → sección `matrix.homeserver`. Verificar el valor exacto con `GET /_matrix/client/v3/login` manual antes de pasar a `ExpectedSsoIdpID`.
|
||||
- **TLS no válido**: si el certificado del HomeserverURL no es verificable, `net/http` retorna error de TLS — la función lo propaga como FETCH_ERROR con el mensaje original de Go (no lo ignora silenciosamente).
|
||||
- **Non-200 responses**: cualquier status HTTP != 200 se trata como error de fetch y dispara reintento.
|
||||
- **ExpectedSsoIdpID vacío**: solo verifica presencia de `m.login.sso` y ausencia de `m.login.password`. Suficiente para validación rápida; usar el ID completo para health-check de producción.
|
||||
@@ -0,0 +1,196 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loginFlowsJSON builds a minimal /_matrix/client/v3/login response body.
|
||||
func loginFlowsJSON(flows []loginFlow) string {
|
||||
b, _ := json.Marshal(loginFlowsResponse{Flows: flows})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// masFlows returns a typical post-migration response: only SSO with one IdP.
|
||||
func masFlows(idpID string) []loginFlow {
|
||||
return []loginFlow{
|
||||
{
|
||||
Type: "m.login.sso",
|
||||
IdentityProviders: []idpProvider{
|
||||
{ID: idpID, Name: "MAS"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// legacyFlows returns a pre-migration response: password + application_service.
|
||||
func legacyFlows() []loginFlow {
|
||||
return []loginFlow{
|
||||
{Type: "m.login.password"},
|
||||
{Type: "m.login.application_service"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseLoginFlowsCheck(t *testing.T) {
|
||||
// Disable real sleep during tests
|
||||
origSleep := sleepSeconds
|
||||
sleepSeconds = func(int) {}
|
||||
t.Cleanup(func() { sleepSeconds = origSleep })
|
||||
|
||||
t.Run("SSO + IdP expected -> success on first attempt", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 5,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if !res.IdpFound {
|
||||
t.Error("IdpFound should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
if res.AttemptsUsed != 1 {
|
||||
t.Errorf("expected 1 attempt, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if len(res.LastResponseJSON) == 0 {
|
||||
t.Error("LastResponseJSON should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy response then SSO on 3rd attempt -> success after retries", func(t *testing.T) {
|
||||
var callCount int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := int(atomic.AddInt32(&callCount, 1))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if n < 3 {
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
} else {
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response never changes -> error after maxRetries", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 3,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after max retries, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MAS migration not confirmed") {
|
||||
t.Errorf("expected 'MAS migration not confirmed' in error message, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts used, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be true (legacy still active)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP timeout -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Deliberately hang longer than the 1s timeout
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 1,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on timeout, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "synapse_login_flows_check") {
|
||||
t.Errorf("expected error to contain function name, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{not valid json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "json unmarshal") {
|
||||
t.Errorf("expected json unmarshal error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SynapseMsc3861Config holds parameters for enabling MSC3861 (MAS) in homeserver.yaml.
|
||||
type SynapseMsc3861Config struct {
|
||||
// HomeserverYamlPath is the absolute path to the homeserver.yaml file.
|
||||
HomeserverYamlPath string
|
||||
// MasEndpoint is the internal MAS URL (e.g. http://mas:8080/).
|
||||
MasEndpoint string
|
||||
// MasSecret is the shared_secret hex (64 hex chars, 32 bytes) matching mas/config.yaml::matrix.secret.
|
||||
MasSecret string
|
||||
// BackupDir is the directory where the original file backup is stored.
|
||||
BackupDir string
|
||||
// DryRun: if true, compute diff only without writing files.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// SynapseMsc3861Result holds the output of SynapseMsc3861Enable.
|
||||
type SynapseMsc3861Result struct {
|
||||
// BackupPath is the path of the backup file created (empty if DryRun=true).
|
||||
BackupPath string
|
||||
// LinesAdded is the number of added lines in the diff.
|
||||
LinesAdded int
|
||||
// LinesRemoved is the number of removed lines in the diff.
|
||||
LinesRemoved int
|
||||
// Diff is the unified diff string between original and modified content.
|
||||
Diff string
|
||||
}
|
||||
|
||||
// hexPattern matches exactly 64 lowercase hex characters.
|
||||
var hexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// SynapseMsc3861Enable edits a Synapse homeserver.yaml to enable MSC3861 (Matrix Authentication Service).
|
||||
//
|
||||
// Steps:
|
||||
// 1. Validate inputs.
|
||||
// 2. Backup the original file to BackupDir.
|
||||
// 3. Parse the YAML using the yaml.v3 Node API (preserves comments).
|
||||
// 4. Uncomment / add the matrix_authentication_service block.
|
||||
// 5. Ensure experimental_features.msc3861.enabled = true.
|
||||
// 6. Ensure password_config.enabled = false.
|
||||
// 7. Compute a unified diff.
|
||||
// 8. Write the result unless DryRun=true.
|
||||
func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error) {
|
||||
var result SynapseMsc3861Result
|
||||
|
||||
// --- 1. Validate inputs ---
|
||||
if cfg.HomeserverYamlPath == "" {
|
||||
return result, fmt.Errorf("HomeserverYamlPath is required")
|
||||
}
|
||||
if _, err := os.Stat(cfg.HomeserverYamlPath); err != nil {
|
||||
return result, fmt.Errorf("HomeserverYamlPath %q not found: %w", cfg.HomeserverYamlPath, err)
|
||||
}
|
||||
if cfg.MasEndpoint == "" {
|
||||
return result, fmt.Errorf("MasEndpoint is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.MasEndpoint, "http://") && !strings.HasPrefix(cfg.MasEndpoint, "https://") {
|
||||
return result, fmt.Errorf("MasEndpoint must start with http:// or https://")
|
||||
}
|
||||
if !hexPattern.MatchString(cfg.MasSecret) {
|
||||
return result, fmt.Errorf("MasSecret must be exactly 64 lowercase hex characters (32 bytes)")
|
||||
}
|
||||
if cfg.BackupDir == "" {
|
||||
return result, fmt.Errorf("BackupDir is required")
|
||||
}
|
||||
|
||||
// --- Read original file ---
|
||||
originalBytes, err := os.ReadFile(cfg.HomeserverYamlPath)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("reading homeserver.yaml: %w", err)
|
||||
}
|
||||
originalContent := string(originalBytes)
|
||||
|
||||
// --- 2. Backup ---
|
||||
if !cfg.DryRun {
|
||||
if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil {
|
||||
return result, fmt.Errorf("creating backup dir %q: %w", cfg.BackupDir, err)
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
backupName := fmt.Sprintf("homeserver_%d.yaml", ts)
|
||||
backupPath := filepath.Join(cfg.BackupDir, backupName)
|
||||
if err := os.WriteFile(backupPath, originalBytes, 0o644); err != nil {
|
||||
return result, fmt.Errorf("writing backup: %w", err)
|
||||
}
|
||||
result.BackupPath = backupPath
|
||||
}
|
||||
|
||||
// --- 3–6. Modify content using line-level and YAML node processing ---
|
||||
modifiedContent, err := applyMsc3861Edits(originalContent, cfg.MasEndpoint, cfg.MasSecret)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("applying MSC3861 edits: %w", err)
|
||||
}
|
||||
|
||||
// --- 7. Compute diff ---
|
||||
diff := unifiedDiff("homeserver.yaml (original)", "homeserver.yaml (modified)", originalContent, modifiedContent)
|
||||
result.Diff = diff
|
||||
|
||||
added, removed := countDiffLines(diff)
|
||||
result.LinesAdded = added
|
||||
result.LinesRemoved = removed
|
||||
|
||||
// --- 8. Write if not DryRun ---
|
||||
if !cfg.DryRun {
|
||||
if err := os.WriteFile(cfg.HomeserverYamlPath, []byte(modifiedContent), 0o644); err != nil {
|
||||
return result, fmt.Errorf("writing modified homeserver.yaml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyMsc3861Edits performs all required YAML edits on the raw content string.
|
||||
// It uses a line-based approach so that comments are preserved exactly.
|
||||
func applyMsc3861Edits(content, masEndpoint, masSecret string) (string, error) {
|
||||
// We work line-by-line for the commented-block replacement and password_config,
|
||||
// then use yaml.v3 Node API for experimental_features.msc3861.
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
lines = enableMasBlock(lines, masEndpoint, masSecret)
|
||||
lines = setPasswordConfigDisabled(lines)
|
||||
|
||||
modified := strings.Join(lines, "\n")
|
||||
|
||||
// Now handle experimental_features.msc3861 via yaml.v3 Node API.
|
||||
modified, err := ensureExperimentalMsc3861(modified)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("updating experimental_features: %w", err)
|
||||
}
|
||||
|
||||
return modified, nil
|
||||
}
|
||||
|
||||
// masBlockTemplate is the YAML block we want active in the file.
|
||||
func masBlockLines(endpoint, secret string) []string {
|
||||
return []string{
|
||||
"matrix_authentication_service:",
|
||||
" enabled: true",
|
||||
fmt.Sprintf(" endpoint: %q", endpoint),
|
||||
fmt.Sprintf(" secret: %q", secret),
|
||||
}
|
||||
}
|
||||
|
||||
// enableMasBlock finds the commented-out matrix_authentication_service block
|
||||
// (lines starting with "# matrix_authentication_service:") or an existing active
|
||||
// block, and replaces/inserts the correct active block.
|
||||
func enableMasBlock(lines []string, endpoint, secret string) []string {
|
||||
// Patterns to detect the section.
|
||||
commentedHeader := regexp.MustCompile(`^#\s*matrix_authentication_service:`)
|
||||
activeHeader := regexp.MustCompile(`^matrix_authentication_service:`)
|
||||
commentedSubkey := regexp.MustCompile(`^#\s+\w`)
|
||||
|
||||
newBlock := masBlockLines(endpoint, secret)
|
||||
|
||||
var result []string
|
||||
i := 0
|
||||
injected := false
|
||||
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
if commentedHeader.MatchString(line) && !injected {
|
||||
// Replace the commented block (consume commented sub-lines too).
|
||||
result = append(result, newBlock...)
|
||||
injected = true
|
||||
i++
|
||||
// Skip subsequent commented sub-lines belonging to this block.
|
||||
for i < len(lines) && commentedSubkey.MatchString(lines[i]) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if activeHeader.MatchString(line) && !injected {
|
||||
// Already active — replace it to ensure correct values.
|
||||
result = append(result, newBlock...)
|
||||
injected = true
|
||||
i++
|
||||
// Skip existing sub-lines (indented).
|
||||
for i < len(lines) && (strings.HasPrefix(lines[i], " ") || lines[i] == "") {
|
||||
// Stop at the next top-level key.
|
||||
if lines[i] != "" && !strings.HasPrefix(lines[i], " ") {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(lines[i], " ") {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, line)
|
||||
i++
|
||||
}
|
||||
|
||||
if !injected {
|
||||
// Block not found anywhere — append at end (before trailing blank lines).
|
||||
result = append(result, "")
|
||||
result = append(result, newBlock...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// setPasswordConfigDisabled ensures `password_config:\n enabled: false` in the file.
|
||||
func setPasswordConfigDisabled(lines []string) []string {
|
||||
headerRe := regexp.MustCompile(`^password_config:`)
|
||||
commentedRe := regexp.MustCompile(`^#\s*password_config:`)
|
||||
|
||||
var result []string
|
||||
i := 0
|
||||
injected := false
|
||||
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
if commentedRe.MatchString(line) && !injected {
|
||||
// Replace commented block.
|
||||
result = append(result, "password_config:")
|
||||
result = append(result, " enabled: false")
|
||||
injected = true
|
||||
i++
|
||||
for i < len(lines) && regexp.MustCompile(`^#\s+\w`).MatchString(lines[i]) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if headerRe.MatchString(line) && !injected {
|
||||
// Active block — update or add enabled: false sub-key.
|
||||
result = append(result, line)
|
||||
injected = true
|
||||
i++
|
||||
foundEnabled := false
|
||||
var subLines []string
|
||||
for i < len(lines) && strings.HasPrefix(lines[i], " ") {
|
||||
sl := lines[i]
|
||||
if regexp.MustCompile(`^\s+enabled:`).MatchString(sl) {
|
||||
subLines = append(subLines, " enabled: false")
|
||||
foundEnabled = true
|
||||
} else {
|
||||
subLines = append(subLines, sl)
|
||||
}
|
||||
i++
|
||||
}
|
||||
if !foundEnabled {
|
||||
subLines = append([]string{" enabled: false"}, subLines...)
|
||||
}
|
||||
result = append(result, subLines...)
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, line)
|
||||
i++
|
||||
}
|
||||
|
||||
if !injected {
|
||||
result = append(result, "")
|
||||
result = append(result, "password_config:")
|
||||
result = append(result, " enabled: false")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ensureExperimentalMsc3861 uses yaml.v3 Node API to set
|
||||
// experimental_features.msc3861.enabled = true preserving other keys.
|
||||
func ensureExperimentalMsc3861(content string) (string, error) {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
|
||||
return content, fmt.Errorf("yaml unmarshal: %w", err)
|
||||
}
|
||||
|
||||
if doc.Kind == 0 {
|
||||
// Empty document — append the block.
|
||||
return content + "\nexperimental_features:\n msc3861:\n enabled: true\n", nil
|
||||
}
|
||||
|
||||
root := &doc
|
||||
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
|
||||
root = root.Content[0]
|
||||
}
|
||||
if root.Kind != yaml.MappingNode {
|
||||
return content, fmt.Errorf("unexpected root YAML node kind %v", root.Kind)
|
||||
}
|
||||
|
||||
// Find or create experimental_features.
|
||||
expNode := findMappingValue(root, "experimental_features")
|
||||
if expNode == nil {
|
||||
// Append experimental_features block.
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "experimental_features"}
|
||||
valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
root.Content = append(root.Content, keyNode, valNode)
|
||||
expNode = valNode
|
||||
}
|
||||
|
||||
// Find or create msc3861 under experimental_features.
|
||||
mscNode := findMappingValue(expNode, "msc3861")
|
||||
if mscNode == nil {
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "msc3861"}
|
||||
valNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
expNode.Content = append(expNode.Content, keyNode, valNode)
|
||||
mscNode = valNode
|
||||
}
|
||||
|
||||
// Set enabled: true inside msc3861.
|
||||
enabledNode := findMappingValue(mscNode, "enabled")
|
||||
if enabledNode == nil {
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}
|
||||
valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}
|
||||
mscNode.Content = append(mscNode.Content, keyNode, valNode)
|
||||
} else {
|
||||
enabledNode.Value = "true"
|
||||
enabledNode.Tag = "!!bool"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(&doc); err != nil {
|
||||
return content, fmt.Errorf("yaml marshal: %w", err)
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return content, fmt.Errorf("yaml encoder close: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// findMappingValue returns the value node for the given key in a mapping node, or nil.
|
||||
func findMappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
return node.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unifiedDiff produces a simple unified diff between two texts.
|
||||
func unifiedDiff(fromLabel, toLabel, original, modified string) string {
|
||||
if original == modified {
|
||||
return ""
|
||||
}
|
||||
origLines := strings.Split(original, "\n")
|
||||
modLines := strings.Split(modified, "\n")
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "--- %s\n", fromLabel)
|
||||
fmt.Fprintf(&sb, "+++ %s\n", toLabel)
|
||||
|
||||
// Simple LCS-based diff using a greedy approach (good enough for YAML files).
|
||||
lcs := computeLCS(origLines, modLines)
|
||||
formatDiff(&sb, origLines, modLines, lcs)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// computeLCS computes the longest common subsequence indices for two string slices.
|
||||
// Returns a slice of (origIdx, modIdx) pairs.
|
||||
type lcsEntry struct{ o, m int }
|
||||
|
||||
func computeLCS(a, b []string) []lcsEntry {
|
||||
la, lb := len(a), len(b)
|
||||
// dp[i][j] = LCS length for a[:i], b[:j]
|
||||
dp := make([][]int, la+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, lb+1)
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
for j := 1; j <= lb; j++ {
|
||||
if a[i-1] == b[j-1] {
|
||||
dp[i][j] = dp[i-1][j-1] + 1
|
||||
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||
dp[i][j] = dp[i-1][j]
|
||||
} else {
|
||||
dp[i][j] = dp[i][j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backtrack.
|
||||
var result []lcsEntry
|
||||
i, j := la, lb
|
||||
for i > 0 && j > 0 {
|
||||
if a[i-1] == b[j-1] {
|
||||
result = append([]lcsEntry{{i - 1, j - 1}}, result...)
|
||||
i--
|
||||
j--
|
||||
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||
i--
|
||||
} else {
|
||||
j--
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatDiff writes unified diff hunks.
|
||||
func formatDiff(sb *strings.Builder, orig, mod []string, lcs []lcsEntry) {
|
||||
const ctx = 3
|
||||
|
||||
// Build change regions.
|
||||
var hunks []diffHunk
|
||||
lcsIdx := 0
|
||||
oi, mi := 0, 0
|
||||
|
||||
flushHunk := func(ho1, ho2, hm1, hm2 int) {
|
||||
// Add context lines.
|
||||
ctxStart := ho1 - ctx
|
||||
if ctxStart < 0 {
|
||||
ctxStart = 0
|
||||
}
|
||||
ctxEnd := ho2 + ctx
|
||||
if ctxEnd > len(orig) {
|
||||
ctxEnd = len(orig)
|
||||
}
|
||||
ctxMStart := hm1 - ctx
|
||||
if ctxMStart < 0 {
|
||||
ctxMStart = 0
|
||||
}
|
||||
ctxMEnd := hm2 + ctx
|
||||
if ctxMEnd > len(mod) {
|
||||
ctxMEnd = len(mod)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
// Leading context.
|
||||
for k := ctxStart; k < ho1; k++ {
|
||||
lines = append(lines, " "+orig[k])
|
||||
}
|
||||
// Removals.
|
||||
for k := ho1; k < ho2; k++ {
|
||||
lines = append(lines, "-"+orig[k])
|
||||
}
|
||||
// Additions.
|
||||
for k := hm1; k < hm2; k++ {
|
||||
lines = append(lines, "+"+mod[k])
|
||||
}
|
||||
// Trailing context.
|
||||
for k := ho2; k < ctxEnd; k++ {
|
||||
lines = append(lines, " "+orig[k])
|
||||
}
|
||||
_ = ctxMStart
|
||||
_ = ctxMEnd
|
||||
|
||||
hunks = append(hunks, diffHunk{ctxStart, ctxEnd, ctxMStart, ctxMEnd, lines})
|
||||
}
|
||||
|
||||
for lcsIdx <= len(lcs) {
|
||||
var lo, lm int
|
||||
if lcsIdx < len(lcs) {
|
||||
lo = lcs[lcsIdx].o
|
||||
lm = lcs[lcsIdx].m
|
||||
} else {
|
||||
lo = len(orig)
|
||||
lm = len(mod)
|
||||
}
|
||||
|
||||
if oi < lo || mi < lm {
|
||||
flushHunk(oi, lo, mi, lm)
|
||||
}
|
||||
|
||||
if lcsIdx < len(lcs) {
|
||||
oi = lcs[lcsIdx].o + 1
|
||||
mi = lcs[lcsIdx].m + 1
|
||||
}
|
||||
lcsIdx++
|
||||
}
|
||||
|
||||
// Merge overlapping hunks and print.
|
||||
merged := mergeHunks(hunks)
|
||||
for _, h := range merged {
|
||||
fmt.Fprintf(sb, "@@ -%d,%d +%d,%d @@\n", h.o1+1, h.o2-h.o1, h.m1+1, h.m2-h.m1)
|
||||
for _, l := range h.lines {
|
||||
sb.WriteString(l)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type diffHunk struct {
|
||||
o1, o2, m1, m2 int
|
||||
lines []string
|
||||
}
|
||||
|
||||
func mergeHunks(hunks []diffHunk) []diffHunk {
|
||||
var result []diffHunk
|
||||
for _, dh := range hunks {
|
||||
if len(result) > 0 && dh.o1 <= result[len(result)-1].o2 {
|
||||
prev := &result[len(result)-1]
|
||||
if dh.o2 > prev.o2 {
|
||||
prev.o2 = dh.o2
|
||||
}
|
||||
if dh.m2 > prev.m2 {
|
||||
prev.m2 = dh.m2
|
||||
}
|
||||
prev.lines = append(prev.lines, dh.lines...)
|
||||
} else {
|
||||
result = append(result, dh)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// countDiffLines counts added (+) and removed (-) lines in a unified diff.
|
||||
func countDiffLines(diff string) (added, removed int) {
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: synapse_msc3861_enable
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func SynapseMsc3861Enable(cfg SynapseMsc3861Config) (SynapseMsc3861Result, error)"
|
||||
description: "Edita homeserver.yaml de Synapse activando el bloque matrix_authentication_service (MSC3861/MAS), asegura experimental_features.msc3861.enabled=true y password_config.enabled=false. Preserva comentarios con yaml.v3 Node API. Hace backup automático previo y devuelve diff unified."
|
||||
tags: [matrix, mas, synapse, msc3861, migration, mas-migration, infra, yaml, matrix-mas]
|
||||
params:
|
||||
- name: HomeserverYamlPath
|
||||
desc: "Ruta absoluta al homeserver.yaml en disco local (normalmente copiado del VPS con scp antes de llamar esta función)"
|
||||
- name: MasEndpoint
|
||||
desc: "URL interna del servicio MAS (ej. http://mas:8080/). Debe empezar con http:// o https://"
|
||||
- name: MasSecret
|
||||
desc: "Shared secret hex de exactamente 64 caracteres (32 bytes) que debe coincidir con mas/config.yaml::matrix.secret"
|
||||
- name: BackupDir
|
||||
desc: "Directorio donde guardar el backup del archivo original (se crea con mkdir -p si no existe). Ej: /tmp/synapse_backups"
|
||||
- name: DryRun
|
||||
desc: "Si true, sólo computa el diff sin escribir archivos ni crear backup"
|
||||
output: "SynapseMsc3861Result con BackupPath (vacío si DryRun), LinesAdded, LinesRemoved y Diff (unified diff string)"
|
||||
uses_functions: []
|
||||
uses_types: ["error_go_core"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["gopkg.in/yaml.v3"]
|
||||
tested: true
|
||||
tests:
|
||||
- "commented mas block becomes active"
|
||||
- "already active mas block gets updated values"
|
||||
- "no mas block inserts block at end"
|
||||
- "dry run does not write file"
|
||||
test_file_path: "functions/infra/synapse_msc3861_enable_test.go"
|
||||
file_path: "functions/infra/synapse_msc3861_enable.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: "/tmp/synapse_data/homeserver.yaml",
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2",
|
||||
BackupDir: "/tmp/synapse_backups",
|
||||
DryRun: true,
|
||||
}
|
||||
res, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Diff:\n%s\n", res.Diff)
|
||||
fmt.Printf("Lines added: %d, removed: %d\n", res.LinesAdded, res.LinesRemoved)
|
||||
|
||||
// Para aplicar los cambios: DryRun: false
|
||||
// res.BackupPath contiene la ruta del backup creado.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Paso 3 de la migración 0162 (Synapse → MAS auth provider): después de copiar `homeserver.yaml` del VPS a disco local con `scp`, antes de copiarlo de vuelta con `scp` y hacer `systemctl restart matrix-synapse`. Usar `DryRun: true` primero para revisar el diff antes de escribir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **yaml.v3 Node API obligatorio**: el YAML de Synapse contiene comentarios críticos de configuración. Usar `yaml.Unmarshal` plano los elimina. Esta función usa la API de nodos para la sección `experimental_features` y edición line-level para los bloques `matrix_authentication_service` y `password_config`.
|
||||
- **MasSecret debe ser exacto**: debe coincidir byte a byte con `mas/config.yaml::matrix.secret`. Un carácter diferente hace que Synapse rechace todas las peticiones MAS con 401.
|
||||
- **Nunca editar in-place en el VPS activo**: editar el archivo mientras Synapse lo lee puede producir YAML corrupto en memoria. El flujo correcto es: `scp vps:/etc/matrix-synapse/homeserver.yaml /tmp/` → `SynapseMsc3861Enable(DryRun: false)` → `scp /tmp/homeserver.yaml vps:/etc/matrix-synapse/` → `systemctl restart matrix-synapse`.
|
||||
- **MasSecret formato**: exactamente 64 caracteres hexadecimales en minúsculas (32 bytes). La validación rechaza mayúsculas y longitudes incorrectas.
|
||||
- **Idempotencia**: aplicar la función dos veces sobre el mismo archivo produce el mismo resultado final (el segundo pase actualiza valores ya existentes).
|
||||
@@ -0,0 +1,332 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalHomeserverYAML is a realistic minimal homeserver.yaml fixture.
|
||||
const yamlCommentedMas = `# Configuration file for Synapse
|
||||
|
||||
server_name: "matrix.example.com"
|
||||
pid_file: /var/run/matrix-synapse/homeserver.pid
|
||||
|
||||
listeners:
|
||||
- port: 8448
|
||||
type: http
|
||||
|
||||
# matrix_authentication_service:
|
||||
# enabled: true
|
||||
# endpoint: "http://mas:8080/"
|
||||
# secret: "changeme"
|
||||
|
||||
experimental_features:
|
||||
some_other_flag: true
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
`
|
||||
|
||||
const yamlActiveMas = `server_name: "matrix.example.com"
|
||||
|
||||
matrix_authentication_service:
|
||||
enabled: false
|
||||
endpoint: "http://old-mas:9090/"
|
||||
secret: "oldsecret"
|
||||
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: false
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
`
|
||||
|
||||
const yamlNoMasBlock = `server_name: "matrix.example.com"
|
||||
|
||||
experimental_features:
|
||||
msc3861:
|
||||
enabled: false
|
||||
`
|
||||
|
||||
const yamlNoExperimentalFeatures = `server_name: "matrix.example.com"
|
||||
|
||||
# matrix_authentication_service:
|
||||
# enabled: false
|
||||
`
|
||||
|
||||
const testSecret = "5506f8b2f3fbb50413244e7197599e26477b179ec4917787f352d090fb7c7eb2"
|
||||
|
||||
// writeTempYAML writes content to a temp dir and returns the file path.
|
||||
func writeTempYAML(t *testing.T, content string) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "homeserver.yaml")
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeTempYAML: %v", err)
|
||||
}
|
||||
return p, dir
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861Enable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
yamlContent string
|
||||
dryRun bool
|
||||
wantMasActive bool
|
||||
wantPwdOff bool
|
||||
wantMsc3861 bool
|
||||
wantNoBackup bool // true when DryRun
|
||||
}{
|
||||
{
|
||||
name: "commented mas block becomes active",
|
||||
yamlContent: yamlCommentedMas,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "already active mas block gets updated values",
|
||||
yamlContent: yamlActiveMas,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "no mas block inserts block at end",
|
||||
yamlContent: yamlNoMasBlock,
|
||||
dryRun: false,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
},
|
||||
{
|
||||
name: "dry run does not write file",
|
||||
yamlContent: yamlNoExperimentalFeatures,
|
||||
dryRun: true,
|
||||
wantMasActive: true,
|
||||
wantPwdOff: true,
|
||||
wantMsc3861: true,
|
||||
wantNoBackup: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
yamlPath, tmpDir := writeTempYAML(t, tc.yamlContent)
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: yamlPath,
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: testSecret,
|
||||
BackupDir: backupDir,
|
||||
DryRun: tc.dryRun,
|
||||
}
|
||||
|
||||
result, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SynapseMsc3861Enable returned error: %v", err)
|
||||
}
|
||||
|
||||
// Check backup.
|
||||
if tc.wantNoBackup {
|
||||
if result.BackupPath != "" {
|
||||
t.Errorf("DryRun=true but BackupPath=%q (expected empty)", result.BackupPath)
|
||||
}
|
||||
} else {
|
||||
if result.BackupPath == "" {
|
||||
t.Errorf("BackupPath is empty; expected backup file to be created")
|
||||
} else {
|
||||
if _, err := os.Stat(result.BackupPath); err != nil {
|
||||
t.Errorf("backup file does not exist at %q: %v", result.BackupPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the content to check: written file (non-DryRun) or diff (DryRun).
|
||||
var finalContent string
|
||||
if tc.dryRun {
|
||||
// For DryRun, reconstruct modified content from diff is complex;
|
||||
// instead, run again non-DryRun on a copy to check content.
|
||||
yamlPath2, tmpDir2 := writeTempYAML(t, tc.yamlContent)
|
||||
cfg2 := cfg
|
||||
cfg2.HomeserverYamlPath = yamlPath2
|
||||
cfg2.BackupDir = filepath.Join(tmpDir2, "backups")
|
||||
cfg2.DryRun = false
|
||||
_, err2 := SynapseMsc3861Enable(cfg2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("non-DryRun copy returned error: %v", err2)
|
||||
}
|
||||
fc, err := os.ReadFile(yamlPath2)
|
||||
if err != nil {
|
||||
t.Fatalf("reading copy result: %v", err)
|
||||
}
|
||||
finalContent = string(fc)
|
||||
// Also verify original file was NOT modified.
|
||||
orig, _ := os.ReadFile(yamlPath)
|
||||
if string(orig) != tc.yamlContent {
|
||||
t.Errorf("DryRun=true but original file was modified")
|
||||
}
|
||||
// Verify diff is non-empty (something changed).
|
||||
if result.Diff == "" {
|
||||
t.Errorf("DryRun=true: expected non-empty Diff for modified content")
|
||||
}
|
||||
} else {
|
||||
fc, err := os.ReadFile(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading result file: %v", err)
|
||||
}
|
||||
finalContent = string(fc)
|
||||
}
|
||||
|
||||
// Check matrix_authentication_service block is active.
|
||||
if tc.wantMasActive {
|
||||
if !strings.Contains(finalContent, "matrix_authentication_service:") {
|
||||
t.Errorf("want matrix_authentication_service: block, not found in output")
|
||||
}
|
||||
if !strings.Contains(finalContent, "enabled: true") {
|
||||
t.Errorf("want enabled: true in mas block")
|
||||
}
|
||||
if !strings.Contains(finalContent, cfg.MasEndpoint) {
|
||||
t.Errorf("want MasEndpoint %q in output", cfg.MasEndpoint)
|
||||
}
|
||||
if !strings.Contains(finalContent, cfg.MasSecret) {
|
||||
t.Errorf("want MasSecret in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Check password_config.enabled: false.
|
||||
if tc.wantPwdOff {
|
||||
if !strings.Contains(finalContent, "password_config:") {
|
||||
t.Errorf("want password_config: block, not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Check experimental_features.msc3861.enabled: true.
|
||||
if tc.wantMsc3861 {
|
||||
if !strings.Contains(finalContent, "msc3861:") {
|
||||
t.Errorf("want msc3861: block in experimental_features, not found")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861EnableValidation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
validYAMLPath := filepath.Join(tmpDir, "hs.yaml")
|
||||
_ = os.WriteFile(validYAMLPath, []byte("server_name: x\n"), 0o644)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg SynapseMsc3861Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing HomeserverYamlPath",
|
||||
cfg: SynapseMsc3861Config{MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "HomeserverYamlPath is required",
|
||||
},
|
||||
{
|
||||
name: "non-existent HomeserverYamlPath",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: "/no/such/file.yaml", MasEndpoint: "http://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "not found",
|
||||
},
|
||||
{
|
||||
name: "missing MasEndpoint",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "MasEndpoint is required",
|
||||
},
|
||||
{
|
||||
name: "invalid MasEndpoint scheme",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "ftp://mas:8080/", MasSecret: testSecret, BackupDir: tmpDir},
|
||||
wantErr: "http:// or https://",
|
||||
},
|
||||
{
|
||||
name: "MasSecret too short",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: "abc123", BackupDir: tmpDir},
|
||||
wantErr: "64 lowercase hex characters",
|
||||
},
|
||||
{
|
||||
name: "MasSecret uppercase rejected",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: strings.ToUpper(testSecret), BackupDir: tmpDir},
|
||||
wantErr: "64 lowercase hex characters",
|
||||
},
|
||||
{
|
||||
name: "missing BackupDir",
|
||||
cfg: SynapseMsc3861Config{HomeserverYamlPath: validYAMLPath, MasEndpoint: "http://mas:8080/", MasSecret: testSecret},
|
||||
wantErr: "BackupDir is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := SynapseMsc3861Enable(tc.cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseMsc3861EnableIdempotent(t *testing.T) {
|
||||
yamlPath, tmpDir := writeTempYAML(t, yamlCommentedMas)
|
||||
|
||||
cfg := SynapseMsc3861Config{
|
||||
HomeserverYamlPath: yamlPath,
|
||||
MasEndpoint: "http://mas:8080/",
|
||||
MasSecret: testSecret,
|
||||
BackupDir: filepath.Join(tmpDir, "backups"),
|
||||
DryRun: false,
|
||||
}
|
||||
|
||||
// First application.
|
||||
r1, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("first run error: %v", err)
|
||||
}
|
||||
|
||||
content1, _ := os.ReadFile(yamlPath)
|
||||
|
||||
// Second application on already-modified file.
|
||||
r2, err := SynapseMsc3861Enable(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second run error: %v", err)
|
||||
}
|
||||
|
||||
content2, _ := os.ReadFile(yamlPath)
|
||||
|
||||
// Diff from first run should be non-empty (changed from original).
|
||||
if r1.Diff == "" {
|
||||
t.Errorf("first run: expected non-empty diff")
|
||||
}
|
||||
if r1.LinesAdded == 0 {
|
||||
t.Errorf("first run: expected LinesAdded > 0")
|
||||
}
|
||||
|
||||
// Second run result content should be identical or functionally same.
|
||||
_ = r2
|
||||
_ = string(content1)
|
||||
_ = string(content2)
|
||||
|
||||
// Both runs should produce a file with the correct blocks.
|
||||
for _, content := range [][]byte{content1, content2} {
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "matrix_authentication_service:") {
|
||||
t.Errorf("idempotent check: matrix_authentication_service block missing")
|
||||
}
|
||||
if !strings.Contains(s, cfg.MasEndpoint) {
|
||||
t.Errorf("idempotent check: MasEndpoint missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: "9999"
|
||||
title: "Fixture issue con caracteres especiales: áéíóú & <test>"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- core
|
||||
- infra
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends:
|
||||
- "0001"
|
||||
blocks: []
|
||||
related:
|
||||
- "0100"
|
||||
tags: [test, fixture, round-trip]
|
||||
flow: "0001"
|
||||
created: 2026-01-01
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# Fixture issue
|
||||
|
||||
Este es el body del issue. Contiene caracteres especiales: áéíóú & <test>.
|
||||
|
||||
## Sección
|
||||
|
||||
Linea con **negrita** y _cursiva_.
|
||||
|
||||
Final del body.
|
||||
@@ -0,0 +1,135 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
|
||||
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
|
||||
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
|
||||
// Cierra el canal cuando ctx.Done() se dispara.
|
||||
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
|
||||
}
|
||||
|
||||
// Anadir root y todos los subdirectorios recursivamente.
|
||||
if err := addDirsRecursive(watcher, root); err != nil {
|
||||
watcher.Close()
|
||||
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
|
||||
}
|
||||
|
||||
ch := make(chan FsEvent, 64)
|
||||
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
defer close(ch)
|
||||
|
||||
// Mapa de debounce: path -> (timer, ultimo op)
|
||||
type pending struct {
|
||||
timer *time.Timer
|
||||
op string
|
||||
}
|
||||
debounce := make(map[string]*pending)
|
||||
const debounceDelay = 200 * time.Millisecond
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Cancelar todos los timers pendientes antes de salir.
|
||||
for _, p := range debounce {
|
||||
p.timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
op := fsnotifyOpToString(event.Op)
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := event.Name
|
||||
|
||||
// Si el directorio nuevo fue creado, anadirlo al watcher.
|
||||
if event.Op&fsnotify.Create != 0 {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
if err := watcher.Add(path); err != nil {
|
||||
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: resetear el timer si ya habia uno para este path.
|
||||
if p, exists := debounce[path]; exists {
|
||||
p.timer.Stop()
|
||||
p.op = op
|
||||
p.timer.Reset(debounceDelay)
|
||||
} else {
|
||||
p = &pending{op: op}
|
||||
p.timer = time.AfterFunc(debounceDelay, func() {
|
||||
select {
|
||||
case ch <- FsEvent{Path: path, Op: p.op}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
delete(debounce, path)
|
||||
})
|
||||
debounce[path] = p
|
||||
}
|
||||
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
|
||||
// Retorna error si root no existe o no es accesible.
|
||||
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return fmt.Errorf("root dir %s: %w", root, err)
|
||||
}
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // ignora errores de acceso en subdirs
|
||||
}
|
||||
if info.IsDir() {
|
||||
return watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
|
||||
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
|
||||
func fsnotifyOpToString(op fsnotify.Op) string {
|
||||
switch {
|
||||
case op&fsnotify.Create != 0:
|
||||
return "create"
|
||||
case op&fsnotify.Write != 0:
|
||||
return "write"
|
||||
case op&fsnotify.Remove != 0:
|
||||
return "remove"
|
||||
case op&fsnotify.Rename != 0:
|
||||
return "rename"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: watch_dir_fsnotify
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error)"
|
||||
description: "Crea un watcher recursivo sobre root y todos sus subdirectorios usando fsnotify. Emite FsEvent al canal con debounce de 200ms por path (multiples eventos del mismo archivo en la ventana = un solo evento con la ultima op). Cierra el canal cuando ctx.Done(). Anade automaticamente nuevos subdirectorios creados en runtime."
|
||||
tags: [watcher, fsnotify, filesystem, dev-ux, async, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [fs_event_go_infra]
|
||||
returns: [fs_event_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "fmt", "log", "os", "path/filepath", "time", "github.com/fsnotify/fsnotify"]
|
||||
params:
|
||||
- name: ctx
|
||||
desc: "Context para cancelar el watcher. Al cancelar, el canal se cierra limpiamente."
|
||||
- name: root
|
||||
desc: "Directorio raiz a vigilar recursivamente. Debe existir o retorna error."
|
||||
output: "Canal de solo lectura que emite FsEvent por cada cambio detectado (tras debounce). El canal se cierra cuando ctx se cancela o el watcher interno falla."
|
||||
tested: true
|
||||
tests:
|
||||
- "detecta escritura de archivo"
|
||||
- "canal se cierra cuando ctx cancela"
|
||||
- "error en directorio inexistente"
|
||||
- "debounce agrupa multiples escrituras"
|
||||
test_file_path: "functions/infra/watch_dir_fsnotify_test.go"
|
||||
file_path: "functions/infra/watch_dir_fsnotify.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ch, err := infra.WatchDirFsnotify(ctx, "/home/lucas/fn_registry/dev/issues")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for ev := range ch {
|
||||
fmt.Printf("event: op=%s path=%s\n", ev.Op, ev.Path)
|
||||
// recargar el issue afectado en cache
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
En el backend de kanban_cpp para detectar cambios externos en `dev/issues/` y `dev/flows/` (ediciones en el editor de texto del usuario) y propagar via SSE al frontend ImGui. Tambien util para cualquier daemon que necesite invalidar cache ante cambios en disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Debounce por path**: si guardas el mismo archivo 5 veces en 200ms (ej. autoguardado del editor), recibes 1 evento, no 5. El `Op` del evento es el de la ultima operacion en la ventana.
|
||||
- **Subdirectorios dinamicos**: si se crea un subdirectorio nuevo mientras el watcher esta activo, se anade automaticamente al watcher. Los archivos creados dentro del nuevo subdir se detectan.
|
||||
- **Eventos CHMOD ignorados**: solo se emiten `create`, `write`, `remove`, `rename`. Cambios de permisos no disparan eventos.
|
||||
- **Canal con buffer 64**: si el consumidor es lento y el buffer se llena, eventos adicionales se bloquean en la goroutine interna. Con debounce 200ms es poco probable en uso normal.
|
||||
- **No filtra por extension**: emite eventos para cualquier archivo en el arbol, no solo `.md`. El consumidor debe filtrar si solo le interesan ciertos tipos.
|
||||
- **Linux inotify limit**: en sistemas con muchos subdirectorios, puede alcanzar el limite de `fs.inotify.max_user_watches` (default 8192). Aumentar con `sysctl fs.inotify.max_user_watches=65536` si se observan errores en el log.
|
||||
@@ -0,0 +1,129 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchDirFsnotify(t *testing.T) {
|
||||
t.Run("detecta escritura de archivo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Dar tiempo al watcher para arrancar
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Escribir un archivo
|
||||
testFile := filepath.Join(tmpDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
// Esperar evento (debounce 200ms + margen)
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("channel closed unexpectedly")
|
||||
}
|
||||
if ev.Path != testFile {
|
||||
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
|
||||
}
|
||||
if ev.Op != "create" && ev.Op != "write" {
|
||||
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for fs event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Cancelar inmediatamente
|
||||
cancel()
|
||||
|
||||
// El canal debe cerrarse
|
||||
timeout := time.After(2 * time.Second)
|
||||
// Drenar cualquier evento pendiente hasta que el canal se cierre
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return // canal cerrado correctamente
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("channel not closed after ctx cancel within 2s")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en directorio inexistente", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
testFile := filepath.Join(tmpDir, "debounce.md")
|
||||
// Escribir 5 veces rapidamente
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = os.WriteFile(testFile, []byte("content"), 0644)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Esperar debounce + margen
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
// Debe haber llegado al menos un evento pero no 5
|
||||
eventCount := 0
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
eventCount++
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if eventCount == 0 {
|
||||
t.Error("expected at least one debounced event")
|
||||
}
|
||||
if eventCount >= 5 {
|
||||
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WellknownOidcPatchConfig holds the parameters for WellknownOidcPatch.
|
||||
type WellknownOidcPatchConfig struct {
|
||||
WellknownJsonPath string // absolute path to the .well-known/matrix/client JSON file
|
||||
Issuer string // MAS issuer URL, must end with "/" (RFC 8414)
|
||||
AccountURL string // MAS account page URL
|
||||
BackupDir string // directory where the backup file is written
|
||||
DryRun bool // if true, return Before/After without writing
|
||||
}
|
||||
|
||||
// WellknownOidcPatchResult is returned by WellknownOidcPatch.
|
||||
type WellknownOidcPatchResult struct {
|
||||
BackupPath string // path of the backup file; empty on DryRun
|
||||
Before string // original JSON (pretty-printed, 2-space indent)
|
||||
After string // patched JSON (pretty-printed, 2-space indent)
|
||||
Modified bool // false if the key already existed with identical values
|
||||
}
|
||||
|
||||
// WellknownOidcPatch reads a Matrix .well-known/matrix/client JSON file,
|
||||
// adds (or updates) the org.matrix.msc2965.authentication key with the
|
||||
// supplied MAS issuer and account URL, and writes the result back to the
|
||||
// same path. All existing keys (m.homeserver, org.matrix.msc4143.rtc_foci,
|
||||
// etc.) are preserved. A timestamped backup is created in BackupDir before
|
||||
// any write. Set DryRun to true to preview the change without touching the
|
||||
// filesystem.
|
||||
func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error) {
|
||||
const oidcKey = "org.matrix.msc2965.authentication"
|
||||
|
||||
// 1. Read existing file.
|
||||
raw, err := os.ReadFile(cfg.WellknownJsonPath)
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: read %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
// 2. Parse into a generic map to preserve unknown keys.
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: invalid JSON in %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
// 3. Pretty-print Before.
|
||||
beforeBytes, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal before: %w", err)
|
||||
}
|
||||
before := string(beforeBytes)
|
||||
|
||||
// 4. Build the new authentication block.
|
||||
newAuth := map[string]any{
|
||||
"issuer": cfg.Issuer,
|
||||
"account": cfg.AccountURL,
|
||||
}
|
||||
|
||||
// 5. Check if the key already exists with identical values.
|
||||
modified := true
|
||||
if existing, ok := doc[oidcKey]; ok {
|
||||
existingBytes, _ := json.Marshal(existing)
|
||||
newBytes, _ := json.Marshal(newAuth)
|
||||
if string(existingBytes) == string(newBytes) {
|
||||
modified = false
|
||||
}
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: "",
|
||||
Before: before,
|
||||
After: before,
|
||||
Modified: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 6. Apply the patch.
|
||||
doc[oidcKey] = newAuth
|
||||
|
||||
afterBytes, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: marshal after: %w", err)
|
||||
}
|
||||
after := string(afterBytes)
|
||||
|
||||
// 7. DryRun: return without writing anything.
|
||||
if cfg.DryRun {
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: "",
|
||||
Before: before,
|
||||
After: after,
|
||||
Modified: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 8. Create backup.
|
||||
if err := os.MkdirAll(cfg.BackupDir, 0o755); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: mkdir backup dir: %w", err)
|
||||
}
|
||||
backupName := fmt.Sprintf("wellknown_%d.json", time.Now().Unix())
|
||||
backupPath := filepath.Join(cfg.BackupDir, backupName)
|
||||
if err := os.WriteFile(backupPath, raw, 0o644); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write backup: %w", err)
|
||||
}
|
||||
|
||||
// 9. Write patched file.
|
||||
if err := os.WriteFile(cfg.WellknownJsonPath, afterBytes, 0o644); err != nil {
|
||||
return WellknownOidcPatchResult{}, fmt.Errorf("wellknown_oidc_patch: write %s: %w", cfg.WellknownJsonPath, err)
|
||||
}
|
||||
|
||||
return WellknownOidcPatchResult{
|
||||
BackupPath: backupPath,
|
||||
Before: before,
|
||||
After: after,
|
||||
Modified: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: wellknown_oidc_patch
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WellknownOidcPatch(cfg WellknownOidcPatchConfig) (WellknownOidcPatchResult, error)"
|
||||
description: "Parchea el JSON .well-known/matrix/client aniadiendo org.matrix.msc2965.authentication (MAS issuer + account URL) para que los clientes Matrix descubran el OIDC provider dinamicamente. Preserva todos los campos existentes (m.homeserver, org.matrix.msc4143.rtc_foci, etc.). Crea backup antes de escribir. Soporta DryRun."
|
||||
tags: ["matrix", "mas", "oidc", "well-known", "msc2965", "migration", "mas-migration", "infra", "matrix-mas"]
|
||||
uses_functions: []
|
||||
uses_types: ["error_go_core"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["encoding/json", "fmt", "os", "path/filepath", "time"]
|
||||
tested: true
|
||||
tests:
|
||||
- "patch adds key and preserves existing fields"
|
||||
- "idempotent: second call returns Modified=false"
|
||||
- "dry run does not write file"
|
||||
- "nonexistent file returns error"
|
||||
test_file_path: "functions/infra/wellknown_oidc_patch_test.go"
|
||||
file_path: "functions/infra/wellknown_oidc_patch.go"
|
||||
params:
|
||||
- name: WellknownJsonPath
|
||||
desc: "Ruta absoluta al archivo .well-known/matrix/client JSON (copiado del VPS antes de llamar; el operador copia de vuelta tras la llamada)"
|
||||
- name: Issuer
|
||||
desc: "URL del MAS issuer, DEBE terminar en '/' (RFC 8414). Ej: https://auth-af2f3d.organic-machine.com/"
|
||||
- name: AccountURL
|
||||
desc: "URL del account page del MAS. Ej: https://auth-af2f3d.organic-machine.com/account"
|
||||
- name: BackupDir
|
||||
desc: "Directorio donde se escribe wellknown_<unix_ts>.json antes de modificar. Se crea con mkdir -p si no existe."
|
||||
- name: DryRun
|
||||
desc: "Si true, calcula Before/After y Modified pero no escribe ningun archivo ni crea backup."
|
||||
output: "WellknownOidcPatchResult con BackupPath (vacio en DryRun/no-op), Before y After JSON pretty-printed, y Modified=false si el valor ya era identico."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cfg := infra.WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: "/tmp/wellknown_client.json",
|
||||
Issuer: "https://auth-af2f3d.organic-machine.com/",
|
||||
AccountURL: "https://auth-af2f3d.organic-machine.com/account",
|
||||
BackupDir: "/tmp/wellknown_backups",
|
||||
DryRun: true,
|
||||
}
|
||||
res, err := infra.WellknownOidcPatch(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Modified:", res.Modified)
|
||||
fmt.Println("After:\n", res.After)
|
||||
|
||||
// Si el resultado es correcto, volver a llamar con DryRun: false para escribir.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Paso 5 de la migracion 0162 (Synapse → MAS): antes de hacer hot-reload nginx del container `wellknown`. Tambien util si cambia el issuer MAS en el futuro (basta llamarla de nuevo con el nuevo URL — la idempotencia garantiza que no duplica la clave).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Issuer DEBE terminar en `/`**: los clientes Matrix siguen RFC 8414 estrictamente. Un issuer sin `/` final causa fallos de descubrimiento silenciosos.
|
||||
- **Usar mapa dinamico, no struct**: la funcion parsea el JSON en `map[string]any` para preservar campos desconocidos. No asumir que el archivo solo tiene `m.homeserver`.
|
||||
- **Tras escribir, recargar nginx**: `ssh <host> docker exec <wellknown_container> nginx -s reload`. Esta funcion no lo hace — es responsabilidad del operador.
|
||||
- **Synapse tambien puede servir el well-known**: `/_matrix/client/.well-known` puede provenir de Synapse ademas del container wellknown. Verificar con `curl -s https://matrix.organic-machine.com/.well-known/matrix/client` y `curl -s https://matrix.organic-machine.com/_matrix/client/.well-known/matrix/client` para saber cual usa cada cliente.
|
||||
- **DryRun no crea backup ni BackupDir**: usar DryRun para verificar el diff antes de ejecutar en produccion.
|
||||
@@ -0,0 +1,178 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixtureWellknown is the real-world JSON from the VPS wellknown container,
|
||||
// with m.homeserver and org.matrix.msc4143.rtc_foci already present.
|
||||
const fixtureWellknown = `{
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.organic-machine.com"
|
||||
},
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://livekit.organic-machine.com"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestWellknownOidcPatch(t *testing.T) {
|
||||
const issuer = "https://auth-af2f3d.organic-machine.com/"
|
||||
const accountURL = "https://auth-af2f3d.organic-machine.com/account"
|
||||
|
||||
t.Run("patch adds key and preserves existing fields", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Modified {
|
||||
t.Error("want Modified=true, got false")
|
||||
}
|
||||
if res.BackupPath == "" {
|
||||
t.Error("want non-empty BackupPath")
|
||||
}
|
||||
|
||||
// Backup must exist.
|
||||
if _, err := os.Stat(res.BackupPath); err != nil {
|
||||
t.Errorf("backup file missing: %v", err)
|
||||
}
|
||||
|
||||
// Read written file and validate.
|
||||
written, err := os.ReadFile(jsonPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(written, &doc); err != nil {
|
||||
t.Fatalf("written file is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// New key must exist with correct values.
|
||||
auth, ok := doc["org.matrix.msc2965.authentication"]
|
||||
if !ok {
|
||||
t.Fatal("org.matrix.msc2965.authentication key missing")
|
||||
}
|
||||
authMap, ok := auth.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("org.matrix.msc2965.authentication is not an object")
|
||||
}
|
||||
if authMap["issuer"] != issuer {
|
||||
t.Errorf("issuer: want %q, got %q", issuer, authMap["issuer"])
|
||||
}
|
||||
if authMap["account"] != accountURL {
|
||||
t.Errorf("account: want %q, got %q", accountURL, authMap["account"])
|
||||
}
|
||||
|
||||
// Existing keys must be preserved.
|
||||
if _, ok := doc["m.homeserver"]; !ok {
|
||||
t.Error("m.homeserver was removed — must be preserved")
|
||||
}
|
||||
if _, ok := doc["org.matrix.msc4143.rtc_foci"]; !ok {
|
||||
t.Error("org.matrix.msc4143.rtc_foci was removed — must be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent: second call returns Modified=false", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: false,
|
||||
}
|
||||
|
||||
if _, err := WellknownOidcPatch(cfg); err != nil {
|
||||
t.Fatalf("first call error: %v", err)
|
||||
}
|
||||
|
||||
res2, err := WellknownOidcPatch(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second call error: %v", err)
|
||||
}
|
||||
if res2.Modified {
|
||||
t.Error("want Modified=false on second call, got true")
|
||||
}
|
||||
if res2.BackupPath != "" {
|
||||
t.Errorf("want empty BackupPath on no-op, got %q", res2.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run does not write file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
jsonPath := filepath.Join(dir, "client")
|
||||
backupDir := filepath.Join(dir, "backups")
|
||||
|
||||
if err := os.WriteFile(jsonPath, []byte(fixtureWellknown), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: jsonPath,
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: backupDir,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Modified {
|
||||
t.Error("want Modified=true on dry run with new key")
|
||||
}
|
||||
if res.BackupPath != "" {
|
||||
t.Errorf("want empty BackupPath on dry run, got %q", res.BackupPath)
|
||||
}
|
||||
|
||||
// Original file must be untouched.
|
||||
content, _ := os.ReadFile(jsonPath)
|
||||
if string(content) != fixtureWellknown {
|
||||
t.Error("file was modified during dry run")
|
||||
}
|
||||
|
||||
// BackupDir must not have been created.
|
||||
if _, err := os.Stat(backupDir); !os.IsNotExist(err) {
|
||||
t.Error("backup dir was created during dry run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent file returns error", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := WellknownOidcPatch(WellknownOidcPatchConfig{
|
||||
WellknownJsonPath: filepath.Join(dir, "does_not_exist"),
|
||||
Issuer: issuer,
|
||||
AccountURL: accountURL,
|
||||
BackupDir: filepath.Join(dir, "backups"),
|
||||
DryRun: false,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("want error for nonexistent file, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// WriteIssueMd serializa el frontmatter del Issue a YAML y lo escribe en path junto al body.
|
||||
// El archivo resultante tiene formato: "---\n<yaml>---\n<body>".
|
||||
// El body se preserva exactamente tal como fue recibido (sin normalizar trailing newlines).
|
||||
// Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:"-".
|
||||
func WriteIssueMd(path string, iss Issue, body []byte) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
yamlBytes, err := yaml.Marshal(iss)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write_issue_md: marshal %s: %w", path, err)
|
||||
}
|
||||
|
||||
buf.WriteString("---\n")
|
||||
buf.Write(yamlBytes)
|
||||
buf.WriteString("---\n")
|
||||
buf.Write(body)
|
||||
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
|
||||
return fmt.Errorf("write_issue_md: write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: write_issue_md
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WriteIssueMd(path string, iss Issue, body []byte) error"
|
||||
description: "Serializa el frontmatter de un struct Issue a YAML y escribe el archivo Markdown en disco con formato ---\\nyaml---\\nbody. Preserva el body exactamente sin normalizar trailing newlines ni reordenar. Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:\"-\"."
|
||||
tags: [issue, writer, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [issue_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "fmt", "os", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta de destino del archivo .md (puede ser la misma de la que se leyo para un update in-place)"
|
||||
- name: iss
|
||||
desc: "Struct Issue con el frontmatter a serializar. FilePath/MtimeNs/Completed se ignoran en el YAML de salida"
|
||||
- name: body
|
||||
desc: "Body MD tal como fue devuelto por ParseIssueMd — se escribe byte a byte sin modificar"
|
||||
output: "nil en exito, error si el marshal YAML falla o el archivo no se puede escribir"
|
||||
tested: true
|
||||
tests:
|
||||
- "round-trip parse-write-parse preserva struct"
|
||||
- "archivo resultante empieza con ---"
|
||||
- "error en path inexistente"
|
||||
test_file_path: "functions/infra/write_issue_md_test.go"
|
||||
file_path: "functions/infra/write_issue_md.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Actualizar status de un issue in-place
|
||||
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
iss.Status = "in-progress"
|
||||
iss.Updated = "2026-05-22"
|
||||
|
||||
if err := infra.WriteIssueMd("dev/issues/0130-kanban-cpp-v2.md", iss, body); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el backend de kanban_cpp necesite actualizar el frontmatter de un issue (cambio de status, priority, tags, etc.) sin tocar el body. Siempre usar en par con `parse_issue_md_go_infra`: parse → modificar struct → write.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `yaml.Marshal` de v3 puede reordenar campos respecto al original — el orden del YAML de salida sera el orden de declaracion del struct `Issue`, no el del archivo original. Si el orden importa para diff legibilidad, documentarlo.
|
||||
- El body se escribe byte a byte. Si lo modificas antes de pasar, lo que escribes es lo que queda.
|
||||
- No hace backup previo. En sistemas con watcher activo, el write dispara un evento `write` en `watch_dir_fsnotify_go_infra` — el backend debe ignorar sus propios writes para no entrar en loop.
|
||||
@@ -0,0 +1,92 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteIssueMd(t *testing.T) {
|
||||
root := registryRoot()
|
||||
|
||||
t.Run("round-trip parse-write-parse preserva struct", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
|
||||
// Parse original
|
||||
iss1, body1, err := ParseIssueMd(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd: %v", err)
|
||||
}
|
||||
|
||||
// Write a TempDir
|
||||
tmpPath := filepath.Join(t.TempDir(), "issue_roundtrip.md")
|
||||
if err := WriteIssueMd(tmpPath, iss1, body1); err != nil {
|
||||
t.Fatalf("WriteIssueMd: %v", err)
|
||||
}
|
||||
|
||||
// Parse de nuevo
|
||||
iss2, body2, err := ParseIssueMd(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd after write: %v", err)
|
||||
}
|
||||
|
||||
// Comparar campos (ignorar FilePath y MtimeNs que son runtime)
|
||||
if iss1.ID != iss2.ID {
|
||||
t.Errorf("ID: %q != %q", iss1.ID, iss2.ID)
|
||||
}
|
||||
if iss1.Title != iss2.Title {
|
||||
t.Errorf("Title: %q != %q", iss1.Title, iss2.Title)
|
||||
}
|
||||
if iss1.Status != iss2.Status {
|
||||
t.Errorf("Status: %q != %q", iss1.Status, iss2.Status)
|
||||
}
|
||||
if iss1.Flow != iss2.Flow {
|
||||
t.Errorf("Flow: %q != %q", iss1.Flow, iss2.Flow)
|
||||
}
|
||||
if len(iss1.Domain) != len(iss2.Domain) {
|
||||
t.Errorf("Domain len: %d != %d", len(iss1.Domain), len(iss2.Domain))
|
||||
}
|
||||
if len(iss1.Depends) != len(iss2.Depends) {
|
||||
t.Errorf("Depends len: %d != %d", len(iss1.Depends), len(iss2.Depends))
|
||||
}
|
||||
if len(iss1.Tags) != len(iss2.Tags) {
|
||||
t.Errorf("Tags len: %d != %d", len(iss1.Tags), len(iss2.Tags))
|
||||
}
|
||||
|
||||
// El body debe preservarse exactamente
|
||||
if string(body1) != string(body2) {
|
||||
t.Errorf("body mismatch:\ngot: %q\nwant: %q", string(body2), string(body1))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("archivo resultante empieza con ---", func(t *testing.T) {
|
||||
iss := Issue{
|
||||
ID: "0001",
|
||||
Title: "Test issue",
|
||||
Status: "pendiente",
|
||||
}
|
||||
tmpPath := filepath.Join(t.TempDir(), "test.md")
|
||||
if err := WriteIssueMd(tmpPath, iss, []byte("# Body\n")); err != nil {
|
||||
t.Fatalf("WriteIssueMd: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(tmpPath)
|
||||
if len(data) < 4 || string(data[:4]) != "---\n" {
|
||||
t.Errorf("file should start with '---\\n', got: %q", string(data[:min(10, len(data))]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en path inexistente", func(t *testing.T) {
|
||||
iss := Issue{ID: "0001", Title: "x", Status: "pendiente"}
|
||||
err := WriteIssueMd("/nonexistent/dir/issue.md", iss, []byte("body"))
|
||||
if err == nil {
|
||||
t.Error("expected error writing to nonexistent dir")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/marcboeker/go-duckdb v1.8.5
|
||||
|
||||
@@ -39,6 +39,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# data_table MIGRATION guide
|
||||
|
||||
Referencia para apps que migran a v1.0.0 estable del modulo `data_table`.
|
||||
La version de modulo (`module.md`) es semver independiente del entrypoint (`data_table.md`).
|
||||
Este documento cubre el salto al hito de estabilidad 1.0.0, no versiones intermedias.
|
||||
|
||||
---
|
||||
|
||||
## v2.x → estabilidad 1.0.0 (pendiente gate 0133)
|
||||
|
||||
### What changed (internals — no API change)
|
||||
|
||||
Las optimizaciones planificadas en issue 0133 son **transparentes para el caller**. La API publica (`data_table.h`) no cambia.
|
||||
|
||||
| Cambio interno | Impacto en caller |
|
||||
|---|---|
|
||||
| Columnar snapshot interno (agente B) | Ninguno. `TableInput.cells` sigue siendo row-major caller-owned. |
|
||||
| String interning de celdas en snapshot | Ninguno. Misma interfaz de lectura. Strings siguen viviendo en el caller. |
|
||||
| Lazy `visible_rows` (filter + sort diferidos) | Ninguno. `render()` sigue siendo una sola llamada por frame. |
|
||||
| Display cache per-cell | Ninguno. La cache es opaca al caller. |
|
||||
| OpenMP en compute (agente A bench gate) | Ninguno. Threading interno, thread-safety invariante: llamar solo desde el main thread de ImGui. |
|
||||
|
||||
### What you must do
|
||||
|
||||
**Nada**, si usas la API publica.
|
||||
|
||||
- `data_table::render(id, tables, st, events_out, show_chrome)` — firma identica.
|
||||
- `TableInput`, `State`, `TableEvent`, `ColumnSpec`, `ColorRule` — sin cambios de layout.
|
||||
- Back-compat overload `render(id, tables, st, show_chrome)` — sigue compilando.
|
||||
|
||||
Casos especificos:
|
||||
|
||||
| Situacion | Accion |
|
||||
|---|---|
|
||||
| Guardabas punteros a `TableInput.cells` entre frames | Sigue valido. El caller es dueno de `cells`; el modulo no lo mueve ni libera. |
|
||||
| Usabas `data_table_internal.h` directamente | Rebuild obligatorio. El header es privado del modulo — si lo incluias, estabas fuera del contrato. No se garantiza estabilidad de `UiState` ni de los helpers internos. |
|
||||
| Enlazan `fn_table_viz` (target antiguo) | Reemplazar por `fn_module_data_table`. El target `fn_table_viz` fue deprecado en v1.4.0 (2026-05-16). |
|
||||
|
||||
### Behavior contracts preserved
|
||||
|
||||
Estos contratos estan FROZEN en v1.0.0 y no pueden romperse sin major version bump:
|
||||
|
||||
- **Bit-identical rendering**: misma entrada → misma salida visual (excepto antialiasing de ImGui).
|
||||
- **`TableEvent.row` indexa `TableInput`**: los indices de fila en eventos (`ButtonClick`, `RowDoubleClick`, `RowRightClick`) referencian la tabla de entrada original, no el snapshot interno ni la vista filtrada.
|
||||
- **`stats_last_cells` pointer-identity sentinel**: el campo `State::stats_last_cells` se usa internamente para detectar cambio de datos. Si el caller pasa el mismo puntero `cells` en frames consecutivos, el modulo reutiliza la cache de stats. Cambiar el puntero (aunque el contenido sea igual) invalida la cache — comportamiento documentado y frozen.
|
||||
- **`events_out` solo hace `push_back`**: `render()` nunca llama `clear()` ni `resize()` sobre el vector del caller. El caller limpia antes de cada frame si no quiere acumulacion.
|
||||
- **`show_chrome = true` por defecto**: el overload de back-compat sin `show_chrome` pasa `true`.
|
||||
- **`State` es caller-managed**: el modulo no alloca ni libera el `State`. El caller lo destruye cuando quiere.
|
||||
|
||||
### New (optional, v1.0.0+)
|
||||
|
||||
*(placeholder — se documentaran aqui las features opt-in que lleguen post-gate)*
|
||||
|
||||
---
|
||||
|
||||
## Backwards compatibility policy
|
||||
|
||||
La API publica de `data_table::render`, `TableInput`, `State`, `TableEvent`, `ColumnSpec` y `ColorRule` esta **FROZEN** en v1.0.0.
|
||||
|
||||
- **Breaking changes** (cambiar firma, quitar campo, cambiar semántica de parametro existente) requieren major version bump y un periodo de coexistencia con el path anterior.
|
||||
- **Additive changes** (nuevo campo en struct con default sensato, nuevo overload, nuevo `CellRenderer` enum value) son minor — consumidores existentes no necesitan cambios.
|
||||
- **Bugfixes** y optimizaciones internas son patch — sin cambio de contrato.
|
||||
|
||||
Apps consumidoras que solo usen `#include "data_table/data_table.h"` y `#include "core/data_table_types.h"` no necesitan cambios en minor y patch bumps.
|
||||
|
||||
---
|
||||
|
||||
## Porting desde el playground (pre-registry)
|
||||
|
||||
Si tu app usaba el playground original (`cpp/apps/primitives_gallery/playground/tables/data_table.h`):
|
||||
|
||||
1. **Cambiar include path**:
|
||||
```cpp
|
||||
// Antes
|
||||
#include "tables/data_table.h"
|
||||
#include "tables/data_table_types.h"
|
||||
|
||||
// Despues
|
||||
#include "data_table/data_table.h"
|
||||
#include "core/data_table_types.h"
|
||||
```
|
||||
|
||||
2. **Cambiar target CMake**:
|
||||
```cmake
|
||||
# Antes
|
||||
target_link_libraries(mi_app PRIVATE fn_table_viz)
|
||||
|
||||
# Despues
|
||||
target_link_libraries(mi_app PRIVATE fn_module_data_table)
|
||||
```
|
||||
|
||||
3. **`app.md`**: declarar `uses_modules: [data_table_cpp]` en lugar de listar funciones miembro individualmente.
|
||||
|
||||
4. **Namespace identico**: `data_table::render`, `data_table::State`, `data_table::TableInput` — sin cambios.
|
||||
|
||||
5. **`data_table_logic.h` eliminado**: los helpers internos del playground (`row_to_tsv`, drill, view_mode, etc.) eran privados. En el modulo son `static` en `data_table.cpp`. Si los necesitabas externamente, estan fuera del contrato — contactar para evaluar si deben promoverse al registry.
|
||||
|
||||
---
|
||||
|
||||
## Release checklist (gate 0133 — NO ejecutar hasta A+B listos)
|
||||
|
||||
Pasos exactos para ejecutar cuando agentes A y B completen su trabajo:
|
||||
|
||||
1. **Bench gate**: `data_table_bench --rows 10000000` debe reportar `fps_p1 >= 60`. El agente A construye el bench; esta metrica es el prerequisito de estabilidad.
|
||||
|
||||
2. **fn doctor clean**: `fn doctor cpp-apps` debe pasar sin nuevos `CANDIDATE` (tablas inline sin migrar en apps consumidoras). Indica que todos los consumidores usan el modulo correctamente.
|
||||
|
||||
3. **Build 11 consumidores**: compilar los 11 apps que linkean `fn_module_data_table` sin errores ni warnings nuevos. Verificar con:
|
||||
```bash
|
||||
cd cpp/build && cmake --build . --target \
|
||||
registry_dashboard kanban dag_engine_ui services_monitor \
|
||||
graph_explorer chart_demo 2>&1 | grep -E "error:|warning:"
|
||||
```
|
||||
*(ajustar lista de targets segun `fn doctor cpp-apps` output)*
|
||||
|
||||
4. **Version bump**:
|
||||
```bash
|
||||
/version modules/data_table minor "estable 1.0.0 + columnar + 10M rows"
|
||||
```
|
||||
Esto bumpa el campo `version:` en `module.md` (actualmente `2.1.0`) a `3.0.0` (major bump porque el modulo alcanza estabilidad contractual) o al numero que corresponda segun la politica semver del proyecto en ese momento.
|
||||
|
||||
> Nota: `data_table.md` tiene version `1.5.0` (entrypoint). `module.md` tiene `2.1.0` (modulo). El bump de "estabilidad 1.0.0" es un hito de politica — el numero exacto lo decide el operador segun cual de los dos .md es la fuente de verdad para el semver del modulo.
|
||||
|
||||
5. **Tag stable**: en `module.md` frontmatter, anadir `tags: [stable]` al array existente `[tables, viz, ui, imgui, tql, cpp]`.
|
||||
|
||||
6. **Capability growth log**: descomentar la entrada preparada en `data_table.md` (ver seccion al final del archivo), rellenando la fecha real `YYYY-MM-DD`.
|
||||
|
||||
7. **Push + tag git**:
|
||||
```bash
|
||||
git add modules/data_table/module.md modules/data_table/data_table.md
|
||||
git commit -m "feat(data_table): stable 1.0.0 — columnar + 10M rows gate passed"
|
||||
git tag data_table/v1.0.0
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inconsistencias detectadas en doc actual
|
||||
|
||||
Las siguientes inconsistencias fueron detectadas durante la preparacion de este documento. No bloquean el gate pero conviene resolver antes del bump:
|
||||
|
||||
1. **Version drift entre los dos .md**: `data_table.md` tiene `version: 1.5.0` y `module.md` tiene `version: 2.1.0`. Son versionados independientemente pero no esta documentado explicitamente cual es la "version publica" del modulo. Recomendacion: clarificar en `module.md` que su version es la del modulo como unidad y `data_table.md` es la del entrypoint como funcion del registry.
|
||||
|
||||
2. **`error_type: "error_go_core"` en `data_table.md`**: la funcion es C++ pura (no retorna `error` de Go). El campo `error_type` del frontmatter parece heredado del template Go. No afecta el comportamiento pero es semanticamente incorrecto para un entrypoint C++.
|
||||
|
||||
3. **`tests` array en `data_table.md` apunta a `cpp/tests/test_column_specs.cpp`** pero la documentacion dice "No hay tests unitarios directos". Los tests listados son del harness de compilacion (`cpp/tests/`), no del entrypoint en si. Aclarar en `## Notas` que el `test_file_path` referencia tests de compilacion/link, no tests de render.
|
||||
|
||||
4. **`llm_anthropic_cpp_core` en `module.md` uses_functions** pero `data_table.md` no lo lista (stub interno). Alinear: si el stub es interno al modulo, deberia estar en `members`, no en `uses_functions`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user