Files
executus/skill/schedule.go
T
steve 41659b2412 P4: skill noun — domain + LEAN SkillStore + ToRunnable + Memory
The skill half of the persona/skill pair, as a clean redesign (not a faithful
lift of mort's 60-method skills.Storage kitchen sink):
- skill.go/skill_version.go/validate.go/inputs.go/schedule.go moved clean; the
  only host couplings severed: llms.IsTierName -> model.IsTierName, and the
  chatbot DefaultChatbotInputName const localized.
- store.go: a DELIBERATELY LEAN SkillStore — skill lifecycle (CRUD + visibility)
  + versioning + scheduling ONLY. The KV/file/quota sub-stores that were fused
  into mort's interface are the tools/ store seams; email/channel grants stay
  host concerns.
- runnable.go: Skill.ToRunnable() lowers a skill into run.RunnableAgent (flat
  tool list, no palette — composition is a host concern); DueAt() helper.
- memory.go: NewMemory() — zero-dep in-process SkillStore (visibility filters,
  newest-first versions).

Tests: ToRunnable mapping, visibility (public/shared/private) listing, version
ordering + lookup. No mort dependency (go.mod tidy clean); core imports ZERO
from skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:40:58 -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
}