From d9184312aa97ea4fbc3da070a58645c295962cb6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 4 Aug 2026 17:08:11 +0100 Subject: [PATCH] fix: schedule card placement, preview state, and scheduled-workflow docs --- claude.md => CLAUDE.md | 33 ++++++++++++ docsite/docs/vantage/workflows.md | 51 +++++++++++++++++++ .../workflows/EditWorkflowModal.tsx | 12 +++-- web/components/workflows/ScheduleCard.tsx | 26 +++++++--- 4 files changed, 110 insertions(+), 12 deletions(-) rename claude.md => CLAUDE.md (97%) diff --git a/claude.md b/CLAUDE.md similarity index 97% rename from claude.md rename to CLAUDE.md index 517b64b..b3fd964 100644 --- a/claude.md +++ b/CLAUDE.md @@ -124,6 +124,39 @@ A library of reusable **steps** (bash or PowerShell scripts with declared inputs Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever). +### Scheduled workflows + +A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron +and an IANA zone name, both validated at save time. `next_run_at` is +**persisted on the document, not held in memory**: a leader handover between +computing an occurrence and firing it would otherwise lose it or fire it twice, +the same argument that put `workflow_log_seq` in MongoDB. + +`server/internal/workflowsched` ticks every 30s inside the **existing** +`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers — +one role, one lock. **The atomic claim, not the lock, is what prevents a double +fire**: the `UpdateOne` matches on the document *and* its current `next_run_at` +while setting the recomputed one, so a second process reaching the same workflow +matches nothing and does nothing. The lock only makes it cheap. + +`workflowsched` **must not import `services`** — `services` already imports it +for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles. +`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps` +from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses, +with `"schedule"` as the actor, so there is no second dispatch path and the run +detail page needed no changes. + +`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile` +builds on Alpine, which ships no zone database, so without it +`time.LoadLocation("Europe/London")` fails and every schedule silently falls +back to UTC — an hour wrong for half the year, in the direction nobody notices +until a maintenance window lands in business hours. It works on a developer +machine either way, which is exactly why it gets forgotten. + +Skips are recorded and surfaced, not just logged: past the 1h grace window is +`missed`, an active run is `already_running`, and a schedule that no longer +parses is disabled rather than left spinning the loop every 30 seconds forever. + ### Server tags and workflow targeting A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32, diff --git a/docsite/docs/vantage/workflows.md b/docsite/docs/vantage/workflows.md index 74b0322..8d4305b 100644 --- a/docsite/docs/vantage/workflows.md +++ b/docsite/docs/vantage/workflows.md @@ -147,6 +147,57 @@ A run shows the script that actually executed, not the current library version. Targets run **in parallel**; steps within one server run **in order**. +## Schedules + +A workflow can carry a schedule, and Vantage will start it the same way a person +would — the same dispatch, the same snapshot, the same run page. A scheduled run +is an ordinary run with `schedule` recorded as who triggered it. + +Open a workflow, choose **Edit**, and tick **Run on a schedule**. The expression +is standard five-field cron: + +``` +minute hour day-of-month month day-of-week +``` + +The presets write cron underneath, so you can start from one and adjust: + +| Preset | Cron | +| ------------------- | ----------- | +| Hourly | `0 * * * *` | +| Nightly, 02:00 | `0 2 * * *` | +| Weekly, Sun 02:00 | `0 2 * * 0` | +| Monthly, 1st 02:00 | `0 2 1 * *` | + +There is no seconds field and no `@daily`-style shorthand. The next three +occurrences are shown as you type, and they are computed by the server rather +than the browser, so what you see is exactly what will fire. + +### Timezones + +A schedule stores an IANA timezone by name — `Europe/London`, not an offset. +That is what makes a 02:00 job stay at 02:00 across a daylight-saving change +instead of drifting an hour for half the year. An unknown zone is refused when +you save it, not at 2am. + +### Overlaps are skipped, not queued + +If a run of the same workflow is still going when the next occurrence comes +round, the occurrence is **skipped** and the reason recorded. It is not queued +behind the running one. A patch workflow that takes longer than its interval +should fall behind visibly rather than pile up. + +### Missed occurrences + +If the control plane was not running when an occurrence was due, it still fires +when the control plane comes back — but only within **one hour** of the due +time. Anything older is recorded as missed and dropped. A job missed by ten +minutes during an upgrade should still run; one missed by two days should not +suddenly fire at lunchtime. + +Either kind of skip is shown on the workflow's schedule panel, with the time it +was due and why it did not run. + ## Watching a run Step stdout and stderr stream back as chunks, are appended to a log file on the diff --git a/web/components/workflows/EditWorkflowModal.tsx b/web/components/workflows/EditWorkflowModal.tsx index b2683bd..2d8cb2a 100644 --- a/web/components/workflows/EditWorkflowModal.tsx +++ b/web/components/workflows/EditWorkflowModal.tsx @@ -54,7 +54,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: }; return ( - +
{error &&
{error}
}
@@ -79,7 +79,12 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: {servers && servers.length === 0 &&

No servers registered.

}
-
+ {/* The schedule saves through its own endpoint, so it sits above + the footer rather than under it — the footer's Save covers the + name and targets only, and the two are labelled accordingly. */} + + +
@@ -88,11 +93,10 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: Cancel
-
); diff --git a/web/components/workflows/ScheduleCard.tsx b/web/components/workflows/ScheduleCard.tsx index 47f17be..7554c04 100644 --- a/web/components/workflows/ScheduleCard.tsx +++ b/web/components/workflows/ScheduleCard.tsx @@ -28,7 +28,10 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) { const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"); const [error, setError] = useState(null); - const { data: preview } = useQuery({ + // isError, not !preview: an in-flight query and a rejected expression both + // leave data undefined, so keying the invalid message off the data alone + // flashes "not valid" at every keystroke on a perfectly good cron string. + const { data: preview, isError: previewFailed } = useQuery({ queryKey: ["schedule-preview", workflow.workflow_id, cron, tz], queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz), retry: false, @@ -44,13 +47,16 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) { }); return ( -
-
-

Schedule

+ // No panel chrome: this renders inside a Modal that already supplies the + // border, the background and a title bar, and nesting a second card in + // one produced a box inside a box. +
+
+

Schedule

{enabled ? "Active" : "Off"}
-
+