Запланировать последующую поддержку

CloudflareBeginner
Практиковаться сейчас

Введение

Команда поддержки часто обещает ещё раз проверить тикет позже: после того как клиент попробует исправление, после окончания окна обслуживания или до истечения срока эскалации. Таймер браузера не может безопасно отвечать за такое обещание: при закрытии вкладки оно исчезнет. Расписание Agent сохраняет будущее действие вместе с именованным Agent, поэтому платформа сможет активировать этот устойчивый экземпляр в нужный момент.

В этой лабораторной работе вы создадите небольшую доску последующих действий без языковой модели:

  1. schedule() регистрирует один отложенный обратный вызов и возвращает устойчивый идентификатор расписания.
  2. listSchedules() позволяет приложению просматривать ожидающую работу с помощью текущего асинхронного API.
  3. cancelSchedule() удаляет ещё не выполненный элемент после того, как сервер проверит, что он принадлежит ему.
  4. Обратный вызов сохраняет ограниченную запись о завершении в состоянии Agent и создаёт журнал с ограниченным объёмом данных.

Вы запланируете короткую задачу и дождётесь её завершения, затем создадите более длительную задачу и отмените её до выполнения. Вызовы используют только синтетические ссылки на тикеты. Одинаковые запросы регистрации используют идемпотентность SDK, поэтому случайное двойное нажатие не создаёт дублирующую работу.

Agents SDK реализует этот жизненный цикл поверх сигнала тревоги Durable Object с хранилищем на базе SQLite. Вы используете высокоуровневый API расписаний вместо самостоятельного управления временными метками сигналов и записями хранилища, но работа по-прежнему принадлежит одному именованному экземпляру Agent и сохраняется после обычных перезапусков Worker.

Прежде чем проходить этот курс напрямую, завершите лабораторную работу Подключение LabEx к вашей учётной записи Cloudflare. Для каждой новой виртуальной машины LabEx требуется отдельная авторизация Wrangler. Предыдущие лабораторные работы курса рекомендуются, но эта лабораторная работа создаёт и удаляет собственные изолированные ресурсы.

Авторизуйте виртуальную машину и настройте Agent

На этом шаге вы авторизуете новую виртуальную машину и определите единственный временный Worker и класс Durable Object, используемые в лабораторной работе.

cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json

Откройте напечатанную ссылку на устройство в браузере LabEx, проверьте отображаемый код и подтвердите учётную запись для обучения. Не сообщайте никому пароль, токен или код авторизации. В результате JSON убедитесь, что "loggedIn": true, прочитайте имя учётной записи и скопируйте её идентификатор.

Создайте уникальное имя ресурса и файл wrangler.jsonc:

RUN="labex-c11-s04-$(openssl rand -hex 6)"
ACCOUNT_ID="YOUR_ACCOUNT_ID"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "durable_objects": {
    "bindings": [
      { "name": "FollowUpAgent", "class_name": "FollowUpAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] }
  ]
}
JSON
python3 .labex/verify.py auth

Имя binding используется маршрутизатором и клиентом; имя класса обозначает реализацию. Миграция v1 просит Cloudflare создать хранилище на базе SQLite для этого класса. Она ещё не создаёт конкретный именованный экземпляр — экземпляр, например planning, появится, когда первый запрос обратится к нему.

Реализуйте устойчивое планирование последующих действий

На этом шаге вы реализуете регистрацию, просмотр, отмену и последующий обратный вызов в одном именованном Agent.

cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest, type Schedule } from "agents";

type CompletedFollowUp = { ticketId: string; completedAt: string };
export type FollowUpState = { completed: CompletedFollowUp[]; revision: number };
export type PendingFollowUp = { id: string; ticketId: string; runAt: string };

export class FollowUpAgent extends Agent<Cloudflare.Env, FollowUpState> {
  initialState: FollowUpState = { completed: [], revision: 0 };

  private ticket(value: unknown): string {
    const ticketId = typeof value === "string" ? value.trim().toUpperCase() : "";
    if (!/^T-[A-Z0-9-]{3,24}$/.test(ticketId)) {
      throw new Error("ticket must look like T-DEMO-101");
    }
    return ticketId;
  }

  @callable()
  async scheduleFollowUp(ticketInput: string, delaySeconds: number): Promise<PendingFollowUp> {
    const ticketId = this.ticket(ticketInput);
    if (!Number.isInteger(delaySeconds) || delaySeconds < 3 || delaySeconds > 300) {
      throw new Error("delay must be an integer from 3 to 300 seconds");
    }
    const scheduled = await this.schedule(
      delaySeconds,
      "completeFollowUp",
      { ticketId },
      {
        idempotent: true,
        retry: { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 500 }
      }
    );
    return this.pending(scheduled);
  }

  @callable()
  async listFollowUps(): Promise<PendingFollowUp[]> {
    const schedules = await this.listSchedules({ type: "delayed" });
    return schedules
      .filter((item) => item.callback === "completeFollowUp")
      .map((item) => this.pending(item))
      .sort((left, right) => left.runAt.localeCompare(right.runAt));
  }

  @callable()
  async cancelFollowUp(scheduleId: string): Promise<boolean> {
    if (!/^[a-zA-Z0-9_-]{8,80}$/.test(scheduleId)) throw new Error("invalid schedule ID");
    const owned = await this.getScheduleById(scheduleId);
    if (!owned || owned.callback !== "completeFollowUp") return false;
    return this.cancelSchedule(scheduleId);
  }

  @callable()
  getBoard(): FollowUpState {
    return this.state;
  }

  async completeFollowUp(payload: unknown, _schedule: Schedule<unknown>): Promise<void> {
    const ticketId = this.ticket((payload as { ticketId?: unknown })?.ticketId);
    const next: FollowUpState = {
      completed: [...this.state.completed, { ticketId, completedAt: new Date().toISOString() }].slice(-5),
      revision: this.state.revision + 1
    };
    this.setState(next);
    console.log(JSON.stringify({
      event: "follow_up_completed",
      instance: this.name,
      revision: next.revision,
      completedCount: next.completed.length
    }));
  }

  private pending(schedule: Schedule<unknown>): PendingFollowUp {
    const payload = schedule.payload as { ticketId?: unknown };
    return {
      id: schedule.id,
      ticketId: this.ticket(payload.ticketId),
      runAt: new Date(schedule.time * 1000).toISOString()
    };
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

Cloudflare.Env поступает из объявлений binding, созданных Wrangler, которые вы сгенерируете перед компиляцией. Поэтому в исходном коде не поддерживается вторая копия окружения, написанная вручную. schedule() получает относительную задержку, имя обратного вызова и небольшой сериализуемый набор данных. { idempotent: true } означает, что повторная передача того же обратного вызова и тех же данных вернёт существующее ожидающее расписание, а не добавит ещё одно. Политика повторных попыток разрешает не более двух запусков обратного вызова с короткой ограниченной задержкой между ними; поэтому постоянная ошибка не сможет повторяться бесконечно. Обратный вызов сохраняет только пять синтетических завершений, а структурированный журнал не содержит ссылки на тикет.

Методы просмотра списка и поиска намеренно вызываются с await. В старых примерах могут встречаться синхронные вызовы getSchedule() или getSchedules(); в текущем коде Agents SDK следует использовать getScheduleById() и listSchedules().

Подключите доску последующих действий

На этом шаге вы настроите текущее преобразование декораторов и подключите предоставленную страницу к одному именованному Agent.

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": { "noEmit": true },
  "include": ["src/**/*.ts", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [agents(), cloudflare()] });
TS

cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { FollowUpState, PendingFollowUp } from "./server";

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <main><p class="eyebrow">Durable scheduling</p><h1>Support Follow-Up Board</h1>
  <p id="status" class="status">Connecting to FollowUpAgent:planning…</p>
  <form id="form"><input id="ticket" value="T-DEMO-101" aria-label="Ticket reference">
  <input id="delay" type="number" min="3" max="300" value="12" aria-label="Delay in seconds">
  <button>Schedule follow-up</button></form><p id="error" class="error"></p>
  <div class="columns"><section class="panel"><h2>Pending</h2><div id="pending"></div></section>
  <section class="panel"><h2>Completed</h2><div id="completed"></div></section></div>
  <p class="notice">This demonstration uses synthetic ticket references only.</p></main>`;

const client = new AgentClient<FollowUpState>({ agent: "FollowUpAgent", name: "planning", host: window.location.host });
const pendingView = document.querySelector<HTMLDivElement>("#pending")!;
const completedView = document.querySelector<HTMLDivElement>("#completed")!;
const statusView = document.querySelector<HTMLParagraphElement>("#status")!;
const errorView = document.querySelector<HTMLParagraphElement>("#error")!;

function renderCompleted(state: FollowUpState) {
  completedView.innerHTML = state.completed.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.completedAt).toLocaleTimeString()}</small></div>`
  ).join("") || '<p class="empty">No completed follow-ups yet</p>';
}

async function refresh() {
  const pending = await client.call<PendingFollowUp[]>("listFollowUps", []);
  pendingView.innerHTML = pending.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.runAt).toLocaleTimeString()}</small><br>` +
    `<button class="secondary" data-id="${item.id}">Cancel</button></div>`
  ).join("") || '<p class="empty">No pending follow-ups</p>';
  const state = await client.call<FollowUpState>("getBoard", []);
  renderCompleted(state);
}

await client.ready;
statusView.textContent = "Connected to FollowUpAgent:planning";
await refresh();
setInterval(() => refresh().catch(() => undefined), 2000);

document.querySelector<HTMLFormElement>("#form")!.addEventListener("submit", async (event) => {
  event.preventDefault(); errorView.textContent = "";
  try {
    const ticket = document.querySelector<HTMLInputElement>("#ticket")!.value;
    const delay = Number(document.querySelector<HTMLInputElement>("#delay")!.value);
    await client.call("scheduleFollowUp", [ticket, delay]); await refresh();
  } catch (cause) { errorView.textContent = cause instanceof Error ? cause.message : String(cause); }
});

pendingView.addEventListener("click", async (event) => {
  const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-id]");
  if (!button) return;
  await client.call("cancelFollowUp", [button.dataset.id]); await refresh();
});
TS
python3 .labex/verify.py client

Страница опрашивает Agent каждые две секунды только для того, чтобы этот пример на обычном TypeScript было проще читать. Само расписание не является таймером браузера: закрытие страницы не отменяет его. Сервер остаётся источником истины для проверки, определения принадлежности и выполнения.

Сгенерируйте типы и соберите приложение

На этом шаге вы сгенерируете типы binding и соберёте обе части приложения до запуска любого окружения.

Сгенерируйте типы окружения на основе точного binding, проверьте обе части TypeScript и соберите Worker вместе со статической страницей:

npx wrangler types
grep -n "FollowUpAgent" worker-configuration.d.ts | head
npm run check
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
python3 .labex/verify.py build

Успешная сборка подтверждает согласованность binding, преобразования декораторов, общих типов и пакетов. Она ещё не доказывает, что сигнал тревоги сработает или что развёрнутый ресурс принадлежит облачной учётной записи; эти проверки выполняются на следующих шагах во время работы приложения.

Проверьте жизненный цикл локально

На этом шаге вы убедитесь, что устойчивое выполнение и отмена работают в локальном окружении Cloudflare.

Запустите локальное окружение как постоянный фоновый процесс:

CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head

Откройте http://localhost:5173 в браузере рабочего стола LabEx. Запланируйте T-DEMO-101 через 12 секунд. Сначала он появится в разделе Pending; закрытие или обновление страницы не отменяет эту работу. После наступления запланированного времени обратный вызов удалит её из хранилища расписаний и добавит в раздел Completed.

Затем запланируйте T-DEMO-CANCEL через 90 секунд и нажмите Cancel. Элемент исчезнет из Pending и никогда не появится в Completed. Запустите независимую проверку, которая использует собственное случайное имя Agent и подтверждает идемпотентную регистрацию, выполнение и отмену:

python3 .labex/verify.py local

Разверните приложение и проверьте запланированную работу

На этом шаге вы повторите жизненный цикл в Cloudflare и сопоставите наблюдаемое поведение с данными Dashboard.

Остановите именно локальный процесс, разверните production-сборку и дождитесь URL:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
  curl --silent --fail "$WORKER_URL/" > /dev/null && break
  sleep 2
done

Откройте точный URL во встроенном браузере. Запланируйте T-CLOUD-101 через 20 секунд и сначала найдите устойчивую строку в списке ожидающих задач.

Облачное последующее действие отображается в списке ожидающих расписаний

Время запуска и идентификатор расписания принадлежат принятому временному запуску; ваши значения будут отличаться. Важно, что элемент показывает Agent, а не обратный отсчёт, сохранённый на странице.

Дождитесь обратного вызова и убедитесь, что тот же синтетический тикет появился в разделе Completed.

Запланированный обратный вызов переместил синтетический тикет в состояние завершённых

Создайте T-CLOUD-CANCEL с задержкой 90 секунд, зафиксируйте его состояние Pending и отмените его. Панель ожидающих задач должна снова стать пустой, а запись о завершении не должна измениться.

Более длительное ожидающее последующее действие готово к явной отмене

Отменённое расписание отсутствует, а предыдущее завершение сохраняется

Откройте Workers & Pages, выберите точный Worker labex-c11-s04-... и откройте раздел Bindings. Убедитесь, что FollowUpAgent указывает на то же имя класса.

Binding Worker соединяет запросы с FollowUpAgent

Откройте Durable Objects и проверьте пространство имён FollowUpAgent. Оно использует SQL-хранилище, поскольку состояние Agent и расписания требуют устойчивых записей.

Пространство имён Durable Object FollowUpAgent использует SQL-хранилище

Наконец, откройте Observability → Logs, отфильтруйте записи по follow_up_completed и разверните одно событие. Ограниченное событие содержит экземпляр Agent, номер редакции и количество завершений, но не содержит ссылки на тикет.

Ограниченный журнал завершения не содержит ссылки на синтетический тикет

Данные в Dashboard могут появляться с задержкой, поэтому независимая удалённая проверка является авторитетной:

python3 .labex/verify.py deployed
python3 .labex/verify.py observed

Удалите пространство имён расписаний и Worker

На этом шаге вы удалите только пространство имён класса и Worker, созданные этой лабораторной работой.

Расписания и состояние завершений находятся в пространстве имён класса Durable Object. Сначала явно удалите этот класс, а затем оставшийся stateless Worker:

cat > src/cleanup.ts <<'TS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
TS
RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] },
    { "tag": "v2", "deleted_classes": ["FollowUpAgent"] }
  ]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted

Не удаляйте другие ресурсы учётной записи. Убедитесь, что удалены только точно сгенерированный Worker и его пространство имён FollowUpAgent.

Временный Worker планирования отсутствует после очистки

Пространство имён FollowUpAgent отсутствует после миграции удаления

Отзовите авторизацию этой виртуальной машины

На этом шаге вы удалите OAuth-разрешение, сохранённое на временной виртуальной машине, и проверите структурированное состояние выхода из системы.

После успешной очистки облачных ресурсов удалите OAuth-авторизацию, сохранённую на этой временной виртуальной машине:

npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout

Убедитесь, что явно указано "loggedIn": false. Ошибка сети не позволяет сделать однозначный вывод; в этом случае повторите проверку. Временный Worker, его пространство имён расписаний и локальная авторизация этой виртуальной машины теперь удалены.

Итоги

Вы предоставили одному именованному Cloudflare Agent возможность выполнять будущую работу устойчиво, не полагаясь на открытую вкладку браузера или языковую модель. Вы зарегистрировали ограниченный отложенный обратный вызов, сделали повторную регистрацию идемпотентной, просмотрели ожидающие расписания через текущий асинхронный API, проверили принадлежность перед отменой и сохранили только небольшую историю завершений.

Вы также связали абстракцию SDK с жизненным циклом сигнала тревоги Durable Object, подтвердили завершение и отмену локально и удалённо, проверили свидетельства с ограниченным объёмом данных и явно удалили пространство имён класса, Worker и авторизацию временной виртуальной машины.