From 9751a24c1e0e8ba0ffa34b64ab840afc6c6bdc6e Mon Sep 17 00:00:00 2001 From: dwrz Date: Tue, 2 Jun 2026 12:03:03 +0000 Subject: [PATCH] Add job package --- internal/job/job.go | 278 +++++++++++++++++++++++++++++++++++++++ internal/job/job_test.go | 229 ++++++++++++++++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 internal/job/job.go create mode 100644 internal/job/job_test.go diff --git a/internal/job/job.go b/internal/job/job.go new file mode 100644 index 0000000..0470f66 --- /dev/null +++ b/internal/job/job.go @@ -0,0 +1,278 @@ +// Package job provides cron-based scheduling for autonomous LLM tasks. +// +// Jobs are defined in the YAML configuration with a cron expression, +// a system message, and a task description. When the service fires a +// job, it constructs messages and calls the LLM agent loop to execute the +// task. +package job + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/robfig/cron/v3" + "github.com/sashabaranov/go-openai" + + "code.chimeric.al/chimerical/odidere/internal/llm" +) + +// Config holds the user-facing configuration for a job. +// It is unmarshaled from YAML and passed to New, which validates and +// parses it into an immutable Job. +type Config struct { + // Enabled controls whether the job is active. Defaults to true. + Enabled *bool `yaml:"enabled,omitempty"` + // MaxIterations caps the number of agent loop iterations. + // A value of 0 means unlimited (no cap). + MaxIterations int `yaml:"max_iterations,omitempty"` + // Name uniquely identifies the job within the config. + Name string `yaml:"name"` + // Schedule is a cron expression (e.g., "0 9 * * *"). + Schedule string `yaml:"schedule"` + // SystemMessage overrides the global system message for this job. + SystemMessage string `yaml:"system_message,omitempty"` + // Task is the user-level instruction to give the LLM. + Task string `yaml:"task"` + // Timeout limits the job execution time (e.g., "10m"). + // Defaults to the global LLM timeout if empty. + Timeout string `yaml:"timeout,omitempty"` +} + +// IsEnabled returns whether the job should be scheduled. +// If Enabled is nil, the job is considered enabled by default. +func (cfg Config) IsEnabled() bool { + if cfg.Enabled == nil { + return true + } + return *cfg.Enabled +} + +// Validate checks that required fields are present and constraints hold. +// It does not parse values — that is the responsibility of New. +func (cfg Config) Validate() error { + if cfg.Name == "" { + return fmt.Errorf("missing name") + } + if cfg.Task == "" { + return fmt.Errorf("missing task") + } + if cfg.Schedule == "" { + return fmt.Errorf("missing schedule") + } + if cfg.MaxIterations < 0 { + return fmt.Errorf("max_iterations must be non-negative") + } + return nil +} + +// Job defines a scheduled autonomous task. +// +// Job is immutable after construction via New. All fields are private and +// accessed through getter methods. This ensures that a Job's configuration +// cannot change once it is handed to the service. +type Job struct { + // cron is the cron.Schedule derived from schedule during + // construction. This is what the robfig/cron package uses to determine + // when the job should fire. + cron cron.Schedule + + // enabled indicates whether the job should be registered with the cron + // scheduler. + enabled bool + + // log is the structured logger used for job execution. + log *slog.Logger + + // llm is the LLM client used to execute the job. + llm *llm.Client + + // maxIterations caps the agent loop iterations for this job. Zero means + // unlimited. + maxIterations int + + // name uniquely identifies the job within the config. It is used for + // logging, scheduling, and lookup. + name string + + // schedule is the original cron expression string from the config + // (e.g. "0 9 * * *"). Stored for logging and display purposes. + schedule string + + // systemMessage is the per-job system prompt that overrides the global + // LLM system message. Empty string means use the default. + systemMessage string + + // task is the user-level instruction passed to the LLM when the job + // fires. This is the core of what the job does. + task string + + // timeout is the parsed execution time limit. Zero means no per-job + // timeout (falls back to the global LLM timeout). + timeout time.Duration +} + +// New creates a new Job from a Config. +// log is the structured logger used for job execution. +// llm is the LLM client used to execute the job. +func New(cfg Config, log *slog.Logger, llmClient *llm.Client) (*Job, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + sched, err := cron.ParseStandard(cfg.Schedule) + if err != nil { + return nil, fmt.Errorf( + "invalid schedule %q: %w", cfg.Schedule, err, + ) + } + + var timeout time.Duration + if cfg.Timeout != "" { + timeout, err = time.ParseDuration(cfg.Timeout) + if err != nil { + return nil, fmt.Errorf( + "invalid timeout %q: %w", cfg.Timeout, err, + ) + } + } + + enabled := true + if cfg.Enabled != nil { + enabled = *cfg.Enabled + } + + return &Job{ + cron: sched, + enabled: enabled, + log: log.With(slog.String("job", cfg.Name)), + llm: llmClient, + maxIterations: cfg.MaxIterations, + name: cfg.Name, + schedule: cfg.Schedule, + systemMessage: cfg.SystemMessage, + task: cfg.Task, + timeout: timeout, + }, nil +} + +// Cron returns the parsed cron schedule. +func (j *Job) Cron() cron.Schedule { + return j.cron +} + +// IsEnabled returns whether the job should be scheduled. +func (j *Job) IsEnabled() bool { + return j.enabled +} + +// MaxIterations returns the configured max iterations. A value of 0 +// means unlimited (no cap on agent loop iterations). +func (j *Job) MaxIterations() int { + return j.maxIterations +} + +// Name returns the job name. +func (j *Job) Name() string { + return j.name +} + +// Schedule returns the cron schedule string. +func (j *Job) Schedule() string { + return j.schedule +} + +// SystemMessage returns the job's system message. +func (j *Job) SystemMessage() string { + return j.systemMessage +} + +// Task returns the job's task description. +func (j *Job) Task() string { + return j.task +} + +// Timeout returns the job's timeout duration. +func (j *Job) Timeout() time.Duration { + return j.timeout +} + +// Result holds the outcome of a job execution. +type Result struct { + // Name is the job name. + Name string + // Duration is how long the job took to run. + Duration time.Duration + // Messages is the full conversation returned by the LLM agent loop, + // including tool calls and tool results. Empty if the job failed. + Messages []openai.ChatCompletionMessage + // Error is set if the job failed. + Error error +} + +// Run executes the job: builds messages from the job config, calls the LLM, +// and logs progress. The agent uses tools for any additional output. +// Uses j.log and j.llm internally. +func (j *Job) Run(ctx context.Context) Result { + start := time.Now() + log := j.log.With(slog.String("job", j.Name())) + log.Info("starting job") + + // Build messages from job config. + messages := []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: j.Task(), + }, + } + + // Apply per-job timeout if set. + if j.Timeout() > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, j.Timeout()) + defer cancel() + } + + msgs, err := j.llm.Query( + ctx, + messages, + "", + j.SystemMessage(), + j.MaxIterations(), + ) + duration := time.Since(start) + if err != nil { + log.Error("job failed", + slog.Any("error", err), + slog.Duration("duration", duration), + ) + return Result{ + Name: j.Name(), + Duration: duration, + Error: fmt.Errorf("job %q: %w", j.Name(), err), + } + } + if len(msgs) == 0 { + log.Error("job returned no messages", + slog.Duration("duration", duration), + ) + return Result{ + Name: j.Name(), + Duration: duration, + Error: fmt.Errorf( + "job %q: no response from LLM", j.Name(), + ), + } + } + log.Info("job completed", + slog.Duration("duration", duration), + slog.Int("messages", len(msgs)), + ) + + return Result{ + Name: j.Name(), + Duration: duration, + Messages: msgs, + } +} diff --git a/internal/job/job_test.go b/internal/job/job_test.go new file mode 100644 index 0000000..17de5ed --- /dev/null +++ b/internal/job/job_test.go @@ -0,0 +1,229 @@ +package job + +import ( + "log/slog" + "testing" + "time" +) + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "empty config", + config: Config{}, + wantErr: true, + }, + { + name: "missing name", + config: Config{ + Schedule: "0 9 * * *", + Task: "do something", + }, + wantErr: true, + }, + { + name: "missing task", + config: Config{ + Name: "test", + Schedule: "0 9 * * *", + }, + wantErr: true, + }, + { + name: "missing schedule", + config: Config{ + Name: "test", + Task: "do something", + }, + wantErr: true, + }, + { + name: "negative max iterations", + config: Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + MaxIterations: -1, + }, + wantErr: true, + }, + { + name: "minimal valid config", + config: Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + }, + wantErr: false, + }, + { + name: "full valid config", + config: Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + Timeout: "10m", + MaxIterations: 20, + SystemMessage: "You are an assistant.", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfigIsEnabled(t *testing.T) { + // nil enabled → defaults to true + cfg := Config{Name: "test"} + if !cfg.IsEnabled() { + t.Error("expected nil enabled to be true") + } + + // explicit true + enabled := true + cfg.Enabled = &enabled + if !cfg.IsEnabled() { + t.Error("expected explicit true") + } + + // explicit false + disabled := false + cfg.Enabled = &disabled + if cfg.IsEnabled() { + t.Error("expected explicit false") + } +} + +func TestConfigMaxIterations(t *testing.T) { + // Zero means unlimited + cfg := Config{Name: "test"} + if got := cfg.MaxIterations; got != 0 { + t.Errorf("default max iterations = %d, want 0", got) + } + + cfg.MaxIterations = 0 + if got := cfg.MaxIterations; got != 0 { + t.Errorf("explicit zero max iterations = %d, want 0", got) + } + + // Positive value passes through + cfg.MaxIterations = 25 + if got := cfg.MaxIterations; got != 25 { + t.Errorf("custom max iterations = %d, want 25", got) + } +} + +func TestConfigTimeout(t *testing.T) { + cfg := Config{Name: "test"} + if cfg.Timeout != "" { + t.Errorf("default timeout = %q, want empty", cfg.Timeout) + } + + cfg.Timeout = "10m" + got, err := time.ParseDuration(cfg.Timeout) + if err != nil { + t.Fatalf("ParseDuration(%q) error = %v", cfg.Timeout, err) + } + if got.Minutes() != 10 { + t.Errorf("parsed timeout = %v, want 10m", got) + } +} + +func TestNew(t *testing.T) { + cfg := Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + Timeout: "10m", + MaxIterations: 20, + SystemMessage: "You are an assistant.", + } + + j, err := New(cfg, slog.Default(), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if j.Name() != "test" { + t.Errorf("Name() = %q, want %q", j.Name(), "test") + } + if j.Schedule() != "0 9 * * *" { + t.Errorf("Schedule() = %q, want %q", j.Schedule(), "0 9 * * *") + } + if !j.IsEnabled() { + t.Error("IsEnabled() = false, want true") + } + if j.SystemMessage() != "You are an assistant." { + t.Errorf("SystemMessage() = %q, want %q", j.SystemMessage(), "You are an assistant.") + } + if j.Task() != "do something" { + t.Errorf("Task() = %q, want %q", j.Task(), "do something") + } + if j.Timeout().Minutes() != 10 { + t.Errorf("Timeout() = %v, want 10m", j.Timeout()) + } + if j.MaxIterations() != 20 { + t.Errorf("MaxIterations() = %d, want 20", j.MaxIterations()) + } + if j.Cron() == nil { + t.Error("Cron() = nil, want non-nil") + } +} + +func TestNew_Disabled(t *testing.T) { + disabled := false + cfg := Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + Enabled: &disabled, + } + + j, err := New(cfg, slog.Default(), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if j.IsEnabled() { + t.Error("IsEnabled() = true, want false") + } +} + +func TestNew_InvalidSchedule(t *testing.T) { + cfg := Config{ + Name: "test", + Task: "do something", + Schedule: "not-a-cron", + } + + _, err := New(cfg, slog.Default(), nil) + if err == nil { + t.Fatal("New() expected error for invalid schedule") + } +} + +func TestNew_InvalidTimeout(t *testing.T) { + cfg := Config{ + Name: "test", + Task: "do something", + Schedule: "0 9 * * *", + Timeout: "not-a-duration", + } + + _, err := New(cfg, slog.Default(), nil) + if err == nil { + t.Fatal("New() expected error for invalid timeout") + } +}