Scheduling plugin
Remote service plugin. Schedules one pinned version to publish, or one pinned published version to unpublish, and runs it later through Dee Wan plugin protocol v1. Own Cloudflare Worker, own D1, own cron, own management UI. No code runs inside Dee Wan.
Owner decision open: repository name, remote, and manifest id. com.example.scheduling is a placeholder. Do not install in production with it.
Docs: end to end against a local Dee Wan. Core side: writing a plugin, protocol v1, security, testing.
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, origin from PUBLIC_ORIGIN |
POST /admin/activation-codes |
operator, Bearer ADMIN_SECRET |
one-time code, 15 min. Body {} = activate code. {"installation_id":"..."} = rotate code for that installation only |
POST /dee-wan/activate |
Dee Wan core | activate creates installation (refuses existing). rotate replaces token. Code consumed once |
GET /installations/:id?dee_wan_launch=dwl_... |
browser from Dee Wan | exchange code, set session, 303 to page without code |
GET /installations/:id |
session | content, schedule form, schedule list |
POST /installations/:id/schedules |
session + CSRF | create schedule |
POST /installations/:id/schedules/:scheduleId/cancel |
session + CSRF | cancel pending/retrying |
POST /admin/installations/:id/delete-data |
operator | delete all plugin data for installation |
scheduled() runs one runner tick.
Install
Section titled “Install”- Deploy Worker (below). Set
PUBLIC_ORIGIN. - Mint activation code:
Terminal window curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" https://scheduling.example/admin/activation-codes - Dee Wan → Settings → Plugins → install from
https://scheduling.example/dee-wan/manifest.jsonwith the code. Grant capabilities and models. - Rotate token: mint code with
{"installation_id":"<id>"}, give it to Dee Wan rotate.
Wrangler
Section titled “Wrangler”Copy wrangler.example.jsonc to wrangler.jsonc. Fill D1 id.
- Binding
PLUGIN_DB(D1). - Cron
* * * * *. - Vars:
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(≥16 chars),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_KEYnpx wrangler deployLosing TOKEN_KEY makes stored installation tokens unreadable. Schedules then refuse with token_unreadable; rotate each installation.
Local dev against local Dee Wan
Section titled “Local dev against local Dee Wan”Management UI cookie is __Host- + Secure: browser needs HTTPS or localhost.
- Plugin
.dev.vars:ENVIRONMENT=development,PUBLIC_ORIGIN=http://localhost:8788,DEE_WAN_DEV_ORIGINS=http://localhost:8787,ADMIN_SECRET=...,TOKEN_KEY=.... - Dee Wan core:
ENVIRONMENT=development,PLUGIN_DEV_ORIGINS=http://localhost:8788,BACKEND_URL=http://localhost:8787. npx wrangler d1 migrations apply dee-wan-scheduling --local, thennpx wrangler dev --port 8788 --test-scheduled.- Trigger a tick:
curl "http://localhost:8788/__scheduled?cron=*+*+*+*+*".
HTTP Dee Wan base URL is accepted only when ENVIRONMENT=development and exact origin is in DEE_WAN_DEV_ORIGINS. Otherwise HTTPS, no credentials, no query, no fragment.
Schedule semantics
Section titled “Schedule semantics”- Create only from a content launch (
Scheduleaction). Plugin reads content with the delegation; chosen(kind, to)must be in delegatedworkflow.transitions. - Pins: publish →
current_version_id; unpublish →published_version_id; plusworkflow.revision. Stored against primary instance; core publishes whole translation group. - One live (
pending|retrying|running) schedule per installation + content + kind. New schedule supersedes live pending/retrying in one batch. Running →execution_in_progress. - Reschedule = new row, new revision, new idempotency key.
- Idempotency key:
sched:<installation_id>:<schedule_id>:r<revision>. Same on every retry. - Cancel: pending/retrying only. Running →
execution_in_progress. Finished → not cancellable. Never calls Dee Wan, never reverses a transition. - Core re-checks the person’s authority, pins and revision at execution.
- Manage launch lists and cancels all installation schedules. Cannot create.
Statuses: pending, retrying, running, completed, refused, cancelled, superseded.
Stored: UTC due_at, IANA timezone, local wall time, fold. Timezone rule change never rewrites due_at. Resolution uses Intl.DateTimeFormat only.
| 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 |
Confirmation and list show local + zone and UTC.
Runner
Section titled “Runner”Cron tick: up to 20 due rows, round-robin across installations. Claim = compare-and-set on observed status + lease token; sets running, new lease token, lease_until = now + LEASE_MS, attempt_count + 1. One command per claim. Settle only if own lease token still holds. Crashed running row reclaimed after lease expiry, resent with same key.
| 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 |
| status/code mismatch, bad JSON, 3xx, other status | refused untrusted_response |
| 429 | retrying at Retry-After (seconds or HTTP date) |
| 500/502/503/504, network failure, timeout (10 s) | retrying, backoff 15 s·2^(n-1) capped 5 min, jitter 50–100 % |
past MAX_ATTEMPTS, past due_at + MAX_LATENESS_MS, or Retry-After > MAX_RETRY_AFTER_MS |
refused retry_exhausted, nothing more sent |
retry_exhausted after a network failure means outcome unknown: check content in Dee Wan. A schedule is never sent later than the lateness cap, including a reclaimed crashed lease.
Attempts are recorded in schedule_attempt (attempt, start, finish, code, HTTP status). Operational record for the user, not telemetry.
Data, retention, uninstall
Section titled “Data, retention, uninstall”- Stored: installation (id, site id, Dee Wan base URL, AES-GCM encrypted token), hashed activation codes, hashed sessions (30 min) with CSRF token, schedules, attempts. No content snapshots, no Dee Wan user sessions.
- Launch codes and raw tokens are never logged or stored raw.
- Uninstall in Dee Wan revokes the token. Plugin data stays. Remaining schedules refuse with
plugin_unauthorizedwhen due. - Delete plugin data: operator calls
POST /admin/installations/:id/delete-datawithBearer ADMIN_SECRET. Refused with 409execution_in_progresswhile a schedule runs. No automatic retention purge is built.
npm test # vitest, from this directorynpm run typechecktest/helpers/d1.ts is a better-sqlite3 D1 shim. test/helpers/fake-core.ts fakes Dee Wan protocol v1: idempotent replay per key, pin/revision checks, delegated authority, failure injection.
test/docs.test.ts drives the Runner and Time tables above through classify and resolveLocalTime: a row nobody updated fails. Core’s backend/test/plugin-contract.test.ts checks this plugin’s copy of the outcome table, capabilities and secret shapes against core’s own.