From e5238e4081e3c2072cbbebefa17fb8ba0f19ac78 Mon Sep 17 00:00:00 2001 From: dwrz Date: Tue, 2 Jun 2026 12:03:15 +0000 Subject: [PATCH] Add load job configuration --- config.example.yaml | 9 ++ internal/config/config.go | 8 ++ internal/config/config_test.go | 182 +++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 87764cd..e036c44 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,3 +45,12 @@ tools: required: - location timeout: "10s" + +jobs: + - name: "daily_summary" + schedule: "0 9 * * *" + enabled: true + system_message: "You are an assistant." + task: "Report the uptime on your host." + timeout: "3m" + max_iterations: 5 # 0 means unlimited diff --git a/internal/config/config.go b/internal/config/config.go index 826c50a..6d2c521 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "regexp" "time" + "code.chimeric.al/chimerical/odidere/internal/job" "code.chimeric.al/chimerical/odidere/internal/llm" "code.chimeric.al/chimerical/odidere/internal/stt" "code.chimeric.al/chimerical/odidere/internal/tool" @@ -30,6 +31,8 @@ type Config struct { LLM llm.Config `yaml:"llm"` // STT configures the speech-to-text client. STT stt.Config `yaml:"stt"` + // Jobs defines scheduled autonomous tasks. + Jobs []job.Config `yaml:"jobs"` // Tools defines external commands available to the LLM. Tools []tool.Tool `yaml:"tools"` // TTS configures the text-to-speech client. @@ -74,6 +77,11 @@ func (cfg *Config) Validate() error { cfg.ShutdownTimeout, err, ) } + for i, jc := range cfg.Jobs { + if err := jc.Validate(); err != nil { + return fmt.Errorf("jobs[%d] invalid: %w", i, err) + } + } for i, t := range cfg.Tools { if err := t.Validate(); err != nil { return fmt.Errorf("tools[%d] invalid: %w", i, err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f0367fe..302f93e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -377,3 +377,185 @@ func TestApplyDefaults_NoOverwrite(t *testing.T) { ) } } + +func TestLoad_Jobs(t *testing.T) { + tests := []struct { + name string + config string + shouldErr bool + check func(t *testing.T, cfg *Config) + }{ + { + name: "valid job config", + config: ` +llm: + model: test-model + url: http://localhost:8080 + +stt: + url: http://localhost:8178 + +tts: + url: http://localhost:8880 + voice: af_heart + +jobs: + - name: daily_summary + schedule: "0 9 * * *" + enabled: true + system_message: "You are a research assistant." + task: "Summarize today's news about AI." + timeout: "10m" + max_iterations: 20 +`, + check: func(t *testing.T, cfg *Config) { + if len(cfg.Jobs) != 1 { + t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) + } + j := cfg.Jobs[0] + if j.Name != "daily_summary" { + t.Errorf("Name = %q, want %q", j.Name, "daily_summary") + } + if j.Task != "Summarize today's news about AI." { + t.Errorf("Task = %q, want %q", j.Task, "Summarize today's news about AI.") + } + if j.Schedule != "0 9 * * *" { + t.Errorf("Schedule = %q, want %q", j.Schedule, "0 9 * * *") + } + timeout, err := time.ParseDuration(j.Timeout) + if err != nil { + t.Fatalf("ParseDuration(%q) error = %v", j.Timeout, err) + } + if timeout.Minutes() != 10 { + t.Errorf("Timeout = %v, want 10m", timeout) + } + if j.MaxIterations != 20 { + t.Errorf("MaxIterations = %d, want 20", j.MaxIterations) + } + if !j.IsEnabled() { + t.Error("IsEnabled() = false, want true") + } + }, + }, + { + name: "job with minimal config (defaults)", + config: ` +llm: + model: test-model + url: http://localhost:8080 + +stt: + url: http://localhost:8178 + +tts: + url: http://localhost:8880 + voice: af_heart + +jobs: + - name: log_job + schedule: "*/5 * * * *" + task: "Check system health." +`, + check: func(t *testing.T, cfg *Config) { + if len(cfg.Jobs) != 1 { + t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) + } + j := cfg.Jobs[0] + if j.MaxIterations != 0 { + t.Errorf("MaxIterations = %d, want 0 (unlimited)", j.MaxIterations) + } + }, + }, + { + name: "disabled job", + config: ` +llm: + model: test-model + url: http://localhost:8080 + +stt: + url: http://localhost:8178 + +tts: + url: http://localhost:8880 + voice: af_heart + +jobs: + - name: disabled_job + schedule: "0 0 * * *" + enabled: false + task: "This should not run." +`, + check: func(t *testing.T, cfg *Config) { + if len(cfg.Jobs) != 1 { + t.Fatalf("len(Jobs) = %d, want 1", len(cfg.Jobs)) + } + if cfg.Jobs[0].IsEnabled() { + t.Error("IsEnabled() = true, want false") + } + }, + }, + { + name: "job missing name", + config: ` +llm: + model: test-model + url: http://localhost:8080 + +stt: + url: http://localhost:8178 + +tts: + url: http://localhost:8880 + voice: af_heart + +jobs: + - schedule: "0 9 * * *" + task: "do something" +`, + shouldErr: true, + }, + { + name: "job missing task", + config: ` +llm: + model: test-model + url: http://localhost:8080 + +stt: + url: http://localhost:8178 + +tts: + url: http://localhost:8880 + voice: af_heart + +jobs: + - name: no_task + schedule: "0 9 * * *" +`, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfig(t, tt.config) + cfg, err := Load(path) + + if tt.shouldErr { + if err == nil { + t.Fatal("Load() expected error") + } + return + } + + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +}