Skip to content

[local-explorer-ui] Adds the Cron Trigger group to the Local Explorer - #15652

Open
tpmmorris wants to merge 8 commits into
tpmmorris/feat/cron-local-explorer/backendfrom
tpmmorris/feat/cron-local-explorer/frontend
Open

tpmmorris wants to merge 8 commits into
tpmmorris/feat/cron-local-explorer/backendfrom
tpmmorris/feat/cron-local-explorer/frontend

Conversation

@tpmmorris

Copy link
Copy Markdown
Collaborator

Adds a Cron Trigger group to the local explorer | Internal SPEC

Provides interface for users to test configured crons, and create custom crons ad-hoc for quick testing without having to reconfigure the worker every time a user wants to experiment with a new cron.

Two lists are provided, Configured Crons (left pane) and Custom Crons (right pane). A Configured Cron is one which is defined by the user in the Worker's config file. A Custom Cron is a transient cron defined and manipulated from the Local Explorer UI.

All crons:

  • Can be triggered at an arbitrary time set by the user (in UTC/epoch-milliseconds).
  • Can be duplicated to test rapidly for multiple different times.

Configured Crons:

  • Lists crons from the selected Worker's triggers.crons config option.

Custom Crons:

  • Allows users to define the cron expression.
    • Can use literal string or the cron expression builder.
  • Can be deleted at any point.

Note

All images in this description come from a WIP UI version.


Example landing page, showing the Configured Crons and Custom Crons panes with one cron in each


Cron expression builder for the 7:35AM on the last weekday of each month. The expression stays up to date as the builder is adjusted.


Examples of both the epoch millisecond and data and time setters, used to dictate the scheduledTime passed to the scheduled() handler.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows: N/A
    • Additional testing not necessary because: N/A
  • Public documentation
    • Cloudflare docs PR(s): WIP
    • Documentation not necessary because: N/A

@tpmmorris
tpmmorris added this pull request to stack #15653 September 15, 2026 14:23
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0cb2ad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@cloudflare/local-explorer-ui Minor
miniflare Minor
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-plugin Patch
wrangler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@tpmmorris
tpmmorris force-pushed the tpmmorris/feat/cron-local-explorer/frontend branch from f09fdf0 to 8b06127 Compare September 17, 2026 15:10
@tpmmorris
tpmmorris marked this pull request as ready for review September 17, 2026 15:16
devin-ai-integration[bot]

This comment was marked as resolved.

@tpmmorris
tpmmorris force-pushed the tpmmorris/feat/cron-local-explorer/frontend branch from 8b06127 to c5ce6e1 Compare September 17, 2026 15:42
@tpmmorris
tpmmorris force-pushed the tpmmorris/feat/cron-local-explorer/frontend branch from c5ce6e1 to 0f8952d Compare September 17, 2026 15:49
@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Sep 17, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15652

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15652

@cloudflare/codemods

npm i https://pkg.pr.new/@cloudflare/codemods@15652

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15652

@cloudflare/containers-shared

npm i https://pkg.pr.new/@cloudflare/containers-shared@15652

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15652

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15652

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15652

miniflare

npm i https://pkg.pr.new/miniflare@15652

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15652

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15652

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15652

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15652

@cloudflare/vitest-plugin

npm i https://pkg.pr.new/@cloudflare/vitest-plugin@15652

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15652

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15652

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15652

wrangler

npm i https://pkg.pr.new/wrangler@15652

commit: e0cb2ad

@tpmmorris
tpmmorris force-pushed the tpmmorris/feat/cron-local-explorer/frontend branch from 0f8952d to d771b20 Compare September 17, 2026 16:09
@tpmmorris
tpmmorris force-pushed the tpmmorris/feat/cron-local-explorer/frontend branch from d771b20 to d99127b Compare September 17, 2026 16:16

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 5 new potential issues.

Devin Review

Comment on lines +53 to +54
const epochMs = date.getTime();
return { kind: "exact", epochMs, utc: date.toISOString() };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Out-of-range calendar times remain triggerable

resolveUtcCalendarTime accepts years outside the scheduled API’s epoch bounds. These dates enable Trigger, but every invocation fails with status 400.

Learn more

Calendar values and epoch values reach the same scheduled_time request field. The epoch editor enforces MIN_DATE_EPOCH_MS and MAX_DATE_EPOCH_MS, but the calendar parser only checks JavaScript's broader year range. The Trigger gate treats every exact calendar result as valid. The API then rejects values outside its integer bounds.

Example: 9999-12-31T23:59:59.999 resolves successfully and enables Trigger. Its epoch exceeds MAX_DATE_EPOCH_MS, so the scheduled endpoint returns 400 instead of invoking the Worker.

Recommended fix: Return an invalid calendar resolution when date.getTime() falls outside MIN_DATE_EPOCH_MS through MAX_DATE_EPOCH_MS. Update the boundary tests to cover calendar values immediately inside and outside that range.

Suggested change
const epochMs = date.getTime();
return { kind: "exact", epochMs, utc: date.toISOString() };
const epochMs = date.getTime();
if (epochMs < MIN_DATE_EPOCH_MS || epochMs > MAX_DATE_EPOCH_MS) {
return {
kind: "invalid",
error: "Date and time are outside the supported scheduled-time range.",
};
}
return { kind: "exact", epochMs, utc: date.toISOString() };

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +475 to +489
let duplicateId: string | undefined;
setWorkers((current) => {
const entry = current[workerName] ?? emptyState();
const row = entry.rows.find((candidate) => candidate.id === id);
if (!row) {
return current;
}
const duplicate = duplicateCronRow(row);
duplicateId = duplicate.id;
return {
...current,
[workerName]: { ...entry, rows: [...entry.rows, duplicate] },
};
});
return duplicateId;

@devin-ai-integration devin-ai-integration Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicated cron rows miss focus

duplicateRow returns before its state updater assigns duplicateId. The caller receives undefined, so the new row never receives requested focus.

Learn more

React queues functional state updaters and can execute them after the event handler returns. Assigning duplicateId inside that updater cannot provide a synchronous return value. The caller immediately passes the returned value to focusSoon, which takes its fallback path when the value is undefined.

Example: Duplicating 0 17 * * sun adds the draft row, but focus moves to the Add draft control or pane heading instead of its Cron expression input.

Recommended fix: Create the duplicate and its ID before calling setWorkers, or change duplicateRow to accept a focus callback/effect that runs after the row is committed. Keep row lookup inside the state update so concurrent changes cannot duplicate a removed row.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +292 to +315
/** Persist only editable custom-row drafts; transient and configured state is omitted. */
export function writePersistedCustomCronRows(
storage: Storage,
key: string,
rows: CronRow[]
): void {
const customRows = rows
.filter((row) => row.source === "custom")
.slice(0, MAX_PERSISTED_CUSTOM_ROWS)
.map(persistedDraft);
if (customRows.length === 0) {
remove(storage, key);
return;
}
const raw = JSON.stringify(customRows);
if (byteLength(raw) > MAX_PERSISTED_CUSTOM_ROWS_BYTES) {
remove(storage, key);
return;
}
try {
storage.setItem(key, raw);
} catch {
// Quota, privacy, and security errors must not break the editor.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Custom drafts persist across sessions

The PR describes custom crons as transient, but writePersistedCustomCronRows restores them after navigation and reload. Confirm the intended product contract.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +57 to +60
const cronValid =
row.cron.trim() !== "" &&
(row.cronInputMode !== "builder" ||
(row.builderApplied === true && builder.expression === row.cron));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Expression mode skips cron validation

cronValid accepts every non-empty expression, and the endpoint applies the same minimal check. Confirm whether arbitrary cron strings are intentional test inputs.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +136 to +167
case "last-day-of-month":
expression = `${minute} ${hour} L * *`;
break;
case "last-weekday-of-month":
expression = `${minute} ${hour} LW * *`;
break;
case "nearest-weekday": {
const day = field(
errors,
"dayOfMonth",
draft.dayOfMonth,
1,
31,
"Day of month"
);
expression = `${minute} ${hour} ${day}W * *`;
break;
}
case "last-named-weekday":
expression = `${minute} ${hour} * * ${draft.weekday}L`;
break;
case "nth-weekday": {
const occurrence = field(
errors,
"occurrence",
draft.occurrence,
1,
5,
"Weekday occurrence"
);
expression = `${minute} ${hour} * * ${draft.weekday}#${occurrence}`;
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Builder extensions need contract verification

The builder emits L, LW, W, weekday L, and #. Local dispatch does not parse expressions, so verify production Cron Trigger compatibility.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Devin Review

Comment on lines +36 to +38
const activeWorkerName = loaderData.bootstrapAuthoritative
? selectedWorker?.name
: search.worker;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Successful refresh leaves explorer unavailable

After bootstrap fails, activeWorkerName remains the query value because bootstrapAuthoritative never changes. Successful refreshes cannot canonicalize workers or repopulate the sidebar.

Learn more

The root loader owns the Worker list and bootstrapAuthoritative, while CronTriggersProvider.refresh updates only provider-local state. A failed initial request therefore freezes the route and sidebar in bootstrap-failure mode. The provider can fetch valid metadata later, but this route still selects the original query Worker and the root layout still sees no Workers.

Example: Open ?worker=missing-worker while the initial Worker request returns 500. The automatic refresh then returns worker-1. The page keeps reading missing-worker, shows the unavailable state, and offers no selector, although worker-1 is now available.

Recommended fix: Make a successful refresh update or invalidate the root Worker loader, then derive and canonicalize activeWorkerName from the refreshed authoritative list. Preserve the requested query only while metadata remains unavailable.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +442 to +445
const poll = window.setInterval(() => {
if (document.visibilityState === "visible") {
void refresh(refreshWorker, true);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Slow polling discards every refresh

When refresh exceeds five seconds, the poll starts another request and invalidates the first generation. Sustained slow responses never update Worker metadata.

Learn more

Every poll starts a refresh without checking refreshingGeneration. RefreshGenerationTracker then marks only the newest request as current, and completed older requests exit without applying metadata. If each request takes longer than the polling interval, a newer request always exists before the previous response arrives.

Example: The Worker endpoint consistently responds in six seconds. Requests start at 0, 5, 10, and 15 seconds. Each response arrives one second after its successor starts, fails isLatest, and gets discarded.

Recommended fix: Do not start an automatic refresh while that Worker's previous refresh is pending. Alternatively, schedule the next poll after the current refresh settles instead of using an overlapping interval.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

import { Route as EmailRouteImport } from './routes/email'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ObservabilityIndexRouteImport } from './routes/observability/index'
import { Route as CronTriggersIndexRouteImport } from './routes/cron-triggers/index'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Verify route tree regeneration

Repository guidance forbids direct generated-file edits. Confirm the new route was added through TanStack Router generation rather than manual changes.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

2 participants