fix(cron): stop the job editor rounding an interval it can represent exactly - #8769
Conversation
…exactly `parseJobDefaults` chose the interval unit by MAGNITUDE -- the largest unit that is <= `secs` -- and then rounded. For a 90-minute job that meant `secs = 5400 >= 3600`, so unit 'hours' and `Math.round(1.5) = 2`. `buildBody` re-serialises `intVal * 3600`, so opening that job and saving an unrelated field silently rewrote its schedule to 2 hours. The same shape one unit up: a 36-hour job (129600) chose 'days' and round(1.5) = 2, persisting as 2 days. Choose the largest unit that divides `secs` EVENLY instead. 90 minutes is exactly representable in the units the form already offers; the magnitude choice discarded that representation before the rounding ever ran. Where nothing divides evenly (e.g. 90 seconds) the pre-existing nearest-magnitude choice is kept deliberately. The form offers no sub-minute unit, so that schedule cannot be represented here at all -- widening the unit set is a separate question from this rounding defect, and quietly changing it under cover of the fix would be a second, undeclared change. This is the interval-side sibling of kirodotdev#8469, named in kirodotdev#8644's review as the one unfixed site of that root cause remaining in this function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the right layer: unit selection by divisibility, with the round-trip invariant pinned as a table and the declared non-change test-enforced. [DESIGN-REVIEWED] de75403 |
UX Review (Fable 5, fork) — ✅ PASSUX-level review of The diff is a targeted fix with tests and two before/after screenshots (PR-added, not in the base tree). No new strings, controls, or layout — it removes a silent-schedule-rewrite defect. One residual case remains: sub-minute jobs (creatable only outside this form) still display rounded and persist the rounded value silently on save, which the PR deliberately scopes out but leaves as the same silent-corruption class for that rare input. UX-Verdict: PASS Removes a silent schedule rewrite: a 90-minute job now displays and round-trips as 90 minutes instead of quietly becoming 2 hours. Suggestions
[UX-REVIEWED] de75403 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All checks are done: the change is a single cause-level hunk plus tests, the exports it relies on already exist in base ( First-Principles-Verdict: PASS Unit selection now asks "which unit divides What this change shipsIntent: stop the job editor from silently rewriting an interval it can represent exactly when the user saves an unrelated field — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] de75403 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate hinges on a The changed logic is correct for all realistic positive No findings. [OPUS-REVIEWED] de75403 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: interval unit selection in the cron job editor picked the largest unit <= the interval instead of the largest that divides it evenly, so a 90-minute job round-tripped to 2 hours on any unrelated save -- root cause is one expression in parseJobDefaults, fixed with an even-divisor test and a regression test. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
Problem / Motivation
The cron job editor picks the interval unit by magnitude — the largest unit
that is
<=the interval — and then rounds:For a 90-minute job,
secs = 5400is>= 3600, so the unit ishoursandMath.round(1.5)is 2.buildBodythen re-serialisesintVal * 3600.So opening a 90-minute job and saving any unrelated field — renaming it,
toggling silent — silently rewrites its schedule to 2 hours. The user is never
told; the form simply displayed the wrong interval and then persisted what it
displayed.
The same shape one unit up: a 36-hour job (
129600) selectsdays,Math.round(1.5)is 2, and it persists as 2 days.Why it matters
This is silent corruption of a schedule the user configured, triggered by an edit
that has nothing to do with scheduling. A job set to run every 90 minutes quietly
runs every 120, and nothing in the UI reports the change — the form shows "2
hours" as if that were what was stored.
90 minutes is exactly representable in the units this form already offers.
Nothing about the interval was un-representable; the magnitude test discarded the
representation that worked before rounding ever ran.
What changed (motivation → approach → change)
Symptom — an interval the form can represent exactly is rounded to a
neighbouring value, and the rounded value is written back on the next save.
Root cause — unit selection asks "which unit is this interval bigger than?"
when the question that preserves the value is "which unit divides it?".
Change — pick the largest unit that divides
secsevenly:Largest-that-divides, not smallest-that-divides: a minutes-only rule would also
round-trip correctly but would show a daily job as "1440 minutes". Exactness is
necessary, not sufficient — both properties are pinned by tests.
What is deliberately NOT changed. Where nothing divides evenly — a 90-second
job — the pre-existing nearest-magnitude behaviour is kept. The form offers no
sub-minute unit, so that schedule cannot be represented here at all; widening the
unit set is a separate product question, and changing it under cover of this fix
would be a second, undeclared change. There is a test asserting that path still
behaves exactly as it does today, so the boundary is enforced rather than merely
stated.
One production file, one hunk.
Tests
website/src/test/JobForm.interval.test.ts(new), driving the two exportedfunctions the editor actually uses:
keeps a 90-minute job in minutes instead of rounding it to 2 hourskeeps a 36-hour job in hours instead of rounding it to 2 days— the samedefect at the next unit boundary, so the fix is not special-cased to one value.
buildBody(parseJobDefaults(job))must return the interval it was given, for90 minutes, 36 hours, 150 minutes, 1 hour, 2 hours, 1 day, 30 minutes, 1 week.
This is what "saving an unrelated field must not rewrite the schedule" means in
code.
still prefers the largest EXACT unit, not merely the smallest one— guards the1440-minutes regression a naive fix would introduce.
leaves a sub-minute schedule on the pre-existing nearest-unit behaviour—pins the declared non-change above.
never produces an interval below the input control minimum of 1— the numberinput is
min={1}, so the clamp must survive.Red-before against the unmodified
maincomponent: 5 failed / 8 passed,with the corruption stated numerically —
Green-after: 13 passed.
Gates:
tsc -b✓,eslint✓ on both changed files.Manual verification
N/A — unit coverage sufficient. The round-trip table exercises the exact
parse→serialise path the editor runs on save, which is where the corruption
happens; a manual click-through would confirm one value of the eight the table
covers.
Screenshots / video
Both captures render the real
JobFormcomponent with the app's own stylesheet,mounted against one fixture job —
every_secs: 5400, i.e. every 90 minutes. Only thecomponent under test differs between them.
Before — the editor shows
2 hours, and saving persists 7200s:After — the editor shows
90 minutes, and saving persists 5400s:No layout, component, or theme change — the same two controls render; the values
in them are what the fix corrects.
Related Issues
No linked issue. This is the residual named in #8644's First Principles
review, which fixed the cron-expression side of #8469 and counted this as the one
remaining unfixed sibling of the same root cause in the same function:
That is the approach implemented here.
Pattern harvest
Rule candidate:
review-promptPattern: a lossy parse feeding a re-serialising save. Neither half is a defect
alone — a display rounding is harmless if nothing writes it back, and a serialiser
is correct if its input is exact. The corruption exists only because the editor
round-trips:
parse → (edit an unrelated field) → serialisepublishes the parse'sapproximation as the user's stored value. When reviewing an edit form, ask whether
serialise(parse(x)) === xfor everyxthe backend can store, and pin it as atable — that assertion catches this whole class, while a per-field test does not,
because every individual field looks right.
This is the second instance in one function (#8469 was the cron-expression half),
which is what makes it a pattern rather than a bug.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code