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:
lookupSupportCasesigue siendo una herramienta de servidor de solo lectura y se ejecuta sin aprobación.requestPriorityChangeusa la opción compatibleneedsApproval, por lo que su funciónexecuteno puede ejecutarse hasta que el cliente envíe una respuesta de aprobación.- El cliente de React muestra la acción pendiente y llama a
addToolApprovalResponse()para aprobarla o rechazarla. - 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.
- 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
mediumen la revisión0; - una lectura desde otra cola falla;
- una llamada al efecto aprobado cambia el estado a
highen la revisión1; - la misma clave de operación devuelve
duplicate: truey permanece en la revisión1; 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:

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:

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:

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:

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:

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:

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:

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:

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

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-requestedy 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
1en 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.



