Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions docs/memory/feature-flows/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,32 @@ Broadcast via dedicated scheduler -> Redis pub/sub -> backend WebSocket relay fo
- `0 */6 * * *` - Every 6 hours
- `*/30 * * * *` - Every 30 minutes

### Client-side cron validation (#925)

The schedule form pre-validates the cron expression as the user types, and the list marks
stored-invalid rows — the backend 400 is no longer the first feedback (design-system p17).

- **Validator**: `src/frontend/src/utils/cronValidation.js` — a pure, zero-dependency mirror of
the backend's exact grammar (`services/schedule_validation.py::validate_cron_expression`:
5-field split + verbatim `_dow_to_apscheduler` port + APScheduler 3.11 field rules, including
the prefix-matched name expressions, the step-span rule, and the Python-truthiness
`last or MAX` fallback). Total + fail-open: an internal error returns `{valid: true}` so a
port bug can never brick the panel; the server 400 stays the authority.
- **Form gating** (`SchedulesPanel.vue`): `shouldShowCronError` (pure, exported) — while
creating, the inline error appears only after first blur and only for non-empty input; while
editing, an invalid stored cron (incl. empty) shows unconditionally. The format-hint line is
the reserved error slot (no modal jump). Submit is disabled only when the cron is
**non-empty AND invalid** — empty keeps the native `required` bubble. Presets come from the
exported `CRON_PRESETS` and are fixture-proven valid.
- **Row icon**: `computeCronValidityMap(schedules)` → warning triangle inside the cron chip for
invalid rows, tooltip/aria-label exactly `Invalid cron expression`.
- **Drift alarm**: both suites assert the shared probe-generated fixture
`tests/fixtures/cron-grammar-cases.json` — `tests/unit/test_925_cron_grammar_fixture.py`
against the live backend validator (fires on an APScheduler grammar change),
`src/frontend/tests/unit/cronValidation.spec.js` against the client mirror. Quirk rows
(`MON/999`, `jan/0`, `lastx`, `0-6,1` vs `0-6`, falsy-zero `0-0/2`) are pinned verbatim and
must not be "fixed" — parity outranks tidiness.

---

## Dependencies
Expand Down
48 changes: 48 additions & 0 deletions docs/memory/requirements/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,54 @@ schedules:
`docs/memory/feature-flows/scheduling.md`,
`docs/memory/feature-flows/agent-compatibility-validation.md`

### 10.17 Client-Side Cron Validation (#925)
- **Status**: ✅ Implemented (2026-08-13)
- **GitHub Issue**: #925
- **Description**: The schedule form validated cron expressions only at save time — the backend's
400 was the first feedback — and a stored-invalid row (legacy croniter-era data, or rows created
before a validator tightening) sat in the list indistinguishable from a healthy one. The frontend
now pre-validates as the user types (design-system p17) and marks invalid stored rows.
- **Requirement — mirror contract, not a second grammar**: the client validator
(`src/frontend/src/utils/cronValidation.js`, pure leaf, **zero npm deps**) is a hand-rolled
mirror of the backend's exact acceptance grammar —
`services/schedule_validation.py::validate_cron_expression` = strict 5-field split +
`_dow_to_apscheduler` translation (ported verbatim, branch order included) + APScheduler 3.11
field rules (`AllExpression`/`RangeExpression`/name-range prefix expressions, step-span rule,
Python-truthiness `last or MAX` fallback — deliberately `||` not `??` in JS). Cron libs
(`cron-parser`, `cronstrue`) were rejected: they implement a foreign grammar with ≥8 proven
verdict disagreements (`@daily`, 6-field, dow `0-6`, `last`, `7/2`, `L`/`#`, step-span,
`5/2`). This is the learnings.md #1472 lesson ("validate with the parser that registers")
applied one level up: the client validates with a pinned mirror of that parser.
- **Drift alarm — shared fixture asserted by both suites**: the grammar contract is
`tests/fixtures/cron-grammar-cases.json` (~110 rows, **generated from a probe against the live
backend validator, never hand-typed**). `tests/unit/test_925_cron_grammar_fixture.py` re-proves
every row against `validate_cron_expression` in the backend CI env (an APScheduler bump that
changes the grammar fails there loudly); `src/frontend/tests/unit/cronValidation.spec.js`
asserts the client mirror agrees row-for-row. Quirk rows (prefix-matched names `MON/999` /
`jan/0` / `lastx`, comma-translation asymmetry `0-6,1` vs `0-6`, falsy-zero `0-0/2`) are
pinned and must NOT be "fixed" — parity outranks tidiness.
- **Fail-open posture**: the validator is total (`String(expr ?? '')`) and a top-level catch
returns `{valid: true}` + `console.error` — an internal port bug must not brick the panel or
block saves. The backend 400 remains the enforcement authority; client validation is UX only.
- **Form gating**: inline error in a reserved-footprint slot (the format-hint line doubles as the
error slot — no modal jump, p4/p6); submit disabled **only when non-empty AND invalid** — an
empty cron keeps the native `required` bubble path; while **editing**, an invalid (incl.
empty/whitespace) stored cron shows its error unconditionally so the disabled Update button is
never unexplained; while creating, errors appear only after first blur (no mid-typing flash).
- **List surface**: each stored row with an invalid `cron_expression` renders a warning triangle
inside its cron chip, tooltip + aria-label exactly `Invalid cron expression` (shape + hue,
p24). Presets are sourced from the exported `CRON_PRESETS` so "presets never warn" is tested
against the shipped list.
- **Documented divergence (client-stricter only)**: the client accepts ASCII digits only where
Python's `int()`/`\d` also accept Unicode digits / `+` signs / underscores — rejecting e.g.
`٥ * * * *` that the server accepts. Direction is client-stricter on absurd input; the
damaging direction (client-invalid/server-valid false warnings on real input) is guarded by
the quirk rows. The fixture marks such rows `divergence: "client-stricter"`.
- **Out of scope**: no backend runtime change (400 contract byte-preserved); no server-side
migration/repair of stored invalid rows; no client timezone validation (the form select is a
fixed known-good list; cron field validity is timezone-independent).
- **Flow**: `docs/memory/feature-flows/scheduling.md` (§ Client-side cron validation)

---

## 34. Agent-Defined Pipelines (#919)
Expand Down
78 changes: 71 additions & 7 deletions src/frontend/src/components/SchedulesPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,29 @@
type="text"
required
placeholder="0 9 * * *"
@blur="cronTouched = true"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-action-primary-500"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month day_of_week (e.g., "0 9 * * *" for 9 AM daily)
<!-- #925: the format hint doubles as the reserved error slot — one
footprint (min-h-8 ≈ the hint's own 2 wrapped lines), so the
modal never jumps when validation kicks in (p4/p6). -->
<p
class="text-xs mt-1 min-h-8"
:class="showCronError ? 'text-status-danger-600 dark:text-status-danger-400' : 'text-gray-500 dark:text-gray-400'"
>
<template v-if="showCronError"><span data-testid="cron-error">{{ cronVerdict.error }}</span></template>
<template v-else>Format: minute hour day month day_of_week (e.g., "0 9 * * *" for 9 AM daily)</template>
</p>
<div class="mt-1 flex flex-wrap gap-1">
<button type="button" @click="setCronPreset('0 9 * * *')" class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 dark:text-gray-300">Daily 9 AM</button>
<button type="button" @click="setCronPreset('0 9 * * 1')" class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 dark:text-gray-300">Weekly Mon</button>
<button type="button" @click="setCronPreset('0 */6 * * *')" class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 dark:text-gray-300">Every 6h</button>
<button type="button" @click="setCronPreset('*/30 * * * *')" class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 dark:text-gray-300">Every 30m</button>
<!-- #925: presets come from the exported CRON_PRESETS so "presets
never warn" is tested against the shipped list. -->
<button
v-for="p in CRON_PRESETS"
:key="p.expression"
type="button"
@click="setCronPreset(p.expression)"
class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 dark:text-gray-300"
>{{ p.label }}</button>
</div>
</div>

Expand Down Expand Up @@ -248,9 +261,11 @@
>
Cancel
</button>
<!-- #925: disabled ONLY for non-empty-AND-invalid cron — an empty
cron keeps the native `required` bubble path for the form. -->
<button
type="submit"
:disabled="formLoading"
:disabled="formLoading || submitBlockedByCron"
class="px-4 py-2 text-sm font-medium text-white bg-action-primary-600 border border-transparent rounded-md hover:bg-action-primary-700 disabled:bg-gray-400"
>
<span v-if="formLoading" class="flex items-center">
Expand Down Expand Up @@ -356,6 +371,21 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<code class="font-mono bg-gray-100 dark:bg-gray-700 px-1 rounded">{{ schedule.cron_expression }}</code>
<!-- #925: stored-invalid cron (won't register with the scheduler).
Inside the chip span so flex-wrap can't detach the icon from
its chip; `=== false` so a map miss can never false-warn. -->
<span
v-if="cronValidity[schedule.id] === false"
class="ml-1 text-status-warning-600 dark:text-status-warning-400"
title="Invalid cron expression"
aria-label="Invalid cron expression"
role="img"
data-testid="cron-invalid-warning"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19H19a2 2 0 001.75-2.96l-6.93-12a2 2 0 00-3.5 0l-6.93 12A2 2 0 005.07 19z" />
</svg>
</span>
</span>
<span class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down Expand Up @@ -859,6 +889,14 @@ import ScheduleAnalyticsCard from './ScheduleAnalyticsCard.vue'
import LoadFailed from './LoadFailed.vue'
import InlineError from './InlineError.vue'
import { apiErrorMessage } from '../utils/apiError'
// #925: client-side mirror of the backend cron grammar (see utils/cronValidation.js
// header — parity pinned by tests/fixtures/cron-grammar-cases.json in both suites).
import {
validateCronExpression,
shouldShowCronError,
computeCronValidityMap,
CRON_PRESETS,
} from '../utils/cronValidation'
import { useAuthStore } from '../stores/auth'
import { useExecutionsStore } from '../stores/executions'

Expand Down Expand Up @@ -906,6 +944,29 @@ const showCreateForm = ref(false)
const editingSchedule = ref(null)
const formLoading = ref(false)
const formError = ref('')

// #925: client-side cron validation. Display gating lives in the pure, exported
// shouldShowCronError/computeCronValidityMap (node-tested); these computeds are
// thin wiring only.
const cronTouched = ref(false) // first blur of the cron input (create flow)
const cronVerdict = computed(() => validateCronExpression(formData.value.cron_expression))
const showCronError = computed(() =>
shouldShowCronError({
valid: cronVerdict.value.valid,
expr: formData.value.cron_expression,
touched: cronTouched.value,
editing: !!editingSchedule.value,
})
)
// Submit blocked ONLY when non-empty AND invalid — empty keeps the native
// `required` bubble (disabling on empty would kill the browser's
// constraint-validation path for every field in the form).
const submitBlockedByCron = computed(() => {
const expr = String(formData.value.cron_expression ?? '')
return expr.trim() !== '' && !cronVerdict.value.valid
})
// Row warning icons: id → validity, recomputed only when the list is replaced.
const cronValidity = computed(() => computeCronValidityMap(schedules.value))
const triggerLoading = ref(null)
// #1634: a Set, not one id — two rows can be in flight at once, and a single ref
// let the first completion re-enable the second row's control mid-request (AC #6).
Expand Down Expand Up @@ -1285,6 +1346,8 @@ function closeForm() {
showCreateForm.value = false
editingSchedule.value = null
formError.value = ''
cronTouched.value = false // #925: next create starts un-flashed

formData.value = {
name: '',
cron_expression: '',
Expand Down Expand Up @@ -1596,6 +1659,7 @@ watch(() => props.initialMessage, (newMessage) => {
formData.value.enabled = true
formData.value.timeout_seconds = 3600 // #665
formData.value.allowed_tools = null
cronTouched.value = false // #925: this path opens the form without closeForm()
showCreateForm.value = true
}
}, { immediate: true })
Expand Down
Loading
Loading