carlos / platform Public

schedule: Spec.String renders the canonical short duration

time.Duration.String always spells out hours, minutes and seconds, so
a 6h schedule rendered as "every 6h0m0s" in the timers.spec column and
in carlos schedule ls (spec §4.3's own example is "every 6h"). Add
shortEvery, which drops whichever of hours/minutes is zero — every
ValidEvery duration is a whole number of minutes, so there is never a
seconds component to render. Updates the one test that pinned the old
stdlib-format registry row.
Paul Campbell pushed by paul@keymail.dev 3500766bc0506f3cd9412e46202ff817415183c8 parent 964f2a7
3 files changed, +49 −3
  • internal/schedule/converge_test.go +1 −1
  • internal/schedule/spec.go +21 −1
  • internal/schedule/spec_test.go +27 −1
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go
index 2b61969d5..2c6d6ba4c 100644
--- a/internal/schedule/converge_test.go
+++ b/internal/schedule/converge_test.go
@@ -48,7 +48,7 @@ func TestConvergeMaterialisesDeclarationPerExecInstance(t *testing.T) {
if err != nil {
t.Fatalf("%s: %v", h, err)
}
- if tm.Kind != KindSchedule || tm.Spec != "every 6h0m0s" || tm.Path != "/jobs/sync" || tm.NextAt != at("2026-08-23T18:00:00Z").Unix() {
+ if tm.Kind != KindSchedule || tm.Spec != "every 6h" || tm.Path != "/jobs/sync" || tm.NextAt != at("2026-08-23T18:00:00Z").Unix() {
t.Fatalf("%s: %+v", h, tm)
}
}
diff --git a/internal/schedule/spec.go b/internal/schedule/spec.go
index 7b439c95b..2627453fa 100644
--- a/internal/schedule/spec.go
+++ b/internal/schedule/spec.go
@@ -78,7 +78,7 @@ func ValidEvery(d time.Duration) error {
func (s Spec) String() string {
switch {
case s.Every > 0:
- return "every " + s.Every.String()
+ return "every " + shortEvery(s.Every)
case s.Cron != nil:
return "cron " + s.Cron.String()
default:
@@ -86,6 +86,26 @@ func (s Spec) String() string {
}
}
+// shortEvery renders d the way a member typed it, not the way
+// time.Duration.String does: stdlib always spells out hours, minutes AND
+// seconds ("6h" comes back "6h0m0s"), which is what a box would have stored
+// as the timers.spec column and what `carlos schedule ls` would have shown
+// verbatim. ValidEvery only ever lets a whole number of minutes through, so
+// d has no seconds component to render; this drops whichever of hours/
+// minutes is zero instead of printing it.
+func shortEvery(d time.Duration) string {
+ h := d / time.Hour
+ m := (d % time.Hour) / time.Minute
+ switch {
+ case h == 0:
+ return fmt.Sprintf("%dm", m)
+ case m == 0:
+ return fmt.Sprintf("%dh", h)
+ default:
+ return fmt.Sprintf("%dh%dm", h, m)
+ }
+}
+
// Next is the first fire strictly after now — epoch-aligned for every,
// minute-stepped for cron, the instant itself for a pending once and the
// zero time for a once that has passed. Always from now, never from the
diff --git a/internal/schedule/spec_test.go b/internal/schedule/spec_test.go
index 20bf8268a..2d877410d 100644
--- a/internal/schedule/spec_test.go
+++ b/internal/schedule/spec_test.go
@@ -6,7 +6,7 @@ import (
)
func TestParseSpecRoundTrips(t *testing.T) {
- for _, s := range []string{"every 6h0m0s", "cron 0 8 * * *", "at 1787004000"} {
+ for _, s := range []string{"every 6h", "every 1h30m", "cron 0 8 * * *", "at 1787004000"} {
sp, err := ParseSpec(s)
if err != nil {
t.Fatalf("%q: %v", s, err)
@@ -22,6 +22,32 @@ func TestParseSpecRoundTrips(t *testing.T) {
}
}
+// Spec.String is what lands in the timers.spec column and what `carlos
+// schedule ls` shows verbatim (spec §4.3's example is literally "every
+// 6h"). time.Duration.String would render that "6h0m0s" — always spelling
+// out hours, minutes and seconds — so String must render the canonical
+// short form instead: whatever a member typed, or the minimal equivalent
+// for whatever a box already had on disk.
+func TestSpecStringRendersCanonicalShortDuration(t *testing.T) {
+ cases := map[string]string{
+ "every 6h0m0s": "every 6h", // what stdlib Duration.String produces
+ "every 60m": "every 1h", // minutes that add up to whole hours
+ "every 90m": "every 1h30m",
+ "every 45m": "every 45m", // already minimal
+ "every 1m": "every 1m",
+ "every 720h": "every 720h", // the 30-day ceiling
+ }
+ for in, want := range cases {
+ sp, err := ParseSpec(in)
+ if err != nil {
+ t.Fatalf("%q: %v", in, err)
+ }
+ if got := sp.String(); got != want {
+ t.Errorf("ParseSpec(%q).String() = %q, want %q", in, got, want)
+ }
+ }
+}
+
// Epoch alignment (spec §6.1): "every 6h" fires at 00/06/12/18 UTC on every
// box, whatever time the row was created, so there is no phase to persist
// and nothing drifts across restarts.