Skip to content

feat(catalog): add a schedule manager to the catalog worker - #3878

Open
rossnelson wants to merge 3 commits into
mainfrom
catalog-schedule-manager
Open

rossnelson wants to merge 3 commits into
mainfrom
catalog-schedule-manager

Conversation

@rossnelson

@rossnelson rossnelson commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds a schedule manager to the Temporal Catalog so examples can declare scheduled jobs and the catalog worker provisions them at startup.

  • Declaration on the example. A workflow example can carry a schedule block: id, cron expressions or intervals, optional input, a required paused flag, and a note. Generation validates every field and rejects duplicate schedule ids. The hello example declares an hourly schedule.
  • Sync example. A new shared schedule-sync example holds the reconciler. Its workflow lists schedules in the target namespace, keeps only those carrying the uiCatalog ownership memo, and plans create, update, and delete. Foreign schedules are never touched. One squatting on a declared id is reported as blocked. Orphaned catalog-owned schedules are deleted.
  • Startup bootstrap. After Nexus endpoint provisioning, the dev script ensures the hourly ui-catalog-schedule-sync manager schedule exists, rewrites its args to the current declared list, and triggers one sync. Works over plaintext, API key, and TLS.
  • Disabled by default. Nothing runs unless .env.catalog.local sets CATALOG_SCHEDULES=enabled. Any other value fails startup naming the variable. The banner prints the mode.
  • UI. The example page shows an advisory schedule readiness check that never blocks Run. Missing shows the enable setting as copyable text. Present links to the schedule page. The list shows a Scheduled badge.

Why

The catalog worker needs a way to exercise Schedules end to end, and a place to add scheduled jobs to its startup sequence. The design mirrors the existing Nexus endpoint provisioning step and keeps the catalog's rules: declared in code, validated at generation, task queues from registration only.

Verification

  • pnpm check, pnpm lint, pnpm catalog verify: clean.
  • Catalog and scripts tests: 247 files, 3212 passed.
  • End to end against a local server in a dedicated namespace: cold start, restart, hand-deleted schedule recreated, hand-edited cron rewritten, memo-marked orphan deleted, foreign schedule ignored, foreign squatter blocked, disabled default, and invalid flag value. Details in src/lib/catalog/scenarios.md.

Notes for reviewers

  • Run the manager in a namespace no other reconciler owns. A reconciler that deletes unknown schedules will remove the catalog's schedules on its next pass.
  • Every worker restart under watch mode triggers a sync when enabled.
  • Disabling the manager later leaves existing catalog schedules in place. A pnpm catalog schedules clear command is a natural follow-up.

Workflow examples can declare a schedule in code. Generation validates the
declaration, rejects duplicate schedule ids, and emits it into the browser
descriptor. The hello example declares an hourly schedule.

A new shared schedule-sync example holds the reconciler. Its workflow lists
schedules in the target namespace, keeps only those carrying the uiCatalog
ownership memo, and plans create, update, and delete. Foreign schedules are
never touched; one squatting on a declared id is reported as blocked.
Orphaned catalog-owned schedules are deleted.

The worker startup sequence bootstraps an hourly ui-catalog-schedule-sync
manager schedule after Nexus endpoint provisioning, rewrites its arguments
to the current declared list, and triggers one sync. This works over
plaintext, API key, and TLS connections.

The manager is disabled by default. CATALOG_SCHEDULES=enabled in
.env.catalog.local turns it on; any other value fails startup naming the
variable. The banner prints the mode.

The example page shows an advisory schedule readiness check that never
blocks Run, with the enable setting as copyable text when the schedule is
missing and a link to the schedule page when present. The list shows a
Scheduled badge.
@rossnelson
rossnelson requested a review from a team as a code owner September 3, 2026 13:42
@vercel

vercel Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
holocene Ready Ready Preview Sep 3, 2026 3:36pm UTC

Request Review

The reconciler rewrote state from the declaration on every update, so a
catalog-owned schedule paused in the UI was resumed by the next sync --
at most an hour later, and immediately on the next worker restart under
watch mode. Stopping a misbehaving declared schedule meant editing code
and redeploying the worker, which is the wrong tool during an incident.

A running state that disagrees with the declaration is now treated as a
decision somebody made. The plan gains a `held` bucket, and a held
schedule is skipped entirely: spec, action and state are all left alone,
because rewriting the cron of a schedule someone paused mid-incident is
the same surprise in a different form. Drift in either direction counts,
so a declared-paused schedule somebody resumed is held too.

Hold beats delete. An owned schedule that no example declares any more is
reported rather than deleted when it is paused; otherwise pausing
something during an incident and then dropping its example would remove
it silently. A foreign schedule is still blocked whatever its state --
its running state is none of the manager's business.

Pausing `ui-catalog-schedule-sync` itself now stops reconciliation
outright. The startup bootstrap reads the manager's paused state and
skips both the argument rewrite and the trigger, so the pause survives
worker restarts instead of being undone by the next one.

Held ids travel in the workflow result beside created, updated, deleted
and blocked, so a hold is visible in workflow history without reading
worker logs. The example page's readiness check reports the schedule as
held, says which direction it drifted, and still links to it.

A held schedule can drift arbitrarily far while nothing is rewriting it.
Resuming it hands it back to the reconciler, which rewrites all of that
on the next pass -- documented in the README, the skill, and the example
setup notes, so nobody treats a hold as a place to keep hand edits.
The bootstrap received the CATALOG_TARGET_ID-filtered bindings, so the
desired list it built covered only the targets this process happened to
run. The reconciler deletes any catalog-owned schedule that is not on
that list, so narrowing the run did not reconcile a subset -- it deleted
what the other targets declare.

Reachable today: CATALOG_TARGET_ID=shared-workflows with the manager
enabled finds the schedule-sync example, rewrites the manager's args to
the shared-only list and triggers it, removing schedules a scaffolded
local example declared. The opposite filter escaped only by accident,
because the manager's example was then absent and the bootstrap skipped.

dev.ts now passes the unfiltered bindings, and the bootstrap takes the
registry's target ids alongside them so it can prove the set is complete
and refuse rather than act when it is not. That check reads the registry
rather than the bindings, which is what lets it see the difference -- and
it is what a caller assembling bindings elsewhere, such as a deploy step
with one function per task queue, would otherwise get wrong silently.

Running targets are now passed separately from declared ones. A run
narrowed to a target that does not register schedule-sync reports a skip
instead of triggering a sync that would sit as backlog nothing polls.

Two extractions come with it, so the next caller does not copy this:

- catalogScheduleManagerOperations builds the four SDK callbacks the
  bootstrap needs from an open connection, so every caller writes the
  same memo, the same spec, and the same not-found handling. dev.ts loses
  about ninety lines of plumbing.
- toScheduleSpec moves out of the reconciler's activities into
  schedule-spec.ts. It is pure conversion, and living beside Client and
  Connection meant anything describing a schedule pulled a client in with
  it. Both of its imports are type-only, so it now has no runtime
  dependency at all.

This branch was successfully deployed

1 active deployment
Preview — 15da7907 Deployed Sep 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant