c8559676ed
executus CI / test (push) Has been cancelled
Merges the skill half of the persona/skill pair plus the second nested module. (Squashed onto main from phase-4b-skill; the audit/budget/persona batteries it was stacked on already landed via the P4 merge.) - skill/: clean-redesign Skill noun + LEAN SkillStore (lifecycle/versions/ schedule only) + ToRunnable + Memory default. - contrib/store/: separate go.mod carrying modernc.org/sqlite, so the driver never enters the core go.sum. db.Budget()/Personas()/Skills()/Audit() back all four store seams (JSON-blob + indexed columns; round-trip tested). Includes the verified gadfly #5 fixes (AppendVersion tx+UNIQUE+error, Mark*ScheduledRun atomic json_set, busy_timeout, NaN guard). - CI: builds + tests the nested module and asserts it owns the sqlite driver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
3.6 KiB
Go
108 lines
3.6 KiB
Go
package skill
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
// scheduleParser is the cron parser shared across the skills package. It
|
|
// accepts the standard 5-field syntax (minute hour dom month dow) plus
|
|
// descriptors such as @daily, @hourly, etc. We do not enable the seconds
|
|
// field — schedule cadence is governed in minutes, and a seconds field
|
|
// would invite specs that fire below the min-interval floor without
|
|
// surfacing as such in the spec text.
|
|
//
|
|
// Why standalone vs. cron.ParseStandard: ParseStandard rejects descriptors
|
|
// (@daily, @hourly). Skills callers may want to write @daily as a
|
|
// shorthand alongside the explicit "daily" / "weekly" forms we translate
|
|
// below.
|
|
var scheduleParser = cron.NewParser(
|
|
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
|
)
|
|
|
|
// ParseSchedule turns a user-supplied schedule expression into a
|
|
// cron.Schedule. The empty string returns (nil, nil) — callers should
|
|
// treat that as "on-demand only".
|
|
//
|
|
// Why: Skill.Schedule is a string field stored verbatim; the validator,
|
|
// the scheduler runner, and any future tooling all need to round-trip
|
|
// through the same parser. Centralising it here avoids drift.
|
|
//
|
|
// Accepted shorthands:
|
|
// - "daily" → "0 0 * * *" (midnight UTC every day)
|
|
// - "weekly" → "0 0 * * 0" (midnight UTC every Sunday)
|
|
//
|
|
// Anything else is fed through robfig/cron/v3's standard parser
|
|
// (descriptors enabled).
|
|
//
|
|
// Test: schedule_test.go covers shorthand expansion and invalid-spec
|
|
// rejection.
|
|
func ParseSchedule(expr string) (cron.Schedule, error) {
|
|
expr = strings.TrimSpace(expr)
|
|
if expr == "" {
|
|
return nil, nil
|
|
}
|
|
switch strings.ToLower(expr) {
|
|
case "daily":
|
|
expr = "0 0 * * *"
|
|
case "weekly":
|
|
expr = "0 0 * * 0"
|
|
}
|
|
sched, err := scheduleParser.Parse(expr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid schedule %q: %w", expr, err)
|
|
}
|
|
return sched, nil
|
|
}
|
|
|
|
// ScheduleMinInterval returns an estimate of the smallest gap between
|
|
// consecutive fire times for a parsed schedule. It samples the next two
|
|
// fire times from a couple of starting points and returns the smallest
|
|
// observed gap.
|
|
//
|
|
// Why: cron.Schedule does not expose a "smallest interval" API. The
|
|
// validator needs this to enforce a per-skill min-interval floor (so an
|
|
// admin can't accidentally register "* * * * *" and burn GPU minutes).
|
|
// Two probe points are enough to catch irregular schedules whose tightest
|
|
// gap appears at a particular point in the week (e.g. "0 9 * * 1,5",
|
|
// where Mon→Fri is 4d but Fri→Mon is 3d — both sampled).
|
|
//
|
|
// Returns 0 if sched is nil.
|
|
//
|
|
// Test: schedule_test.go covers a "* * * * *" minute-interval probe and
|
|
// the irregular Mon/Fri case.
|
|
func ScheduleMinInterval(sched cron.Schedule) time.Duration {
|
|
if sched == nil {
|
|
return 0
|
|
}
|
|
// Probe from a fixed reference and from a midweek offset. Six fire
|
|
// times across two starts catches weekly irregularities (the worst
|
|
// case is a schedule that fires once a week — we still get one gap
|
|
// per probe). Using a wall-clock-independent reference keeps the
|
|
// test deterministic.
|
|
starts := []time.Time{
|
|
time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Monday 00:00
|
|
time.Date(2024, 1, 4, 12, 30, 0, 0, time.UTC), // Thursday 12:30
|
|
time.Date(2024, 6, 15, 23, 59, 59, 0, time.UTC), // mid-year, late
|
|
}
|
|
var min time.Duration
|
|
for _, t := range starts {
|
|
// Sample three consecutive fires per start to capture two gaps.
|
|
f1 := sched.Next(t)
|
|
f2 := sched.Next(f1)
|
|
f3 := sched.Next(f2)
|
|
for _, gap := range []time.Duration{f2.Sub(f1), f3.Sub(f2)} {
|
|
if gap <= 0 {
|
|
continue
|
|
}
|
|
if min == 0 || gap < min {
|
|
min = gap
|
|
}
|
|
}
|
|
}
|
|
return min
|
|
}
|