Changeset 0.27.2 (#169)

This commit is contained in:
2026-03-11 00:22:02 +00:00
parent e4efe6b934
commit dcb915555e
20 changed files with 2106 additions and 880 deletions

49
server/taskutil/cron.go Normal file
View File

@@ -0,0 +1,49 @@
package taskutil
import (
"time"
"github.com/robfig/cron/v3"
)
// cronParser is a shared parser instance. Standard 5-field cron with
// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.).
var cronParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)
// NextRunFromSchedule computes the next run time from a cron expression
// and timezone. Returns nil for "once" schedules (one-shot tasks).
//
// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field
// cron support via robfig/cron/v3.
func NextRunFromSchedule(schedule, timezone string) *time.Time {
if schedule == "once" {
return nil
}
now := time.Now()
if tz, err := time.LoadLocation(timezone); err == nil {
now = now.In(tz)
}
sched, err := cronParser.Parse(schedule)
if err != nil {
// Unparseable — log at call site, caller decides fallback
return nil
}
next := sched.Next(now).UTC()
return &next
}
// ValidateCron checks whether a cron expression is valid.
// Returns nil for valid expressions, error describing the problem otherwise.
// "once" is always valid (one-shot schedule).
func ValidateCron(schedule string) error {
if schedule == "once" {
return nil
}
_, err := cronParser.Parse(schedule)
return err
}

View File

@@ -0,0 +1,110 @@
package taskutil
import (
"context"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// TaskConfig holds the runtime task configuration read from global_settings.
// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent,
// tasks.default_max_tokens, tasks.default_max_tool_calls,
// tasks.default_max_wall_clock
type TaskConfig struct {
Enabled bool
AllowPersonal bool
MaxConcurrent int
DefaultMaxTokens int
DefaultMaxToolCalls int
DefaultMaxWallClock int // seconds
}
// DefaultTaskConfig returns sensible defaults when no global config is set.
func DefaultTaskConfig() TaskConfig {
return TaskConfig{
Enabled: true,
AllowPersonal: true,
MaxConcurrent: 5,
DefaultMaxTokens: 4096,
DefaultMaxToolCalls: 10,
DefaultMaxWallClock: 300,
}
}
// LoadTaskConfig reads task configuration from global_settings.
// Falls back to defaults for missing keys.
func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig {
cfg := DefaultTaskConfig()
if gc == nil {
return cfg
}
raw, err := gc.Get(ctx, "tasks")
if err != nil || raw == nil {
return cfg
}
if v, ok := boolVal(raw, "enabled"); ok {
cfg.Enabled = v
}
if v, ok := boolVal(raw, "allow_personal"); ok {
cfg.AllowPersonal = v
}
if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 {
cfg.MaxConcurrent = v
}
if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 {
cfg.DefaultMaxTokens = v
}
if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 {
cfg.DefaultMaxToolCalls = v
}
if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 {
cfg.DefaultMaxWallClock = v
}
return cfg
}
// ApplyDefaults fills zero-value budget fields on a task with the global defaults.
func (tc TaskConfig) ApplyDefaults(t *models.Task) {
if t.MaxTokens == 0 {
t.MaxTokens = tc.DefaultMaxTokens
}
if t.MaxToolCalls == 0 {
t.MaxToolCalls = tc.DefaultMaxToolCalls
}
if t.MaxWallClock == 0 {
t.MaxWallClock = tc.DefaultMaxWallClock
}
}
// ── helpers ────────────────────────────────────
func boolVal(m models.JSONMap, key string) (bool, bool) {
v, ok := m[key]
if !ok {
return false, false
}
b, ok := v.(bool)
return b, ok
}
func intVal(m models.JSONMap, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
// JSON numbers are float64 after Unmarshal
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
default:
log.Printf("[task_config] unexpected type for %s: %T", key, v)
return 0, false
}
}