레코드 변경에 승인 필요하도록 설정하기

CloudflareBeginner
지금 연습하기

소개

검증된 도구라도 실행을 너무 서두를 수 있습니다. 모델이 레코드 변경을 제안하면, 실제로 기록을 변경하기 전에 사람이 정확한 작업을 검토해야 할 수 있습니다. 휴먼 인 더 루프(human-in-the-loop) 승인은 도구 호출을 일시 중지하고, 클라이언트에 인수를 표시한 뒤, 사람이 승인하거나 거부할 수 있도록 합니다.

이 실습에서는 작은 합성 지원 Agent를 읽기 도구 하나와 승인으로 보호되는 업데이트 하나로 확장합니다.

  1. lookupSupportCase는 읽기 전용 서버 도구로 유지되며 승인 없이 실행됩니다.
  2. requestPriorityChange는 지원되는 needsApproval 옵션을 사용하므로, 클라이언트가 승인 응답을 보낼 때까지 execute 함수가 실행되지 않습니다.
  3. React 클라이언트는 대기 중인 작업을 표시하고, 승인 또는 거부를 위해 addToolApprovalResponse()를 호출합니다.
  4. 영속 멱등성 원장은 작업 키를 기록하므로, 승인된 작업이 반복 전달되면 두 번째 업데이트를 적용하지 않고 첫 번째 결과를 반환합니다.
  5. 결정론적 프로브와 하나의 제한된 Workers AI 흐름으로 대기, 거부, 승인 및 중복 결과를 검증합니다.

승인과 권한 부여는 서로 다른 질문에 답합니다. 권한 부여는 서명된 세션이 접근할 수 있는 큐를 제한하고, 승인은 사람이 정확히 제안된 변경을 받아들일지 묻습니다. 멱등성은 세 번째 문제를 해결합니다. 네트워크나 클라이언트가 같은 승인된 작업을 한 번 이상 전달할 수 있기 때문입니다. 이 실습의 모든 레코드는 합성 데이터이며 삭제해도 됩니다. 실제 헬프데스크 시스템에는 연결되지 않습니다.

제공된 셸과 수명이 짧은 세션 토큰을 사용하므로, 프런트엔드나 인증 보일러플레이트가 아니라 승인 경계에 집중할 수 있습니다. Workers AI 무료 할당량은 계정의 다른 활동과 공유됩니다. 계정에 남은 할당량이 없으면 유료 플랜을 활성화하지 말고 중지합니다.

이 과정에 직접 들어오기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새로 생성된 모든 LabEx VM에는 자체 Wrangler 인증이 필요합니다. 이전 과정의 실습을 먼저 완료하는 것이 좋지만, 해당 실습의 VM과 리소스는 여기서 재사용되지 않습니다.

VM 인증 및 승인 Worker 선언

이 단계에서는 새 VM을 인증하고 승인으로 보호되는 Agent가 사용할 리소스를 선언합니다.

터미널을 열고 준비된 프로젝트 디렉터리로 이동합니다.

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

이 VM을 인증합니다.

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는 각 동기식 callable 호출을 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가 지원하는 인터페이스를 통해 승인 객체를 읽습니다. 승인 ID는 사람의 결정을 정확한 도구 호출에 연결합니다. 브라우저는 데이터베이스 메서드를 직접 호출하지 않습니다. JSON 카드에서는 계정 인증 정보를 노출하지 않고 조회 결과, 제안된 인수 및 최종 쓰기 결과를 확인할 수 있습니다.

로컬에서 빌드하고 경계 검증

이 단계에서는 애플리케이션을 컴파일하고 모델 호출을 사용하지 않은 채 실제 도구 구현을 실행합니다.

정확한 환경 타입을 생성하고, 타입을 검사한 다음 두 번들을 모두 빌드합니다.

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

Wrangler는 실제 바인딩에서 Cloudflare.Env를 생성합니다. 따라서 직접 작성한 환경 인터페이스가 wrangler.jsonc와 달라지는 것을 방지할 수 있습니다.

Workers AI는 원격 바인딩이므로 로컬 런타임에는 Wrangler가 이미 저장한 OAuth 액세스 권한이 필요합니다. 이 권한을 자식 프로세스에만 전달하고 셸 변수의 복사본은 즉시 삭제합니다.

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을 공개하지 않아도 됩니다. 이를 통해 모델 동작과 독립적으로 서버 측 효과의 안전성을 검증합니다.

  • 초기 우선순위는 리비전 0medium입니다.
  • 다른 큐에서 읽기를 시도하면 실패합니다.
  • 승인된 효과를 한 번 호출하면 리비전 1high가 됩니다.
  • 같은 작업 키를 사용하면 duplicate: true가 반환되고 리비전 1이 유지됩니다.
  • 다른 이름의 Agent에는 리비전 0인 별도의 레코드가 유지됩니다.

이 결정론적 테스트는 반복 전달이 안전한지 확인합니다. 브라우저 흐름은 SDK 게이트가 승인 전과 거부 시 효과 실행을 막는다는 사실을 별도로 검증합니다.

배포하고 거부, 승인 및 재실행 검증

이 단계에서는 애플리케이션을 배포한 다음, 동일한 변경 제안이 대기 상태로 유지되고, 거부되고, 한 번 승인되며, 반복되어도 안전하게 처리되는 과정을 관찰합니다.

프로덕션 번들을 배포하고 생성된 서명 키를 비밀로 업로드합니다.

npm run deploy
npx wrangler secret bulk .dev.vars

비밀 명령은 값을 구성 파일이나 번들에 넣지 않고 전송합니다. .dev.vars를 출력하지 마세요.

배포 결과에 표시된 정확한 origin을 저장한 다음, planning을 대상으로 10분 동안 유효한 토큰을 생성합니다.

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로 유지됩니다.

반복 전달된 승인 작업이 추가 쓰기 없이 첫 번째 결과를 반환함

정확한 어시스턴트 문장은 모델이 생성하므로 달라질 수 있습니다. 승인 카드 상태, 도구 결과 필드 및 레코드 리비전이 유용한 증거입니다. 큐, 티켓 및 레코드는 모두 합성 예제입니다.

새로 이름을 지정한 Agent를 대상으로 독립적인 클라우드 프로브를 실행합니다. 이 프로브는 추가 모델 호출을 사용하지 않습니다.

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 네임스페이스

이 VM이 아직 인증된 상태에서 두 리소스가 모두 사라졌음을 검증합니다.

python3 .labex/verify.py deleted

Worker만 삭제하면 상태를 보유하는 클래스의 수명 주기가 불명확하게 남습니다. 마이그레이션 v2는 Worker 삭제를 확인하기 전에 이 실습의 네임스페이스, 합성 레코드 및 멱등성 원장을 명시적으로 삭제합니다.

이 VM의 인증 취소

이 단계에서는 클라우드 정리가 완료되었음을 확인한 후 임시 VM 인증을 취소합니다.

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

구조화된 결과에는 "loggedIn": false가 표시되어야 합니다. 또는 Wrangler가 인증되지 않은 상태를 나타내는 0이 아닌 종료 결과를 반환할 수 있습니다. 로그아웃은 의도적으로 마지막에 수행합니다. 삭제 검증기에는 유효한 읽기 권한이 필요하지만, 폐기할 VM에는 필요하지 않습니다.

요약

Cloudflare AIChatAgent의 레코드 변경 앞에 지원되는 사람의 승인 절차를 추가했습니다. 다음 작업을 수행했습니다.

  • 쓰기 작업에는 needsApproval을 설정하고 읽기 작업은 자동으로 유지했습니다.
  • approval-requested 파트를 표시하고 명시적인 승인 또는 거부 응답을 제출했습니다.
  • 대기 중이거나 거부된 작업이 합성 레코드를 변경하지 않음을 검증했습니다.
  • 영속 멱등성 키를 저장하여 승인된 재시도가 다시 쓰지 않고 리비전 1을 반환하도록 했습니다.
  • 서버에서 이름이 지정된 Agent의 범위를 적용하여 권한 부여를 별도로 유지했습니다.
  • 개인정보가 제한된 승인 이벤트를 확인했습니다.
  • 로그아웃하기 전에 정확한 SQLite 클래스 네임스페이스와 Worker를 삭제했습니다.

이제 사람의 승인은 명시적이고 감사 가능한 상태이며, 멱등성 원장은 반복 전달로부터 효과를 보호합니다. 다음 실습에서는 방향을 바꿉니다. MCP를 통해 합성 읽기 전용 기능을 게시하고, 검색과 전송을 새로운 개념으로 다룹니다.