From b4bdbede401df9bd09a99edc577e869337d2e17d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 8 May 2026 23:43:36 +0700 Subject: [PATCH] feat: implement core daemon (config, fire client, scheduler) --- config.go | 104 +++++++++++++++++++++++++++++++++++++++++++ fire_client.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- logger.go | 40 +++++++++++++++++ main.go | 61 +++++++++++++++++++++++++ scheduler.go | 60 +++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 config.go create mode 100644 fire_client.go create mode 100644 logger.go create mode 100644 main.go create mode 100644 scheduler.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..6304620 --- /dev/null +++ b/config.go @@ -0,0 +1,104 @@ +package main + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + "text/template" + "time" + + "github.com/robfig/cron/v3" +) + +const defaultTextTemplate = "Scheduled trigger at {{.LocalTime}}" + +// Config is the validated runtime configuration assembled from env vars. +type Config struct { + FireURL string + Token string + Schedules []string + Location *time.Location + Template *template.Template + LogLevel string +} + +// Load reads env vars and returns a fully validated Config or an error. +// All required vars must be present and all parsed values must pass validation. +func Load() (*Config, error) { + cfg := &Config{} + + cfg.FireURL = strings.TrimSpace(os.Getenv("ROUTINE_FIRE_URL")) + if cfg.FireURL == "" { + return nil, errors.New("missing required env var ROUTINE_FIRE_URL") + } + + cfg.Token = strings.TrimSpace(os.Getenv("ROUTINE_FIRE_TOKEN")) + if cfg.Token == "" { + return nil, errors.New("missing required env var ROUTINE_FIRE_TOKEN") + } + + rawCron := os.Getenv("CRON_SCHEDULE") + schedules, err := parseSchedules(rawCron) + if err != nil { + return nil, err + } + cfg.Schedules = schedules + + tz := strings.TrimSpace(os.Getenv("TZ")) + if tz == "" { + cfg.Location = time.UTC + } else { + loc, err := time.LoadLocation(tz) + if err != nil { + return nil, fmt.Errorf("invalid TZ %q: %w", tz, err) + } + cfg.Location = loc + } + + rawTpl := os.Getenv("TEXT_TEMPLATE") + if rawTpl == "" { + rawTpl = defaultTextTemplate + } + tpl, err := template.New("text").Parse(rawTpl) + if err != nil { + return nil, fmt.Errorf("invalid TEXT_TEMPLATE: %w", err) + } + cfg.Template = tpl + + cfg.LogLevel = strings.TrimSpace(os.Getenv("LOG_LEVEL")) + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } + + return cfg, nil +} + +var scheduleSeparators = regexp.MustCompile(`[;\n]+`) + +// parseSchedules splits CRON_SCHEDULE on `;` or newlines, trims, drops empties, +// and validates each expression against the standard 5-field parser. +func parseSchedules(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, errors.New("missing required env var CRON_SCHEDULE") + } + parts := scheduleSeparators.Split(raw, -1) + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s == "" { + continue + } + if _, err := parser.Parse(s); err != nil { + return nil, fmt.Errorf("invalid cron %q: %w", s, err) + } + out = append(out, s) + } + if len(out) == 0 { + return nil, errors.New("CRON_SCHEDULE has no non-empty expressions") + } + return out, nil +} diff --git a/fire_client.go b/fire_client.go new file mode 100644 index 0000000..6317d64 --- /dev/null +++ b/fire_client.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "text/template" + "time" +) + +const ( + headerVersion = "2023-06-01" + headerBeta = "experimental-cc-routine-2026-04-01" +) + +// FireClient POSTs to the Anthropic /fire endpoint with the headers and body +// shape required by the Claude Code routines beta. Each call creates a new +// session; the daemon never retries on failure. +type FireClient struct { + URL string + Token string + HTTP *http.Client + Template *template.Template + TZ *time.Location + Log *slog.Logger + NowFunc func() time.Time // overridable for tests; defaults to time.Now +} + +type fireRequest struct { + Text string `json:"text"` +} + +type fireResponse struct { + Type string `json:"type"` + ClaudeCodeSessionID string `json:"claude_code_session_id"` + ClaudeCodeSessionURL string `json:"claude_code_session_url"` +} + +// Fire renders the text template, POSTs to the routine /fire endpoint, and +// logs the outcome. Returns nil on transport-level success regardless of HTTP +// status — non-2xx is logged at error level so the daemon stays up. +// Returns a non-nil error only for unrecoverable client-side problems +// (template render failure or request build failure). +func (c *FireClient) Fire(ctx context.Context, cronExpr string) error { + now := c.now() + text, err := c.renderText(now, cronExpr) + if err != nil { + return fmt.Errorf("render text: %w", err) + } + + body, err := json.Marshal(fireRequest{Text: text}) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("anthropic-version", headerVersion) + req.Header.Set("anthropic-beta", headerBeta) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + c.Log.Error("fire request failed", "cron", cronExpr, "err", err.Error()) + return nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + c.Log.Error("fire non-2xx response", + "cron", cronExpr, + "status", resp.StatusCode, + "body", string(respBody)) + return nil + } + + var fr fireResponse + if err := json.Unmarshal(respBody, &fr); err != nil { + c.Log.Warn("fire 2xx but body unparseable", + "cron", cronExpr, + "status", resp.StatusCode, + "body", string(respBody)) + return nil + } + c.Log.Info("fire ok", + "cron", cronExpr, + "session_url", fr.ClaudeCodeSessionURL, + "session_id", fr.ClaudeCodeSessionID) + return nil +} + +func (c *FireClient) now() time.Time { + if c.NowFunc != nil { + return c.NowFunc() + } + return time.Now() +} + +func (c *FireClient) renderText(now time.Time, cronExpr string) (string, error) { + data := map[string]any{ + "Now": now, + "LocalTime": now.In(c.TZ).Format("2006-01-02 15:04 MST"), + "Cron": cronExpr, + } + var buf bytes.Buffer + if err := c.Template.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/go.mod b/go.mod index 2a1938a..df55d96 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/tiennm99/claude-code-routine-cron go 1.22 -require github.com/robfig/cron/v3 v3.0.1 // indirect +require github.com/robfig/cron/v3 v3.0.1 diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..73ad65d --- /dev/null +++ b/logger.go @@ -0,0 +1,40 @@ +package main + +import ( + "log/slog" + "os" + "strings" +) + +// newLogger builds a JSON slog logger writing to stdout at the given level. +// Unknown levels fall back to info and emit a warning on the new logger. +func newLogger(level string) *slog.Logger { + lvl := parseLevel(level) + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + l := slog.New(h) + if !knownLevel(level) { + l.Warn("unknown LOG_LEVEL, using info", "got", level) + } + return l +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func knownLevel(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "info", "debug", "warn", "warning", "error": + return true + } + return false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0545bce --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + httpClientTimeout = 30 * time.Second + httpFireTimeout = 30 * time.Second + shutdownTimeout = 35 * time.Second +) + +func main() { + cfg, err := Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + logger := newLogger(cfg.LogLevel) + slog.SetDefault(logger) + + fire := &FireClient{ + URL: cfg.FireURL, + Token: cfg.Token, + HTTP: &http.Client{Timeout: httpClientTimeout}, + Template: cfg.Template, + TZ: cfg.Location, + Log: logger, + } + + sched, err := NewScheduler(cfg, fire, logger) + if err != nil { + logger.Error("scheduler init failed", "err", err.Error()) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + sched.Start() + logger.Info("started", + "schedules", cfg.Schedules, + "tz", cfg.Location.String(), + "entries", sched.EntryCount(), + ) + + <-ctx.Done() + logger.Info("shutting down") + + stopCtx, stopCancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer stopCancel() + sched.Stop(stopCtx) + logger.Info("stopped cleanly") +} diff --git a/scheduler.go b/scheduler.go new file mode 100644 index 0000000..82f42aa --- /dev/null +++ b/scheduler.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + + "github.com/robfig/cron/v3" +) + +// Scheduler wires cron schedules to FireClient invocations. +type Scheduler struct { + cron *cron.Cron + fire *FireClient + log *slog.Logger +} + +// NewScheduler builds a Scheduler with all schedules registered. Returns an +// error if any registration fails (defensive — config.go already validates +// expressions, but the cron library may have stricter rules at AddFunc time). +func NewScheduler(cfg *Config, fire *FireClient, log *slog.Logger) (*Scheduler, error) { + c := cron.New(cron.WithLocation(cfg.Location)) + s := &Scheduler{cron: c, fire: fire, log: log} + + for _, expr := range cfg.Schedules { + expr := expr + _, err := c.AddFunc(expr, func() { + ctx, cancel := context.WithTimeout(context.Background(), httpFireTimeout) + defer cancel() + if err := fire.Fire(ctx, expr); err != nil { + log.Error("fire returned error", "cron", expr, "err", err.Error()) + } + }) + if err != nil { + return nil, fmt.Errorf("register cron %q: %w", expr, err) + } + } + return s, nil +} + +// Start the underlying cron scheduler. Non-blocking. +func (s *Scheduler) Start() { + s.cron.Start() +} + +// Stop the scheduler and wait for in-flight tasks to finish or for the +// supplied context to be cancelled, whichever comes first. +func (s *Scheduler) Stop(ctx context.Context) { + stopped := s.cron.Stop().Done() + select { + case <-stopped: + case <-ctx.Done(): + s.log.Warn("shutdown deadline reached before all fires completed") + } +} + +// EntryCount exposes the number of registered schedules for tests/diagnostics. +func (s *Scheduler) EntryCount() int { + return len(s.cron.Entries()) +}