Files
steve c8559676ed
executus CI / test (push) Has been cancelled
P4b: skill noun + contrib/store (SQLite for budget/persona/skill/audit)
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>
2026-06-27 00:15:00 -04:00

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
}