Exigir aprobación para cambios en registros

CloudflareBeginner
Practicar Ahora

Introducción

Una herramienta validada puede seguir siendo demasiado permisiva. Si un modelo propone cambiar un registro, quizá una persona deba revisar la acción exacta antes de que se realice cualquier escritura. La aprobación humana en el proceso pausa esa llamada a la herramienta, muestra sus argumentos en el cliente y permite que una persona la apruebe o la rechace.

En este laboratorio, ampliará un pequeño Agent sintético de soporte con una herramienta de lectura y una actualización protegida por aprobación:

  1. lookupSupportCase sigue siendo una herramienta de servidor de solo lectura y se ejecuta sin aprobación.
  2. requestPriorityChange usa la opción compatible needsApproval, por lo que su función execute no puede ejecutarse hasta que el cliente envíe una respuesta de aprobación.
  3. El cliente de React muestra la acción pendiente y llama a addToolApprovalResponse() para aprobarla o rechazarla.
  4. Un registro de idempotencia persistente almacena la clave de operación, de modo que una entrega aprobada repetida devuelve el primer resultado en lugar de aplicar una segunda actualización.
  5. Unas comprobaciones deterministas y un flujo acotado de Workers AI demuestran los resultados pendiente, rechazado, aprobado y duplicado.

La aprobación y la autorización responden a preguntas diferentes. La autorización limita a qué cola puede acceder la sesión firmada; la aprobación pregunta si una persona acepta este cambio propuesto concreto. La idempotencia resuelve un tercer problema: una red o un cliente pueden entregar la misma acción aprobada más de una vez. Todos los registros de este laboratorio son sintéticos y desechables; no hay ningún sistema real de soporte conectado.

El shell proporcionado y el token de sesión de corta duración permiten centrarse en el límite de aprobación, no en el código repetitivo del frontend o de la autenticación. Las asignaciones gratuitas de Workers AI se comparten con otras actividades de la cuenta. Si la cuenta no tiene ninguna asignación disponible, deténgase en lugar de activar un plan de pago.

Antes de entrar directamente en este curso, complete Conectar LabEx con su cuenta de Cloudflare. Cada VM nueva de LabEx necesita su propia autorización de Wrangler. Se recomiendan los laboratorios anteriores del curso, pero sus VM y recursos nunca se reutilizan aquí.

Autorizar la VM y declarar el Worker de aprobación

En este paso, autorizará la VM nueva y declarará los recursos que utiliza el Agent protegido por aprobación.

Abra un terminal y entre en el proyecto preparado:

cd /home/labex/project/approval-record-changes

Autorice esta VM:

npx wrangler login

Abra el enlace mostrado, apruebe los permisos de Wrangler indicados para su cuenta de aprendizaje exclusiva y vuelva al terminal. Confirme el resultado estructurado:

npx wrangler whoami --json

Busque "loggedIn": true, confirme el nombre de la cuenta y copie el ID real de esa cuenta. Guárdelo junto con un nombre único y desechable para el Worker:

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s06-$(openssl rand -hex 6)"
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 },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "ApprovalAgent", "class_name": "ApprovalAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ApprovalAgent"] }
  ]
}
JSON
python3 .labex/verify.py authorization

El binding AI proporciona inferencia del modelo sin incluir una clave de API. El binding de Durable Objects proporciona a cada ApprovalAgent con nombre su propio almacenamiento SQLite para el caso sintético y su registro de idempotencia. El navegador usará el nombre planning; otro nombre recibe otra instancia independiente y no puede ver los datos de planning. Todavía no se ha desplegado nada.

Definir los contratos de las herramientas

En este paso, describirá exactamente qué argumentos acepta cada herramienta.

El esquema de una herramienta es un contrato en tiempo de ejecución. Los tipos de TypeScript ayudan durante la compilación, pero la salida del modelo llega en tiempo de ejecución y debe comprobarse de nuevo. Cree src/cases.ts:

cat > src/cases.ts <<'TS'
import { z } from "zod";

const queue = z.string()
  .min(3)
  .max(40)
  .regex(/^[a-z0-9-]+$/, "queue must use lowercase letters, digits or hyphens");

export const lookupCaseInput = z.object({
  queue,
  ticketId: z.literal("T-SYNTH-101")
}).strict();

export const changePriorityInput = lookupCaseInput.extend({
  priority: z.literal("high"),
  operationKey: z.string()
    .min(8)
    .max(80)
    .regex(/^[a-z0-9-]+$/, "operation key must use lowercase letters, digits or hyphens")
}).strict();

export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type ChangePriorityInput = z.infer<typeof changePriorityInput>;
export type SupportCase = {
  queue: string;
  ticketId: "T-SYNTH-101";
  summary: string;
  priority: "low" | "medium" | "high";
  revision: number;
};
export type ChangeResult = SupportCase & { duplicate: boolean; operationKey: string };

export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
  const result = schema.safeParse(input);
  if (!result.success) {
    const issue = result.error.issues[0];
    throw new Error(`invalid tool input: ${issue.path.join(".") || "request"} ${issue.message}`);
  }
  return result.data;
}
TS
python3 .labex/verify.py schemas

El contrato de lectura solo acepta un nombre de cola válido y el único ticket sintético. El contrato de cambio limita el ejercicio a la prioridad high y exige una operationKey estable. .strict() también rechaza los campos inesperados, lo que reduce la ambigüedad e impide que un llamador introduzca instrucciones no compatibles en la operación.

Una clave de idempotencia identifica una operación lógica entre varios reintentos. El servidor almacenará el primer resultado correcto con esa clave; si recibe de nuevo la misma operación aprobada, devolverá el resultado almacenado en lugar de escribir dos veces. La validación no concede aprobación ni acceso: el Agent sigue comprobando su nombre persistente y el SDK sigue esperando la decisión humana.

Implementar el límite de aprobación y el efecto idempotente

En este paso, mantendrá las lecturas automáticas, pausará la escritura con needsApproval y hará idempotente el efecto aprobado.

Cree src/server.ts:

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { callable, routeAgentRequest } from "agents";
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import {
  changePriorityInput,
  lookupCaseInput,
  parseInput,
  type ChangePriorityInput,
  type ChangeResult,
  type LookupCaseInput,
  type SupportCase
} from "./cases";
import { verifySessionRequest } from "./session-auth";

export class ApprovalAgent extends AIChatAgent<Cloudflare.Env> {
  maxPersistedMessages = 12;

  private ensureTables(): void {
    this.sql`CREATE TABLE IF NOT EXISTS support_cases (
      ticket_id TEXT PRIMARY KEY,
      queue TEXT NOT NULL,
      case_summary TEXT NOT NULL,
      priority TEXT NOT NULL,
      revision INTEGER NOT NULL
    )`;
    this.sql`INSERT OR IGNORE INTO support_cases
      (ticket_id, queue, case_summary, priority, revision)
      VALUES ('T-SYNTH-101', ${this.name}, 'Synthetic customer cannot open a sample invoice', 'medium', 0)`;
    this.sql`CREATE TABLE IF NOT EXISTS approval_operations (
      operation_key TEXT PRIMARY KEY,
      ticket_id TEXT NOT NULL,
      applied_revision INTEGER NOT NULL
    )`;
  }

  private scopedCase(input: LookupCaseInput): SupportCase {
    if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
    this.ensureTables();
    const rows = this.sql<{
      queue: string;
      ticketId: "T-SYNTH-101";
      summary: string;
      priority: "low" | "medium" | "high";
      revision: number;
    }>`SELECT queue, ticket_id AS ticketId, case_summary AS summary, priority, revision
       FROM support_cases WHERE ticket_id = ${input.ticketId}`;
    const record = rows[0];
    if (!record || record.queue !== this.name) throw new Error("case not found in this Agent scope");
    return record;
  }

  @callable()
  inspectCase(input: unknown): SupportCase {
    return this.scopedCase(parseInput(lookupCaseInput, input));
  }

  private applyApprovedChange(input: unknown): ChangeResult {
    const parsed: ChangePriorityInput = parseInput(changePriorityInput, input);
    const current = this.scopedCase(parsed);
    const prior = this.sql<{ appliedRevision: number }>`SELECT applied_revision AS appliedRevision
      FROM approval_operations WHERE operation_key = ${parsed.operationKey}`[0];
    if (prior) {
      return { ...current, duplicate: true, operationKey: parsed.operationKey };
    }
    this.sql`UPDATE support_cases
      SET priority = ${parsed.priority}, revision = ${current.revision + 1}
      WHERE ticket_id = ${parsed.ticketId} AND queue = ${this.name}`;
    const changed = this.scopedCase(parsed);
    this.sql`INSERT INTO approval_operations (operation_key, ticket_id, applied_revision)
      VALUES (${parsed.operationKey}, ${parsed.ticketId}, ${changed.revision})`;
    console.log(JSON.stringify({
      event: "approval_change_applied",
      instance: this.name,
      operationKey: parsed.operationKey,
      revision: changed.revision
    }));
    return { ...changed, duplicate: false, operationKey: parsed.operationKey };
  }

  @callable()
  async verifyApprovedChange(input: unknown, proof: string): Promise<ChangeResult> {
    const parsed = parseInput(changePriorityInput, input);
    const payload = new TextEncoder().encode(`approval-probe:${JSON.stringify(parsed)}`);
    const key = await crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(this.env.SESSION_SIGNING_KEY),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    );
    const normalized = proof.replace(/-/g, "+").replace(/_/g, "/");
    const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
    const signature = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
    const valid = await crypto.subtle.verify("HMAC", key, signature, payload);
    if (!valid) throw new Error("approval probe proof is invalid");
    return this.applyApprovedChange(parsed);
  }

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    const tools = {
      lookupSupportCase: tool({
        description: "Read synthetic ticket T-SYNTH-101 only from the current named support queue.",
        inputSchema: lookupCaseInput,
        execute: async (input) => this.inspectCase(input)
      }),
      requestPriorityChange: tool({
        description: "Set synthetic ticket T-SYNTH-101 to high priority in the current queue. Use operation key raise-synthetic-priority.",
        inputSchema: changePriorityInput,
        needsApproval: true,
        execute: async (input) => this.applyApprovedChange(input)
      })
    };
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: `You assist only the synthetic ${this.name} queue. Perform exactly the one action the user requests. For a lookup, call lookupSupportCase only. For a priority request, call requestPriorityChange only with operationKey raise-synthetic-priority and wait for the human decision. Never claim a denied or pending change happened. Keep the final answer to one short sentence.`,
      messages: await convertToModelMessages(this.messages),
      tools,
      stopWhen: stepCountIs(4),
      maxOutputTokens: 96,
      temperature: 0,
      abortSignal: options?.abortSignal
    });
    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Cloudflare.Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

El modelo nunca recibe acceso directo a la base de datos. Propone argumentos tipados, pero needsApproval: true impide que execute se ejecute hasta que el cliente envíe una respuesta de aprobación positiva. Por tanto, un rechazo deja el método intacto. El callable de solo lectura permite realizar una inspección determinista. Otro callable de verificación solo puede acceder al efecto con una prueba HMAC derivada del secreto de firma local, por lo que un cliente de navegador normal no puede saltarse el límite de aprobación humana.

El Agent gestiona cada invocación síncrona del callable sin un await, de modo que una segunda entrega encuentra la fila del registro creada por la primera. La clave de operación estable devuelve el resultado ya aplicado con duplicate: true; no vuelve a incrementar el registro. Solo se registran el evento, la instancia del Agent, la clave de operación y la revisión, no el texto del caso.

Mostrar la decisión de aprobación humana

En este paso, mostrará una llamada pendiente a una herramienta como una decisión explícita, en lugar de ejecutarla silenciosamente.

Cree la configuración de TypeScript y Vite:

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

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

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

Cree src/client.tsx:

cat > src/client.tsx <<'TSX'
import { getToolApproval, useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { getToolName, isToolUIPart } from "ai";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function ApprovalChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";
  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "ApprovalAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, addToolApprovalResponse, status, error } = useAgentChat({
    agent,
    autoContinueAfterToolResult: false
  });

  return (
    <main>
      <p className="eyebrow">Human approval before effect</p>
      <h1>Synthetic Change Review</h1>
      <p className="scope">Allowed queue: <strong>{session}</strong> · allowed ticket: <strong>T-SYNTH-101</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No change request in this signed session yet.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) => {
              if (part.type === "text") return <span key={index}>{part.text}</span>;
              if (isToolUIPart(part)) {
                const toolName = getToolName(part);
                if ("approval" in part && part.state === "approval-requested") {
                  const approvalId = getToolApproval(part)?.id;
                  return (
                    <div className="approval" key={part.toolCallId}>
                      <strong>Approval required: {toolName}</strong>
                      <p>Review these exact synthetic arguments. No record has changed yet.</p>
                      <pre>{JSON.stringify(part.input, null, 2)}</pre>
                      <div className="approval-actions">
                        <button disabled={!approvalId} () => {
                          if (!approvalId) return;
                          await addToolApprovalResponse({ id: approvalId, approved: true });
                          sendMessage();
                        }}>Approve</button>
                        <button className="deny" disabled={!approvalId} => approvalId && addToolApprovalResponse({ id: approvalId, approved: false })}>Deny</button>
                      </div>
                    </div>
                  );
                }
                return (
                  <div className="tool-card" key={part.toolCallId}>
                    <strong>{toolName}</strong><span className="tool">{part.state}</span>
                    {"output" in part && part.output !== undefined && <pre>{JSON.stringify(part.output, null, 2)}</pre>}
                  </div>
                );
              }
              return null;
            })}
          </article>
        ))}
      </section>
      <div className="quick-actions">
        <button type="button" disabled={status === "streaming" || status === "submitted"} => sendMessage({ text: `Look up T-SYNTH-101 in ${session}.` })}>Check current case</button>
        <span>Read-only: safe before and after a decision.</span>
      </div>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
      }}>
        <input name="message" defaultValue={`Request high priority for T-SYNTH-101 in ${session} with operation key raise-synthetic-priority.`} maxLength={220} aria-label="Change request" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      <p className="notice">Training fixture only: this page cannot reach a real support system.</p>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed approval session…</p></main>}><ApprovalChat /></Suspense>
);
TSX
python3 .labex/verify.py client

useAgent() se conecta exactamente a un Agent con nombre mediante su token de corta duración. El botón de solo lectura y el formulario de cambio crean deliberadamente turnos separados: cada turno tiene un único propósito, para que pueda observar el estado antes y después de una decisión sin mezclar una herramienta de lectura ya terminada con la escritura pausada. useAgentChat() expone la función auxiliar para enviar la respuesta de aprobación. El cliente de Agents notifica al servidor esa decisión; aquí se desactiva la continuación automática del cliente para que la misma llamada aprobada a la herramienta no se envíe una segunda vez. El resultado de la herramienta y una lectura nueva proporcionan pruebas más sólidas que una frase adicional de resumen generada por el modelo. isToolUIPart() distingue una acción de herramienta del texto normal del asistente, mientras que getToolApproval() lee el objeto de aprobación mediante la interfaz compatible del SDK. El ID de aprobación vincula la decisión de la persona con esta llamada concreta a la herramienta; el navegador no invoca directamente el método de la base de datos. Las tarjetas JSON permiten observar el resultado de la consulta, los argumentos propuestos y el resultado final de la escritura sin exponer las credenciales de la cuenta.

Compilar y demostrar los límites localmente

En este paso, compilará la aplicación y probará la implementación real de las herramientas sin consumir una llamada al modelo.

Genere los tipos exactos del entorno, compruebe los tipos y compile ambos paquetes:

npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build

Wrangler deriva Cloudflare.Env a partir de los bindings reales. Esto evita que una interfaz de entorno escrita manualmente se desincronice de wrangler.jsonc.

Workers AI es un binding remoto, por lo que el entorno local necesita el acceso OAuth que Wrangler ya almacenó. Páselo únicamente al proceso hijo y borre de inmediato la copia del shell:

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
tail -n 12 .labex/dev.log
python3 .labex/verify.py local

No muestre el valor OAuth temporal ni lo guarde en .dev.vars. La comprobación independiente usa un Agent con nombre aleatorio, el método inspectCase() de solo lectura y una prueba HMAC exclusiva para pruebas derivada del secreto de firma local. Esa prueba permite al verificador probar el mismo efecto privado que utiliza la herramienta del modelo aprobada sin publicar un callable que evite la aprobación. Demuestra la seguridad del efecto en el servidor independientemente del comportamiento del modelo:

  • la prioridad inicial es medium en la revisión 0;
  • una lectura desde otra cola falla;
  • una llamada al efecto aprobado cambia el estado a high en la revisión 1;
  • la misma clave de operación devuelve duplicate: true y permanece en la revisión 1; y
  • otro Agent con nombre conserva su registro aislado en la revisión 0.

Esta prueba determinista responde si la entrega repetida es segura. El flujo del navegador demuestra por separado que el límite del SDK impide el efecto antes de la aprobación y cuando se rechaza.

Desplegar y probar el rechazo, la aprobación y la repetición

En este paso, desplegará la aplicación y observará cómo el mismo cambio propuesto permanece pendiente, se rechaza, se aprueba una vez y sigue siendo seguro cuando se repite.

Despliegue el paquete de producción y cargue la clave de firma generada como secreto:

npm run deploy
npx wrangler secret bulk .dev.vars

El comando de secretos envía el valor sin incluirlo en la configuración ni en el paquete. No muestre .dev.vars.

Guarde el origen exacto que muestra el despliegue y cree un token de diez minutos para planning:

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

Abra la URL completa en el navegador de LabEx. Primero seleccione Check current case: el resultado de solo lectura muestra la prioridad medium en la revisión 0. Después envíe la solicitud de cambio preparada. Este segundo turno, con un único propósito, se detiene en Approval required. Antes de tomar una decisión, su función execute del lado del servidor no se ha ejecutado:

Un cambio sintético de prioridad pausado antes de producir cualquier efecto

Seleccione Deny. La herramienta aparece como rechazada y el Agent no debe afirmar que la actualización se realizó. Seleccione de nuevo Check current case: la nueva lectura sigue mostrando la prioridad medium en la revisión 0, lo que demuestra que la ejecución rechazada no cambió nada. Vuelva a enviar la solicitud de cambio preparada para crear una nueva tarjeta de aprobación:

Una operación rechazada seguida de un registro sin cambios y una nueva decisión pendiente

Seleccione Approve en esta segunda solicitud. El cliente compatible envía el ID de aprobación al Agent, el servidor ejecuta la operación una vez y el resultado muestra la prioridad high, la revisión 1 y duplicate: false. Seleccione Check current case para observar la misma revisión de forma independiente:

El cambio sintético aprobado aplicado exactamente una vez

Envíe por tercera vez la misma solicitud de cambio preparada y apruébela. El registro persistente reconoce raise-synthetic-priority; el resultado muestra duplicate: true. Seleccione una vez más Check current case y el registro seguirá en la revisión 1:

Una entrega aprobada repetida que devuelve el primer resultado sin realizar otra escritura

Las frases exactas del asistente las genera el modelo y pueden variar. El estado de la tarjeta de aprobación, los campos del resultado de la herramienta y la revisión del registro son las pruebas útiles. La cola, el ticket y el registro son ejemplos sintéticos.

Ejecute una comprobación en la nube con un nombre nuevo e independiente. No consume otra llamada al modelo:

python3 .labex/verify.py deployed

La comprobación verifica los bindings y el espacio de nombres desplegados, y después repite el rechazo de ámbito, un cambio correcto, la seguridad al repetir una clave idéntica y el aislamiento de un Agent con nombre frente a otro Agent remoto con un nombre independiente. Prueba el efecto idempotente sin consumir otra llamada al modelo; el flujo del navegador es el que demuestra el límite de aprobación del SDK.

Inspeccionar y eliminar los recursos de aprobación

En este paso, relacionará el comportamiento en tiempo de ejecución con las vistas de recursos de Cloudflare y después eliminará únicamente los recursos de este laboratorio.

En el Dashboard de Cloudflare, abra Workers & Pages, seleccione el Worker exacto labex-c11-s06-... e inspeccione Bindings. Debería ver el binding de Workers AI AI y el binding de Durable Object ApprovalAgent. Después abra Settings > Variables and Secrets para confirmar que SESSION_SIGNING_KEY está almacenado como secreto cifrado y no como texto sin formato:

El Worker de aprobación desplegado con los bindings AI y ApprovalAgent

Abra Durable Objects y seleccione el espacio de nombres respaldado por SQL que pertenece a este Worker. planning y los nombres del verificador son instancias de objetos independientes dentro de este único espacio de nombres de clase:

El espacio de nombres ApprovalAgent respaldado por SQL

Abra los registros del Worker o la vista de observabilidad y busque approval_change_applied. Una operación lógica aceptada produce una única entrada estructurada. Contiene la instancia del Agent, la clave de operación estable y la revisión, pero no el resumen del caso sintético ni el texto del chat:

Un único evento acotado de cambio aprobado en los registros de Cloudflare

Después de inspeccionarlo, cree una migración explícita para eliminar la clase y elimine el Worker exacto:

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['ApprovalAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

Confirme que el Worker desechable ya no existe:

El Worker de aprobación desechable eliminado

Después confirme que su espacio de nombres ApprovalAgent ya no existe:

El espacio de nombres ApprovalAgent desechable eliminado

Demuestre ambas ausencias mientras esta VM siga autorizada:

python3 .labex/verify.py deleted

Eliminar únicamente el Worker dejaría ambiguo el ciclo de vida de la clase con estado. La migración v2 elimina explícitamente el espacio de nombres, el registro sintético y el registro de idempotencia de este laboratorio antes de verificar la eliminación del Worker.

Revocar la autorización de esta VM

En este paso, revocará la autorización temporal de la VM después de demostrar que la limpieza en la nube se ha completado.

npx wrangler logout
npx wrangler whoami --json || true

El resultado estructurado debería indicar "loggedIn": false, aunque Wrangler también puede devolver un resultado no autenticado con código distinto de cero. El cierre de sesión se realiza deliberadamente al final: el verificador de eliminación necesita acceso de lectura válido, mientras que la VM desechada ya no lo necesita.

Resumen

Colocó una decisión humana compatible delante de un cambio de registro de un AIChatAgent de Cloudflare. Usted:

  • mantuvo la lectura automática y marcó la escritura con needsApproval;
  • mostró las partes approval-requested y envió respuestas explícitas de aprobación o rechazo;
  • demostró que las operaciones pendientes y rechazadas dejan sin cambios el registro sintético;
  • almacenó una clave de idempotencia persistente para que un reintento aprobado devolviera la revisión 1 en lugar de escribir de nuevo;
  • mantuvo separadas la autorización y la aprobación al imponer en el servidor el ámbito del Agent con nombre;
  • observó un evento de aprobación limitado para proteger la privacidad; y
  • eliminó el espacio de nombres exacto de la clase SQLite y el Worker antes de cerrar la sesión.

La aprobación humana ahora es explícita y auditable, mientras que el registro de idempotencia protege el efecto frente a entregas repetidas. El siguiente laboratorio cambia de dirección: publica una capacidad sintética de solo lectura mediante MCP, donde el descubrimiento y el transporte serán los nuevos conceptos.