mirror of
https://github.com/tiennm99/claude-code-routine-trigger.git
synced 2026-10-11 03:13:13 +00:00
feat: implement core daemon (config, fire client, scheduler)
This commit is contained in:
1 parent
981ec3255e
commit
9c4d7a532f
6 files changed
+384
-1
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1
-1
@@ -2,4 +2,4 @@ module github.com/tiennm99/claude-code-routine-cron
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/robfig/cron/v3 v3.0.1 // indirect
|
require github.com/robfig/cron/v3 v3.0.1
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user