Agendar um acompanhamento de suporte

CloudflareBeginner
Pratique Agora

Introdução

Uma equipe de suporte costuma prometer verificar um ticket novamente mais tarde: depois que o cliente tentar uma correção, depois que uma janela de manutenção terminar ou antes do prazo para uma escalação. Um timer do navegador não pode assumir essa responsabilidade com segurança, porque fechar a aba o apaga. Um agendamento de Agent armazena a ação futura junto ao Agent nomeado, permitindo que a plataforma desperte essa instância durável quando chegar o momento.

Neste laboratório, você criará um pequeno painel de acompanhamentos sem usar um modelo de linguagem:

  1. schedule() registra um callback atrasado e retorna o ID durável do agendamento.
  2. listSchedules() permite que a aplicação inspecione o trabalho pendente usando a API assíncrona atual.
  3. cancelSchedule() remove um item ainda pendente depois que o servidor verifica o que pertence a ele.
  4. O callback registra uma conclusão limitada no estado do Agent e emite um log com dados mínimos.

Você agendará uma tarefa curta e observará sua conclusão. Depois, criará uma tarefa mais longa e a cancelará antes da execução. As chamadas usam apenas referências de tickets fictícias. Solicitações de registro idênticas usam a idempotência do SDK, impedindo que um clique duplo acidental crie trabalho duplicado.

O Agents SDK implementa esse ciclo de vida sobre um alarme de Durable Object com armazenamento baseado em SQLite. Você usa a API de agendamento de nível mais alto em vez de gerenciar timestamps de alarmes e registros de armazenamento por conta própria, mas o trabalho ainda pertence a uma única instância de Agent nomeada e sobrevive às reinicializações normais do Worker.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta da Cloudflare. Cada nova VM do LabEx precisa de sua própria autorização do Wrangler. Os laboratórios anteriores do curso são recomendados, mas este laboratório cria e remove seus próprios recursos isolados.

Autorizar a VM e configurar o Agent

Nesta etapa, você autorizará esta VM nova e definirá o único Worker descartável e a única classe de Durable Object usados pelo laboratório.

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

Abra o link do dispositivo exibido no navegador do LabEx, confirme o código mostrado e aprove a conta de aprendizagem. Não envie senha, token ou código de autorização a ninguém. No resultado JSON, confirme que "loggedIn": true, leia o nome da conta e copie o ID dela.

Gere um nome de recurso exclusivo e crie 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

O nome do binding é usado pelo roteador e pelo cliente; o nome da classe identifica a implementação. A migração v1 pede à Cloudflare que crie armazenamento baseado em SQLite para essa classe. Ela ainda não cria uma instância nomeada específica: uma instância como planning aparece quando o tráfego a acessa pela primeira vez.

Implementar o agendamento durável de acompanhamentos

Nesta etapa, você implementará o registro, a inspeção, o cancelamento e o callback final em um único Agent nomeado.

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 vem das declarações de binding geradas pelo Wrangler que você criará antes da compilação. Assim, o código-fonte não mantém uma segunda cópia escrita manualmente do ambiente. schedule() recebe um atraso relativo, o nome do callback e um payload pequeno e serializável. { idempotent: true } significa que repetir o mesmo callback com o mesmo payload retorna o agendamento pendente existente, em vez de adicionar outro. A política de novas tentativas permite no máximo duas execuções do callback, com um recuo curto e limitado; portanto, um erro permanente não entra em um loop infinito. O callback mantém apenas cinco conclusões fictícias, e seu log estruturado não inclui a referência do ticket.

Os métodos de listagem e consulta são aguardados explicitamente. Exemplos mais antigos podem mostrar chamadas síncronas como getSchedule() ou getSchedules(); no código atual do Agents SDK, use getScheduleById() e listSchedules().

Conectar o painel de acompanhamentos

Nesta etapa, você configurará a transformação atual dos decorators e conectará a página fornecida a um Agent nomeado.

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

A página consulta o Agent a cada dois segundos apenas para manter este exemplo simples em TypeScript. O agendamento em si não é um timer do navegador: fechar a página não o cancela. O servidor continua sendo a autoridade para validação, propriedade e execução.

Gerar os tipos e compilar a aplicação

Nesta etapa, você gerará os tipos dos bindings e compilará as duas partes da aplicação antes de iniciar qualquer runtime.

Gere os tipos do ambiente a partir do binding exato, verifique as duas partes em TypeScript e compile o Worker e a página estática:

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

Uma compilação sem erros comprova que o binding, a transformação dos decorators, os tipos compartilhados e os bundles são compatíveis. Isso ainda não comprova que um alarme será disparado nem que a conta na nuvem possui o recurso implantado; essas verificações de runtime serão feitas nas próximas etapas.

Comprovar o ciclo de vida localmente

Nesta etapa, você comprovará que a execução durável e o cancelamento funcionam no runtime local da Cloudflare.

Inicie o runtime local como um processo persistente em segundo plano:

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

Abra http://localhost:5173 no navegador da área de trabalho do LabEx. Agende T-DEMO-101 para 12 segundos. Primeiro, ele aparecerá em Pending; fechar ou atualizar a página não controla essa tarefa. Depois do horário agendado, o callback a removerá do armazenamento de agendamentos e a registrará em Completed.

Em seguida, agende T-DEMO-CANCEL para 90 segundos e clique em Cancel. O item desaparecerá de Pending e nunca chegará a Completed. Execute a verificação independente, que usa seu próprio nome aleatório de Agent e comprova o registro idempotente, a execução e o cancelamento:

python3 .labex/verify.py local

Implantar e inspecionar o trabalho agendado

Nesta etapa, você repetirá o ciclo de vida na Cloudflare e relacionará o comportamento observável às evidências do Dashboard.

Pare o processo local exato, implante a compilação de produção e aguarde a 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

Abra a URL exata no navegador integrado. Agende T-CLOUD-101 para 20 segundos e observe primeiro a linha durável pendente.

Um acompanhamento na nuvem aparece na lista de agendamentos pendentes

O horário de execução e o ID do agendamento pertencem à execução descartável aceita; seus valores serão diferentes. A evidência importante é que o item é listado pelo Agent, e não por uma contagem regressiva armazenada na página.

Aguarde o callback e confirme que o mesmo ticket fictício aparece em Completed.

O callback agendado moveu o ticket fictício para o estado concluído

Crie T-CLOUD-CANCEL para 90 segundos, registre seu estado pendente e cancele-o. O painel de pendências deve voltar a ficar vazio, enquanto a entrada concluída permanece inalterada.

Um acompanhamento pendente mais longo está pronto para cancelamento explícito

O agendamento cancelado desapareceu, enquanto a conclusão anterior permanece

Abra Workers & Pages, selecione o Worker exato labex-c11-s04-... e inspecione Bindings. Confirme que FollowUpAgent aponta para o mesmo nome de classe.

O binding do Worker conecta as solicitações ao FollowUpAgent

Abra Durable Objects e inspecione o namespace FollowUpAgent. Ele usa armazenamento SQL porque o estado e os agendamentos do Agent exigem registros duráveis.

O namespace de Durable Object FollowUpAgent usa armazenamento SQL

Por fim, abra Observability → Logs, filtre por follow_up_completed e expanda um evento. O evento limitado contém a instância do Agent, a revisão e a contagem de conclusões, mas não contém a referência do ticket.

Um log de conclusão limitado omite a referência do ticket fictício

As visualizações do Dashboard podem aparecer com atraso; por isso, a verificação remota independente é a autoridade:

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

Remover o namespace de agendamentos e o Worker

Nesta etapa, você apagará somente o namespace da classe e o Worker criados por este laboratório.

Os agendamentos e o estado das conclusões ficam no namespace da classe de Durable Object. Exclua explicitamente essa classe antes de remover o Worker stateless restante:

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

Não exclua recursos não relacionados da conta. Confirme que somente o Worker gerado exato e seu namespace FollowUpAgent foram removidos.

O Worker descartável de agendamentos não está mais presente após a limpeza

O namespace FollowUpAgent não está mais presente após sua migração de exclusão

Revogar a autorização desta VM

Nesta etapa, você removerá a concessão OAuth armazenada nesta VM descartável e verificará o estado estruturado de logout.

Depois que a limpeza na nuvem for concluída, remova a autorização OAuth armazenada nesta VM descartável:

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

Confirme explicitamente "loggedIn": false. Um erro de rede não é conclusivo e deve ser verificado novamente. O Worker descartável, seu namespace de agendamentos e a autorização local desta VM agora foram removidos.

Resumo

Você atribuiu a um único Cloudflare Agent nomeado um trabalho futuro durável sem depender de um navegador aberto ou de um modelo de linguagem. Registrou um callback atrasado e limitado, tornou os registros repetidos idempotentes, inspecionou os agendamentos pendentes pela API assíncrona atual, verificou a propriedade antes do cancelamento e registrou apenas um pequeno histórico de conclusões.

Você também relacionou a abstração do SDK ao ciclo de vida do alarme do Durable Object, comprovou a conclusão e o cancelamento local e remotamente, inspecionou evidências com dados mínimos e removeu explicitamente o namespace da classe, o Worker e a autorização da VM descartável.