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:
@@ -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) {
|
||||
|
||||
+126
-71
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user