Compare commits
15 Commits
auto/0129
...
bd9f0d8437
| Author | SHA1 | Date | |
|---|---|---|---|
| bd9f0d8437 | |||
| 207c08c3b7 | |||
| 01bc2aeb14 | |||
| 9ec7751f6f | |||
| fef86250a0 | |||
| 472b6092bb | |||
| ea5c94fc8a | |||
| a8b09ad154 | |||
| 6aa874f2b6 | |||
| 93352a7780 | |||
| 0ffae6daa4 | |||
| 74b58cf0d0 | |||
| 9752fb106a | |||
| 8cb0121573 | |||
| 90115270d2 |
@@ -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
|
||||||
@@ -535,3 +535,21 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag
|
|||||||
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
||||||
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
||||||
endif()
|
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()
|
||||||
|
|||||||
@@ -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 "compute_column_stats.h"
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -353,6 +355,59 @@ struct VizPanel {
|
|||||||
mutable ViewMode last_non_table = ViewMode::Bar;
|
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.
|
// State: stage pipeline + viz globales.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -419,6 +474,11 @@ struct State {
|
|||||||
std::vector<DrillStep> drill_back;
|
std::vector<DrillStep> drill_back;
|
||||||
std::vector<DrillStep> drill_forward;
|
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).
|
// Helpers (definidos en compute_stage.cpp).
|
||||||
Stage& raw();
|
Stage& raw();
|
||||||
const Stage& raw() const;
|
const Stage& raw() const;
|
||||||
|
|||||||
@@ -269,8 +269,21 @@ Response request(const Request& req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd << ' ' << sh_q(req.url)
|
cmd << ' ' << sh_q(req.url)
|
||||||
<< " -o " << sh_q(tmp_body_out)
|
<< " -o " << sh_q(tmp_body_out);
|
||||||
<< " 2>&1";
|
|
||||||
|
// 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).
|
// Capture stderr (curl prints transport errors to stderr with -sS).
|
||||||
std::string curl_stderr;
|
std::string curl_stderr;
|
||||||
|
|||||||
@@ -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
|
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/sse_client.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.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.
|
||||||
@@ -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,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 |
|
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||||
| [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 |
|
| [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
|
## 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
|
package infra
|
||||||
|
|
||||||
import (
|
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,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/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
github.com/marcboeker/go-duckdb v1.8.5
|
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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
github.com/go-faster/errors v0.7.1 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`.
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cfloat>
|
#include <cfloat>
|
||||||
|
#include <cinttypes>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -315,7 +316,185 @@ static std::string row_to_tsv(const char* const* cells, int rows, int cols,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue 0133 — Change 3: Reader rewire helpers.
|
||||||
|
//
|
||||||
|
// snap_cell: devuelve el string de una celda desde el snapshot columnar cuando
|
||||||
|
// la columna esta en rango, con fallback al raw cells array.
|
||||||
|
// Para columnas Int/Float usa un buffer thread_local de 32 bytes (evita alloc).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static inline const char* snap_cell(int r, int c,
|
||||||
|
const SnapshotCache& snap,
|
||||||
|
const StringPool& pool,
|
||||||
|
char (&tmp)[32])
|
||||||
|
{
|
||||||
|
if (c >= 0 && c < (int)snap.cols.size()) {
|
||||||
|
const ColumnSnapshot& cs = snap.cols[(size_t)c];
|
||||||
|
if (cs.type == ColumnType::Int) {
|
||||||
|
std::snprintf(tmp, sizeof(tmp), "%" PRId64, cs.i64[(size_t)r]);
|
||||||
|
return tmp;
|
||||||
|
} else if (cs.type == ColumnType::Float) {
|
||||||
|
std::snprintf(tmp, sizeof(tmp), "%.17g", cs.f64[(size_t)r]);
|
||||||
|
return tmp;
|
||||||
|
} else if (!cs.str_ids.empty()) {
|
||||||
|
return pool.at(cs.str_ids[(size_t)r]).c_str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(void)tmp;
|
||||||
|
return nullptr; // caller must fallback to cells[r*cols+c]
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare_snap: evaluates filter f against row r using snapshot when available.
|
||||||
|
// Falls back to raw cells if column not in snapshot.
|
||||||
|
static inline bool compare_snap(int r, int f_col,
|
||||||
|
const char* f_val, Op f_op,
|
||||||
|
const char* const* cells, int cols,
|
||||||
|
const SnapshotCache& snap,
|
||||||
|
const StringPool& pool)
|
||||||
|
{
|
||||||
|
// Fast numeric path: avoid string conversion for numeric comparisons.
|
||||||
|
if (f_col >= 0 && f_col < (int)snap.cols.size()) {
|
||||||
|
const ColumnSnapshot& cs = snap.cols[(size_t)f_col];
|
||||||
|
if (cs.type == ColumnType::Int &&
|
||||||
|
(f_op == Op::Eq || f_op == Op::Neq || f_op == Op::Gt ||
|
||||||
|
f_op == Op::Gte || f_op == Op::Lt || f_op == Op::Lte)) {
|
||||||
|
double fv;
|
||||||
|
if (parse_number(f_val, fv)) {
|
||||||
|
int64_t av = cs.i64[(size_t)r];
|
||||||
|
int64_t bv = (int64_t)fv;
|
||||||
|
switch (f_op) {
|
||||||
|
case Op::Eq: return av == bv;
|
||||||
|
case Op::Neq: return av != bv;
|
||||||
|
case Op::Gt: return av > bv;
|
||||||
|
case Op::Gte: return av >= bv;
|
||||||
|
case Op::Lt: return av < bv;
|
||||||
|
case Op::Lte: return av <= bv;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cs.type == ColumnType::Float &&
|
||||||
|
(f_op == Op::Eq || f_op == Op::Neq || f_op == Op::Gt ||
|
||||||
|
f_op == Op::Gte || f_op == Op::Lt || f_op == Op::Lte)) {
|
||||||
|
double fv;
|
||||||
|
if (parse_number(f_val, fv)) {
|
||||||
|
double av = cs.f64[(size_t)r];
|
||||||
|
switch (f_op) {
|
||||||
|
case Op::Eq: return av == fv;
|
||||||
|
case Op::Neq: return av != fv;
|
||||||
|
case Op::Gt: return av > fv;
|
||||||
|
case Op::Gte: return av >= fv;
|
||||||
|
case Op::Lt: return av < fv;
|
||||||
|
case Op::Lte: return av <= fv;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// String column: snapshot offers no speed advantage for substring ops
|
||||||
|
// (Contains/NotContains/StartsWith/EndsWith need full string scan regardless).
|
||||||
|
// Only use intern path for equality (id compare avoids strcmp).
|
||||||
|
if (!cs.str_ids.empty()) {
|
||||||
|
if (f_op == Op::Eq || f_op == Op::Neq) {
|
||||||
|
// Find the interned id of f_val (if not found, no row can match Eq,
|
||||||
|
// and all rows match Neq).
|
||||||
|
std::string_view fv_sv(f_val);
|
||||||
|
auto fv_it = pool.index.find(fv_sv);
|
||||||
|
if (f_op == Op::Eq) {
|
||||||
|
if (fv_it == pool.index.end()) return false; // f_val not interned => no match
|
||||||
|
return cs.str_ids[(size_t)r] == fv_it->second;
|
||||||
|
} else { // Op::Neq
|
||||||
|
if (fv_it == pool.index.end()) return true; // f_val not interned => all differ
|
||||||
|
return cs.str_ids[(size_t)r] != fv_it->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For substring / prefix / suffix ops: fall through to raw cells (no snapshot benefit).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: raw cells (e.g. derived column not in snapshot, or string substring op).
|
||||||
|
const char* cell = (f_col >= 0 && f_col < cols) ? cells[r * cols + f_col] : nullptr;
|
||||||
|
return compare(cell, f_val, f_op);
|
||||||
|
}
|
||||||
|
|
||||||
// compute_visible_rows: applies stage-0 filters + optional sort, returns matching row indices.
|
// compute_visible_rows: applies stage-0 filters + optional sort, returns matching row indices.
|
||||||
|
// Issue 0133 — Change 3: overload with snapshot for columnar reads.
|
||||||
|
static std::vector<int> compute_visible_rows(const char* const* cells,
|
||||||
|
int rows, int cols,
|
||||||
|
const State& st,
|
||||||
|
const SnapshotCache& snap,
|
||||||
|
const StringPool& pool)
|
||||||
|
{
|
||||||
|
std::vector<int> out;
|
||||||
|
out.reserve(rows);
|
||||||
|
const Stage& s = st.raw();
|
||||||
|
for (int r = 0; r < rows; ++r) {
|
||||||
|
bool keep = true;
|
||||||
|
for (const auto& f : s.filters) {
|
||||||
|
if (f.col < 0 || f.col >= cols) continue;
|
||||||
|
if (!compare_snap(r, f.col, f.value.c_str(), f.op,
|
||||||
|
cells, cols, snap, pool)) {
|
||||||
|
keep = false; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keep) out.push_back(r);
|
||||||
|
}
|
||||||
|
if (!s.sorts.empty()) {
|
||||||
|
const SortClause& sc0 = s.sorts.front();
|
||||||
|
int sc = -1;
|
||||||
|
if (!sc0.col.empty() && sc0.col[0] == '@') {
|
||||||
|
sc = std::atoi(sc0.col.c_str() + 1);
|
||||||
|
}
|
||||||
|
bool desc = sc0.desc;
|
||||||
|
if (sc >= 0 && sc < cols) {
|
||||||
|
// Fast numeric sort via snapshot.
|
||||||
|
if (sc < (int)snap.cols.size()) {
|
||||||
|
const ColumnSnapshot& cs = snap.cols[(size_t)sc];
|
||||||
|
if (cs.type == ColumnType::Int) {
|
||||||
|
std::sort(out.begin(), out.end(), [&](int a, int b) {
|
||||||
|
int64_t va = cs.i64[(size_t)a];
|
||||||
|
int64_t vb = cs.i64[(size_t)b];
|
||||||
|
return desc ? (va > vb) : (va < vb);
|
||||||
|
});
|
||||||
|
goto sort_done;
|
||||||
|
} else if (cs.type == ColumnType::Float) {
|
||||||
|
std::sort(out.begin(), out.end(), [&](int a, int b) {
|
||||||
|
double va = cs.f64[(size_t)a];
|
||||||
|
double vb = cs.f64[(size_t)b];
|
||||||
|
return desc ? (va > vb) : (va < vb);
|
||||||
|
});
|
||||||
|
goto sort_done;
|
||||||
|
} else if (!cs.str_ids.empty()) {
|
||||||
|
// String sort: compare uint32_t ids first (if equal -> same string).
|
||||||
|
std::sort(out.begin(), out.end(), [&](int a, int b) {
|
||||||
|
uint32_t ia = cs.str_ids[(size_t)a];
|
||||||
|
uint32_t ib = cs.str_ids[(size_t)b];
|
||||||
|
if (ia == ib) return false; // equal
|
||||||
|
int cmp = std::strcmp(pool.at(ia).c_str(), pool.at(ib).c_str());
|
||||||
|
return desc ? (cmp > 0) : (cmp < 0);
|
||||||
|
});
|
||||||
|
goto sort_done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback sort via raw cells.
|
||||||
|
std::sort(out.begin(), out.end(), [&](int a, int b) {
|
||||||
|
const char* ca = cells[a * cols + sc];
|
||||||
|
const char* cb = cells[b * cols + sc];
|
||||||
|
if (!ca) ca = "";
|
||||||
|
if (!cb) cb = "";
|
||||||
|
double na, nb;
|
||||||
|
bool num = parse_number(ca, na) && parse_number(cb, nb);
|
||||||
|
int cmp;
|
||||||
|
if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0);
|
||||||
|
else cmp = std::strcmp(ca, cb);
|
||||||
|
return desc ? (cmp > 0) : (cmp < 0);
|
||||||
|
});
|
||||||
|
sort_done:;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute_visible_rows: legacy overload without snapshot (used by stage>0 path
|
||||||
|
// which operates on materialized StageOutput — not the raw cells snapshot).
|
||||||
static std::vector<int> compute_visible_rows(const char* const* cells,
|
static std::vector<int> compute_visible_rows(const char* const* cells,
|
||||||
int rows, int cols,
|
int rows, int cols,
|
||||||
const State& st)
|
const State& st)
|
||||||
@@ -816,6 +995,121 @@ void render(const char* id,
|
|||||||
ensure_init(st, eff_cols);
|
ensure_init(st, eff_cols);
|
||||||
auto& U = ui();
|
auto& U = ui();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Issue 0133 — Change 1+2: Columnar snapshot + string interning.
|
||||||
|
//
|
||||||
|
// Se reconstruye si:
|
||||||
|
// - Es el primer frame (last_cells_ptr == nullptr), o
|
||||||
|
// - El puntero de `cells` cambio (caller reemplazo el buffer).
|
||||||
|
//
|
||||||
|
// Snapshot cubre las columnas ORIGINALES (pre-derived) del stage-0 input.
|
||||||
|
// Las derived columns no se incluyen en el snapshot — se calculan en
|
||||||
|
// compute_stage y el snapshot solo optimiza el acceso a datos crudos.
|
||||||
|
//
|
||||||
|
// StringPool.clear() + rebuild siempre que el snapshot se reconstruya,
|
||||||
|
// para mantener coherencia de indices entre pool y snapshot.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Snapshot invalido si:
|
||||||
|
// 1. El puntero de cells cambio (nuevos datos).
|
||||||
|
// 2. El pool fue limpiado despues del build (st.string_pool es un nuevo State
|
||||||
|
// o fue cleared externamente): pool_size_built != strings.size().
|
||||||
|
// Esto cubre el caso "begin_scenario crea nuevo State con pool vacio pero
|
||||||
|
// same cells pointer" — sin este check los str_ids apuntarian a un pool
|
||||||
|
// vacio y se crashearia en pool.at(str_ids[r]).
|
||||||
|
const bool snap_stale = (U.snapshot.last_cells_ptr != cells) ||
|
||||||
|
(U.snapshot.pool_size_built !=
|
||||||
|
(uint32_t)st.string_pool.strings.size() &&
|
||||||
|
!U.snapshot.cols.empty());
|
||||||
|
if (snap_stale) {
|
||||||
|
// Invalidar y reconstruir.
|
||||||
|
U.snapshot.last_cells_ptr = cells;
|
||||||
|
U.snapshot.cols.clear();
|
||||||
|
U.snapshot.cols.resize((size_t)orig_cols);
|
||||||
|
|
||||||
|
// Limpiar el StringPool del State para este rebuild.
|
||||||
|
st.string_pool.clear();
|
||||||
|
// Reservar capacidad estimada para evitar reallocs que invalidarian
|
||||||
|
// los string_view del mapa interno del pool.
|
||||||
|
// Estimamos hasta row_count valores unicos por columna string (worst case).
|
||||||
|
// En practica muchos menos; reserve no aloca el doble automatico.
|
||||||
|
st.string_pool.strings.reserve((size_t)(row_count < 65536 ? row_count : 65536));
|
||||||
|
|
||||||
|
for (int c = 0; c < orig_cols; ++c) {
|
||||||
|
ColumnSnapshot& cs = U.snapshot.cols[(size_t)c];
|
||||||
|
// Detectar tipo efectivo para esta columna.
|
||||||
|
ColumnType d = declared_types ? declared_types[c] : ColumnType::Auto;
|
||||||
|
ColumnType ct = effective_type(d, cells, row_count, orig_cols, c);
|
||||||
|
cs.type = ct;
|
||||||
|
|
||||||
|
if (ct == ColumnType::Int) {
|
||||||
|
cs.i64.resize((size_t)row_count);
|
||||||
|
for (int r = 0; r < row_count; ++r) {
|
||||||
|
const char* sv = cells[(size_t)(r * orig_cols + c)];
|
||||||
|
double tmp = 0.0;
|
||||||
|
if (sv && parse_number(sv, tmp)) {
|
||||||
|
cs.i64[(size_t)r] = (int64_t)tmp;
|
||||||
|
} else {
|
||||||
|
cs.i64[(size_t)r] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ct == ColumnType::Float) {
|
||||||
|
cs.f64.resize((size_t)row_count);
|
||||||
|
for (int r = 0; r < row_count; ++r) {
|
||||||
|
const char* sv = cells[(size_t)(r * orig_cols + c)];
|
||||||
|
double tmp = 0.0;
|
||||||
|
if (sv && parse_number(sv, tmp)) {
|
||||||
|
cs.f64[(size_t)r] = tmp;
|
||||||
|
} else {
|
||||||
|
cs.f64[(size_t)r] = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// String, Bool, Date, Json, Auto → intern as string.
|
||||||
|
// Cardinality cap: if >2048 unique values seen in first 25% of rows,
|
||||||
|
// skip interning this column (high-cardinality cols like timestamps
|
||||||
|
// offer no compression benefit and hurt cache). str_ids stays empty;
|
||||||
|
// compare_snap falls back to raw cells for this column.
|
||||||
|
static const int kCardinalityCap = 2048;
|
||||||
|
const int sample_n = (row_count < 4) ? row_count : (row_count / 4);
|
||||||
|
uint32_t pool_before = (uint32_t)st.string_pool.strings.size();
|
||||||
|
bool skip_intern = false;
|
||||||
|
for (int r = 0; r < sample_n; ++r) {
|
||||||
|
const char* sv = cells[(size_t)(r * orig_cols + c)];
|
||||||
|
std::string_view svv = sv ? std::string_view(sv) : std::string_view("");
|
||||||
|
st.string_pool.intern(svv);
|
||||||
|
if ((int)st.string_pool.strings.size() - (int)pool_before > kCardinalityCap) {
|
||||||
|
skip_intern = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skip_intern) {
|
||||||
|
// Rollback pool entries added during sample (remove tail entries).
|
||||||
|
// Simpler: just leave pool with sample entries and mark col as no-intern
|
||||||
|
// by keeping str_ids empty. Pool entries are harmless (amortized).
|
||||||
|
// cs.str_ids stays empty → compare_snap falls through to raw cells.
|
||||||
|
cs.str_ids.clear();
|
||||||
|
} else {
|
||||||
|
// Low cardinality: intern all rows.
|
||||||
|
cs.str_ids.resize((size_t)row_count);
|
||||||
|
// Fill already-sampled rows from pool (intern is idempotent).
|
||||||
|
for (int r = 0; r < sample_n; ++r) {
|
||||||
|
const char* sv = cells[(size_t)(r * orig_cols + c)];
|
||||||
|
std::string_view svv = sv ? std::string_view(sv) : std::string_view("");
|
||||||
|
cs.str_ids[(size_t)r] = st.string_pool.intern(svv);
|
||||||
|
}
|
||||||
|
// Intern remaining rows.
|
||||||
|
for (int r = sample_n; r < row_count; ++r) {
|
||||||
|
const char* sv = cells[(size_t)(r * orig_cols + c)];
|
||||||
|
std::string_view svv = sv ? std::string_view(sv) : std::string_view("");
|
||||||
|
cs.str_ids[(size_t)r] = st.string_pool.intern(svv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Record pool size at end of build so validity check is accurate.
|
||||||
|
U.snapshot.pool_size_built = (uint32_t)st.string_pool.strings.size();
|
||||||
|
}
|
||||||
|
|
||||||
// Build eff_headers / src_for_eff / eff_types para STAGE 0.
|
// Build eff_headers / src_for_eff / eff_types para STAGE 0.
|
||||||
std::vector<const char*> eff_headers(eff_cols);
|
std::vector<const char*> eff_headers(eff_cols);
|
||||||
std::vector<int> src_for_eff(eff_cols);
|
std::vector<int> src_for_eff(eff_cols);
|
||||||
@@ -991,7 +1285,10 @@ void render(const char* id,
|
|||||||
st_tmp.stages[0].sorts.push_back({tmp, sc0.desc});
|
st_tmp.stages[0].sorts.push_back({tmp, sc0.desc});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto visible_rows = compute_visible_rows(cells, row_count, orig_cols, st_tmp);
|
// Issue 0133 — Change 3: use snapshot-aware overload when snapshot is valid.
|
||||||
|
auto visible_rows = (U.snapshot.last_cells_ptr == cells && !U.snapshot.cols.empty())
|
||||||
|
? compute_visible_rows(cells, row_count, orig_cols, st_tmp, U.snapshot, st.string_pool)
|
||||||
|
: compute_visible_rows(cells, row_count, orig_cols, st_tmp);
|
||||||
|
|
||||||
int visible_cols = 0;
|
int visible_cols = 0;
|
||||||
for (int k = 0; k < eff_cols; ++k) if (st.col_visible[k]) ++visible_cols;
|
for (int k = 0; k < eff_cols; ++k) if (st.col_visible[k]) ++visible_cols;
|
||||||
@@ -1038,7 +1335,13 @@ void render(const char* id,
|
|||||||
if (!st.col_visible[c]) continue;
|
if (!st.col_visible[c]) continue;
|
||||||
int src = src_for_eff[c];
|
int src = src_for_eff[c];
|
||||||
if (!first) out += ',';
|
if (!first) out += ',';
|
||||||
out += csv_escape(cells[r * orig_cols + src]);
|
// Issue 0133 — Change 3: use snapshot for orig cols.
|
||||||
|
char tmp32[32];
|
||||||
|
const char* cv = (src < orig_cols && src < (int)U.snapshot.cols.size())
|
||||||
|
? snap_cell(r, src, U.snapshot, st.string_pool, tmp32)
|
||||||
|
: nullptr;
|
||||||
|
if (!cv) cv = cells[r * orig_cols + src];
|
||||||
|
out += csv_escape(cv);
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
out += '\n';
|
out += '\n';
|
||||||
@@ -1091,6 +1394,8 @@ void render(const char* id,
|
|||||||
for (int r : visible_rows) {
|
for (int r : visible_rows) {
|
||||||
for (int c : vcols) {
|
for (int c : vcols) {
|
||||||
if (c < orig_cols) {
|
if (c < orig_cols) {
|
||||||
|
// Raw pointer: materialization copies to string anyway — snapshot
|
||||||
|
// path offers no benefit here and adds snprintf overhead for Int/Float.
|
||||||
const char* p = cells[r * orig_cols + c];
|
const char* p = cells[r * orig_cols + c];
|
||||||
so_main.cell_backing.emplace_back(p ? p : "");
|
so_main.cell_backing.emplace_back(p ? p : "");
|
||||||
} else {
|
} else {
|
||||||
@@ -1161,6 +1466,7 @@ void render(const char* id,
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int src = src_for_eff[c];
|
int src = src_for_eff[c];
|
||||||
|
// Raw pointer: materialization copies to string anyway.
|
||||||
const char* p = cells[r * orig_cols + src];
|
const char* p = cells[r * orig_cols + src];
|
||||||
s0_backing.emplace_back(p ? p : "");
|
s0_backing.emplace_back(p ? p : "");
|
||||||
}
|
}
|
||||||
@@ -1213,7 +1519,10 @@ void render(const char* id,
|
|||||||
st_tmp.stages[0].sorts.push_back({tmp, sc0.desc});
|
st_tmp.stages[0].sorts.push_back({tmp, sc0.desc});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto vrows = compute_visible_rows(cells, row_count, orig_cols, st_tmp);
|
// Issue 0133 — Change 3: use snapshot-aware filter/sort when available.
|
||||||
|
auto vrows = (U.snapshot.last_cells_ptr == cells && !U.snapshot.cols.empty())
|
||||||
|
? compute_visible_rows(cells, row_count, orig_cols, st_tmp, U.snapshot, st.string_pool)
|
||||||
|
: compute_visible_rows(cells, row_count, orig_cols, st_tmp);
|
||||||
|
|
||||||
// Materializar stage0 output: cells (eff_cols) con derived evaluadas.
|
// Materializar stage0 output: cells (eff_cols) con derived evaluadas.
|
||||||
std::vector<std::string> mat_backing;
|
std::vector<std::string> mat_backing;
|
||||||
@@ -1223,10 +1532,9 @@ void render(const char* id,
|
|||||||
|
|
||||||
for (int r : vrows) {
|
for (int r : vrows) {
|
||||||
for (int c = 0; c < eff_cols; ++c) {
|
for (int c = 0; c < eff_cols; ++c) {
|
||||||
const char* p;
|
|
||||||
std::string buf;
|
|
||||||
if (c < orig_cols) {
|
if (c < orig_cols) {
|
||||||
p = cells[r * orig_cols + c];
|
// Raw pointer: materialization copies to string anyway.
|
||||||
|
const char* p = cells[r * orig_cols + c];
|
||||||
mat_backing.emplace_back(p ? p : "");
|
mat_backing.emplace_back(p ? p : "");
|
||||||
} else {
|
} else {
|
||||||
const DerivedColumn& d = stage0.derived[c - orig_cols];
|
const DerivedColumn& d = stage0.derived[c - orig_cols];
|
||||||
@@ -1249,7 +1557,7 @@ void render(const char* id,
|
|||||||
lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err));
|
lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// retipo puro
|
// retipo puro — raw pointer from orig cells.
|
||||||
int src = d.source_col;
|
int src = d.source_col;
|
||||||
const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : "";
|
const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : "";
|
||||||
mat_backing.emplace_back(sp ? sp : "");
|
mat_backing.emplace_back(sp ? sp : "");
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ uses_types:
|
|||||||
- ColorRule_cpp_core
|
- ColorRule_cpp_core
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: "error_go_core"
|
error_type: ""
|
||||||
imports:
|
imports:
|
||||||
- imgui.h
|
- imgui.h
|
||||||
- app_base.h
|
- app_base.h
|
||||||
@@ -261,6 +261,10 @@ No hay tests unitarios directos: `render()` requiere ImGui + ImPlot context acti
|
|||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
<!-- ANADIR CUANDO PASE EL GATE 0133:
|
||||||
|
v1.0.0-stable (YYYY-MM-DD) — finalize: columnar snapshot + string interning + lazy filter/sort + display cache + OpenMP compute. Bench 10M rows >=60fps. API publica frozen: render(), TableInput, State, TableEvent, ColumnSpec, ColorRule no admiten breaking changes sin major bump. Ver MIGRATION.md para contratos exactos.
|
||||||
|
-->
|
||||||
|
|
||||||
v1.1.0 (2026-05-15) — declarative CellRenderer (Badge/Progress/Duration/Icon) via TableInput.column_specs sidecar. Back-compat preservado: apps existentes sin column_specs siguen funcionando sin cambios.
|
v1.1.0 (2026-05-15) — declarative CellRenderer (Badge/Progress/Duration/Icon) via TableInput.column_specs sidecar. Back-compat preservado: apps existentes sin column_specs siguen funcionando sin cambios.
|
||||||
|
|
||||||
v1.2.0 (2026-05-15) — Button renderer + event sink (ButtonClick/RowDoubleClick/RowRightClick) + tooltip per cell + column_specs persisted in TQL (aux_column_specs roundtrip). Back-compat preserved: events_out=nullptr by default; existing render() callers unchanged.
|
v1.2.0 (2026-05-15) — Button renderer + event sink (ButtonClick/RowDoubleClick/RowRightClick) + tooltip per cell + column_specs persisted in TQL (aux_column_specs roundtrip). Back-compat preserved: events_out=nullptr by default; existing render() callers unchanged.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// Este header lo incluyen SOLO los .cpp del modulo (data_table.cpp + sus 6 sub-funciones).
|
// Este header lo incluyen SOLO los .cpp del modulo (data_table.cpp + sus 6 sub-funciones).
|
||||||
//
|
//
|
||||||
// Issue 0107c — split de data_table.cpp (4777 LOC) en 6 sub-funciones del registry.
|
// Issue 0107c — split de data_table.cpp (4777 LOC) en 6 sub-funciones del registry.
|
||||||
|
// Issue 0133 — columnar snapshot + string interning (Changes 1+2).
|
||||||
//
|
//
|
||||||
// Provee:
|
// Provee:
|
||||||
// 1. `UiState` agregador (composicion de sub-states declarados en los .h de
|
// 1. `UiState` agregador (composicion de sub-states declarados en los .h de
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
// `effective_type`, `view_mode_label`, `join_strategy_label`, etc.
|
// `effective_type`, `view_mode_label`, `join_strategy_label`, etc.
|
||||||
// 4. Forward refs de funciones internas que cruzan sub-funciones (ej. el
|
// 4. Forward refs de funciones internas que cruzan sub-funciones (ej. el
|
||||||
// draw_header_menu de chips llama draw_color_rule_menu de color_rules).
|
// draw_header_menu de chips llama draw_color_rule_menu de color_rules).
|
||||||
|
// 5. `ColumnSnapshot` / `SnapshotCache` — snapshot columnar interno (issue 0133).
|
||||||
//
|
//
|
||||||
// Politica:
|
// Politica:
|
||||||
// - Si un helper se usa SOLO dentro de UNA sub-funcion -> queda `static` en su .cpp.
|
// - Si un helper se usa SOLO dentro de UNA sub-funcion -> queda `static` en su .cpp.
|
||||||
@@ -22,6 +24,63 @@
|
|||||||
//
|
//
|
||||||
// API publica externa = data_table/data_table.h (intacta tras refactor).
|
// API publica externa = data_table/data_table.h (intacta tras refactor).
|
||||||
// API interna del modulo = este header.
|
// API interna del modulo = este header.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DISEÑO: Snapshot columnar + String Interning (issue 0133)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// MOTIVACION
|
||||||
|
// El layout de datos de entrada es row-major: `cells[row * cols + col]`
|
||||||
|
// (punteros a C-strings en el `TableInput` del caller). Acceder a una
|
||||||
|
// columna entera (para filtrar, ordenar, colorear, calcular stats) requiere
|
||||||
|
// saltar `cols` posiciones en memoria por fila — mal para cache a 10M+ filas.
|
||||||
|
// El snapshot convierte a column-major una sola vez por frame y amortiza el
|
||||||
|
// coste entre filter / sort / color_rules / stats.
|
||||||
|
//
|
||||||
|
// SNAPSHOT COLUMNAR — `ColumnSnapshot` / `SnapshotCache`
|
||||||
|
// - `SnapshotCache` vive en `UiState` (singleton thread_local de este modulo).
|
||||||
|
// - Contiene un vector de `ColumnSnapshot`, uno por columna efectiva de la
|
||||||
|
// tabla DESPUES de joins pero ANTES de stages (= stage-0 input).
|
||||||
|
// - `SnapshotCache::last_cells_ptr` guarda el puntero de `cells` del ultimo
|
||||||
|
// rebuild. Si el puntero cambia (o es la primera llamada), se reconstruye
|
||||||
|
// el snapshot completo. Esto sigue exactamente el patron de `stats_last_cells`
|
||||||
|
// en `State` (data_table_types.h:399).
|
||||||
|
// - String columns (ColumnType::String / Auto sin numero) almacenan indices
|
||||||
|
// uint32_t al `StringPool` del `State` correspondiente.
|
||||||
|
// - Int columns almacenan int64_t parseados una sola vez via `parse_number`.
|
||||||
|
// - Float columns almacenan double parseados una sola vez via `parse_number`.
|
||||||
|
// - Si `parse_number` falla en una celda que deberia ser Int o Float, la celda
|
||||||
|
// se trata como 0 / 0.0. Este comportamiento es consistente con `compare()`
|
||||||
|
// y el sort actual que ya llaman `parse_number` per-compare.
|
||||||
|
//
|
||||||
|
// STRING INTERNING — `StringPool` en `State`
|
||||||
|
// - `StringPool` vive en `State` (NOT global, NOT singleton) para que cada
|
||||||
|
// instancia de tabla tenga su propio pool sin interferencia.
|
||||||
|
// - `intern(sv)` inserta la cadena si no esta y devuelve su indice uint32_t.
|
||||||
|
// Usa `unordered_map<string_view, uint32_t>` con la `string_view` apuntando
|
||||||
|
// al `strings[i]` del vector (el vector se reserva antes del rebuild para
|
||||||
|
// evitar reallocs que invaliden los string_views del mapa).
|
||||||
|
// - En datasets tipicos (60-70% de strings repetidos) la reduccion de RAM
|
||||||
|
// es de 60-70% en la columna interned vs copias planas.
|
||||||
|
//
|
||||||
|
// INVARIANTES
|
||||||
|
// 1. Pointer-identity: si `cells == last_cells_ptr`, el snapshot es valido
|
||||||
|
// para este frame. Cambio de puntero => rebuild completo.
|
||||||
|
// 2. row→snapshot_row: el indice de fila en el snapshot es IDENTICO al indice
|
||||||
|
// de fila del `TableInput` original (no hay reordenacion en el snapshot).
|
||||||
|
// `TableEvent.row` del caller sigue siendo indice en `TableInput`.
|
||||||
|
// 3. El snapshot es input de stage-0. Los stages sucesivos (compute_stage)
|
||||||
|
// operan sobre `StageOutput` materializado y NO consultan el snapshot
|
||||||
|
// directamente — el snapshot solo alimenta la ruta stage-0 de render().
|
||||||
|
// 4. `stats_last_cells` y snapshot son independientes: `stats_last_cells`
|
||||||
|
// ya existia antes de issue 0133 y permanece como sentinel propio del
|
||||||
|
// cache de stats. El snapshot tiene su propio `last_cells_ptr`.
|
||||||
|
// Ambos pueden diferir temporalmente si `stats_cache` invalida por
|
||||||
|
// filtro (hash de filtros) pero el snapshot sigue valido por puntero.
|
||||||
|
// 5. `StringPool` se limpia (clear()) en cada rebuild del snapshot para
|
||||||
|
// mantener coherencia: los indices del snapshot siempre corresponden
|
||||||
|
// al pool del mismo frame.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#include "core/data_table_types.h"
|
#include "core/data_table_types.h"
|
||||||
#include "core/auto_detect_type.h"
|
#include "core/auto_detect_type.h"
|
||||||
@@ -40,6 +99,28 @@
|
|||||||
|
|
||||||
namespace data_table {
|
namespace data_table {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ColumnSnapshot — snapshot de una columna en memoria columnar.
|
||||||
|
// Creado una vez por frame al detectar cambio de puntero en `cells`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct ColumnSnapshot {
|
||||||
|
ColumnType type; // tipo efectivo inferido (post auto_detect)
|
||||||
|
std::vector<uint32_t> str_ids; // para String/Auto: indices al StringPool
|
||||||
|
std::vector<int64_t> i64; // para Int: valores parseados
|
||||||
|
std::vector<double> f64; // para Float: valores parseados
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SnapshotCache — vive en UiState (thread_local singleton).
|
||||||
|
// Un snapshot cubre TODAS las columnas efectivas de la tabla activa
|
||||||
|
// (post-join, pre-stages). Se invalida por pointer-identity de `cells`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct SnapshotCache {
|
||||||
|
const char* const* last_cells_ptr = nullptr; // sentinel de invalidacion por ptr
|
||||||
|
uint32_t pool_size_built = 0; // strings.size() cuando se construyo
|
||||||
|
std::vector<ColumnSnapshot> cols; // un entry por columna efectiva
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UiState — singleton thread_local del modulo. Agrupa:
|
// UiState — singleton thread_local del modulo. Agrupa:
|
||||||
// (a) Sub-states declarados en headers de sub-funciones (AskAiState etc.).
|
// (a) Sub-states declarados en headers de sub-funciones (AskAiState etc.).
|
||||||
@@ -120,6 +201,11 @@ struct UiState {
|
|||||||
|
|
||||||
// ----- Export path (chips export action) -----
|
// ----- Export path (chips export action) -----
|
||||||
std::string last_export_path;
|
std::string last_export_path;
|
||||||
|
|
||||||
|
// ----- Columnar snapshot (issue 0133, Change 1) -----
|
||||||
|
// Invalida cuando cells pointer cambia entre frames.
|
||||||
|
// Usado en render() stage-0 path para filter/sort/color_rules/stats.
|
||||||
|
SnapshotCache snapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Singleton accessor. Definido en data_table.cpp (entrypoint).
|
// Singleton accessor. Definido en data_table.cpp (entrypoint).
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: flow
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "0.1.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
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"`
|
||||||
|
Name string `yaml:"name,omitempty"`
|
||||||
|
Priority string `yaml:"priority,omitempty"`
|
||||||
|
FilePath string `yaml:"-"`
|
||||||
|
MtimeNs int64 `yaml:"-"`
|
||||||
|
}
|
||||||
|
description: "Frontmatter YAML de un archivo dev/flows/*.md. Campos de runtime (FilePath, MtimeNs) no se serializan en YAML."
|
||||||
|
tags: [flow, frontmatter, yaml, kanban, dev-ux, registry]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/flow_type.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
f := infra.Flow{
|
||||||
|
ID: "0001",
|
||||||
|
Name: "hn-top-stories",
|
||||||
|
Status: "pending",
|
||||||
|
Tags: []string{"scraping", "news"},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Producido por `scan_flows_dir_go_infra`. Los flows del registry usan campos variados en su frontmatter — el struct cubre el subconjunto comun: id/name/title/status/kind/tags/priority. Campos desconocidos se ignoran silenciosamente por yaml.Unmarshal.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: fs_event
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "0.1.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
type FsEvent struct {
|
||||||
|
Path string
|
||||||
|
Op string // "create" | "write" | "remove" | "rename"
|
||||||
|
}
|
||||||
|
description: "Evento del watcher de sistema de archivos. Op es uno de: create, write, remove, rename."
|
||||||
|
tags: [watcher, fsnotify, event, filesystem, kanban, dev-ux]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/fs_event_type.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Recibido desde el canal de watch_dir_fsnotify_go_infra:
|
||||||
|
ev := infra.FsEvent{
|
||||||
|
Path: "/home/lucas/fn_registry/dev/issues/0130a-kanban-cpp-v2-parser.md",
|
||||||
|
Op: "write",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Producido por `watch_dir_fsnotify_go_infra`. El canal emite un evento por archivo afectado tras el debounce de 200ms. Si se producen multiples operaciones sobre el mismo path en la ventana de debounce, se emite solo la ultima operacion.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: issue
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "0.1.0"
|
||||||
|
algebraic: product
|
||||||
|
definition: |
|
||||||
|
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"`
|
||||||
|
FilePath string `yaml:"-"`
|
||||||
|
MtimeNs int64 `yaml:"-"`
|
||||||
|
Completed bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
description: "Frontmatter YAML de un archivo dev/issues/*.md. Campos de runtime (FilePath, MtimeNs, Completed) no se serializan en YAML."
|
||||||
|
tags: [issue, frontmatter, yaml, kanban, dev-ux, registry]
|
||||||
|
uses_types: []
|
||||||
|
file_path: "functions/infra/issue_type.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
iss := infra.Issue{
|
||||||
|
ID: "0130",
|
||||||
|
Title: "Kanban C++ v2",
|
||||||
|
Status: "pendiente",
|
||||||
|
Priority: "alta",
|
||||||
|
Domain: []string{"cpp-stack", "apps-infra"},
|
||||||
|
Scope: "multi-app",
|
||||||
|
Tags: []string{"kanban", "cpp"},
|
||||||
|
Created: "2026-05-22",
|
||||||
|
Updated: "2026-05-22",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Producido por `parse_issue_md_go_infra`. Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` se deserializan como `[]string` — si el YAML los omite, quedan como slice vacio (no nil). `Completed` se deduce del path (contiene `/completed/`), no del frontmatter.
|
||||||
Reference in New Issue
Block a user