要求批准记录变更

CloudflareBeginner
立即练习

简介

经过验证的工具仍然可能执行得过于积极。如果模型提出记录变更,在写入任何数据之前,可能需要由人工检查具体操作。人工参与的批准流程会暂停工具调用,在客户端显示参数,并让人工批准或拒绝该操作。

在本实验中,你将扩展一个小型合成支持 Agent,加入一个读取工具和一个需要批准的更新操作:

  1. lookupSupportCase 仍然是服务端只读工具,无需批准即可运行。
  2. requestPriorityChange 使用受支持的 needsApproval 选项,因此在客户端发送批准响应之前,其 execute 函数不会运行。
  3. React 客户端会显示待处理操作,并调用 addToolApprovalResponse() 来批准或拒绝操作。
  4. 持久化幂等性账本会记录操作键,因此重复交付已批准的操作时,会返回第一次的结果,而不会再次执行更新。
  5. 确定性探针和一个受限的 Workers AI 流程会验证待处理、拒绝、批准和重复操作的结果。

批准和授权回答的是不同问题。授权限制已签名会话可以访问哪个队列;批准则确认人工是否接受这一次具体的变更提议。幂等性解决第三个问题:网络或客户端可能会多次交付同一个已批准的操作。本实验中的所有记录都是合成数据,可以随时丢弃;实验不会连接真实的帮助台系统。

实验提供的 Shell 和短期会话令牌,可以让你专注于批准边界,而不是前端或身份验证模板代码。Workers AI 的免费额度会与账户中的其他活动共享。如果账户没有剩余额度,请停止操作,不要启用付费计划。

直接进入本课程前,请完成将 LabEx 连接到你的 Cloudflare 账户 每台新建的 LabEx 虚拟机都需要单独完成 Wrangler 授权。建议先完成课程中的早期实验,但这里不会复用它们的虚拟机或资源。

授权虚拟机并声明批准 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 运行,直到客户端提交明确的批准响应。因此,拒绝操作不会改变任何数据。只读的 callable 支持确定性检查。另一个独立的验证 callable 只有在获得由本地签名密钥生成的 HMAC 证明后,才能访问实际效果,因此普通浏览器客户端无法绕过人工批准门控。

Agent 会在不使用 await 的情况下处理每次同步 callable 调用,因此第二次交付可以看到第一次创建的账本记录。稳定的操作键会返回已经应用的结果,并将 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 支持的接口读取批准对象。批准 ID 会将人工决定绑定到这一次具体的工具调用;浏览器不会直接调用数据库方法。JSON 卡片会显示读取结果、提议参数和最终写入结果,同时不会暴露账户凭据。

在本地构建并验证边界

在本步骤中,你将编译应用,并在不消耗模型调用的情况下测试实际工具实现。

生成准确的环境类型,检查类型并构建两个 Bundle:

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

Wrangler 会根据实际绑定生成 Cloudflare.Env。这样可以避免手写的环境接口逐渐偏离 wrangler.jsonc

Workers AI 是远程绑定,因此本地运行时需要使用 Wrangler 已经保存的 OAuth 访问权限。只将它传递给子进程,然后立即清除 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

不要打印临时 OAuth 值,也不要将其保存到 .dev.vars。独立探针会使用随机命名的 Agent、只读的 inspectCase() 方法,以及由本地签名密钥生成的仅用于测试的 HMAC 证明。该证明让验证器可以测试与已批准模型工具相同的私有效果,而无需发布能够绕过批准的 callable。它可以独立于模型行为,验证该效果在服务端的安全性:

  • 初始优先级为 medium,修订号为 0
  • 跨队列读取会失败;
  • 一次批准后的效果调用会将优先级改为 high,修订号变为 1
  • 使用相同操作键时会返回 duplicate: true,并保持修订号为 1;以及
  • 另一个命名的 Agent 仍然保留隔离的修订号为 0 的记录。

这个确定性测试可以验证重复交付是否安全。浏览器流程则单独验证:在批准之前以及被拒绝时,受支持的 SDK 门控都会阻止该效果执行。

部署并测试拒绝、批准和重放

在本步骤中,你将完成部署,然后观察同一个变更提议保持待处理、被拒绝、只批准一次,并在重复提交时仍然安全。

部署生产 Bundle,并将生成的签名密钥上传为密钥:

npm run deploy
npx wrangler secret bulk .dev.vars

密钥命令会在不将值放入配置或 Bundle 的情况下发送它。不要打印 .dev.vars

保存部署输出的准确源站地址,然后为 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"

在 LabEx 浏览器中打开完整 URL。先选择 Check current case:只读结果应显示优先级为 medium,修订号为 0。然后发送预先填写的变更请求。这个第二个、单一目的的会话轮次会停在 Approval required。在做出决定之前,服务端的 execute 函数尚未运行:

在产生任何效果前暂停的合成优先级变更

选择 Deny。工具会变为拒绝状态,Agent 不得声称更新已经发生。再次选择 Check current case:新的读取结果仍应显示优先级为 medium,修订号为 0,这证明被拒绝的执行没有产生任何变更。再次发送预先填写的变更请求,创建新的批准卡片:

拒绝操作后记录保持不变,并出现新的待处理决定

在这次新的请求上选择 Approve。受支持的客户端会将批准 ID 发回 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,选择准确名称为 labex-c11-s06-... 的 Worker,并检查 Bindings。你应该能看到 AI Workers AI 绑定和 ApprovalAgent Durable Object 绑定。然后打开 Settings > Variables and Secrets,确认 SESSION_SIGNING_KEY 以加密密钥的形式存储,而不是明文:

包含 AI 和 ApprovalAgent 绑定的已部署批准 Worker

打开 Durable Objects,选择由此 Worker 所属的、由 SQL 支持的命名空间。planning 和验证器使用的名称,是同一个类命名空间中的不同对象实例:

由 SQL 支持的 ApprovalAgent 命名空间

打开 Worker 的日志或可观测性视图,查找 approval_change_applied。一个已接受的逻辑操作会产生一条结构化日志记录。记录包含 Agent 实例、稳定的操作键和修订号,但不包含合成案例摘要或聊天文本:

Cloudflare 日志中的一条受限批准变更事件

检查完成后,创建明确的类删除迁移,并删除准确的 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 已不存在:

已删除的临时批准 Worker

然后确认其 ApprovalAgent 命名空间已不存在:

已删除的临时 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 部分,并提交明确的批准或拒绝响应;
  • 证明待处理和被拒绝的操作不会改变合成记录;
  • 保存持久化幂等性键,使批准后的重试返回修订号 1,而不会再次写入;
  • 在服务端强制执行命名 Agent 作用域,从而将授权与批准分离;
  • 观察受隐私限制的批准事件;以及
  • 在退出登录前删除准确的 SQLite 类命名空间和 Worker。

现在,人工批准已经明确且可审计,而幂等性账本可以保护实际效果免受重复交付的影响。下一个实验将转向另一个方向:通过 MCP 发布合成只读能力,届时新的重点将是发现和传输。