From b1f3407ec222e3e08a0d6a4fbafbd798819d3f80 Mon Sep 17 00:00:00 2001
From: Dan Carter
Date: Wed, 19 Aug 2026 09:09:08 -0400
Subject: [PATCH 1/6] Revalidate MCP actions before dispatch
---
.../mcp-shared/__tests__/action-store.test.ts | 145 ++++++--
.../__tests__/client-pagination.test.ts | 18 +
.../mcp-shared/__tests__/connection.test.ts | 29 +-
packages/mcp-shared/__tests__/facet.test.ts | 349 +++++++++++++++++-
packages/mcp-shared/__tests__/session.test.ts | 58 ++-
packages/mcp-shared/__tests__/tools.test.ts | 16 +
packages/mcp-shared/src/account.ts | 26 +-
packages/mcp-shared/src/action-store.ts | 112 +++++-
packages/mcp-shared/src/client.ts | 24 +-
packages/mcp-shared/src/connection.ts | 42 ++-
packages/mcp-shared/src/facet.ts | 135 ++++++-
packages/mcp-shared/src/session.ts | 52 ++-
packages/mcp-shared/src/tools.ts | 6 +
.../__tests__/auto-approval.test.ts | 174 ++++++++-
.../workshop-backend/src/auto-approval.ts | 48 ++-
packages/workshop-backend/src/overseer.ts | 38 +-
packages/workshop-shared/src/api.ts | 5 +-
packages/workshop-shared/src/gatekeeper.ts | 58 +++
18 files changed, 1215 insertions(+), 120 deletions(-)
diff --git a/packages/mcp-shared/__tests__/action-store.test.ts b/packages/mcp-shared/__tests__/action-store.test.ts
index 2f2afd8e7..f0797a4b6 100644
--- a/packages/mcp-shared/__tests__/action-store.test.ts
+++ b/packages/mcp-shared/__tests__/action-store.test.ts
@@ -1,8 +1,15 @@
import { describe, expect, it } from "vitest";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
-import { ActionStore } from "../src/action-store.js";
-import { McpProtocolError, McpSessionExpiredError } from "../src/client.js";
+import { ActionInvalidatedError, ActionStore } from "../src/action-store.js";
+import {
+ ACTION_INVALIDATED_ERROR_CODE,
+ getActionInvalidationReason,
+} from "@gadgets/workshop-shared/gatekeeper";
+import {
+ McpProtocolError,
+ McpSessionExpiredError,
+} from "../src/client.js";
type TestSql = ConstructorParameters[0];
@@ -12,7 +19,7 @@ function fakeSql(): TestSql {
exec(query: string, ...bindings: SQLInputValue[]) {
const rows = bindings.length > 0
? db.prepare(query).all(...bindings)
- : /^\s*(?:SELECT|INSERT.*RETURNING)/is.test(query)
+ : /^\s*(?:SELECT|INSERT.*RETURNING)/is.test(query)
? db.prepare(query).all()
: (db.exec(query), []);
return {
@@ -28,11 +35,81 @@ function fakeSql(): TestSql {
const log = { debug() {}, info() {}, warn() {}, error() {}, with() { return log; } };
const ok = async () => ({ content: [{ type: "text" as const, text: "done" }] });
+const snapshot = { policyFingerprint: "action:manual", connectionGeneration: 1 };
+const stage = (store: ActionStore, args: Record) =>
+ store.stage("send", args, snapshot);
describe("ActionStore", () => {
+ it("persists the approved policy and account generation", () => {
+ const store = new ActionStore(fakeSql());
+ const staged = store.stage("send", {}, {
+ policyFingerprint: "vetted:w01",
+ connectionGeneration: 7,
+ });
+
+ expect(store.get(staged.id)).toMatchObject({
+ policyFingerprint: "vetted:w01",
+ connectionGeneration: 7,
+ });
+ });
+
+ it("closes an action invalidated before dispatch without claiming it may have landed", async () => {
+ const store = new ActionStore(fakeSql());
+ const staged = stage(store, {});
+
+ await expect(store.apply(staged.id, async () => {
+ throw new ActionInvalidatedError("Policy changed. Stage the call again.");
+ }, log)).rejects.toMatchObject({
+ errorCode: ACTION_INVALIDATED_ERROR_CODE,
+ message: `${ACTION_INVALIDATED_ERROR_CODE}: Policy changed. Stage the call again.`,
+ });
+ await expect(store.apply(staged.id, async () => {
+ throw new Error("must not dispatch again");
+ }, log)).rejects.toMatchObject({
+ errorCode: ACTION_INVALIDATED_ERROR_CODE,
+ message: `${ACTION_INVALIDATED_ERROR_CODE}: Policy changed. Stage the call again.`,
+ });
+
+ expect(store.get(staged.id)).toMatchObject({
+ state: "failed",
+ retryable: false,
+ error: "Policy changed. Stage the call again.",
+ });
+ });
+
+ it("retains the invalidation discriminator in the serialized error message", async () => {
+ const store = new ActionStore(fakeSql());
+ const staged = stage(store, {});
+
+ const error = await store.apply(staged.id, async () => {
+ throw new ActionInvalidatedError("Policy changed.");
+ }, log).catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toContain(ACTION_INVALIDATED_ERROR_CODE);
+ expect(getActionInvalidationReason(new Error((error as Error).message)))
+ .toBe("Policy changed.");
+ });
+
+ it("keeps validation failures retryable when tools/call was never reached", async () => {
+ const store = new ActionStore(fakeSql());
+ const staged = stage(store, {});
+
+ await expect(store.apply(staged.id, async () => {
+ throw new McpProtocolError("validation failed");
+ }, log)).rejects.toThrow(/validation failed/);
+
+ expect(store.get(staged.id)).toMatchObject({
+ state: "failed",
+ retryable: true,
+ error: "validation failed",
+ dispatched: false,
+ });
+ });
+
it("keeps a decided action available until the Gadget collects it", async () => {
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", { to: "a@b.c" });
+ const staged = stage(store, { to: "a@b.c" });
await store.apply(staged.id, fn => fn({ callTool: ok } as never), log);
expect(store.get(staged.id)?.state).toBe("applied");
expect(store.get(staged.id)?.result?.text).toBe("done");
@@ -41,9 +118,9 @@ describe("ActionStore", () => {
it("retains only recent terminal actions without pruning pending ones", async () => {
const sql = fakeSql();
const store = new ActionStore(sql);
- const pending = store.stage("send", { pending: true });
+ const pending = stage(store, { pending: true });
for (let i = 0; i < 250; i++) {
- const staged = store.stage("send", { i });
+ const staged = stage(store, { i });
await store.apply(staged.id, fn => fn({ callTool: ok } as never), log);
}
const { count } = sql.exec<{ count: number }>("SELECT count(*) AS count FROM mcp_actions").one();
@@ -59,8 +136,8 @@ describe("ActionStore", () => {
// Pending records cannot be pruned, so without a cap they are an unbounded write primitive: a
// Gadget that queues calls and never collects them grows the Durable Object forever.
const store = new ActionStore(fakeSql());
- for (let i = 0; i < 50; i++) store.stage("send", { i });
- expect(() => store.stage("send", { overflow: true })).toThrow(/awaiting approval/);
+ for (let i = 0; i < 50; i++) stage(store, { i });
+ expect(() => stage(store, { overflow: true })).toThrow(/awaiting approval/);
});
it("rejects arguments that cannot be stored safely", () => {
@@ -68,27 +145,27 @@ describe("ActionStore", () => {
const circular: Record = {};
circular.self = circular;
- expect(() => store.stage("send", circular)).toThrow(/JSON-compatible/);
- expect(() => store.stage("send", { body: "x".repeat(70_000) })).toThrow(/too large/);
+ expect(() => stage(store, circular)).toThrow(/JSON-compatible/);
+ expect(() => stage(store, { body: "x".repeat(70_000) })).toThrow(/too large/);
});
it("frees a pending slot however the call was decided", async () => {
const store = new ActionStore(fakeSql());
- for (let i = 0; i < 48; i++) store.stage("send", { i });
- const applied = store.stage("send", { applied: true });
- const rejected = store.stage("send", { rejected: true });
- expect(() => store.stage("send", {})).toThrow();
+ for (let i = 0; i < 48; i++) stage(store, { i });
+ const applied = stage(store, { applied: true });
+ const rejected = stage(store, { rejected: true });
+ expect(() => stage(store, {})).toThrow();
await store.apply(applied.id, fn => fn({ callTool: ok } as never), log);
store.reject(rejected.id);
- expect(() => store.stage("send", {})).not.toThrow();
- expect(() => store.stage("send", {})).not.toThrow();
+ expect(() => stage(store, {})).not.toThrow();
+ expect(() => stage(store, {})).not.toThrow();
});
it("records a declined call as retryable, distinct from a rejection", async () => {
// The server answered, so the tool did not run and another attempt cannot duplicate anything.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
const declined = async () => {
throw new McpProtocolError("MCP server rejected: unknown tool", -32601, "declined");
};
@@ -110,7 +187,7 @@ describe("ActionStore", () => {
// server acted, so the request may already have been carried out. Retrying is how one approval
// becomes two writes, and MCP has no inverse operation to undo the second.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let calls = 0;
const dropped = async () => {
calls++;
@@ -133,7 +210,7 @@ describe("ActionStore", () => {
it("treats an unrecognised failure as possibly performed", async () => {
// Fails safe. A throw site that has not said what it means is not evidence the tool never ran.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
const boom = async () => { throw new Error("upstream exploded"); };
await expect(store.apply(staged.id, fn => fn({ callTool: boom } as never), log))
.rejects.toThrow(/may or may not have taken effect/);
@@ -151,7 +228,7 @@ describe("ActionStore", () => {
};
for (let attempt = 0; attempt < 250; attempt++) {
- const staged = store.stage("send", { attempt });
+ const staged = stage(store, { attempt });
await expect(store.apply(staged.id, fn => fn({ callTool: declined } as never), log))
.rejects.toThrow();
}
@@ -164,7 +241,7 @@ describe("ActionStore", () => {
// Pruning must not reach the record that just failed: the Gadget learns the outcome by asking
// for it afterwards.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
const declined = async () => {
throw new McpProtocolError("MCP server rejected: unknown tool", -32601, "declined");
};
@@ -178,7 +255,7 @@ describe("ActionStore", () => {
// MCP says a session-bearing 404 means no dispatch, but a fronting proxy can produce the same
// response after the upstream accepted the write. The two cases are indistinguishable here.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let calls = 0;
const expired = async () => {
calls++;
@@ -199,7 +276,7 @@ describe("ActionStore", () => {
// tool. MCP describes no inverse operation, so the duplicate write cannot be undone -- the
// claim has to be taken before the first await, not after the call returns.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", { to: "a@b.c" });
+ const staged = stage(store, { to: "a@b.c" });
let calls = 0;
const slow = async () => {
calls++;
@@ -221,7 +298,7 @@ describe("ActionStore", () => {
it("does not reject an action after application has started", async () => {
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let release!: () => void;
const blocked = new Promise(resolve => { release = resolve; });
const applying = store.apply(staged.id, async fn => {
@@ -243,7 +320,7 @@ describe("ActionStore", () => {
// write permanently. The action is closed as failed and a person decides what to do.
const sql = fakeSql();
const store = new ActionStore(sql);
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
sql.exec(
"UPDATE mcp_actions SET state = 'applying', claimed_at = ? WHERE id = ?",
Date.now() - 5 * 60 * 1000,
@@ -265,19 +342,19 @@ describe("ActionStore", () => {
// Object never makes one. Counting those rows retired the binding one interruption at a time.
const sql = fakeSql();
const store = new ActionStore(sql);
- for (let n = 0; n < 50; n++) store.stage("send", { n });
+ for (let n = 0; n < 50; n++) stage(store, { n });
sql.exec(
"UPDATE mcp_actions SET state = 'applying', claimed_at = ?",
Date.now() - 5 * 60 * 1000,
);
- expect(new ActionStore(sql).stage("send", { n: 50 }).state).toBe("pending");
+ expect(stage(new ActionStore(sql), { n: 50 }).state).toBe("pending");
});
it("does not retire a live slow call when another action is staged", async () => {
const sql = fakeSql();
const store = new ActionStore(sql);
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let release!: () => void;
const applying = store.apply(staged.id, async fn => {
await new Promise(resolve => { release = resolve; });
@@ -289,7 +366,7 @@ describe("ActionStore", () => {
staged.id,
);
- store.stage("send", { another: true });
+ stage(store, { another: true });
expect(store.get(staged.id)?.state).toBe("applying");
release();
await applying;
@@ -299,14 +376,14 @@ describe("ActionStore", () => {
const sql = fakeSql();
let store = new ActionStore(sql);
for (let batch = 0; batch < 3; batch++) {
- for (let n = 0; n < 50; n++) store.stage("send", { batch, n });
+ for (let n = 0; n < 50; n++) stage(store, { batch, n });
sql.exec(
"UPDATE mcp_actions SET state = 'applying', claimed_at = ? WHERE state = 'pending'",
Date.now() - 5 * 60 * 1000,
);
store = new ActionStore(sql);
}
- store.stage("send", { final: true });
+ stage(store, { final: true });
const { applying } = sql.exec<{ applying: number }>(
"SELECT count(*) AS applying FROM mcp_actions WHERE state = 'applying'").one();
@@ -322,7 +399,7 @@ describe("ActionStore", () => {
// and the next attempt re-ran a tool call that had already taken effect. Losing the result is
// recoverable; repeating the write is not.
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let calls = 0;
const poison = async () => {
calls++;
@@ -342,7 +419,7 @@ describe("ActionStore", () => {
it("bounds retained results by UTF-8 bytes", async () => {
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
const big = async () => ({ content: [{ type: "text" as const, text: "😀".repeat(40_000) }] });
await store.apply(staged.id, fn => fn({ callTool: big } as never), log);
expect(store.get(staged.id)?.state).toBe("applied");
@@ -351,7 +428,7 @@ describe("ActionStore", () => {
it("is idempotent, because the Workshop may retry a call whose result it never saw", async () => {
const store = new ActionStore(fakeSql());
- const staged = store.stage("send", {});
+ const staged = stage(store, {});
let calls = 0;
const counting = async () => { calls++; return { content: [] }; };
await store.apply(staged.id, fn => fn({ callTool: counting } as never), log);
diff --git a/packages/mcp-shared/__tests__/client-pagination.test.ts b/packages/mcp-shared/__tests__/client-pagination.test.ts
index 1d82bfe1f..c3df576e9 100644
--- a/packages/mcp-shared/__tests__/client-pagination.test.ts
+++ b/packages/mcp-shared/__tests__/client-pagination.test.ts
@@ -183,6 +183,24 @@ describe("McpClient.listTools", () => {
expect(calls()).toBe(2);
});
+ it("does not carry an exact lookup deadline into a later tool call", async () => {
+ let calls = 0;
+ vi.stubGlobal("fetch", async (_input: unknown, init?: RequestInit) => {
+ calls++;
+ const request = JSON.parse(String(init?.body));
+ return new Response(JSON.stringify({
+ jsonrpc: "2.0",
+ id: request.id,
+ result: { content: [] },
+ }), { headers: { "Content-Type": "application/json" } });
+ });
+ const client = new McpClient("https://mcp.example.com/mcp", async () => null);
+
+ await expect(client.findTool("send", 0)).rejects.toThrow(/timed out|timeout/i);
+ await expect(client.callTool("send", {})).resolves.toEqual({ content: [] });
+ expect(calls).toBe(1);
+ });
+
it("stops paging when a bounded search has enough matches", async () => {
const calls = stubPages([{
tools: Array.from({ length: 25 }, (_, i) => ({ name: `jira_tool_${i}` })),
diff --git a/packages/mcp-shared/__tests__/connection.test.ts b/packages/mcp-shared/__tests__/connection.test.ts
index de7d029f3..4378b3c36 100644
--- a/packages/mcp-shared/__tests__/connection.test.ts
+++ b/packages/mcp-shared/__tests__/connection.test.ts
@@ -5,7 +5,12 @@ import {
McpCallNotDispatchedError,
McpClient,
} from "../src/client.js";
-import { withClient, type ConnectionAccount } from "../src/connection.js";
+import {
+ createMcpConnectionChangedRpcError,
+ McpConnectionChangedError,
+ withClient,
+ type ConnectionAccount,
+} from "../src/connection.js";
afterEach(() => vi.unstubAllGlobals());
@@ -62,6 +67,24 @@ it("classifies credential lookup failure as not dispatched", async () => {
expect(callMayHaveTakenEffect(error)).toBe(false);
});
+it("preserves a connection change detected during credential lookup", async () => {
+ const account: ConnectionAccount = {
+ async getConnection() {
+ throw createMcpConnectionChangedRpcError("The account was repointed.");
+ },
+ async assertConnectionCurrent() {},
+ async setMcpSessionId() { return true; },
+ async noteCredentialsExpired() {},
+ };
+
+ const error = await withClient({}, account, "https://mcp.example.com",
+ client => client.callTool("send", {}), { retryOnExpiry: false }).catch(err => err);
+
+ expect(error).toBeInstanceOf(McpConnectionChangedError);
+ expect(error.message).toBe("The account was repointed.");
+ expect(callMayHaveTakenEffect(error)).toBe(false);
+});
+
it("classifies initialization failure as not dispatched", async () => {
vi.stubGlobal("fetch", async () => new Response(null, { status: 500 }));
const account: ConnectionAccount = {
@@ -102,7 +125,7 @@ it("rechecks account generation immediately before the tool request", async () =
return { authorization: "token", sessionId: "session", generation: 1 };
},
async assertConnectionCurrent() {
- if (!current) throw new Error("connection replaced");
+ if (!current) throw createMcpConnectionChangedRpcError("connection replaced");
},
async setMcpSessionId() { return true; },
async noteCredentialsExpired() {},
@@ -113,7 +136,7 @@ it("rechecks account generation immediately before the tool request", async () =
return client.callTool("send", {});
}, { retryOnExpiry: false }).catch(err => err);
- expect(error).toBeInstanceOf(McpCallNotDispatchedError);
+ expect(error).toBeInstanceOf(McpConnectionChangedError);
expect(requests).toBe(0);
});
diff --git a/packages/mcp-shared/__tests__/facet.test.ts b/packages/mcp-shared/__tests__/facet.test.ts
index 38112314e..929497367 100644
--- a/packages/mcp-shared/__tests__/facet.test.ts
+++ b/packages/mcp-shared/__tests__/facet.test.ts
@@ -1,13 +1,24 @@
import { expect, it, vi } from "vitest";
+import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import { McpFacetBase } from "../src/facet.js";
import { McpSessionBase } from "../src/session.js";
-import { classifyTool, type ServerTrust } from "../src/tools.js";
+import { classifyTool, toolPolicyFingerprint, type ServerTrust } from "../src/tools.js";
import type { McpClient, McpTool } from "../src/client.js";
import type { ToolScope } from "../src/scope.js";
import type { ScopedCatalog } from "../src/catalog.js";
-import type { ConnectionAccount } from "../src/connection.js";
-import type { ResourceDescription } from "@gadgets/workshop-shared/gatekeeper";
+import {
+ McpConnectionChangedError,
+ type ConnectionAccount,
+ type WithClientOptions,
+} from "../src/connection.js";
+import {
+ ACTION_INVALIDATED_ERROR_CODE,
+ ACTION_RESTAGE_REQUIRED_ERROR_CODE,
+ getActionInvalidationReason,
+ getActionRestageRequiredReason,
+ type ResourceDescription,
+} from "@gadgets/workshop-shared/gatekeeper";
const log = {
debug() {}, info() {}, error() {},
@@ -16,6 +27,23 @@ const log = {
with() { return this; },
};
+function fakeSql(): SqlStorage {
+ const db = new DatabaseSync(":memory:");
+ return {
+ exec(query: string, ...bindings: SQLInputValue[]) {
+ const rows = bindings.length > 0
+ ? db.prepare(query).all(...bindings)
+ : /^\s*(?:SELECT|INSERT.*RETURNING)/is.test(query)
+ ? db.prepare(query).all()
+ : (db.exec(query), []);
+ return {
+ toArray: () => rows as T[],
+ one: () => rows[0] as T,
+ };
+ },
+ } as unknown as SqlStorage;
+}
+
class TestSession extends McpSessionBase {}
class TestFacet extends McpFacetBase
)}
+ {record.type === 'action' && record.invalidationReason && (
+
+ {record.invalidationReason}
+
+ )}
{formatFullDate(at)}
{record.resourceTitle}
diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx
index 23988f3e2..7c07c0304 100644
--- a/packages/workshop-frontend/src/ChatInterface.tsx
+++ b/packages/workshop-frontend/src/ChatInterface.tsx
@@ -5830,32 +5830,6 @@ function ChatInterface({
return changed;
};
- const applyOptimisticActionState = (actionId: number, state: "approved" | "rejected"): boolean => {
- let changed = false;
- const locations = cacheRef.current.actionMessages.get(actionId);
- if (!locations) return false;
-
- for (const [key, location] of locations) {
- const messages = cacheRef.current.messages.get(location.chatId);
- const msg = messages?.[location.sequence];
- if (msg?.type !== "action" || msg.actionId !== actionId || !msg.actionLog) {
- locations.delete(key);
- continue;
- }
-
- const nextMessages = [...messages!];
- nextMessages[location.sequence] = {
- ...msg,
- actionLog: { ...msg.actionLog, state, appliedAt: new Date() },
- };
- cacheRef.current.messages.set(location.chatId, nextMessages);
- changed = true;
- }
-
- if (locations.size === 0) cacheRef.current.actionMessages.delete(actionId);
- return changed;
- };
-
const applyOptimisticHookEnabled = (actionId: number, enabled: boolean): boolean => {
let changed = false;
const locations = cacheRef.current.actionMessages.get(actionId);
@@ -5907,9 +5881,7 @@ function ChatInterface({
const { alwaysApproveTag, isTagAutoApproved } =
useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange);
- const resolveAction = useResolveAction(overseer, setProcessingActions, (actionId, state) => {
- if (applyOptimisticActionState(actionId, state)) forceUpdate();
- });
+ const resolveAction = useResolveAction(overseer, setProcessingActions);
// Handle enabling/disabling a bound hook from the chat thread.
const handleToggleHook = async (actionId: number, hookId: number, enabled: boolean) => {
@@ -6462,7 +6434,8 @@ function ChatInterface({
const isPending = state === "pending";
const isApproved = state === "approved";
- const isRejected = state === "rejected";
+ const isInvalidated = log.invalidationReason !== undefined;
+ const isRejected = state === "rejected" && !isInvalidated;
// A blocking (awaitDecision) pending action suspends the agent turn and blocks the composer, so
// present it as a prominent callout with its details expanded by default.
const isBlocking = isPending && log.description.awaitDecision === true;
@@ -6475,8 +6448,10 @@ function ChatInterface({
? "Approved"
: isRejected
? "Denied"
- : null;
- const stateLabelCls = isRejected
+ : isInvalidated
+ ? "Invalidated"
+ : null;
+ const stateLabelCls = isRejected || isInvalidated
? "text-kumo-danger"
: "text-kumo-inactive";
// Auto-approval target: offer "Always approve this type" only when enabling a rule would
@@ -6627,6 +6602,9 @@ function ChatInterface({
+ {log.invalidationReason && (
+
{log.invalidationReason}
+ )}
{resourceMeta}
)}
diff --git a/packages/workshop-frontend/src/useResolveAction.ts b/packages/workshop-frontend/src/useResolveAction.ts
index 11607851b..22b1d8031 100644
--- a/packages/workshop-frontend/src/useResolveAction.ts
+++ b/packages/workshop-frontend/src/useResolveAction.ts
@@ -1,25 +1,21 @@
-import { useCallback, useRef, type Dispatch, type SetStateAction } from 'react'
+import { useCallback, type Dispatch, type SetStateAction } from 'react'
import { useKumoToastManager } from '@cloudflare/kumo'
import type { RpcStub } from 'capnweb'
-import type { ActionState, Overseer } from '@gadgets/workshop-shared/api'
+import type { Overseer } from '@gadgets/workshop-shared/api'
type ActionDecision = 'approve' | 'deny'
export function useResolveAction(
overseer: RpcStub,
setProcessing: Dispatch>>,
- onResolved?: (actionId: number, state: Extract) => void,
) {
const toasts = useKumoToastManager()
- const onResolvedRef = useRef(onResolved)
- onResolvedRef.current = onResolved
return useCallback(async (actionId: number, decision: ActionDecision) => {
setProcessing(previous => new Set(previous).add(actionId))
try {
if (decision === 'approve') await overseer.approveAction(actionId)
else await overseer.rejectAction(actionId)
- onResolvedRef.current?.(actionId, decision === 'approve' ? 'approved' : 'rejected')
} catch (error) {
console.error(`Failed to ${decision} action:`, error)
toasts.add({ title: `Failed to ${decision} action`, variant: 'error' })
From 82c3d9845d0f42b4dea9bba927a04f8234c9da7d Mon Sep 17 00:00:00 2001
From: Dan Carter
Date: Wed, 19 Aug 2026 10:19:01 -0400
Subject: [PATCH 3/6] Refresh auto-approval affordances after invalidation
---
packages/workshop-frontend/src/Activity.tsx | 26 ++++++-
.../workshop-frontend/src/ChatInterface.tsx | 22 +++++-
.../src/useAlwaysApproveTag.test.tsx | 67 +++++++++++++++++++
.../src/useAlwaysApproveTag.ts | 25 ++++++-
4 files changed, 135 insertions(+), 5 deletions(-)
create mode 100644 packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx
index 5614780cd..80ab12d87 100644
--- a/packages/workshop-frontend/src/Activity.tsx
+++ b/packages/workshop-frontend/src/Activity.tsx
@@ -133,8 +133,24 @@ export default function Activity({
} | null>(null)
const toasts = useKumoToastManager()
- const { pendingActions, historyGroups, historyTotal, historyShown, invalidationCount } = useMemo(() => {
+ const {
+ pendingActions,
+ historyGroups,
+ historyTotal,
+ historyShown,
+ invalidationCount,
+ autoApprovalInvalidations,
+ } = useMemo(() => {
const records = [...actionsById.values()]
+ const invalidations = new Map()
+ for (const record of records) {
+ if (record.type === 'action' && record.invalidationReason && record.gatekeeperId !== undefined) {
+ invalidations.set(
+ record.gatekeeperId,
+ Math.max(record.id, invalidations.get(record.gatekeeperId) ?? -1),
+ )
+ }
+ }
const pending = records
.filter(record => record.state === 'pending')
.toSorted((a, b) => timeValue(a.createdAt) - timeValue(b.createdAt) || a.id - b.id)
@@ -158,6 +174,7 @@ export default function Activity({
invalidationCount: records.filter(
record => record.type === 'action' && record.invalidationReason,
).length,
+ autoApprovalInvalidations: invalidations,
}
}, [actionsById, historyFilter])
@@ -181,7 +198,12 @@ export default function Activity({
}
const { alwaysApproveTag, isTagAutoApproved } =
- useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange)
+ useAlwaysApproveTag(
+ overseer,
+ setProcessingActions,
+ onAutoApproveChange,
+ autoApprovalInvalidations,
+ )
const toggleExpanded = (id: number) => {
setExpandedActionId(previous => (previous === id ? null : id))
diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx
index 7c07c0304..9938ccc4a 100644
--- a/packages/workshop-frontend/src/ChatInterface.tsx
+++ b/packages/workshop-frontend/src/ChatInterface.tsx
@@ -5875,11 +5875,31 @@ function ChatInterface({
actionKind: ActionKind; actionLabel: string } | null
>(null);
+ const autoApprovalInvalidations = useMemo(() => {
+ const invalidations = new Map();
+ for (const message of currentMessages ?? []) {
+ const log = message.type === "action" ? message.actionLog : undefined;
+ if (log?.type !== "action" || !log.invalidationReason || log.gatekeeperId === undefined) {
+ continue;
+ }
+ invalidations.set(
+ log.gatekeeperId,
+ Math.max(message.actionId, invalidations.get(log.gatekeeperId) ?? -1),
+ );
+ }
+ return invalidations;
+ }, [currentMessages]);
+
// Enable auto-approval of an action tag on its connection (gated by the confirm dialog). The
// server applies the now-eligible pending action(s) via its drain, and the action state flips to
// "approved" through the actions subscription -- so we don't optimistically mutate it here.
const { alwaysApproveTag, isTagAutoApproved } =
- useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange);
+ useAlwaysApproveTag(
+ overseer,
+ setProcessingActions,
+ onAutoApproveChange,
+ autoApprovalInvalidations,
+ );
const resolveAction = useResolveAction(overseer, setProcessingActions);
diff --git a/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
new file mode 100644
index 000000000..3de60321f
--- /dev/null
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
@@ -0,0 +1,67 @@
+// @vitest-environment jsdom
+/* eslint-disable react/react-in-jsx-scope */
+
+import { act, type Dispatch, type SetStateAction } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { RpcStub } from 'capnweb'
+import type { Overseer } from '@gadgets/workshop-shared/api'
+import type { ActionKind } from '@gadgets/workshop-shared/gatekeeper'
+import { useAlwaysApproveTag } from './useAlwaysApproveTag'
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+vi.mock('@cloudflare/kumo', () => ({
+ useKumoToastManager: () => ({ add: vi.fn<() => void>() }),
+}))
+
+const ACTION_KIND: ActionKind = { tag: 'send', label: 'Send' }
+
+describe('useAlwaysApproveTag', () => {
+ let root: Root | undefined
+ let container: HTMLDivElement | undefined
+
+ afterEach(() => {
+ act(() => root?.unmount())
+ container?.remove()
+ vi.restoreAllMocks()
+ })
+
+ it('forgets locally enabled tags only when a newer invalidation arrives', async () => {
+ const overseer = {
+ setAutoApprovedActionKind:
+ vi.fn<(_gatekeeperId: number, _actionKind: ActionKind) => Promise>(async () => {}),
+ } as unknown as RpcStub
+ const setProcessingActions =
+ vi.fn<(_value: SetStateAction>) => void>() as unknown as
+ Dispatch>>
+ let state: ReturnType | undefined
+
+ function Probe({ invalidations }: { invalidations: ReadonlyMap }) {
+ state = useAlwaysApproveTag(
+ overseer,
+ setProcessingActions,
+ undefined,
+ invalidations,
+ )
+ return null
+ }
+
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ await act(async () => root!.render())
+ await act(async () => { await state!.alwaysApproveTag(1, 7, ACTION_KIND) })
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
+
+ await act(async () => root!.render())
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(false)
+
+ await act(async () => { await state!.alwaysApproveTag(2, 7, ACTION_KIND) })
+ await act(async () => root!.render())
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
+
+ await act(async () => root!.render())
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(false)
+ })
+})
diff --git a/packages/workshop-frontend/src/useAlwaysApproveTag.ts b/packages/workshop-frontend/src/useAlwaysApproveTag.ts
index f415be233..d838c22ae 100644
--- a/packages/workshop-frontend/src/useAlwaysApproveTag.ts
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'
+import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { useKumoToastManager } from '@cloudflare/kumo'
import { RpcStub } from 'capnweb'
import { Overseer } from '@gadgets/workshop-shared/api'
@@ -14,9 +14,30 @@ export function useAlwaysApproveTag(
setProcessingActions: Dispatch>>,
// Invoked after a rule is successfully enabled, so other views (e.g. the Connections rule list)
// can refresh without waiting to be re-opened.
- onEnabled?: () => void) {
+ onEnabled?: () => void,
+ latestInvalidations?: ReadonlyMap) {
const toasts = useKumoToastManager()
const [enabledTags, setEnabledTags] = useState>(new Set())
+ const seenInvalidations = useRef(new Map())
+
+ useEffect(() => {
+ if (!latestInvalidations) return
+ const changedGatekeepers = new Set()
+ for (const [gatekeeperId, actionId] of latestInvalidations) {
+ if (actionId > (seenInvalidations.current.get(gatekeeperId) ?? -1)) {
+ changedGatekeepers.add(gatekeeperId)
+ seenInvalidations.current.set(gatekeeperId, actionId)
+ }
+ }
+ if (changedGatekeepers.size === 0) return
+ setEnabledTags(previous => {
+ const next = new Set([...previous].filter(key => {
+ const separator = key.indexOf(':')
+ return !changedGatekeepers.has(Number(key.slice(0, separator)))
+ }))
+ return next.size === previous.size ? previous : next
+ })
+ }, [latestInvalidations])
// Enable auto-approval for the action's class. Returns true on success, false on failure (the
// error is surfaced via a toast) so the caller can decide whether to dismiss a confirm dialog.
From e6a6ebe62a15e53a28e235467cbcb5ded9237564 Mon Sep 17 00:00:00 2001
From: Dan Carter
Date: Wed, 19 Aug 2026 10:30:27 -0400
Subject: [PATCH 4/6] Track invalidations across workspace chats
---
.../workshop-frontend/src/ChatInterface.tsx | 29 +++++++++----------
1 file changed, 13 insertions(+), 16 deletions(-)
diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx
index 9938ccc4a..44048e235 100644
--- a/packages/workshop-frontend/src/ChatInterface.tsx
+++ b/packages/workshop-frontend/src/ChatInterface.tsx
@@ -5462,9 +5462,21 @@ function ChatInterface({
};
}, [overseer]);
- // Patch cached chat messages on action upserts.
+ const [autoApprovalInvalidations, setAutoApprovalInvalidations] =
+ useState>(new Map());
+
+ // Patch cached chat messages and reconcile auto-approval state on workspace-wide action upserts.
useActionEntries(overseer, (record) => {
if (applyActionLogUpdateToCachedMessages(record)) scheduleUpdate();
+ if (record.type === "action" && record.invalidationReason && record.gatekeeperId !== undefined) {
+ const gatekeeperId = record.gatekeeperId;
+ setAutoApprovalInvalidations(previous => {
+ if (record.id <= (previous.get(gatekeeperId) ?? -1)) return previous;
+ const next = new Map(previous);
+ next.set(gatekeeperId, record.id);
+ return next;
+ });
+ }
});
// Reset per-chat UI state when selectedChatId changes
@@ -5875,21 +5887,6 @@ function ChatInterface({
actionKind: ActionKind; actionLabel: string } | null
>(null);
- const autoApprovalInvalidations = useMemo(() => {
- const invalidations = new Map();
- for (const message of currentMessages ?? []) {
- const log = message.type === "action" ? message.actionLog : undefined;
- if (log?.type !== "action" || !log.invalidationReason || log.gatekeeperId === undefined) {
- continue;
- }
- invalidations.set(
- log.gatekeeperId,
- Math.max(message.actionId, invalidations.get(log.gatekeeperId) ?? -1),
- );
- }
- return invalidations;
- }, [currentMessages]);
-
// Enable auto-approval of an action tag on its connection (gated by the confirm dialog). The
// server applies the now-eligible pending action(s) via its drain, and the action state flips to
// "approved" through the actions subscription -- so we don't optimistically mutate it here.
From 44682aed36058dc6662baeb0209f4b593ed32b52 Mon Sep 17 00:00:00 2001
From: Dan Carter
Date: Wed, 19 Aug 2026 12:56:32 -0400
Subject: [PATCH 5/6] Simplify MCP action invalidation state
---
.../mcp-shared/__tests__/action-store.test.ts | 121 +++++++++++++-----
packages/mcp-shared/__tests__/facet.test.ts | 48 ++++---
packages/mcp-shared/src/action-store.ts | 111 ++++++++--------
packages/mcp-shared/src/facet.ts | 18 +--
packages/mcp-shared/src/session.ts | 7 +-
.../__tests__/auto-approval.test.ts | 7 +-
.../workshop-backend/src/auto-approval.ts | 10 +-
packages/workshop-shared/src/gatekeeper.ts | 83 +++++-------
8 files changed, 217 insertions(+), 188 deletions(-)
diff --git a/packages/mcp-shared/__tests__/action-store.test.ts b/packages/mcp-shared/__tests__/action-store.test.ts
index f0797a4b6..d1e240c0b 100644
--- a/packages/mcp-shared/__tests__/action-store.test.ts
+++ b/packages/mcp-shared/__tests__/action-store.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
-import { ActionInvalidatedError, ActionStore } from "../src/action-store.js";
+import { ActionStore } from "../src/action-store.js";
import {
- ACTION_INVALIDATED_ERROR_CODE,
- getActionInvalidationReason,
+ createActionDispatchStoppedError,
+ getActionDispatchStopped,
} from "@gadgets/workshop-shared/gatekeeper";
import {
McpProtocolError,
@@ -57,38 +57,26 @@ describe("ActionStore", () => {
const store = new ActionStore(fakeSql());
const staged = stage(store, {});
- await expect(store.apply(staged.id, async () => {
- throw new ActionInvalidatedError("Policy changed. Stage the call again.");
- }, log)).rejects.toMatchObject({
- errorCode: ACTION_INVALIDATED_ERROR_CODE,
- message: `${ACTION_INVALIDATED_ERROR_CODE}: Policy changed. Stage the call again.`,
- });
- await expect(store.apply(staged.id, async () => {
+ const first = await store.apply(staged.id, async () => {
+ throw createActionDispatchStoppedError(
+ "invalidated", "Policy changed. Stage the call again.");
+ }, log).catch((caught: unknown) => caught);
+ const replay = await store.apply(staged.id, async () => {
throw new Error("must not dispatch again");
- }, log)).rejects.toMatchObject({
- errorCode: ACTION_INVALIDATED_ERROR_CODE,
- message: `${ACTION_INVALIDATED_ERROR_CODE}: Policy changed. Stage the call again.`,
+ }, log).catch((caught: unknown) => caught);
+
+ expect(getActionDispatchStopped(first)).toEqual({
+ kind: "invalidated",
+ reason: "Policy changed. Stage the call again.",
});
+ expect(getActionDispatchStopped(replay)).toEqual(getActionDispatchStopped(first));
- expect(store.get(staged.id)).toMatchObject({
+ const stored = store.get(staged.id);
+ expect(stored).toMatchObject({
state: "failed",
retryable: false,
- error: "Policy changed. Stage the call again.",
});
- });
-
- it("retains the invalidation discriminator in the serialized error message", async () => {
- const store = new ActionStore(fakeSql());
- const staged = stage(store, {});
-
- const error = await store.apply(staged.id, async () => {
- throw new ActionInvalidatedError("Policy changed.");
- }, log).catch((caught: unknown) => caught);
-
- expect(error).toBeInstanceOf(Error);
- expect((error as Error).message).toContain(ACTION_INVALIDATED_ERROR_CODE);
- expect(getActionInvalidationReason(new Error((error as Error).message)))
- .toBe("Policy changed.");
+ expect(getActionDispatchStopped(stored?.error)).toEqual(getActionDispatchStopped(first));
});
it("keeps validation failures retryable when tools/call was never reached", async () => {
@@ -103,7 +91,6 @@ describe("ActionStore", () => {
state: "failed",
retryable: true,
error: "validation failed",
- dispatched: false,
});
});
@@ -337,6 +324,80 @@ describe("ActionStore", () => {
expect(recovered.get(staged.id)?.state).toBe("failed");
});
+ it("keeps a claim retryable when interruption preceded the dispatch boundary", async () => {
+ const sql = fakeSql();
+ const store = new ActionStore(sql);
+ const staged = stage(store, {});
+ sql.exec(
+ "UPDATE mcp_actions SET state = 'applying', retryable = 1 WHERE id = ?",
+ staged.id,
+ );
+ const recovered = new ActionStore(sql);
+
+ await recovered.apply(staged.id, fn => fn({ callTool: ok } as never), log);
+
+ expect(recovered.get(staged.id)?.state).toBe("applied");
+ });
+
+ it("conservatively closes interrupted claims when migrating the main-era schema", () => {
+ const sql = fakeSql();
+ sql.exec(`CREATE TABLE mcp_actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tool_name TEXT NOT NULL,
+ args_json TEXT NOT NULL,
+ state TEXT NOT NULL,
+ submitted_at INTEGER NOT NULL,
+ claimed_at INTEGER,
+ retryable INTEGER,
+ result_json TEXT,
+ error TEXT
+ ) STRICT`);
+ sql.exec(
+ `INSERT INTO mcp_actions (
+ tool_name, args_json, state, submitted_at, retryable
+ ) VALUES ('send', '{}', 'applying', 1, 1)`,
+ );
+
+ const migrated = new ActionStore(sql);
+
+ expect(migrated.get(1)).toMatchObject({ state: "failed", retryable: false });
+ });
+
+ it("uses the prior dispatch marker once when migrating interrupted claims", async () => {
+ const sql = fakeSql();
+ sql.exec(`CREATE TABLE mcp_actions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tool_name TEXT NOT NULL,
+ args_json TEXT NOT NULL,
+ state TEXT NOT NULL,
+ submitted_at INTEGER NOT NULL,
+ policy_fingerprint TEXT,
+ connection_generation INTEGER,
+ dispatched INTEGER,
+ claimed_at INTEGER,
+ retryable INTEGER,
+ result_json TEXT,
+ error TEXT
+ ) STRICT`);
+ sql.exec(
+ `INSERT INTO mcp_actions (
+ id, tool_name, args_json, state, submitted_at, policy_fingerprint,
+ connection_generation, dispatched, retryable
+ ) VALUES
+ (1, 'send', '{}', 'applying', 1, 'action:manual', 1, 1, 1),
+ (2, 'send', '{}', 'applying', 1, 'action:manual', 1, 0, 1)`,
+ );
+
+ const migrated = new ActionStore(sql);
+
+ expect(migrated.get(1)).toMatchObject({ state: "failed", retryable: false });
+ expect(migrated.get(2)).toMatchObject({ state: "failed", retryable: true });
+ await expect(migrated.apply(1, fn => fn({ callTool: ok } as never), log))
+ .rejects.toThrow(/may or may not have taken effect/);
+ await migrated.apply(2, fn => fn({ callTool: ok } as never), log);
+ expect(migrated.get(2)?.state).toBe("applied");
+ });
+
it("does not let claims nobody will settle consume the queue permanently", async () => {
// Only the next `apply()` for that same id settles an expired claim, and an evicted Durable
// Object never makes one. Counting those rows retired the binding one interruption at a time.
diff --git a/packages/mcp-shared/__tests__/facet.test.ts b/packages/mcp-shared/__tests__/facet.test.ts
index 929497367..ee293de75 100644
--- a/packages/mcp-shared/__tests__/facet.test.ts
+++ b/packages/mcp-shared/__tests__/facet.test.ts
@@ -13,10 +13,7 @@ import {
type WithClientOptions,
} from "../src/connection.js";
import {
- ACTION_INVALIDATED_ERROR_CODE,
- ACTION_RESTAGE_REQUIRED_ERROR_CODE,
- getActionInvalidationReason,
- getActionRestageRequiredReason,
+ getActionDispatchStopped,
type ResourceDescription,
} from "@gadgets/workshop-shared/gatekeeper";
@@ -128,15 +125,15 @@ class TestFacet extends McpFacetBase caught);
- expect(error).toMatchObject({ errorCode: ACTION_RESTAGE_REQUIRED_ERROR_CODE });
- expect(getActionRestageRequiredReason(new Error((error as Error).message)))
- .toMatch(/predates current approval policy checks/i);
- expect(subject.lookupAction(action.id)).toMatchObject({
+ expect(getActionDispatchStopped(error)).toMatchObject({
+ kind: "restage",
+ reason: expect.stringMatching(/predates current approval policy checks/i),
+ });
+ const stored = subject.lookupAction(action.id);
+ expect(stored).toMatchObject({
state: "failed",
retryable: false,
- dispatched: false,
error: expect.stringMatching(/predates current approval policy checks/i),
});
- await expect(subject.applyAction(action.id))
- .rejects.toMatchObject({ errorCode: ACTION_RESTAGE_REQUIRED_ERROR_CODE });
+ const replay = await subject.applyAction(action.id).catch((caught: unknown) => caught);
+ expect(getActionDispatchStopped(replay)).toEqual(getActionDispatchStopped(stored?.error));
const session = await subject.startSession(queue as never);
await expect(session.getActionResult(action.id)).resolves.toMatchObject({
status: "failed",
@@ -420,12 +418,11 @@ it("restages a legacy failed action whose absent retryable flag means retryable"
subject.removeActionSnapshot(action.id);
subject.markActionLegacyRetryableFailure(action.id);
- await expect(subject.applyAction(action.id))
- .rejects.toMatchObject({ errorCode: ACTION_RESTAGE_REQUIRED_ERROR_CODE });
+ const error = await subject.applyAction(action.id).catch((caught: unknown) => caught);
+ expect(getActionDispatchStopped(error)?.kind).toBe("restage");
expect(subject.lookupAction(action.id)).toMatchObject({
state: "failed",
retryable: false,
- dispatched: false,
error: expect.stringMatching(/predates current approval policy checks/i),
});
});
@@ -455,13 +452,13 @@ it("invalidates an action when the connection changes before a client opens", as
const error = await subject.applyAction(action.id).catch((caught: unknown) => caught);
- expect(error).toMatchObject({ errorCode: ACTION_INVALIDATED_ERROR_CODE });
- expect(getActionInvalidationReason(new Error((error as Error).message)))
- .toMatch(/connection changed/i);
+ expect(getActionDispatchStopped(error)).toMatchObject({
+ kind: "invalidated",
+ reason: expect.stringMatching(/connection changed/i),
+ });
expect(subject.lookupAction(action.id)).toMatchObject({
state: "failed",
retryable: false,
- dispatched: false,
});
expect(subject.toolCalls).toBe(0);
});
@@ -476,15 +473,14 @@ it("replays an assert-time connection invalidation as not dispatched", async ()
});
subject.toolCallFailure = new McpConnectionChangedError("The account was repointed.");
- await expect(subject.applyAction(action.id))
- .rejects.toMatchObject({ errorCode: ACTION_INVALIDATED_ERROR_CODE });
+ const first = await subject.applyAction(action.id).catch((caught: unknown) => caught);
+ expect(getActionDispatchStopped(first)?.kind).toBe("invalidated");
expect(subject.lookupAction(action.id)).toMatchObject({
state: "failed",
retryable: false,
- dispatched: false,
});
- await expect(subject.applyAction(action.id))
- .rejects.toMatchObject({ errorCode: ACTION_INVALIDATED_ERROR_CODE });
+ const replay = await subject.applyAction(action.id).catch((caught: unknown) => caught);
+ expect(getActionDispatchStopped(replay)).toEqual(getActionDispatchStopped(first));
expect(subject.toolCalls).toBe(0);
});
diff --git a/packages/mcp-shared/src/action-store.ts b/packages/mcp-shared/src/action-store.ts
index 1c0e2410e..a1102867b 100644
--- a/packages/mcp-shared/src/action-store.ts
+++ b/packages/mcp-shared/src/action-store.ts
@@ -2,10 +2,8 @@
// database; claims are persisted before external I/O so an interrupted write is never replayed.
import {
- ACTION_INVALIDATED_ERROR_CODE,
- createActionInvalidatedError,
- createActionRestageRequiredError,
- getActionRestageRequiredReason,
+ createActionDispatchStoppedError,
+ getActionDispatchStopped,
} from "@gadgets/workshop-shared/gatekeeper";
import { callMayHaveTakenEffect, type McpClient, type McpToolCallResult } from "./client.js";
import type { McpLog } from "./log.js";
@@ -26,7 +24,6 @@ type ActionRow = {
submitted_at: number;
policy_fingerprint: string | null;
connection_generation: number | null;
- dispatched: number | null;
claimed_at: number | null;
retryable: number | null;
result_json: string | null;
@@ -42,7 +39,6 @@ function fromRow(row: ActionRow): StoredAction {
submittedAt: row.submitted_at,
policyFingerprint: row.policy_fingerprint ?? undefined,
connectionGeneration: row.connection_generation ?? undefined,
- dispatched: row.dispatched === null ? undefined : row.dispatched === 1,
claimedAt: row.claimed_at ?? undefined,
retryable: row.retryable === null ? undefined : row.retryable === 1,
result: row.result_json
@@ -57,19 +53,8 @@ export const APPLY_OUTCOME_UNKNOWN_MESSAGE =
"This call was interrupted after it had been sent, so it may or may not have taken effect. " +
"Check the server before trying it again.";
-/** The approved tool or account policy changed before dispatch, so the action must be restaged. */
-export class ActionInvalidatedError extends Error {
- /** Stable code retained when this error crosses the gatekeeper RPC boundary. */
- readonly errorCode = ACTION_INVALIDATED_ERROR_CODE;
- /** Human-readable reason stored separately from the serialized discriminator. */
- readonly reason: string;
-
- constructor(reason: string) {
- super(createActionInvalidatedError(reason).message);
- this.name = "ActionInvalidatedError";
- this.reason = reason;
- }
-}
+const APPLY_NOT_DISPATCHED_MESSAGE =
+ "This call was interrupted before it was sent. Try applying it again.";
/** Stores queued MCP actions in one facet-local SQLite table. */
export class ActionStore {
@@ -85,7 +70,6 @@ export class ActionStore {
submitted_at INTEGER NOT NULL,
policy_fingerprint TEXT,
connection_generation INTEGER,
- dispatched INTEGER CHECK (dispatched IS NULL OR dispatched IN (0, 1)),
claimed_at INTEGER,
retryable INTEGER CHECK (retryable IS NULL OR retryable IN (0, 1)),
result_json TEXT CHECK (result_json IS NULL OR json_valid(result_json)),
@@ -102,21 +86,45 @@ export class ActionStore {
if (!columns.has("connection_generation")) {
sql.exec("ALTER TABLE mcp_actions ADD COLUMN connection_generation INTEGER");
}
- if (!columns.has("dispatched")) {
- sql.exec("ALTER TABLE mcp_actions ADD COLUMN dispatched INTEGER");
- }
+ sql.exec(`CREATE TABLE IF NOT EXISTS mcp_action_store_meta (
+ version INTEGER NOT NULL
+ ) STRICT`);
+ const version = sql.exec<{ version: number }>(
+ "SELECT version FROM mcp_action_store_meta LIMIT 1").toArray()[0]?.version ?? 0;
// A fresh store means a fresh Durable Object activation. Any persisted claim belonged to an
// interrupted prior activation and must never be replayed because the write may have landed.
- sql.exec(
- `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?
- WHERE state = 'applying' AND dispatched IS NOT 0`,
- APPLY_OUTCOME_UNKNOWN_MESSAGE,
- );
- sql.exec(
- `UPDATE mcp_actions SET state = 'failed', retryable = 1,
- error = 'This call was interrupted before it was sent. Try applying it again.'
- WHERE state = 'applying' AND dispatched = 0`,
- );
+ if (version === 0) {
+ if (columns.has("dispatched")) {
+ sql.exec(
+ `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?
+ WHERE state = 'applying' AND dispatched IS NOT 0`,
+ APPLY_OUTCOME_UNKNOWN_MESSAGE,
+ );
+ sql.exec(
+ `UPDATE mcp_actions SET state = 'failed', retryable = 1, error = ?
+ WHERE state = 'applying' AND dispatched = 0`,
+ APPLY_NOT_DISPATCHED_MESSAGE,
+ );
+ } else {
+ sql.exec(
+ `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?
+ WHERE state = 'applying'`,
+ APPLY_OUTCOME_UNKNOWN_MESSAGE,
+ );
+ }
+ sql.exec("INSERT INTO mcp_action_store_meta (version) VALUES (1)");
+ } else {
+ sql.exec(
+ `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?
+ WHERE state = 'applying' AND retryable IS NOT 1`,
+ APPLY_OUTCOME_UNKNOWN_MESSAGE,
+ );
+ sql.exec(
+ `UPDATE mcp_actions SET state = 'failed', retryable = 1, error = ?
+ WHERE state = 'applying' AND retryable = 1`,
+ APPLY_NOT_DISPATCHED_MESSAGE,
+ );
+ }
this.#prune();
}
@@ -128,15 +136,13 @@ export class ActionStore {
#save(action: StoredAction): void {
this.#sql.exec(
- `UPDATE mcp_actions SET state = ?, claimed_at = ?, retryable = ?, result_json = ?, error = ?,
- dispatched = ?
+ `UPDATE mcp_actions SET state = ?, claimed_at = ?, retryable = ?, result_json = ?, error = ?
WHERE id = ?`,
action.state,
action.claimedAt ?? null,
action.retryable === undefined ? null : Number(action.retryable),
action.result === undefined ? null : JSON.stringify(action.result),
action.error ?? null,
- action.dispatched === undefined ? null : Number(action.dispatched),
action.id,
);
}
@@ -189,9 +195,8 @@ export class ActionStore {
stored.state = "failed";
stored.claimedAt = undefined;
stored.retryable = false;
- stored.dispatched = false;
stored.result = undefined;
- stored.error = createActionRestageRequiredError(reason).message;
+ stored.error = createActionDispatchStoppedError("restage", reason).message;
this.#save(stored);
this.#prune();
}
@@ -206,12 +211,8 @@ export class ActionStore {
if (stored.state === "applied") return;
if (stored.state === "rejected") throw new Error(`MCP action ${id} was already rejected.`);
if (stored.state === "failed" && stored.retryable === false) {
- if (stored.dispatched === false) {
- const restageReason = getActionRestageRequiredReason(new Error(stored.error ?? ""));
- if (restageReason !== undefined) throw createActionRestageRequiredError(restageReason);
- throw new ActionInvalidatedError(
- stored.error ?? "This MCP action became invalid before dispatch.");
- }
+ const stopped = getActionDispatchStopped(stored.error ?? "");
+ if (stopped) throw createActionDispatchStoppedError(stopped.kind, stopped.reason);
throw new Error(stored.error ?? `MCP action ${id} cannot be retried.`);
}
if (stored.state === "applying") {
@@ -220,34 +221,34 @@ export class ActionStore {
stored.state = "applying";
stored.claimedAt = Date.now();
- stored.dispatched = false;
+ stored.retryable = true;
stored.error = undefined;
stored.result = undefined;
this.#save(stored);
let result: McpToolCallResult;
- let dispatched = false;
+ let crossedDispatchBoundary = false;
try {
result = await call(client => {
- dispatched = true;
- stored.dispatched = true;
+ crossedDispatchBoundary = true;
+ stored.retryable = false;
this.#save(stored);
return client.callTool(stored.toolName, stored.args);
});
} catch (err) {
- if (err instanceof ActionInvalidatedError) {
+ const stopped = getActionDispatchStopped(err);
+ if (stopped) {
stored.state = "failed";
stored.retryable = false;
- stored.dispatched = false;
- stored.error = err.reason;
+ stored.error = createActionDispatchStoppedError(stopped.kind, stopped.reason).message;
this.#save(stored);
this.#prune();
- log.warn("tool call invalidated before dispatch", {
- event: "action.apply.invalidated", actionId: id, toolName: stored.toolName, error: err,
+ log.warn("tool call stopped before dispatch", {
+ event: "action.apply.stopped", actionId: id, toolName: stored.toolName, error: err,
});
- throw err;
+ throw createActionDispatchStoppedError(stopped.kind, stopped.reason);
}
- const mayHaveLanded = dispatched && callMayHaveTakenEffect(err);
+ const mayHaveLanded = crossedDispatchBoundary && callMayHaveTakenEffect(err);
stored.state = "failed";
stored.retryable = !mayHaveLanded;
stored.error = mayHaveLanded
diff --git a/packages/mcp-shared/src/facet.ts b/packages/mcp-shared/src/facet.ts
index 762c39d96..b0118d776 100644
--- a/packages/mcp-shared/src/facet.ts
+++ b/packages/mcp-shared/src/facet.ts
@@ -3,7 +3,7 @@
import { DurableObject, type RpcStub } from "cloudflare:workers";
import {
- createActionRestageRequiredError,
+ createActionDispatchStoppedError,
type ActionKind,
type ApprovalQueue,
type Gatekeeper,
@@ -11,11 +11,7 @@ import {
type ResourceDescription,
} from "@gadgets/workshop-shared/gatekeeper";
-import {
- ActionInvalidatedError,
- ActionStore,
- REVERT_UNSUPPORTED_MESSAGE,
-} from "./action-store.js";
+import { ActionStore, REVERT_UNSUPPORTED_MESSAGE } from "./action-store.js";
import {
CATALOG_TTL_MS,
HydratedTools,
@@ -338,7 +334,7 @@ export abstract class McpFacetBase<
const reason =
"This MCP action predates current approval policy checks. Stage the call again.";
this.#actions().markRestageRequired(action, reason);
- throw createActionRestageRequiredError(reason);
+ throw createActionDispatchStoppedError("restage", reason);
}
return this.#actions().apply(
action,
@@ -347,16 +343,16 @@ export abstract class McpFacetBase<
const catalog = await this.catalog(deadline);
if (stored.connectionGeneration === undefined || stored.policyFingerprint === undefined
|| connectionGeneration !== stored.connectionGeneration) {
- throw new ActionInvalidatedError(
+ throw createActionDispatchStoppedError("invalidated",
"This MCP connection changed after approval was requested. Stage the call again.");
}
if (!scopeAllows(this.scope, stored.toolName, catalog.isPortal)) {
- throw new ActionInvalidatedError(
+ throw createActionDispatchStoppedError("invalidated",
"This MCP tool is no longer allowed by the binding. Stage the call again.");
}
const tool = await this.#freshTool(client, stored.toolName, catalog, deadline);
if (!tool || toolPolicyFingerprint(tool, this.trust) !== stored.policyFingerprint) {
- throw new ActionInvalidatedError(
+ throw createActionDispatchStoppedError("invalidated",
"This MCP tool's approval policy changed. Review and stage the call again.");
}
return () => fn(client);
@@ -364,7 +360,7 @@ export abstract class McpFacetBase<
return dispatch();
}, { retryOnExpiry: false }).catch(error => {
if (error instanceof McpConnectionChangedError) {
- throw new ActionInvalidatedError(
+ throw createActionDispatchStoppedError("invalidated",
"This MCP connection changed after approval was requested. Stage the call again.");
}
throw error;
diff --git a/packages/mcp-shared/src/session.ts b/packages/mcp-shared/src/session.ts
index 3ffd49644..de4ca5c6a 100644
--- a/packages/mcp-shared/src/session.ts
+++ b/packages/mcp-shared/src/session.ts
@@ -6,7 +6,7 @@
import { RpcTarget, type RpcStub } from "cloudflare:workers";
import {
- getActionRestageRequiredReason,
+ getActionDispatchStopped,
type ActionDescription,
type ActionKind,
type ApprovalQueue,
@@ -55,8 +55,6 @@ export type StoredAction = {
policyFingerprint?: string;
/** Account connection generation the user approved, absent on actions staged by older code. */
connectionGeneration?: number;
- /** Set immediately before `tools/call`; false proves a failure preceded dispatch. */
- dispatched?: boolean;
/** When the in-flight apply was claimed, for recovering a claim whose Durable Object died mid-call. */
claimedAt?: number;
/**
@@ -291,8 +289,7 @@ export class McpSessionBase extends RpcTarget {
case "failed":
return {
status: "failed",
- message: (stored.error && getActionRestageRequiredReason(new Error(stored.error)))
- ?? stored.error
+ message: (stored.error && getActionDispatchStopped(stored.error)?.reason) ?? stored.error
?? `Calling "${stored.toolName}" on ${host.serverName} failed.`,
};
case "applied": {
diff --git a/packages/workshop-backend/__tests__/auto-approval.test.ts b/packages/workshop-backend/__tests__/auto-approval.test.ts
index 019e569d6..2867fe9ee 100644
--- a/packages/workshop-backend/__tests__/auto-approval.test.ts
+++ b/packages/workshop-backend/__tests__/auto-approval.test.ts
@@ -10,8 +10,7 @@ import {
import type { ActionRecord, AutoApproveTagRecord } from "../src/overseer.js";
import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api";
import {
- createActionInvalidatedError,
- createActionRestageRequiredError,
+ createActionDispatchStoppedError,
} from "@gadgets/workshop-shared/gatekeeper";
import { makeMockStorage } from "./mock-storage.js";
@@ -361,7 +360,7 @@ describe("handleActionApplyFailure", () => {
expect(handleActionApplyFailure(
storage,
GK,
- createActionRestageRequiredError("Stage the call again."),
+ createActionDispatchStoppedError("restage", "Stage the call again."),
)).toBe("Stage the call again.");
expect([...storage.autoApproveTags.list()].map(rule => rule.actionKind.tag).toSorted())
.toEqual(["delete", "edit"]);
@@ -375,7 +374,7 @@ describe("handleActionApplyFailure", () => {
expect(handleActionApplyFailure(
storage,
GK,
- createActionInvalidatedError("The connection changed."),
+ createActionDispatchStoppedError("invalidated", "The connection changed."),
)).toBe("The connection changed.");
expect([...storage.autoApproveTags.list()]).toEqual([]);
});
diff --git a/packages/workshop-backend/src/auto-approval.ts b/packages/workshop-backend/src/auto-approval.ts
index 215a47820..9ac33911f 100644
--- a/packages/workshop-backend/src/auto-approval.ts
+++ b/packages/workshop-backend/src/auto-approval.ts
@@ -6,8 +6,7 @@
import type { Collection } from "@gadgets/typed-storage";
import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api";
import {
- getActionInvalidationReason,
- getActionRestageRequiredReason,
+ getActionDispatchStopped,
} from "@gadgets/workshop-shared/gatekeeper";
import { createWorkshopLogger } from "./observability";
import type { ActionRecord, AutoApproveTagRecord } from "./overseer.js";
@@ -47,12 +46,11 @@ export function handleActionApplyFailure(
storage: Pick,
gatekeeperId: number,
error: unknown): string | undefined {
- const invalidationReason = getActionInvalidationReason(error);
- if (invalidationReason !== undefined) {
+ const stopped = getActionDispatchStopped(error);
+ if (stopped?.kind === "invalidated") {
clearAutoApprovalRules(storage, gatekeeperId);
- return invalidationReason;
}
- return getActionRestageRequiredReason(error);
+ return stopped?.reason;
}
export class AutoApprovalDrainer {
diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts
index 84a480c8d..469aef4b4 100644
--- a/packages/workshop-shared/src/gatekeeper.ts
+++ b/packages/workshop-shared/src/gatekeeper.ts
@@ -18,57 +18,36 @@
import type { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers";
-/** Stable RPC error code thrown when an approved action became invalid before dispatch. */
-export const ACTION_INVALIDATED_ERROR_CODE = "GATEKEEPER_ACTION_INVALIDATED";
-
-const ACTION_INVALIDATED_ERROR_PREFIX = `${ACTION_INVALIDATED_ERROR_CODE}: `;
-
-function codedActionErrorReason(
- error: unknown, code: string, prefix: string): string | undefined {
- const candidate = typeof error === "object" && error !== null && "errorCode" in error
- ? error.errorCode
- : undefined;
- const message = typeof error === "object" && error !== null && "message" in error
- && typeof error.message === "string"
- ? error.message
- : undefined;
- return message?.startsWith(prefix) ? message.slice(prefix.length)
- : candidate === code ? message : undefined;
-}
-
-/** Creates an invalidation error whose discriminator survives Workers RPC error serialization. */
-export function createActionInvalidatedError(reason: string): Error & {
- errorCode: typeof ACTION_INVALIDATED_ERROR_CODE;
-} {
- return Object.assign(new Error(`${ACTION_INVALIDATED_ERROR_PREFIX}${reason}`), {
- errorCode: ACTION_INVALIDATED_ERROR_CODE,
- } as const);
-}
-
-/** Reads an invalidation reason from a local coded error or its message-only RPC representation. */
-export function getActionInvalidationReason(error: unknown): string | undefined {
- return codedActionErrorReason(
- error, ACTION_INVALIDATED_ERROR_CODE, ACTION_INVALIDATED_ERROR_PREFIX);
-}
-
-/** Stable RPC error code thrown when an old queued action lacks an approval-policy snapshot. */
-export const ACTION_RESTAGE_REQUIRED_ERROR_CODE = "GATEKEEPER_ACTION_RESTAGE_REQUIRED";
+/** Why an approved action stopped before dispatch. */
+export type ActionDispatchStopped = {
+ /** Whether current authority changed or an older action lacks the snapshot needed to prove it. */
+ kind: "invalidated" | "restage";
+ /** Human-readable explanation shown to the user. */
+ reason: string;
+};
-const ACTION_RESTAGE_REQUIRED_ERROR_PREFIX = `${ACTION_RESTAGE_REQUIRED_ERROR_CODE}: `;
+const ACTION_DISPATCH_STOPPED_PREFIX = "GATEKEEPER_ACTION_DISPATCH_STOPPED:";
-/** Creates a restage-required error whose discriminator survives Workers RPC serialization. */
-export function createActionRestageRequiredError(reason: string): Error & {
- errorCode: typeof ACTION_RESTAGE_REQUIRED_ERROR_CODE;
-} {
- return Object.assign(new Error(`${ACTION_RESTAGE_REQUIRED_ERROR_PREFIX}${reason}`), {
- errorCode: ACTION_RESTAGE_REQUIRED_ERROR_CODE,
- } as const);
+/** Creates a dispatch-stopped error whose kind survives Workers RPC error serialization. */
+export function createActionDispatchStoppedError(
+ kind: ActionDispatchStopped["kind"], reason: string): Error {
+ return new Error(`${ACTION_DISPATCH_STOPPED_PREFIX}${kind}: ${reason}`);
}
-/** Reads a restage-required reason from a local coded error or its message-only RPC form. */
-export function getActionRestageRequiredReason(error: unknown): string | undefined {
- return codedActionErrorReason(
- error, ACTION_RESTAGE_REQUIRED_ERROR_CODE, ACTION_RESTAGE_REQUIRED_ERROR_PREFIX);
+/** Reads a dispatch-stopped result from a local error, RPC error, or persisted message. */
+export function getActionDispatchStopped(error: unknown): ActionDispatchStopped | undefined {
+ const message = typeof error === "string" ? error
+ : typeof error === "object" && error !== null && "message" in error
+ && typeof error.message === "string"
+ ? error.message
+ : undefined;
+ if (!message?.startsWith(ACTION_DISPATCH_STOPPED_PREFIX)) return undefined;
+ const encoded = message.slice(ACTION_DISPATCH_STOPPED_PREFIX.length);
+ const separator = encoded.indexOf(": ");
+ if (separator < 0) return undefined;
+ const kind = encoded.slice(0, separator);
+ if (kind !== "invalidated" && kind !== "restage") return undefined;
+ return { kind, reason: encoded.slice(separator + 2) };
}
/**
@@ -864,10 +843,12 @@ export interface Gatekeeper extends DurableObject {
* If this throws an exception, the user will be informed that the action failed and given the
* opportunity to retry or discard.
*
- * If policy or authority changed after approval but before dispatch, throw an error created by
- * `createActionInvalidatedError()`. New Workshop versions record that distinct terminal outcome;
- * old versions leave the action pending rather than recording a write that was never dispatched
- * as approved.
+ * If policy or authority changed after approval but before dispatch, throw an `"invalidated"`
+ * error created by `createActionDispatchStoppedError()`. New Workshop versions record that
+ * distinct terminal outcome; old versions leave the action pending rather than recording a write
+ * that was never dispatched as approved. `"restage"` is reserved for an older queued action that
+ * lacks the snapshot needed for current validation. Both stop the current drain, but only
+ * `"invalidated"` clears auto-approval rules whose authority may have changed.
*
* Depending on policy conditions, an action may be approved and applied automatically. However,
* the gatekeeper is nevertheless expected to submit all actions for approval; there is no mode
From 728b9858b02b18513937a7db48f710e3bf72dd8d Mon Sep 17 00:00:00 2001
From: Dan Carter
Date: Wed, 19 Aug 2026 12:56:43 -0400
Subject: [PATCH 6/6] Centralize auto-approval invalidation updates
---
packages/workshop-frontend/src/Activity.tsx | 18 +--------
.../workshop-frontend/src/ChatInterface.tsx | 21 +---------
.../src/useAlwaysApproveTag.test.tsx | 39 ++++++++++++-------
.../src/useAlwaysApproveTag.ts | 31 +++++++--------
4 files changed, 43 insertions(+), 66 deletions(-)
diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx
index 80ab12d87..c3f9d6445 100644
--- a/packages/workshop-frontend/src/Activity.tsx
+++ b/packages/workshop-frontend/src/Activity.tsx
@@ -139,18 +139,8 @@ export default function Activity({
historyTotal,
historyShown,
invalidationCount,
- autoApprovalInvalidations,
} = useMemo(() => {
const records = [...actionsById.values()]
- const invalidations = new Map()
- for (const record of records) {
- if (record.type === 'action' && record.invalidationReason && record.gatekeeperId !== undefined) {
- invalidations.set(
- record.gatekeeperId,
- Math.max(record.id, invalidations.get(record.gatekeeperId) ?? -1),
- )
- }
- }
const pending = records
.filter(record => record.state === 'pending')
.toSorted((a, b) => timeValue(a.createdAt) - timeValue(b.createdAt) || a.id - b.id)
@@ -174,7 +164,6 @@ export default function Activity({
invalidationCount: records.filter(
record => record.type === 'action' && record.invalidationReason,
).length,
- autoApprovalInvalidations: invalidations,
}
}, [actionsById, historyFilter])
@@ -198,12 +187,7 @@ export default function Activity({
}
const { alwaysApproveTag, isTagAutoApproved } =
- useAlwaysApproveTag(
- overseer,
- setProcessingActions,
- onAutoApproveChange,
- autoApprovalInvalidations,
- )
+ useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange)
const toggleExpanded = (id: number) => {
setExpandedActionId(previous => (previous === id ? null : id))
diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx
index 44048e235..7c07c0304 100644
--- a/packages/workshop-frontend/src/ChatInterface.tsx
+++ b/packages/workshop-frontend/src/ChatInterface.tsx
@@ -5462,21 +5462,9 @@ function ChatInterface({
};
}, [overseer]);
- const [autoApprovalInvalidations, setAutoApprovalInvalidations] =
- useState>(new Map());
-
- // Patch cached chat messages and reconcile auto-approval state on workspace-wide action upserts.
+ // Patch cached chat messages on action upserts.
useActionEntries(overseer, (record) => {
if (applyActionLogUpdateToCachedMessages(record)) scheduleUpdate();
- if (record.type === "action" && record.invalidationReason && record.gatekeeperId !== undefined) {
- const gatekeeperId = record.gatekeeperId;
- setAutoApprovalInvalidations(previous => {
- if (record.id <= (previous.get(gatekeeperId) ?? -1)) return previous;
- const next = new Map(previous);
- next.set(gatekeeperId, record.id);
- return next;
- });
- }
});
// Reset per-chat UI state when selectedChatId changes
@@ -5891,12 +5879,7 @@ function ChatInterface({
// server applies the now-eligible pending action(s) via its drain, and the action state flips to
// "approved" through the actions subscription -- so we don't optimistically mutate it here.
const { alwaysApproveTag, isTagAutoApproved } =
- useAlwaysApproveTag(
- overseer,
- setProcessingActions,
- onAutoApproveChange,
- autoApprovalInvalidations,
- );
+ useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange);
const resolveAction = useResolveAction(overseer, setProcessingActions);
diff --git a/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
index 3de60321f..acabc8986 100644
--- a/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
@@ -5,7 +5,7 @@ import { act, type Dispatch, type SetStateAction } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcStub } from 'capnweb'
-import type { Overseer } from '@gadgets/workshop-shared/api'
+import type { ActionLogEntry, Overseer } from '@gadgets/workshop-shared/api'
import type { ActionKind } from '@gadgets/workshop-shared/gatekeeper'
import { useAlwaysApproveTag } from './useAlwaysApproveTag'
@@ -15,6 +15,16 @@ vi.mock('@cloudflare/kumo', () => ({
useKumoToastManager: () => ({ add: vi.fn<() => void>() }),
}))
+const actionEntries = vi.hoisted(() => ({
+ listener: undefined as ((record: ActionLogEntry) => void) | undefined,
+}))
+
+vi.mock('./useActions', () => ({
+ useActionEntries: (_overseer: unknown, listener: (record: ActionLogEntry) => void) => {
+ actionEntries.listener = listener
+ },
+}))
+
const ACTION_KIND: ActionKind = { tag: 'send', label: 'Send' }
describe('useAlwaysApproveTag', () => {
@@ -27,7 +37,7 @@ describe('useAlwaysApproveTag', () => {
vi.restoreAllMocks()
})
- it('forgets locally enabled tags only when a newer invalidation arrives', async () => {
+ it('forgets locally enabled tags for each newly invalidated action, regardless of action order', async () => {
const overseer = {
setAutoApprovedActionKind:
vi.fn<(_gatekeeperId: number, _actionKind: ActionKind) => Promise>(async () => {}),
@@ -37,31 +47,34 @@ describe('useAlwaysApproveTag', () => {
Dispatch>>
let state: ReturnType | undefined
- function Probe({ invalidations }: { invalidations: ReadonlyMap }) {
- state = useAlwaysApproveTag(
- overseer,
- setProcessingActions,
- undefined,
- invalidations,
- )
+ function Probe() {
+ state = useAlwaysApproveTag(overseer, setProcessingActions)
return null
}
+ const invalidate = (id: number) => actionEntries.listener!({
+ id,
+ type: 'action',
+ gatekeeperId: 7,
+ invalidationReason: 'Connection changed.',
+ } as ActionLogEntry)
+
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
- await act(async () => root!.render())
+ await act(async () => root!.render())
+ act(() => invalidate(10))
await act(async () => { await state!.alwaysApproveTag(1, 7, ACTION_KIND) })
expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
- await act(async () => root!.render())
+ act(() => invalidate(5))
expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(false)
await act(async () => { await state!.alwaysApproveTag(2, 7, ACTION_KIND) })
- await act(async () => root!.render())
+ act(() => invalidate(5))
expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
- await act(async () => root!.render())
+ act(() => invalidate(4))
expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(false)
})
})
diff --git a/packages/workshop-frontend/src/useAlwaysApproveTag.ts b/packages/workshop-frontend/src/useAlwaysApproveTag.ts
index d838c22ae..a807b76ce 100644
--- a/packages/workshop-frontend/src/useAlwaysApproveTag.ts
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.ts
@@ -3,6 +3,7 @@ import { useKumoToastManager } from '@cloudflare/kumo'
import { RpcStub } from 'capnweb'
import { Overseer } from '@gadgets/workshop-shared/api'
import { ActionKind } from '@gadgets/workshop-shared/gatekeeper'
+import { useActionEntries } from './useActions'
/**
* Enables an auto-approval rule for the action's (gatekeeperId, actionKind.tag), and tracks
@@ -14,30 +15,26 @@ export function useAlwaysApproveTag(
setProcessingActions: Dispatch>>,
// Invoked after a rule is successfully enabled, so other views (e.g. the Connections rule list)
// can refresh without waiting to be re-opened.
- onEnabled?: () => void,
- latestInvalidations?: ReadonlyMap) {
+ onEnabled?: () => void) {
const toasts = useKumoToastManager()
const [enabledTags, setEnabledTags] = useState>(new Set())
- const seenInvalidations = useRef(new Map())
+ const seenInvalidations = useRef(new Set())
useEffect(() => {
- if (!latestInvalidations) return
- const changedGatekeepers = new Set()
- for (const [gatekeeperId, actionId] of latestInvalidations) {
- if (actionId > (seenInvalidations.current.get(gatekeeperId) ?? -1)) {
- changedGatekeepers.add(gatekeeperId)
- seenInvalidations.current.set(gatekeeperId, actionId)
- }
- }
- if (changedGatekeepers.size === 0) return
+ seenInvalidations.current.clear()
+ setEnabledTags(new Set())
+ }, [overseer])
+
+ useActionEntries(overseer, record => {
+ if (record.type !== 'action' || !record.invalidationReason || record.gatekeeperId === undefined ||
+ seenInvalidations.current.has(record.id)) return
+ seenInvalidations.current.add(record.id)
+ const prefix = `${record.gatekeeperId}:`
setEnabledTags(previous => {
- const next = new Set([...previous].filter(key => {
- const separator = key.indexOf(':')
- return !changedGatekeepers.has(Number(key.slice(0, separator)))
- }))
+ const next = new Set([...previous].filter(key => !key.startsWith(prefix)))
return next.size === previous.size ? previous : next
})
- }, [latestInvalidations])
+ })
// Enable auto-approval for the action's class. Returns true on success, false on failure (the
// error is surfaced via a toast) so the caller can decide whether to dismiss a confirm dialog.