Files
goclaw/internal/store/pg/cron_scan.go
T
Luan Vu 25fd9c9d6d feat(cron): configurable default timezone for cron expressions (#117)
* feat(cron): configurable default timezone for cron expressions

Cron expressions (e.g. "0 8 * * *") are evaluated relative to a timezone.
Without an explicit per-job timezone, they default to the server's system
timezone, which may not match the user's local time — especially in Docker
containers (default UTC) or multi-region deployments.

This adds a `default_timezone` setting to `CronConfig` (IANA format, e.g.
"Asia/Ho_Chi_Minh") that is applied as fallback when a cron job has no
explicit `schedule.tz`. The setting is configurable via the UI config page
(Integrations → Cron Scheduler) and hot-reloads on config changes.

Backend:
- Add `DefaultTimezone` field to `CronConfig`
- Add `SetDefaultTimezone()` to `CronStore` interface + PG implementation
- Apply default TZ in `AddJob()` when `schedule.TZ` is empty
- Wire at startup + subscribe to config change events for hot reload
- Update cron tool description so LLM knows about gateway default

Frontend:
- Add timezone dropdown (20 common IANA timezones) to Cron config section
- Add i18n keys for en, vi, zh

* fix(cron): apply default timezone to existing jobs via computeNextRun

Pass defaultTZ as fallback to computeNextRun so existing cron jobs
(with timezone = NULL in DB) also use the gateway's configured default
timezone when computing next_run_at. This ensures old jobs benefit
from the timezone setting without needing a DB migration or backfill.

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-03-10 18:44:28 +07:00

149 lines
3.4 KiB
Go

package pg
import (
"database/sql"
"encoding/json"
"time"
"github.com/adhocore/gronx"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *PGCronStore) scanJob(id uuid.UUID) (*store.CronJob, error) {
row := s.db.QueryRow(
`SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE id = $1`, id)
return scanCronSingleRow(row)
}
// --- Scan helpers ---
type cronRowScanner interface {
Scan(dest ...any) error
}
func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
var id uuid.UUID
var agentID *uuid.UUID
var userID *string
var name, scheduleKind string
var enabled, deleteAfterRun bool
var cronExpr, tz, lastStatus, lastError *string
var runAt, nextRunAt, lastRunAt *time.Time
var intervalMS *int64
var payloadJSON []byte
var createdAt, updatedAt time.Time
err := row.Scan(&id, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz,
&intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError,
&createdAt, &updatedAt)
if err != nil {
return nil, err
}
var payload store.CronPayload
json.Unmarshal(payloadJSON, &payload)
job := &store.CronJob{
ID: id.String(),
Name: name,
Enabled: enabled,
Schedule: store.CronSchedule{
Kind: scheduleKind,
},
Payload: payload,
CreatedAtMS: createdAt.UnixMilli(),
UpdatedAtMS: updatedAt.UnixMilli(),
DeleteAfterRun: deleteAfterRun,
}
if agentID != nil {
job.AgentID = agentID.String()
}
if userID != nil {
job.UserID = *userID
}
if cronExpr != nil {
job.Schedule.Expr = *cronExpr
}
if runAt != nil {
ms := runAt.UnixMilli()
job.Schedule.AtMS = &ms
}
if intervalMS != nil {
job.Schedule.EveryMS = intervalMS
}
if tz != nil {
job.Schedule.TZ = *tz
}
if nextRunAt != nil {
ms := nextRunAt.UnixMilli()
job.State.NextRunAtMS = &ms
}
if lastRunAt != nil {
ms := lastRunAt.UnixMilli()
job.State.LastRunAtMS = &ms
}
if lastStatus != nil {
job.State.LastStatus = *lastStatus
}
if lastError != nil {
job.State.LastError = *lastError
}
return job, nil
}
func scanCronSingleRow(row *sql.Row) (*store.CronJob, error) {
return scanCronRow(row)
}
// --- Helpers ---
// computeNextRun calculates the next run time for a schedule.
// defaultTZ is the gateway-level fallback IANA timezone used when the
// schedule itself does not specify a timezone (existing jobs with TZ = NULL).
func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time {
switch schedule.Kind {
case "at":
if schedule.AtMS != nil {
t := time.UnixMilli(*schedule.AtMS)
if t.After(now) {
return &t
}
}
return nil
case "every":
if schedule.EveryMS != nil && *schedule.EveryMS > 0 {
t := now.Add(time.Duration(*schedule.EveryMS) * time.Millisecond)
return &t
}
return nil
case "cron":
if schedule.Expr == "" {
return nil
}
tz := schedule.TZ
if tz == "" {
tz = defaultTZ
}
evalTime := now
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
evalTime = now.In(loc)
}
}
nextTime, err := gronx.NextTickAfter(schedule.Expr, evalTime, false)
if err != nil {
return nil
}
utcNext := nextTime.UTC()
return &utcNext
default:
return nil
}
}