Programar un seguimiento de soporte

CloudflareBeginner
Practicar Ahora

Introducción

Un equipo de soporte suele prometer revisar un ticket más adelante: después de que el cliente pruebe una solución, cuando termine una ventana de servicio o antes de que venza el plazo de escalamiento. Un temporizador del navegador no puede conservar esa promesa de forma segura, porque al cerrar la pestaña se pierde. Una programación de Agent almacena la acción futura junto con el Agent identificado, de modo que la plataforma pueda activar esa instancia duradera cuando llegue el momento.

En este laboratorio, creará un pequeño panel de seguimientos sin usar un modelo de lenguaje:

  1. schedule() registra una devolución de llamada retrasada y devuelve su identificador de programación duradero.
  2. listSchedules() permite que la aplicación inspeccione el trabajo pendiente mediante la API asíncrona actual.
  3. cancelSchedule() elimina un elemento que aún está pendiente después de que el servidor verifica que le pertenece.
  4. La devolución de llamada registra una finalización acotada en el estado del Agent y emite un registro con información limitada por privacidad.

Programará una tarea breve y observará cómo se completa. Después, creará una tarea más larga y la cancelará antes de que se ejecute. Las llamadas utilizan únicamente referencias de tickets sintéticas. Las solicitudes de registro idénticas activan la idempotencia del SDK, lo que evita que un doble clic accidental cree trabajo duplicado.

Agents SDK implementa este ciclo de vida sobre una alarma de Durable Object respaldada por SQLite. Usted utilizará la API de programación de alto nivel en lugar de administrar directamente las marcas de tiempo de las alarmas y los registros de almacenamiento. Aun así, el trabajo seguirá perteneciendo a una única instancia de Agent identificada y sobrevivirá a los reinicios normales de Workers.

Antes de entrar directamente en este curso, complete Connect LabEx to Your Cloudflare Account. Cada nueva VM de LabEx necesita su propia autorización de Wrangler. Se recomiendan los laboratorios anteriores del curso, pero este laboratorio crea y elimina sus propios recursos aislados.

Autorizar la VM y configurar el Agent

En este paso, autorizará esta VM nueva y definirá el único Worker desechable y la clase Durable Object que utiliza el laboratorio.

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

Abra el enlace del dispositivo que se muestra en el navegador de LabEx, confirme el código mostrado y apruebe la cuenta de aprendizaje. No envíe a nadie ninguna contraseña, token ni código de autorización. En el resultado JSON, compruebe que "loggedIn": true, lea el nombre de la cuenta y copie su ID.

Genere un nombre de recurso único y cree 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

El nombre del binding es el que utilizan el enrutador y el cliente; el nombre de la clase identifica la implementación. La migración v1 solicita a Cloudflare que cree almacenamiento respaldado por SQLite para esa clase. Todavía no crea una instancia con un nombre concreto: una instancia como planning aparece cuando el tráfico se dirige a ella por primera vez.

Implementar la programación de seguimientos duraderos

En este paso, implementará el registro, la inspección, la cancelación y la devolución de llamada final en un único Agent identificado.

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 procede de las declaraciones de bindings generadas por Wrangler que creará antes de compilar, por lo que el código fuente no mantiene una segunda copia escrita manualmente del entorno. schedule() recibe un retraso relativo, el nombre de una devolución de llamada y una carga útil serializable pequeña. { idempotent: true } significa que repetir la misma devolución de llamada con la misma carga útil devuelve la programación pendiente existente en lugar de añadir otra. La política de reintentos permite como máximo dos intentos de la devolución de llamada, con un retroceso corto y acotado; por tanto, un error permanente no puede repetirse indefinidamente. La devolución de llamada conserva solo cinco finalizaciones sintéticas y su registro estructurado omite la referencia del ticket.

Los métodos de listado y búsqueda se esperan deliberadamente con await. Algunos ejemplos antiguos pueden mostrar llamadas síncronas como getSchedule() o getSchedules(); el código actual de Agents SDK debe utilizar getScheduleById() y listSchedules().

Conectar el panel de seguimientos

En este paso, configurará la transformación actual de decoradores y conectará la página proporcionada a un único Agent identificado.

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

La página consulta el Agent cada dos segundos únicamente para que este ejemplo sencillo de TypeScript sea fácil de leer. La programación no es un temporizador del navegador: cerrar la página no la cancela. El servidor sigue siendo la autoridad para la validación, la propiedad y la ejecución.

Generar tipos y compilar la aplicación

En este paso, generará los tipos de los bindings y compilará las dos partes de la aplicación antes de iniciar cualquier entorno de ejecución.

Genere los tipos del entorno a partir del binding exacto, compruebe ambas partes de TypeScript y compile el Worker junto con la 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

Una compilación limpia demuestra que el binding, la transformación de decoradores, los tipos compartidos y los paquetes son compatibles. Todavía no demuestra que se active una alarma ni que la cuenta de Cloudflare sea propietaria del recurso desplegado; esas comprobaciones de ejecución se realizarán en los pasos siguientes.

Demostrar el ciclo de vida localmente

En este paso, demostrará que la ejecución duradera y la cancelación funcionan en el entorno de ejecución local de Cloudflare.

Inicie el entorno local como un proceso persistente en 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 en el navegador de escritorio de LabEx. Programe T-DEMO-101 para dentro de 12 segundos. Primero aparecerá en Pending; cerrar o actualizar la página no controla ese trabajo. Cuando llegue la hora programada, la devolución de llamada lo quitará del almacén de programaciones y lo registrará en Completed.

Después, programe T-DEMO-CANCEL para dentro de 90 segundos y haga clic en Cancel. Desaparecerá de Pending y nunca llegará a Completed. Ejecute la comprobación independiente, que utiliza su propio nombre aleatorio de Agent y demuestra el registro idempotente, la ejecución y la cancelación:

python3 .labex/verify.py local

Desplegar e inspeccionar el trabajo programado

En este paso, repetirá el ciclo de vida en Cloudflare y relacionará el comportamiento observable con las pruebas del Dashboard.

Detenga el proceso local exacto, despliegue la compilación de producción y espere a que esté disponible su 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 la URL exacta en el navegador integrado. Programe T-CLOUD-101 para dentro de 20 segundos y observe primero la fila pendiente duradera.

Un seguimiento en la nube aparece en la lista de programaciones pendientes

La hora de ejecución y el identificador de programación pertenecen a la ejecución desechable aceptada; sus valores serán diferentes. La evidencia importante es que el elemento aparece en la lista del Agent, no en una cuenta atrás almacenada en la página.

Espere a que se ejecute la devolución de llamada y confirme que el mismo ticket sintético aparece en Completed.

La devolución de llamada programada movió el ticket sintético al estado completado

Cree T-CLOUD-CANCEL para dentro de 90 segundos, capture su estado pendiente y cancélelo. El panel de elementos pendientes debería volver a quedar vacío, mientras que la entrada completada permanece sin cambios.

Un seguimiento pendiente más largo está listo para cancelarse explícitamente

La programación cancelada ya no aparece, mientras se conserva la finalización anterior

Abra Workers & Pages, seleccione el Worker exacto labex-c11-s04-... e inspeccione Bindings. Confirme que FollowUpAgent apunta al mismo nombre de clase.

El binding del Worker conecta las solicitudes con FollowUpAgent

Abra Durable Objects e inspeccione el espacio de nombres FollowUpAgent. Utiliza almacenamiento SQL porque el estado y las programaciones de Agent requieren registros duraderos.

El espacio de nombres Durable Object de FollowUpAgent utiliza almacenamiento SQL

Por último, abra Observability → Logs, filtre por follow_up_completed y expanda un evento. El evento acotado contiene la instancia del Agent, la revisión y el número de finalizaciones, pero no contiene la referencia del ticket.

Un registro de finalización acotado omite la referencia del ticket sintético

Las vistas del Dashboard pueden tardar en actualizarse, por lo que la comprobación remota independiente es la autoridad:

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

Eliminar el espacio de nombres de programación y el Worker

En este paso, eliminará únicamente el espacio de nombres de la clase y el Worker creados por este laboratorio.

Las programaciones y el estado de finalización viven en el espacio de nombres de la clase Durable Object. Elimine explícitamente esa clase antes de quitar el Worker sin estado 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

No elimine recursos ajenos de la cuenta. Confirme que solo se hayan eliminado el Worker generado exacto y su espacio de nombres FollowUpAgent.

El Worker de programación desechable ya no aparece después de la limpieza

El espacio de nombres FollowUpAgent ya no aparece después de su migración de eliminación

Revocar la autorización de esta VM

En este paso, eliminará la concesión de OAuth almacenada en esta VM desechable y verificará el estado estructurado de cierre de sesión.

Después de que la limpieza en la nube se complete correctamente, elimine la autorización de OAuth almacenada en esta VM desechable:

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

Compruebe explícitamente que "loggedIn": false. Un error de red no es concluyente y debe volver a intentarse. El Worker desechable, su espacio de nombres de programación y la autorización local de esta VM ya se han eliminado.

Resumen

Proporcionó a un Agent de Cloudflare identificado trabajo futuro duradero sin depender de un navegador abierto ni de un modelo de lenguaje. Registró una devolución de llamada retrasada y acotada, hizo idempotentes los registros repetidos, inspeccionó las programaciones pendientes mediante la API asíncrona actual, verificó la propiedad antes de cancelar y conservó únicamente un historial pequeño de finalizaciones.

También relacionó la abstracción del SDK con el ciclo de vida de las alarmas de Durable Objects, comprobó la finalización y la cancelación de forma local y remota, inspeccionó pruebas con información limitada por privacidad y eliminó explícitamente el espacio de nombres de la clase, el Worker y la autorización de la VM desechable.