diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a72df08..d43ed59 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -59,8 +59,10 @@ tools/ tool registry + tool implementations (subpackages) internal/config/ schema.go + loader.go cmd/launcher/ entrypoint principal (rulesRegistry) cmd/agentctl/ CLI de gestion +crons/ catálogo de automatizaciones nombradas (schedule.yaml + prompts) dev-scripts/server/ start, stop, restart, ps, logs, dashboard dev-scripts/agent/ new, register, verify, avatar, remove, list +dev-scripts/cron/ new, list, apply — gestión de automatizaciones cron dev-scripts/e2e/ install, run — E2E tests con Playwright e2e/ proyecto Node.js con Playwright (tests, fixtures, Element Web) ``` diff --git a/.claude/commands/fix-issue.md b/.claude/commands/fix-issue.md index 79262ff..2022d32 100644 --- a/.claude/commands/fix-issue.md +++ b/.claude/commands/fix-issue.md @@ -23,13 +23,24 @@ Se necesita el issue objetivo. Si no se proporciona, preguntar. - tareas/fases - arquitectura y limites (pure core / impure shell) -3. Crear rama de trabajo usando `/git-branch`: +3. Crear rama de trabajo (inline, sin invocar `/git-branch`): -```text -/git-branch +Verificar la rama actual: + +```bash +git branch --show-current ``` -Usar formato `issue/-` y nunca trabajar en `master`. +- Si ya estamos en `issue/-` que coincide con el issue → continuar directamente a paso 4. +- Si estamos en `master` o cualquier otra rama → crear la rama: + +```bash +git checkout master +git pull --rebase +git checkout -b issue/- +``` + +Nunca trabajar directamente en `master`. 4. Planificar con `TodoWrite`: diff --git a/crons/README.md b/crons/README.md new file mode 100644 index 0000000..1e66112 --- /dev/null +++ b/crons/README.md @@ -0,0 +1,73 @@ +# crons/ — Catálogo de automatizaciones + +Directorio central de automatizaciones nombradas para los agentes. Cada subdirectorio es una +automatización reutilizable que puede aplicarse a uno o más agentes. + +## Estructura de una automatización + +``` +crons// + schedule.yaml # spec: descripción, cron por defecto, acción + prompts/ + message.md # plantilla de mensaje (send_message) + prompt.md # prompt para el LLM (llm_prompt) +``` + +## Convención de `schedule.yaml` + +```yaml +# Metadata +name: nombre-de-la-automatizacion +description: "Descripción breve" + +# Cron por defecto (el agente puede sobreescribir en su config.yaml) +default_cron: "0 9 * * *" + +# Acción +action: + kind: send_message # send_message | llm_prompt + template: prompts/message.md # relativo a la raíz del proyecto + +# Sala por defecto (opcional; el agente debe sobreescribir con output_room) +default_output_room: "" +``` + +> **Nota**: `template` es relativo a la **raíz del proyecto**, no a la carpeta de la automatización. +> Usa siempre la ruta completa desde la raíz: `crons//prompts/message.md`. + +## Automatizaciones disponibles + +| Nombre | Tipo | Cron por defecto | Descripción | +|--------|------|-----------------|-------------| +| `good-morning` | `send_message` | `0 9 * * *` | Saludo de buenos días | +| `daily-summary` | `llm_prompt` | `0 18 * * *` | Resumen diario del equipo | + +## Scripts de gestión + +```bash +# Crear nueva automatización (interactivo) +./dev-scripts/cron/new.sh + +# Listar todas las automatizaciones con descripción +./dev-scripts/cron/list.sh + +# Aplicar automatización a un agente (parchea config.yaml) +./dev-scripts/cron/apply.sh +``` + +## Cómo añadir manualmente a un agente + +En `agents//config.yaml`: + +```yaml +schedules: + - name: good-morning + cron: "0 9 * * *" + output_room: "!TUROOM:matrix-af2f3d.organic-machine.com" + action: + kind: send_message + template: "crons/good-morning/prompts/message.md" +``` + +Ajusta `output_room` con la sala real del agente. El campo `cron` puede sobreescribir el +`default_cron` del catálogo. diff --git a/crons/daily-summary/prompts/prompt.md b/crons/daily-summary/prompts/prompt.md new file mode 100644 index 0000000..1884df8 --- /dev/null +++ b/crons/daily-summary/prompts/prompt.md @@ -0,0 +1,7 @@ +Genera un breve resumen del día para el equipo. Incluye: + +- Un saludo de cierre del día +- Un recordatorio de mantener el ánimo y la energía para mañana +- Una frase motivadora corta + +Responde en español, de forma amigable y concisa (máximo 3-4 líneas). diff --git a/crons/daily-summary/schedule.yaml b/crons/daily-summary/schedule.yaml new file mode 100644 index 0000000..a56c006 --- /dev/null +++ b/crons/daily-summary/schedule.yaml @@ -0,0 +1,15 @@ +# Automatización: daily-summary +name: daily-summary +description: "Resumen diario generado por el LLM" + +# Cron por defecto: cada día a las 18:00 +default_cron: "0 18 * * *" + +# Acción +action: + kind: llm_prompt + # Relativo a la raíz del proyecto + template: crons/daily-summary/prompts/prompt.md + +# Sala de salida por defecto (vacío = el agente debe configurar output_room) +default_output_room: "" diff --git a/crons/good-morning/prompts/message.md b/crons/good-morning/prompts/message.md new file mode 100644 index 0000000..18210bc --- /dev/null +++ b/crons/good-morning/prompts/message.md @@ -0,0 +1,3 @@ +¡Buenos días! 🌅 + +Espero que tengan un excelente día. Estoy aquí si necesitan ayuda con algo. diff --git a/crons/good-morning/schedule.yaml b/crons/good-morning/schedule.yaml new file mode 100644 index 0000000..bf96d91 --- /dev/null +++ b/crons/good-morning/schedule.yaml @@ -0,0 +1,15 @@ +# Automatización: good-morning +name: good-morning +description: "Saludo de buenos días en una sala" + +# Cron por defecto: cada día a las 9:00 +default_cron: "0 9 * * *" + +# Acción +action: + kind: send_message + # Relativo a la raíz del proyecto + template: crons/good-morning/prompts/message.md + +# Sala de salida por defecto (vacío = el agente debe configurar output_room) +default_output_room: "" diff --git a/dev-scripts/cron/README.md b/dev-scripts/cron/README.md new file mode 100644 index 0000000..9d08beb --- /dev/null +++ b/dev-scripts/cron/README.md @@ -0,0 +1,45 @@ +# dev-scripts/cron/ — Gestión de automatizaciones cron + +Scripts para crear, listar y aplicar automatizaciones del catálogo `crons/`. + +## Scripts + +### `new.sh` — Scaffolder interactivo + +Crea una nueva automatización en `crons//`: + +```bash +./dev-scripts/cron/new.sh +``` + +Pregunta: nombre, descripción, tipo de acción (`send_message` o `llm_prompt`) y cron expression. +Crea `schedule.yaml` y el archivo de prompt/mensaje vacío. +Imprime el bloque YAML listo para añadir a `config.yaml`. + +### `list.sh` — Listar automatizaciones + +Lista todas las automatizaciones del catálogo con nombre, tipo, cron y descripción: + +```bash +./dev-scripts/cron/list.sh +``` + +### `apply.sh` — Aplicar a un agente + +Añade una automatización al `config.yaml` de un agente: + +```bash +./dev-scripts/cron/apply.sh + +# Ejemplo: +./dev-scripts/cron/apply.sh good-morning assistant-bot +``` + +Usa `yq` si está disponible para parchear el YAML directamente. +Si `yq` no está instalado, imprime el bloque YAML para copiar a mano. + +Recuerda editar `output_room` en `config.yaml` con la sala real del agente. + +## Catálogo + +Las automatizaciones viven en `crons/`. Ver `crons/README.md` para la documentación completa. diff --git a/dev-scripts/cron/apply.sh b/dev-scripts/cron/apply.sh new file mode 100755 index 0000000..9a9467e --- /dev/null +++ b/dev-scripts/cron/apply.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# apply.sh +# Añade la automatización al config.yaml del agente . +# Usa yq si está disponible; en caso contrario imprime el bloque YAML para copiar a mano. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [[ $# -ne 2 ]]; then + echo "Uso: $0 " >&2 + echo "Ejemplo: $0 good-morning assistant-bot" >&2 + exit 1 +fi + +NAME="$1" +AGENT_ID="$2" + +SCHEDULE_FILE="$REPO_ROOT/crons/$NAME/schedule.yaml" +AGENT_CONFIG="$REPO_ROOT/agents/$AGENT_ID/config.yaml" + +if [[ ! -f "$SCHEDULE_FILE" ]]; then + echo "Error: no existe crons/$NAME/schedule.yaml" >&2 + echo "Usa ./dev-scripts/cron/list.sh para ver las automatizaciones disponibles." >&2 + exit 1 +fi + +if [[ ! -f "$AGENT_CONFIG" ]]; then + echo "Error: no existe agents/$AGENT_ID/config.yaml" >&2 + exit 1 +fi + +# Parse schedule.yaml fields +kind="" +template="" +cron_expr="" + +while IFS= read -r line; do + case "$line" in + " kind:"*) kind="${line#*kind:}"; kind="${kind// /}" ;; + " template:"*) template="${line#*template:}"; template="${template# }" ;; + default_cron:*) cron_expr="${line#default_cron:}"; cron_expr="${cron_expr# }"; cron_expr="${cron_expr//\"/}" ;; + esac +done < "$SCHEDULE_FILE" + +if [[ -z "$kind" || -z "$template" || -z "$cron_expr" ]]; then + echo "Error: schedule.yaml incompleto (falta kind, template o default_cron)." >&2 + exit 1 +fi + +# Build YAML block +YAML_BLOCK=" - name: $NAME + cron: \"$cron_expr\" + output_room: \"\" # TODO: reemplaza con la sala real del agente + action: + kind: $kind + template: \"$template\"" + +# Try yq first +if command -v yq &>/dev/null; then + # Check if schedules key already has this entry + existing=$(yq ".schedules // [] | .[] | select(.name == \"$NAME\") | .name" "$AGENT_CONFIG" 2>/dev/null || true) + if [[ -n "$existing" ]]; then + echo "Advertencia: el agente $AGENT_ID ya tiene un schedule llamado '$NAME'. No se añade de nuevo." + exit 0 + fi + + # Append using yq + yq -i ".schedules += [{\"name\": \"$NAME\", \"cron\": \"$cron_expr\", \"output_room\": \"\", \"action\": {\"kind\": \"$kind\", \"template\": \"$template\"}}]" "$AGENT_CONFIG" + echo "✓ Añadido schedule '$NAME' a agents/$AGENT_ID/config.yaml" + echo "→ Edita output_room en agents/$AGENT_ID/config.yaml para apuntar a la sala correcta." +else + echo "yq no está disponible. Añade manualmente el siguiente bloque a agents/$AGENT_ID/config.yaml:" + echo "" + echo "schedules:" + echo "$YAML_BLOCK" + echo "" + echo "→ Edita output_room para apuntar a la sala correcta del agente." +fi diff --git a/dev-scripts/cron/list.sh b/dev-scripts/cron/list.sh new file mode 100755 index 0000000..3ce5119 --- /dev/null +++ b/dev-scripts/cron/list.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# list.sh — Lista todas las automatizaciones del catálogo crons/ con nombre, tipo y descripción. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CRONS_DIR="$REPO_ROOT/crons" + +if [[ ! -d "$CRONS_DIR" ]]; then + echo "No se encontró el directorio crons/." >&2 + exit 1 +fi + +# Collect entries: name, kind, default_cron, description +entries=() +while IFS= read -r -d '' schedule_file; do + name="" + description="" + kind="" + cron_expr="" + + while IFS= read -r line; do + case "$line" in + name:*) name="${line#name:}"; name="${name// /}" ;; + description:*) description="${line#description:}"; description="${description# }"; description="${description#\"}"; description="${description%\"}" ;; + " kind:"*) kind="${line#*kind:}"; kind="${kind// /}" ;; + default_cron:*) cron_expr="${line#default_cron:}"; cron_expr="${cron_expr# }"; cron_expr="${cron_expr//\"/}" ;; + esac + done < "$schedule_file" + + if [[ -n "$name" ]]; then + entries+=("$name|$kind|$cron_expr|$description") + fi +done < <(find "$CRONS_DIR" -name "schedule.yaml" -print0 | sort -z) + +if [[ ${#entries[@]} -eq 0 ]]; then + echo "No hay automatizaciones en crons/." + exit 0 +fi + +# Print header +printf "%-22s %-15s %-15s %s\n" "NOMBRE" "TIPO" "CRON" "DESCRIPCIÓN" +printf "%-22s %-15s %-15s %s\n" "------" "----" "----" "-----------" + +for entry in "${entries[@]}"; do + IFS='|' read -r name kind cron_expr description <<< "$entry" + printf "%-22s %-15s %-15s %s\n" "$name" "$kind" "$cron_expr" "$description" +done diff --git a/dev-scripts/cron/new.sh b/dev-scripts/cron/new.sh new file mode 100755 index 0000000..dfb2f2a --- /dev/null +++ b/dev-scripts/cron/new.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# new.sh — Scaffolder interactivo para automatizaciones cron +# Crea crons//schedule.yaml y el archivo de prompt/mensaje vacío. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "=== Nueva automatización cron ===" +echo "" + +# Nombre +read -rp "Nombre de la automatización (ej: weekly-report): " NAME +NAME="${NAME// /-}" +NAME="${NAME,,}" # lowercase +if [[ -z "$NAME" ]]; then + echo "Error: el nombre no puede estar vacío." >&2 + exit 1 +fi +if [[ -d "$REPO_ROOT/crons/$NAME" ]]; then + echo "Error: ya existe crons/$NAME/" >&2 + exit 1 +fi + +# Descripción +read -rp "Descripción breve: " DESCRIPTION +if [[ -z "$DESCRIPTION" ]]; then + echo "Error: la descripción no puede estar vacía." >&2 + exit 1 +fi + +# Tipo de acción +echo "" +echo "Tipo de acción:" +echo " 1) send_message — envía un mensaje estático o plantilla" +echo " 2) llm_prompt — llama al LLM con un prompt y envía la respuesta" +read -rp "Selecciona [1/2]: " ACTION_TYPE_NUM +case "$ACTION_TYPE_NUM" in + 1) ACTION_KIND="send_message"; PROMPT_FILE="message.md" ;; + 2) ACTION_KIND="llm_prompt"; PROMPT_FILE="prompt.md" ;; + *) + echo "Error: selección inválida. Usa 1 o 2." >&2 + exit 1 + ;; +esac + +# Cron expression +DEFAULT_CRON="0 9 * * *" +read -rp "Cron expression [default: $DEFAULT_CRON]: " CRON_EXPR +CRON_EXPR="${CRON_EXPR:-$DEFAULT_CRON}" + +# Crear estructura +CRON_DIR="$REPO_ROOT/crons/$NAME" +PROMPTS_DIR="$CRON_DIR/prompts" +mkdir -p "$PROMPTS_DIR" + +# schedule.yaml +cat > "$CRON_DIR/schedule.yaml" </config.yaml:" +echo "" +echo " schedules:" +echo " - name: $NAME" +echo " cron: \"$CRON_EXPR\"" +echo " output_room: \"!TUROOM:matrix-af2f3d.organic-machine.com\"" +echo " action:" +echo " kind: $ACTION_KIND" +echo " template: \"crons/$NAME/prompts/$PROMPT_FILE\"" +echo "" +echo "O usa: ./dev-scripts/cron/apply.sh $NAME " diff --git a/dev/feature_flags.json b/dev/feature_flags.json index 45e9f37..3a43dad 100644 --- a/dev/feature_flags.json +++ b/dev/feature_flags.json @@ -5,6 +5,12 @@ "issue": "0019", "description": "Hardening contra prompt injection: deny-by-default en tools, SSRF protection, path traversal, allowlists", "added": "2026-03-07" + }, + "centralized-security-groups": { + "enabled": false, + "issue": "0024", + "description": "Sistema centralizado de grupos de usuarios y agentes para control de acceso; elimina security.roles y allowed_users por agente", + "added": "2026-03-08" } } } diff --git a/dev/issues/README.md b/dev/issues/README.md index 23bc6eb..69c3864 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -30,4 +30,9 @@ afectados y notas de implementacion. | 22a | E2E: Infraestructura base | [0022a-e2e-infra.md](completed/0022a-e2e-infra.md) | completado | | 22b | E2E: Auth fixtures y helpers | [0022b-e2e-auth-helpers.md](completed/0022b-e2e-auth-helpers.md) | completado | | 22c | E2E: Tests de agentes + docs | [0022c-e2e-agent-tests.md](completed/0022c-e2e-agent-tests.md) | completado | -| 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado | +| 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado | +| 24 | Grupos y permisos centralizados | [0024-centralized-security-groups.md](0024-centralized-security-groups.md) | pendiente | +| 24a | Security types: pkg/security/ | [0024a-security-types.md](0024a-security-types.md) | pendiente | +| 24b | Security loader: shell/security/ | [0024b-security-loader.md](0024b-security-loader.md) | pendiente | +| 24c | Security integration + cleanup | [0024c-security-integration.md](0024c-security-integration.md) | pendiente | +| 25 | Catálogo cron + scaffolder | [0025-cron-scaffolder.md](completed/0025-cron-scaffolder.md) | completado | diff --git a/dev/issues/completed/0025-cron-scaffolder.md b/dev/issues/completed/0025-cron-scaffolder.md new file mode 100644 index 0000000..2a7cfdd --- /dev/null +++ b/dev/issues/completed/0025-cron-scaffolder.md @@ -0,0 +1,169 @@ +# 0025 — Catálogo de automatizaciones cron + scaffolder + +## Objetivo + +Crear un directorio `crons/` como catálogo central de automatizaciones nombradas, y un conjunto de +scripts en `dev-scripts/cron/` para crear nuevas automatizaciones, listarlas y aplicarlas a agentes +sin editar YAML a mano. Evolución directa de la infraestructura creada en el issue 0005. + +## Contexto + +- `shell/cron/` ya implementa el scheduler con `send_message` y `llm_prompt` (issue 0005) +- Las automatizaciones se definen en cada `agents//config.yaml` bajo `schedules:`, lo que las + dispersa y dificulta reutilizarlas entre agentes +- No hay forma de crear una nueva automatización sin editar YAML a mano y conocer la estructura +- No existe un catálogo centralizado ni scripts de gestión +- Depende de: issue 0005 (completado) + +## Arquitectura + +``` +crons/ NEW — catálogo de automatizaciones nombradas + good-morning/ + schedule.yaml NEW — spec (description, cron, action, output_room por defecto) + prompts/ + message.md NEW — plantilla de mensaje + daily-summary/ + schedule.yaml NEW + prompts/ + prompt.md NEW + +dev-scripts/cron/ NEW — herramientas de gestión + new.sh NEW — scaffolder interactivo + list.sh NEW — listar automatizaciones con descripción + apply.sh NEW — añadir automatización a config de agente + +shell/cron/scheduler.go MODIFY — añadir Fire(name) para disparo manual en tests +shell/cron/actions.go MODIFY — pequeñas mejoras si surgen al escribir ejemplos +``` + +### Patrón pure core / impure shell + +- `pkg/` — sin cambios (no hay lógica pura nueva) +- `shell/cron/` — modificación mínima: añadir `Fire(ctx, sc)` para testing manual +- `crons/` — datos puros (YAML + Markdown), sin código Go +- `dev-scripts/cron/` — shell scripts impuros (leen/escriben filesystem, parchean YAML) + +### Convención de `crons//schedule.yaml` + +```yaml +# Metadata +name: good-morning +description: "Saludo de buenos días en una sala" + +# Schedule por defecto (el agente puede sobreescribir) +default_cron: "0 9 * * *" + +# Acción +action: + kind: send_message # send_message | llm_prompt + template: prompts/message.md # relativo a la carpeta de la automatización + +# Sala por defecto (opcional; el agente puede sobreescribir con output_room) +default_output_room: "" +``` + +Este archivo es solo **documentación + template**. El agente lo referencia en su `config.yaml` +usando la sección `schedules:` habitual; `apply.sh` automatiza ese paso. + +## Tareas + +### Fase 1: Estructura `crons/` y automatizaciones de ejemplo + +- [ ] **1.1** Crear `crons/` con un `README.md` que explique la convención +- [ ] **1.2** Crear `crons/good-morning/schedule.yaml` + `prompts/message.md` (ejemplo `send_message`) +- [ ] **1.3** Crear `crons/daily-summary/schedule.yaml` + `prompts/prompt.md` (ejemplo `llm_prompt`) + +### Fase 2: Scripts de gestión en `dev-scripts/cron/` + +- [ ] **2.1** `dev-scripts/cron/new.sh` — scaffolder interactivo: + - Pregunta: nombre, descripción, tipo (`send_message` o `llm_prompt`), cron expression + - Crea `crons//schedule.yaml` y el archivo de prompt/mensaje vacío + - Imprime el bloque YAML listo para copiar en `config.yaml` +- [ ] **2.2** `dev-scripts/cron/list.sh` — lista todas las carpetas bajo `crons/` con nombre y + descripción extraída del `schedule.yaml` +- [ ] **2.3** `dev-scripts/cron/apply.sh ` — añade la entrada `schedules:` a + `agents//config.yaml` con los valores por defecto del `schedule.yaml`. Usa `yq` si está + disponible; en caso contrario imprime el bloque YAML para copiar a mano + +### Fase 3: Mejora menor en `shell/cron/` + +- [ ] **3.1** Exportar `Fire(ctx context.Context, sc config.ScheduleCfg)` en `scheduler.go` para + poder disparar un schedule en tests o desde CLI sin esperar al cron +- [ ] **3.2** Actualizar `scheduler_test.go` para usar `Fire` en lugar de `@every 100ms` donde + sea posible (reduce tiempo de test) + +### Fase 4: Tests + +- [ ] **4.1** Test de `Fire` para `send_message` inline +- [ ] **4.2** Test de `Fire` para `llm_prompt` +- [ ] **4.3** Verificar que `go test -tags goolm ./shell/cron/...` pasa sin regresiones + +### Fase 5: Cleanup y docs + +- [ ] **5.1** Añadir entrada `crons/` en la tabla de estructura de `CLAUDE.md` +- [ ] **5.2** Añadir `dev-scripts/cron/` en la misma tabla +- [ ] **5.3** Mención en `dev-scripts/agent/README.md` o crear `dev-scripts/cron/README.md` + +--- + +## Ejemplo de uso + +```bash +# Crear una nueva automatización +./dev-scripts/cron/new.sh + +# → Nombre de la automatización: weekly-report +# → Descripción: Resumen semanal del equipo +# → Tipo de acción [send_message/llm_prompt]: llm_prompt +# → Cron expression [default: 0 9 * * 1]: 0 9 * * 1 +# ✓ Creado: crons/weekly-report/schedule.yaml +# ✓ Creado: crons/weekly-report/prompts/prompt.md +# +# Añade esto a agents//config.yaml: +# schedules: +# - name: weekly-report +# cron: "0 9 * * 1" +# output_room: "!ROOM:server.com" +# action: +# kind: llm_prompt +# template: "crons/weekly-report/prompts/prompt.md" + +# Listar automatizaciones disponibles +./dev-scripts/cron/list.sh + +# → good-morning send_message "0 9 * * *" Saludo de buenos días +# → daily-summary llm_prompt "0 18 * * *" Resumen diario del equipo +# → weekly-report llm_prompt "0 9 * * 1" Resumen semanal del equipo + +# Aplicar a un agente (parchea config.yaml automáticamente) +./dev-scripts/cron/apply.sh good-morning assistant-bot +# → Añadido schedule 'good-morning' a agents/assistant-bot/config.yaml +# → Edita output_room en config.yaml para apuntar a la sala correcta +``` + +## Decisiones de diseño + +- **`crons/` como catálogo de datos, no de código**: Los archivos `schedule.yaml` son solo + documentación + template. No hay un registry Go nuevo; el scheduler sigue leyendo de + `config.yaml` como hasta ahora. Esto evita añadir un pattern nuevo al proyecto. +- **`apply.sh` opcional**: Si `yq` no está disponible, el script imprime el bloque YAML para + copiar a mano. Sin dependencias obligatorias. +- **`Fire()` en lugar de cron real en tests**: Los tests actuales usan `@every 100ms` y duermen + 350ms. `Fire()` los hace deterministas e instantáneos. +- **No registry Go para crons**: Añadir un registry compilado (como `cmd/launcher`) para crons + sería over-engineering. La gestión vía shell scripts es suficiente y más flexible. + +## Prerequisitos + +- Issue 0005 completado (scheduler en `shell/cron/` — ya está) + +## Riesgos + +- **`yq` no disponible en el entorno**: `apply.sh` cae back a imprimir el bloque YAML, nunca + falla. Sin riesgo real. +- **Paths relativos en `schedule.yaml`**: El campo `template` en el YAML es relativo a la raíz + del proyecto. Documentar claramente en el `README.md` del catálogo. +- **Divergencia entre catálogo y config del agente**: Si alguien edita `schedule.yaml` después + de aplicarlo, el agente no se actualiza. Es intencional — `apply.sh` es un helper de + scaffolding, no sync continua. diff --git a/shell/cron/scheduler.go b/shell/cron/scheduler.go index d2ead76..5c416ab 100644 --- a/shell/cron/scheduler.go +++ b/shell/cron/scheduler.go @@ -45,6 +45,22 @@ func New( } } +// Fire immediately executes the action for the given schedule, bypassing the cron timer. +// Useful for tests and manual triggering from CLI. +func (s *Scheduler) Fire(ctx context.Context, sc config.ScheduleCfg) { + room := sc.OutputRoom + if room == "" { + s.logger.Warn("Fire: schedule has no output_room, skipping", "name", sc.Name) + return + } + handler := s.buildHandler(sc) + if handler == nil { + s.logger.Warn("Fire: unsupported action kind", "name", sc.Name, "kind", sc.Action.Kind) + return + } + handler(ctx, room) +} + // Start registers all schedules and starts the cron loop. // It returns when ctx is cancelled, stopping the cron runner. func (s *Scheduler) Start(ctx context.Context) { diff --git a/shell/cron/scheduler_test.go b/shell/cron/scheduler_test.go index 12cb5ae..b595732 100644 --- a/shell/cron/scheduler_test.go +++ b/shell/cron/scheduler_test.go @@ -62,7 +62,7 @@ func waitCalls(t *testing.T, f *fakeSender, n int32) { t.Fatalf("expected %d call(s) to SendMarkdown, got %d", n, f.calls.Load()) } -// ── tests ────────────────────────────────────────────────────────────────── +// ── cron-based tests (require timer) ────────────────────────────────────── func TestScheduler_SendMessage_Inline(t *testing.T) { sender := &fakeSender{} @@ -171,76 +171,6 @@ func TestScheduler_LLMPrompt(t *testing.T) { } } -func TestScheduler_LLMPrompt_NoLLM(t *testing.T) { - // When no LLM is configured, llm_prompt should be skipped gracefully (no panic). - sender := &fakeSender{} - cfg := []config.ScheduleCfg{ - { - Name: "no-llm", - Cron: "@every 100ms", - OutputRoom: "!room:server.com", - Action: config.ScheduledAction{ - Kind: "llm_prompt", - Prompt: "hello", - }, - }, - } - - s := shellcron.New(cfg, sender, nil, "", newTestLogger(t)) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - s.Start(ctx) - }() - - // Wait a bit to confirm nothing is sent - time.Sleep(350 * time.Millisecond) - cancel() - <-done - - if sender.calls.Load() != 0 { - t.Errorf("expected 0 calls without LLM, got %d", sender.calls.Load()) - } -} - -func TestScheduler_SkipsInvalidSchedule(t *testing.T) { - // Schedules without output_room or without action kind must be skipped silently. - sender := &fakeSender{} - cfg := []config.ScheduleCfg{ - { - Name: "no-room", - Cron: "@every 100ms", - // missing OutputRoom - Action: config.ScheduledAction{Kind: "send_message", Message: "hi"}, - }, - { - Name: "no-kind", - Cron: "@every 100ms", - OutputRoom: "!room:server.com", - // missing Action.Kind - }, - } - - s := shellcron.New(cfg, sender, nil, "", newTestLogger(t)) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - s.Start(ctx) - }() - - time.Sleep(350 * time.Millisecond) - cancel() - <-done - - if sender.calls.Load() != 0 { - t.Errorf("expected 0 calls for invalid schedules, got %d", sender.calls.Load()) - } -} - func TestScheduler_MatrixSendError(t *testing.T) { // If matrix.SendMarkdown returns an error, the scheduler should log it and not panic. cfg := []config.ScheduleCfg{ @@ -269,3 +199,128 @@ func TestScheduler_MatrixSendError(t *testing.T) { cancel() <-done } + +// ── Fire() tests (deterministic, no timer) ───────────────────────────────── + +func TestFire_SendMessage_Inline(t *testing.T) { + sender := &fakeSender{} + s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) + + sc := config.ScheduleCfg{ + Name: "fire-inline", + Cron: "0 9 * * *", + OutputRoom: "!fireroom:server.com", + Action: config.ScheduledAction{ + Kind: "send_message", + Message: "buenos días via Fire", + }, + } + + s.Fire(context.Background(), sc) + + if sender.calls.Load() != 1 { + t.Fatalf("expected 1 call, got %d", sender.calls.Load()) + } + if sender.lastRM != "!fireroom:server.com" { + t.Errorf("unexpected room: %s", sender.lastRM) + } + if sender.lastMD != "buenos días via Fire" { + t.Errorf("unexpected message: %s", sender.lastMD) + } +} + +func TestFire_LLMPrompt(t *testing.T) { + sender := &fakeSender{} + llm := fakeLLM("respuesta del LLM via Fire") + s := shellcron.New(nil, sender, llm, "gpt-4o", newTestLogger(t)) + + sc := config.ScheduleCfg{ + Name: "fire-llm", + Cron: "0 18 * * *", + OutputRoom: "!llmroom:server.com", + Action: config.ScheduledAction{ + Kind: "llm_prompt", + Prompt: "resume el día", + }, + } + + s.Fire(context.Background(), sc) + + if sender.calls.Load() != 1 { + t.Fatalf("expected 1 call, got %d", sender.calls.Load()) + } + if sender.lastMD != "respuesta del LLM via Fire" { + t.Errorf("unexpected LLM reply: %q", sender.lastMD) + } +} + +func TestFire_NoOutputRoom_Skips(t *testing.T) { + sender := &fakeSender{} + s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) + + sc := config.ScheduleCfg{ + Name: "fire-no-room", + Cron: "0 9 * * *", + OutputRoom: "", // intentionally empty + Action: config.ScheduledAction{ + Kind: "send_message", + Message: "should not send", + }, + } + + s.Fire(context.Background(), sc) + + if sender.calls.Load() != 0 { + t.Errorf("expected 0 calls when output_room is empty, got %d", sender.calls.Load()) + } +} + +func TestFire_LLMPrompt_NoLLM_Skips(t *testing.T) { + // When no LLM is configured, Fire with llm_prompt should not send anything. + sender := &fakeSender{} + s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) + + sc := config.ScheduleCfg{ + Name: "fire-no-llm", + Cron: "0 9 * * *", + OutputRoom: "!room:server.com", + Action: config.ScheduledAction{ + Kind: "llm_prompt", + Prompt: "hello", + }, + } + + s.Fire(context.Background(), sc) + + if sender.calls.Load() != 0 { + t.Errorf("expected 0 calls without LLM, got %d", sender.calls.Load()) + } +} + +func TestScheduler_SkipsInvalidSchedule(t *testing.T) { + // Schedules without output_room or without action kind must be skipped during Start. + // We use Fire directly to test the skip logic without timer overhead. + sender := &fakeSender{} + s := shellcron.New(nil, sender, nil, "", newTestLogger(t)) + ctx := context.Background() + + // No output_room → skip + s.Fire(ctx, config.ScheduleCfg{ + Name: "no-room", + Cron: "@every 100ms", + // missing OutputRoom + Action: config.ScheduledAction{Kind: "send_message", Message: "hi"}, + }) + + // No kind → Fire calls buildHandler which returns nil → skip + s.Fire(ctx, config.ScheduleCfg{ + Name: "no-kind", + Cron: "@every 100ms", + OutputRoom: "!room:server.com", + // missing Action.Kind + }) + + if sender.calls.Load() != 0 { + t.Errorf("expected 0 calls for invalid schedules, got %d", sender.calls.Load()) + } +}