Перейти к содержимому
Версия 1.0 ещё не выпущена; проект находится на стадии pre-MVP. См. возможности и ограничения версии 1.0.

Плагин планирования Dee Wan

Это плагин в виде удалённого сервиса. Он планирует публикацию одной закреплённой версии или снятие с публикации одной закреплённой опубликованной версии. Он также выполняет именованные релизы: группы элементов публикации и снятия с публикации по разным моделям и языкам, которые выполняются вместе в заданное время или сразу. Каждое изменение он выполняет через протокол плагинов Dee Wan v1. У него собственный Cloudflare Worker, собственная база данных D1, собственный cron-триггер и собственный интерфейс управления. Никакой его код не выполняется внутри Dee Wan.

id манифеста — dev.dee-wan.scheduling, он задан как PLUGIN_ID в src/app.ts. Он никогда не меняется между релизами: id установленного плагина не может меняться, а релиз с другим id отклоняется как manifest_identity_changed. Форку, который вы разворачиваете как другой плагин, нужен собственный id в формате обратного домена.

Этот каталог готов стать корнем собственного репозитория, https://github.com/dee-wan-cms/dee-wan-scheduling.git. Пока переноса не было, он живёт в репозитории Dee Wan в plugins/scheduling. Шаги перечислены в последнем разделе, «Перенос в собственный репозиторий».

Документация: Тестирование плагина настраивает этот плагин рядом с локальным Dee Wan. Планирование от начала до конца затем проводит через установку, публикацию по расписанию и предусмотренные сценарии ошибок. Со стороны ядра см. Написание плагина, Protocol v1, Безопасность плагинов и Тестирование плагина.

Для импортированного расписания данные о владельце сохраняются до отправки запроса. Если ответ потерян, отмена находит запрос по исходному делегированию, записи, сохранённой версии, времени и переходу. У операции cancel-draft параметр schedule_id необязателен: без него она отменяет запрос с неизвестным результатом. Сохранённая отметка об отмене не позволит запоздавшему запросу создать расписание. Уже созданные расписания остаются доступными для отдельной отмены импорта.

Плагин Манифест Протокол Dee Wan
0.1.0 v1 v1
Маршрут Кто Что
GET /dee-wan/manifest.json Админка Dee Wan Манифест v1, с origin, взятым из PUBLIC_ORIGIN.
GET /assets/scheduling.css Браузер Таблица стилей интерфейса управления.
POST /admin/activation-codes Оператор, с Bearer ADMIN_SECRET Выпускает одноразовый код, действительный 15 минут. Тело {} выпускает код activate. {"installation_id":"..."} выпускает код rotate только для этой инсталляции. Отвечает 201 с {"code", "purpose", "installation_id", "expires_at"}: purpose — activate или rotate, installation_id равен null для кода activate, а expires_at — в миллисекундах от начала эпохи.
POST /dee-wan/activate Ядро Dee Wan activate создаёт инсталляцию и отклоняет существующую. rotate заменяет токен. Каждый код расходуется один раз.
POST /dee-wan/operations/schedule-status Ядро Dee Wan Операция schedule-status. Принимает закрытый конверт вызова, обменивает его код токеном инсталляции, читает связанную запись и возвращает строки её расписаний, ожидающих запуска или повторной попытки либо выполняемых сейчас. Только чтение. Открытый конверт — 400, неизвестная инсталляция — 404; код, который ядро не обменивает, другая операция или нечитаемая запись дают status: "refused".
POST /dee-wan/operations/schedule-column Ядро Dee Wan Столбец списка контента schedule-column. Принимает закрытый конверт вызова, обменивает его код и отвечает одним блоком cells: метка для каждой названной записи, у которой есть расписание, ожидающее запуска или повторной попытки либо выполняемое сейчас (ближайшее, вида Publish 2026-09-20 09:00; тон warning при ожидании повторной попытки или во время выполнения). Читает только расписания этой инсталляции и ничего не пишет. У записи без активного расписания ячейки нет. Код, который ядро не обменяет, другая операция, привязанная запись или вход, не являющийся списком идентификаторов записей, получают ответ status: "refused".
POST /dee-wan/operations/schedule-draft Ядро Dee Wan Операция schedule-draft. Обменивает код, принимает закрытый вход { locale, transition, due_at, timezone, version_id } (due_at — целая минута UTC, timezone — имя IANA, version_id — сохранённая версия черновика для закрепления), читает связанную запись через delegation_id связки и создаёт такое же закреплённое расписание публикации, как форма, закреплённое ровно на version_id, сохраняя это делегирование и email вызывающего. Тот же запрос снова отвечает уже созданным расписанием. Отказы дают status: "refused" по имени: invalid_input, invalid_instant, unknown_timezone, past_time, entry_unreadable, requester_unauthorized, locale_unavailable, transition_not_allowed, nothing_to_pin, version_moved, draft_changed (текущий черновик записи — не version_id), already_scheduled, execution_in_progress. Она никогда не заменяет другое активное расписание.
POST /dee-wan/operations/releases Ядро Dee Wan Операция сайта releases. Обменивает код и читает охват delegation_id связки. Пустой release_id: все релизы строками и releases.json. С release_id: элементы этого релиза, которые вызывающий может читать, с готовностью или результатом, строками и release.json (квитанция). Только чтение.
POST /dee-wan/operations/release-edit Ядро Dee Wan Операция сайта release-edit: action create (name), rename (release_id, name), add / add_future (release_id, instance_id, kind, необязательные locale и transition) или remove (release_id, instance_id, kind, locale). Отвечает релизом так же, как releases. Отказы дают status: "refused" по имени, как в разделе «Релизы» ниже.
POST /dee-wan/operations/release-run Ядро Dee Wan Операция сайта release-run, разрушающая: action schedule (release_id, due_at — целая минута UTC, timezone — имя IANA), cancel, run_now или retry. Делегирование вызывающего становится тем, под которым выполняется релиз. run_now и retry сразу запускают первые элементы и отвечают релизом.
POST /dee-wan/operations/timeline-preview Автоматизация ядра Dee Wan Накопительный план релизов для release_id, только чтение с текущим делегированием. Без времени — release_time_required; пересекающиеся изменения в один момент — release_time_ambiguous. Недоступна напрямую через management API, CLI и MCP.
GET /installations/:id?dee_wan_launch=dwl_... Браузер, из Dee Wan Обменивает код, устанавливает сессию и перенаправляет с 303 на страницу без кода. Запуск manage с dee_wan_view=calendar или dee_wan_view=releases вместо этого перенаправляет на это представление.
GET /installations/:id Сессия Запуск над контентом показывает контент, форму расписания и расписания этого контента. Запуск manage показывает список событий.
GET /installations/:id?month=&zone=&model=&lang=&state= Сессия manage Список событий: один месяц, по дням.
GET /installations/:id/calendar?month=&zone=&model=&lang=&state= Сессия manage Календарь: один месяц сеткой, с числом расписаний на каждый день.
GET /installations/:id/calendar/:day?zone=&model=&lang=&state= Сессия manage Один день (YYYY-MM-DD): его расписания по времени срабатывания, затем по id.
GET /installations/:id/schedules/:scheduleId Сессия Одно расписание с предпросмотром его контента, прочитанного через делегирование сессии.
POST /installations/:id/schedules Сессия и CSRF-токен Создаёт расписание.
POST /installations/:id/schedules/:scheduleId/reschedule Сессия и CSRF-токен Переносит на новое время операцию, ожидающую запуска или повторной попытки.
POST /installations/:id/schedules/:scheduleId/cancel Сессия и CSRF-токен Отменяет операцию, ожидающую запуска или повторной попытки.
GET /installations/:id/releases Сессия manage Все релизы, сначала новые, и форма создания релиза.
POST /installations/:id/releases Сессия manage и CSRF-токен Создаёт релиз (name, от 1 до 80 символов).
GET /installations/:id/releases/:releaseId Сессия manage Один релиз: когда он выполняется, ход выполнения, его элементы с готовностью или его квитанция.
POST /installations/:id/releases/:releaseId/rename Сессия manage и CSRF-токен Переименовывает релиз.
POST /installations/:id/releases/:releaseId/schedule Сессия manage и CSRF-токен Задаёт или переносит время (local, timezone, fold) черновика релиза или запланированного релиза.
POST /installations/:id/releases/:releaseId/run Сессия manage и CSRF-токен Выполняет черновик релиза или запланированный релиз сейчас.
POST /installations/:id/releases/:releaseId/cancel Сессия manage и CSRF-токен Снимает запланированный релиз с расписания или останавливает выполняющийся.
POST /installations/:id/releases/:releaseId/retry Сессия manage и CSRF-токен Снова выполняет неудавшиеся и отменённые элементы завершённого релиза.
POST /installations/:id/releases/:releaseId/items/:itemId/remove Сессия и CSRF-токен Удаляет элемент, пока релиз не начался. Сессия над контентом удаляет только элементы своей записи.
POST /installations/:id/release-items Сессия над контентом и CSRF-токен Добавляет запись сессии в релиз (release) или в новый релиз (new_release), с transition kind:to и необязательным locale. version_mode: ordinary (по умолчанию) / future (только публикация).
POST /admin/installations/:id/delete-data Оператор, с Bearer ADMIN_SECRET Удаляет все данные плагина для инсталляции, включая релизы, и отвечает {"deleted": true}. 404 not_found, если у плагина нет такой инсталляции. 409 execution_in_progress, пока выполняется одно из её расписаний или элементов релиза.

scheduled() выполняет один тик исполнителя расписаний, затем один тик релизов.

Node 24 и версия npm, закреплённая в репозитории Dee Wan. Начните в корне репозитория:

Общий набор инструментов устанавливает утверждённый корневой установщик. Перед запуском плагина соберите packages/plugin-conformance: локальная зависимость file:../../packages/plugin-conformance использует собранный пакет. Корневой установщик не устанавливает этот вложенный плагин отдельно. Если локальной ссылки на пакет проверок нет, создайте её командами ниже из корня репозитория. Существующую корректную ссылку используйте повторно; не заменяйте другой файл или каталог. В отдельном репозитории плагина используйте npm install, предварительно заменив зависимость от локального пакета на опубликованную версию из npm; пока такой публикации нет.

Ссылка направляет объявленную зависимость file: на packages/plugin-conformance в этой рабочей копии:

Окно терминала
mkdir -p plugins/scheduling/node_modules/@dee-wan
ln -s ../../../../packages/plugin-conformance plugins/scheduling/node_modules/@dee-wan/plugin-conformance
Окно терминала
node scripts/install-dependencies.mjs # from the Dee Wan checkout root
npm --prefix packages/plugin-conformance run build
cd plugins/scheduling
npm run typecheck
npm test
npm run dev # wrangler dev on port 8788, with --test-scheduled
  1. Разверните Worker (см. ниже) и задайте PUBLIC_ORIGIN.
  2. Выпустите код активации:
    Окно терминала
    curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" https://scheduling.example/admin/activation-codes
  3. В Dee Wan перейдите в раздел Настройки → Плагины и установите из https://scheduling.example/dee-wan/manifest.json с этим кодом. Выдайте возможности и модели. workflow:locale нужна только для расписания одного языка. Без неё каждое расписание переводит всю группу переводов.
  4. Чтобы сменить токен, выпустите код с {"installation_id":"<id>"} и передайте его действию rotate в Dee Wan.

Манифест объявляет четыре ui_extensions. Это данные. Dee Wan сам рисует каждую карточку и открывает Scheduling обычным делегированным запуском.

  • schedule-card (entry_sidebar) и page-schedule (page_sidebar): карточка Schedule рядом с записью или страницей, модель которой входит в выданные права. Она запускает действие с контентом schedule.
  • calendar-row (settings_row): карточка Calendar в строке Scheduling в разделе Настройки → Плагины. Она запускает представление управления calendar и заменяет там обычную кнопку Календарь.
  • releases-row (settings_row): карточка Releases в той же строке. Она запускает представление управления releases.

Scheduling не объявляет редакторов полей.

Операции охватывают состояние расписаний, метки в списке контента, релизы и автоматизацию ядра. Политик плагин не объявляет.

  • schedule-column (scope: "page", audience: "column", cell: "badge", requires: ["content:read"], результат cells). Dee Wan добавляет столбец Scheduled в список контента каждой выданной модели. Ядро называет строки страницы, которые человеку разрешено читать, но не их значения, а эта операция отвечает меткой для каждой записи с активным расписанием. Она не создаёт делегирования и никогда не перечисляется как обычная операция.
  • schedule-status (audience: "interactive", requires: ["content:read"], результат rows, list_action: true). Dee Wan показывает её рядом с записью выданной модели; человек запускает её там и видит ожидающие расписания записи. В списке контента она также пункт Действия плагинов для выбранных человеком строк и запускается по разу для каждой. Агент запускает её через API управления или MCP.
  • schedule-draft (audience: "automation", requires: ["content:read", "workflow:read", "workflow:publish", "workflow:locale"], результат rows и json, разрушающая). Её запускает только контент-планировщик Dee Wan, от имени владельца плана, чтобы поставить в расписание ровно ту версию черновика, которую он сохранил (version_id); если текущий черновик записи — другая версия, она отказывает с draft_changed и ничего не планирует. Это никогда не операция редактора, API управления, CLI или MCP, и эти маршруты выполнения её отклоняют. Блок schedule.json результата — ссылка, которую хранит Dee Wan: { schedule_id, status, locale, transition, due_at, timezone, local_datetime, pinned_version_id }. Расписание хранит delegation_id операции, поэтому Dee Wan перечитывает полномочия владельца при выполнении — так же, как для расписания из формы.
  • timeline-preview (scope: "site", audience: "automation", requires: ["content:read", "workflow:read", "workflow:release"], результат json): накопительный предпросмотр до release_id, только для ядра и с текущим делегированием. Ничего не записывает.
  • releases, release-edit и release-run (scope: "site", audience: "interactive", результат rows и json; release-run разрушающая и также требует workflow:publish + workflow:release). Это страница Releases в виде операций, поэтому админка, API управления, CLI и локальный и удалённый MCP выполняют одни и те же правила. Dee Wan предлагает операции сайта людям с site:plugins и связывает делегирование manage вызывающего на выданных моделях, которые он читает; Scheduling читает охват этого делегирования при каждом вызове. Одна операция на каждый вид изменения, со входом action, удерживает манифест в пределах лимита операций.

Скопируйте wrangler.example.jsonc в wrangler.jsonc. Для развёртывания замените заглушку database_id на id, который выводит wrangler d1 create. Для локальной разработки заглушка остаётся.

  • Привязка: PLUGIN_DB (D1).
  • Cron: * * * * *.
  • Переменные: PUBLIC_ORIGIN, ENVIRONMENT, DEE_WAN_DEV_ORIGINS, MAX_ATTEMPTS (5), MAX_LATENESS_MS (900000), LEASE_MS (60000), MAX_RETRY_AFTER_MS (300000).
  • Секреты: ADMIN_SECRET (не менее 16 символов), TOKEN_KEY (base64 от 32 случайных байт, openssl rand -base64 32).
Окно терминала
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
npm run deploy

Новые файлы из migrations/ применяйте той же командой migrations apply до развёртывания релиза, которому они нужны.

Потеря TOKEN_KEY делает сохранённые токены инсталляций нечитаемыми. Тогда расписания отклоняются с token_unreadable, и для каждой установки плагина приходится заново менять токен.

Конфигурация — оба сервиса, их настройки и команды — описана в Тестирование плагина. Аккаунт Cloudflare не нужен.

Cookie интерфейса управления использует префикс __Host- и атрибут Secure, поэтому браузеру нужен HTTPS или localhost. Базовый URL Dee Wan по HTTP принимается, только когда ENVIRONMENT=development и его точный origin указан в DEE_WAN_DEV_ORIGINS. В остальных случаях базовый URL должен использовать HTTPS и не должен содержать учётных данных, query и фрагмента.

  • Расписание можно создать только из запуска над контентом (действие Schedule). Плагин читает контент с делегированием, и выбранная пара (kind, to) должна входить в делегированные workflow.transitions.
  • Язык: когда у инсталляции есть workflow:locale и у контента больше одного языка, форма предлагает All languages или один язык. Для контента с одним языком выбора нет. All languages меняет состояние всей группы переводов. При выборе отдельного языка меняется состояние только этого языка: выбранная пара (kind, to) должна входить в переходы этого языка в workflow.locales, и команда отправляет locale. Язык, которого Dee Wan не перечисляет, отклоняется с locale_unavailable.
  • Закрепления: публикация закрепляет current_version_id, снятие с публикации закрепляет published_version_id, и оба также закрепляют workflow.revision. Снятие с публикации одного языка закрепляет собственную published_version_id этого языка. Публикация одного языка закрепляет current_version_id группы, потому что одна версия содержит все языки. Ревизия всегда принадлежит группе. Расписание хранится относительно основного экземпляра, с locale для одного языка и пустым значением для всей группы.
  • На инсталляцию, элемент контента, вид и язык существует не более одного живого (pending|retrying|running) расписания. Расписание всей группы и расписание одного языка для того же элемента и вида тоже конфликтуют: группа включает этот язык. Новое расписание одним пакетом заменяет все конфликтующие активные расписания, ожидающие запуска или повторной попытки. Поэтому расписание всей группы заменяет расписания отдельных языков этого вида, а расписание одного языка заменяет расписание всей группы этого вида. Расписания разных языков могут ожидать выполнения одновременно. Если конфликтующее расписание выполняется, создание завершается с execution_in_progress.
  • Перепланирование создаёт новую строку с новой ревизией и новым ключом идемпотентности. Ревизии считаются на инсталляцию, элемент контента и вид, по всем языкам.
  • Ключ идемпотентности — sched:<installation_id>:<schedule_id>:r<revision>. Он остаётся одинаковым при каждом повторе.
  • Отменить можно только расписания, ожидающие запуска или повторной попытки. Отмена выполняющегося расписания возвращает execution_in_progress. Завершённое расписание отменить нельзя. Отмена никогда не вызывает Dee Wan и никогда не откатывает переход.
  • В момент выполнения ядро перепроверяет полномочия человека, закрепления и ревизию.
  • Запуск manage открывает список событий или календарь, если его запросил Dee Wan: расписания моделей, доступных в рамках делегирования. В этих представлениях можно открыть предпросмотр, а перенести или отменить — те расписания, которые человек может менять. Создавать расписания он не может.

Статусы: pending, retrying, running, completed, refused, cancelled, superseded.

Откройте Publication dependencies на странице записи или в Releases Scheduling. Review publication set начинает просмотр с выбранной сохранённой версии и учитывает обычные, локализованные и компонентные связи, а также ссылки на общие разделы для выбранного языка. Каждый шаг читает не более восьми записей; просмотр ограничен 500 посещёнными и 100 видимыми записями. Недоступные или ограниченные ссылки не раскрывают идентификатор или название и оставляют просмотр неполным. Можно добавить до 25 известных записей, доступных для публикации, из полного или неполного просмотра. Перед добавлением откройте страницу подтверждения, где перечислены все выбранные записи; каждая точная версия повторно проверяется. Историческую версию, которая больше не подходит для публикации, не подменяют новой. Если проверка временно недоступна, нажмите Save and continue review, чтобы продолжить незавершённый просмотр, или откройте просмотр снова и повторите проверку перед подтверждением.

Релиз публикует или снимает с публикации до 25 элементов одной командой CMS. Элементы могут относиться к разным моделям и языкам одного сайта. Каждый элемент задаёт запись, язык или всю группу переводов, вид (publish или unpublish) и целевое состояние.

  • Откройте карточку Releases в разделе Настройки → Плагины или Релизы на панели управления. Это запуск manage с view: releases: нужен доступ к разрешённой модели, а не site:plugins. Операции сайта по-прежнему требуют site:plugins. Добавляйте записи через Add to release на карточке Schedule.
  • Изменение должно быть сейчас доступно человеку в его делегировании. Разные языки одной группы переводов допустимы. Повтор одного языка или пересечение всей группы с отдельным языком отклоняется как already_in_release. Предел — 25 элементов (release_full).
  • Элементы показывают название, язык, модель, изменение и текущую готовность. Недоступные для чтения элементы подсчитываются без названия и причины. Run now и Retry unfinished items отключены, пока есть блокирующий элемент. Недоступная проверка даёт неизвестный результат, а не блокировку; запуск не начинается.
  • Состояния: draft, scheduled, running, done, failed, unknown, cancelled. Failed означает отказ CMS: ни одно изменение публикации не применено. Outcome unknown означает, что ответ не установил, была ли команда применена. Исполнитель сохраняет полученные результаты и не угадывает успех отдельных элементов.
  • Добавление и удаление элементов доступны только в draft и scheduled. Дата, время и часовой пояс работают как у отдельного расписания. Релиз выполняется от имени человека, который его запланировал, запустил или повторил. Ядро перепроверяет текущие права непосредственно перед применением.
  • При добавлении обычного элемента версия не закрепляется. Перед отправкой исполнитель сохраняет последний обычный черновик для публикации (expected_current_version_id), опубликованную версию для снятия (expected_published_version_id) и ревизию workflow. Для независимой будущей версии используется отдельная ветка, описанная ниже. Весь набор отправляется в /releases/commit с разрешённым workflow:release и правами на каждое изменение.
  • Ядро проверяет конечное состояние публикации целиком, включая зависимости внутри релиза. Общая секция и использующая её страница могут публиковаться вместе независимо от порядка. Разные языки одной группы применяются вместе. Отказ из-за прав, версии, workflow, языка, зависимостей или предела SQL-команд ничего не меняет. Ядро не разбивает слишком большую команду на отдельные коммиты.
  • Run now проверяет весь релиз и отправляет одну команду. Тик проверяет один релиз с наступившим временем. Заблокированный релиз остаётся scheduled, сохраняет blocked_at и запись Blocked attempts; автоматически он больше не стартует. Исправьте или уберите элементы и нажмите Run now либо задайте новое время, чтобы снять отметку.
  • Если набор элементов изменился во время проверки, запуск или повтор отклоняется как release_changed; ничего не отправляется. Запланированный запуск проверит новый набор в следующем тике.
  • Снятие расписания возвращает релиз в draft. Остановка предотвращает ещё не отправленную работу, но не отменяет уже отправленную атомарную команду: её результат необходимо получить.
  • Ключ идемпотентности всей команды — rel:<release_id>:a<attempt>. Потерянный, временный или недоверенный ответ сохраняет все закрепления и повторяет ту же команду с тем же ключом. После исчерпания автоматических попыток неизвестный результат показывается как Outcome unknown. Повтор сначала восстанавливает результат этой команды. После определённого отказа можно начать новую попытку со свежими версиями и проверкой готовности. Завершённая публикация не отправляется как новая команда.
  • Квитанции сохраняют результат релиза, опубликованные версии элементов, последний код и время завершения. Операция releases отдаёт их как release.json. Доставка уведомлений и завершение автоматических перенаправлений происходят после коммита и не могут его отменить.

В форме Add to release вариант Version → Latest ordinary draft сохраняет обычное поведение. Для публикации выберите Version → Independent future version, чтобы создать отдельную версию для этого релиза. Снятие с публикации с будущей версией недопустимо (future_requires_publish). В операции release-edit этому соответствует action: add_future с kind: publish; action: add использует обычный черновик. Интерфейс самого плагина остаётся на английском.

В Dee Wan откройте Будущие версии записи и выберите версию нужного релиза. Измените поля или секции и факты составной страницы, затем нажмите Сохранить. История будущей версии содержит неизменяемые сохранённые версии этой ветки. У других релизов могут быть свои ветки той же записи. Изменение и публикация ветки не затрагивают обычный черновик. При начале выполнения ядро закрепляет ревизию и версию выбранной ветки; после этого редактировать её нельзя. Повтор восстанавливает именно записанную команду и версию, а не подставляет свежий обычный черновик.

Перед Предпросмотр до этого выпуска задайте дату, время и часовой пояс релиза; иначе предпросмотр отклоняется с release_time_required. Он начинает с опубликованного контента, применяет публикации и снятия с публикации из более ранних релизов, затем выбранный релиз. Текущие права чтения сохраняются; публикация и публичный кеш не меняются. Релизы с одинаковым временем упорядочиваются по стабильному идентификатору, но пересекающиеся изменения одной записи на одном языке в этот момент отклоняются с release_time_ambiguous: задайте разное время. Для составных страниц используйте предпросмотр в редакторе будущей версии страницы.

Ядро получает этот план через операцию автоматизации timeline-preview с release_id, только для чтения. Она недоступна напрямую через management API, CLI и MCP. Перед публикацией по-прежнему проверяются текущие делегированные права, обязательные поля и задачи, workflow, связи и закреплённые медиа выбранного будущего снимка. Редактирование закреплённой ветки и предпросмотр недоступной цели отклоняются; обычный черновик вместо них не подставляется.

Календарь публикаций всего сайта. Он читает только расписания, которые хранит этот плагин. Dee Wan не хранит их копию и не запускает планировщик.

  • Кто: любой, кто читает хотя бы одну модель, выданную Scheduling, включая только свои записи. Календарь в разделе Обзор или в списке Контент, а в разделе Настройки → Плагины — карточка Calendar в строке Scheduling — это запуск manage с view: calendar; ему не нужно site:plugins. Управлять в разделе Настройки → Плагины — простой запуск manage, ему по-прежнему нужно site:plugins; он ведёт к списку событий. Манифест объявляет manage_views: ["calendar", "releases"], и Dee Wan добавляет dee_wan_view=calendar к URL запуска. Подсказка ничего не разрешает; плагин читает её только после успешного обмена кода manage. При запуске действия над контентом календарь и список событий недоступны: запрос к ним возвращает 403.
  • Охват: перед каждой страницей и каждым изменением плагин спрашивает Dee Wan GET /api/plugin/v1/delegations/<id>. Каждое представление, счётчик, фильтр, уведомление и страница расписания показывают только расписания перечисленных там моделей. Запрос к любому другому расписанию возвращает not_found. Пользователь, которому не доступна ни одна модель, видит No content you can see is scheduled here. Расписания хранит Scheduling; Dee Wan не держит копию.
  • Свои записи: модель с entries: "checked" — та, где человек читает или обновляет только свои записи (read:own, update:own). Для неё плагин на каждой странице и при каждом изменении спрашивает Dee Wan GET /api/plugin/v1/delegations/<id>/entries, до 100 id записей за вызов. Он показывает, считает и предлагает в фильтрах только записи, которые перечислил Dee Wan, а Reschedule или Cancel предлагает только для видов, указанных у этой записи. Запрос к любому другому расписанию возвращает not_found. Человек без своих расписаний видит пустой календарь. Scheduling не хранит владельца. Представление постранично перебирает все записи таких моделей в своём диапазоне и спрашивает о каждой записи один раз, сколько бы расписаний у неё ни было; счётчики, списки и фильтры строятся из этого одного проверенного набора. Диапазон, где таких записей больше 4 000 (больше, чем один запрос Workers Free может спросить), отклоняется целиком: This range is too large to check with Dee Wan at once. Nothing is shown. Open a single day. Фильтры моделей и языков для таких моделей строятся из проверенных записей диапазона представления.
  • Удалено: если Dee Wan отвечает на этот запрос 401 plugin_unauthorized (инсталляцию удалили, отключили или отозвали её токен), каждая страница отвечает 200 и пишет This installation was removed from Dee Wan or turned off there. Its schedules no longer run. Она не показывает ни расписаний, ни счётчиков, ни действий. Без охвата плагин не знает, какие модели человеку видны, поэтому не показывает ни одной. Отправленные всё же Cancel или Reschedule ничего не меняют.
  • Dee Wan недоступен: нет ответа, 5xx или 429 rate_limited — страница отвечает 403 с Dee Wan is not available right now. Nothing changed. Try again in a minute. и не показывает расписаний.
  • Кто что может менять: Reschedule и Cancel появляются, только когда вид расписания (publish или unpublish) указан для его модели. Dee Wan указывает вид, когда у инсталляции есть workflow:publish или workflow:unpublish, человек может обновлять элемент (любой элемент модели или, при update:own, свой), а в процессе модели есть переход этого вида, право на который у человека есть. Пользователь без этих прав может только просматривать расписания; страница сообщает об этом. Reschedule или Cancel, отправленные всё равно, отвечают not_permitted и ничего не меняют. Перенос затем всё равно перечитывает контент, как ниже.
  • Представления: Calendar — сетка месяца, начиная с понедельника, с числом расписаний на каждый день. День с расписаниями ссылается на этот день. Agenda — тот же месяц списком, по дням. Day перечисляет расписания одного дня по времени срабатывания, затем по id.
  • Адрес: месяц и все фильтры хранятся в query, поэтому каждое представление — обычная GET-ссылка, а форма фильтров работает без JavaScript. Переход к предыдущему и следующему месяцу сохраняет фильтры. Их же сохраняют ссылки между Calendar и Agenda и ссылки на дни.
  • month: YYYY-MM. По умолчанию — текущий месяц в выбранном часовом поясе. Непригодное значение заменяется значением по умолчанию.
  • zone: часовой пояс IANA, в котором читаются дни. По умолчанию UTC. Каждая строка попадает на местный день своего времени срабатывания в этом поясе; границы месяца и дня — местные полуночи этого пояса, в том числе при переводе часов и там, где сама полночь пропускается. Неизвестный пояс отклоняется по имени с 400 и ссылкой на то же представление в UTC.
  • model: id модели. В списке есть только модели, для которых у этой инсталляции есть расписания и к которым разрешён доступ в рамках делегирования, по slug, записанному при создании расписания.
  • lang: язык. В списке есть только языки, для которых у этой инсталляции есть расписания одного языка на моделях, доступных в рамках делегирования. Расписание всей группы меняет состояние всех языковых версий, поэтому подходит под любой фильтр языка.
  • state: current (по умолчанию: pending, retrying, running, completed, refused), live (pending, retrying, running), all или один статус. Завершённые и отклонённые расписания показывают код и сообщение результата.
  • Границы: каждое представление читает один месяц или один день диапазоном по due_at, который обслуживает индекс schedule_agenda (installation_id, due_at, id). Календарь считает расписания по дням в этом же одном запросе. Список событий и представление дня показывают не больше 200 строк, затем подсказку сузить фильтры. Представления ничего не записывают.
  • Названия: каждое расписание показывает заголовок элемента, записанный при его создании, или slug, если у элемента нет заголовка. Расписание одного языка показывает название языка на языке интерфейса. Когда часовой пояс просмотра отличается от собственного пояса расписания, время показывается и в поясе просмотра.
  • Предпросмотр: Open у расписания читает его контент из Dee Wan с делегированием сессии. Он показывает заголовок, модель, язык и текущее состояние, а также актуальны ли закреплённая версия и ревизия процесса согласования. Для расписания одного языка язык, состояние и закрепление — собственные у этого языка. Если Dee Wan больше не перечисляет этот язык, предпросмотр говорит locale_unavailable. Если Dee Wan отказывает в чтении, страница говорит почему. Back возвращает к представлению, из которого он был открыт.
  • Перенос: новое местное время и часовой пояс для расписания, ожидающего запуска или повторной попытки. Закрепления, вид, цель и язык остаются прежними. Сначала плагин заново читает контент через делегирование сессии: человеку всё ещё должен предлагаться этот переход, для этого языка, если он у расписания есть, а закреплённые версия и ревизия должны оставаться текущими. Иначе он отказывает с locale_unavailable, transition_not_allowed, scheduled_target_stale или workflow_conflict и ничего не меняет. Перенос вставляет новую строку со следующей ревизией и новым ключом идемпотентности, а старую помечает как superseded. Новая строка несёт делегирование человека, который перенёс расписание. Выполняющееся расписание отвечает execution_in_progress, а завершённое перенести нельзя.
  • Отмена: только расписания, ожидающие запуска или повторной попытки, как в «Семантике расписаний». Она никогда не вызывает Dee Wan. Из представления она возвращает к этому же представлению с уведомлением. Адрес return принимается только для представлений этой инсталляции, а его query разбирается заново, а не повторяется как есть.

Панель Workflow в Dee Wan ссылается сюда. Если последний переход workflow элемента сделал этот плагин, панель предлагает View schedule, что открывает страницу этого плагина для этого элемента через действие над контентом Schedule.

Каждое расписание хранит due_at в UTC, часовой пояс IANA, местное время и fold. Изменение правила часового пояса никогда не переписывает due_at. Для преобразования местного времени в UTC используется только Intl.DateTimeFormat.

Ввод Результат
2026-07-01T09:00 America/New_York 2026-07-01 13:00 UTC
2026-03-29T02:30 Europe/Berlin отклонено nonexistent_local_time (часы переходят 02:00→03:00)
2026-10-25T02:30 Europe/Berlin, без fold отклонено ambiguous_local_time
то же, fold earlier 2026-10-25 00:30 UTC (CEST)
то же, fold later 2026-10-25 01:30 UTC (CET)
2026-07-01T09:00 Mars/Base отклонено unknown_timezone
2025-12-31T23:59 UTC, уже в прошлом отклонено past_time

Подтверждение и список показывают локальное время с его поясом и время UTC. Если пояс UTC, время показано один раз.

Каждый тик cron берёт до 20 записей, срок выполнения которых наступил, чередуя инсталляции. Захват через compare-and-set меняет наблюдаемые статус и токен блокировки, увеличивает attempt_count и назначает новый токен с lease_until = now + LEASE_MS. После расшифровки токена отправитель повторно проверяет владение, лимиты попыток/возраста и оставшееся время блокировки, достаточное для полного тайм-аута запроса. Устаревший владелец ничего не отправляет и не завершает чужую запись. Восстановление может повторить те же ключ и тело. Таймаут не доказывает остановку удалённого выполнения. Идемпотентность ядра ограничивает эффект, но не обещает единственный HTTP-запрос.

Ответ Dee Wan Результат плагина
200 transition_applied / idempotent_replay completed
400 invalid_request refused, без повтора
401 plugin_unauthorized refused, без повтора
403 capability_refused / requester_unauthorized refused, без повтора
404 content_not_found refused, без повтора
409 scheduled_target_stale / workflow_conflict / dependent_content / idempotency_key_reused refused, без повтора
422 transition_unavailable / transition_refused refused, без повтора
Присутствующий outcome с несовпадением статуса/кода/retry или некорректными полями. Тело без outcome на остальных статусах. Перенаправления refused, untrusted_response
429 rate_limited retrying. Допустимый ограниченный Retry-After, иначе экспоненциальный backoff с джиттером
503 temporary_failure retrying. Допустимый ограниченный Retry-After, иначе экспоненциальный backoff с джиттером
Ответ 429/500/502/503/504 без outcome. Сетевой сбой или таймаут (10 s) retrying. Ограниченный Retry-After, если передан, иначе экспоненциальный backoff с джиттером
сверх MAX_ATTEMPTS, позже due_at + MAX_LATENESS_MS или Retry-After > MAX_RETRY_AFTER_MS refused retry_exhausted, больше ничего не отправляется

Структурированный outcome проверяется прежде, чем результат определяется по HTTP-статусу при отсутствии outcome. Retry-After принимает целые неотрицательные секунды или каноническую HTTP-дату (IMF-fixdate). Прошедшая дата означает ноль, некорректное значение — backoff. Значение выше MAX_RETRY_AFTER_MS, лимит попыток или возраста дают retry_exhausted. Минимум LEASE_MS — 11 секунд, больше таймаута запроса в 10 секунд. Если оставшегося времени блокировки недостаточно, запрос не отправляется. Успехом считаются только transition_applied и idempotent_replay.

retry_exhausted после сетевого сбоя означает, что исход неизвестен, поэтому проверьте контент в Dee Wan. Расписание никогда не отправляется позже лимита опоздания, в том числе при возобновлении работы после сбоя владельца блокировки.

Попытки записываются в schedule_attempt (попытка, начало, окончание, код, HTTP-статус). Это операционная запись для пользователя, а не телеметрия.

  • Хранимые данные: инсталляция (id, id сайта, базовый URL Dee Wan и токен, зашифрованный AES-GCM). Хешированные коды активации, хешированные сессии (30 минут) с их CSRF-токенами, расписания и попытки, а также релизы с их элементами и квитанциями. Никакие снимки контента и никакие пользовательские сессии Dee Wan не хранятся.
  • Коды запуска и сырые токены никогда не логируются и никогда не хранятся в сыром виде.
  • Удаление плагина в Dee Wan отзывает токен. Данные плагина остаются. Оставшиеся расписания отклоняются с plugin_unauthorized, когда наступает их срок. Страницы плагина затем пишут, что инсталляция удалена, и не показывают расписаний.
  • Чтобы удалить данные плагина, оператор вызывает POST /admin/installations/:id/delete-data с Bearer ADMIN_SECRET. Вызов отклоняется с 409 execution_in_progress, пока выполняется какое-либо расписание. Автоматической очистки по сроку хранения нет.
Окно терминала
npm test # vitest, from this directory
npm run typecheck
npm run test:cross-service # this plugin and a live Dee Wan, both under wrangler dev; two to three minutes

npm test включает test/conformance.test.ts, который прогоняет случаи протокола v1 из @dee-wan/plugin-conformance для этого плагина: таблицу исходов, статусы, флаги retry, возможности и форматы секретов на соответствие опубликованным схемам, манифест на соответствие его опубликованной схеме, классификацию ответов, активацию, обмен кодом запуска и доставку с помощью настоящего исполнителя и тестового ядра из пакета. test/helpers/d1.ts — прослойка D1 над better-sqlite3, которая применяет каждый файл из migrations/.

test/docs.test.ts прогоняет таблицы «Исполнитель» и «Время» выше через classify и resolveLocalTime, поэтому устаревшая строка приводит к ошибке теста.

test:cross-service запускает test/cross-service/cross-service.test.mjs. Он запускает admin Worker Dee Wan и этот плагин под локальным wrangler dev, устанавливает плагин через Dee Wan и прогоняет публикации и снятия с публикации по расписанию, а также отказы через протокол v1. Dee Wan — внешний checkout: задайте DEE_WAN_CORE_DIR как репозиторий Dee Wan с установленными зависимостями (node scripts/install-dependencies.mjs), собранными пакетами (npm run build:packages) и сгенерированным клиентом Prisma (npm --workspace backend run prisma:generate). Внутри репозитория Dee Wan переменную можно не задавать. Автоматизированный межсервисный прогон перечисляет, что он проверяет.

.github/workflows/ci.yml — собственный CI этого плагина для его собственного репозитория: установка, проверка типов, npm test, затем межсервисный прогон с рабочей копией Dee Wan на vars.DEE_WAN_CORE_REF (по умолчанию main). GitHub не запускает его, пока этот каталог находится внутри репозитория Dee Wan, а CI Dee Wan не запускает этот плагин.

Перенос требует явных полномочий: создание dee-wan-cms/dee-wan-scheduling, push в него и публикация @dee-wan/plugin-conformance — внешние действия. С такими полномочиями, из корня репозитория Dee Wan:

Окно терминала
# 1. Publish the conformance package (needs npm publish rights for the @dee-wan scope).
node scripts/install-dependencies.mjs
npm --prefix packages/plugin-conformance run build
cd packages/plugin-conformance
npm test
npm publish --access public
cd ../..
# 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.git
cd dee-wan-scheduling
npm pkg set devDependencies.@dee-wan/plugin-conformance=^0.1.0
npm install
npm run typecheck
npm test
git add package.json package-lock.json
git commit -m "Depend on the published @dee-wan/plugin-conformance"
git push

Затем в том репозитории: если dee-wan-cms/dee-wan приватный, добавьте секрет DEE_WAN_CORE_TOKEN с доступом только на чтение и при необходимости задайте переменную DEE_WAN_CORE_REF — ref рабочей копии Dee Wan, с которой выполняются тесты. Ссылки в этом README и в docs/end-to-end.md, ведущие в ../../docs/plugins/, должны стать ссылками на опубликованную документацию Dee Wan.

Удаление plugins/scheduling из репозитория Dee Wan — отдельное изменение там. Оно также убирает этот README и docs/end-to-end.md с сайта документации Dee Wan (docs-site/allowlist.mjs и их переводы ru и th) и направляет docs/plugins/*.md на новый репозиторий.


Стабильная версия ещё не выпущена (pre-MVP)
Коммит исходного кода: e207e63239a627fbd2ae9c414ef7c6eb19fcfe5a (с локальными изменениями)
Дата сборки (UTC): 2026-10-21T09:28:24.214Z
Коммит для обновления: не определён: в рабочей копии есть изменения. Примечания к выпуску