Scheduling plugin
This is a remote service plugin. It schedules one pinned version to be published, or one pinned published version to be unpublished. It also runs named releases: groups of publish and unpublish items across models and languages, run together at a set time or now. It carries out each change through Dee Wan plugin protocol v1. It has its own Cloudflare Worker, its own D1 database, its own cron trigger, and its own management UI. None of its code runs inside Dee Wan.
The manifest id is dev.dee-wan.scheduling, set as PLUGIN_ID in src/app.ts. It never changes across releases: an installed plugin’s id cannot change, and a release with a different id is refused as manifest_identity_changed. A fork that you deploy as a different plugin needs its own reverse-domain id.
This directory is prepared to become the root of its own repository, https://github.com/dee-wan-cms/dee-wan-scheduling.git. Until that move happens it lives in the Dee Wan repository at plugins/scheduling. The last section, Moving to its own repository, lists the steps.
Documentation: Testing a plugin sets up this plugin alongside a local Dee Wan instance. Scheduling, end to end then walks through install, a scheduled publish, and each failure. On the core side, see Writing a plugin, Protocol v1, Plugin security, and Testing a plugin.
Compatibility
Section titled “Compatibility”| Plugin | Manifest | Dee Wan protocol |
|---|---|---|
| 0.1.0 | v1 | v1 |
Routes
Section titled “Routes”| Route | Who | What |
|---|---|---|
GET /dee-wan/manifest.json |
Dee Wan admin | Manifest v1, with the origin taken from PUBLIC_ORIGIN. |
GET /assets/scheduling.css |
Browser | The management UI’s stylesheet. |
POST /admin/activation-codes |
Operator, with Bearer ADMIN_SECRET |
Issues a one-time code, valid for 15 minutes. Body {} issues an activation code. {"installation_id":"..."} issues a rotation code for that installation only. Answers 201 with {"code", "purpose", "installation_id", "expires_at"}: purpose is activate or rotate, installation_id is null for an activation code, and expires_at is in milliseconds since the epoch. |
POST /dee-wan/activate |
Dee Wan core | activate creates an installation and refuses an existing one. rotate replaces the token. Each code is consumed once. |
POST /dee-wan/operations/schedule-status |
Dee Wan core | The schedule-status operation. Takes the closed invocation envelope, exchanges its code with the installation’s token, reads the bound entry and answers its pending, retrying and running schedules as rows. Read-only. An open envelope is 400 and an unknown installation 404; if core will not exchange the code, if the request names another operation, or if the entry cannot be read, the operation returns status: "refused". |
POST /dee-wan/operations/schedule-column |
Dee Wan core | The schedule-column content-list column. Takes the closed invocation envelope, exchanges its code, and answers one cells block: a badge for each named entry that has a pending, retrying or running schedule (the soonest, as Publish 2026-09-20 09:00; tone warning once it is retrying or running). It reads only this installation’s schedules and writes nothing. An entry with no live schedule gets no cell. A code core will not exchange, another operation, a bound entry or input that is not a list of entry ids answers status: "refused". |
POST /dee-wan/operations/schedule-draft |
Dee Wan core | The schedule-draft operation. Exchanges the code, takes the closed input { locale, transition, due_at, timezone, version_id } (due_at a whole UTC minute, timezone IANA, version_id the saved draft version to pin), reads the bound entry through the binding’s delegation_id and creates the same pinned publish schedule as the form, pinned to exactly version_id, keeping that delegation and the caller’s email. The same request again answers the schedule it made. Refusals answer status: "refused" by name: invalid_input, invalid_instant, unknown_timezone, past_time, entry_unreadable, requester_unauthorized, locale_unavailable, transition_not_allowed, nothing_to_pin, version_moved, draft_changed (the entry’s current draft is not version_id), already_scheduled, execution_in_progress. It never supersedes another live schedule. |
POST /dee-wan/operations/releases |
Dee Wan core | The releases site operation. Exchanges the code and reads the reach of the binding’s delegation_id. Empty release_id: every release as rows and releases.json. With release_id: that release’s items the caller may read, with readiness or outcome, as rows and release.json (the receipt). Read-only. |
POST /dee-wan/operations/release-edit |
Dee Wan core | The release-edit site operation: action create (name), rename (release_id, name), add / add_future (release_id, instance_id, kind, optional locale and transition) or remove (release_id, instance_id, kind, locale). Answers the release as releases does. Refusals answer status: "refused" by name, as in Releases below. |
POST /dee-wan/operations/release-run |
Dee Wan core | The release-run site operation, destructive: action schedule (release_id, due_at a whole UTC minute, timezone IANA), cancel, run_now or retry. The caller’s delegation becomes the one the release runs under. run_now and retry start the first items at once and answer the release. |
POST /dee-wan/operations/timeline-preview |
Dee Wan core automation | Read-only cumulative release timeline for release_id, under the current delegation. Requires a release time; refuses release_time_required or overlapping same-instant changes as release_time_ambiguous. Not directly available through management API, CLI or MCP. |
GET /installations/:id?dee_wan_launch=dwl_... |
Browser, from Dee Wan | Exchanges the code, sets a session, and redirects with 303 to the page without the code. A manage launch with dee_wan_view=calendar or dee_wan_view=releases redirects to that view instead. |
GET /installations/:id |
Session | A content launch shows the content, the schedule form and that content’s schedules. A manage launch shows the agenda. |
GET /installations/:id?month=&zone=&model=&lang=&state= |
Manage session | The agenda: one month as a list, grouped by day. |
GET /installations/:id/calendar?month=&zone=&model=&lang=&state= |
Manage session | The calendar: one month as a grid, with a count per day. |
GET /installations/:id/calendar/:day?zone=&model=&lang=&state= |
Manage session | One day (YYYY-MM-DD): its schedules by due time, then id. |
GET /installations/:id/schedules/:scheduleId |
Session | One schedule, with a preview of its content read through the session’s delegation. |
POST /installations/:id/schedules |
Session and CSRF token | Creates a schedule. |
POST /installations/:id/schedules/:scheduleId/reschedule |
Session and CSRF token | Moves a pending or retrying schedule to a new time. |
POST /installations/:id/schedules/:scheduleId/cancel |
Session and CSRF token | Cancels a pending or retrying schedule. |
GET /installations/:id/releases |
Manage session | Every release, newest first, and the form that creates one. |
POST /installations/:id/releases |
Manage session and CSRF token | Creates a release (name, 1 to 80 characters). |
GET /installations/:id/releases/:releaseId |
Manage session | One release: when it runs, progress, its items with readiness, or its receipt. |
POST /installations/:id/releases/:releaseId/rename |
Manage session and CSRF token | Renames the release. |
POST /installations/:id/releases/:releaseId/schedule |
Manage session and CSRF token | Sets or moves the time (local, timezone, fold) of a draft or scheduled release. |
POST /installations/:id/releases/:releaseId/run |
Manage session and CSRF token | Runs a draft or scheduled release now. |
POST /installations/:id/releases/:releaseId/cancel |
Manage session and CSRF token | Unschedules a scheduled release, or stops a running one. |
POST /installations/:id/releases/:releaseId/retry |
Manage session and CSRF token | Runs the failed and cancelled items of a finished release again. |
POST /installations/:id/releases/:releaseId/items/:itemId/remove |
Session and CSRF token | Removes an item while the release has not started. A content session removes only its own entry’s items. |
POST /installations/:id/release-items |
Content session and CSRF token | Adds the session’s entry to a release (release), or to a new one (new_release), with transition kind:to, optional locale, and version_mode ordinary (default) or future (publish only). |
POST /admin/installations/:id/delete-data |
Operator, with Bearer ADMIN_SECRET |
Deletes all plugin data for the installation, releases included, and answers {"deleted": true}. 404 not_found if the plugin holds no such installation. 409 execution_in_progress while one of its schedules or release items is running. |
scheduled() runs one schedule runner tick, then one release tick.
Develop
Section titled “Develop”Node 24 and npm. From this directory:
npm installnpm run typechecknpm testnpm run dev # wrangler dev on port 8788, with --test-schedulednpm install also installs @dee-wan/plugin-conformance, the protocol v1 suite the tests run. Inside the Dee Wan repository it is the local package packages/plugin-conformance, declared as file:../../packages/plugin-conformance. Build it first with npm run build in that directory. The other tools also resolve from the Dee Wan root install there. In this plugin’s own repository the package is a semver range from the npm registry, once it is published.
Install
Section titled “Install”- Deploy the Worker (see below) and set
PUBLIC_ORIGIN. - Mint an activation code:
Terminal window curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" https://scheduling.example/admin/activation-codes - In Dee Wan, go to Settings → Plugins and install from
https://scheduling.example/dee-wan/manifest.jsonwith the code. Grant the capabilities and models.workflow:localeis needed only to schedule one language at a time. Without it, every schedule moves the whole translation group. - To rotate the token, mint a code with
{"installation_id":"<id>"}and give it to the rotate action in Dee Wan.
Cards in Dee Wan
Section titled “Cards in Dee Wan”The manifest declares four ui_extensions. They are data. Dee Wan draws each card itself and opens Scheduling through its normal delegated launch.
schedule-card(entry_sidebar) andpage-schedule(page_sidebar): a Schedule card beside an entry or a page whose model the grant includes. It launches theschedulecontent action.calendar-row(settings_row): a Calendar card on the Scheduling row in Settings → Plugins. It launches thecalendarmanage view, so it replaces the plain Calendar button there.releases-row(settings_row): a Releases card on the same row. It launches thereleasesmanage view.
Scheduling declares no field editor.
Its operations cover schedule status, content-list badges, releases and core automation. It declares no policy.
schedule-column(scope: "page",audience: "column",cell: "badge",requires: ["content:read"], resultcells). Dee Wan adds a Scheduled column to the content list of every granted model. Core sends the ids of rows on the current page that the person may read, never their values; the operation returns a badge for each entry with a live schedule. It binds no delegation and is never listed as an ordinary operation.schedule-status(audience: "interactive",requires: ["content:read"], resultrows,list_action: true). Dee Wan shows it beside an entry of a granted model; a person runs it there and sees the entry’s pending schedules. In the content list it is also a Plugin actions entry for the rows a person selects, run once per row. An agent runs it through the management API or MCP.schedule-draft(audience: "automation",requires: ["content:read", "workflow:read", "workflow:publish", "workflow:locale"], resultrowsandjson, destructive). Only Dee Wan’s content planner runs it, as the plan owner, to schedule the exact draft version it saved (version_id); when the entry’s current draft is another version the operation refuses withdraft_changedand schedules nothing. It is never an editor, management API, CLI or MCP operation, and those invocation routes reject it. The result’sschedule.jsonblock is the reference Dee Wan keeps:{ schedule_id, status, locale, transition, due_at, timezone, local_datetime, pinned_version_id }. The schedule keeps the operation’sdelegation_id, so Dee Wan re-reads the owner’s authority when it runs, exactly as for a schedule made in the form.timeline-preview(scope: "site",audience: "automation",requires: ["content:read", "workflow:read", "workflow:release"], resultjson): core-only cumulative preview throughrelease_id, under the current delegation. It writes nothing.releases,release-editandrelease-run(scope: "site",audience: "interactive", resultrowsandjson;release-runis destructive and also requiresworkflow:publish+workflow:release). They are the Releases page as operations, so the admin, the management API, the CLI and local and remote MCP run the same rules. Dee Wan offers site operations to people withsite:pluginsand binds the caller’s manage delegation, on the granted models they read; Scheduling reads that delegation’s reach on every call. One operation per kind of change, with anactioninput, keeps the manifest inside the operation cap.
Wrangler
Section titled “Wrangler”Copy wrangler.example.jsonc to wrangler.jsonc. For a deployment, replace the placeholder database_id with the id wrangler d1 create prints. Local development keeps the placeholder.
- Binding:
PLUGIN_DB(D1). - Cron:
* * * * *. - Variables:
PUBLIC_ORIGIN,ENVIRONMENT,DEE_WAN_DEV_ORIGINS,MAX_ATTEMPTS(5),MAX_LATENESS_MS(900000),LEASE_MS(60000),MAX_RETRY_AFTER_MS(300000). - Secrets:
ADMIN_SECRET(at least 16 characters),TOKEN_KEY(base64 of 32 random bytes,openssl rand -base64 32).
npx wrangler d1 create dee-wan-schedulingnpx wrangler d1 migrations apply dee-wan-scheduling --remote # --local for devnpx wrangler secret put ADMIN_SECRETnpx wrangler secret put TOKEN_KEYnpm run deployApply new files in migrations/ with the same migrations apply command before you deploy a release that needs them.
Losing TOKEN_KEY makes the stored installation tokens unreadable. Schedules are then refused with token_unreadable, and each installation has to be rotated.
Local development against a local Dee Wan
Section titled “Local development against a local Dee Wan”The setup — both services, their settings, and the commands — is in Testing a plugin. It needs no Cloudflare account.
The management UI cookie uses the __Host- prefix and the Secure attribute, so the browser needs HTTPS or localhost. An HTTP Dee Wan base URL is accepted only when ENVIRONMENT=development and its exact origin is listed in DEE_WAN_DEV_ORIGINS. Otherwise the base URL must use HTTPS and must contain no credentials, no query, and no fragment.
Schedule semantics
Section titled “Schedule semantics”- A schedule can be created only from a content launch (the
Scheduleaction). The plugin reads the content with the delegation, and the chosen(kind, to)must be in the delegatedworkflow.transitions. - Language: when the installation holds
workflow:localeand the content has more than one language, the form offersAll languagesor one language. Single-language content shows no choice.All languagesmoves the whole translation group. One language moves only that language: the chosen(kind, to)must be in that language’s transitions underworkflow.locales, and the command sendslocale. A posted language that Dee Wan does not list is refused withlocale_unavailable. - Pins: a publish pins
current_version_id, an unpublish pinspublished_version_id, and both also pinworkflow.revision. A one-language unpublish pins that language’s ownpublished_version_id. A one-language publish pins the group’scurrent_version_id, because one version holds every language. The revision is always the group’s. The schedule is stored against the primary instance, withlocaleset for one language and empty for the whole group. - There is at most one live (
pending|retrying|running) schedule per installation, content item, kind, and language. A whole-group schedule and a one-language schedule of the same item and kind conflict too: the group includes that language. A new schedule supersedes every conflicting live pending or retrying schedule in one batch. So a whole-group schedule replaces the item’s one-language schedules of that kind, and a one-language schedule replaces the whole-group schedule of that kind. One-language schedules for different languages wait side by side. If a conflicting schedule is running, creation fails withexecution_in_progress. - Rescheduling creates a new row with a new revision and a new idempotency key. Revisions count per installation, content item and kind, across languages.
- The idempotency key is
sched:<installation_id>:<schedule_id>:r<revision>. It stays the same on every retry. - Only pending or retrying schedules can be cancelled. Cancelling a running schedule returns
execution_in_progress. A finished schedule cannot be cancelled. Cancelling never calls Dee Wan and never reverses a transition. - At execution time, core re-checks the person’s authority, the pins, and the revision.
- A manage launch opens the agenda, or the calendar when Dee Wan asked for it: the schedules of the models the delegation reaches. The person can preview them, and reschedule or cancel those they may change. They cannot create schedules.
Statuses: pending, retrying, running, completed, refused, cancelled, superseded.
Releases
Section titled “Releases”A release publishes or unpublishes up to 25 items in one CMS command. Items may span models and languages within one site. Each item names an entry, a language or the whole translation group, a kind (publish or unpublish) and a target state.
- Open the Releases card in Settings → Plugins, or Releases on the Dashboard. This is a manage launch with
view: releases; it needs access to a granted model, notsite:plugins. Site operations still requiresite:plugins. Add entries from Add to release on their Schedule cards. - Each change must currently be offered to the person through their delegation. Different languages of one translation group are allowed. Duplicate language scopes, or a whole-group item overlapping a language item, are refused as
already_in_release. The limit is 25 items (release_full). - Items show their title, language, model, change and current readiness. Unreadable items are counted without exposing their titles or reasons. Run now and Retry unfinished items are disabled while an item is blocked. An unavailable readiness check is unknown, not blocked; nothing starts.
- States are
draft,scheduled,running,done,failed,unknownandcancelled.Failedmeans the CMS refused the command and none of its publication changes committed.Outcome unknownmeans a response did not establish whether the command committed. Item receipts retain the reported result; the runner never guesses which items succeeded. - Adding or removing items is allowed only in
draftandscheduled. Scheduling uses the same date, time and timezone rules as a single schedule. The release runs as the person who schedules, starts or retries it. Core rechecks current grants immediately before commit. - Ordinary items are not pinned when added. At execution, the runner records the latest saved ordinary draft for publication (
expected_current_version_id), the live version for withdrawal (expected_published_version_id) and the workflow revision. An independent future item uses its own branch instead, as described below. The whole recorded set goes to/releases/commitwith reviewedworkflow:releaseand the required per-change capabilities. - Core validates the complete final publication state, including dependencies between items in the release. List order does not determine whether a shared section and its page can publish together. Distinct locales of one translation group commit together. Permission, version, workflow, locale, dependency or statement-budget refusal changes nothing. Core never splits an oversized command into smaller commits.
- Run now checks the whole release and sends one command. A scheduled tick checks one due release before starting it. A blocked scheduled release stays
scheduled, recordsblocked_atand a Blocked attempts receipt, and is not automatically started again. Fix or remove the blocking items and use Run now, or set a new time to clear the mark. - If the item set changes during readiness checks, starting or retrying is refused as
release_changed; nothing is sent. The scheduled tick checks the new set on a later tick. - Unscheduling returns a scheduled release to
draft. Stopping prevents work that has not been sent. It cannot reverse an in-flight atomic command; the recorded result must still be recovered. - Idempotency uses
rel:<release_id>:a<attempt>for the complete command. A missing, transient or untrusted response retains every recorded pin and repeats the same command under the same key. After automatic retry limits, the release showsOutcome unknownwhen commit status remains unknown. Retry recovers that command before starting new work. A definite refusal permits a new attempt with fresh versions after readiness checks. Completed publication is never repeated as a new command. - Receipts keep the release outcome, per-item published versions, last code and completion time. The
releasesoperation downloads them asrelease.json. Delivery of notifications and automatic redirect completion happen after the tenant commit and cannot roll it back.
Independent future versions
Section titled “Independent future versions”In Add to release, Version → Latest ordinary draft keeps the ordinary behavior. For publication, choose Version → Independent future version to create a separate version for this release. Future versions cannot be used for unpublish (future_requires_publish). The release-edit operation offers the same choice through action: add_future with kind: publish; action: add keeps the ordinary draft.
In Dee Wan, open Future versions on the entry and select the release’s version. Edit its fields, or the sections and facts of a composed page, then Save. Future history lists that branch’s immutable saved versions. Other releases can hold their own branches for the same entry. Editing or publishing one leaves the ordinary draft intact. When execution starts, core freezes the selected branch revision and version; it cannot be edited after freezing. Retries recover that exact recorded command and version rather than substituting a newer ordinary draft.
Set the release’s date, time and timezone before Preview through this release; otherwise preview refuses with release_time_required. The preview starts from published content, applies earlier releases’ publish and unpublish items, then the selected release. It respects current read permissions and does not change publication or public caches. Releases at the same instant are ordered by stable release id, but overlapping changes to the same entry and language at that instant refuse with release_time_ambiguous: choose different times. For composed pages, use the preview inside the future page editor.
Core obtains this timeline through the read-only timeline-preview automation operation with release_id. It is not a management API, CLI or MCP operation. Publication still rechecks current delegated authority, required fields and tasks, workflow, references and pinned media for the selected future snapshot. Editing a frozen branch or previewing an unavailable target is refused; the ordinary draft is never substituted.
Calendar and agenda
Section titled “Calendar and agenda”The site-wide publishing calendar. It reads only the schedules this plugin stores. Dee Wan keeps no copy of them and runs no scheduler.
- Who: anyone who reads at least one model granted to Scheduling, own entries included. Calendar on the Dashboard or the Content list, or on the Scheduling row in Settings → Plugins, is a manage launch with
view: calendar; it needs nosite:plugins. Manage in Settings → Plugins is a bare manage launch and still needssite:plugins; it lands on the agenda. The manifest declaresmanage_views: ["calendar", "releases"], and Dee Wan addsdee_wan_view=calendarto the launch URL. The hint grants nothing; the plugin reads it only after a successful exchange of a manage code. A content launch has no calendar or agenda and answers 403 there. - Reach: before every page and every change the plugin asks Dee Wan
GET /api/plugin/v1/delegations/<id>. Each view, count, filter, notice and schedule page shows only schedules of the models listed there. Any other schedule answersnot_found. A person with access to no model seesNo content you can see is scheduled here.Scheduling keeps the schedules; Dee Wan keeps no copy. - Own entries: a model listed
entries: "checked"is one where the person reads or updates only their own entries (read:own,update:own). For it the plugin asks Dee WanGET /api/plugin/v1/delegations/<id>/entries, up to 100 entry ids a call, on every page and change. It shows, counts and offers filters only for the entries Dee Wan lists, and offers Reschedule or Cancel only for the kinds listed on that entry. Any other schedule answersnot_found. A person with no own schedules sees an empty calendar. Scheduling stores no owner. A view pages through every entry of such models in its range and asks once per entry, however many schedules it has; counts, lists and filters come from that one checked set. A range with more than 4,000 such entries, past what one Workers Free request may ask, is refused whole:This range is too large to check with Dee Wan at once. Nothing is shown. Open a single day.The model and language filters of such models come from the checked entries of the view’s range. - Removed: when Dee Wan answers that read with 401
plugin_unauthorized(the installation was uninstalled, turned off, or its token revoked), every page answers 200 and saysThis installation was removed from Dee Wan or turned off there. Its schedules no longer run.It shows no schedule, no count and no action. Without the delegation’s current model list the plugin cannot tell which models the person may see, so it shows none. A Cancel or Reschedule sent anyway changes nothing. - Dee Wan unavailable: no answer, a 5xx, or 429
rate_limitedanswers 403 withDee Wan is not available right now. Nothing changed. Try again in a minute.and shows no schedules. - Who may change what: Reschedule and Cancel appear only when the schedule’s kind (
publishorunpublish) is listed for its model. Dee Wan lists a kind when the installation holdsworkflow:publishorworkflow:unpublish, the person may update the item (any item of the model, or underupdate:owntheir own), and the model’s workflow has an edge of that kind whose permission the person holds. A person who does not meet these conditions browses read only and the page says so. A Reschedule or Cancel sent anyway answersnot_permittedand changes nothing. A reschedule then still re-reads the content, as below. - Views: Calendar is a month grid, Monday first, with a count per day. A day with schedules links to that day. Agenda is the same month as a list, grouped by day. Day lists one day’s schedules by due time, then id.
- Address: the month and every filter live in the query, so each view is a plain GET link and the filter form works without JavaScript. Previous and next month keep the filters. So do the links between Calendar and Agenda, and the day links.
month:YYYY-MM. Default: the current month in the chosen timezone. An unusable value falls back to the default.zone: the IANA timezone the days are interpreted in. DefaultUTC. Each row lands on the local day of its due time in that zone; month and day bounds are that zone’s local midnights, also across clock changes and where midnight itself is skipped. An unknown zone is refused by name with 400 and a link to the same view inUTC.model: a model id. The list holds only models this installation has schedules for and the delegation reaches, by the slug recorded when the schedule was created.lang: a language. The list holds only languages this installation has one-language schedules for, on models the delegation reaches. A whole-group schedule moves every language, so it matches any language filter.state:current(the default: pending, retrying, running, completed, refused),live(pending, retrying, running),all, or one status. Completed and refused schedules show their result code and message.- Bounds: each view reads one month, or one day, with a range on
due_atserved by theschedule_agenda (installation_id, due_at, id)index. The calendar counts per day in that one query. The agenda and the day show at most 200 rows, then a note to narrow the filters. Views write nothing. - Names: each schedule shows the item’s title recorded when it was created, or its slug when the item has no title. A one-language schedule shows the language name, in the UI language. When the viewing zone differs from the schedule’s own zone, the time also shows in the viewing zone.
- Preview:
Openon a schedule reads its content from Dee Wan with the session’s delegation. It shows the title, model, language and current state, and whether the pinned version and workflow revision still match the current values. For a one-language schedule the language, state and pin are that language’s own. If Dee Wan no longer lists that language, the preview sayslocale_unavailable. If Dee Wan refuses the read, the page says why.Backreturns to the view it was opened from. - Reschedule: a new local time and timezone for a pending or retrying schedule. The pins, the kind, the target and the language stay as they were. The plugin first re-reads the content through the session’s delegation: the person must still be offered that transition, for that language when the schedule has one, and the pinned version and revision must still be current. Otherwise it refuses with
locale_unavailable,transition_not_allowed,scheduled_target_staleorworkflow_conflictand changes nothing. A reschedule inserts a new row with the next revision and marks the old onesuperseded, so it gets a new idempotency key. The new row carries the rescheduling person’s delegation. A running schedule answersexecution_in_progress, and a finished one cannot be moved. - Cancel: pending or retrying schedules only, as in Schedule semantics. It never calls Dee Wan. From a view it returns to that view with the notice. A
returnaddress is accepted only for this installation’s views, and its query is re-read, not echoed.
Dee Wan’s Workflow panel links here. When the last workflow move of an item was made by this plugin, the panel offers View schedule, which opens this plugin’s page for that item through the Schedule content action.
Each schedule stores the UTC due_at, the IANA timezone, the local wall time, and the fold. A timezone rule change never rewrites due_at. Resolution uses only Intl.DateTimeFormat.
| Input | Result |
|---|---|
2026-07-01T09:00 America/New_York |
2026-07-01 13:00 UTC |
2026-03-29T02:30 Europe/Berlin |
refused nonexistent_local_time (clocks jump 02:00→03:00) |
2026-10-25T02:30 Europe/Berlin, no fold |
refused ambiguous_local_time |
same, fold earlier |
2026-10-25 00:30 UTC (CEST) |
same, fold later |
2026-10-25 01:30 UTC (CET) |
2026-07-01T09:00 Mars/Base |
refused unknown_timezone |
2025-12-31T23:59 UTC, already past |
refused past_time |
The confirmation and the list show the local time with its zone, and the UTC time. When the zone is UTC, the time shows once.
Runner
Section titled “Runner”Each cron tick takes up to 20 due rows, round-robin across installations. A compare-and-set claim changes the observed status and lease token, increments attempt_count, and assigns a fresh token with lease_until = now + LEASE_MS. After token decryption, the sender rechecks ownership and attempt/age limits, and checks that enough lease time remains for the complete request timeout. A runner that no longer owns the lease sends nothing and cannot settle another owner’s row. Recovery may resend the identical key and body. A timeout does not prove that remote execution stopped. Core idempotency is the effect boundary, not a promise of one HTTP request.
| Dee Wan answer | Plugin result |
|---|---|
200 transition_applied / idempotent_replay |
completed |
400 invalid_request |
refused, no retry |
401 plugin_unauthorized |
refused, no retry |
403 capability_refused / requester_unauthorized |
refused, no retry |
404 content_not_found |
refused, no retry |
409 scheduled_target_stale / workflow_conflict / dependent_content / idempotency_key_reused |
refused, no retry |
422 transition_unavailable / transition_refused |
refused, no retry |
| Present outcome with a status/code/retry mismatch or malformed fields. Body without an outcome on other statuses. Redirects | refused, untrusted_response |
429 rate_limited |
retrying. Uses a valid bounded Retry-After. Otherwise, uses exponential backoff with jitter. |
503 temporary_failure |
retrying. Uses a valid bounded Retry-After. Otherwise, uses exponential backoff with jitter. |
| Bare 429/500/502/503/504 without an outcome. Network failure or timeout (10 s) | retrying. Uses bounded Retry-After when supplied. Otherwise, uses exponential backoff with jitter. |
past MAX_ATTEMPTS, past due_at + MAX_LATENESS_MS, or Retry-After > MAX_RETRY_AFTER_MS |
refused retry_exhausted, nothing more sent |
A structured outcome is checked before the bare-status fallback. Retry-After accepts nonnegative integer seconds or a canonical HTTP date (IMF-fixdate). A past date means zero and malformed values use backoff. A Retry-After value above MAX_RETRY_AFTER_MS, an exceeded attempt cap or an exceeded age cap yields retry_exhausted. LEASE_MS has a minimum of 11 seconds, longer than the 10-second request timeout. A claim with insufficient lease time sends nothing. A valid success is only transition_applied or idempotent_replay.
retry_exhausted after a network failure means the outcome is unknown, so check the content in Dee Wan. A schedule is never sent later than the lateness cap, including when a lease left by a crashed runner is reclaimed.
Attempts are recorded in schedule_attempt (attempt, start, finish, code, HTTP status). This is an operational record for the user, not telemetry.
Data, retention, uninstall
Section titled “Data, retention, uninstall”- Stored data includes the installation id, site id, Dee Wan base URL and AES-GCM-encrypted token. It also includes hashed activation codes, hashed sessions (30 minutes) with their CSRF tokens, schedules and attempts, and releases with their items and receipts. No content snapshots and no Dee Wan user sessions are stored.
- Launch codes and raw tokens are never logged or stored raw.
- Uninstalling the plugin in Dee Wan revokes the token. The plugin data stays. Remaining schedules are refused with
plugin_unauthorizedwhen they come due. The plugin pages then say the installation was removed and show no schedules. - To delete plugin data, the operator calls
POST /admin/installations/:id/delete-datawithBearer ADMIN_SECRET. The call is refused with 409execution_in_progresswhile a schedule is running. No automatic retention purge is built.
npm test # vitest, from this directorynpm run typechecknpm run test:cross-service # this plugin and a live Dee Wan, both under wrangler dev; two to three minutesnpm test includes test/conformance.test.ts, which runs the @dee-wan/plugin-conformance protocol v1 cases against this plugin: the outcome table, statuses, retry flags, capabilities and secret shapes against the published schemas, the manifest against the published manifest schema, reply classification, activation, launch exchange, and delivery through the real runner against the package’s fake core. test/helpers/d1.ts is a better-sqlite3 D1 shim that applies every file in migrations/.
test/docs.test.ts runs the Runner and Time tables above through classify and resolveLocalTime, so a row that nobody updated fails.
test:cross-service runs test/cross-service/cross-service.test.mjs. It starts Dee Wan’s admin Worker and this plugin under local wrangler dev, installs the plugin through Dee Wan, and runs scheduled publishes, unpublishes and refusals through protocol v1. Dee Wan is an external checkout: set DEE_WAN_CORE_DIR to a Dee Wan repository with its dependencies installed (node scripts/install-dependencies.mjs), its packages built (npm run build:packages) and its Prisma client generated (npm --workspace backend run prisma:generate). Inside the Dee Wan repository the variable may be left unset. The automated cross-service run lists what it asserts.
.github/workflows/ci.yml is this plugin’s own CI, for its own repository: install, typecheck, npm test, then the cross-service run against a Dee Wan checkout at vars.DEE_WAN_CORE_REF (default main). GitHub does not run it while this directory is inside the Dee Wan repository, and Dee Wan’s CI does not run this plugin.
Moving to its own repository
Section titled “Moving to its own repository”The move needs explicit authority: creating dee-wan-cms/dee-wan-scheduling, pushing to it and publishing @dee-wan/plugin-conformance are external actions. With that authority, from the Dee Wan repository root:
# 1. Publish the conformance package (needs npm publish rights for the @dee-wan scope).cd packages/plugin-conformancenpm installnpm testnpm publish --access publiccd ../..
# 2. Split this directory with its history into a branch.git subtree split --prefix=plugins/scheduling -b scheduling-split
# 3. Create the empty repository dee-wan-cms/dee-wan-scheduling on GitHub, then push the branch as main.git push https://github.com/dee-wan-cms/dee-wan-scheduling.git scheduling-split:main
# 4. In a clone of the new repository: depend on the published package and commit a lockfile.git clone https://github.com/dee-wan-cms/dee-wan-scheduling.gitcd dee-wan-schedulingnpm pkg set devDependencies.@dee-wan/plugin-conformance=^0.1.0npm installnpm run typechecknpm testgit add package.json package-lock.jsongit commit -m "Depend on the published @dee-wan/plugin-conformance"git pushThen, in that repository: if dee-wan-cms/dee-wan is private, add a read-only DEE_WAN_CORE_TOKEN secret, and optionally set the DEE_WAN_CORE_REF variable to the Dee Wan ref to test against. The links in this README and in docs/end-to-end.md that point into ../../docs/plugins/ must become links to the published Dee Wan documentation.
Removing plugins/scheduling from the Dee Wan repository is a separate change there. It also drops this README and docs/end-to-end.md from the Dee Wan documentation site (docs-site/allowlist.mjs and their ru and th translations) and points docs/plugins/*.md at the new repository.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes