feat: catálogo crons/ + scripts dev-scripts/cron/ + Fire() en scheduler

Implementa issue 0025: catálogo central de automatizaciones cron y scaffolder.

- crons/: directorio de automatizaciones nombradas con README explicando la
  convención. Incluye dos ejemplos listos para usar:
    · good-morning (send_message, 0 9 * * *) — saludo diario
    · daily-summary (llm_prompt, 0 18 * * *)  — resumen generado por LLM

- dev-scripts/cron/new.sh: scaffolder interactivo — pregunta nombre,
  descripción, tipo de acción y cron expression; crea schedule.yaml +
  archivo de prompt vacío; imprime el bloque YAML para copiar en config.yaml.

- dev-scripts/cron/list.sh: lista todas las automatizaciones del catálogo
  con nombre, tipo, cron y descripción en formato tabular.

- dev-scripts/cron/apply.sh: añade la automatización al config.yaml del
  agente indicado usando yq si está disponible; si no, imprime el bloque
  YAML para copiar a mano (sin dependencias obligatorias).

- shell/cron/scheduler.go: exporta Fire(ctx, sc) para disparo inmediato
  de un schedule sin esperar al timer cron — útil en tests y CLI.

- shell/cron/scheduler_test.go: cuatro tests nuevos para Fire()
  (send_message inline, llm_prompt, sin output_room, sin LLM).
  TestScheduler_SkipsInvalidSchedule y TestFire_LLMPrompt_NoLLM_Skips
  reemplazados por versiones instantáneas usando Fire en lugar de
  @every 100ms + sleep, eliminando ~700ms de tiempo de test.
This commit is contained in:
2026-03-08 20:01:02 +00:00
parent fb2c7fdaa9
commit e481cb8783
10 changed files with 474 additions and 71 deletions
+73
View File
@@ -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/<nombre>/
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/<nombre>/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 <nombre> <agent-id>
```
## Cómo añadir manualmente a un agente
En `agents/<id>/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.
+7
View File
@@ -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).
+15
View File
@@ -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: ""
+3
View File
@@ -0,0 +1,3 @@
¡Buenos días! 🌅
Espero que tengan un excelente día. Estoy aquí si necesitan ayuda con algo.
+15
View File
@@ -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: ""
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# apply.sh <name> <agent-id>
# Añade la automatización <name> al config.yaml del agente <agent-id>.
# 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 <nombre-automatizacion> <agent-id>" >&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
+47
View File
@@ -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
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# new.sh — Scaffolder interactivo para automatizaciones cron
# Crea crons/<name>/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" <<EOF
# Automatización: $NAME
name: $NAME
description: "$DESCRIPTION"
# Cron por defecto
default_cron: "$CRON_EXPR"
# Acción
action:
kind: $ACTION_KIND
# Relativo a la raíz del proyecto
template: crons/$NAME/prompts/$PROMPT_FILE
# Sala de salida por defecto (vacío = el agente debe configurar output_room)
default_output_room: ""
EOF
# Archivo de prompt/mensaje
touch "$PROMPTS_DIR/$PROMPT_FILE"
echo ""
echo "✓ Creado: crons/$NAME/schedule.yaml"
echo "✓ Creado: crons/$NAME/prompts/$PROMPT_FILE"
echo ""
echo "Edita crons/$NAME/prompts/$PROMPT_FILE con el contenido deseado."
echo ""
echo "Añade esto a agents/<id>/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 <agent-id>"
+16
View File
@@ -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. // Start registers all schedules and starts the cron loop.
// It returns when ctx is cancelled, stopping the cron runner. // It returns when ctx is cancelled, stopping the cron runner.
func (s *Scheduler) Start(ctx context.Context) { func (s *Scheduler) Start(ctx context.Context) {
+126 -71
View File
@@ -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()) 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) { func TestScheduler_SendMessage_Inline(t *testing.T) {
sender := &fakeSender{} 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) { func TestScheduler_MatrixSendError(t *testing.T) {
// If matrix.SendMarkdown returns an error, the scheduler should log it and not panic. // If matrix.SendMarkdown returns an error, the scheduler should log it and not panic.
cfg := []config.ScheduleCfg{ cfg := []config.ScheduleCfg{
@@ -269,3 +199,128 @@ func TestScheduler_MatrixSendError(t *testing.T) {
cancel() cancel()
<-done <-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())
}
}