Skip to content
Pre-MVP. The 1.0 codebase is not a released product — see the 1.0 product boundary.

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.

Plugin Manifest Dee Wan protocol
0.1.0 v1 v1
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.

  1. Deploy Worker (below). Set PUBLIC_ORIGIN.
  2. Mint activation code:
    Terminal window
    curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" https://scheduling.example/admin/activation-codes
  3. Dee Wan → Settings → Plugins → install from https://scheduling.example/dee-wan/manifest.json with the code. Grant capabilities and models.
  4. Rotate token: mint code with {"installation_id":"<id>"}, give it to Dee Wan rotate.

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).
Terminal window
npx wrangler d1 create dee-wan-scheduling
npx wrangler d1 migrations apply dee-wan-scheduling --remote # --local for dev
npx wrangler secret put ADMIN_SECRET
npx wrangler secret put TOKEN_KEY
npx wrangler deploy

Losing TOKEN_KEY makes stored installation tokens unreadable. Schedules then refuse with token_unreadable; rotate each installation.

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, then npx 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.

  • Create only from a content launch (Schedule action). Plugin reads content with the delegation; chosen (kind, to) must be in delegated workflow.transitions.
  • Pins: publish → current_version_id; unpublish → published_version_id; plus workflow.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.

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.

  • 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_unauthorized when due.
  • Delete plugin data: operator calls POST /admin/installations/:id/delete-data with Bearer ADMIN_SECRET. Refused with 409 execution_in_progress while a schedule runs. No automatic retention purge is built.
Terminal window
npm test # vitest, from this directory
npm run typecheck

test/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.