Введение
Даже проверенный инструмент может действовать слишком поспешно. Если модель предлагает изменить запись, человеку может потребоваться проверить точное действие до выполнения любой записи. Подтверждение человеком в контуре (human-in-the-loop approval) приостанавливает вызов инструмента, показывает его аргументы в клиенте и позволяет человеку подтвердить или отклонить действие.
В этой лабораторной работе вы расширите небольшой синтетический Agent поддержки одним инструментом чтения и одним обновлением, требующим подтверждения:
lookupSupportCaseостаётся серверным инструментом только для чтения и выполняется без подтверждения.requestPriorityChangeиспользует поддерживаемую опциюneedsApproval, поэтому его функцияexecuteне сможет выполниться, пока клиент не отправит ответ о подтверждении.- React-клиент отображает ожидающее действие и вызывает
addToolApprovalResponse()для вариантов Approve или Deny. - Надёжный журнал идемпотентности сохраняет ключ операции, поэтому повторная доставка подтверждённой операции возвращает первый результат, не выполняя второе обновление.
- Детерминированные проверки и один ограниченный сценарий Workers AI подтверждают состояния ожидания, отказа, подтверждения и дубликата.
Подтверждение и авторизация отвечают на разные вопросы. Авторизация ограничивает очереди, к которым может обращаться подписанная сессия; подтверждение определяет, принимает ли человек именно это предложенное изменение. Идемпотентность решает третью проблему: сеть или клиент могут доставить одну и ту же подтверждённую операцию более одного раза. Все записи в этой лабораторной работе синтетические и временные; реальная система службы поддержки не подключается.
Предоставленные оболочка и короткоживущий токен сессии позволяют сосредоточиться на границе подтверждения, а не на шаблонном коде интерфейса или аутентификации. Бесплатные квоты Workers AI распределяются между другими операциями аккаунта. Если в аккаунте больше не осталось доступной квоты, остановитесь и не подключайте платный план.
Прежде чем проходить этот курс напрямую, выполните лабораторную работу Connect LabEx to Your Cloudflare Account. Каждой новой виртуальной машине LabEx требуется собственная авторизация Wrangler. Предыдущие лабораторные работы курса рекомендуются, но их виртуальные машины и ресурсы здесь никогда не используются повторно.
Авторизуйте виртуальную машину и объявите Approval Worker
На этом шаге вы авторизуете новую виртуальную машину и объявите ресурсы, которые использует Agent с подтверждением.
Откройте терминал и перейдите в подготовленный проект:
cd /home/labex/project/approval-record-changes
Авторизуйте эту виртуальную машину:
npx wrangler login
Откройте показанную ссылку, подтвердите описанные разрешения Wrangler для выделенного учебного аккаунта и вернитесь в терминал. Проверьте структурированный результат:
npx wrangler whoami --json
Найдите "loggedIn": true, проверьте имя аккаунта и скопируйте фактический ID этого аккаунта. Сохраните его вместе с уникальным временным именем 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
Привязка AI предоставляет выполнение моделей без встраивания API-ключа. Привязка Durable Object предоставляет каждому именованному ApprovalAgent собственное хранилище SQLite для синтетической заявки и журнала идемпотентности. Браузер будет использовать имя planning; отдельное имя получает отдельный экземпляр и не может видеть данные planning. Пока ничего не развернуто.
Определите контракты инструментов
На этом шаге вы точно опишете аргументы, которые принимает каждый инструмент.
Схема инструмента — это контракт времени выполнения. Типы TypeScript помогают во время компиляции, но вывод модели поступает во время выполнения, поэтому его нужно проверять повторно. Создайте 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
Контракт чтения принимает только допустимое имя очереди и одну синтетическую заявку. Контракт изменения ограничивает упражнение приоритетом high и требует стабильного operationKey. .strict() также отклоняет неожиданные поля, уменьшая неоднозначность и не позволяя вызывающей стороне передавать в операцию неподдерживаемые инструкции.
Ключ идемпотентности идентифицирует одну логическую операцию при повторных попытках. Сервер сохранит первый успешный результат под этим ключом; если та же подтверждённая операция поступит снова, он вернёт сохранённый результат вместо повторной записи. Проверка данных не даёт ни подтверждения, ни доступа — Agent по-прежнему проверяет своё постоянное имя, а SDK по-прежнему ожидает решения человека.
Реализуйте подтверждение и идемпотентный эффект
На этом шаге вы оставите операции чтения автоматическими, приостановите запись с помощью needsApproval и сделаете подтверждённый эффект идемпотентным.
Создайте 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
Модель не получает прямого доступа к базе данных. Она предлагает типизированные аргументы, но needsApproval: true не позволяет функции execute выполниться, пока клиент не отправит положительный ответ о подтверждении. Поэтому отказ оставляет метод без изменений. Вызываемый метод только для чтения обеспечивает детерминированную проверку. Отдельный вызываемый метод проверки может обратиться к эффекту только с HMAC-доказательством, созданным на основе локального секрета подписи, поэтому обычный браузерный клиент не может обойти подтверждение человека.
Agent обрабатывает каждый синхронный вызываемый метод без await, поэтому при второй доставке он видит строку журнала, созданную первой доставкой. Стабильный ключ операции возвращает уже применённый результат с duplicate: true; запись не увеличивается повторно. В журнал записываются только событие, экземпляр Agent, ключ операции и ревизия — текст заявки не записывается.
Отобразите решение человека о подтверждении
На этом шаге вы отобразите ожидающий вызов инструмента как явное решение, а не будете выполнять его незаметно.
Создайте конфигурацию TypeScript и 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
Создайте 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() подключается ровно к одному именованному Agent с короткоживущим токеном. Кнопка чтения и форма изменения намеренно создают отдельные ходы: каждый ход имеет одну цель, поэтому вы можете наблюдать состояние до и после решения, не смешивая уже завершённый инструмент чтения с приостановленной записью. useAgentChat() предоставляет вспомогательную функцию для отправки ответа о подтверждении. Клиент Agents сообщает серверу об этом решении; автоматическое продолжение клиента здесь отключено, поэтому один и тот же подтверждённый вызов инструмента не отправляется повторно. Результат инструмента и новое чтение дают более надёжное подтверждение, чем дополнительное предложение, сгенерированное моделью. isToolUIPart() отличает действие инструмента от обычного текста ассистента, а getToolApproval() получает объект подтверждения через поддерживаемый интерфейс SDK. Идентификатор подтверждения связывает решение человека именно с этим вызовом инструмента; браузер не вызывает метод базы данных напрямую. Карточки JSON позволяют наблюдать результат чтения, предложенные аргументы и итог записи, не раскрывая учётные данные аккаунта.
Соберите приложение и локально проверьте границы
На этом шаге вы скомпилируете приложение и проверите фактическую реализацию инструментов, не расходуя вызов модели.
Сгенерируйте точные типы окружения, проверьте типы и соберите оба пакета:
npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build
Wrangler выводит тип Cloudflare.Env из фактических привязок. Это не позволяет самописному интерфейсу окружения расходиться с wrangler.jsonc.
Workers AI использует удалённую привязку, поэтому локальной среде выполнения нужен OAuth-доступ, уже сохранённый Wrangler. Передайте его только дочернему процессу и сразу удалите копию из оболочки:
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
Не выводите временное значение OAuth и не сохраняйте его в .dev.vars. Независимая проверка использует случайно названный Agent, метод inspectCase() только для чтения и HMAC-доказательство только для теста, созданное на основе локального секрета подписи. Это доказательство позволяет проверяющему проверить тот же приватный эффект, который использует подтверждённый инструмент модели, не публикуя вызываемый метод, обходящий подтверждение. Так независимо от поведения модели проверяется безопасность эффекта на сервере:
- исходный приоритет равен
medium, ревизия —0; - чтение из другой очереди завершается ошибкой;
- один вызов подтверждённого эффекта устанавливает
highи ревизию1; - повторное использование того же ключа операции возвращает
duplicate: true, а ревизия остаётся равной1; и - другой именованный Agent сохраняет изолированную запись с ревизией
0.
Эта детерминированная проверка отвечает на вопрос, безопасна ли повторная доставка. Поток в браузере отдельно доказывает, что поддерживаемый шлюз SDK предотвращает выполнение эффекта до подтверждения и при отказе.
Разверните приложение и проверьте отказ, подтверждение и повторную доставку
На этом шаге вы развернёте приложение, а затем увидите, как одно и то же предлагаемое изменение остаётся ожидающим, отклоняется, один раз подтверждается и сохраняет безопасность при повторении.
Разверните пакет для production и загрузите созданный ключ подписи как секрет:
npm run deploy
npx wrangler secret bulk .dev.vars
Команда для секрета передаёт значение, не помещая его в конфигурацию или пакет. Не выводите .dev.vars.
Сохраните точный origin, напечатанный после развертывания, затем создайте токен на десять минут для 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"
Откройте полный URL в браузере LabEx. Сначала выберите Check current case: результат операции чтения покажет приоритет medium и ревизию 0. Затем отправьте подготовленный запрос на изменение. Этот второй ход с одной целью остановится на состоянии Approval required. До принятия решения серверная функция execute ещё не выполнялась:

Выберите Deny. Инструмент перейдёт в состояние отказа, и Agent не должен заявлять, что обновление произошло. Снова выберите Check current case: новое чтение по-прежнему покажет приоритет medium и ревизию 0, что доказывает отсутствие изменений после отклонённого выполнения. Повторно отправьте подготовленный запрос на изменение, чтобы создать новую карточку подтверждения:

Во втором запросе выберите Approve. Поддерживаемый клиент отправит идентификатор подтверждения Agent, сервер выполнит операцию один раз, а результат покажет приоритет high, ревизию 1 и duplicate: false. Выберите Check current case, чтобы независимо увидеть ту же ревизию:

В третий раз отправьте тот же подготовленный запрос и подтвердите его. Надёжный журнал распознает raise-synthetic-priority; результат покажет duplicate: true. Снова выберите Check current case — запись останется на ревизии 1:

Точные предложения ассистента генерируются моделью и могут отличаться. Полезными доказательствами являются состояние карточки подтверждения, поля результата инструмента и ревизия записи. Очередь, заявка и запись — синтетические примеры.
Запустите новую независимую облачную проверку с отдельным именем. Она не расходует дополнительный вызов модели:
python3 .labex/verify.py deployed
Проверка анализирует точные развернутые привязки и пространство имён, а затем повторяет проверку отказа по области, одного успешного изменения, безопасности повтора с тем же ключом и изоляции именованных Agent для независимо названного удалённого Agent. Она проверяет идемпотентный эффект без дополнительного вызова модели; именно поток в браузере подтверждает шлюз подтверждения SDK.
Проверьте и удалите ресурсы подтверждения
На этом шаге вы свяжете поведение во время выполнения с представлениями ресурсов Cloudflare, а затем удалите только ресурсы этой лабораторной работы.
В Cloudflare Dashboard откройте Workers & Pages, выберите точный Worker labex-c11-s06-... и откройте раздел Bindings. Вы должны увидеть привязку Workers AI AI и привязку Durable Object ApprovalAgent. Затем откройте Settings > Variables and Secrets, чтобы убедиться, что SESSION_SIGNING_KEY хранится как зашифрованный секрет, а не как обычный текст:

Откройте Durable Objects и выберите пространство имён с поддержкой SQL, принадлежащее этому Worker. Имена planning и имена проверяющих — это отдельные экземпляры объектов внутри одного пространства имён класса:

Откройте журналы Worker или представление наблюдаемости и найдите approval_change_applied. Одна принятая логическая операция создаёт одну структурированную запись. Она содержит экземпляр Agent, стабильный ключ операции и ревизию, но не содержит сводку синтетической заявки или текст чата:

После проверки создайте явную миграцию удаления класса и удалите точный Worker:
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
Убедитесь, что временный Worker отсутствует:

Затем убедитесь, что его пространство имён ApprovalAgent отсутствует:

Пока эта виртуальная машина всё ещё авторизована, подтвердите отсутствие обоих ресурсов:
python3 .labex/verify.py deleted
Удаление только Worker оставило бы жизненный цикл состояния класса неоднозначным. Миграция v2 явно удаляет пространство имён, синтетическую запись и журнал идемпотентности этой лабораторной работы до проверки удаления Worker.
Отзовите авторизацию этой виртуальной машины
На этом шаге вы отзовёте временную авторизацию виртуальной машины после подтверждения очистки в облаке.
npx wrangler logout
npx wrangler whoami --json || true
В структурированном результате должно быть указано "loggedIn": false, либо Wrangler может вернуть ненулевой код завершения для неавторизованного состояния. Выход из аккаунта выполняется намеренно последним: проверяющему удаление требуется действующий доступ на чтение, а выброшенной виртуальной машине он больше не нужен.
Итоги
Вы разместили поддерживаемое решение человека перед изменением записи в Cloudflare AIChatAgent. Вы:
- оставили чтение автоматическим, а для записи установили
needsApproval; - отображали части
approval-requestedи отправляли явные ответы Approve или Deny; - доказали, что ожидающие и отклонённые операции не изменяют синтетическую запись;
- сохранили надёжный ключ идемпотентности, благодаря которому повторное подтверждение возвращает ревизию
1, не выполняя запись снова; - отделили авторизацию, проверяя на сервере область именованного Agent;
- наблюдали событие подтверждения с ограниченным объёмом данных; и
- удалили точное пространство имён класса SQLite и Worker до выхода из аккаунта.
Теперь подтверждение человека является явным и проверяемым, а журнал идемпотентности защищает эффект от повторной доставки. В следующей лабораторной работе направление изменится: вы опубликуете синтетическую возможность только для чтения через MCP, где новыми понятиями станут обнаружение и транспорт.



