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.
3 files changed,
+49
−3
internal/schedule/converge_test.go+1 −1internal/schedule/spec.go+21 −1internal/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. |