diff --git a/packages/mcp-shared/__tests__/action-store.test.ts b/packages/mcp-shared/__tests__/action-store.test.ts
index 2f2afd8e7..d1e240c0b 100644
--- a/packages/mcp-shared/__tests__/action-store.test.ts
+++ b/packages/mcp-shared/__tests__/action-store.test.ts
@@ -2,7 +2,14 @@ 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 {
+ createActionDispatchStoppedError,
+ getActionDispatchStopped,
+} 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,68 @@ 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, {});
+
+ 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).catch((caught: unknown) => caught);
+
+ expect(getActionDispatchStopped(first)).toEqual({
+ kind: "invalidated",
+ reason: "Policy changed. Stage the call again.",
+ });
+ expect(getActionDispatchStopped(replay)).toEqual(getActionDispatchStopped(first));
+
+ const stored = store.get(staged.id);
+ expect(stored).toMatchObject({
+ state: "failed",
+ retryable: false,
+ });
+ expect(getActionDispatchStopped(stored?.error)).toEqual(getActionDispatchStopped(first));
+ });
+
+ 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",
+ });
+ });
+
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 +105,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 +123,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 +132,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 +174,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 +197,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 +215,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 +228,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 +242,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 +263,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 +285,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 +307,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,
@@ -260,24 +324,98 @@ 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.
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 +427,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 +437,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 +460,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 +480,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 +489,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..ee293de75 100644
--- a/packages/mcp-shared/__tests__/facet.test.ts
+++ b/packages/mcp-shared/__tests__/facet.test.ts
@@ -1,13 +1,21 @@
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 {
+ getActionDispatchStopped,
+ type ResourceDescription,
+} from "@gadgets/workshop-shared/gatekeeper";
const log = {
debug() {}, info() {}, error() {},
@@ -16,6 +24,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/useAlwaysApproveTag.test.tsx b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
new file mode 100644
index 000000000..acabc8986
--- /dev/null
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.test.tsx
@@ -0,0 +1,80 @@
+// @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 { ActionLogEntry, 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 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', () => {
+ let root: Root | undefined
+ let container: HTMLDivElement | undefined
+
+ afterEach(() => {
+ act(() => root?.unmount())
+ container?.remove()
+ vi.restoreAllMocks()
+ })
+
+ 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 () => {}),
+ } as unknown as RpcStub
+ const setProcessingActions =
+ vi.fn<(_value: SetStateAction>) => void>() as unknown as
+ Dispatch>>
+ let state: ReturnType | undefined
+
+ 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())
+ act(() => invalidate(10))
+ await act(async () => { await state!.alwaysApproveTag(1, 7, ACTION_KIND) })
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
+
+ act(() => invalidate(5))
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(false)
+
+ await act(async () => { await state!.alwaysApproveTag(2, 7, ACTION_KIND) })
+ act(() => invalidate(5))
+ expect(state!.isTagAutoApproved(7, ACTION_KIND.tag)).toBe(true)
+
+ 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 f415be233..a807b76ce 100644
--- a/packages/workshop-frontend/src/useAlwaysApproveTag.ts
+++ b/packages/workshop-frontend/src/useAlwaysApproveTag.ts
@@ -1,8 +1,9 @@
-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'
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
@@ -17,6 +18,23 @@ export function useAlwaysApproveTag(
onEnabled?: () => void) {
const toasts = useKumoToastManager()
const [enabledTags, setEnabledTags] = useState>(new Set())
+ const seenInvalidations = useRef(new Set())
+
+ useEffect(() => {
+ 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 => !key.startsWith(prefix)))
+ return next.size === previous.size ? previous : next
+ })
+ })
// 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.
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' })
diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts
index d858dd2d5..01d3a6bbe 100644
--- a/packages/workshop-shared/src/api.ts
+++ b/packages/workshop-shared/src/api.ts
@@ -1470,7 +1470,7 @@ export interface CodeSubscriber {
* Specifies the state of an action in the action log:
* * pending: Action has not been applied yet. It is waiting for approval.
* * approved: Action was approved and applied.
- * * rejected: Action was rejected by the user.
+ * * rejected: Action was rejected by the user, or became invalid before dispatch.
*/
export type ActionState = "pending" | "approved" | "rejected";
@@ -1507,6 +1507,9 @@ export type ActionLogEntry = {
* clicking Approve. Only ever set alongside state "approved" (there is no automatic rejection).
*/
autoApproved?: boolean;
+
+ /** Why an approved action became invalid before dispatch. Present only when state is rejected. */
+ invalidationReason?: string;
} | {
type: "observation";
description: ObservationDescription;
diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts
index 48bcdd729..469aef4b4 100644
--- a/packages/workshop-shared/src/gatekeeper.ts
+++ b/packages/workshop-shared/src/gatekeeper.ts
@@ -18,6 +18,38 @@
import type { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers";
+/** 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_DISPATCH_STOPPED_PREFIX = "GATEKEEPER_ACTION_DISPATCH_STOPPED:";
+
+/** 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 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) };
+}
+
/**
* A pagination cursor.
*
@@ -811,6 +843,13 @@ 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 `"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
* in which it's OK to skip the check.