mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 07:12:03 +00:00
25fd9c9d6d
* 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>
149 lines
3.4 KiB
Go
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
|
|
}
|
|
}
|