From a6fe8767524048fe37a747298fb72054d25eba6d Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 17 Sep 2026 12:49:31 -0500 Subject: [PATCH 01/20] Add the apply-through action contracts Declares the batch action surface on both sides of the kernel boundary. Provider side (gatekeeper.ts): `applyActionsThrough(actionId, vetoes, context)` processes every non-vetoed action through one boundary, with vetoes durable before any application and `stopped`/`invalidatedByVeto` reporting what did not land. `ApplyActionContext` carries the invocation-scoped `GitCache` and `GitPackBuilder` capabilities, so pack building is authorized by one call's non-vetoed prefix and revoked when it returns. `applyAction`/`rejectAction` stay for the per-action fallback every shipping gatekeeper still uses. Workspace side (api.ts): `Overseer.applyActionsThrough(id, vetoes)` takes workspace record IDs and translates them, never exposing provider-local action IDs to the browser. `ACTION_ERROR_CODES` classifies the expected outcomes by `code` rather than message text, on the generic `codedErrorFamily` helper. Declarations and doc comments only; implemented in the commits that follow. --- packages/workshop-shared/src/api.ts | 71 ++++++-- packages/workshop-shared/src/coded-errors.ts | 16 ++ packages/workshop-shared/src/gatekeeper.ts | 181 +++++++++++++++---- 3 files changed, 214 insertions(+), 54 deletions(-) create mode 100644 packages/workshop-shared/src/coded-errors.ts diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index d2c9aac697..df57d3ffd4 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -27,6 +27,7 @@ import { RpcCompatible, RpcStub, RpcTarget } from "capnweb"; import { AccountDescription, ActionKind, ActionDescription, AvatarImage, GatekeeperUiFrame, ObservationDescription, ResourceDescription, ResourceConfiguratorFrame, SupportedResource, VendorDescription, HookDescription } from "./gatekeeper.js"; import type { CodeChange } from "./code-change.js"; import type { UiFeatureFlags } from "./feature-flags.js"; +import { codedErrorFamily } from "./coded-errors.js"; export const SERVICE_SALT = new Uint8Array([ 0xd9, 0x4e, 0x54, 0x1d, 0x29, 0xc1, 0x03, 0x74, 0x73, 0x7e, 0xb3, 0xe3, 0x34, 0x6d, 0x8f, 0x21 @@ -327,21 +328,6 @@ export interface ObserverConfigCallback extends RpcTarget { configure(needs: ObserverBindingNeed[]): Promise; } -/** Builds the create/read helpers for a family of expected errors carrying stable - * machine-readable codes. The per-code messages double as the classification fallback for errors - * from older deployments that lost the code in transit, so changing one is a compatibility break. */ -function codedErrorFamily(messages: Record) { - const codes = new Set(Object.keys(messages)); - return { - create: (code: Code): Error & { code: Code } => - Object.assign(new Error(messages[code]), { code }), - getCode: (error: unknown): Code | undefined => { - const candidate = typeof error === "object" && error !== null && "code" in error - ? error.code : undefined; - return codes.has(candidate) ? candidate as Code : undefined; - }, - }; -} /** Stable error codes attached to expected failures from `AuthenticatedApi.openGadget()`. */ export const OPEN_GADGET_ERROR_CODES = { @@ -392,6 +378,34 @@ export const createAuthError = authErrors.create; /** Reads the machine-readable code from an authentication failure. */ export const getAuthErrorCode = authErrors.getCode; +/** Stable codes for expected action outcome failures. */ +export const ACTION_ERROR_CODES = { + /** An earlier undecided action on the same connection held the frontier below this one. */ + blocked: "ACTION_BLOCKED", + /** The gatekeeper could not apply an action and recorded why on its card. */ + stopped: "ACTION_STOPPED", +} as const; + +/** An expected action outcome failure code. */ +export type ActionErrorCode = + typeof ACTION_ERROR_CODES[keyof typeof ACTION_ERROR_CODES]; + +/** Fixed client-facing messages for expected action outcome failures. */ +export const ACTION_ERROR_MESSAGES: Record = { + [ACTION_ERROR_CODES.blocked]: + "An earlier action needs a decision before this one can be applied.", + [ACTION_ERROR_CODES.stopped]: + "Action could not be completed. See the action card for details.", +}; + +const actionErrors = codedErrorFamily(ACTION_ERROR_MESSAGES); + +/** Creates an expected action outcome failure with a machine-readable code. */ +export const createActionError = actionErrors.create; + +/** Reads the machine-readable code from an expected action outcome failure. */ +export const getActionErrorCode = actionErrors.getCode; + /** * One user as listed in the deployment-wide user directory (see * `AuthenticatedApi.searchUsers`). @@ -1670,7 +1684,7 @@ export const READ_FILES_RESPONSE_BUDGET = 8 * 1024 * 1024; * 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 invalidated by another rejected action. */ export type ActionState = "pending" | "approved" | "rejected"; @@ -1707,6 +1721,19 @@ export type ActionLogEntry = { * clicking Approve. Only ever set alongside state "approved" (there is no automatic rejection). */ autoApproved?: boolean; + + /** + * Workspace action ID whose rejection invalidated this action. Only set when `state` is + * "rejected" and the action was rejected as part of a dependency cascade. + */ + cascadedFrom?: number; + + /** + * Display-safe reason the most recent application attempt stopped at this action. Set while the + * action is pending, and retained on an action the user rejected after such an attempt, whose + * outcome the gatekeeper never confirmed. Cleared when the action applies. + */ + failure?: string; } | { type: "observation"; description: ObservationDescription; @@ -2005,8 +2032,16 @@ export interface Overseer extends RpcTarget { : Promise; /** - * Approve an action that is currently in the "pending" state. The action will be performed on - * approval. + * Process one Gatekeeper connection through the action record identified by `id`, rejecting the + * selected action records in `vetoes`. Every selected record must belong to the same connection + * and be no later than the boundary in that Gatekeeper's local action order. Selections that + * are already decided are ignored, so a concurrent decision can't fail the whole request. + */ + applyActionsThrough(id: number, vetoes: number[]): Promise; + + /** + * Approve an action that is currently in the "pending" state. This performs the action and may + * also perform earlier pending actions from the same Gatekeeper connection. */ approveAction(id: number): Promise; diff --git a/packages/workshop-shared/src/coded-errors.ts b/packages/workshop-shared/src/coded-errors.ts new file mode 100644 index 0000000000..2b6dbb3a59 --- /dev/null +++ b/packages/workshop-shared/src/coded-errors.ts @@ -0,0 +1,16 @@ +/** + * Builds helpers for expected errors whose `code` property is the sole classification signal. + * Unknown or missing codes are left unclassified, regardless of message text or constructor. + */ +export function codedErrorFamily(messages: Record) { + return { + create: (code: Code): Error & { code: Code } => + Object.assign(new Error(messages[code]), { code }), + getCode: (error: unknown): Code | undefined => { + const candidate = typeof error === "object" && error !== null && "code" in error + ? error.code : undefined; + return typeof candidate === "string" && Object.hasOwn(messages, candidate) + ? candidate as Code : undefined; + }, + }; +} diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index a02c1a88c4..31e8b84be9 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -17,6 +17,7 @@ // `Adapter` type is the root interface implemented by the service binding. import type { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers"; +import { codedErrorFamily } from "./coded-errors.js"; /** * A pagination cursor. @@ -765,6 +766,45 @@ export interface GatekeeperUser extends WorkerEntrypoint { */ export interface GatekeeperUserVerifier extends WorkerEntrypoint {} +/** Result of applying a Gatekeeper's queued actions through a decision frontier. */ +export interface ApplyActionsThroughResult { + /** + * Something went unexpectedly wrong at the given action number; remaining actions were not + * applied. The user may retry after resolving the problem or vetoing. + */ + stopped?: { + /** First unvetoed pending action at or below the boundary that could not be applied. */ + at: number; + + /** + * Explanation of why application stopped. Expected native-RPC errors may retain an own stable + * `code`, but the message must stand alone as display-safe text because the backend persists + * only that bounded presentation string. + */ + reason: Error; + }; + + /** + * Indicates actions which were invalidated as a result of vetoes. Each entry's `action` is an + * action number which has been invalidated (these may be action numbers within the range just + * applied, as well as future action numbers not yet applied), and its `invalidatedBy` is the + * vetoed action number that invalidated it (always an action listed in `vetoes`). The caller + * records these actions as rejected, so every entry must reflect durable gatekeeper state. + * + * These actions will have no effect when applied and will not produce an error. + * + * A list rather than a keyed map: JavaScript stringifies numeric object keys, so a map would + * force every consumer to parse them back, and the list keeps the gatekeeper's own ordering. + * + * Note that the Gatekeeper is not necessarily obliged to track when a veto may invalidate a + * future action. A Gatekeeper implementation may instead choose not to track dependencies, and + * instead let the future action fail with an error (producing `stopped`), leaving it up to the + * user to figure out the conflict and veto the dependent action manually. It is up to each + * Gatekeeper to decide the right trade-off between implementation complexity and UX. + */ + invalidatedByVeto?: Array<{action: number, invalidatedBy: number}>; +} + /** * Interface exposed by a Gatekeeper instance implementing a specific resource binding on a * specific Gadget. @@ -904,45 +944,65 @@ export interface Gatekeeper extends DurableObject { gitPull?(oids: GitOid[], cache: RpcStub, hints: GitPullHints): Promise; // --------------------------------------------------------------------------- - // Callbacks invoked by the overseer to apply (or reject) actions that were previously queued - // for approval via the ApprovalQueue. + // Callback invoked by the overseer to resolve actions that were previously queued for approval + // via the ApprovalQueue. // // Each action is identified by a sequential integer action ID, assigned by the gatekeeper when - // it submits the action for approval. The action ID is passed back to these methods so the + // it submits the action for approval. The action ID is passed back to this method so the // gatekeeper can look up the action details in its own storage. /** - * Action was approved. This call should apply the action (or schedule it to be applied). + * Applies all actions through the given action ID (includes all previous actions that are not + * yet applied). Action IDs listed in `vetoes` are actions the user has rejected. + * + * Actions are applied in ascending ID order. Vetoed actions and actions invalidated by a veto + * become terminal no-ops. Processing stops at the first application failure; a pending in-range + * action the gatekeeper still holds must never be silently skipped — it is either applied or + * reported via `stopped`. An action whose `submitAction()` call has not yet completed must not + * be applied. + * + * Every ID in `vetoes` must be durably recorded before any action is applied, including when + * processing stops: the caller clears its staged veto on any call that returns, so a veto lost + * behind a `stopped` result would let a later frontier apply a rejected action. + * + * `actionId` is the requested processing boundary, and every ID in `vetoes` is at or below it. + * A batch that vetoes every in-range action still uses its ordinary final action ID as the + * boundary and must finish its veto processing before returning. * - * If this throws an exception, the user will be informed that the action failed and given the - * opportunity to retry or discard. + * `context` supplies the connection's Git cache and an invocation-scoped pack builder. + * A recognized coded `buildPack()` rejection must be returned as `stopped` at that action + * before external side effects; unknown failures propagate. A durably completed push is an + * idempotent no-op and does not need another pack. * - * Depending on policy conditions, an action may be approved and applied automatically. However, + * Depending on policy conditions, actions 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. * - * To the maximum extent possible, implementations of `applyAction()` should be idempotent, as - * a poorly-timed crash may cause the overseer to fail to record that an `applyAction()` - * completed, and the user will likely then try to apply the action again in the future. + * Calls must be idempotent. Missing IDs and vetoes of unknown or already-applied actions are + * ignored. A repeated request must re-report persisted invalidations attributable to its vetoes. + */ + applyActionsThrough?(actionId: number, vetoes: number[], + context: ApplyActionContext): Promise; + + /** + * Applies one approved action using a Git cache scoped to that action. Implementations should + * be idempotent because a crash may prevent the overseer from recording a completed call. + * Actions that don't interact with Git may ignore or omit `cache` in their implementation. + * + * A thrown failure's message is persisted and displayed like `stopped.reason`, so it must stand + * alone as display-safe text. * - * `cache` provides access to the workspace's git cache, which is often needed at apply time - * (when no `ObservationAuthorizer` is available). In fact, this stub points to a wrapper around - * `GitCache` that is scoped specifically for this action, which enables the `buildPack()` method - * to function -- it will build a pack specifically for the set of commits that had been listed - * in the action's `ActionDescription.pushedCommits`. Actions that don't interact with git can - * ignore this parameter (and can even omit the parameter from their `applyAction()` - * declaration). + * @deprecated Implement `applyActionsThrough()` instead. */ applyAction(action: number, cache: RpcStub): Promise; /** - * Indicates that an action was rejected by the user. The gatekeeper should clean up any - * associated storage. + * Rejects one pending action. The returned `restart` flag is ignored; the overseer discards it. + * This remains required while callers support immediate per-action rejection. * - * If the returned `restart` flag is true, rejecting this action requires restarting the Gadget. - * This is sometimes needed by gatekeepers that simulate actions as if they had been approved -- - * the session may be in a state that is difficult to roll back without confusing the Gadget. - * The Overseer will take care of the restart, possibly after rejecting other actions. + * Rejecting an action the gatekeeper already discarded or rejected -- including one its own + * cascade invalidated -- must be a no-op success: the requested end state already holds, and a + * thrown failure is indistinguishable from a transient one, so the caller retries it forever. */ rejectAction(action: number): Promise; @@ -963,11 +1023,9 @@ export interface Gatekeeper extends DurableObject { * `canRetry` should be true if the revert failed (for a reason described in `message`), but * it could make sense to retry later. In this case the UI will continue to give the user the * option to revert. - * - * `restart` has the same meaning as for `rejectAction()`. */ revertAction(action: number): - Promise; + Promise; } export interface ObservationAuthorizer extends RpcTarget { @@ -1073,9 +1131,8 @@ export interface ApprovalQueue extends ObservationAuthorizer { * be carried out until much later. It's intended that the user might not approve actions until * hours or days later, but this shouldn't cause any problems. * - * `action` is a sequential integer action ID assigned by the gatekeeper. It will be passed back - * to the Gatekeeper's applyAction() or rejectAction() when the action is later approved or - * rejected. + * `action` is a sequential integer action ID assigned by the gatekeeper. It will later be used as + * a decision frontier or veto in the Gatekeeper's `applyActionsThrough()` method. * * `description` describes the action in a way that can direct UI representation and policy * enforcement details. @@ -1292,14 +1349,14 @@ export type ActionDescription = { * will cause `submitAction()` to throw an exception. * * Even when the action is successfully submitted, the Gatekeeper is obliged -- as always -- not - * to actually transmit any data until the action is approved and applied with `applyAction()`. - * As always, though, the Gatekeeper is expected to simulate the effects of the action - * immediately. E.g. if the agent queries the state of the remote repo, the Gatekeeper should - * indicate that the push has completed. + * to actually transmit any data until the action is approved and applied with `applyAction()` or + * `applyActionsThrough()`. As always, though, the Gatekeeper is expected to simulate the effects + * of the action immediately. E.g. if the agent queries the state of the remote repo, the + * Gatekeeper should indicate that the push has completed. * - * In order to assist in simulation, the `GitCache` passed to the Gatekeeper will always provide - * access to all objects which are pending a push (part of a submitted but not-yet-applied - * action). See `GitCache` for more info. + * The `GitCache` available to the Gatekeeper provides access to all objects pending a push. At + * application time, `GitCache.buildPack()` serves the legacy single-action path and + * `GitPackBuilder.buildPack()` serves the batch path. */ pushedCommits?: GitOid[]; @@ -1459,6 +1516,58 @@ export type GitOid = string; */ export type GitObjectType = "commit" | "tree" | "blob" | "tag"; +/** Stable error codes for expected failures from `GitPackBuilder.buildPack()`. */ +export const GIT_PACK_ERROR_CODES = { + /** The invocation that owned the builder has completed. */ + builderExpired: "GIT_PACK_BUILDER_EXPIRED", + /** The selected action was not an authorized declared push in this invocation. */ + actionNotAuthorized: "GIT_PACK_ACTION_NOT_AUTHORIZED", + /** The selected action is no longer pending or its gatekeeper connection was removed. */ + actionUnavailable: "GIT_PACK_ACTION_UNAVAILABLE", +} as const; + +/** An expected `GitPackBuilder.buildPack()` failure code. */ +export type GitPackErrorCode = + typeof GIT_PACK_ERROR_CODES[keyof typeof GIT_PACK_ERROR_CODES]; + +const gitPackErrors = codedErrorFamily({ + [GIT_PACK_ERROR_CODES.builderExpired]: "Git pack builder is no longer active.", + [GIT_PACK_ERROR_CODES.actionNotAuthorized]: + "Action is not authorized for Git pack building in this apply-through call.", + [GIT_PACK_ERROR_CODES.actionUnavailable]: + "Git pack action is no longer pending or its connection was removed.", +}); + +/** Creates an expected Git pack failure carrying its stable machine-readable code. */ +export const createGitPackError: ( + code: GitPackErrorCode, +) => Error & { code: GitPackErrorCode } = gitPackErrors.create; + +/** Classifies an expected Git pack failure by recognized `code` only. */ +export const getGitPackErrorCode: (error: unknown) => GitPackErrorCode | undefined = + gitPackErrors.getCode; + +/** + * Invocation-scoped native-RPC capability for building packs for authorized declared pushes. + */ +export interface GitPackBuilder extends RpcTarget { + /** + * Builds a pack for one gatekeeper-local action ID authorized in the containing apply-through + * call. The selected action need not equal that call's frontier. A valid push whose full closure + * is already known to the remote returns a valid empty pack. Expected availability and authority + * failures carry a code from `GIT_PACK_ERROR_CODES`. + */ + buildPack(action: number): Promise>; +} + +/** Git capabilities supplied to an action-processing invocation. */ +export type ApplyActionContext = { + /** This connection's cache view, including later pending pushes; no legacy buildPack(). */ + gitCache: RpcStub; + /** Builds only this invocation's authorized pushes; expires when the invocation completes. */ + gitPackBuilder: RpcStub; +}; + /** * Interface to the workspace's git object cache, as exposed to one gatekeeper. * From 334d63a75b0633dee34ed38f7b4411210f1c672c Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 17 Sep 2026 12:59:02 -0500 Subject: [PATCH 02/20] Replace per-action approval with serialized batch action sync `ActionSyncDriver` (src/actions.ts) owns one serialized pass per connection, replacing the per-action auto-approval module this commit deletes. A queue carries both an explicit batch and a legacy immediate rejection, so a click arriving mid-pass can neither interleave with it nor be lost. An explicit batch authorizes the whole pending prefix at or below its boundary to the calling user, delivers only the vetoes inside that boundary, and leaves later ones staged and indexed for a covering pass. Vetoes are persisted as `vetoPending` before the gatekeeper is called and acknowledged only after it confirms them, so a failed delivery is retried rather than lost, and a rejected push keeps its simulation read grant until the veto lands. Reconciliation applies cascade invalidations before approvals, records `stoppedAt` for the action that halted a pass, and stamps every mutation so the resume replay sees it. Native `applyActionsThrough` is preferred with a per-action fallback pinned to a missing-method error; the fallback checkpoints each veto and aborts before any apply when one fails. `GitPackBuilderImpl` (git-cache.ts) snapshots the invocation's authorized local selectors and revokes retained duplicates when the call ends. Tests: actions.test.ts drives the real driver and client over the production storage schema, subsuming the deleted auto-approval suite; git-push-actions.test.ts proves the Git capability in workerd through `TestGitPackGatekeeper` - real SQLite, facets, RPC and pack streams, including decoded pack contents, mark conversion and capability expiry. --- .../__tests__/action-log-pagination.test.ts | 20 +- .../__tests__/actions.test.ts | 1202 +++++++++++++++++ .../__tests__/auto-approval.test.ts | 283 ---- .../workshop-backend/__tests__/fixtures.ts | 36 +- .../__tests__/git-push-actions.test.ts | 419 +++++- .../__tests__/open-gadget-errors.test.ts | 39 +- .../workshop-backend/__tests__/test-worker.ts | 90 +- packages/workshop-backend/src/actions.ts | 478 +++++++ .../workshop-backend/src/auto-approval.ts | 99 -- packages/workshop-backend/src/git-cache.ts | 67 + packages/workshop-backend/src/overseer.ts | 276 ++-- .../workshop-backend/tsconfig.vitest.json | 4 + packages/workshop-backend/vitest.config.ts | 10 +- 13 files changed, 2469 insertions(+), 554 deletions(-) create mode 100644 packages/workshop-backend/__tests__/actions.test.ts delete mode 100644 packages/workshop-backend/__tests__/auto-approval.test.ts create mode 100644 packages/workshop-backend/src/actions.ts delete mode 100644 packages/workshop-backend/src/auto-approval.ts create mode 100644 packages/workshop-backend/tsconfig.vitest.json diff --git a/packages/workshop-backend/__tests__/action-log-pagination.test.ts b/packages/workshop-backend/__tests__/action-log-pagination.test.ts index cc66ace969..9b3a8a593d 100644 --- a/packages/workshop-backend/__tests__/action-log-pagination.test.ts +++ b/packages/workshop-backend/__tests__/action-log-pagination.test.ts @@ -1,30 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import type { RpcStub } from "capnweb"; -import type { ActionLogEntry, ActionsSubscriber } from "@gadgets/workshop-shared/api"; +import type { ActionLogEntry } from "@gadgets/workshop-shared/api"; import { ACTION_HISTORY_PAGE_DEFAULT_LIMIT, ACTION_REPLAY_PAGE_SIZE, } from "../src/overseer.js"; import { makeMockStorage } from "./mock-storage.js"; import { - FIXTURE_EPOCH, makeActionStorage, makePreIndexActionStorage, openFakeOverseer, putAction, + FIXTURE_EPOCH, makeActionStorage, makePreIndexActionStorage, makeSubscriber, openFakeOverseer, + putAction, } from "./fixtures.js"; vi.mock("capnweb-validate", () => ({ validateRpc: () => () => undefined })); -// Hand-rolled ActionsSubscriber stub. `events` interleaves entry ids with "ready", so tests can -// assert both content and ordering of the delivered stream. -function makeSubscriber(entry?: (record: ActionLogEntry) => Promise) { - let events: Array = []; - let subscriber = { - entry: entry ?? (async (record: ActionLogEntry) => { events.push(record.id); }), - ready: async () => { events.push("ready"); }, - dup: () => subscriber, - onRpcBroken: () => {}, - [Symbol.dispose]: () => {}, - }; - return { subscriber: subscriber as unknown as RpcStub, events }; -} - describe("subscribeToActions", () => { it("delivers no pre-existing records: ready fires immediately", async () => { // Live deltas only — the current pending set is queried via listActions({filter: "pending"}). diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts new file mode 100644 index 0000000000..454019dfff --- /dev/null +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -0,0 +1,1202 @@ +import { env } from "cloudflare:workers"; +import { describe, it, expect, vi } from "vitest"; +import { + ActionSyncDriver, ActionSyncStorage, GatekeeperActionTarget, isMethodMissing, +} from "../src/actions.js"; +import type { + ActionRecord, GatekeeperActionRecord, OverseerDurableObject, +} from "../src/overseer.js"; +import { + ACTION_ERROR_CODES, getActionErrorCode, type ActionLogEntry, type AiChatAuthorInfo, +} from "@gadgets/workshop-shared/api"; +import type { ApplyActionsThroughResult } from "@gadgets/workshop-shared/gatekeeper"; +import type { ManualApproval } from "../src/actions.js"; +import { keyString } from "@gadgets/typed-storage"; +import { + createGitPackError, + getGitPackErrorCode, + GIT_PACK_ERROR_CODES, +} from "@gadgets/workshop-shared/gatekeeper"; +import { + FIXTURE_EPOCH, makeActionStorage as makeStorage, makeSubscriber, openFakeOverseer, + putAction as putStoredAction, +} from "./fixtures.js"; + +vi.mock("capnweb-validate", () => ({ validateRpc: () => () => undefined })); + +declare module "cloudflare:workers" { + interface ProvidedEnv { + TEST_OVERSEER: DurableObjectNamespace; + } +} + +const GK = 1; +const ENABLER: AiChatAuthorInfo = { type: "user", id: "enabler@example.com", name: "Enabler" }; +const APPROVER: AiChatAuthorInfo = { type: "user", id: "approver@example.com", name: "Approver" }; +const REJECTER: AiChatAuthorInfo = { type: "user", id: "rejecter@example.com", name: "Rejecter" }; + +function enableRule(storage: ActionSyncStorage, actionTag = "edit", gatekeeperId = GK) { + storage.autoApproveTags.put({ + gatekeeperId, actionKind: { tag: actionTag, label: "Edits" }, enabledBy: ENABLER }); +} + +// Workspace record ids are deliberately offset from gatekeeper-local action ids (`id = action*10`) +// so a test that confuses the two ID spaces fails loudly. +function putAction( + storage: ActionSyncStorage, action: number, + opts: { gatekeeperId?: number; actionTag?: string; autoApprovable?: boolean; + state?: ActionRecord["state"]; chatId?: number; awaitDecision?: boolean; + suspendedTurn?: boolean; vetoPending?: true; resolvedBy?: AiChatAuthorInfo; + failure?: string; createdAt?: Date } = {}): number { + let id = action * 10; + storage.actions.put({ + id, + gatekeeperId: opts.gatekeeperId ?? GK, + caller: { from: "agent", chatId: opts.chatId ?? 1 }, + createdAt: opts.createdAt ?? new Date(), + state: opts.state ?? "pending", + type: "action", + action, + ...(opts.vetoPending ? { vetoPending: true } : {}), + ...(opts.suspendedTurn !== undefined ? { suspendedTurn: opts.suspendedTurn } : {}), + ...(opts.resolvedBy ? { resolvedBy: opts.resolvedBy } : {}), + ...(opts.failure !== undefined ? { failure: opts.failure } : {}), + description: { + title: `Action ${action}`, + description: `Action ${action} description`, + implementsRevert: true, + actionKind: { tag: opts.actionTag ?? "edit", label: "Edits" }, + autoApprovable: opts.autoApprovable ?? true, + ...(opts.awaitDecision ? { awaitDecision: true } : {}), + }, + }); + return id; +} + +function getAction(storage: ActionSyncStorage, action: number): GatekeeperActionRecord { + let record = storage.actions.get(action * 10); + if (!record || record.type !== "action") throw new Error(`No action ${action}`); + return record; +} + +// A migrated gatekeeper stub: records every batch call and answers from a scripted queue (or {}). +function makeBatchGatekeeper() { + let calls: Array<{actionId: number, vetoes: number[]}> = []; + let results: Array = []; + let target = { + async applyActionsThrough(actionId: number, vetoes: number[]) { + calls.push({ actionId, vetoes }); + let next = results.shift() ?? {}; + if (next instanceof Error) throw next; + return next; + }, + async applyAction() { throw new Error("legacy applyAction must not be called"); }, + async rejectAction() { throw new Error("legacy rejectAction must not be called"); }, + } as unknown as GatekeeperActionTarget; + return { target, calls, results }; +} + +// A pre-migration live stub rejects the batch method probe, then serves legacy per-action calls. +function makeLegacyGatekeeper(opts: {failApply?: number[]} = {}) { + let probes = 0; + let calls: string[] = []; + let target = { + async applyActionsThrough() { + probes++; + throw new TypeError( + 'The RPC receiver does not implement the method "applyActionsThrough".'); + }, + async applyAction(action: number) { + calls.push(`apply:${action}`); + if (opts.failApply?.includes(action)) throw new Error(`apply ${action} failed`); + }, + async rejectAction(action: number) { + calls.push(`reject:${action}`); + return { restart: true }; // must be discarded + }, + } as unknown as GatekeeperActionTarget; + return { target, calls, probeCount: () => probes }; +} + +function makeDriver(storage: ActionSyncStorage, target: GatekeeperActionTarget) { + return new ActionSyncDriver(storage, () => target, { + createGitCache: vi.fn(), + createGitPackBuilder: vi.fn(), + applyLegacyAction: async (gatekeeper, record) => { + let apply = gatekeeper.applyAction as unknown as (action: number) => Promise; + await apply(record.action); + }, + persistApproved: record => storage.actions.put(record), + persistRejected: record => storage.actions.put(record), + }); +} + +function makeClient(storage: ActionSyncStorage, target: GatekeeperActionTarget) { + let driver = makeDriver(storage, target); + return openFakeOverseer(storage, { impl: { + applyDecidedActions: (gatekeeperId: number, approval?: ManualApproval) => + driver.apply(gatekeeperId, approval), + rejectPendingAction: (record: GatekeeperActionRecord, author: AiChatAuthorInfo) => + driver.reject(record, author), + applyActionBatch: ( + boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], + author: AiChatAuthorInfo) => driver.applyThrough(boundary, vetoes, author), + } }); +} + +// Drain the microtask queue (and one macrotask) so parked continuations reach their next await. +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("ActionSyncDriver.apply", () => { + it("applies the clicked action, riding rule-authorized actions along on either side", async () => { + let storage = makeStorage(); + enableRule(storage); + let a1 = putAction(storage, 1); // rule-authorized, below the click + let a2 = putAction(storage, 2, { autoApprovable: false }); // clicked + let a3 = putAction(storage, 3); // rule-authorized, above the click + + let { target, calls } = makeBatchGatekeeper(); + let { decided } = await makeDriver(storage, target) + .apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(calls).toEqual([{ actionId: 3, vetoes: [] }]); + expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a2, a3]); + for (let action of [1, 3]) { + let ridden = getAction(storage, action); + expect(ridden.state).toBe("approved"); + expect(ridden.autoApproved).toBe(true); + expect(ridden.resolvedBy?.id).toBe(ENABLER.id); + } + let clicked = getAction(storage, 2); + expect(clicked.state).toBe("approved"); + expect(clicked.autoApproved).toBe(false); + expect(clicked.resolvedBy?.id).toBe(APPROVER.id); + }); + + it("refuses a click above an undecided gate, telling it which action to approve first", + async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); // neither clicked nor rule-authorized + putAction(storage, 2, { autoApprovable: false }); + + let { target, calls } = makeBatchGatekeeper(); + let driver = makeDriver(storage, target); + + expect(await driver.apply(GK, { action: 2, resolvedBy: APPROVER })) + .toEqual({ decided: [], blockedBy: "Action 1" }); + expect(calls).toEqual([]); + // The refusal is transient queue state, reported to the clicker rather than recorded, so it + // can't go stale on the record or later be mistaken for a gatekeeper failure. + expect(getAction(storage, 1).failure).toBeUndefined(); + expect(getAction(storage, 2).failure).toBeUndefined(); + + // Approving the gate, then clicking again, applies both. + await driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + await driver.apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(calls).toEqual([{ actionId: 1, vetoes: [] }, { actionId: 2, vetoes: [] }]); + expect(getAction(storage, 2).state).toBe("approved"); + }); + + it("treats action ID 0 as a real frontier", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 0); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).apply(GK); + + expect(calls).toEqual([{ actionId: 0, vetoes: [] }]); + expect(getAction(storage, 0).state).toBe("approved"); + }); + + it("does not flush persisted vetoes without an authorized frontier", async () => { + let storage = makeStorage(); + putAction(storage, 0, { autoApprovable: false }); + putAction(storage, 1, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).apply(GK); + + expect(calls).toEqual([]); + expect(getAction(storage, 0).state).toBe("pending"); + expect(getAction(storage, 1).vetoPending).toBe(true); + }); + + it("never auto-approves past a manual gate", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + putAction(storage, 2, { autoApprovable: false }); // manual gate + putAction(storage, 3); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).apply(GK); + + expect(calls).toEqual([{ actionId: 1, vetoes: [] }]); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2).state).toBe("pending"); + expect(getAction(storage, 3).state).toBe("pending"); + }); + + it("does not scan resolved or unrelated action history", async () => { + let storage = makeStorage(); + enableRule(storage); + for (let action = 1; action <= 500; action++) { + putAction(storage, action, { state: "approved", gatekeeperId: GK + 1 }); + } + putAction(storage, 501); + putAction(storage, 502, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let fullScan = vi.spyOn(storage.actions, "list"); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).apply(GK); + + expect(fullScan).not.toHaveBeenCalled(); + expect(calls).toEqual([{ actionId: 501, vetoes: [] }]); + expect(getAction(storage, 501).state).toBe("approved"); + expect(getAction(storage, 502).vetoPending).toBe(true); + }); + + it("makes no call when nothing is eligible", async () => { + let storage = makeStorage(); + putAction(storage, 1); // auto-approvable, but no rule enables it + putAction(storage, 2, { autoApprovable: false }); + + let { target, calls } = makeBatchGatekeeper(); + let { decided } = await makeDriver(storage, target).apply(GK); + + expect(decided).toEqual([]); + expect(calls).toEqual([]); + for (let action of [1, 2]) expect(getAction(storage, action).state).toBe("pending"); + }); + + it("records a display-safe failure on the stopped action and clears it on a later success", + async () => { + let storage = makeStorage(); + enableRule(storage); + let a1 = putAction(storage, 1); // rides along under the rule + putAction(storage, 2, { autoApprovable: false }); + + let { target, calls, results } = makeBatchGatekeeper(); + results.push({ stopped: { at: 2, reason: new Error("page was deleted upstream") } }); + let driver = makeDriver(storage, target); + + let first = await driver.apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(first.decided).toEqual([a1]); + expect(first.stoppedAt).toBe(2); + expect(getAction(storage, 1).state).toBe("approved"); + let stopped = getAction(storage, 2); + expect(stopped.state).toBe("pending"); + expect(stopped.failure).toBe("page was deleted upstream"); + + // Retry after the user resolves the problem: only the stopped action remains pending, and its + // failure is cleared. The already-applied action is never re-sent (idempotent contract), and + // the gatekeeper sees a second call at the same frontier. + let retry = await driver.apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(retry.decided).toEqual([getAction(storage, 2).id]); + expect(calls).toEqual([{ actionId: 2, vetoes: [] }, { actionId: 2, vetoes: [] }]); + let retried = getAction(storage, 2); + expect(retried.state).toBe("approved"); + expect(retried.failure).toBeUndefined(); + }); + + it("clamps the gatekeeper's failure text before persisting it", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + + let { target, results } = makeBatchGatekeeper(); + results.push({ stopped: { at: 1, reason: new Error("x".repeat(5000)) } }); + await makeDriver(storage, target).apply(GK); + + expect(getAction(storage, 1).failure).toBe("x".repeat(500)); + }); + + it("never re-applies a failed action on a rule alone", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1, { failure: "the upstream page was deleted" }); + putAction(storage, 2); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).apply(GK); + + // The gatekeeper said why it stopped, not whether the action landed, so re-sending it + // unattended could repeat a side effect. It becomes a gate until a human retries it. + expect(calls).toEqual([]); + expect(getAction(storage, 1).state).toBe("pending"); + expect(getAction(storage, 2).state).toBe("pending"); + }); + + it("still rides a rule-authorized action along once the gate that refused a click is resolved", + async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 5, { autoApprovable: false }); // the gate + putAction(storage, 7); // rule-authorized, above the gate + + let { target, calls } = makeBatchGatekeeper(); + let driver = makeDriver(storage, target); + + // Clicking 7 first is refused, and must leave no trace that would later be read as a + // gatekeeper failure -- otherwise 7 would never auto-apply again. + expect(await driver.apply(GK, { action: 7, resolvedBy: APPROVER })) + .toEqual({ decided: [], blockedBy: "Action 5" }); + + await driver.apply(GK, { action: 5, resolvedBy: APPROVER }); + + expect(calls).toEqual([{ actionId: 7, vetoes: [] }]); + expect(getAction(storage, 5).state).toBe("approved"); + expect(getAction(storage, 7).state).toBe("approved"); + expect(getAction(storage, 7).autoApproved).toBe(true); + }); + + + it("delivers a persisted veto from a fresh driver through a covering boundary", async () => { + let storage = makeStorage(); + putAction(storage, 1, { state: "approved" }); + putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + // A fresh driver over the same storage (e.g. after DO hibernation) sees durable delivery intent, + // but transmits it only when an explicit boundary covers it. + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).applyThrough(getAction(storage, 2), [], REJECTER); + + expect(calls).toEqual([{ actionId: 2, vetoes: [2] }]); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + }); + + it("authorizes the whole explicit prefix while leaving later vetoes staged", async () => { + let storage = makeStorage(); + let a1 = putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { autoApprovable: false }); + let a3 = putAction(storage, 3, { autoApprovable: false }); + putAction(storage, 4, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + let { target, calls } = makeBatchGatekeeper(); + let driver = makeDriver(storage, target); + let { decided } = await driver.applyThrough( + getAction(storage, 3), [getAction(storage, 2)], APPROVER); + + expect(calls).toEqual([{ actionId: 3, vetoes: [2] }]); + expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a3]); + for (let action of [1, 3]) { + expect(getAction(storage, action)).toMatchObject({ + state: "approved", resolvedBy: APPROVER, autoApproved: false, + }); + } + expect(getAction(storage, 2).state).toBe("rejected"); + expect(getAction(storage, 4).vetoPending).toBe(true); + }); + + it("uses the ordinary final action as an all-veto boundary", async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { autoApprovable: false }); + + let { target, calls } = makeBatchGatekeeper(); + await makeDriver(storage, target).applyThrough( + getAction(storage, 2), [getAction(storage, 1), getAction(storage, 2)], REJECTER); + + expect(calls).toEqual([{ actionId: 2, vetoes: [1, 2] }]); + expect(getAction(storage, 1).state).toBe("rejected"); + expect(getAction(storage, 2).state).toBe("rejected"); + }); + + it("reports stopping action zero", async () => { + let storage = makeStorage(); + putAction(storage, 0, { autoApprovable: false }); + let { target, results } = makeBatchGatekeeper(); + results.push({ stopped: { at: 0, reason: new Error("zero stopped") } }); + + let result = await makeDriver(storage, target) + .applyThrough(getAction(storage, 0), [], APPROVER); + + expect(result.stoppedAt).toBe(0); + expect(getAction(storage, 0)).toMatchObject({ state: "pending", failure: "zero stopped" }); + }); + + it.each([1, 4])("fails closed when a gatekeeper stops outside the applied plan at %i", + async invalidAt => { + let storage = makeStorage(); + putAction(storage, 2, { autoApprovable: false }); + putAction(storage, 3, { autoApprovable: false }); + let { target, results } = makeBatchGatekeeper(); + results.push({ stopped: { at: invalidAt, reason: new Error("invalid stop") } }); + + let result = await makeDriver(storage, target) + .applyThrough(getAction(storage, 3), [], APPROVER); + + expect(result.stoppedAt).toBe(2); + expect(getAction(storage, 2)).toMatchObject({ + state: "pending", + failure: "The gatekeeper could not apply this action.", + }); + expect(getAction(storage, 3).state).toBe("pending"); + expect(getAction(storage, 3).failure).toBeUndefined(); + }); + + it("rides staged vetoes along with an approval", async () => { + let storage = makeStorage(); + enableRule(storage); + let a1 = putAction(storage, 1); + putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let a3 = putAction(storage, 3, { autoApprovable: false }); + + let { target, calls } = makeBatchGatekeeper(); + let { decided } = await makeDriver(storage, target) + .apply(GK, { action: 3, resolvedBy: APPROVER }); + + expect(calls).toEqual([{ actionId: 3, vetoes: [2] }]); + expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a3]); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + }); + + it("marks cascade-invalidated actions rejected with the vetoing record's attribution", + async () => { + let storage = makeStorage(); + putAction(storage, 1, { state: "approved" }); + let vetoId = putAction(storage, 2, + { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let a3 = putAction(storage, 3, { autoApprovable: false }); + + let { target, results } = makeBatchGatekeeper(); + results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); + let { decided } = await makeDriver(storage, target) + .applyThrough(getAction(storage, 3), [], REJECTER); + + expect(decided).toEqual([a3]); + let invalidated = getAction(storage, 3); + expect(invalidated.state).toBe("rejected"); + expect(invalidated.cascadedFrom).toBe(vetoId); + expect(invalidated.resolvedBy?.id).toBe(REJECTER.id); + }); + + it("marks an action rejected, not approved, when the frontier covers it but the same pass's " + + "veto cascade-invalidates it", async () => { + let storage = makeStorage(); + enableRule(storage); + let a1 = putAction(storage, 1); + let vetoId = putAction(storage, 2, + { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let a3 = putAction(storage, 3, { autoApprovable: false }); // depends on the vetoed action 2 + + // Approving 3 rides veto 2 along; the gatekeeper applies 1, deletes 3 as a cascade of 2. + let { target, calls, results } = makeBatchGatekeeper(); + results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); + let { decided } = await makeDriver(storage, target) + .apply(GK, { action: 3, resolvedBy: APPROVER }); + + expect(calls).toEqual([{ actionId: 3, vetoes: [2] }]); + expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a3]); + expect(getAction(storage, 1).state).toBe("approved"); + let invalidated = getAction(storage, 3); + expect(invalidated.state).toBe("rejected"); + expect(invalidated.cascadedFrom).toBe(vetoId); + expect(invalidated.resolvedBy?.id).toBe(REJECTER.id); + }); + + it("ignores cascades attributed to a veto that was not sent", async () => { + let storage = makeStorage(); + let actionId = putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let { target, results } = makeBatchGatekeeper(); + results.push({ invalidatedByVeto: [{ action: 1, invalidatedBy: 2 }] }); + + let { decided } = await makeDriver(storage, target) + .applyThrough(getAction(storage, 1), [], APPROVER); + + expect(decided).toEqual([actionId]); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2).vetoPending).toBe(true); + }); + + it("ignores invalidations for unknown or already-decided actions", async () => { + let storage = makeStorage(); + putAction(storage, 1, { state: "approved" }); + putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + let { target, results } = makeBatchGatekeeper(); + results.push({ invalidatedByVeto: [ + { action: 1, invalidatedBy: 2 }, // already applied + { action: 99, invalidatedBy: 2 }, // unknown + ]}); + let { decided } = await makeDriver(storage, target) + .applyThrough(getAction(storage, 2), [], REJECTER); + + expect(decided).toEqual([]); + expect(getAction(storage, 1).state).toBe("approved"); + }); + + it("coalesces concurrent approvals into one follow-up pass at the highest frontier", async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { autoApprovable: false }); + putAction(storage, 3, { autoApprovable: false }); + + let calls: Array<{actionId: number, vetoes: number[]}> = []; + let gates: Array<() => void> = []; + let target = { + applyActionsThrough(actionId: number, vetoes: number[]) { + calls.push({ actionId, vetoes }); + return new Promise(resolve => { + gates.push(() => resolve({})); + }); + }, + } as unknown as GatekeeperActionTarget; + let driver = makeDriver(storage, target); + + let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); // parks mid-RPC + await flush(); + let second = driver.apply(GK, { action: 3, resolvedBy: APPROVER }); // staged + let third = driver.apply(GK, { action: 2, resolvedBy: APPROVER }); // merged with second + expect(calls).toEqual([{ actionId: 1, vetoes: [] }]); + + gates.shift()!(); // finish pass 1 + await flush(); + expect(calls).toEqual([{ actionId: 1, vetoes: [] }, { actionId: 3, vetoes: [] }]); + + gates.shift()!(); // finish pass 2 + let [a, b, c] = await Promise.all([first, second, third]); + expect(a.decided).toEqual([10]); + // The coalesced requests share the pass and its decided set. + expect(b.decided.toSorted((x, y) => x - y)).toEqual([20, 30]); + expect(c).toEqual(b); + for (let action of [1, 2, 3]) expect(getAction(storage, action).state).toBe("approved"); + }); + it("runs an explicit batch between the in-flight pass and later staged approvals", async () => { + let storage = makeStorage(); + for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); + let firstCall = Promise.withResolvers(); + let calls: Array<{actionId: number, vetoes: number[]}> = []; + let target = { + async applyActionsThrough(actionId: number, vetoes: number[]) { + calls.push({ actionId, vetoes }); + if (actionId === 1) await firstCall.promise; + return {}; + }, + } as unknown as GatekeeperActionTarget; + let driver = makeDriver(storage, target); + + let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + await flush(); + let batch = driver.applyThrough(getAction(storage, 2), [getAction(storage, 2)], REJECTER); + let later = driver.apply(GK, { action: 3, resolvedBy: APPROVER }); + + firstCall.resolve(); + await Promise.all([first, batch, later]); + + expect(calls).toEqual([ + { actionId: 1, vetoes: [] }, + { actionId: 2, vetoes: [2] }, + { actionId: 3, vetoes: [] }, + ]); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2).state).toBe("rejected"); + expect(getAction(storage, 3).state).toBe("approved"); + }); + + it("revalidates the complete batch after waiting in the decision queue", async () => { + let storage = makeStorage(); + for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); + let firstCall = Promise.withResolvers(); + let calls: Array<{actionId: number, vetoes: number[]}> = []; + let target = { + async applyActionsThrough(actionId: number, vetoes: number[]) { + calls.push({ actionId, vetoes }); + if (actionId === 1) await firstCall.promise; + return {}; + }, + } as unknown as GatekeeperActionTarget; + let driver = makeDriver(storage, target); + + let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + await flush(); + let batch = driver.applyThrough(getAction(storage, 3), [getAction(storage, 2)], REJECTER); + storage.actions.delete(20); + firstCall.resolve(); + + await first; + await expect(batch).rejects.toThrow("No such action: 20"); + expect(calls).toEqual([{ actionId: 1, vetoes: [] }]); + expect(getAction(storage, 3).state).toBe("pending"); + }); + + it("ignores a selected veto the queue's earlier pass approved", async () => { + let storage = makeStorage(); + putAction(storage, 2, { autoApprovable: false }); + putAction(storage, 3, { autoApprovable: false }); + let firstCall = Promise.withResolvers(); + let calls: Array<{actionId: number, vetoes: number[]}> = []; + let target = { + async applyActionsThrough(actionId: number, vetoes: number[]) { + calls.push({ actionId, vetoes }); + if (actionId === 2) await firstCall.promise; + return {}; + }, + } as unknown as GatekeeperActionTarget; + let driver = makeDriver(storage, target); + + let click = driver.apply(GK, { action: 2, resolvedBy: APPROVER }); + await flush(); + let batch = driver.applyThrough(getAction(storage, 3), [getAction(storage, 2)], REJECTER); + firstCall.resolve(); + await Promise.all([click, batch]); + + expect(calls).toEqual([{ actionId: 2, vetoes: [] }, { actionId: 3, vetoes: [] }]); + expect(getAction(storage, 2)).toMatchObject({ state: "approved", resolvedBy: APPROVER }); + expect(getAction(storage, 3)).toMatchObject({ state: "approved", resolvedBy: REJECTER }); + }); + + it("refuses a rejection after an in-flight approval has applied the same action", async () => { + let storage = makeStorage(); + putAction(storage, 1); + let applied = Promise.withResolvers(); + let legacy = makeLegacyGatekeeper(); + legacy.target.applyAction = (async () => applied.promise) as typeof legacy.target.applyAction; + let driver = makeDriver(storage, legacy.target); + + let pass = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + let rejection = expect(driver.reject(getAction(storage, 1), REJECTER)) + .rejects.toThrow("Action is not pending"); + applied.resolve(); + await Promise.all([pass, rejection]); + + expect(getAction(storage, 1).state).toBe("approved"); + expect(legacy.calls).toEqual([]); + }); + + it("holds later approvals until a rejection between passes is acknowledged", async () => { + let storage = makeStorage(); + for (let action of [1, 2, 3]) putAction(storage, action); + let applied = Promise.withResolvers(); + let rejected = Promise.withResolvers(); + let legacy = makeLegacyGatekeeper(); + legacy.target.applyAction = (async (action: number) => { + legacy.calls.push(`apply:${action}`); + if (action === 1) await applied.promise; + }) as typeof legacy.target.applyAction; + legacy.target.rejectAction = (async (action: number) => { + legacy.calls.push(`reject:${action}`); + await rejected.promise; + }) as typeof legacy.target.rejectAction; + let driver = makeDriver(storage, legacy.target); + + let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + let veto = driver.reject(getAction(storage, 2), REJECTER); + let second = driver.apply(GK, { action: 3, resolvedBy: APPROVER }); + applied.resolve(); + await flush(); + expect(legacy.calls).toEqual(["apply:1", "reject:2"]); + expect(getAction(storage, 2).state).toBe("pending"); + expect(getAction(storage, 3).state).toBe("pending"); + + rejected.resolve(); + await Promise.all([first, veto, second]); + expect(legacy.calls).toEqual(["apply:1", "reject:2", "apply:3"]); + expect(getAction(storage, 2).state).toBe("rejected"); + expect(getAction(storage, 3).state).toBe("approved"); + }); + + it("propagates a transport failure to the awaiting caller and recovers on the next sync", + async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + + let { target, results } = makeBatchGatekeeper(); + results.push(new Error("network unreachable")); + let driver = makeDriver(storage, target); + + await expect(driver.apply(GK, { action: 1, resolvedBy: APPROVER })) + .rejects.toThrow("network unreachable"); + expect(getAction(storage, 1).state).toBe("pending"); + + await driver.apply(GK, { action: 1, resolvedBy: APPROVER }); + expect(getAction(storage, 1).state).toBe("approved"); + }); + + it("rejects a cascade-invalidated action that was submitted during the pass", async () => { + let storage = makeStorage(); + let vetoId = putAction(storage, 2, + { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + let { target, results } = makeBatchGatekeeper(); + results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); + let pass = makeDriver(storage, target) + .applyThrough(getAction(storage, 2), [], REJECTER); + let a3 = putAction(storage, 3, { autoApprovable: false }); // arrives while the RPC is in + let { decided } = await pass; // flight, so it misses the snapshot + + expect(decided).toContain(a3); + let invalidated = getAction(storage, 3); + expect(invalidated.state).toBe("rejected"); + expect(invalidated.cascadedFrom).toBe(vetoId); + }); +}); + +describe("ActionSyncDriver legacy fallback", () => { + it("recognizes workerd's real missing-method error", async () => { + let stub = env.TEST_OVERSEER.get(env.TEST_OVERSEER.newUniqueId()); + let call = (stub as any).applyActionsThrough(1, []); + let error: unknown; + try { + await call; + } catch (caught) { + error = caught; + } finally { + call[Symbol.dispose](); + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('does not implement "applyActionsThrough"'); + expect(isMethodMissing(error)).toBe(true); + }); + it("does not replay a coded batch failure whose message resembles method-missing prose", + async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + let { target, results } = makeBatchGatekeeper(); + let failure = createGitPackError(GIT_PACK_ERROR_CODES.builderExpired); + failure.message = 'The RPC receiver does not implement "applyActionsThrough".'; + results.push(failure); + + let caught: unknown; + try { + await makeDriver(storage, target).apply(GK, { action: 1, resolvedBy: APPROVER }); + } catch (error) { + caught = error; + } + + expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.builderExpired); + expect(getAction(storage, 1).state).toBe("pending"); + }); + + it("falls back on workerd's method-missing TypeError, delivering vetoes then applies in " + + "ascending order, and probes only once", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + putAction(storage, 3, { autoApprovable: false }); + + let legacy = makeLegacyGatekeeper(); + let driver = makeDriver(storage, legacy.target); + + await driver.apply(GK, { action: 3, resolvedBy: APPROVER }); + + // Vetoes first (the {restart} return is discarded), then pending actions ascending. + expect(legacy.calls).toEqual(["reject:2", "apply:1", "apply:3"]); + expect(legacy.probeCount()).toBe(1); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + expect(getAction(storage, 3).state).toBe("approved"); + + // The legacy verdict is cached: a later pass goes straight to per-action calls. + putAction(storage, 4, { autoApprovable: false }); + await driver.apply(GK, { action: 4, resolvedBy: APPROVER }); + expect(legacy.probeCount()).toBe(1); + expect(legacy.calls).toEqual(["reject:2", "apply:1", "apply:3", "apply:4"]); + }); + + it("synthesizes {stopped} from the first legacy apply failure, then retries only the suffix", + async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + putAction(storage, 2); + putAction(storage, 3, { autoApprovable: false }); + + let failing = [2]; + let legacy = makeLegacyGatekeeper({ failApply: failing }); + let driver = makeDriver(storage, legacy.target); + await driver.apply(GK, { action: 3, resolvedBy: APPROVER }); + + expect(legacy.calls).toEqual(["apply:1", "apply:2"]); // never skips ahead of the failure + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2)).toMatchObject({ state: "pending", failure: "apply 2 failed" }); + expect(getAction(storage, 3).state).toBe("pending"); + + // The retry re-sends only the undelivered suffix: a replayed legacy applyAction would throw + // on the action that already landed. + failing.length = 0; + await driver.apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(legacy.calls).toEqual(["apply:1", "apply:2", "apply:2"]); + expect(getAction(storage, 2)).toMatchObject({ state: "approved" }); + expect(getAction(storage, 2).failure).toBeUndefined(); + expect(getAction(storage, 3).state).toBe("pending"); + }); + + it("checkpoints legacy vetoes and retries only the undelivered suffix", async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { autoApprovable: false }); + putAction(storage, 3, { autoApprovable: false }); + + let legacy = makeLegacyGatekeeper(); + let failSecond = true; + legacy.target.rejectAction = (async (action: number) => { + legacy.calls.push(`reject:${action}`); + if (action === 2 && failSecond) throw new Error("reject 2 failed"); + }) as typeof legacy.target.rejectAction; + + let firstDriver = makeDriver(storage, legacy.target); + await expect(firstDriver.applyThrough( + getAction(storage, 3), [getAction(storage, 1), getAction(storage, 2)], REJECTER)) + .rejects.toThrow("reject 2 failed"); + + expect(getAction(storage, 1).vetoPending).toBeUndefined(); + expect(getAction(storage, 2).vetoPending).toBe(true); + expect(legacy.calls).toEqual(["reject:1", "reject:2"]); + expect(getAction(storage, 3).state).toBe("pending"); + + failSecond = false; + await makeDriver(storage, legacy.target) + .applyThrough(getAction(storage, 3), [], APPROVER); + + expect(legacy.calls).toEqual(["reject:1", "reject:2", "reject:2", "apply:3"]); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + expect(getAction(storage, 3).state).toBe("approved"); + }); + + it("records each legacy approval before issuing the next external call", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + putAction(storage, 2, { autoApprovable: false }); + + // What action 1's record looks like at the moment each apply is issued: a crash (or an + // outcome-unknown failure) after the first one must not lose it, since a replayed legacy + // applyAction throws on an already-applied action. + let seen: string[] = []; + let legacy = makeLegacyGatekeeper(); + legacy.target.applyAction = async () => { seen.push(getAction(storage, 1).state); }; + await makeDriver(storage, legacy.target).apply(GK, { action: 2, resolvedBy: APPROVER }); + + expect(seen).toEqual(["pending", "approved"]); + expect(getAction(storage, 2).state).toBe("approved"); + }); +}); + +describe("Overseer action decisions", () => { + it("maps workspace IDs to one bounded gatekeeper-local batch", async () => { + let storage = makeStorage(); + let first = putAction(storage, 1, { autoApprovable: false }); + let boundary = putAction(storage, 2, { autoApprovable: false }); + let later = putAction(storage, 3, { autoApprovable: false }); + putAction(storage, 4, { gatekeeperId: GK + 1, autoApprovable: false }); + let batch = makeBatchGatekeeper(); + let client = await makeClient(storage, batch.target); + + await client.applyActionsThrough(boundary, [first]); + + expect(batch.calls).toEqual([{ actionId: 2, vetoes: [1] }]); + expect(getAction(storage, 1)).toMatchObject({ + state: "rejected", resolvedBy: { id: "profile-id" }, + }); + expect(getAction(storage, 1).vetoPending).toBeUndefined(); + expect(getAction(storage, 2)).toMatchObject({ + state: "approved", resolvedBy: { id: "profile-id" }, autoApproved: false, + }); + expect(getAction(storage, 3).state).toBe("pending"); + expect(getAction(storage, 4).state).toBe("pending"); + + await client.applyActionsThrough(later, [later]); + expect(batch.calls).toEqual([ + { actionId: 2, vetoes: [1] }, + { actionId: 3, vetoes: [3] }, + ]); + expect(getAction(storage, 3).state).toBe("rejected"); + expect(getAction(storage, 4).state).toBe("pending"); + }); + it("reports an earlier stop even when the batch boundary is vetoed", async () => { + let storage = makeStorage(); + let first = putAction(storage, 0, { autoApprovable: false }); + let stopped = putAction(storage, 1, { autoApprovable: false }); + let boundary = putAction(storage, 2, { autoApprovable: false }); + let batch = makeBatchGatekeeper(); + batch.results.push({ stopped: { at: 1, reason: new Error("provider refused action one") } }); + let client = await makeClient(storage, batch.target); + + let error = await client.applyActionsThrough(boundary, [boundary]).catch(caught => caught); + + expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.stopped); + expect(batch.calls).toEqual([{ actionId: 2, vetoes: [2] }]); + expect(storage.actions.get(first)).toMatchObject({ state: "approved" }); + expect(storage.actions.get(stopped)).toMatchObject({ + state: "pending", failure: "provider refused action one", + }); + expect(storage.actions.get(boundary)).toMatchObject({ state: "rejected" }); + }); + + it("replays a recorded stop to a client resuming after the action was created", async () => { + let storage = makeStorage(); + let boundary = putAction(storage, 1, + { autoApprovable: false, createdAt: new Date(FIXTURE_EPOCH) }); + let batch = makeBatchGatekeeper(); + batch.results.push({ stopped: { at: 1, reason: new Error("page was deleted upstream") } }); + let client = await makeClient(storage, batch.target); + + await expect(client.applyActionsThrough(boundary, [])).rejects.toThrow(); + + // Cutoff after creation, before the stop: only the mutation's own stamp carries the record + // into the resume sweep, so a reconnecting client still learns why it wasn't applied. + let entries: ActionLogEntry[] = []; + let { subscriber } = makeSubscriber(async record => { entries.push(record); }); + using _sub = await client.subscribeToActions(subscriber, new Date(FIXTURE_EPOCH + 1)); + + expect(entries).toMatchObject([{ id: boundary, failure: "page was deleted upstream" }]); + }); + + it("refuses a clicked action the pass cascade-invalidated", async () => { + let storage = makeStorage(); + let vetoId = putAction(storage, 1, + { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + let clicked = putAction(storage, 2, { autoApprovable: false }); + let batch = makeBatchGatekeeper(); + batch.results.push({ invalidatedByVeto: [{ action: 2, invalidatedBy: 1 }] }); + let client = await makeClient(storage, batch.target); + + await expect(client.approveAction(clicked)) + .rejects.toThrow(`Action was invalidated by a rejected earlier action: ${clicked}`); + + let entry = (await client.listActions()).entries.find(candidate => candidate.id === clicked); + expect(entry).toMatchObject({ state: "rejected", cascadedFrom: vetoId }); + }); + + it("denies batch mutation to use-only sessions", async () => { + let storage = makeStorage(); + let boundary = putAction(storage, 1, { autoApprovable: false }); + let client = await openFakeOverseer(storage, { role: "use" }); + + await expect(client.applyActionsThrough(boundary, [])) + .rejects.toThrow("Unauthorized: this collaborator only has permission to use the gadget's UI."); + expect(getAction(storage, 1).state).toBe("pending"); + }); + + it("rejects invalid batch selections before mutation or RPC", async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + let boundary = putAction(storage, 2, { autoApprovable: false }); + let later = putAction(storage, 3, { autoApprovable: false }); + let foreign = putAction(storage, 4, { gatekeeperId: GK + 1, autoApprovable: false }); + putStoredAction(storage, 50, { type: "observation" }); + let batch = makeBatchGatekeeper(); + let client = await makeClient(storage, batch.target); + + await expect(client.applyActionsThrough(999, [])).rejects.toThrow("No such action: 999"); + await expect(client.applyActionsThrough(50, [])).rejects.toThrow("Not an action: 50"); + await expect(client.applyActionsThrough(boundary, [foreign])) + .rejects.toThrow("Action batch contains a different connection."); + await expect(client.applyActionsThrough(boundary, [later])) + .rejects.toThrow("Veto is beyond the action batch boundary."); + + expect(batch.calls).toEqual([]); + for (let action of [1, 2, 3, 4]) expect(getAction(storage, action).state).toBe("pending"); + }); + + it("keeps exact approval blocked by an earlier undecided action", async () => { + let storage = makeStorage(); + putAction(storage, 1, { autoApprovable: false }); + let boundary = putAction(storage, 2, { autoApprovable: false }); + let batch = makeBatchGatekeeper(); + let client = await makeClient(storage, batch.target); + + let error = await client.approveAction(boundary).catch(caught => caught); + + expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.blocked); + expect(batch.calls).toEqual([]); + expect(getAction(storage, 1).state).toBe("pending"); + expect(getAction(storage, 2).state).toBe("pending"); + }); + + // Chat 7 suspended but its storage fails; 8 suspended, with an unsuspended sibling in-turn; + // 9 suspended but its awaited action was rejected; 10 never suspended; 11 predates the flag + // and resumes on the rule that used to set it. + it("resumes only chats whose turn suspended, and one failed resume doesn't strand the rest", + async () => { + let storage = makeStorage(); + let a1 = putAction(storage, 1, { chatId: 7, awaitDecision: true, suspendedTurn: true }); + let a2 = putAction(storage, 2, { chatId: 8, awaitDecision: true, suspendedTurn: true }); + let a3 = putAction(storage, 3, { chatId: 9, awaitDecision: true, suspendedTurn: true }); + let a4 = putAction(storage, 4, + { chatId: 10, awaitDecision: true, suspendedTurn: false }); + let a5 = putAction(storage, 5, + { chatId: 8, awaitDecision: true, suspendedTurn: false, state: "rejected" }); + let a6 = putAction(storage, 6, { chatId: 11, awaitDecision: true }); + + let notes = vi.fn(); + let listedChats: string[] = []; + let client = await openFakeOverseer({ + ...storage, + chats: { + list: ({ prefix }: { prefix: string }) => { + listedChats.push(prefix); + if (prefix === `${keyString(7)}.`) throw new Error("chat storage unavailable"); + // Chat 8's turn also holds an awaitDecision action that never suspended it. + if (prefix === `${keyString(8)}.`) { + return [a2, a5].map(actionId => ({ type: "action", actionId })); + } + let actionId = prefix === `${keyString(9)}.` ? a3 + : prefix === `${keyString(11)}.` ? a6 : a4; + return [{ type: "action", actionId }]; + }, + }, + }, { + impl: { + addChatMessages: notes, + waitForChatMessagePreparation: () => undefined, + applyActionBatch: async () => { + for (let action of [1, 2, 3, 4, 6]) { + let record = getAction(storage, action); + record.state = action === 3 ? "rejected" : "approved"; + storage.actions.put(record); + } + return { decided: [a1, a2, a3, a4, a6] }; + }, + }, + }); + + await client.applyActionsThrough(a4, []); + + expect(notes.mock.calls).toEqual([ + [8, expect.anything(), [expect.objectContaining({ + type: "message", message: expect.stringContaining("Action 2"), + })]], + [11, expect.anything(), [expect.objectContaining({ + type: "message", message: expect.stringContaining("Action 6"), + })]], + ]); + expect(listedChats).toEqual([ + `${keyString(7)}.`, `${keyString(8)}.`, `${keyString(9)}.`, `${keyString(11)}.`, + ]); + }); + + it("awaits legacy rejection of a later action without applying other pending actions", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1, { autoApprovable: false }); + let id = putAction(storage, 2); + putAction(storage, 3); + let rejected = Promise.withResolvers(); + let legacy = makeLegacyGatekeeper(); + legacy.target.rejectAction = (async (action: number) => { + legacy.calls.push(`reject:${action}`); + await rejected.promise; + }) as typeof legacy.target.rejectAction; + let client = await makeClient(storage, legacy.target); + + let settled = false; + let decision = client.rejectAction(id).then(() => { settled = true; }); + await flush(); + expect(legacy.calls).toEqual(["reject:2"]); + expect(settled).toBe(false); + expect(getAction(storage, 2).state).toBe("pending"); + + rejected.resolve(); + await decision; + expect(getAction(storage, 2)).toMatchObject({ + state: "rejected", resolvedBy: { id: "profile-id" }, + }); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + expect(getAction(storage, 1).state).toBe("pending"); + expect(getAction(storage, 3).state).toBe("pending"); + }); + + it("leaves a failed rejection pending so the user can retry it", async () => { + let storage = makeStorage(); + let id = putAction(storage, 1); + let legacy = makeLegacyGatekeeper(); + let reject = vi.fn().mockRejectedValueOnce(new Error("temporary RPC failure")) + .mockResolvedValue(undefined); + legacy.target.rejectAction = reject; + let client = await makeClient(storage, legacy.target); + + await expect(client.rejectAction(id)).rejects.toThrow("temporary RPC failure"); + expect(getAction(storage, 1).state).toBe("pending"); + expect(getAction(storage, 1).appliedAt).toBeUndefined(); + await client.rejectAction(id); + expect(getAction(storage, 1).state).toBe("rejected"); + expect(legacy.calls).toEqual([]); + }); + + it("keeps immediate rejection on the legacy rejectAction endpoint", async () => { + let storage = makeStorage(); + putAction(storage, 0); + let id = putAction(storage, 1); + putAction(storage, 2); + let batch = makeBatchGatekeeper(); + let calls: number[] = []; + let reject = vi.fn(async (action: number) => { + calls.push(action); + if (calls.length === 1) throw new Error("temporary RPC failure"); + }); + batch.target.rejectAction = reject as typeof batch.target.rejectAction; + let client = await makeClient(storage, batch.target); + + await expect(client.rejectAction(id)).rejects.toThrow("temporary RPC failure"); + expect(getAction(storage, 1).state).toBe("pending"); + await client.rejectAction(id); + + expect(calls).toEqual([1, 1]); + expect(batch.calls).toEqual([]); + expect(getAction(storage, 0).state).toBe("pending"); + expect(getAction(storage, 1)).toMatchObject({ + state: "rejected", resolvedBy: { id: "profile-id" }, + }); + expect(getAction(storage, 2).state).toBe("pending"); + }); + + it("distinguishes a blocked approval from an application with a recorded failure", async () => { + let storage = makeStorage(); + let first = putAction(storage, 1); + let second = putAction(storage, 2); + let legacy = makeLegacyGatekeeper({ failApply: [1] }); + let client = await makeClient(storage, legacy.target); + + let blocked = await client.approveAction(second).catch(error => error); + expect(getActionErrorCode(blocked)).toBe(ACTION_ERROR_CODES.blocked); + let stopped = await client.approveAction(first).catch(error => error); + expect(getActionErrorCode(stopped)).toBe(ACTION_ERROR_CODES.stopped); + expect(getAction(storage, 1)).toMatchObject({ state: "pending", failure: "apply 1 failed" }); + expect(getAction(storage, 2).state).toBe("pending"); + }); + + it("reports a stop at an earlier rule-authorized action on the clicked one", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); + let clicked = putAction(storage, 2, { autoApprovable: false }); + let client = await makeClient(storage, makeLegacyGatekeeper({ failApply: [1] }).target); + + let error = await client.approveAction(clicked).catch(caught => caught); + + expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.stopped); + expect(getAction(storage, 1)).toMatchObject({ state: "pending", failure: "apply 1 failed" }); + expect(getAction(storage, 2).state).toBe("pending"); + // The reason lives on the action that stopped, so the clicked one carries none of its own. + expect(getAction(storage, 2).failure).toBeUndefined(); + }); + + it("keeps the failure on an action rejected after a failed apply", async () => { + let storage = makeStorage(); + let id = putAction(storage, 1, { failure: "page was deleted upstream" }); + let client = await makeClient(storage, makeLegacyGatekeeper().target); + + await client.rejectAction(id); + + let record = getAction(storage, 1); + expect(record.state).toBe("rejected"); + expect(record.vetoPending).toBeUndefined(); + expect(record.failure).toBe("page was deleted upstream"); + + // And it survives the mapping to the client API, which is where the user meets it. + let entry = (await client.listActions()).entries.find(candidate => candidate.id === id); + expect(entry?.type === "action" && entry.failure).toBe("page was deleted upstream"); + }); +}); diff --git a/packages/workshop-backend/__tests__/auto-approval.test.ts b/packages/workshop-backend/__tests__/auto-approval.test.ts deleted file mode 100644 index a2e0a42f23..0000000000 --- a/packages/workshop-backend/__tests__/auto-approval.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { AutoApprovalDrainer, AutoApprovalStorage, ApplyPendingActionFn } - from "../src/auto-approval.js"; -import type { ActionRecord } from "../src/overseer.js"; -import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api"; -import { makeMockStorage } from "./mock-storage.js"; -import { makeActionStorage, makePreIndexActionStorage, putAction } from "./fixtures.js"; - -const makeStorage = makeActionStorage; - -const GK = 1; -const ENABLER: AiChatAuthorInfo = { type: "user", id: "enabler@example.com", name: "Enabler" }; - -function enableRule(storage: AutoApprovalStorage, actionTag = "edit", gatekeeperId = GK) { - storage.autoApproveTags.put({ - gatekeeperId, actionKind: { tag: actionTag, label: "Edits" }, enabledBy: ENABLER }); -} - -function getAction(storage: AutoApprovalStorage, id: number): ActionRecord & {type: "action"} { - let record = storage.actions.get(id); - if (!record || record.type !== "action") throw new Error(`No action ${id}`); - return record; -} - -// An apply fn that resolves immediately, mirroring OverseerImpl.applyPendingAction's effect: -// mark the record approved and persist. Records the order of applied action ids. -function makeImmediateApply(storage: AutoApprovalStorage) { - let calls: number[] = []; - let applyFn: ApplyPendingActionFn = async (record, resolvedBy, autoApproved) => { - calls.push(record.id); - let fresh = storage.actions.get(record.id); - if (fresh && fresh.type === "action") { - fresh.state = "approved"; - fresh.appliedAt = new Date(); - fresh.resolvedBy = resolvedBy; - fresh.autoApproved = autoApproved; - storage.actions.put(fresh); - } - }; - return { applyFn, calls }; -} - -// An apply fn whose every invocation parks on a test-held promise until released. Lets a test hold -// an apply mid-flight (input gate open) while launching a second concurrent drain. On release it -// performs the same approve+persist effect as the real apply. -function makeControlledApply(storage: AutoApprovalStorage) { - let calls: number[] = []; - let gates: Array<() => void> = []; - let applyFn: ApplyPendingActionFn = (record, resolvedBy, autoApproved) => { - calls.push(record.id); - return new Promise((resolve) => { - gates.push(() => { - let fresh = storage.actions.get(record.id); - if (fresh && fresh.type === "action") { - fresh.state = "approved"; - fresh.appliedAt = new Date(); - fresh.resolvedBy = resolvedBy; - fresh.autoApproved = autoApproved; - storage.actions.put(fresh); - } - resolve(); - }); - }); - }; - return { - applyFn, - calls, - inFlight: () => gates.length, - releaseNext() { - let gate = gates.shift(); - if (!gate) throw new Error("no apply in flight to release"); - gate(); - }, - }; -} - -// Drain all microtasks (and the macrotask queue) so suspended drain continuations run to their next -// park point. -function flush(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -describe("AutoApprovalDrainer.drain", () => { - it("applies all eligible pending actions in ascending id order", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - putAction(storage, 2); - putAction(storage, 3); - - let { applyFn, calls } = makeImmediateApply(storage); - await new AutoApprovalDrainer(storage, applyFn).drain(GK); - - expect(calls).toEqual([1, 2, 3]); - for (let id of [1, 2, 3]) { - let record = getAction(storage, id); - expect(record.state).toBe("approved"); - expect(record.autoApproved).toBe(true); - expect(record.resolvedBy?.id).toBe(ENABLER.id); - } - }); - - it("stops at a manual gate without skipping ahead, then resumes once it clears", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - putAction(storage, 2, { autoApprovable: false }); // manual gate - putAction(storage, 3); - - let { applyFn, calls } = makeImmediateApply(storage); - let drainer = new AutoApprovalDrainer(storage, applyFn); - await drainer.drain(GK); - - // Only the action before the gate is applied; the gate and everything behind it stay pending. - expect(calls).toEqual([1]); - expect(getAction(storage, 2).state).toBe("pending"); - expect(getAction(storage, 3).state).toBe("pending"); - - // Clear the gate (as a manual approval would) and re-drain: the rest applies, still in order. - let gate = getAction(storage, 2); - gate.state = "approved"; - storage.actions.put(gate); - await drainer.drain(GK); - - expect(calls).toEqual([1, 3]); - expect(getAction(storage, 3).state).toBe("approved"); - }); - - // Two concurrent drains for the same gatekeeper must not double-apply. The input gate is open - // across the apply await, so without the single-flight guard the second drain's pending re-check - // would see the still-"pending" record and apply it again. - it("never applies an action more than once under concurrent drains", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - - let apply = makeControlledApply(storage); - let drainer = new AutoApprovalDrainer(storage, apply.applyFn); - - let first = drainer.drain(GK); // starts, calls apply(1), parks mid-apply - let second = drainer.drain(GK); // must coalesce, not start a second apply - await second; - - expect(apply.calls).toEqual([1]); - expect(apply.inFlight()).toBe(1); - - apply.releaseNext(); // resolve apply(1); record becomes approved - await first; // rerun pass re-lists: action 1 no longer pending -> no re-apply - - expect(apply.calls).toEqual([1]); - expect(getAction(storage, 1).state).toBe("approved"); - }); - - // Work that arrives while a drain is parked must still be applied -- the coalescing - // "rerun" flag must not drop the wakeup. - it("applies work submitted while a drain is parked mid-apply", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - - let apply = makeControlledApply(storage); - let drainer = new AutoApprovalDrainer(storage, apply.applyFn); - - let first = drainer.drain(GK); // parks mid-apply on action 1 - - putAction(storage, 2); // new eligible action arrives mid-drain - let second = drainer.drain(GK); // coalesces -> sets the rerun flag - await second; - expect(apply.calls).toEqual([1]); - - apply.releaseNext(); // finish action 1; rerun pass should pick up action 2 - await flush(); - - expect(apply.calls).toEqual([1, 2]); - expect(apply.inFlight()).toBe(1); - - apply.releaseNext(); // finish action 2 - await first; - - expect(apply.calls).toEqual([1, 2]); - expect(getAction(storage, 1).state).toBe("approved"); - expect(getAction(storage, 2).state).toBe("approved"); - }); - - it("drains a large log, applying eligible actions in ascending order", async () => { - let storage = makeStorage(); - enableRule(storage); - let eligible: number[] = []; - for (let id = 0; id < 230; id++) { - if (id % 5 === 0) { - putAction(storage, id, { gatekeeperId: GK + 1 }); // other gatekeeper: skipped, not a gate - } else if (id % 5 === 1) { - putAction(storage, id, { state: "approved" }); // already resolved - } else { - putAction(storage, id); - eligible.push(id); - } - } - - let { applyFn, calls } = makeImmediateApply(storage); - await new AutoApprovalDrainer(storage, applyFn).drain(GK); - - expect(calls).toEqual(eligible); - }); - - it("halts at a manual gate deep in the log", async () => { - let storage = makeStorage(); - enableRule(storage); - let gateId = 105; - for (let id = 0; id < 120; id++) { - putAction(storage, id, { autoApprovable: id !== gateId }); - } - - let { applyFn, calls } = makeImmediateApply(storage); - await new AutoApprovalDrainer(storage, applyFn).drain(GK); - - expect(calls).toEqual(Array.from({ length: gateId }, (_, i) => i)); - expect(getAction(storage, gateId).state).toBe("pending"); - expect(getAction(storage, gateId + 1).state).toBe("pending"); - }); - - it("drains pendings written before the index existed once a rebuild backfills it", async () => { - // Mirrors the version-3 migration: the records predate the action-index declarations. - let mock = makeMockStorage(); - let legacy = makePreIndexActionStorage(mock); - putAction(legacy, 1); - putAction(legacy, 2, { state: "approved" }); - putAction(legacy, 3); - - let storage = makeStorage(mock); - storage.actions.pendingByGatekeeper.rebuild(); - storage.actions.byHistoryFilter.rebuild(); - storage.actions.byLastChanged.rebuild(); - enableRule(storage); - - // The apply persists a resolved state, which must not throw on the backfilled index. - let { applyFn, calls } = makeImmediateApply(storage); - await new AutoApprovalDrainer(storage, applyFn).drain(GK); - - expect(calls).toEqual([1, 3]); - expect(getAction(storage, 1).state).toBe("approved"); - expect(getAction(storage, 3).state).toBe("approved"); - }); - - it("halts when an apply fails, leaving it and everything after pending", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - putAction(storage, 2); - putAction(storage, 3); - - let inner = makeImmediateApply(storage); - let applyFn: ApplyPendingActionFn = (record, resolvedBy, autoApproved) => { - if (record.id === 2) throw new Error("apply failed"); - return inner.applyFn(record, resolvedBy, autoApproved); - }; - await new AutoApprovalDrainer(storage, applyFn).drain(GK); - - expect(inner.calls).toEqual([1]); - expect(getAction(storage, 2).state).toBe("pending"); - expect(getAction(storage, 3).state).toBe("pending"); - }); - - // An action created after a drain snapshotted the pending index is out of that drain's scope; - // the creation path is responsible for its own drain() call (which the rerun flag folds in -- - // see the parked-mid-apply test above). - it("leaves actions created after the drain's snapshot for their own drain call", async () => { - let storage = makeStorage(); - enableRule(storage); - putAction(storage, 1); - - let apply = makeControlledApply(storage); - let drainer = new AutoApprovalDrainer(storage, apply.applyFn); - let first = drainer.drain(GK); // snapshots pending = [1] - - putAction(storage, 2); // arrives mid-drain, with no accompanying drain() call - apply.releaseNext(); - await first; - - expect(apply.calls).toEqual([1]); - expect(getAction(storage, 2).state).toBe("pending"); - }); -}); diff --git a/packages/workshop-backend/__tests__/fixtures.ts b/packages/workshop-backend/__tests__/fixtures.ts index 21b9b06910..a90abffcda 100644 --- a/packages/workshop-backend/__tests__/fixtures.ts +++ b/packages/workshop-backend/__tests__/fixtures.ts @@ -1,19 +1,20 @@ // Shared fixtures for the action-log test suites: the production overseer storage over a mock, -// a putAction record factory, and a fake overseer client forged over +// a putAction record factory, an ActionsSubscriber stub, and a fake overseer client forged over // OverseerDurableObject.prototype.open. import { RpcStub as NativeRpcStub } from "cloudflare:workers"; +import type { RpcStub } from "capnweb"; import { createTypedStorage, collection } from "@gadgets/typed-storage"; import type { Collection, Singleton } from "@gadgets/typed-storage"; -import type { Overseer } from "@gadgets/workshop-shared/api"; +import type { ActionLogEntry, ActionsSubscriber, Overseer } from "@gadgets/workshop-shared/api"; import { OverseerDurableObject, makeOverseerStorage } from "../src/overseer.js"; +import { createWorkshopLogger } from "../src/observability.js"; import type { ActionRecord } from "../src/overseer.js"; import { makeMockStorage } from "./mock-storage.js"; /** - * The production schema over mock storage, so the action suites (auto-approval drain, pending - * history query) exercise the shipped actions collection and pendingByGatekeeper index rather - * than a copy. + * The production schema over mock storage, so action-sync and history-query tests exercise the + * shipped actions collection and its indexes rather than a copy. */ export function makeActionStorage(mockStorage = makeMockStorage()) { return makeOverseerStorage(mockStorage); @@ -35,6 +36,22 @@ export function makePreIndexActionStorage(mockStorage: DurableObjectStorage) { /** Base timestamp for fixture records: putAction stamps createdAt = FIXTURE_EPOCH + id. */ export const FIXTURE_EPOCH = 1700000000000; +/** + * Hand-rolled ActionsSubscriber stub. `events` interleaves entry ids with "ready", so tests can + * assert both content and ordering of the delivered stream. + */ +export function makeSubscriber(entry?: (record: ActionLogEntry) => Promise) { + let events: Array = []; + let subscriber = { + entry: entry ?? (async (record: ActionLogEntry) => { events.push(record.id); }), + ready: async () => { events.push("ready"); }, + dup: () => subscriber, + onRpcBroken: () => {}, + [Symbol.dispose]: () => {}, + }; + return { subscriber: subscriber as unknown as RpcStub, events }; +} + /** Puts a record and keeps nextActionId ahead of it, as the real allocator does. */ export function putAction( storage: { actions: Collection, nextActionId: Singleton }, @@ -82,6 +99,7 @@ export async function openFakeOverseer( impl: { ownerId, assertGatekeeperUsable: () => {}, + logger: createWorkshopLogger("test"), ensureAmbientCapsules: async () => {}, markOutputsDirty: () => {}, joinSession: () => () => {}, @@ -89,12 +107,17 @@ export async function openFakeOverseer( joinOutputsFanout: () => () => {}, ensureObserver: async () => {}, syncOutputsTo: async () => {}, + gitCache: { clearPushMarks: () => {} }, // What open() consults for a non-owner's role: the permission-graph lookup and observer // verification in one. The sharing manager is still reached, but only to redeem a share key, // which these tests never pass. authorizeCollaborator: async () => role, getSharingManager: async () => ({}), - ctx: { id: { toString: () => "workspace-id" }, exports: opts.exports ?? {} }, + ctx: { + id: { toString: () => "workspace-id" }, + exports: opts.exports ?? {}, + waitUntil: () => {}, + }, users: { idFromString: (id: string) => id, get: () => ({ @@ -102,6 +125,7 @@ export async function openFakeOverseer( recordSharedGadgetOpen: async () => {}, }), }, + applyDecidedActions: async () => [] as number[], storage: Object.assign(storage, { containsRestrictedData: { get: () => false }, title: { get: () => "Test Workspace" }, diff --git a/packages/workshop-backend/__tests__/git-push-actions.test.ts b/packages/workshop-backend/__tests__/git-push-actions.test.ts index 732d5fcaf5..4750921a3b 100644 --- a/packages/workshop-backend/__tests__/git-push-actions.test.ts +++ b/packages/workshop-backend/__tests__/git-push-actions.test.ts @@ -1,22 +1,27 @@ -// Exercises the push-authorization wiring in the Overseer itself -- submitAction's ancestry -// verification + marking walk, applyPendingAction's action-scoped GitCache stub and mark -// conversion, and removeGatekeeper's queued-push cleanup -- over real SQLite DO storage. The -// WorkspaceGitCache semantics themselves are covered by git-cache.test.ts on mock storage; this -// file covers the overseer-side chokepoints those semantics hang off of. -// -// This lives in __tests__/ (the unit workerd config): the TEST_OVERSEER binding exists only in -// vitest.config.ts, and the tests reach into impl storage/methods directly -- the same pattern -// as git-migration-do.test.ts. The gatekeeper facet is stubbed by overriding -// impl.getGatekeeperFacet on the instance, since a real Gatekeeper DO class cannot be minted -// from a test. +// Exercises push authorization through real Overseer SQLite storage: queue-time ancestry and +// marking, legacy action-scoped GitCache application, batch GitPackBuilder callbacks, completion +// reconciliation, and gatekeeper-removal cleanup. Git cache algorithms remain covered by +// git-cache.test.ts; this suite covers the Overseer lifecycle and native-RPC boundaries they use. +// Tests reach the implementation through TEST_OVERSEER, matching git-migration-do.test.ts; only +// the retained legacy fallback case overrides getGatekeeperFacet. import { describe, expect, it } from "vitest"; import { env } from "cloudflare:workers"; import { runInDurableObject } from "cloudflare:test"; -import type { OverseerDurableObject } from "../src/overseer.js"; -import type { ActionDescription } from "@gadgets/workshop-shared/gatekeeper"; +import type { GatekeeperActionRecord, OverseerDurableObject } from "../src/overseer.js"; +import type { + ActionDescription, + GitObjectType, + GitPackErrorCode, +} from "@gadgets/workshop-shared/gatekeeper"; +import { + GIT_PACK_ERROR_CODES, + getGitPackErrorCode, +} from "@gadgets/workshop-shared/gatekeeper"; import { concatBytes, decodePackBytes, encodeLooseObject, gitObjectOid } from "../src/git-codec"; +import { GitPackBuilderImpl } from "../src/git-cache.js"; +import type { GitObjectMetadataRecord } from "../src/git-cache.js"; declare module "cloudflare:workers" { interface ProvidedEnv { @@ -46,19 +51,23 @@ function commitPayload(tree: string, parents: string[], message: string): Uint8A return new TextEncoder().encode(text); } -async function storeLocal(impl: any, type: string, payload: Uint8Array): Promise { - let oid = await gitObjectOid(type as any, payload); - impl.storage.gitObjects.put({ oid, data: encodeLooseObject(type as any, payload) }); +async function storeLocal( + impl: any, type: GitObjectType, payload: Uint8Array): Promise { + let oid = await gitObjectOid(type, payload); + impl.storage.gitObjects.put({ oid, data: encodeLooseObject(type, payload) }); return oid; } // Seeds the standard scenario: the gatekeeper has proven a base commit (empty tree), and a // locally-authored commit sits on top of it. Returns both oids. -async function seedPushableHistory(impl: any): Promise<{ base: string, head: string }> { - let treeOid = await impl.gitCache.putFromGatekeeper(GATEKEEPER, "tree", new Uint8Array(0)); +async function seedPushableHistory( + impl: any, gatekeeperId = GATEKEEPER, suffix = ""): Promise<{ base: string, head: string }> { + let treeOid = await impl.gitCache.putFromGatekeeper( + gatekeeperId, "tree", new Uint8Array(0)); let base = await impl.gitCache.putFromGatekeeper( - GATEKEEPER, "commit", commitPayload(treeOid, [], "base")); - let head = await storeLocal(impl, "commit", commitPayload(treeOid, [base], "local work")); + gatekeeperId, "commit", commitPayload(treeOid, [], `base${suffix}`)); + let head = await storeLocal( + impl, "commit", commitPayload(treeOid, [base], `local work${suffix}`)); return { base, head }; } @@ -72,8 +81,8 @@ function pushDescription(heads: string[]): ActionDescription { } function marksOf(impl: any, actionId: number): string[] { - return Array.from(impl.storage.gitObjectMetadata.byPendingPushAction.get(actionId)) - .map((record: any) => record.oid); + const records = Array.from(impl.storage.gitObjectMetadata.byPendingPushAction.get(actionId)) as GitObjectMetadataRecord[]; + return records.map(record => record.oid); } async function collect(stream: ReadableStream): Promise { @@ -82,13 +91,45 @@ async function collect(stream: ReadableStream): Promise for (;;) { let { done, value } = await reader.read(); if (done) break; + chunks.push(value); } return concatBytes(chunks); } +function installPackReceiver( + impl: any, gatekeeperId: number, + props: { packAction?: number; readOid?: string; stoppedAt?: number; throwAfterBuild?: boolean } = {}) { + impl.storage.gatekeepers.put({ + id: gatekeeperId, + resourceTitle: "Test Git pack receiver", + class: impl.ctx.exports.TestGitPackGatekeeper({ props }), + }); + return impl.getGatekeeperFacet(gatekeeperId); +} + +function actionRecord(impl: any, gatekeeperId: number, localAction: number) + : GatekeeperActionRecord { + const record = (Array.from(impl.storage.actions.list()) as GatekeeperActionRecord[]) + .find(candidate => candidate.gatekeeperId === gatekeeperId && + candidate.action === localAction); + if (record === undefined) throw new Error(`No action ${localAction}`); + return record; +} + +async function expectGitPackCode( + operation: () => Promise, expected: GitPackErrorCode): Promise { + let caught: unknown; + try { + await operation(); + } catch (error) { + caught = error; + } + expect(getGitPackErrorCode(caught)).toBe(expected); +} + describe("push authorization through the Overseer chokepoints", () => { - it("verifies, marks, applies with an action-scoped cache, and converts marks", async () => { + it("verifies, marks, applies through the legacy cache fallback, and converts marks", async () => { await inOverseer("push-apply", async impl => { let { base, head } = await seedPushableHistory(impl); @@ -103,13 +144,17 @@ describe("push authorization through the Overseer chokepoints", () => { // gatekeeper would: reads a pending commit (simulation view) and builds the pack. let sawPack: Uint8Array | undefined; impl.getGatekeeperFacet = () => ({ + async applyActionsThrough() { + throw new TypeError( + 'The RPC receiver does not implement the method "applyActionsThrough".'); + }, async applyAction(action: number, cache: any) { expect(action).toBe(1); expect((await cache.get(head))!.type).toBe("commit"); sawPack = await collect(await cache.buildPack()); }, }); - await impl.applyPendingAction(record, USER, false); + await impl.applyDecidedActions(GATEKEEPER, { action: 1, resolvedBy: USER }); expect((await decodePackBytes(sawPack!, { maxObjectSize: 1 << 20 }))).toHaveLength(1); expect(impl.storage.actions.get(record.id)!.state).toBe("approved"); @@ -137,21 +182,337 @@ describe("push authorization through the Overseer chokepoints", () => { }); }); - it("cleans a queued push's marks when its gatekeeper is removed", async () => { + it("cleans queued and staged-veto push marks when its gatekeeper is removed", async () => { await inOverseer("push-gatekeeper-removed", async impl => { let { head } = await seedPushableHistory(impl); await impl.submitAction(GATEKEEPER, 1, pushDescription([head]), { from: "user" }); - let record = Array.from(impl.storage.actions.list()) - .find((a: any) => a.type === "action") as any; - expect(marksOf(impl, record.id)).toStrictEqual([head]); + await impl.submitAction(GATEKEEPER, 2, pushDescription([head]), { from: "user" }); + let queued = actionRecord(impl, GATEKEEPER, 1); + // A veto the gatekeeper never acknowledged keeps its simulation read grant until then. + let staged = actionRecord(impl, GATEKEEPER, 2); + impl.storage.actions.put({ ...staged, state: "rejected", vetoPending: true }); + expect(marksOf(impl, queued.id)).toStrictEqual([head]); + expect(marksOf(impl, staged.id)).toStrictEqual([head]); impl.removeGatekeeper(GATEKEEPER); - expect(marksOf(impl, record.id)).toStrictEqual([]); + expect(marksOf(impl, queued.id)).toStrictEqual([]); + expect(marksOf(impl, staged.id)).toStrictEqual([]); expect(impl.storage.gitObjectMetadata.get(head)?.pendingPush ?? []).toStrictEqual([]); // Proof-grade provenance is kept: the base commit's onRemote row survives removal. }); }); + it("builds a bounded explicit prefix with translated veto IDs", async () => { + await inOverseer("batch-pack-frontier", async impl => { + impl.storage.nextActionId.put(Math.max(1000, impl.storage.nextActionId.get())); + const { head } = await seedPushableHistory(impl); + const receiver = installPackReceiver(impl, GATEKEEPER, { packAction: 41, readOid: head }); + + try { + await impl.submitAction(GATEKEEPER, 41, pushDescription([head]), { from: "user" }); + await impl.submitAction(GATEKEEPER, 52, { + title: "Skip notification", + description: "Would notify the release channel.", + implementsRevert: true, + }, { from: "user" }); + await impl.submitAction(GATEKEEPER, 99, { + title: "Publish release notes", + description: "Publishes the release notes.", + implementsRevert: true, + }, { from: "user" }); + await impl.submitAction(GATEKEEPER, 123, { + title: "Later cleanup", + description: "Runs after the release boundary.", + implementsRevert: true, + }, { from: "user" }); + + const push = actionRecord(impl, GATEKEEPER, 41); + const veto = actionRecord(impl, GATEKEEPER, 52); + const boundary = actionRecord(impl, GATEKEEPER, 99); + const laterVeto = actionRecord(impl, GATEKEEPER, 123); + expect([push.id, veto.id, boundary.id, laterVeto.id].every(id => id >= 1000)).toBe(true); + + laterVeto.state = "rejected"; + laterVeto.vetoPending = true; + laterVeto.resolvedBy = USER; + laterVeto.appliedAt = new Date(); + impl.storage.actions.put(laterVeto); + + await impl.applyActionBatch(boundary, [veto], USER); + + expect(await receiver.receivedBatch()).toStrictEqual({ actionId: 99, vetoes: [52] }); + const cached = await receiver.cachedObject(); + expect(await gitObjectOid(cached.type, cached.content)).toBe(head); + const objects = await decodePackBytes(await receiver.capturedPack(), { + maxObjectSize: 1 << 20, + }); + expect(await Promise.all(objects.map(object => gitObjectOid(object.type, object.payload)))) + .toStrictEqual([head]); + expect(actionRecord(impl, GATEKEEPER, 41).state).toBe("approved"); + expect(actionRecord(impl, GATEKEEPER, 52).state).toBe("rejected"); + expect(actionRecord(impl, GATEKEEPER, 99).state).toBe("approved"); + expect(actionRecord(impl, GATEKEEPER, 123)).toMatchObject({ + state: "rejected", vetoPending: true, + }); + expect(marksOf(impl, push.id)).toStrictEqual([]); + expect(impl.storage.gitObjectMetadata.get(head)!.onRemote).toContain(GATEKEEPER); + expect(impl.storage.gitObjectMetadata.get(head)!.pendingPush) + .not.toContainEqual(expect.objectContaining({ actionId: push.id })); + await expectGitPackCode( + () => receiver.buildRetained(41), GIT_PACK_ERROR_CODES.builderExpired); + } finally { + await receiver.releaseRetained(); + } + }); + }); + + it("supplies a builder that refuses non-push actions", async () => { + await inOverseer("batch-non-push-builder", async impl => { + const receiver = installPackReceiver(impl, GATEKEEPER, { packAction: 1 }); + try { + await impl.submitAction(GATEKEEPER, 1, { + title: "Publish release notes", + description: "Publishes the release notes.", + implementsRevert: true, + }, { from: "user" }); + const action = actionRecord(impl, GATEKEEPER, 1); + + expect(await impl.applyActionBatch(action, [], USER)) + .toMatchObject({ decided: [], stoppedAt: 1 }); + expect(actionRecord(impl, GATEKEEPER, 1).state).toBe("pending"); + await expectGitPackCode( + () => receiver.buildRetained(1), GIT_PACK_ERROR_CODES.builderExpired); + } finally { + await receiver.releaseRetained(); + } + }); + }); + + it("authorizes exact local push selectors without workspace-ID collisions", async () => { + await inOverseer("batch-pack-selectors", async impl => { + const foreignGatekeeper = 8; + impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); + impl.storage.gatekeepers.put({ id: foreignGatekeeper, class: {} }); + + const foreignId = impl.storage.nextActionId.get() + 40; + impl.storage.nextActionId.put(foreignId); + const foreignHistory = await seedPushableHistory(impl, foreignGatekeeper, " foreign"); + await impl.submitAction( + foreignGatekeeper, foreignId, pushDescription([foreignHistory.head]), { from: "user" }); + const foreign = actionRecord(impl, foreignGatekeeper, foreignId); + expect(foreign.id).toBe(foreignId); + + impl.storage.nextActionId.put(foreignId + 100); + const ownHistory = await seedPushableHistory(impl, GATEKEEPER, " own"); + await impl.submitAction( + GATEKEEPER, foreignId, pushDescription([ownHistory.head]), { from: "user" }); + const own = actionRecord(impl, GATEKEEPER, foreignId); + + await impl.submitAction(GATEKEEPER, foreignId + 1, { + title: "Non-Git action", + description: "Does not push commits.", + implementsRevert: true, + }, { from: "user" }); + const nonPush = actionRecord(impl, GATEKEEPER, foreignId + 1); + + await impl.submitAction( + GATEKEEPER, foreignId + 3, pushDescription([ownHistory.base]), { from: "user" }); + const empty = actionRecord(impl, GATEKEEPER, foreignId + 3); + + const zeroHistory = await seedPushableHistory(impl, GATEKEEPER, " zero"); + await impl.submitAction(GATEKEEPER, 0, pushDescription([zeroHistory.head]), { from: "user" }); + const zero = actionRecord(impl, GATEKEEPER, 0); + expect(zero.id).not.toBe(0); + + const builder = new GitPackBuilderImpl( + impl.gitCache, impl.storage, GATEKEEPER, [own, nonPush, empty, zero]); + try { + const ownObjects = await decodePackBytes( + await collect(await builder.buildPack(foreignId)), { maxObjectSize: 1 << 20 }); + const ownOids = await Promise.all( + ownObjects.map(object => gitObjectOid(object.type, object.payload))); + expect(ownOids).toStrictEqual([ownHistory.head]); + + const zeroObjects = await decodePackBytes( + await collect(await builder.buildPack(0)), { maxObjectSize: 1 << 20 }); + expect(await Promise.all( + zeroObjects.map(object => gitObjectOid(object.type, object.payload)))) + .toStrictEqual([zeroHistory.head]); + expect(await decodePackBytes( + await collect(await builder.buildPack(foreignId + 3)), { maxObjectSize: 1 })) + .toStrictEqual([]); + + for (const selector of [own.id, nonPush.action]) { + await expectGitPackCode( + () => builder.buildPack(selector), GIT_PACK_ERROR_CODES.actionNotAuthorized); + } + + impl.storage.transaction(() => { + zero.state = "rejected"; + impl.gitCache.clearPushMarks(zero.id); + impl.storage.actions.put(zero); + }); + await expectGitPackCode( + () => builder.buildPack(0), GIT_PACK_ERROR_CODES.actionUnavailable); + expect(getGitPackErrorCode(new Error( + "Git pack action is no longer pending or its connection was removed."))) + .toBeUndefined(); + } finally { + builder[Symbol.dispose](); + } + }); + }); + + it("reconciles partial results and preserves pending state when the response is lost", async () => { + await inOverseer("batch-pack-stopped", async impl => { + const firstHistory = await seedPushableHistory(impl, GATEKEEPER, " first"); + const secondHistory = await seedPushableHistory(impl, GATEKEEPER, " second"); + const receiver = installPackReceiver( + impl, GATEKEEPER, { packAction: 61, stoppedAt: 62 }); + const kind = { tag: "push", label: "Push" }; + + try { + await impl.submitAction(GATEKEEPER, 61, { + ...pushDescription([firstHistory.head]), + autoApprovable: true, + actionKind: kind, + }, { from: "user" }); + await impl.submitAction(GATEKEEPER, 62, { + ...pushDescription([secondHistory.head]), + autoApprovable: false, + }, { from: "user" }); + impl.storage.autoApproveTags.put({ + gatekeeperId: GATEKEEPER, + actionKind: kind, + enabledBy: USER, + }); + const first = actionRecord(impl, GATEKEEPER, 61); + const second = actionRecord(impl, GATEKEEPER, 62); + + await impl.applyDecidedActions(GATEKEEPER, { action: 62, resolvedBy: USER }); + + expect(actionRecord(impl, GATEKEEPER, 61).state).toBe("approved"); + expect(actionRecord(impl, GATEKEEPER, 62).state).toBe("pending"); + expect(marksOf(impl, first.id)).toStrictEqual([]); + expect(marksOf(impl, second.id)).toContain(secondHistory.head); + expect(impl.storage.gitObjectMetadata.get(firstHistory.head)!.onRemote) + .toContain(GATEKEEPER); + expect(impl.storage.gitObjectMetadata.get(secondHistory.head)!.onRemote) + .not.toContain(GATEKEEPER); + await expectGitPackCode( + () => receiver.buildRetained(61), GIT_PACK_ERROR_CODES.builderExpired); + } finally { + await receiver.releaseRetained(); + } + }); + + await inOverseer("batch-pack-response-lost", async impl => { + const { head } = await seedPushableHistory(impl, GATEKEEPER, " response lost"); + const receiver = installPackReceiver( + impl, GATEKEEPER, { packAction: 71, throwAfterBuild: true }); + try { + await impl.submitAction(GATEKEEPER, 71, pushDescription([head]), { from: "user" }); + const record = actionRecord(impl, GATEKEEPER, 71); + let caught: unknown; + try { + await impl.applyDecidedActions(GATEKEEPER, { action: 71, resolvedBy: USER }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(getGitPackErrorCode(caught)).toBeUndefined(); + const lostObjects = await decodePackBytes(await receiver.capturedPack(), { + maxObjectSize: 1 << 20, + }); + expect(await Promise.all( + lostObjects.map(object => gitObjectOid(object.type, object.payload)))) + .toStrictEqual([head]); + expect(actionRecord(impl, GATEKEEPER, 71).state).toBe("pending"); + expect(marksOf(impl, record.id)).toContain(head); + expect(impl.storage.gitObjectMetadata.get(head)!.onRemote).not.toContain(GATEKEEPER); + await expectGitPackCode( + () => receiver.buildRetained(71), GIT_PACK_ERROR_CODES.builderExpired); + } finally { + await receiver.releaseRetained(); + } + }); + }); + + it("rechecks owner and destination lifetime after an awaited pack build", async () => { + await inOverseer("batch-pack-disposed-in-flight", async impl => { + impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); + const { head } = await seedPushableHistory(impl, GATEKEEPER, " disposed"); + await impl.submitAction(GATEKEEPER, 81, pushDescription([head]), { from: "user" }); + const record = actionRecord(impl, GATEKEEPER, 81); + const builder = new GitPackBuilderImpl( + impl.gitCache, impl.storage, GATEKEEPER, [record]); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const original = impl.gitCache.buildPackForAction; + impl.gitCache.buildPackForAction = async (gatekeeperId: number, actionId: number) => { + started.resolve(); + await release.promise; + return original.call(impl.gitCache, gatekeeperId, actionId); + }; + + try { + const build = builder.buildPack(81); + await started.promise; + builder[Symbol.dispose](); + release.resolve(); + let caught: unknown; + try { + await build; + } catch (error) { + caught = error; + } + expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.builderExpired); + expect(marksOf(impl, record.id)).toContain(head); + } finally { + release.resolve(); + impl.gitCache.buildPackForAction = original; + builder[Symbol.dispose](); + } + }); + + await inOverseer("batch-pack-removed-in-flight", async impl => { + impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); + const { head } = await seedPushableHistory(impl, GATEKEEPER, " removed"); + await impl.submitAction(GATEKEEPER, 82, pushDescription([head]), { from: "user" }); + const record = actionRecord(impl, GATEKEEPER, 82); + const builder = new GitPackBuilderImpl( + impl.gitCache, impl.storage, GATEKEEPER, [record]); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const original = impl.gitCache.buildPackForAction; + impl.gitCache.buildPackForAction = async (gatekeeperId: number, actionId: number) => { + started.resolve(); + await release.promise; + return original.call(impl.gitCache, gatekeeperId, actionId); + }; + + try { + const build = builder.buildPack(82); + await started.promise; + impl.removeGatekeeper(GATEKEEPER); + release.resolve(); + let caught: unknown; + try { + await build; + } catch (error) { + caught = error; + } + expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.actionUnavailable); + expect(marksOf(impl, record.id)).toStrictEqual([]); + } finally { + release.resolve(); + impl.gitCache.buildPackForAction = original; + builder[Symbol.dispose](); + } + }); + }); + it("hands sessions a gatekeeper-scoped cache via getGitCache()", async () => { await inOverseer("push-session-cache", async impl => { let { head, base } = await seedPushableHistory(impl); diff --git a/packages/workshop-backend/__tests__/open-gadget-errors.test.ts b/packages/workshop-backend/__tests__/open-gadget-errors.test.ts index ec76f220b6..917b460d80 100644 --- a/packages/workshop-backend/__tests__/open-gadget-errors.test.ts +++ b/packages/workshop-backend/__tests__/open-gadget-errors.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { deserialize, serialize } from "capnweb"; import { createOpenGadgetError, getOpenGadgetErrorCode, @@ -6,32 +7,22 @@ import { } from "@gadgets/workshop-shared/api"; describe("open gadget errors", () => { - it.each([ - [OPEN_GADGET_ERROR_CODES.workspaceNotFound, "Workspace not found."], - [OPEN_GADGET_ERROR_CODES.workspaceAccessDenied, "You don't have access to this workspace."], - [ - OPEN_GADGET_ERROR_CODES.shareLinksDisabled, - "Share links are disabled for this workspace because it contains sensitive data. " + - "The owner must add each person directly.", - ], - ] as const)( - "creates an enumerable %s code with a readable message", - (code, message) => { - let error = createOpenGadgetError(code); + it("classifies a serialized code independently of its message", () => { + const error = createOpenGadgetError(OPEN_GADGET_ERROR_CODES.workspaceAccessDenied); + error.message = "Workspace access changed."; - expect(error.message).toBe(message); - expect(error.code).toBe(code); - expect(Object.keys(error)).toContain("code"); - expect(getOpenGadgetErrorCode(error)).toBe(code); - }, - ); + const received = deserialize(serialize(error)) as Error; - it.each(Object.values(OPEN_GADGET_ERROR_CODES))( - "does not infer %s from an error message", - (code) => { - expect(getOpenGadgetErrorCode(new Error(code))).toBeUndefined(); - }, - ); + expect(getOpenGadgetErrorCode(received)).toBe( + OPEN_GADGET_ERROR_CODES.workspaceAccessDenied, + ); + }); + + it("does not infer a known code from its default message", () => { + expect( + getOpenGadgetErrorCode(new Error("You don't have access to this workspace.")), + ).toBeUndefined(); + }); it("does not classify unexpected errors", () => { expect(getOpenGadgetErrorCode(new Error("storage unavailable"))).toBeUndefined(); diff --git a/packages/workshop-backend/__tests__/test-worker.ts b/packages/workshop-backend/__tests__/test-worker.ts index 123b17ad54..88cb535279 100644 --- a/packages/workshop-backend/__tests__/test-worker.ts +++ b/packages/workshop-backend/__tests__/test-worker.ts @@ -2,7 +2,20 @@ // the real Durable Objects and callbacks) plus test-only entrypoints that stand in for other Workers. import { DurableObject, WorkerEntrypoint, restore } from "cloudflare:workers"; -import type { AccountDescription } from "@gadgets/workshop-shared/gatekeeper"; +import type { RpcStub } from "cloudflare:workers"; +import { validateRpc } from "capnweb-validate"; +import type { + AccountDescription, + ApplyActionContext, + ApplyActionsThroughResult, + Gatekeeper, + GitCache, + GitPackBuilder, +} from "@gadgets/workshop-shared/gatekeeper"; +import { + createGitPackError, + getGitPackErrorCode, +} from "@gadgets/workshop-shared/gatekeeper"; import { GatekeeperConnectCallbackImpl } from "../src/user.js"; import { LoginConnectCallbackImpl } from "../src/auth/login-flow.js"; import { OverseerDurableObject as RealOverseerDurableObject } from "../src/server.js"; @@ -51,6 +64,81 @@ export class TestConnectCallback extends GatekeeperConnectCallbackImpl {} /** The sign-in callback, reachable the same way. */ export class TestLoginCallback extends LoginConnectCallbackImpl {} +type TestGitPackGatekeeperProps = { + packAction?: number; + readOid?: string; + stoppedAt?: number; + throwAfterBuild?: boolean; +}; + +/** Native-RPC test receiver for invocation-scoped Git pack callbacks. */ +@validateRpc() +export class TestGitPackGatekeeper + extends DurableObject + implements Pick, "applyActionsThrough"> { + #captured?: Uint8Array; + #cached?: Awaited>; + #retained?: RpcStub; + #receivedBatch?: {actionId: number; vetoes: number[]}; + + async applyActionsThrough( + actionId: number, + vetoes: number[], + context: ApplyActionContext, + ): Promise { + this.#receivedBatch = { actionId, vetoes: [...vetoes] }; + const { packAction, readOid, stoppedAt, throwAfterBuild } = this.ctx.props; + if (readOid !== undefined) this.#cached = await context.gitCache.get(readOid); + if (packAction === undefined) return {}; + + this.#retained?.[Symbol.dispose](); + this.#retained = context.gitPackBuilder.dup(); + try { + const stream = await this.#retained.buildPack(packAction); + this.#captured = new Uint8Array(await new Response(stream).arrayBuffer()); + } catch (error) { + const code = getGitPackErrorCode(error); + if (code !== undefined) { + return { stopped: { + at: packAction, + reason: error instanceof Error ? error : createGitPackError(code), + } }; + } + throw error; + } + + if (throwAfterBuild) throw new Error("Test batch response lost."); + if (stoppedAt !== undefined) { + return { stopped: { at: stoppedAt, reason: new Error("Test action stopped.") } }; + } + return {}; + } + + async receivedBatch(): Promise<{actionId: number; vetoes: number[]} | undefined> { + return this.#receivedBatch; + } + + async capturedPack(): Promise { + if (this.#captured === undefined) throw new Error("No pack was captured."); + return this.#captured; + } + + async cachedObject() { + return this.#cached; + } + + async buildRetained(action: number): Promise { + if (this.#retained === undefined) throw new Error("No Git pack builder was retained."); + const stream = await this.#retained.buildPack(action); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + async releaseRetained(): Promise { + this.#retained?.[Symbol.dispose](); + this.#retained = undefined; + } +} + /** What each FakeGatekeeperAccount has been asked to do, by its `name` prop. */ const accountCalls = new Map(); diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts new file mode 100644 index 0000000000..0ac79a03f3 --- /dev/null +++ b/packages/workshop-backend/src/actions.ts @@ -0,0 +1,478 @@ +// Serializes gatekeeper decisions. Explicit batches durably stage vetoes; immediate rejections +// become terminal only after acknowledgement, and only authorized actions are applied. + +import type { Collection, NonUniqueIndex } from "@gadgets/typed-storage"; +import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api"; +import type { + ApplyActionsThroughResult, + Gatekeeper, + GitCache, + GitPackBuilder, +} from "@gadgets/workshop-shared/gatekeeper"; +import { getGitPackErrorCode } from "@gadgets/workshop-shared/gatekeeper"; +import { createWorkshopLogger } from "./observability"; +import type { ActionRecord, AutoApproveTagRecord, GatekeeperActionRecord } from "./overseer.js"; + +const logger = createWorkshopLogger("workshop.action.sync"); + +export interface ActionSyncStorage { + actions: Collection & { + pendingByGatekeeper: NonUniqueIndex; + vetoPendingByGatekeeper: NonUniqueIndex; + }; + autoApproveTags: Collection; +} + +/** + * The slice of the gatekeeper stub surface the driver drives, derived from the RPC contract. + * `applyActionsThrough` is optional during the migration; on a live stub the property is always a + * callable proxy and an un-migrated gatekeeper throws when it is invoked (see isMethodMissing). + */ +export type GatekeeperActionTarget = Pick>, + "applyActionsThrough" | "applyAction" | "rejectAction">; + +type LiveApplyActionsThrough = Extract unknown>; + +type ActionSyncHooks = { + createGitCache: (gatekeeperId: number) => GitCache; + createGitPackBuilder: ( + gatekeeperId: number, + pendingPlan: readonly GatekeeperActionRecord[], + ) => GitPackBuilder & Disposable; + applyLegacyAction: ( + gatekeeper: GatekeeperActionTarget, record: GatekeeperActionRecord) => Promise; + persistApproved: (record: GatekeeperActionRecord) => void; + persistRejected: (record: GatekeeperActionRecord) => void; +}; + +/** + * A staged manual approval: the user clicked Approve on `action` (a gatekeeper-local action ID), + * under `resolvedBy`'s authority. Earlier undecided actions go out with it only where an + * auto-approval rule already authorizes them. + */ +export type ManualApproval = { action: number, resolvedBy: AiChatAuthorInfo }; + +/** The reconciled outcome of one action-processing pass. */ +export type PassResult = { + /** Workspace record IDs decided (approved or cascade-rejected) by the pass. */ + decided: number[]; + + /** + * Title of the earlier undecided action that stopped the frontier, set when a click sat above + * it. Transient queue state, so it is reported rather than recorded on the action. + */ + blockedBy?: string; + + /** Gatekeeper-local action ID where application stopped; zero is a valid ID. */ + stoppedAt?: number; +}; + +type StagedPass = { + manualApprovals: ManualApproval[]; + resolve: (result: PassResult) => void; + reject: (error: unknown) => void; + promise: Promise; +}; + +/** + * Returns whether `error` is workerd's code-less missing-`applyActionsThrough` RPC error. + * + * Production workerd includes `the method` in this error; Miniflare's real DO stub omits it. + * Neither runtime attaches a code, so these two migration-only message forms remain the narrow + * compatibility probe. Recognized application codes are authoritative and never trigger replay. + */ +export function isMethodMissing(error: unknown): boolean { + return getGitPackErrorCode(error) === undefined && error instanceof Error && ( + error.message.includes('does not implement the method "applyActionsThrough"') || + error.message.includes('does not implement "applyActionsThrough"')); +} + +// Materializes a lazy index read as action records ordered by `record.action` (the +// gatekeeper-local ID, which is the contract's apply order). Copying up front matters: index reads +// are lazy and a pass mutates the indexes it read from. +function actionsAscending(records: Iterable): GatekeeperActionRecord[] { + return [...records] + .filter((record): record is GatekeeperActionRecord => record.type === "action") + .toSorted((a, b) => a.action - b.action); +} + +// Longest gatekeeper-authored failure text kept on a record. Long enough for a real explanation, +// short enough that a hostile message can't bloat storage or the actions subscription. +const MAX_FAILURE_CHARS = 500; + +function boundFailure(message: string | undefined): string | undefined { + let text = message?.trim(); + return text ? text.slice(0, MAX_FAILURE_CHARS) : undefined; +} + +export class ActionSyncDriver { + // Per-gatekeeper intent for the NEXT pass. A key is present while a request waits to be picked + // up; requests arriving mid-pass merge here, so work submitted during a pass isn't lost. + #staged = new Map(); + + // Per-gatekeeper single-flight guard. Key present => a run loop is active for that gatekeeper. + #running = new Map>(); + + // Gatekeepers observed to lack applyActionsThrough. In-memory only: a fresh isolate re-probes, + // which is what lets a migrated deploy shed the fallback without bookkeeping. + #legacy = new Set(); + + // Explicit batches and immediate rejections run between apply passes under the same guard. + #decisions = new Map Promise>>(); + + constructor( + private storage: ActionSyncStorage, + private getGatekeeper: (gatekeeperId: number) => GatekeeperActionTarget, + private hooks: ActionSyncHooks) {} + + /** + * Reconcile the gatekeeper's queue, optionally staging a manual approval. Resolves with what the + * pass carrying this request's intent decided. Concurrent calls for the same gatekeeper coalesce + * into one pass. + */ + apply(gatekeeperId: number, manualApproval?: ManualApproval): Promise { + let slot = this.#staged.get(gatekeeperId); + if (!slot) { + slot = { manualApprovals: [], ...Promise.withResolvers() }; + this.#staged.set(gatekeeperId, slot); + } + if (manualApproval) slot.manualApprovals.push(manualApproval); + + if (!this.#running.has(gatekeeperId)) { + this.#running.set(gatekeeperId, this.#run(gatekeeperId)); + } + return slot.promise; + } + /** + * Process the selected connection through an explicit boundary after durably staging its vetoes. + * The records are re-read inside the queue so deleted or regrouped actions cannot be resurrected. + */ + applyThrough( + boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], + resolvedBy: AiChatAuthorInfo): Promise { + return this.#enqueueDecision(boundary.gatekeeperId, async () => { + let freshBoundary = this.storage.actions.get(boundary.id); + if (!freshBoundary) throw new Error(`No such action: ${boundary.id}`); + if (freshBoundary.type !== "action") throw new Error(`Not an action: ${boundary.id}`); + if (freshBoundary.gatekeeperId !== boundary.gatekeeperId) { + throw new Error("Action batch contains a different connection."); + } + + let selected = vetoes.map(({id}) => { + let fresh = this.storage.actions.get(id); + if (!fresh) throw new Error(`No such action: ${id}`); + if (fresh.type !== "action") throw new Error(`Not an action: ${id}`); + if (fresh.gatekeeperId !== freshBoundary.gatekeeperId) { + throw new Error("Action batch contains a different connection."); + } + if (fresh.action > freshBoundary.action) { + throw new Error("Veto is beyond the action batch boundary."); + } + return fresh; + }); + + for (let record of selected) { + if (record.state !== "pending") continue; + record.state = "rejected"; + record.vetoPending = true; + record.resolvedBy = resolvedBy; + record.appliedAt = new Date(); + this.storage.actions.put(record); + } + + return await this.#applyOnce(freshBoundary.gatekeeperId, [], { + frontier: freshBoundary.action, + resolvedBy, + }); + }); + } + + /** Delivers one immediate rejection through the legacy endpoint before recording it. */ + reject(record: GatekeeperActionRecord, resolvedBy: AiChatAuthorInfo): Promise { + return this.#enqueueDecision(record.gatekeeperId, async () => { + let fresh = this.storage.actions.get(record.id); + if (fresh?.type !== "action" || fresh.state !== "pending") { + throw new Error(`Action is not pending: ${record.id}`); + } + + await this.getGatekeeper(record.gatekeeperId).rejectAction(fresh.action); + + fresh = this.storage.actions.get(record.id); + if (fresh?.type !== "action" || fresh.state !== "pending") return; + fresh.state = "rejected"; + fresh.resolvedBy = resolvedBy; + fresh.appliedAt = new Date(); + this.hooks.persistRejected(fresh); + }); + } + + #enqueueDecision(gatekeeperId: number, operation: () => Promise): Promise { + let {promise, resolve, reject} = Promise.withResolvers(); + let queue = this.#decisions.get(gatekeeperId); + if (!queue) this.#decisions.set(gatekeeperId, queue = []); + queue.push(async () => { + try { + resolve(await operation()); + } catch (error) { + reject(error); + } + }); + if (!this.#running.has(gatekeeperId)) { + this.#running.set(gatekeeperId, this.#run(gatekeeperId)); + } + return promise; + } + + async #run(gatekeeperId: number): Promise { + try { + for (;;) { + let queue = this.#decisions.get(gatekeeperId); + let decision = queue?.shift(); + if (queue?.length === 0) this.#decisions.delete(gatekeeperId); + if (decision) { + await decision(); + continue; + } + let slot = this.#staged.get(gatekeeperId); + if (!slot) break; + this.#staged.delete(gatekeeperId); + try { + slot.resolve(await this.#applyOnce(gatekeeperId, slot.manualApprovals)); + } catch (error) { + // Auto-approval callers run in waitUntil and do not observe the rejection. + logger.warn("action sync pass failed", { + event: "action.sync.failed", gatekeeperId, error, + }); + slot.reject(error); + } + } + } finally { + // Synchronous with the loop's empty-staged check above, so a request staged mid-pass either + // was picked up by the loop or sees #running empty and starts a fresh one. + this.#running.delete(gatekeeperId); + } + } + + async #applyOnce( + gatekeeperId: number, manualApprovals: ManualApproval[], + batch?: {frontier: number; resolvedBy: AiChatAuthorInfo}): Promise { + // Snapshot both indexes before reconciling (see actionsAscending). The pending index was + // backfilled by the action-index migration; vetoPending only exists on records written after + // its index was introduced, so it needs no legacy backfill. + let pending = actionsAscending(this.storage.actions.pendingByGatekeeper.get(gatekeeperId)); + let stagedVetoes = + actionsAscending(this.storage.actions.vetoPendingByGatekeeper.get(gatekeeperId)) + .filter(record => record.state === "rejected" && record.vetoPending === true); + let byAction = new Map([...pending, ...stagedVetoes].map(record => [record.action, record])); + + // Explicit batches authorize every non-vetoed pending action through their fixed boundary. + // Existing callers retain exact-click and rule authority, including their blocked result. + let frontier = batch?.frontier ?? Math.max(-1, ...manualApprovals.map(({action}) => action)); + let attribution = new Map(); + let blockedBy: string | undefined; + if (batch) { + for (let record of pending) { + if (record.action > frontier) break; + attribution.set(record.action, {resolvedBy: batch.resolvedBy, autoApproved: false}); + } + } else { + // Two authorities extend the old frontier and nothing else: the user's click on that exact + // action, or an auto-approval rule they enabled for its kind. + let clicked = new Map(manualApprovals.map(manual => [manual.action, manual.resolvedBy])); + let gate: GatekeeperActionRecord | undefined; + for (let record of pending) { + let resolvedBy = clicked.get(record.action); + if (resolvedBy) { + attribution.set(record.action, {resolvedBy, autoApproved: false}); + continue; + } + // A prior stop requires a click: unattended replay could repeat a side effect that landed. + let tag = record.failure === undefined && record.description.autoApprovable === true + ? record.description.actionKind?.tag + : undefined; + let rule = tag === undefined + ? undefined + : this.storage.autoApproveTags.get(`${gatekeeperId}:${tag}`); + if (!rule) { + gate = record; + break; + } + attribution.set(record.action, {resolvedBy: rule.enabledBy, autoApproved: true}); + if (record.action > frontier) frontier = record.action; + } + + if (gate && gate.action <= frontier) { + frontier = gate.action - 1; + blockedBy = gate.description.title; + } + } + + let sendVetoes = stagedVetoes.filter(veto => veto.action <= frontier); + if (attribution.size === 0 && sendVetoes.length === 0) return {decided: [], blockedBy}; + + let decided: number[] = []; + + // The single pending->approved chokepoint. Idempotent, so the legacy path can persist an + // approval the moment it lands and the reconcile loop below can replay it harmlessly. + let approve = (action: number) => { + let attr = attribution.get(action); + let fresh = this.#freshAction(byAction, action); + if (!attr || fresh?.state !== "pending") return; + fresh.state = "approved"; + fresh.appliedAt = new Date(); + fresh.resolvedBy = attr.resolvedBy; + fresh.autoApproved = attr.autoApproved; + delete fresh.failure; + this.hooks.persistApproved(fresh); + decided.push(fresh.id); + }; + + // Acknowledge each legacy veto as soon as its RPC returns, before another call can fail. The + // batch path invokes this only after its all-vetoes-durable call returns successfully. + let acknowledgeVeto = (action: number) => { + let fresh = this.#freshAction(byAction, action); + if (!fresh?.vetoPending) return; + delete fresh.vetoPending; + this.hooks.persistRejected(fresh); + }; + + let result = await this.#applyThrough( + gatekeeperId, frontier, sendVetoes.map(veto => veto.action), + pending.filter(record => attribution.has(record.action)), approve, acknowledgeVeto); + let stoppedAt = result.stopped?.at; + let stoppedFailure = result.stopped?.reason?.message; + // A stop outside the authorized set breaks the contract (no shipped gatekeeper can: the + // legacy path synthesizes `at` from this plan). Clamp to the lowest authorized action rather + // than trust it -- nothing is then recorded applied -- and drop the gatekeeper's text, which + // describes an action this pass never sent. + if (stoppedAt !== undefined && !attribution.has(stoppedAt)) { + stoppedAt = pending.find(record => attribution.has(record.action))?.action; + if (stoppedAt === undefined) { + throw new Error("Gatekeeper returned an invalid stopping action."); + } + stoppedFailure = undefined; + } + for (let veto of sendVetoes) acknowledgeVeto(veto.action); + let sentVetoes = new Map(sendVetoes.map(veto => [veto.action, veto])); + // Cascade invalidations first: an action inside the frontier can also be cascade-invalidated + // by a veto delivered in this same pass, and then it was deleted, not applied -- marking it + // rejected here keeps the approval loop below (which only touches pending records) from + // mislabeling it approved. Display-attributed to the veto that caused it, resolved by the user + // whose rejection it was. + if (result.invalidatedByVeto?.length) { + // A cascade may name an action submitted during the RPC await, which the pre-call snapshot + // can't contain; left pending it would later be recorded approved though the gatekeeper had + // deleted it. + for (let record of this.storage.actions.pendingByGatekeeper.get(gatekeeperId)) { + if (record.type === "action") byAction.set(record.action, record); + } + } + for (let entry of result.invalidatedByVeto ?? []) { + let vetoer = sentVetoes.get(entry.invalidatedBy); + if (!vetoer) continue; + let fresh = this.#freshAction(byAction, entry.action); + if (!fresh || fresh.state !== "pending") continue; + fresh.state = "rejected"; + fresh.appliedAt = new Date(); + if (vetoer.resolvedBy) fresh.resolvedBy = vetoer.resolvedBy; + fresh.cascadedFrom = vetoer.id; + delete fresh.failure; + this.hooks.persistRejected(fresh); + decided.push(fresh.id); + } + + // The contract makes `appliedThrough` sound despite ID holes: a gatekeeper never silently + // skips a pending in-range action -- it applies it or reports it via `stopped`. + let appliedThrough = stoppedAt !== undefined ? stoppedAt - 1 : frontier; + for (let action of attribution.keys()) { + if (action <= appliedThrough) approve(action); + } + + // The stopping action stays pending, carrying a display-safe reason the user can act on. The + // gatekeeper writes that text, so it is clamped before storage and kept out of the log. + // Stamped like every other mutation, or byLastChanged would leave it out of a resume replay. + if (stoppedAt !== undefined) { + let fresh = this.#freshAction(byAction, stoppedAt); + if (fresh?.state === "pending") { + fresh.failure = boundFailure(stoppedFailure) ?? + "The gatekeeper could not apply this action."; + fresh.appliedAt = new Date(); + this.storage.actions.put(fresh); + logger.warn("apply stopped", { + event: "action.sync.stopped", gatekeeperId, actionId: fresh.id, + }); + } + } + return {decided, blockedBy, stoppedAt}; + } + + // Re-read before each mutation; earlier checkpoints and cascade refreshes may replace snapshots. + #freshAction(byAction: Map, actionId: number) + : GatekeeperActionRecord | undefined { + let record = byAction.get(actionId); + if (!record) return undefined; + let fresh = this.storage.actions.get(record.id); + return fresh?.type === "action" ? fresh : undefined; + } + + // Batch call with a legacy fallback for gatekeepers that predate applyActionsThrough -- which + // is still all of them. The fallback checkpoints each veto before attempting the next call and + // aborts before any apply when a rejection fails. Delete the fallback half -- and the #legacy + // cache -- once the fallback warning stops appearing in logs and the method becomes required. + async #applyThrough(gatekeeperId: number, actionId: number, vetoes: number[], + pendingPlan: readonly GatekeeperActionRecord[], + approve: (action: number) => void, + acknowledgeVeto: (action: number) => void) + : Promise { + let gatekeeper = this.getGatekeeper(gatekeeperId); + + if (!this.#legacy.has(gatekeeperId)) { + try { + using gitPacks = this.hooks.createGitPackBuilder(gatekeeperId, pendingPlan); + let applyActionsThrough = gatekeeper.applyActionsThrough as LiveApplyActionsThrough; + return await applyActionsThrough(actionId, vetoes, { + gitCache: this.hooks.createGitCache(gatekeeperId), gitPackBuilder: gitPacks, + }); + } catch (error) { + if (!isMethodMissing(error)) throw error; + } + this.#legacy.add(gatekeeperId); + logger.warn("gatekeeper does not implement applyActionsThrough; using per-action fallback", { + event: "action.sync.legacy", gatekeeperId, + }); + } + + // Legacy path: per-action calls in the same order the batch would use -- vetoes first, then + // pending actions ascending. Each confirmed veto is checkpointed immediately; a failed veto + // aborts the pass before any action can be applied. `{restart}` returns are discarded, as the + // overseer always has, and this path never reports `invalidatedByVeto`, so an un-migrated + // gatekeeper's cascades leave their dependants pending until they too are decided. + for (let veto of vetoes) { + try { + await gatekeeper.rejectAction(veto); + } catch (error) { + logger.warn("legacy rejectAction failed", { + event: "action.sync.legacy.reject.failed", gatekeeperId, error, + }); + throw error; + } + acknowledgeVeto(veto); + } + // Each approval is persisted as it lands: unlike a replayed frontier, a replayed per-action + // call throws on an already-applied action, so an unrecorded apply would wedge the record as + // pending forever. + for (let record of pendingPlan) { + try { + await this.hooks.applyLegacyAction(gatekeeper, record); + } catch (error) { + return {stopped: { + at: record.action, + reason: error instanceof Error ? error : new Error(String(error)), + }}; + } + approve(record.action); + } + return {}; + } +} diff --git a/packages/workshop-backend/src/auto-approval.ts b/packages/workshop-backend/src/auto-approval.ts deleted file mode 100644 index ba3e1078d7..0000000000 --- a/packages/workshop-backend/src/auto-approval.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-approval drain core: applies the gatekeeper's eligible pending actions (read off the sparse -// pendingByGatekeeper index) in id order, with a per-gatekeeper single-flight guard so two -// concurrent drains (the DO's input gate is open across the apply await) can't double-apply the -// same action. The apply is injected, keeping this constructible over a mock storage in tests. - -import type { Collection, NonUniqueIndex } from "@gadgets/typed-storage"; -import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api"; -import { createWorkshopLogger } from "./observability"; -import type { ActionRecord, AutoApproveTagRecord } from "./overseer.js"; - -const logger = createWorkshopLogger("workshop.auto.approval"); - -export interface AutoApprovalStorage { - actions: Collection - & { pendingByGatekeeper: NonUniqueIndex }; - autoApproveTags: Collection; -} - -/** - * Applies a single eligible pending action: invoke the gatekeeper, mark it approved, persist. The - * caller has already validated that the record is still pending. - */ -export type ApplyPendingActionFn = ( - record: ActionRecord & {type: "action"}, - resolvedBy: AiChatAuthorInfo, - autoApproved: boolean) => Promise; - -export class AutoApprovalDrainer { - // Per-gatekeeper single-flight state. Key present => a drain is running for that gatekeeper; the - // value is a "rerun" flag, set when another drain is requested while one is in flight, so work - // submitted during a drain isn't lost. - #draining = new Map(); - - constructor( - private storage: AutoApprovalStorage, - private applyPendingAction: ApplyPendingActionFn) {} - - async drain(gatekeeperId: number): Promise { - if (this.#draining.has(gatekeeperId)) { - this.#draining.set(gatekeeperId, true); // ask the running drain to loop again - return; - } - this.#draining.set(gatekeeperId, false); - try { - do { - this.#draining.set(gatekeeperId, false); - await this.#drainOnce(gatekeeperId); - } while (this.#draining.get(gatekeeperId)); - } finally { - this.#draining.delete(gatekeeperId); - } - } - - // Apply all currently-eligible pending actions of the gatekeeper, in ascending id order. Stops - // at the first pending action that is NOT auto-eligible (a manual gate) or that throws while - // applying -- it is never skipped ahead of. This preserves in-order application and the - // invariant that nothing is silently applied past a human gate. - // - // Eligibility requires BOTH signals: the author's `autoApprovable` verdict on the action AND a - // user-enabled rule for the action's type on this gatekeeper. - async #drainOnce(gatekeeperId: number): Promise { - // Materialize before applying: the index yields lazily in ascending id order, and applying - // mutates it mid-iteration. Actions created after this snapshot trigger their own drain(), - // which drain()'s rerun flag folds into this run if it's still in flight. - let pending = [...this.storage.actions.pendingByGatekeeper.get(gatekeeperId)]; - - for (let record of pending) { - if (record.type !== "action") continue; - - let tag = record.description.actionKind?.tag; - let rule = tag !== undefined - ? this.storage.autoApproveTags.get(`${gatekeeperId}:${tag}`) - : undefined; - if (record.description.autoApprovable !== true || rule === undefined) { - // A manual gate. Stop rather than skipping ahead to any later auto-eligible action. - return; - } - - // Re-check immediately before applying, to guard against a concurrent drain having already - // taken this one. - let fresh = this.storage.actions.get(record.id); - if (!fresh || fresh.type !== "action" || fresh.state !== "pending") { - continue; - } - - try { - // Attribute the auto-approval to the user who enabled the rule -- it runs under their - // authority. - await this.applyPendingAction(fresh, rule.enabledBy, true); - } catch (err) { - // Leave the action pending for manual handling and stop the drain (never skip ahead). - logger.error("auto-approval failed", { - event: "auto.approval.failed", actionId: fresh.id, error: err, - }); - return; - } - } - } -} diff --git a/packages/workshop-backend/src/git-cache.ts b/packages/workshop-backend/src/git-cache.ts index 44a575340a..27d499128b 100644 --- a/packages/workshop-backend/src/git-cache.ts +++ b/packages/workshop-backend/src/git-cache.ts @@ -39,8 +39,13 @@ import type { GitCache, GitObjectType, GitOid, + GitPackBuilder, GitPullHints, } from "@gadgets/workshop-shared/gatekeeper"; +import { + createGitPackError, + GIT_PACK_ERROR_CODES, +} from "@gadgets/workshop-shared/gatekeeper"; import { READ_FILES_RESPONSE_BUDGET, type FileAtCommit, @@ -48,6 +53,7 @@ import { type WorkpieceId, } from "@gadgets/workshop-shared/api"; import type { GitObjectRecord } from "./git-store"; +import type { GatekeeperActionRecord, OverseerStorage } from "./overseer.js"; import { buildPackBytes, concatBytes, @@ -1268,6 +1274,67 @@ export class WorkspaceGitCache { // ======================================================================================= // The RPC stub +/** + * Native-RPC pack capability scoped to the declared pushes authorized for one apply-through call. + */ +@validateRpc() +export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Disposable { + #active = true; + #workspaceActionByLocalId = new Map(); + + constructor( + private cache: WorkspaceGitCache, + private storage: Pick, + private gatekeeperId: WorkpieceId, + pendingPlan: readonly GatekeeperActionRecord[], + ) { + super(); + for (const record of pendingPlan) { + if (record.gatekeeperId === gatekeeperId && + (record.description.pushedCommits?.length ?? 0) > 0) { + this.#workspaceActionByLocalId.set(record.action, record.id); + } + } + } + + #requireAction(action: number): GatekeeperActionRecord { + if (!this.#active) { + throw createGitPackError(GIT_PACK_ERROR_CODES.builderExpired); + } + const workspaceId = this.#workspaceActionByLocalId.get(action); + if (workspaceId === undefined) { + throw createGitPackError(GIT_PACK_ERROR_CODES.actionNotAuthorized); + } + const record = this.storage.actions.get(workspaceId); + if (record?.type !== "action" || record.action !== action || + record.gatekeeperId !== this.gatekeeperId || record.state !== "pending" || + (record.description.pushedCommits?.length ?? 0) === 0 || + this.storage.gatekeepers.get(this.gatekeeperId) === undefined) { + throw createGitPackError(GIT_PACK_ERROR_CODES.actionUnavailable); + } + return record; + } + + async buildPack(action: number): Promise> { + const record = this.#requireAction(action); + const stream = await this.cache.buildPackForAction(this.gatekeeperId, record.id); + try { + this.#requireAction(action); + } catch (error) { + try { + await stream.cancel(); + } catch {} + throw error; + } + return stream; + } + + [Symbol.dispose](): void { + this.#active = false; + this.#workspaceActionByLocalId.clear(); + } +} + /** * The `GitCache` stub handed to gatekeepers (see workshop-shared/gatekeeper.ts for the * interface contract). Minted per gatekeeper -- the identity scopes both metadata attribution diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 04ec4ac5d3..c039f51b0c 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -14,7 +14,11 @@ import type { ListOptions } from "@gadgets/typed-storage"; import { GitStore, commitIdentityForAuthor, filesEqual, gitObjectsCollection, threeWayMerge } from "./git-store"; import { - EAGER_BLOB_LIMIT, GitCacheImpl, WorkspaceGitCache, gitObjectMetadataCollection, + EAGER_BLOB_LIMIT, + GitCacheImpl, + GitPackBuilderImpl, + WorkspaceGitCache, + gitObjectMetadataCollection, } from "./git-cache"; import { migrateCodeLogToGit } from "./git-migration"; import * as Y from "yjs"; @@ -48,7 +52,8 @@ import { normalizeAgentCatalog } from "./agent-catalog"; import { refreshCachedBalance } from "./ai-gateway-billing/cloudflare/connection-service"; import { SharingManager, SharingCaller, CollaboratorRecord, ShareKeyRecord, roleRank } from "./sharing"; -import { AutoApprovalDrainer } from "./auto-approval"; +import { ActionSyncDriver, ManualApproval, PassResult } from "./actions"; +import { ACTION_ERROR_CODES, createActionError } from "@gadgets/workshop-shared/api"; import { collectSlashCommands, invokeSlashCommand } from "./slash-commands"; import { createWorkshopLogger, obsContext, traced } from "./observability"; import { retryOnDoReset, wrapDoStubForTelemetry } from "./do-retry"; @@ -681,6 +686,28 @@ export type ActionRecord = { description: ActionDescription; resolvedBy?: AiChatAuthorInfo; // set when resolved (approved/rejected); absent while pending (or legacy) autoApproved?: boolean; // set when applied by an auto-approval rule rather than a human + + /** + * Display-safe reason the most recent application attempt stopped at this action. Set while the + * action is pending, and retained on an action the user rejected after such an attempt, whose + * outcome the gatekeeper never confirmed. Cleared when the action applies. + */ + failure?: string; + + /** + * Workspace action ID of the rejected action whose veto invalidated this one. Only set when + * `state` is "rejected" and the rejection came from a gatekeeper dependency cascade. + */ + cascadedFrom?: number; + + /** Outstanding veto from a previously staged rejection; cleared after delivery. */ + vetoPending?: true; + + /** + * Whether submitting this action suspended its agent turn. Absent on records written before the + * field existed; `suspendedAgentTurn()` answers for those. + */ + suspendedTurn?: boolean; } | { type: "observation"; description: ObservationDescription; @@ -703,6 +730,9 @@ export type ActionRecord = { enabled: boolean; }); +/** The `ActionRecord` variant for a gatekeeper action, not an observation or hook bind. */ +export type GatekeeperActionRecord = ActionRecord & {type: "action"}; + type BoundHookRecord = { id: number; actionId: number; @@ -1016,6 +1046,8 @@ function actionRecordToLog(record: ActionRecord): ActionLogEntry { description: record.description, resolvedBy: record.resolvedBy, autoApproved: record.autoApproved, + cascadedFrom: record.cascadedFrom, + failure: record.failure, }; case "bindHook": return { @@ -1049,6 +1081,13 @@ function stampBindHookAction(storage: OverseerStorage, actionId: number, enabled storage.actions.put(actionRecord); } +// Whether submitting this action suspended its agent turn. A record written before +// `suspendedTurn` existed is read through the rule that set it, so deploying the field can't +// strand a turn that was already waiting on a decision. +function suspendedAgentTurn(record: GatekeeperActionRecord): boolean { + return record.suspendedTurn ?? record.description.awaitDecision === true; +} + // Key of the actions `byLastChanged` index: last state-change time, id-disambiguated because the // frozen clock makes same-instant records routine. Every mutation path stamps appliedAt (apply, // reject, stampBindHookAction); one that doesn't would be missed by the resume replay. @@ -1242,7 +1281,8 @@ export function makeOverseerStorage(storage: DurableObjectStorage) { actions: collection()({ primaryKey: "id", - // All three indexes are backfilled by the version-3 migration. + // The three pre-veto indexes are backfilled by the version-3 migration. The sparse + // vetoPending index needs no backfill: that flag and index are introduced together. uniqueIndexes: { // Resume-replay index (see subscribeToActions): keyed by last state-change time so a // reconnect replays only the records changed during the gap. @@ -1250,12 +1290,20 @@ export function makeOverseerStorage(storage: DurableObjectStorage) { }, nonUniqueIndexes: { - // Sparse index over just the pending records, keyed by gatekeeper, so the auto-approval - // drain is O(pending on that gatekeeper) rather than a full-log scan. + // Sparse index over just the pending records, keyed by gatekeeper, so action sync is + // O(pending on that gatekeeper) rather than a full-log scan. pendingByGatekeeper(record: ActionRecord) { return record.state === "pending" ? record.gatekeeperId : null; }, + // Rejected actions awaiting veto delivery. Kept separate from pendingByGatekeeper because + // the states are disjoint and both sets are sparse. + vetoPendingByGatekeeper(record: ActionRecord) { + return record.type === "action" && record.state === "rejected" && record.vetoPending + ? record.gatekeeperId + : null; + }, + // Keyed by the wire ActionHistoryFilter values, in lockstep with // matchesActionHistoryFilter (api.ts), so every listActions() filter is one ranged // read. The "all" filter has no key: it reads the collection itself. @@ -1594,7 +1642,7 @@ class OverseerImpl implements AgentHooks { #liveChats = new Map(); #chatSubscribers: Set> = new Set(); - #autoApprovalDrainer: AutoApprovalDrainer; + #actionSync: ActionSyncDriver; #preparingChatMessages = new Map>(); @@ -1983,10 +2031,22 @@ class OverseerImpl implements AgentHooks { this.#migrateStorage(); this.defaultGadgetId = this.storage.defaultGadgetId.get(); - this.#autoApprovalDrainer = new AutoApprovalDrainer( - this.storage, - (record, resolvedBy, autoApproved) => - this.applyPendingAction(record, resolvedBy, autoApproved)); + this.#actionSync = new ActionSyncDriver( + this.storage, gatekeeperId => this.getGatekeeperFacet(gatekeeperId), { + createGitCache: gatekeeperId => new GitCacheImpl(this.gitCache, gatekeeperId), + createGitPackBuilder: (gatekeeperId, pendingPlan) => + new GitPackBuilderImpl(this.gitCache, this.storage, gatekeeperId, pendingPlan), + applyLegacyAction: (gatekeeper, record) => gatekeeper.applyAction(record.action, + new GitCacheImpl(this.gitCache, record.gatekeeperId, record.id)), + persistApproved: record => this.storage.transaction(() => { + this.gitCache.convertPushMarksToOnRemote(record.id); + this.storage.actions.put(record); + }), + persistRejected: record => this.storage.transaction(() => { + this.gitCache.clearPushMarks(record.id); + this.storage.actions.put(record); + }), + }); // Mirror every gadget-registry change into the owner's outputs index. Subscribing here makes // the registry the single chokepoint, so creation, acceptance, renaming, reverting and @@ -5370,46 +5430,20 @@ class OverseerImpl implements AgentHooks { await facet.gitPull(oids, new GitCacheImpl(this.gitCache, gatekeeperId), hints); } - // Apply a single pending action: invoke the gatekeeper, mark it approved, and persist (the put - // auto-notifies subscribeToActions). Shared by manual approval (`approveAction`) and the - // auto-approval drain (`drainAutoApprovals`). The caller is responsible for validating that the - // record is still pending before calling. - // - // `resolvedBy`/`autoApproved` are required (not defaulted) so that no apply path can omit how the - // gate was cleared: this is the single chokepoint where an action transitions to "approved", so - // requiring them here guarantees the audit log always records the resolving user and whether it - // was applied automatically. For an auto-approval, `resolvedBy` is the user who enabled the rule. - async applyPendingAction(record: ActionRecord & {type: "action"}, - resolvedBy: AiChatAuthorInfo, autoApproved: boolean): Promise { - let gatekeeper = this.getGatekeeperFacet(record.gatekeeperId); - // The apply-time cache stub is scoped to the gatekeeper AND to this action (approval can - // happen long after the session that queued it, so the queue-time stub is gone) -- the - // binding that makes buildPack() serve exactly this action's pending-push closure. - await gatekeeper.applyAction(record.action, - new GitCacheImpl(this.gitCache, record.gatekeeperId, record.id)); - record.state = "approved"; - record.appliedAt = new Date(); - record.resolvedBy = resolvedBy; - record.autoApproved = autoApproved; - // One durable step for the completion record and the mark conversion (pushed objects are - // now proven on the remote), so a crash between the push and here strands nothing locally - // -- the remote side of that window is the gatekeeper's applyAction idempotency - // responsibility. - this.storage.transaction(() => { - this.gitCache.convertPushMarksToOnRemote(record.id); - this.storage.actions.put(record); - }); + // Reconcile authorized approvals and outstanding vetoes through the single-flight driver. + applyDecidedActions(gatekeeperId: number, manualApproval?: ManualApproval): Promise { + return this.#actionSync.apply(gatekeeperId, manualApproval); } - // Apply all currently-eligible pending actions of the given gatekeeper, in ascending id order. - // Stops at the first pending action that is NOT auto-eligible (i.e. a manual gate) or that throws - // while applying -- it is never skipped ahead of. This preserves in-order application and the - // invariant that nothing is silently applied past a human gate. - // - // Delegates to the single-flight drainer, which guards against concurrent drains for the same - // gatekeeper double-applying an action (the DO's input gate is open across the apply await). - drainAutoApprovals(gatekeeperId: number): Promise { - return this.#autoApprovalDrainer.drain(gatekeeperId); + /** Runs one explicit action prefix through the serialized action driver. */ + applyActionBatch( + boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], + resolvedBy: AiChatAuthorInfo): Promise { + return this.#actionSync.applyThrough(boundary, vetoes, resolvedBy); + } + + rejectPendingAction(record: GatekeeperActionRecord, resolvedBy: AiChatAuthorInfo): Promise { + return this.#actionSync.reject(record, resolvedBy); } // Blocks other messages and agent turns for this chat until the returned object is disposed. @@ -5522,10 +5556,12 @@ class OverseerImpl implements AgentHooks { } // Pushes still queued against this gatekeeper can never apply once it is gone; clean up - // their pending-push marks like a rejection would. (The action records themselves remain, - // as the audit log; onRemote/pullableFrom metadata also remains -- a wrong entry only makes - // a future pull fail with its "reconnect" error.) - for (let action of Array.from(this.storage.actions.pendingByGatekeeper.get(id))) { + // their pending-push marks like a rejection would -- including a staged veto's, whose + // delivery was never acknowledged. (The action records themselves remain, as the audit log; + // onRemote/pullableFrom metadata also remains -- a wrong entry only makes a future pull fail + // with its "reconnect" error.) + for (let action of [...this.storage.actions.pendingByGatekeeper.get(id), + ...this.storage.actions.vetoPendingByGatekeeper.get(id)]) { if (action.type === "action" && action.description.pushedCommits?.length) { this.gitCache.clearPushMarks(action.id); } @@ -5962,7 +5998,17 @@ class OverseerImpl implements AgentHooks { let gatekeeper = this.storage.gatekeepers.get(gatekeeperId); - let record: ActionRecord = { + // Auto-approval gate, named because awaitDecision uses it too. Applying is deferred: it calls + // back into the gatekeeper facet still awaiting submitAction. + let willAutoApprove = !!(description.autoApprovable && description.actionKind && + this.storage.autoApproveTags.get(`${gatekeeperId}:${description.actionKind.tag}`) !== undefined); + + // Only agent turns suspend on awaitDecision, and only when a manual decision is pending. + // Auto-approved actions keep the seamless behavior the user opted into. + let suspendsTurn = caller.from === "agent" && description.awaitDecision === true && + !willAutoApprove; + + let record: GatekeeperActionRecord = { id: actionId, gatekeeperId, caller, @@ -5972,7 +6018,8 @@ class OverseerImpl implements AgentHooks { createdAt: new Date(), state: "pending", type: "action", - description + description, + suspendedTurn: suspendsTurn, }; // The marking walk stamps the verified push closure "pending push" -- the read grant that @@ -5986,19 +6033,12 @@ class OverseerImpl implements AgentHooks { }); this.#associateAction(caller, actionId); - // Same auto-approval gate as before, named because awaitDecision uses it too. The drain is - // deferred because applying calls back into the gatekeeper facet still awaiting submitAction. - let willAutoApprove = !!(description.autoApprovable && description.actionKind && - this.storage.autoApproveTags.get(`${gatekeeperId}:${description.actionKind.tag}`) !== undefined); - - // Only agent turns suspend on awaitDecision, and only when a manual decision is pending. - // Auto-approved actions keep the seamless behavior the user opted into. - if (caller.from === "agent" && description.awaitDecision && !willAutoApprove) { + if (caller.from === "agent" && suspendsTurn) { this.#getOrCreateCapturedActions(caller.chatId).awaitDecision = true; } if (willAutoApprove) { - this.ctx.waitUntil(this.drainAutoApprovals(gatekeeperId)); + this.ctx.waitUntil(this.applyDecidedActions(gatekeeperId)); } } @@ -11107,6 +11147,31 @@ class OverseerClientInterface extends RpcTarget implements Overseer { }; } + async applyActionsThrough(id: number, vetoes: number[]): Promise { + let boundary = this.impl.storage.actions.get(id); + if (!boundary) throw new Error(`No such action: ${id}`); + if (boundary.type !== "action") throw new Error(`Not an action: ${id}`); + + let selected: GatekeeperActionRecord[] = []; + for (let vetoId of vetoes) { + let veto = this.impl.storage.actions.get(vetoId); + if (!veto) throw new Error(`No such action: ${vetoId}`); + if (veto.type !== "action") throw new Error(`Not an action: ${vetoId}`); + if (veto.gatekeeperId !== boundary.gatekeeperId) { + throw new Error("Action batch contains a different connection."); + } + if (veto.action > boundary.action) { + throw new Error("Veto is beyond the action batch boundary."); + } + selected.push(veto); + } + + let profile = await this.#getClientProfile(); + let {decided, stoppedAt} = await this.impl.applyActionBatch(boundary, selected, profile); + await this.#resumeDecidedActionChats(decided); + if (stoppedAt !== undefined) throw createActionError(ACTION_ERROR_CODES.stopped); + } + async approveAction(id: number): Promise { let action = this.impl.storage.actions.get(id); if (!action) { @@ -11126,17 +11191,34 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // Resolve the approver's identity before applying, so a failed profile fetch can't leave the // action applied in the world but still "pending" in storage. let profile = await this.#getClientProfile(); - await this.impl.applyPendingAction(action, profile, false); - // If this was an awaited agent action, resume only after all awaited actions in the turn are - // approved. If applyPendingAction throws, the action stays pending and the turn stays suspended. - if (action.caller.from === "agent" && action.description.awaitDecision) { - await this.#maybeResumeAfterActionDecision(action.caller.chatId); + // Applies this action plus any earlier undecided action an auto-approval rule already + // authorizes; an undecided action with neither authority stops the pass below it. + let {decided, blockedBy, stoppedAt} = await this.impl.applyDecidedActions( + action.gatekeeperId, {action: action.action, resolvedBy: profile}); + + await this.#resumeDecidedActionChats(decided); + + // Report the real outcome: the client displays a resolved approval optimistically, so anything + // other than "approved" must surface as an error. + let fresh = this.impl.storage.actions.get(id); + if (fresh?.type !== "action" || fresh.state === "approved") return; + + // Rejected: a veto this pass delivered cascade-invalidated it, or another client rejected it + // while the pass was in flight. Either way it was not applied. + if (fresh.state === "rejected") { + throw new Error(fresh.cascadedFrom !== undefined + ? `Action was invalidated by a rejected earlier action: ${id}` + : `Action was rejected: ${id}`); } - // Clearing this manual gate may unblock later auto-eligible pending actions on the same - // gatekeeper, so cascade a drain (in-order) once this one is applied. - this.impl.ctx.waitUntil(this.impl.drainAutoApprovals(action.gatekeeperId)); + // Still pending: an earlier undecided action held the frontier below this one, or the + // gatekeeper stopped at or below it. Either way, surface the reason the user can act on. + if (blockedBy !== undefined) { + throw createActionError(ACTION_ERROR_CODES.blocked); + } + if (stoppedAt !== undefined) throw createActionError(ACTION_ERROR_CODES.stopped); + throw new Error("Couldn't apply this action; an earlier action on this connection needs attention."); } async listHooks(): Promise { @@ -11210,10 +11292,31 @@ class OverseerClientInterface extends RpcTarget implements Overseer { return this.impl.deleteHook(id); } + // Resume every genuinely suspended chat independently; one broken chat must not strand another. + async #resumeDecidedActionChats(decided: readonly number[]): Promise { + let chatIds = new Set(); + for (let recordId of decided) { + let record = this.impl.storage.actions.get(recordId); + if (record?.type === "action" && record.caller.from === "agent" && + suspendedAgentTurn(record)) { + chatIds.add(record.caller.chatId); + } + } + for (let chatId of chatIds) { + try { + await this.#maybeResumeAfterActionDecision(chatId); + } catch (err) { + this.impl.logger.warn("failed to resume turn after action decision", { + event: "action.resume.failed", chatId, error: err, + }); + } + } + } + // Resume a turn suspended on awaitDecision once all awaited actions from that turn are approved. // Scoping to the current turn prevents older rejected actions from blocking future resumes. async #maybeResumeAfterActionDecision(chatId: number): Promise { - let awaited: (ActionRecord & {type: "action"})[] = []; + let awaited: GatekeeperActionRecord[] = []; for (let msg of this.impl.storage.chats.list( {prefix: `${keyString(chatId)}.`, reverse: true})) { // Stop at whatever started the current turn: a user/gadget message or a gadget callback. @@ -11225,8 +11328,8 @@ class OverseerClientInterface extends RpcTarget implements Overseer { } if (msg.type === "action") { let record = this.impl.storage.actions.get(msg.actionId); - if (record && record.type === "action" && - record.caller.from === "agent" && record.description.awaitDecision) { + if (record?.type === "action" && record.caller.from === "agent" && + suspendedAgentTurn(record)) { awaited.push(record); } } @@ -11265,23 +11368,11 @@ class OverseerClientInterface extends RpcTarget implements Overseer { throw new Error(`Can't reject an observation: ${id}`); } - let gatekeeper = this.impl.getGatekeeperFacet(action.gatekeeperId); - - // Resolve the rejecter's identity before notifying the gatekeeper, so a failed profile fetch - // can't leave the action rejected with the gatekeeper but still "pending" in storage. + // Resolve the rejecter's identity first, so a failed profile fetch can't leave the action + // half-rejected. let profile = await this.#getClientProfile(); - await gatekeeper.rejectAction(action.action); - - action.state = "rejected"; - action.appliedAt = new Date(); - action.resolvedBy = profile; - // A rejected push's pending-push marks are removed in the same durable step as the state - // change (nothing was transmitted, so nothing became proven). No-op for pushless actions. - this.impl.storage.transaction(() => { - this.impl.gitCache.clearPushMarks(action.id); - this.impl.storage.actions.put(action); - }); + await this.impl.rejectPendingAction(action, profile); // Deny leaves the turn ended, like denyConnectionRequest. The rejected record also prevents a // sibling approval from resuming this turn. @@ -11289,8 +11380,8 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // Enable auto-approval of actions carrying `actionKind` on the given gatekeeper. Stores the // opt-in rule (one of the two gates required to auto-apply -- the action's own `autoApprovable` - // verdict is the other) with the kind's display label, and immediately drains any pending - // actions that this newly unblocks. Auto-approval rules are workspace-wide per gatekeeper. + // verdict is the other) with the kind's display label, then runs an apply pass so any pending + // action this newly authorizes goes out now. Rules are workspace-wide per gatekeeper. async setAutoApprovedActionKind(gatekeeperId: WorkpieceId, actionKind: ActionKind) : Promise { let gatekeeper = this.impl.storage.gatekeepers.get(gatekeeperId); @@ -11305,7 +11396,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { enabledBy: profile, }); // Apply the currently-visible pending action(s) with this tag right away. - this.impl.ctx.waitUntil(this.impl.drainAutoApprovals(gatekeeperId)); + this.impl.ctx.waitUntil(this.impl.applyDecidedActions(gatekeeperId)); } // Remove the auto-approval rule for `tag` on the given gatekeeper, so future matching actions @@ -12344,6 +12435,7 @@ class UseOverseerInterface extends RpcTarget implements Overseer { return {entries: []}; } async approveAction(_id: number): Promise { this.#deny(); } + async applyActionsThrough(_id: number, _vetoes: number[]): Promise { this.#deny(); } async rejectAction(_id: number): Promise { this.#deny(); } async listHooks(): Promise { this.#deny(); } async enableHook(_id: number): Promise { this.#deny(); } diff --git a/packages/workshop-backend/tsconfig.vitest.json b/packages/workshop-backend/tsconfig.vitest.json new file mode 100644 index 0000000000..49df8fa04d --- /dev/null +++ b/packages/workshop-backend/tsconfig.vitest.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "__tests__/test-worker.ts"] +} diff --git a/packages/workshop-backend/vitest.config.ts b/packages/workshop-backend/vitest.config.ts index 94057ac72f..b465877ee6 100644 --- a/packages/workshop-backend/vitest.config.ts +++ b/packages/workshop-backend/vitest.config.ts @@ -30,13 +30,13 @@ const textModules: Plugin = { /** * Tests run inside workerd (via vitest-pool-workers) so they exercise the same runtime APIs as * production -- e.g. Uint8Array.toHex/fromHex and crypto.subtle used by the sharing module. Most - * tests import modules directly; the main Worker and a test-only SQLite DO binding support the - * Overseer cost-persistence integration test without loading the full deployment configuration. + * tests import modules directly; the main Worker and test-only SQLite DO bindings support focused + * Overseer integration tests without loading the full deployment configuration. */ export default defineConfig({ plugins: [ textModules, - capnwebValidate(), + capnwebValidate({ tsconfig: 'tsconfig.vitest.json' }), cloudflareTest({ // The production Worker plus test-only entrypoints (see __tests__/test-worker.ts). main: './__tests__/test-worker.ts', @@ -54,6 +54,10 @@ export default defineConfig({ // Never addressed by name: a binding is what puts the class in `ctx.exports`, from // which the overseer instantiates it (with props) as one of its own facets. TEST_AGENT_SPAWNER: { className: 'AgentSpawnerGatekeeper', useSQLite: true }, + TEST_GIT_PACK_GATEKEEPER: { + className: 'TestGitPackGatekeeper', + useSQLite: true, + }, TEST_USER_DIRECTORY: { className: 'UserDirectoryDurableObject', useSQLite: true }, }, }, From dc512f29c89114cbb9427265b3dc45c14e1595b4 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 17 Sep 2026 12:59:02 -0500 Subject: [PATCH 03/20] Show why an action was not applied A pass that stops leaves its action pending with the gatekeeper's reason, so the Workshop has to present a pending card that already failed once. `ActionFailureNote` renders that text on both surfaces, Activity distinguishes a cascade invalidation from a direct denial, and `useResolveAction` classifies the expected outcomes by error code instead of message text. `useActions` gains `deliverEntry` so one consumer's throwing listener can neither abort delivery to the others nor abort the mount-time replay before its effect returns the cleanup that releases the shared subscription. --- packages/workshop-frontend/src/Activity.tsx | 24 ++++- .../src/ChatInterface.actions.test.tsx | 82 +++++++++++++--- .../workshop-frontend/src/ChatInterface.tsx | 11 ++- .../src/components/ActionFailureNote.tsx | 11 +++ packages/workshop-frontend/src/useActions.ts | 24 ++--- .../src/useResolveAction.test.tsx | 96 +++++++++++++++++++ .../workshop-frontend/src/useResolveAction.ts | 9 +- 7 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 packages/workshop-frontend/src/components/ActionFailureNote.tsx create mode 100644 packages/workshop-frontend/src/useResolveAction.test.tsx diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx index b8e1505f8e..c32a9b349f 100644 --- a/packages/workshop-frontend/src/Activity.tsx +++ b/packages/workshop-frontend/src/Activity.tsx @@ -4,6 +4,7 @@ import { CaretRight, Check, Eye, Lightning, ShieldCheck } from '@phosphor-icons/ import { RpcStub } from 'capnweb' import { ActionLogEntry, Overseer, actionChangeTime } from '@gadgets/workshop-shared/api' import { ActionKind } from '@gadgets/workshop-shared/gatekeeper' +import { ActionFailureNote } from './components/ActionFailureNote' import { GatekeeperIcon } from './components/GatekeeperIcon' import { HookToggle } from './components/HookToggle' import { AlwaysApproveButton, ResolveButton } from './components/ResolveButton' @@ -98,11 +99,23 @@ function activityStatus( return { label: 'Pending', dotClass: 'bg-kumo-brand', textClass: 'text-kumo-strong' } } if (record.state === 'rejected') { - return { label: 'Denied', dotClass: 'bg-kumo-danger', textClass: 'text-kumo-danger' } + return { + label: record.cascadedFrom === undefined ? 'Denied' : 'Invalidated', + dotClass: 'bg-kumo-danger', + textClass: 'text-kumo-danger', + } } return { label: 'Approved', dotClass: 'bg-kumo-success', textClass: 'text-kumo-subtle' } } +// A cascade-invalidated action inherits the resolver of the rejection that took it down, so it must +// not read as a direct decision on this action. +function resolverLabel(record: ActionLogEntry, name: string): string { + if (record.type !== 'action') return `By ${name}` + if (record.cascadedFrom !== undefined) return `Invalidated by ${name}'s earlier rejection` + return record.autoApproved === true ? `Auto-approved (${name}'s rule)` : `By ${name}` +} + function TypeIcon({ record, className }: { record: ActionLogEntry; className?: string }) { const props = { size: 13, weight: 'bold' as const, className } if (record.type === 'observation') return @@ -656,6 +669,9 @@ function ReviewRequest({ {record.description.description}

)} + {record.type === 'action' && record.failure && ( + + )} ) } @@ -675,7 +691,6 @@ function HistoryRow({ }) { const resourceUrl = safeExternalUrl(record.resourceUrl) const resolvedBy = record.type === 'action' ? record.resolvedBy : undefined - const autoApproved = record.type === 'action' && record.autoApproved === true const at = actionChangeTime(record) const status = activityStatus(record) @@ -716,12 +731,15 @@ function HistoryRow({ {record.description.description}

)} + {record.type === 'action' && record.failure && ( + + )}
{formatFullDate(at)} {record.resourceTitle} {resolvedBy && ( - {autoApproved ? `Auto-approved (${resolvedBy.name}'s rule)` : `By ${resolvedBy.name}`} + {resolverLabel(record, resolvedBy.name)} )} {resourceUrl && ( diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index cb8130f6d4..80a4fc0a4b 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -10,6 +10,9 @@ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) +// jsdom implements neither, and the thread view scrolls itself to the newest message on render. +Element.prototype.scrollTo = () => {} +Element.prototype.scrollIntoView = () => {} vi.mock('@cloudflare/kumo', async (importOriginal) => { const actual = await importOriginal() as typeof import('@cloudflare/kumo') @@ -40,7 +43,7 @@ vi.mock('./AuthContext', () => { } }) -import { entry, makeOverseer, makeTestRoot } from './action-test-harness' +import { entry, flushFrames, makeOverseer, makeTestRoot } from './action-test-harness' import ChatInterface from './ChatInterface' import { linkActionLog } from './useActions' @@ -58,6 +61,7 @@ function withChatApi( let subscriber: AiChatSubscriber | undefined Object.assign(server.overseer as object, { getChatMessage, + getChatHistory: async () => ({ messages: [] }), listChats: async () => [], listModels: async () => [], onRpcBroken: () => {}, @@ -74,12 +78,12 @@ function withChatApi( } } -function renderChat(overseer: RpcStub) { +function renderChat(overseer: RpcStub, selectedChatId: number | null = null) { return testRoot.render( {}} pendingConsoleLogCount={0} consoleLogPreview="" @@ -102,40 +106,88 @@ const actionMessage = { actionLog: entry(1), } as AiChatMessage -const resolvedMessage = - { ...actionMessage, actionLog: entry(1, { state: 'approved' }) } as AiChatMessage - // Renders a first session that caches a pending action card, then settles it so a linked swap // can resume. Pass a key to link the stub; unlinked sessions never park a watermark. async function cachePendingCard(key?: string) { const first = makeOverseer() const firstChat = withChatApi(first) if (key !== undefined) linkActionLog(first.overseer, key) - await renderChat(first.overseer) + await renderChat(first.overseer, 1) await first.resolveSubscription() - await first.resolvePendingQuery({ entries: [entry(1)] }) + await first.resolvePendingQuery({ entries: [entry(1), entry(2)] }) firstChat.emitMessage(actionMessage) + flushFrames() } describe('ChatInterface action refresh', () => { - it('refetches cached mutable cards when an unlinked stub swaps', async () => { + it('shows a missed failure on a cached card after a stub swap', async () => { await cachePendingCard() + const failed = entry(1, { failure: 'page was deleted while disconnected' }) const second = makeOverseer() - const secondChat = withChatApi(second, vi.fn(async () => resolvedMessage)) - await renderChat(second.overseer) + const secondChat = withChatApi(second, vi.fn(async () => + ({ ...actionMessage, actionLog: failed }) as AiChatMessage)) + await renderChat(second.overseer, 1) + await second.resolveSubscription() + await second.resolvePendingQuery({ entries: [failed, entry(2)] }) await vi.waitFor(() => expect(secondChat.getChatMessage).toHaveBeenCalledWith(1, 0)) + flushFrames() + + expect(document.body.textContent).toContain('page was deleted while disconnected') }) - it('skips the cached-card refetch on a resumed linked stub swap', async () => { + it('lets a resumed reconnect replay the gap instead of refetching', async () => { await cachePendingCard('ws-chat-resume') + const failed = entry(1, { failure: 'page was deleted while disconnected' }) const second = makeOverseer() - const secondChat = withChatApi(second, vi.fn(async () => resolvedMessage)) + const secondChat = withChatApi(second) linkActionLog(second.overseer, 'ws-chat-resume') - await renderChat(second.overseer) + await renderChat(second.overseer, 1) await second.resolveSubscription() - await second.resolvePendingQuery({ entries: [entry(1)] }) + await second.resolvePendingQuery({ entries: [failed, entry(2)] }) + await second.emit(failed) + flushFrames() + expect(secondChat.getChatMessage).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('page was deleted while disconnected') + }) + + it('does not let a stale refresh regress a card resolved by the new subscription', async () => { + await cachePendingCard() + + let resolveFetch!: (message: AiChatMessage | null) => void + const fetched = new Promise(resolve => { resolveFetch = resolve }) + const second = makeOverseer() + const secondChat = withChatApi(second, vi.fn(() => fetched)) + await renderChat(second.overseer, 1) + await vi.waitFor(() => expect(secondChat.getChatMessage).toHaveBeenCalledWith(1, 0)) + await second.resolveSubscription() + await second.resolvePendingQuery({ entries: [entry(1), entry(2)] }) + await second.emit(entry(1, { state: 'approved' })) + flushFrames() + + await act(async () => resolveFetch({ + ...actionMessage, + actionLog: entry(1, { failure: 'stale failure' }), + } as AiChatMessage)) + flushFrames() + + expect(document.body.textContent).toContain('Approved') + expect(document.body.textContent).not.toContain('stale failure') + }) +}) + +describe('ChatInterface action failure note', () => { + it("shows the gatekeeper's reason on a pending action card", async () => { + const failed = entry(1, { failure: 'page was deleted upstream' }) + const server = makeOverseer() + const chat = withChatApi(server) + await renderChat(server.overseer, 1) + await server.resolveSubscription() + await server.resolvePendingQuery({ entries: [failed] }) + chat.emitMessage({ ...actionMessage, actionLog: failed } as AiChatMessage) + + expect(document.body.textContent).toContain('page was deleted upstream') }) }) diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 2cc4cd540d..3bb2be430e 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -90,6 +90,7 @@ import { useSlashCommandChoice, type OverseerSource, } from "./components/chat/slash-command-catalog"; import GatekeeperModal from "./GatekeeperModal"; +import { ActionFailureNote } from "./components/ActionFailureNote"; import { GatekeeperIcon } from "./components/GatekeeperIcon"; import { formatOf, FORMAT_ICONS } from "./components/format/formats"; import { FormatMiniature } from "./components/format/FormatVisuals"; @@ -4264,9 +4265,13 @@ function ChatInterface({ } const nextMessages = [...cached.messages]; + const log = cached.msg.actionLog; nextMessages[location.sequence] = { ...cached.msg, - actionLog: { ...cached.msg.actionLog, state, appliedAt: new Date() }, + // Approval clears the recorded failure, as the server does; a rejection retains it. + actionLog: log.type === "action" && state === "approved" + ? { ...log, state, appliedAt: new Date(), failure: undefined } + : { ...log, state, appliedAt: new Date() }, }; cacheRef.current.messages.set(location.chatId, nextMessages); changed = true; @@ -4326,7 +4331,7 @@ function ChatInterface({ >(null); // 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 + // server applies the now-eligible pending action(s) in an apply pass, and the state flips to // "approved" through the actions subscription -- so we don't optimistically mutate it here. const { alwaysApproveTag, isTagAutoApproved } = useAlwaysApproveTag(overseer, setProcessingActions, onAutoApproveChange); @@ -5008,6 +5013,7 @@ function ChatInterface({
+ {log.failure && }
{actionControls} @@ -5068,6 +5074,7 @@ function ChatInterface({
+ {log.failure && } {resourceMeta}
)} diff --git a/packages/workshop-frontend/src/components/ActionFailureNote.tsx b/packages/workshop-frontend/src/components/ActionFailureNote.tsx new file mode 100644 index 0000000000..9ae3679033 --- /dev/null +++ b/packages/workshop-frontend/src/components/ActionFailureNote.tsx @@ -0,0 +1,11 @@ +import { WarningIcon } from '@phosphor-icons/react' + +/** The gatekeeper's display-safe reason the last apply attempt stopped at this action. */ +export function ActionFailureNote({ failure }: { failure: string }) { + return ( +
+ + {failure} +
+ ) +} diff --git a/packages/workshop-frontend/src/useActions.ts b/packages/workshop-frontend/src/useActions.ts index 67b66a7ada..27fc4c1955 100644 --- a/packages/workshop-frontend/src/useActions.ts +++ b/packages/workshop-frontend/src/useActions.ts @@ -128,6 +128,16 @@ function resetSession(store: Store): number { return store.generation } +// One consumer's failure must not abort delivery to the others, nor abort the mount-time replay +// before its effect returns the cleanup that releases the shared subscription. +function deliverEntry(listener: (record: ActionLogEntry) => void, record: ActionLogEntry) { + try { + listener(record) + } catch (err) { + console.error('Action entry listener failed:', err) + } +} + function trackChange(store: Store, record: ActionLogEntry): void { const changed = actionChangeTime(record) if (!store.lastChanged || changed > store.lastChanged) store.lastChanged = changed @@ -154,13 +164,7 @@ function openSubscription(overseer: RpcStub, store: Store) { // Entries that never touch the pending set (observations, hook events) don't need a // re-sorted snapshot or a consumer re-render. if (pendingChanged) scheduleNotify(store) - for (const listener of store.entryListeners) { - try { - listener(record) - } catch (err) { - console.error('Action entry listener failed:', err) - } - } + for (const listener of store.entryListeners) deliverEntry(listener, record) } // Settledness is signalled by the pending page loop draining, not by the subscription. @@ -185,7 +189,7 @@ function openSubscription(overseer: RpcStub, store: Store) { const subscribed = startAfter ? overseer.subscribeToActions(subscriber, startAfter) : overseer.subscribeToActions(subscriber) - subscribed.then(sub => { + subscribed.then((sub: RpcStub<{}>) => { if (store.generation !== generation) { sub[Symbol.dispose]() return @@ -298,9 +302,7 @@ export function useActionEntries( // Retained until `release()` drops refCount to 0 and deletes the store, so late // consumers can replay already-received entries while a shared subscription is still alive. - for (const record of store.stagedEntries.values()) { - listener(record) - } + for (const record of store.stagedEntries.values()) deliverEntry(listener, record) return () => { const s = stores.get(overseer) diff --git a/packages/workshop-frontend/src/useResolveAction.test.tsx b/packages/workshop-frontend/src/useResolveAction.test.tsx new file mode 100644 index 0000000000..bb667b0fff --- /dev/null +++ b/packages/workshop-frontend/src/useResolveAction.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import { + ACTION_ERROR_CODES, + ACTION_ERROR_MESSAGES, + createActionError, + type ActionErrorCode, + type Overseer, +} from '@gadgets/workshop-shared/api' +import { makeTestRoot } from './action-test-harness' +import { useResolveAction } from './useResolveAction' + +const testState = vi.hoisted(() => ({ + addToast: vi.fn<(toast: unknown) => void>(), +})) + +vi.mock('@cloudflare/kumo', () => ({ + useKumoToastManager: () => ({ add: testState.addToast }), +})) + +type ResolveAction = (actionId: number, decision: 'approve' | 'deny') => Promise + +const view = makeTestRoot() +let resolveAction: ResolveAction +let processing = new Set() + +function Probe({ overseer, onResolved }: { + overseer: RpcStub + onResolved: (actionId: number, state: 'approved' | 'rejected') => void +}) { + const [currentProcessing, setProcessing] = useState>(new Set()) + processing = currentProcessing + resolveAction = useResolveAction(overseer, setProcessing, onResolved) + return null +} + +function failingOverseer(error: unknown): RpcStub { + return { + approveAction: async () => { throw error }, + rejectAction: async () => { throw error }, + } as unknown as RpcStub +} + +describe('useResolveAction', () => { + afterEach(() => { + view.cleanup() + testState.addToast.mockClear() + vi.restoreAllMocks() + }) + + it.each([ + ACTION_ERROR_CODES.blocked, + ACTION_ERROR_CODES.stopped, + ])('maps %s to trusted copy instead of its diagnostic message', async ( + code: ActionErrorCode, + ) => { + const error = createActionError(code) + error.message = `spoofed diagnostic for ${code}` + const onResolved = vi.fn<() => void>() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await view.render() + + await act(async () => resolveAction(7, 'approve')) + + expect(testState.addToast).toHaveBeenCalledWith({ + title: ACTION_ERROR_MESSAGES[code], + variant: 'error', + }) + expect(onResolved).not.toHaveBeenCalled() + expect(processing.has(7)).toBe(false) + }) + + it('does not expose diagnostics from unknown RPC failures', async () => { + const onResolved = vi.fn<() => void>() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await view.render( + , + ) + + await act(async () => resolveAction(9, 'approve')) + + expect(testState.addToast).toHaveBeenCalledWith({ + title: expect.not.stringContaining('private upstream diagnostic'), + variant: 'error', + }) + expect(onResolved).not.toHaveBeenCalled() + expect(processing.has(9)).toBe(false) + }) +}) diff --git a/packages/workshop-frontend/src/useResolveAction.ts b/packages/workshop-frontend/src/useResolveAction.ts index 11607851ba..68f730140c 100644 --- a/packages/workshop-frontend/src/useResolveAction.ts +++ b/packages/workshop-frontend/src/useResolveAction.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, type Dispatch, type SetStateAction } from 'react' import { useKumoToastManager } from '@cloudflare/kumo' import type { RpcStub } from 'capnweb' +import { ACTION_ERROR_MESSAGES, getActionErrorCode } from '@gadgets/workshop-shared/api' import type { ActionState, Overseer } from '@gadgets/workshop-shared/api' type ActionDecision = 'approve' | 'deny' @@ -22,7 +23,13 @@ export function useResolveAction( 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' }) + const code = getActionErrorCode(error) + toasts.add({ + title: code === undefined + ? 'Couldn’t confirm the action’s outcome. Check its status.' + : ACTION_ERROR_MESSAGES[code], + variant: 'error', + }) } finally { setProcessing(previous => { const next = new Set(previous) From a757f8413027beb6e6cdd7cc567df96ef0546ab8 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 17 Sep 2026 14:03:49 -0500 Subject: [PATCH 04/20] Simplify the apply-through plumbing Make the driver's decision queue the sole batch-validation authority, reduce PassResult's blocked/stopped to the booleans callers consume, drop a re-check the vetoPending index already guarantees, resume multi-chat suspensions in parallel, and consolidate duplicated test scaffolding behind makeBatchGatekeeper's parkAt and one parameterized mid-build invalidation test. --- .../__tests__/actions.test.ts | 267 +++++++++--------- .../workshop-backend/__tests__/fixtures.ts | 8 +- .../__tests__/git-push-actions.test.ts | 146 ++++------ packages/workshop-backend/src/actions.ts | 59 ++-- packages/workshop-backend/src/git-cache.ts | 6 +- packages/workshop-backend/src/overseer.ts | 59 ++-- .../{components => }/ActionFailureNote.tsx | 0 packages/workshop-frontend/src/Activity.tsx | 2 +- .../src/ChatInterface.actions.test.tsx | 5 +- .../workshop-frontend/src/ChatInterface.tsx | 2 +- packages/workshop-frontend/src/useActions.ts | 2 +- packages/workshop-shared/src/gatekeeper.ts | 21 +- 12 files changed, 272 insertions(+), 305 deletions(-) rename packages/workshop-frontend/src/{components => }/ActionFailureNote.tsx (100%) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index 454019dfff..d8c1bb130a 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -1,4 +1,5 @@ import { env } from "cloudflare:workers"; +import { runInDurableObject } from "cloudflare:test"; import { describe, it, expect, vi } from "vitest"; import { ActionSyncDriver, ActionSyncStorage, GatekeeperActionTarget, isMethodMissing, @@ -7,7 +8,7 @@ import type { ActionRecord, GatekeeperActionRecord, OverseerDurableObject, } from "../src/overseer.js"; import { - ACTION_ERROR_CODES, getActionErrorCode, type ActionLogEntry, type AiChatAuthorInfo, + ACTION_ERROR_CODES, getActionErrorCode, type ActionLogEntry, type AiChatAuthorInfo, type Overseer, } from "@gadgets/workshop-shared/api"; import type { ApplyActionsThroughResult } from "@gadgets/workshop-shared/gatekeeper"; import type { ManualApproval } from "../src/actions.js"; @@ -80,12 +81,18 @@ function getAction(storage: ActionSyncStorage, action: number): GatekeeperAction } // A migrated gatekeeper stub: records every batch call and answers from a scripted queue (or {}). -function makeBatchGatekeeper() { +// A call whose frontier matches `parkAt` ("every" matches all) waits until release() (oldest +// first), so tests can hold a pass mid-RPC. +function makeBatchGatekeeper(opts: {parkAt?: number | "every"} = {}) { let calls: Array<{actionId: number, vetoes: number[]}> = []; let results: Array = []; + let parked: Array<() => void> = []; let target = { async applyActionsThrough(actionId: number, vetoes: number[]) { calls.push({ actionId, vetoes }); + if (opts.parkAt === "every" || opts.parkAt === actionId) { + await new Promise(resolve => parked.push(resolve)); + } let next = results.shift() ?? {}; if (next instanceof Error) throw next; return next; @@ -93,7 +100,7 @@ function makeBatchGatekeeper() { async applyAction() { throw new Error("legacy applyAction must not be called"); }, async rejectAction() { throw new Error("legacy rejectAction must not be called"); }, } as unknown as GatekeeperActionTarget; - return { target, calls, results }; + return { target, calls, results, release: () => parked.shift()!() }; } // A pre-migration live stub rejects the batch method probe, then serves legacy per-action calls. @@ -138,9 +145,8 @@ function makeClient(storage: ActionSyncStorage, target: GatekeeperActionTarget) driver.apply(gatekeeperId, approval), rejectPendingAction: (record: GatekeeperActionRecord, author: AiChatAuthorInfo) => driver.reject(record, author), - applyActionBatch: ( - boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], - author: AiChatAuthorInfo) => driver.applyThrough(boundary, vetoes, author), + applyActionBatch: (boundaryId: number, vetoIds: readonly number[], + author: AiChatAuthorInfo) => driver.applyThrough(boundaryId, vetoIds, author), } }); } @@ -185,7 +191,7 @@ describe("ActionSyncDriver.apply", () => { let driver = makeDriver(storage, target); expect(await driver.apply(GK, { action: 2, resolvedBy: APPROVER })) - .toEqual({ decided: [], blockedBy: "Action 1" }); + .toEqual({ decided: [], blocked: true }); expect(calls).toEqual([]); // The refusal is transient queue state, reported to the clicker rather than recorded, so it // can't go stale on the record or later be mistaken for a gatekeeper failure. @@ -241,24 +247,6 @@ describe("ActionSyncDriver.apply", () => { expect(getAction(storage, 3).state).toBe("pending"); }); - it("does not scan resolved or unrelated action history", async () => { - let storage = makeStorage(); - enableRule(storage); - for (let action = 1; action <= 500; action++) { - putAction(storage, action, { state: "approved", gatekeeperId: GK + 1 }); - } - putAction(storage, 501); - putAction(storage, 502, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); - let fullScan = vi.spyOn(storage.actions, "list"); - - let { target, calls } = makeBatchGatekeeper(); - await makeDriver(storage, target).apply(GK); - - expect(fullScan).not.toHaveBeenCalled(); - expect(calls).toEqual([{ actionId: 501, vetoes: [] }]); - expect(getAction(storage, 501).state).toBe("approved"); - expect(getAction(storage, 502).vetoPending).toBe(true); - }); it("makes no call when nothing is eligible", async () => { let storage = makeStorage(); @@ -287,7 +275,7 @@ describe("ActionSyncDriver.apply", () => { let first = await driver.apply(GK, { action: 2, resolvedBy: APPROVER }); expect(first.decided).toEqual([a1]); - expect(first.stoppedAt).toBe(2); + expect(first.stopped).toBe(true); expect(getAction(storage, 1).state).toBe("approved"); let stopped = getAction(storage, 2); expect(stopped.state).toBe("pending"); @@ -346,7 +334,7 @@ describe("ActionSyncDriver.apply", () => { // Clicking 7 first is refused, and must leave no trace that would later be read as a // gatekeeper failure -- otherwise 7 would never auto-apply again. expect(await driver.apply(GK, { action: 7, resolvedBy: APPROVER })) - .toEqual({ decided: [], blockedBy: "Action 5" }); + .toEqual({ decided: [], blocked: true }); await driver.apply(GK, { action: 5, resolvedBy: APPROVER }); @@ -365,7 +353,7 @@ describe("ActionSyncDriver.apply", () => { // A fresh driver over the same storage (e.g. after DO hibernation) sees durable delivery intent, // but transmits it only when an explicit boundary covers it. let { target, calls } = makeBatchGatekeeper(); - await makeDriver(storage, target).applyThrough(getAction(storage, 2), [], REJECTER); + await makeDriver(storage, target).applyThrough(getAction(storage, 2).id, [], REJECTER); expect(calls).toEqual([{ actionId: 2, vetoes: [2] }]); expect(getAction(storage, 2).vetoPending).toBeUndefined(); @@ -381,7 +369,7 @@ describe("ActionSyncDriver.apply", () => { let { target, calls } = makeBatchGatekeeper(); let driver = makeDriver(storage, target); let { decided } = await driver.applyThrough( - getAction(storage, 3), [getAction(storage, 2)], APPROVER); + getAction(storage, 3).id, [getAction(storage, 2).id], APPROVER); expect(calls).toEqual([{ actionId: 3, vetoes: [2] }]); expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a3]); @@ -401,7 +389,7 @@ describe("ActionSyncDriver.apply", () => { let { target, calls } = makeBatchGatekeeper(); await makeDriver(storage, target).applyThrough( - getAction(storage, 2), [getAction(storage, 1), getAction(storage, 2)], REJECTER); + getAction(storage, 2).id, [getAction(storage, 1).id, getAction(storage, 2).id], REJECTER); expect(calls).toEqual([{ actionId: 2, vetoes: [1, 2] }]); expect(getAction(storage, 1).state).toBe("rejected"); @@ -415,9 +403,9 @@ describe("ActionSyncDriver.apply", () => { results.push({ stopped: { at: 0, reason: new Error("zero stopped") } }); let result = await makeDriver(storage, target) - .applyThrough(getAction(storage, 0), [], APPROVER); + .applyThrough(getAction(storage, 0).id, [], APPROVER); - expect(result.stoppedAt).toBe(0); + expect(result.stopped).toBe(true); expect(getAction(storage, 0)).toMatchObject({ state: "pending", failure: "zero stopped" }); }); @@ -430,9 +418,9 @@ describe("ActionSyncDriver.apply", () => { results.push({ stopped: { at: invalidAt, reason: new Error("invalid stop") } }); let result = await makeDriver(storage, target) - .applyThrough(getAction(storage, 3), [], APPROVER); + .applyThrough(getAction(storage, 3).id, [], APPROVER); - expect(result.stoppedAt).toBe(2); + expect(result.stopped).toBe(true); expect(getAction(storage, 2)).toMatchObject({ state: "pending", failure: "The gatekeeper could not apply this action.", @@ -468,7 +456,7 @@ describe("ActionSyncDriver.apply", () => { let { target, results } = makeBatchGatekeeper(); results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); let { decided } = await makeDriver(storage, target) - .applyThrough(getAction(storage, 3), [], REJECTER); + .applyThrough(getAction(storage, 3).id, [], REJECTER); expect(decided).toEqual([a3]); let invalidated = getAction(storage, 3); @@ -509,7 +497,7 @@ describe("ActionSyncDriver.apply", () => { results.push({ invalidatedByVeto: [{ action: 1, invalidatedBy: 2 }] }); let { decided } = await makeDriver(storage, target) - .applyThrough(getAction(storage, 1), [], APPROVER); + .applyThrough(getAction(storage, 1).id, [], APPROVER); expect(decided).toEqual([actionId]); expect(getAction(storage, 1).state).toBe("approved"); @@ -527,7 +515,7 @@ describe("ActionSyncDriver.apply", () => { { action: 99, invalidatedBy: 2 }, // unknown ]}); let { decided } = await makeDriver(storage, target) - .applyThrough(getAction(storage, 2), [], REJECTER); + .applyThrough(getAction(storage, 2).id, [], REJECTER); expect(decided).toEqual([]); expect(getAction(storage, 1).state).toBe("approved"); @@ -539,16 +527,7 @@ describe("ActionSyncDriver.apply", () => { putAction(storage, 2, { autoApprovable: false }); putAction(storage, 3, { autoApprovable: false }); - let calls: Array<{actionId: number, vetoes: number[]}> = []; - let gates: Array<() => void> = []; - let target = { - applyActionsThrough(actionId: number, vetoes: number[]) { - calls.push({ actionId, vetoes }); - return new Promise(resolve => { - gates.push(() => resolve({})); - }); - }, - } as unknown as GatekeeperActionTarget; + let { target, calls, release } = makeBatchGatekeeper({ parkAt: "every" }); let driver = makeDriver(storage, target); let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); // parks mid-RPC @@ -557,11 +536,11 @@ describe("ActionSyncDriver.apply", () => { let third = driver.apply(GK, { action: 2, resolvedBy: APPROVER }); // merged with second expect(calls).toEqual([{ actionId: 1, vetoes: [] }]); - gates.shift()!(); // finish pass 1 + release(); // finish pass 1 await flush(); expect(calls).toEqual([{ actionId: 1, vetoes: [] }, { actionId: 3, vetoes: [] }]); - gates.shift()!(); // finish pass 2 + release(); // finish pass 2 let [a, b, c] = await Promise.all([first, second, third]); expect(a.decided).toEqual([10]); // The coalesced requests share the pass and its decided set. @@ -572,23 +551,16 @@ describe("ActionSyncDriver.apply", () => { it("runs an explicit batch between the in-flight pass and later staged approvals", async () => { let storage = makeStorage(); for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); - let firstCall = Promise.withResolvers(); - let calls: Array<{actionId: number, vetoes: number[]}> = []; - let target = { - async applyActionsThrough(actionId: number, vetoes: number[]) { - calls.push({ actionId, vetoes }); - if (actionId === 1) await firstCall.promise; - return {}; - }, - } as unknown as GatekeeperActionTarget; + let { target, calls, release } = makeBatchGatekeeper({ parkAt: 1 }); let driver = makeDriver(storage, target); let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); await flush(); - let batch = driver.applyThrough(getAction(storage, 2), [getAction(storage, 2)], REJECTER); + let batch = driver.applyThrough( + getAction(storage, 2).id, [getAction(storage, 2).id], REJECTER); let later = driver.apply(GK, { action: 3, resolvedBy: APPROVER }); - firstCall.resolve(); + release(); await Promise.all([first, batch, later]); expect(calls).toEqual([ @@ -604,22 +576,15 @@ describe("ActionSyncDriver.apply", () => { it("revalidates the complete batch after waiting in the decision queue", async () => { let storage = makeStorage(); for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); - let firstCall = Promise.withResolvers(); - let calls: Array<{actionId: number, vetoes: number[]}> = []; - let target = { - async applyActionsThrough(actionId: number, vetoes: number[]) { - calls.push({ actionId, vetoes }); - if (actionId === 1) await firstCall.promise; - return {}; - }, - } as unknown as GatekeeperActionTarget; + let { target, calls, release } = makeBatchGatekeeper({ parkAt: 1 }); let driver = makeDriver(storage, target); let first = driver.apply(GK, { action: 1, resolvedBy: APPROVER }); await flush(); - let batch = driver.applyThrough(getAction(storage, 3), [getAction(storage, 2)], REJECTER); + let batch = driver.applyThrough( + getAction(storage, 3).id, [getAction(storage, 2).id], REJECTER); storage.actions.delete(20); - firstCall.resolve(); + release(); await first; await expect(batch).rejects.toThrow("No such action: 20"); @@ -631,21 +596,14 @@ describe("ActionSyncDriver.apply", () => { let storage = makeStorage(); putAction(storage, 2, { autoApprovable: false }); putAction(storage, 3, { autoApprovable: false }); - let firstCall = Promise.withResolvers(); - let calls: Array<{actionId: number, vetoes: number[]}> = []; - let target = { - async applyActionsThrough(actionId: number, vetoes: number[]) { - calls.push({ actionId, vetoes }); - if (actionId === 2) await firstCall.promise; - return {}; - }, - } as unknown as GatekeeperActionTarget; + let { target, calls, release } = makeBatchGatekeeper({ parkAt: 2 }); let driver = makeDriver(storage, target); let click = driver.apply(GK, { action: 2, resolvedBy: APPROVER }); await flush(); - let batch = driver.applyThrough(getAction(storage, 3), [getAction(storage, 2)], REJECTER); - firstCall.resolve(); + let batch = driver.applyThrough( + getAction(storage, 3).id, [getAction(storage, 2).id], REJECTER); + release(); await Promise.all([click, batch]); expect(calls).toEqual([{ actionId: 2, vetoes: [] }, { actionId: 3, vetoes: [] }]); @@ -728,7 +686,7 @@ describe("ActionSyncDriver.apply", () => { let { target, results } = makeBatchGatekeeper(); results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); let pass = makeDriver(storage, target) - .applyThrough(getAction(storage, 2), [], REJECTER); + .applyThrough(getAction(storage, 2).id, [], REJECTER); let a3 = putAction(storage, 3, { autoApprovable: false }); // arrives while the RPC is in let { decided } = await pass; // flight, so it misses the snapshot @@ -742,18 +700,16 @@ describe("ActionSyncDriver.apply", () => { describe("ActionSyncDriver legacy fallback", () => { it("recognizes workerd's real missing-method error", async () => { let stub = env.TEST_OVERSEER.get(env.TEST_OVERSEER.newUniqueId()); - let call = (stub as any).applyActionsThrough(1, []); + // The DO itself lacks the client interface's batch method; probe workerd's actual rejection. + const receiver = stub as unknown as Fetcher>; + using call = receiver.applyActionsThrough(1, []); let error: unknown; try { await call; } catch (caught) { error = caught; - } finally { - call[Symbol.dispose](); } - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('does not implement "applyActionsThrough"'); expect(isMethodMissing(error)).toBe(true); }); it("does not replay a coded batch failure whose message resembles method-missing prose", @@ -765,12 +721,8 @@ describe("ActionSyncDriver legacy fallback", () => { failure.message = 'The RPC receiver does not implement "applyActionsThrough".'; results.push(failure); - let caught: unknown; - try { - await makeDriver(storage, target).apply(GK, { action: 1, resolvedBy: APPROVER }); - } catch (error) { - caught = error; - } + let caught = await makeDriver(storage, target) + .apply(GK, { action: 1, resolvedBy: APPROVER }).catch(error => error); expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.builderExpired); expect(getAction(storage, 1).state).toBe("pending"); @@ -847,7 +799,7 @@ describe("ActionSyncDriver legacy fallback", () => { let firstDriver = makeDriver(storage, legacy.target); await expect(firstDriver.applyThrough( - getAction(storage, 3), [getAction(storage, 1), getAction(storage, 2)], REJECTER)) + getAction(storage, 3).id, [getAction(storage, 1).id, getAction(storage, 2).id], REJECTER)) .rejects.toThrow("reject 2 failed"); expect(getAction(storage, 1).vetoPending).toBeUndefined(); @@ -857,7 +809,7 @@ describe("ActionSyncDriver legacy fallback", () => { failSecond = false; await makeDriver(storage, legacy.target) - .applyThrough(getAction(storage, 3), [], APPROVER); + .applyThrough(getAction(storage, 3).id, [], APPROVER); expect(legacy.calls).toEqual(["reject:1", "reject:2", "reject:2", "apply:3"]); expect(getAction(storage, 2).vetoPending).toBeUndefined(); @@ -1000,21 +952,6 @@ describe("Overseer action decisions", () => { for (let action of [1, 2, 3, 4]) expect(getAction(storage, action).state).toBe("pending"); }); - it("keeps exact approval blocked by an earlier undecided action", async () => { - let storage = makeStorage(); - putAction(storage, 1, { autoApprovable: false }); - let boundary = putAction(storage, 2, { autoApprovable: false }); - let batch = makeBatchGatekeeper(); - let client = await makeClient(storage, batch.target); - - let error = await client.approveAction(boundary).catch(caught => caught); - - expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.blocked); - expect(batch.calls).toEqual([]); - expect(getAction(storage, 1).state).toBe("pending"); - expect(getAction(storage, 2).state).toBe("pending"); - }); - // Chat 7 suspended but its storage fails; 8 suspended, with an unsuspended sibling in-turn; // 9 suspended but its awaited action was rejected; 10 never suspended; 11 predates the flag // and resumes on the rule that used to set it. @@ -1031,19 +968,18 @@ describe("Overseer action decisions", () => { let a6 = putAction(storage, 6, { chatId: 11, awaitDecision: true }); let notes = vi.fn(); - let listedChats: string[] = []; let client = await openFakeOverseer({ ...storage, chats: { list: ({ prefix }: { prefix: string }) => { - listedChats.push(prefix); if (prefix === `${keyString(7)}.`) throw new Error("chat storage unavailable"); // Chat 8's turn also holds an awaitDecision action that never suspended it. if (prefix === `${keyString(8)}.`) { return [a2, a5].map(actionId => ({ type: "action", actionId })); } - let actionId = prefix === `${keyString(9)}.` ? a3 - : prefix === `${keyString(11)}.` ? a6 : a4; + let actionId = a4; + if (prefix === `${keyString(9)}.`) actionId = a3; + else if (prefix === `${keyString(11)}.`) actionId = a6; return [{ type: "action", actionId }]; }, }, @@ -1072,8 +1008,41 @@ describe("Overseer action decisions", () => { type: "message", message: expect.stringContaining("Action 6"), })]], ]); - expect(listedChats).toEqual([ - `${keyString(7)}.`, `${keyString(8)}.`, `${keyString(9)}.`, `${keyString(11)}.`, + }); + + // The rule pass is the only decision this action will get: nothing later revisits the turn it + // suspended. + it("resumes a chat the newly enabled auto-approval rule unblocks", async () => { + let storage = makeStorage(); + let action = putAction(storage, 1, { chatId: 7, awaitDecision: true, suspendedTurn: true }); + + let notes = vi.fn(); + let waits: Promise[] = []; + let client = await openFakeOverseer({ + ...storage, + gatekeepers: { get: () => ({ id: GK }) }, + chats: { list: () => [{ type: "action", actionId: action }] }, + }, { + impl: { + ctx: { waitUntil: (promise: Promise) => waits.push(promise) }, + addChatMessages: notes, + waitForChatMessagePreparation: () => undefined, + applyDecidedActions: async () => { + let record = getAction(storage, 1); + record.state = "approved"; + storage.actions.put(record); + return { decided: [action] }; + }, + }, + }); + + await client.setAutoApprovedActionKind(GK, { tag: "edit", label: "Edits" }); + await Promise.all(waits); + + expect(notes.mock.calls).toEqual([ + [7, expect.anything(), [expect.objectContaining({ + type: "message", message: expect.stringContaining("Action 1"), + })]], ]); }); @@ -1108,39 +1077,24 @@ describe("Overseer action decisions", () => { expect(getAction(storage, 3).state).toBe("pending"); }); - it("leaves a failed rejection pending so the user can retry it", async () => { - let storage = makeStorage(); - let id = putAction(storage, 1); - let legacy = makeLegacyGatekeeper(); - let reject = vi.fn().mockRejectedValueOnce(new Error("temporary RPC failure")) - .mockResolvedValue(undefined); - legacy.target.rejectAction = reject; - let client = await makeClient(storage, legacy.target); - - await expect(client.rejectAction(id)).rejects.toThrow("temporary RPC failure"); - expect(getAction(storage, 1).state).toBe("pending"); - expect(getAction(storage, 1).appliedAt).toBeUndefined(); - await client.rejectAction(id); - expect(getAction(storage, 1).state).toBe("rejected"); - expect(legacy.calls).toEqual([]); - }); - - it("keeps immediate rejection on the legacy rejectAction endpoint", async () => { + it("keeps immediate rejection on the legacy endpoint, leaving a failed one pending to retry", + async () => { let storage = makeStorage(); putAction(storage, 0); let id = putAction(storage, 1); putAction(storage, 2); let batch = makeBatchGatekeeper(); let calls: number[] = []; - let reject = vi.fn(async (action: number) => { + async function reject(action: number): Promise { calls.push(action); if (calls.length === 1) throw new Error("temporary RPC failure"); - }); + } batch.target.rejectAction = reject as typeof batch.target.rejectAction; let client = await makeClient(storage, batch.target); await expect(client.rejectAction(id)).rejects.toThrow("temporary RPC failure"); expect(getAction(storage, 1).state).toBe("pending"); + expect(getAction(storage, 1).appliedAt).toBeUndefined(); await client.rejectAction(id); expect(calls).toEqual([1, 1]); @@ -1200,3 +1154,42 @@ describe("Overseer action decisions", () => { expect(entry?.type === "action" && entry.failure).toBe("page was deleted upstream"); }); }); + +describe("Overseer auto-approval dispatch", () => { + it("starts the apply pass only after submitAction returns", async () => { + let applied: number[] = []; + let dispatched = Promise.withResolvers(); + let stub = env.TEST_OVERSEER.get(env.TEST_OVERSEER.newUniqueId()); + + await runInDurableObject(stub, async (instance: OverseerDurableObject) => { + // Reaching the DO's own impl, which the class does not expose; the fake facet it receives + // is deliberately narrower than the real one, so this stays untyped. + let host = instance as unknown as { impl: any }; + let impl = host.impl; + impl.getGatekeeperFacet = () => ({ + async applyActionsThrough(actionId: number) { + applied.push(actionId); + dispatched.resolve(); + return {}; + }, + }); + let actionKind = { tag: "push", label: "Push" }; + impl.storage.autoApproveTags.put({ gatekeeperId: GK, actionKind, enabledBy: APPROVER }); + + await impl.submitAction(GK, 1, { + title: "Push to main", + description: "Pushes the listed commits.", + implementsRevert: true, + autoApprovable: true, + actionKind, + }, { from: "user" }); + + // A gatekeeper reaches here with its submitAction() still in flight, so a pass dispatched + // inline would ask it to apply an action it has not finished submitting. + expect(applied).toStrictEqual([]); + + await dispatched.promise; + expect(applied).toStrictEqual([1]); + }); + }); +}); diff --git a/packages/workshop-backend/__tests__/fixtures.ts b/packages/workshop-backend/__tests__/fixtures.ts index a90abffcda..1eaa92e3e0 100644 --- a/packages/workshop-backend/__tests__/fixtures.ts +++ b/packages/workshop-backend/__tests__/fixtures.ts @@ -107,17 +107,12 @@ export async function openFakeOverseer( joinOutputsFanout: () => () => {}, ensureObserver: async () => {}, syncOutputsTo: async () => {}, - gitCache: { clearPushMarks: () => {} }, // What open() consults for a non-owner's role: the permission-graph lookup and observer // verification in one. The sharing manager is still reached, but only to redeem a share key, // which these tests never pass. authorizeCollaborator: async () => role, getSharingManager: async () => ({}), - ctx: { - id: { toString: () => "workspace-id" }, - exports: opts.exports ?? {}, - waitUntil: () => {}, - }, + ctx: { id: { toString: () => "workspace-id" }, exports: opts.exports ?? {} }, users: { idFromString: (id: string) => id, get: () => ({ @@ -125,7 +120,6 @@ export async function openFakeOverseer( recordSharedGadgetOpen: async () => {}, }), }, - applyDecidedActions: async () => [] as number[], storage: Object.assign(storage, { containsRestrictedData: { get: () => false }, title: { get: () => "Test Workspace" }, diff --git a/packages/workshop-backend/__tests__/git-push-actions.test.ts b/packages/workshop-backend/__tests__/git-push-actions.test.ts index 4750921a3b..7aff752795 100644 --- a/packages/workshop-backend/__tests__/git-push-actions.test.ts +++ b/packages/workshop-backend/__tests__/git-push-actions.test.ts @@ -238,7 +238,7 @@ describe("push authorization through the Overseer chokepoints", () => { laterVeto.appliedAt = new Date(); impl.storage.actions.put(laterVeto); - await impl.applyActionBatch(boundary, [veto], USER); + await impl.applyActionBatch(boundary.id, [veto.id], USER); expect(await receiver.receivedBatch()).toStrictEqual({ actionId: 99, vetoes: [52] }); const cached = await receiver.cachedObject(); @@ -277,8 +277,8 @@ describe("push authorization through the Overseer chokepoints", () => { }, { from: "user" }); const action = actionRecord(impl, GATEKEEPER, 1); - expect(await impl.applyActionBatch(action, [], USER)) - .toMatchObject({ decided: [], stoppedAt: 1 }); + expect(await impl.applyActionBatch(action.id, [], USER)) + .toMatchObject({ decided: [], stopped: true }); expect(actionRecord(impl, GATEKEEPER, 1).state).toBe("pending"); await expectGitPackCode( () => receiver.buildRetained(1), GIT_PACK_ERROR_CODES.builderExpired); @@ -315,6 +315,14 @@ describe("push authorization through the Overseer chokepoints", () => { }, { from: "user" }); const nonPush = actionRecord(impl, GATEKEEPER, foreignId + 1); + await impl.submitAction(GATEKEEPER, foreignId + 2, { + title: "Empty push declaration", + description: "Declares a push with no commits.", + implementsRevert: true, + pushedCommits: [], + }, { from: "user" }); + const noCommits = actionRecord(impl, GATEKEEPER, foreignId + 2); + await impl.submitAction( GATEKEEPER, foreignId + 3, pushDescription([ownHistory.base]), { from: "user" }); const empty = actionRecord(impl, GATEKEEPER, foreignId + 3); @@ -324,42 +332,38 @@ describe("push authorization through the Overseer chokepoints", () => { const zero = actionRecord(impl, GATEKEEPER, 0); expect(zero.id).not.toBe(0); - const builder = new GitPackBuilderImpl( - impl.gitCache, impl.storage, GATEKEEPER, [own, nonPush, empty, zero]); - try { - const ownObjects = await decodePackBytes( - await collect(await builder.buildPack(foreignId)), { maxObjectSize: 1 << 20 }); - const ownOids = await Promise.all( - ownObjects.map(object => gitObjectOid(object.type, object.payload))); - expect(ownOids).toStrictEqual([ownHistory.head]); - - const zeroObjects = await decodePackBytes( - await collect(await builder.buildPack(0)), { maxObjectSize: 1 << 20 }); - expect(await Promise.all( - zeroObjects.map(object => gitObjectOid(object.type, object.payload)))) - .toStrictEqual([zeroHistory.head]); - expect(await decodePackBytes( - await collect(await builder.buildPack(foreignId + 3)), { maxObjectSize: 1 })) - .toStrictEqual([]); - - for (const selector of [own.id, nonPush.action]) { - await expectGitPackCode( - () => builder.buildPack(selector), GIT_PACK_ERROR_CODES.actionNotAuthorized); - } + using builder = new GitPackBuilderImpl( + impl.gitCache, impl.storage, GATEKEEPER, [own, nonPush, noCommits, empty, zero]); + const ownObjects = await decodePackBytes( + await collect(await builder.buildPack(foreignId)), { maxObjectSize: 1 << 20 }); + const ownOids = await Promise.all( + ownObjects.map(object => gitObjectOid(object.type, object.payload))); + expect(ownOids).toStrictEqual([ownHistory.head]); + + const zeroObjects = await decodePackBytes( + await collect(await builder.buildPack(0)), { maxObjectSize: 1 << 20 }); + expect(await Promise.all( + zeroObjects.map(object => gitObjectOid(object.type, object.payload)))) + .toStrictEqual([zeroHistory.head]); + expect(await decodePackBytes( + await collect(await builder.buildPack(foreignId + 3)), { maxObjectSize: 1 })) + .toStrictEqual([]); - impl.storage.transaction(() => { - zero.state = "rejected"; - impl.gitCache.clearPushMarks(zero.id); - impl.storage.actions.put(zero); - }); + for (const selector of [own.id, nonPush.action]) { await expectGitPackCode( - () => builder.buildPack(0), GIT_PACK_ERROR_CODES.actionUnavailable); - expect(getGitPackErrorCode(new Error( - "Git pack action is no longer pending or its connection was removed."))) - .toBeUndefined(); - } finally { - builder[Symbol.dispose](); + () => builder.buildPack(selector), GIT_PACK_ERROR_CODES.actionNotAuthorized); } + await expectGitPackCode( + () => builder.buildPack(noCommits.action), + GIT_PACK_ERROR_CODES.actionDeclaresNoPush); + + impl.storage.transaction(() => { + zero.state = "rejected"; + impl.gitCache.clearPushMarks(zero.id); + impl.storage.actions.put(zero); + }); + await expectGitPackCode( + () => builder.buildPack(0), GIT_PACK_ERROR_CODES.actionUnavailable); }); }); @@ -439,10 +443,26 @@ describe("push authorization through the Overseer chokepoints", () => { }); }); - it("rechecks owner and destination lifetime after an awaited pack build", async () => { - await inOverseer("batch-pack-disposed-in-flight", async impl => { + it.each([ + { + mode: "disposed", + expected: GIT_PACK_ERROR_CODES.builderExpired, + invalidate: (_impl: any, builder: GitPackBuilderImpl) => builder[Symbol.dispose](), + // Dispose leaves the still-queued push intact for the next builder. + remainingMarks: (head: string) => [head], + }, + { + mode: "removed", + expected: GIT_PACK_ERROR_CODES.actionUnavailable, + invalidate: (impl: any) => impl.removeGatekeeper(GATEKEEPER), + // Removal cleans the queued push's marks along with the gatekeeper. + remainingMarks: () => [], + }, + ])("rechecks owner and destination lifetime after an awaited pack build ($mode mid-build)", + async ({ mode, expected, invalidate, remainingMarks }) => { + await inOverseer(`batch-pack-${mode}-in-flight`, async impl => { impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); - const { head } = await seedPushableHistory(impl, GATEKEEPER, " disposed"); + const { head } = await seedPushableHistory(impl, GATEKEEPER, ` ${mode}`); await impl.submitAction(GATEKEEPER, 81, pushDescription([head]), { from: "user" }); const record = actionRecord(impl, GATEKEEPER, 81); const builder = new GitPackBuilderImpl( @@ -459,52 +479,10 @@ describe("push authorization through the Overseer chokepoints", () => { try { const build = builder.buildPack(81); await started.promise; - builder[Symbol.dispose](); - release.resolve(); - let caught: unknown; - try { - await build; - } catch (error) { - caught = error; - } - expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.builderExpired); - expect(marksOf(impl, record.id)).toContain(head); - } finally { - release.resolve(); - impl.gitCache.buildPackForAction = original; - builder[Symbol.dispose](); - } - }); - - await inOverseer("batch-pack-removed-in-flight", async impl => { - impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); - const { head } = await seedPushableHistory(impl, GATEKEEPER, " removed"); - await impl.submitAction(GATEKEEPER, 82, pushDescription([head]), { from: "user" }); - const record = actionRecord(impl, GATEKEEPER, 82); - const builder = new GitPackBuilderImpl( - impl.gitCache, impl.storage, GATEKEEPER, [record]); - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - const original = impl.gitCache.buildPackForAction; - impl.gitCache.buildPackForAction = async (gatekeeperId: number, actionId: number) => { - started.resolve(); - await release.promise; - return original.call(impl.gitCache, gatekeeperId, actionId); - }; - - try { - const build = builder.buildPack(82); - await started.promise; - impl.removeGatekeeper(GATEKEEPER); + invalidate(impl, builder); release.resolve(); - let caught: unknown; - try { - await build; - } catch (error) { - caught = error; - } - expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.actionUnavailable); - expect(marksOf(impl, record.id)).toStrictEqual([]); + await expectGitPackCode(() => build, expected); + expect(marksOf(impl, record.id)).toStrictEqual(remainingMarks(head)); } finally { release.resolve(); impl.gitCache.buildPackForAction = original; diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index 0ac79a03f3..6ec8ec6210 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -59,20 +59,17 @@ export type PassResult = { decided: number[]; /** - * Title of the earlier undecided action that stopped the frontier, set when a click sat above - * it. Transient queue state, so it is reported rather than recorded on the action. + * Set when a click sat above an earlier undecided action that held the frontier below it. + * Transient queue state, so it is reported rather than recorded on the action. */ - blockedBy?: string; + blocked?: true; - /** Gatekeeper-local action ID where application stopped; zero is a valid ID. */ - stoppedAt?: number; + /** Set when application stopped; the reason is recorded on the stopped action itself. */ + stopped?: true; }; -type StagedPass = { +type StagedPass = PromiseWithResolvers & { manualApprovals: ManualApproval[]; - resolve: (result: PassResult) => void; - reject: (error: unknown) => void; - promise: Promise; }; /** @@ -112,7 +109,7 @@ export class ActionSyncDriver { #staged = new Map(); // Per-gatekeeper single-flight guard. Key present => a run loop is active for that gatekeeper. - #running = new Map>(); + #running = new Set(); // Gatekeepers observed to lack applyActionsThrough. In-memory only: a fresh isolate re-probes, // which is what lets a migrated deploy shed the fallback without bookkeeping. @@ -140,26 +137,31 @@ export class ActionSyncDriver { if (manualApproval) slot.manualApprovals.push(manualApproval); if (!this.#running.has(gatekeeperId)) { - this.#running.set(gatekeeperId, this.#run(gatekeeperId)); + this.#running.add(gatekeeperId); + void this.#run(gatekeeperId); } return slot.promise; } /** * Process the selected connection through an explicit boundary after durably staging its vetoes. - * The records are re-read inside the queue so deleted or regrouped actions cannot be resurrected. + * The sole batch-validation authority: every record is read and checked inside the queue, so + * deleted or regrouped actions cannot be resurrected. Only the queue key is resolved up front. */ applyThrough( - boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], + boundaryId: number, vetoIds: readonly number[], resolvedBy: AiChatAuthorInfo): Promise { + let boundary = this.storage.actions.get(boundaryId); + if (!boundary) throw new Error(`No such action: ${boundaryId}`); + if (boundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); return this.#enqueueDecision(boundary.gatekeeperId, async () => { - let freshBoundary = this.storage.actions.get(boundary.id); - if (!freshBoundary) throw new Error(`No such action: ${boundary.id}`); - if (freshBoundary.type !== "action") throw new Error(`Not an action: ${boundary.id}`); + let freshBoundary = this.storage.actions.get(boundaryId); + if (!freshBoundary) throw new Error(`No such action: ${boundaryId}`); + if (freshBoundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); if (freshBoundary.gatekeeperId !== boundary.gatekeeperId) { throw new Error("Action batch contains a different connection."); } - let selected = vetoes.map(({id}) => { + let selected = vetoIds.map(id => { let fresh = this.storage.actions.get(id); if (!fresh) throw new Error(`No such action: ${id}`); if (fresh.type !== "action") throw new Error(`Not an action: ${id}`); @@ -219,7 +221,8 @@ export class ActionSyncDriver { } }); if (!this.#running.has(gatekeeperId)) { - this.#running.set(gatekeeperId, this.#run(gatekeeperId)); + this.#running.add(gatekeeperId); + void this.#run(gatekeeperId); } return promise; } @@ -262,15 +265,14 @@ export class ActionSyncDriver { // its index was introduced, so it needs no legacy backfill. let pending = actionsAscending(this.storage.actions.pendingByGatekeeper.get(gatekeeperId)); let stagedVetoes = - actionsAscending(this.storage.actions.vetoPendingByGatekeeper.get(gatekeeperId)) - .filter(record => record.state === "rejected" && record.vetoPending === true); + actionsAscending(this.storage.actions.vetoPendingByGatekeeper.get(gatekeeperId)); let byAction = new Map([...pending, ...stagedVetoes].map(record => [record.action, record])); // Explicit batches authorize every non-vetoed pending action through their fixed boundary. // Existing callers retain exact-click and rule authority, including their blocked result. let frontier = batch?.frontier ?? Math.max(-1, ...manualApprovals.map(({action}) => action)); let attribution = new Map(); - let blockedBy: string | undefined; + let blocked: true | undefined; if (batch) { for (let record of pending) { if (record.action > frontier) break; @@ -280,7 +282,6 @@ export class ActionSyncDriver { // Two authorities extend the old frontier and nothing else: the user's click on that exact // action, or an auto-approval rule they enabled for its kind. let clicked = new Map(manualApprovals.map(manual => [manual.action, manual.resolvedBy])); - let gate: GatekeeperActionRecord | undefined; for (let record of pending) { let resolvedBy = clicked.get(record.action); if (resolvedBy) { @@ -295,21 +296,19 @@ export class ActionSyncDriver { ? undefined : this.storage.autoApproveTags.get(`${gatekeeperId}:${tag}`); if (!rule) { - gate = record; + if (record.action <= frontier) { + frontier = record.action - 1; + blocked = true; + } break; } attribution.set(record.action, {resolvedBy: rule.enabledBy, autoApproved: true}); if (record.action > frontier) frontier = record.action; } - - if (gate && gate.action <= frontier) { - frontier = gate.action - 1; - blockedBy = gate.description.title; - } } let sendVetoes = stagedVetoes.filter(veto => veto.action <= frontier); - if (attribution.size === 0 && sendVetoes.length === 0) return {decided: [], blockedBy}; + if (attribution.size === 0 && sendVetoes.length === 0) return {decided: [], blocked}; let decided: number[] = []; @@ -404,7 +403,7 @@ export class ActionSyncDriver { }); } } - return {decided, blockedBy, stoppedAt}; + return stoppedAt === undefined ? {decided, blocked} : {decided, blocked, stopped: true}; } // Re-read before each mutation; earlier checkpoints and cascade refreshes may replace snapshots. diff --git a/packages/workshop-backend/src/git-cache.ts b/packages/workshop-backend/src/git-cache.ts index 27d499128b..79de252055 100644 --- a/packages/workshop-backend/src/git-cache.ts +++ b/packages/workshop-backend/src/git-cache.ts @@ -1291,7 +1291,7 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis super(); for (const record of pendingPlan) { if (record.gatekeeperId === gatekeeperId && - (record.description.pushedCommits?.length ?? 0) > 0) { + record.description.pushedCommits !== undefined) { this.#workspaceActionByLocalId.set(record.action, record.id); } } @@ -1308,10 +1308,12 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis const record = this.storage.actions.get(workspaceId); if (record?.type !== "action" || record.action !== action || record.gatekeeperId !== this.gatekeeperId || record.state !== "pending" || - (record.description.pushedCommits?.length ?? 0) === 0 || this.storage.gatekeepers.get(this.gatekeeperId) === undefined) { throw createGitPackError(GIT_PACK_ERROR_CODES.actionUnavailable); } + if (!record.description.pushedCommits?.length) { + throw createGitPackError(GIT_PACK_ERROR_CODES.actionDeclaresNoPush); + } return record; } diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index c039f51b0c..522b14b297 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -5436,10 +5436,9 @@ class OverseerImpl implements AgentHooks { } /** Runs one explicit action prefix through the serialized action driver. */ - applyActionBatch( - boundary: GatekeeperActionRecord, vetoes: readonly GatekeeperActionRecord[], - resolvedBy: AiChatAuthorInfo): Promise { - return this.#actionSync.applyThrough(boundary, vetoes, resolvedBy); + applyActionBatch(boundaryId: number, vetoIds: readonly number[], + resolvedBy: AiChatAuthorInfo): Promise { + return this.#actionSync.applyThrough(boundaryId, vetoIds, resolvedBy); } rejectPendingAction(record: GatekeeperActionRecord, resolvedBy: AiChatAuthorInfo): Promise { @@ -5998,8 +5997,8 @@ class OverseerImpl implements AgentHooks { let gatekeeper = this.storage.gatekeepers.get(gatekeeperId); - // Auto-approval gate, named because awaitDecision uses it too. Applying is deferred: it calls - // back into the gatekeeper facet still awaiting submitAction. + // Auto-approval gate, named because awaitDecision uses it too. Applying is deferred; see the + // dispatch at the end of this method. let willAutoApprove = !!(description.autoApprovable && description.actionKind && this.storage.autoApproveTags.get(`${gatekeeperId}:${description.actionKind.tag}`) !== undefined); @@ -6037,8 +6036,10 @@ class OverseerImpl implements AgentHooks { this.#getOrCreateCapturedActions(caller.chatId).awaitDecision = true; } + // waitUntil() does not defer evaluation, so dispatching inline would reach the gatekeeper + // while it is still inside submitAction(), for an action it has not finished submitting. if (willAutoApprove) { - this.ctx.waitUntil(this.applyDecidedActions(gatekeeperId)); + this.ctx.waitUntil(scheduler.wait(0).then(() => this.applyDecidedActions(gatekeeperId))); } } @@ -11148,28 +11149,12 @@ class OverseerClientInterface extends RpcTarget implements Overseer { } async applyActionsThrough(id: number, vetoes: number[]): Promise { - let boundary = this.impl.storage.actions.get(id); - if (!boundary) throw new Error(`No such action: ${id}`); - if (boundary.type !== "action") throw new Error(`Not an action: ${id}`); - - let selected: GatekeeperActionRecord[] = []; - for (let vetoId of vetoes) { - let veto = this.impl.storage.actions.get(vetoId); - if (!veto) throw new Error(`No such action: ${vetoId}`); - if (veto.type !== "action") throw new Error(`Not an action: ${vetoId}`); - if (veto.gatekeeperId !== boundary.gatekeeperId) { - throw new Error("Action batch contains a different connection."); - } - if (veto.action > boundary.action) { - throw new Error("Veto is beyond the action batch boundary."); - } - selected.push(veto); - } - + // The batch is validated by the driver, inside its decision queue, where the records are + // re-read fresh; a copy of those checks here could only ever act on stale reads. let profile = await this.#getClientProfile(); - let {decided, stoppedAt} = await this.impl.applyActionBatch(boundary, selected, profile); + let {decided, stopped} = await this.impl.applyActionBatch(id, vetoes, profile); await this.#resumeDecidedActionChats(decided); - if (stoppedAt !== undefined) throw createActionError(ACTION_ERROR_CODES.stopped); + if (stopped) throw createActionError(ACTION_ERROR_CODES.stopped); } async approveAction(id: number): Promise { @@ -11194,7 +11179,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // Applies this action plus any earlier undecided action an auto-approval rule already // authorizes; an undecided action with neither authority stops the pass below it. - let {decided, blockedBy, stoppedAt} = await this.impl.applyDecidedActions( + let {decided, blocked, stopped} = await this.impl.applyDecidedActions( action.gatekeeperId, {action: action.action, resolvedBy: profile}); await this.#resumeDecidedActionChats(decided); @@ -11214,10 +11199,8 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // Still pending: an earlier undecided action held the frontier below this one, or the // gatekeeper stopped at or below it. Either way, surface the reason the user can act on. - if (blockedBy !== undefined) { - throw createActionError(ACTION_ERROR_CODES.blocked); - } - if (stoppedAt !== undefined) throw createActionError(ACTION_ERROR_CODES.stopped); + if (blocked) throw createActionError(ACTION_ERROR_CODES.blocked); + if (stopped) throw createActionError(ACTION_ERROR_CODES.stopped); throw new Error("Couldn't apply this action; an earlier action on this connection needs attention."); } @@ -11302,7 +11285,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer { chatIds.add(record.caller.chatId); } } - for (let chatId of chatIds) { + // In parallel: one batch can decide actions across several chats, and each resume awaits its + // own user-DO round trip, so a serial loop would sum those latencies into the caller's RPC. + await Promise.all([...chatIds].map(async chatId => { try { await this.#maybeResumeAfterActionDecision(chatId); } catch (err) { @@ -11310,7 +11295,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { event: "action.resume.failed", chatId, error: err, }); } - } + })); } // Resume a turn suspended on awaitDecision once all awaited actions from that turn are approved. @@ -11395,8 +11380,10 @@ class OverseerClientInterface extends RpcTarget implements Overseer { actionKind, enabledBy: profile, }); - // Apply the currently-visible pending action(s) with this tag right away. - this.impl.ctx.waitUntil(this.impl.applyDecidedActions(gatekeeperId)); + // Apply the currently-visible pending action(s) with this tag right away, resuming any turn + // that was suspended waiting on one. + this.impl.ctx.waitUntil(this.impl.applyDecidedActions(gatekeeperId) + .then(({decided}) => this.#resumeDecidedActionChats(decided))); } // Remove the auto-approval rule for `tag` on the given gatekeeper, so future matching actions diff --git a/packages/workshop-frontend/src/components/ActionFailureNote.tsx b/packages/workshop-frontend/src/ActionFailureNote.tsx similarity index 100% rename from packages/workshop-frontend/src/components/ActionFailureNote.tsx rename to packages/workshop-frontend/src/ActionFailureNote.tsx diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx index c32a9b349f..b2bc219c91 100644 --- a/packages/workshop-frontend/src/Activity.tsx +++ b/packages/workshop-frontend/src/Activity.tsx @@ -4,7 +4,7 @@ import { CaretRight, Check, Eye, Lightning, ShieldCheck } from '@phosphor-icons/ import { RpcStub } from 'capnweb' import { ActionLogEntry, Overseer, actionChangeTime } from '@gadgets/workshop-shared/api' import { ActionKind } from '@gadgets/workshop-shared/gatekeeper' -import { ActionFailureNote } from './components/ActionFailureNote' +import { ActionFailureNote } from './ActionFailureNote' import { GatekeeperIcon } from './components/GatekeeperIcon' import { HookToggle } from './components/HookToggle' import { AlwaysApproveButton, ResolveButton } from './components/ResolveButton' diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index 80a4fc0a4b..fe2538d910 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -139,7 +139,10 @@ describe('ChatInterface action refresh', () => { it('lets a resumed reconnect replay the gap instead of refetching', async () => { await cachePendingCard('ws-chat-resume') - const failed = entry(1, { failure: 'page was deleted while disconnected' }) + const failed = entry(1, { + failure: 'page was deleted while disconnected', + appliedAt: new Date(1700005000000), + }) const second = makeOverseer() const secondChat = withChatApi(second) linkActionLog(second.overseer, 'ws-chat-resume') diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 3bb2be430e..43ba0dbad6 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -90,7 +90,7 @@ import { useSlashCommandChoice, type OverseerSource, } from "./components/chat/slash-command-catalog"; import GatekeeperModal from "./GatekeeperModal"; -import { ActionFailureNote } from "./components/ActionFailureNote"; +import { ActionFailureNote } from "./ActionFailureNote"; import { GatekeeperIcon } from "./components/GatekeeperIcon"; import { formatOf, FORMAT_ICONS } from "./components/format/formats"; import { FormatMiniature } from "./components/format/FormatVisuals"; diff --git a/packages/workshop-frontend/src/useActions.ts b/packages/workshop-frontend/src/useActions.ts index 27fc4c1955..59de0a4edf 100644 --- a/packages/workshop-frontend/src/useActions.ts +++ b/packages/workshop-frontend/src/useActions.ts @@ -189,7 +189,7 @@ function openSubscription(overseer: RpcStub, store: Store) { const subscribed = startAfter ? overseer.subscribeToActions(subscriber, startAfter) : overseer.subscribeToActions(subscriber) - subscribed.then((sub: RpcStub<{}>) => { + subscribed.then(sub => { if (store.generation !== generation) { sub[Symbol.dispose]() return diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 31e8b84be9..150b19e840 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -959,7 +959,8 @@ export interface Gatekeeper extends DurableObject { * become terminal no-ops. Processing stops at the first application failure; a pending in-range * action the gatekeeper still holds must never be silently skipped — it is either applied or * reported via `stopped`. An action whose `submitAction()` call has not yet completed must not - * be applied. + * be applied: wait for that call, then apply it. Omitting it would be the silent skip above, and + * reporting it as `stopped` would record a failure on an action that has not been attempted. * * Every ID in `vetoes` must be durably recorded before any action is applied, including when * processing stops: the caller clears its staged veto on any call that returns, so a veto lost @@ -1338,6 +1339,9 @@ export type ActionDescription = { * If present, applying this action will push the named commits to the remote resource this * gatekeeper fronts. * + * An empty list is not a push: no ancestry is verified, no objects are marked, and + * `GitPackBuilder.buildPack()` rejects the action. Omit the field rather than passing `[]`. + * * At the time the action is submitted, the overseer may validate whether it makes sense to push * this commit (and the transitive closure of objects that come with it) to this gatekeeper, and * whether the gatekeeper is allowed to receive these commits. A variety of security policies, @@ -1524,6 +1528,8 @@ export const GIT_PACK_ERROR_CODES = { actionNotAuthorized: "GIT_PACK_ACTION_NOT_AUTHORIZED", /** The selected action is no longer pending or its gatekeeper connection was removed. */ actionUnavailable: "GIT_PACK_ACTION_UNAVAILABLE", + /** The selected action declares no pushed commits, so it has no pack to build. */ + actionDeclaresNoPush: "GIT_PACK_ACTION_DECLARES_NO_PUSH", } as const; /** An expected `GitPackBuilder.buildPack()` failure code. */ @@ -1536,6 +1542,8 @@ const gitPackErrors = codedErrorFamily({ "Action is not authorized for Git pack building in this apply-through call.", [GIT_PACK_ERROR_CODES.actionUnavailable]: "Git pack action is no longer pending or its connection was removed.", + [GIT_PACK_ERROR_CODES.actionDeclaresNoPush]: + "Action declares no pushed commits, so it has no pack to build.", }); /** Creates an expected Git pack failure carrying its stable machine-readable code. */ @@ -1554,16 +1562,19 @@ export interface GitPackBuilder extends RpcTarget { /** * Builds a pack for one gatekeeper-local action ID authorized in the containing apply-through * call. The selected action need not equal that call's frontier. A valid push whose full closure - * is already known to the remote returns a valid empty pack. Expected availability and authority - * failures carry a code from `GIT_PACK_ERROR_CODES`. + * is already known to the remote returns a valid empty pack. Expected failures carry a code + * from `GIT_PACK_ERROR_CODES`. */ buildPack(action: number): Promise>; } /** Git capabilities supplied to an action-processing invocation. */ export type ApplyActionContext = { - /** This connection's cache view, including later pending pushes; no legacy buildPack(). */ - gitCache: RpcStub; + /** + * This connection's cache view, including later pending pushes. Not action-scoped, so + * `buildPack()` is omitted from it: packs come from `gitPackBuilder`. + */ + gitCache: RpcStub>; /** Builds only this invocation's authorized pushes; expires when the invocation completes. */ gitPackBuilder: RpcStub; }; From adeef97adc837a6d27756980c1ffa3e4f7299a30 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 18 Sep 2026 09:53:33 -0500 Subject: [PATCH 05/20] Report out-of-order gatekeeper action submissions The frontier model the batch path relies on assumes a gatekeeper's local action IDs increase: `applyActionsThrough` authorizes by range, so an action submitted below a boundary the user already decided would be applied without a recorded approval. Nothing enforces that, and no shipped gatekeeper takes the batch path yet, so the assumption is untested in practice. Record each connection's highest submitted ID in memory and warn when one arrives out of order. Nothing is rejected: the count tells us whether the promise holds before the first native implementer makes enforcement worthwhile, whereas rejecting now could only fail a submission the legacy path would have handled correctly. --- packages/workshop-backend/src/overseer.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 522b14b297..d61a8e576e 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -1646,6 +1646,12 @@ class OverseerImpl implements AgentHooks { #preparingChatMessages = new Map>(); + // Highest local action ID each gatekeeper has submitted. In-memory, like the action driver's + // legacy probe: this only reports whether the contract's sequential-ID promise holds in + // practice, which the frontier model will depend on once a gatekeeper implements + // applyActionsThrough. + #highestSubmittedAction = new Map(); + // Set of chatIds that currently have a running agent turn. Feeds the alarm (see // #agentKeepAliveTime) and lets `alarm()` wait for all agents to finish. #runningAgents = new Set(); @@ -6030,6 +6036,15 @@ class OverseerImpl implements AgentHooks { } this.storage.actions.put(record); }); + + let highest = this.#highestSubmittedAction.get(gatekeeperId); + if (highest !== undefined && action <= highest) { + this.logger.warn("gatekeeper submitted an out-of-order action id", { + event: "action.submit.out-of-order", gatekeeperId, + }); + } + this.#highestSubmittedAction.set(gatekeeperId, Math.max(action, highest ?? action)); + this.#associateAction(caller, actionId); if (caller.from === "agent" && suspendsTurn) { From 56cf8e03581f82055ca654999c51da4040ed92ba Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 18 Sep 2026 09:53:33 -0500 Subject: [PATCH 06/20] Derive an action's status label in one place Activity learned to distinguish a cascade invalidation from a direct denial, but the chat card kept mapping every rejected action to "Denied". The same record then read "Invalidated" on one surface and "Denied" on the other, and the chat label attributed a cascade to a decision nobody had made about that action. `actionStatusLabel` is now the single mapping from a record's state and `cascadedFrom` to its display label, and both surfaces read it. Deriving it twice independently is what let them disagree. --- packages/workshop-backend/src/actions.ts | 25 ++++++++++------- packages/workshop-frontend/src/Activity.tsx | 12 ++++---- .../src/ChatInterface.actions.test.tsx | 28 +++++++++++++++++++ .../workshop-frontend/src/ChatInterface.tsx | 8 ++---- .../src/features/actions/actionStatus.ts | 16 +++++++++++ packages/workshop-shared/src/api.ts | 2 +- 6 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 packages/workshop-frontend/src/features/actions/actionStatus.ts diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index 6ec8ec6210..e636365b9b 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -1,7 +1,7 @@ // Serializes gatekeeper decisions. Explicit batches durably stage vetoes; immediate rejections // become terminal only after acknowledgement, and only authorized actions are applied. -import type { Collection, NonUniqueIndex } from "@gadgets/typed-storage"; +import type { Collection, NonUniqueIndex, TypedStorage } from "@gadgets/typed-storage"; import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api"; import type { ApplyActionsThroughResult, @@ -15,7 +15,7 @@ import type { ActionRecord, AutoApproveTagRecord, GatekeeperActionRecord } from const logger = createWorkshopLogger("workshop.action.sync"); -export interface ActionSyncStorage { +export interface ActionSyncStorage extends TypedStorage { actions: Collection & { pendingByGatekeeper: NonUniqueIndex; vetoPendingByGatekeeper: NonUniqueIndex; @@ -174,14 +174,19 @@ export class ActionSyncDriver { return fresh; }); - for (let record of selected) { - if (record.state !== "pending") continue; - record.state = "rejected"; - record.vetoPending = true; - record.resolvedBy = resolvedBy; - record.appliedAt = new Date(); - this.storage.actions.put(record); - } + // One transaction: an unstaged veto is indistinguishable from an undecided action, and the + // pass below authorizes every pending action under the boundary -- so half a staged batch + // would apply what the user vetoed. + this.storage.transaction(() => { + for (let record of selected) { + if (record.state !== "pending") continue; + record.state = "rejected"; + record.vetoPending = true; + record.resolvedBy = resolvedBy; + record.appliedAt = new Date(); + this.storage.actions.put(record); + } + }); return await this.#applyOnce(freshBoundary.gatekeeperId, [], { frontier: freshBoundary.action, diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx index b2bc219c91..cf0c52a3ee 100644 --- a/packages/workshop-frontend/src/Activity.tsx +++ b/packages/workshop-frontend/src/Activity.tsx @@ -5,6 +5,7 @@ import { RpcStub } from 'capnweb' import { ActionLogEntry, Overseer, actionChangeTime } from '@gadgets/workshop-shared/api' import { ActionKind } from '@gadgets/workshop-shared/gatekeeper' import { ActionFailureNote } from './ActionFailureNote' +import { actionStatusLabel } from './features/actions/actionStatus' import { GatekeeperIcon } from './components/GatekeeperIcon' import { HookToggle } from './components/HookToggle' import { AlwaysApproveButton, ResolveButton } from './components/ResolveButton' @@ -95,17 +96,14 @@ function activityStatus( ? { label: 'Enabled', dotClass: 'bg-kumo-success', textClass: 'text-kumo-subtle' } : { label: 'Disabled', dotClass: 'bg-kumo-inactive', textClass: 'text-kumo-subtle' } } + const label = actionStatusLabel(record) if (record.state === 'pending') { - return { label: 'Pending', dotClass: 'bg-kumo-brand', textClass: 'text-kumo-strong' } + return { label, dotClass: 'bg-kumo-brand', textClass: 'text-kumo-strong' } } if (record.state === 'rejected') { - return { - label: record.cascadedFrom === undefined ? 'Denied' : 'Invalidated', - dotClass: 'bg-kumo-danger', - textClass: 'text-kumo-danger', - } + return { label, dotClass: 'bg-kumo-danger', textClass: 'text-kumo-danger' } } - return { label: 'Approved', dotClass: 'bg-kumo-success', textClass: 'text-kumo-subtle' } + return { label, dotClass: 'bg-kumo-success', textClass: 'text-kumo-subtle' } } // A cascade-invalidated action inherits the resolver of the rejection that took it down, so it must diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index fe2538d910..2aa6bf341d 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -119,6 +119,18 @@ async function cachePendingCard(key?: string) { flushFrames() } +// Renders one resolved action card in a live chat, which is where its status label is derived. +async function renderResolvedCard(over: Record) { + const resolved = entry(1, over) + const server = makeOverseer() + const chat = withChatApi(server) + await renderChat(server.overseer, 1) + await server.resolveSubscription() + await server.resolvePendingQuery({ entries: [resolved] }) + chat.emitMessage({ ...actionMessage, actionLog: resolved } as AiChatMessage) + flushFrames() +} + describe('ChatInterface action refresh', () => { it('shows a missed failure on a cached card after a stub swap', async () => { await cachePendingCard() @@ -194,3 +206,19 @@ describe('ChatInterface action failure note', () => { expect(document.body.textContent).toContain('page was deleted upstream') }) }) + +describe('ChatInterface action status', () => { + it('presents a cascade invalidation as invalidated rather than denied', async () => { + await renderResolvedCard({ state: 'rejected', cascadedFrom: 2 }) + + expect(document.body.textContent).toContain('Invalidated') + expect(document.body.textContent).not.toContain('Denied') + }) + + it('presents a direct rejection as denied', async () => { + await renderResolvedCard({ state: 'rejected' }) + + expect(document.body.textContent).toContain('Denied') + expect(document.body.textContent).not.toContain('Invalidated') + }) +}) diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 43ba0dbad6..31f7f51cfd 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -14,6 +14,7 @@ import { type PointerEvent as ReactPointerEvent, } from "react"; import { reportIssue } from './errorReporting' +import { actionStatusLabel } from './features/actions/actionStatus' import { Dialog, DropdownMenu, @@ -4907,7 +4908,6 @@ function ChatInterface({ } const isPending = state === "pending"; - const isApproved = state === "approved"; const isRejected = state === "rejected"; // 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. @@ -4917,11 +4917,7 @@ function ChatInterface({ // decision. Resolved actions are history, and collapse so a long thread stays scannable. const showDescription = isPending || open; const metadata = log.resourceTitle; - const stateLabel = isApproved - ? "Approved" - : isRejected - ? "Denied" - : null; + const stateLabel = isPending ? null : actionStatusLabel(log); const stateLabelCls = isRejected ? "text-kumo-danger" : "text-kumo-inactive"; diff --git a/packages/workshop-frontend/src/features/actions/actionStatus.ts b/packages/workshop-frontend/src/features/actions/actionStatus.ts new file mode 100644 index 0000000000..b2e242195d --- /dev/null +++ b/packages/workshop-frontend/src/features/actions/actionStatus.ts @@ -0,0 +1,16 @@ +import type { ActionState } from '@gadgets/workshop-shared/api' + +/** + * How an action's outcome reads to the user. A cascade-invalidated action was taken down by an + * earlier rejection rather than refused on its own merits, so it must not read as a decision anyone + * made about this action (see `cascadedFrom` in the API). + * + * Shared because deriving it per surface is what let the chat card and the Activity row disagree + * about the same record. + */ +export function actionStatusLabel(action: { state: ActionState; cascadedFrom?: number }): + 'Pending' | 'Approved' | 'Denied' | 'Invalidated' { + if (action.state === 'pending') return 'Pending' + if (action.state === 'approved') return 'Approved' + return action.cascadedFrom === undefined ? 'Denied' : 'Invalidated' +} diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index df57d3ffd4..ba3da2da08 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -395,7 +395,7 @@ export const ACTION_ERROR_MESSAGES: Record = { [ACTION_ERROR_CODES.blocked]: "An earlier action needs a decision before this one can be applied.", [ACTION_ERROR_CODES.stopped]: - "Action could not be completed. See the action card for details.", + "Action could not be completed. Check this connection's action cards for the reason.", }; const actionErrors = codedErrorFamily(ACTION_ERROR_MESSAGES); From 7eec0606ceb97e40100b1edf5ff0c6a5a01c6cdc Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 18 Sep 2026 17:47:35 -0500 Subject: [PATCH 07/20] Require gatekeepers to publish retained actions in order `applyActionsThrough` authorizes by range, and `invalidatedByVeto` may name an action above the boundary, so the contract already leans on a gatekeeper's published IDs being a prefix -- but only the in-flight-submission rule said so, and that rule reads as though a lower ID could still appear mid-pass. A gatekeeper that published 3 before 2 would hand the overseer a frontier covering an action it has never seen, or a cascade rejection for a record it cannot hold. State the obligation where it is discharged: `submitAction()` publishes retained actions in ascending ID order, a frontier therefore covers every retained action below it, and an invalidation is reported only once its action's submission has completed. Missing IDs stay legal; nothing requires a contiguous sequence. The cascade-publication test now parks the RPC and publishes action 3 while it is parked, which is the interleaving the contract permits. Writing after the call returned modelled a gatekeeper the contract forbids. --- .../workshop-backend/__tests__/actions.test.ts | 12 +++++++++--- packages/workshop-shared/src/gatekeeper.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index d8c1bb130a..e71d0b31d9 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -683,12 +683,18 @@ describe("ActionSyncDriver.apply", () => { let vetoId = putAction(storage, 2, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); - let { target, results } = makeBatchGatekeeper(); + let { target, results, release } = makeBatchGatekeeper({ parkAt: 2 }); results.push({ invalidatedByVeto: [{ action: 3, invalidatedBy: 2 }] }); let pass = makeDriver(storage, target) .applyThrough(getAction(storage, 2).id, [], REJECTER); - let a3 = putAction(storage, 3, { autoApprovable: false }); // arrives while the RPC is in - let { decided } = await pass; // flight, so it misses the snapshot + await flush(); + + // Action 3 is published while the call is parked: the contract lets the gatekeeper report it + // invalidated because its submission completed before the result returned -- but it arrived + // too late for the pre-call snapshot. + let a3 = putAction(storage, 3, { autoApprovable: false }); + release(); + let { decided } = await pass; expect(decided).toContain(a3); let invalidated = getAction(storage, 3); diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 150b19e840..3f5c41e359 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -791,6 +791,12 @@ export interface ApplyActionsThroughResult { * vetoed action number that invalidated it (always an action listed in `vetoes`). The caller * records these actions as rejected, so every entry must reflect durable gatekeeper state. * + * Every reported action must also have completed its `submitAction()` call before this result + * is returned; the caller can only record a rejection against a record it already has. An ID + * above the requested boundary is fine -- that is a future application, not an unpublished + * record -- but an action whose submission is still in flight is waited out before it is + * reported. + * * These actions will have no effect when applied and will not produce an error. * * A list rather than a keyed map: JavaScript stringifies numeric object keys, so a map would @@ -961,6 +967,9 @@ export interface Gatekeeper extends DurableObject { * reported via `stopped`. An action whose `submitAction()` call has not yet completed must not * be applied: wait for that call, then apply it. Omitting it would be the silent skip above, and * reporting it as `stopped` would record a failure on an action that has not been attempted. + * That wait is for an action already published to the caller: `submitAction()` publishes + * retained actions in ascending ID order, so a frontier covers every retained action below it. + * It is not licence to fold a still-unpublished lower ID into an open pass. * * Every ID in `vetoes` must be durably recorded before any action is applied, including when * processing stops: the caller clears its staged veto on any call that returns, so a veto lost @@ -1135,6 +1144,12 @@ export interface ApprovalQueue extends ObservationAuthorizer { * `action` is a sequential integer action ID assigned by the gatekeeper. It will later be used as * a decision frontier or veto in the Gatekeeper's `applyActionsThrough()` method. * + * A gatekeeper implementing `applyActionsThrough()` must publish the actions it retains in + * ascending ID order: a higher ID must not become visible to the overseer while a lower + * retained action is still unpublished. Concurrent or pipelined submissions are fine where they + * preserve that order. IDs the gatekeeper never retains may be skipped; the sequence need not + * be contiguous. + * * `description` describes the action in a way that can direct UI representation and policy * enforcement details. * From 30429ad4592ee433198976d3ce1c21a9b694b0c0 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 18 Sep 2026 17:47:47 -0500 Subject: [PATCH 08/20] Refuse queued approvals that predate a new failure An approval or batch already waiting in a connection's decision queue was planned as though nothing had happened since it was made. When the pass ahead of it stopped, the stale request became authority to retry the action that had just failed: a double-click on Approve reached the provider twice for one intent, the second attempt repeating a side effect whose outcome the first call never reported. A queued batch went further and applied the failed action under a boundary the user chose before the failure existed. The persisted `failure` only holds the automatic path back; an exact click or an explicit batch walks past it by design, which is what a human retry is. Stamp each request with the connection's stop count as it is admitted, and count structured stops on the run that records them. A click authorizes its action only if it was admitted no earlier than that action's latest stop, and a batch is refused when an in-range action it does not veto stopped after the batch was selected. Refusal writes nothing: no veto is staged, no reason is overwritten, no rule is dropped, and the caller gets ACTION_STOPPED rather than a gate error telling them to go and approve something they already did. Vetoing the failed action, or asking again once its reason is on the card, both go through. The state is deliberately ephemeral, per connection, and lives only as long as the run loop that owns the queue. Requests do not survive a restart either, so there is nothing for a durable generation to protect. --- .../__tests__/actions.test.ts | 201 ++++++++++++++++-- packages/workshop-backend/src/actions.ts | 95 +++++++-- packages/workshop-shared/src/api.ts | 14 +- 3 files changed, 266 insertions(+), 44 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index e71d0b31d9..98e1e1927f 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -42,14 +42,15 @@ function enableRule(storage: ActionSyncStorage, actionTag = "edit", gatekeeperId } // Workspace record ids are deliberately offset from gatekeeper-local action ids (`id = action*10`) -// so a test that confuses the two ID spaces fails loudly. +// so a test that confuses the two ID spaces fails loudly. `id` overrides that, for tests that need +// the same local action id on two connections. function putAction( storage: ActionSyncStorage, action: number, opts: { gatekeeperId?: number; actionTag?: string; autoApprovable?: boolean; state?: ActionRecord["state"]; chatId?: number; awaitDecision?: boolean; suspendedTurn?: boolean; vetoPending?: true; resolvedBy?: AiChatAuthorInfo; - failure?: string; createdAt?: Date } = {}): number { - let id = action * 10; + failure?: string; createdAt?: Date; id?: number } = {}): number { + let id = opts.id ?? action * 10; storage.actions.put({ id, gatekeeperId: opts.gatekeeperId ?? GK, @@ -75,8 +76,13 @@ function putAction( } function getAction(storage: ActionSyncStorage, action: number): GatekeeperActionRecord { - let record = storage.actions.get(action * 10); - if (!record || record.type !== "action") throw new Error(`No action ${action}`); + return getRecord(storage, action * 10); +} + +// By workspace record id, for the tests that seed explicit ids. +function getRecord(storage: ActionSyncStorage, id: number): GatekeeperActionRecord { + let record = storage.actions.get(id); + if (record?.type !== "action") throw new Error(`No action record ${id}`); return record; } @@ -125,8 +131,10 @@ function makeLegacyGatekeeper(opts: {failApply?: number[]} = {}) { return { target, calls, probeCount: () => probes }; } -function makeDriver(storage: ActionSyncStorage, target: GatekeeperActionTarget) { - return new ActionSyncDriver(storage, () => target, { +function makeDriver( + storage: ActionSyncStorage, + target: GatekeeperActionTarget | ((gatekeeperId: number) => GatekeeperActionTarget)) { + return new ActionSyncDriver(storage, typeof target === "function" ? target : () => target, { createGitCache: vi.fn(), createGitPackBuilder: vi.fn(), applyLegacyAction: async (gatekeeper, record) => { @@ -305,20 +313,35 @@ describe("ActionSyncDriver.apply", () => { expect(getAction(storage, 1).failure).toBe("x".repeat(500)); }); - it("never re-applies a failed action on a rule alone", async () => { + it("never re-applies a failed action on a rule alone, neither deciding it nor dropping the rule", + async () => { let storage = makeStorage(); enableRule(storage); - putAction(storage, 1, { failure: "the upstream page was deleted" }); + let a1 = putAction(storage, 1); putAction(storage, 2); - let { target, calls } = makeBatchGatekeeper(); + let { target, calls, results } = makeBatchGatekeeper(); + results.push({ stopped: { at: 1, reason: new Error("the upstream page was deleted") } }); await makeDriver(storage, target).apply(GK); - // The gatekeeper said why it stopped, not whether the action landed, so re-sending it - // unattended could repeat a side effect. It becomes a gate until a human retries it. - expect(calls).toEqual([]); - expect(getAction(storage, 1).state).toBe("pending"); + expect(calls).toEqual([{ actionId: 2, vetoes: [] }]); expect(getAction(storage, 2).state).toBe("pending"); + // The gatekeeper said why it stopped, not whether the action landed, so re-sending it + // unattended could repeat a side effect. It becomes a gate until a human retries it -- which + // means the rule that authorized the attempt has to survive the attempt. + expect(storage.autoApproveTags.get(`${GK}:edit`)?.enabledBy).toEqual(ENABLER); + + // Nor is the attempt a decision: the user sees a pending action with a reason and no resolver. + let entry = (await (await openFakeOverseer(storage)).listActions({ filter: "pending" })) + .entries.find(candidate => candidate.id === a1); + expect(entry).toMatchObject({ + state: "pending", failure: "the upstream page was deleted" }); + expect(entry?.type === "action" && entry.resolvedBy).toBeUndefined(); + expect(entry?.type === "action" && entry.autoApproved).toBeUndefined(); + + // A fresh driver (a restarted DO) runs the rule pass again and must not retry it. + await makeDriver(storage, target).apply(GK); + expect(calls).toEqual([{ actionId: 2, vetoes: [] }]); }); it("still rides a rule-authorized action along once the gate that refused a click is resolved", @@ -611,6 +634,83 @@ describe("ActionSyncDriver.apply", () => { expect(getAction(storage, 3)).toMatchObject({ state: "approved", resolvedBy: REJECTER }); }); + it("refuses a queued batch that was selected before an in-range action failed", async () => { + let storage = makeStorage(); + for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); + let { target, calls, results, release } = makeBatchGatekeeper({ parkAt: 2 }); + results.push({ stopped: { at: 2, reason: new Error("the document was locked") } }); + let driver = makeDriver(storage, target); + + let first = driver.applyThrough(getAction(storage, 2).id, [], APPROVER); + await flush(); + let second = driver.applyThrough( + getAction(storage, 3).id, [getAction(storage, 3).id], REJECTER); + release(); + + expect((await first).stopped).toBe(true); + // Selected before action 2 failed, so it is not authority to retry it -- and its own veto is + // not staged, since the batch it belongs to never ran. + expect(await second).toEqual({ decided: [], stopped: true }); + expect(calls).toEqual([{ actionId: 2, vetoes: [] }]); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2)).toMatchObject({ + state: "pending", failure: "the document was locked" }); + expect(getAction(storage, 3).state).toBe("pending"); + expect(getAction(storage, 3).vetoPending).toBeUndefined(); + }); + + it("runs a queued batch that vetoes the action a stop just failed on", async () => { + let storage = makeStorage(); + for (let action of [1, 2, 3]) putAction(storage, action, { autoApprovable: false }); + let { target, calls, results, release } = makeBatchGatekeeper({ parkAt: 2 }); + results.push({ stopped: { at: 2, reason: new Error("the document was locked") } }); + let driver = makeDriver(storage, target); + + let first = driver.applyThrough(getAction(storage, 2).id, [], APPROVER); + await flush(); + let second = driver.applyThrough( + getAction(storage, 3).id, [getAction(storage, 2).id], REJECTER); + release(); + await Promise.all([first, second]); + + // Rejecting what failed removes the barrier, so the rest of the batch is not cancelled along + // with it. The reason stays on the record as the history of why it was rejected. + expect(calls).toEqual([{ actionId: 2, vetoes: [] }, { actionId: 3, vetoes: [2] }]); + expect(getAction(storage, 1).state).toBe("approved"); + expect(getAction(storage, 2)).toMatchObject({ + state: "rejected", resolvedBy: REJECTER, failure: "the document was locked" }); + expect(getAction(storage, 2).vetoPending).toBeUndefined(); + expect(getAction(storage, 3).state).toBe("approved"); + }); + + it("keeps a stop from freezing a queued retry on another connection", async () => { + let storage = makeStorage(); + let other = GK + 1; + let a1 = putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 0, { gatekeeperId: other, autoApprovable: false, id: 20 }); + let b1 = putAction(storage, 1, { gatekeeperId: other, autoApprovable: false, id: 30, + failure: "an earlier attempt failed" }); + let a = makeBatchGatekeeper(); + a.results.push({ stopped: { at: 1, reason: new Error("A refused action one") } }); + let b = makeBatchGatekeeper({ parkAt: 0 }); + let driver = makeDriver(storage, id => id === GK ? a.target : b.target); + + let held = driver.apply(other, { action: 0, resolvedBy: APPROVER }); + await flush(); + // Queued on B, then A stops on the same gatekeeper-local action number. Stops are per + // connection: A's failure is no reason to hold B's queue. + let retry = driver.apply(other, { action: 1, resolvedBy: APPROVER }); + expect((await driver.apply(GK, { action: 1, resolvedBy: APPROVER })).stopped).toBe(true); + b.release(); + await Promise.all([held, retry]); + + expect(b.calls).toEqual([{ actionId: 0, vetoes: [] }, { actionId: 1, vetoes: [] }]); + expect(getRecord(storage, b1).state).toBe("approved"); + expect(getRecord(storage, b1).failure).toBeUndefined(); + expect(getRecord(storage, a1)).toMatchObject({ + state: "pending", failure: "A refused action one" }); + }); + it("refuses a rejection after an in-flight approval has applied the same action", async () => { let storage = makeStorage(); putAction(storage, 1); @@ -1127,20 +1227,79 @@ describe("Overseer action decisions", () => { expect(getAction(storage, 2).state).toBe("pending"); }); + it("refuses a queued approval that was requested before the action failed", async () => { + let storage = makeStorage(); + let id = putAction(storage, 1, { autoApprovable: false }); + putAction(storage, 2, { autoApprovable: false }); + let held = Promise.withResolvers(); + let applies = 0; + let legacy = makeLegacyGatekeeper(); + legacy.target.applyAction = (async () => { + applies++; + await held.promise; + throw new Error("the connection dropped before the response arrived"); + }) as typeof legacy.target.applyAction; + let client = await makeClient(storage, legacy.target); + + let first = client.approveAction(id).catch(caught => caught); + await flush(); + // Queued while the first attempt is still in flight, so it carries no authority to retry a + // failure that did not exist when it was made: the outcome of the lost call is unknown, and + // repeating it unasked could repeat a side effect that landed. + let second = client.approveAction(id).catch(caught => caught); + held.resolve(); + + expect(getActionErrorCode(await first)).toBe(ACTION_ERROR_CODES.stopped); + expect(getActionErrorCode(await second)).toBe(ACTION_ERROR_CODES.stopped); + expect(applies).toBe(1); + expect(getAction(storage, 1)).toMatchObject({ + state: "pending", failure: "the connection dropped before the response arrived", + }); + expect(getAction(storage, 2).state).toBe("pending"); + + // A request made after the stop was recorded is fresh authority, and does retry it. + legacy.target.applyAction = (async () => { applies++; }) as typeof legacy.target.applyAction; + await client.approveAction(id); + + expect(applies).toBe(2); + expect(getAction(storage, 1)).toMatchObject({ + state: "approved", resolvedBy: { id: "profile-id" }, autoApproved: false, + }); + expect(getAction(storage, 1).failure).toBeUndefined(); + expect(getAction(storage, 2).state).toBe("pending"); + }); + it("reports a stop at an earlier rule-authorized action on the clicked one", async () => { let storage = makeStorage(); enableRule(storage); putAction(storage, 1); let clicked = putAction(storage, 2, { autoApprovable: false }); - let client = await makeClient(storage, makeLegacyGatekeeper({ failApply: [1] }).target); - - let error = await client.approveAction(clicked).catch(caught => caught); + let later = putAction(storage, 3, { autoApprovable: false }); + let held = Promise.withResolvers(); + let legacy = makeLegacyGatekeeper(); + legacy.target.applyAction = (async (action: number) => { + legacy.calls.push(`apply:${action}`); + await held.promise; + throw new Error("apply 1 failed"); + }) as typeof legacy.target.applyAction; + let client = await makeClient(storage, legacy.target); - expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.stopped); + let first = client.approveAction(clicked).catch(caught => caught); + await flush(); + // Queued above the action that is about to fail. Once it has, this click reports that stop + // rather than "approve the earlier action first": that gate was already rule-authorized. + let second = client.approveAction(later).catch(caught => caught); + held.resolve(); + + expect(getActionErrorCode(await first)).toBe(ACTION_ERROR_CODES.stopped); + expect(getActionErrorCode(await second)).toBe(ACTION_ERROR_CODES.stopped); + expect(legacy.calls).toEqual(["apply:1"]); expect(getAction(storage, 1)).toMatchObject({ state: "pending", failure: "apply 1 failed" }); - expect(getAction(storage, 2).state).toBe("pending"); - // The reason lives on the action that stopped, so the clicked one carries none of its own. - expect(getAction(storage, 2).failure).toBeUndefined(); + // The reason lives on the action that stopped, so neither later action carries one of its own. + for (let action of [2, 3]) { + expect(getAction(storage, action).state).toBe("pending"); + expect(getAction(storage, action).failure).toBeUndefined(); + } }); it("keeps the failure on an action rejected after a failed apply", async () => { diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index e636365b9b..2a86c3ae64 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -64,12 +64,25 @@ export type PassResult = { */ blocked?: true; - /** Set when application stopped; the reason is recorded on the stopped action itself. */ + /** + * Set when application stopped; the reason is recorded on the stopped action itself. Also set + * when the request was already queued when that stop was recorded, so its authority predates + * the failure and cannot retry it. + */ stopped?: true; }; +// A queued manual approval, stamped with the connection's stop count when it was admitted. A +// stop recorded after that revokes this request's authority over the action that failed. +type QueuedManualApproval = ManualApproval & {stopGeneration: number}; + +// One run of the loop's scheduling state, discarded when the connection's queues drain. +// `stopGeneration` counts the structured stops this run has recorded (from 1); `stoppedActions` +// maps the gatekeeper-local action each was recorded on to that count. +type RunState = {stopGeneration: number, stoppedActions?: Map}; + type StagedPass = PromiseWithResolvers & { - manualApprovals: ManualApproval[]; + manualApprovals: QueuedManualApproval[]; }; /** @@ -109,7 +122,7 @@ export class ActionSyncDriver { #staged = new Map(); // Per-gatekeeper single-flight guard. Key present => a run loop is active for that gatekeeper. - #running = new Set(); + #running = new Map(); // Gatekeepers observed to lack applyActionsThrough. In-memory only: a fresh isolate re-probes, // which is what lets a migrated deploy shed the fallback without bookkeeping. @@ -134,12 +147,14 @@ export class ActionSyncDriver { slot = { manualApprovals: [], ...Promise.withResolvers() }; this.#staged.set(gatekeeperId, slot); } - if (manualApproval) slot.manualApprovals.push(manualApproval); - - if (!this.#running.has(gatekeeperId)) { - this.#running.add(gatekeeperId); - void this.#run(gatekeeperId); + // Stamped by value at admission: the run this lands in may record further stops before the + // request is planned, and those are exactly the ones its author cannot have seen. 0 when no + // run is active, which is also where a fresh one starts. + if (manualApproval) { + let stopGeneration = this.#running.get(gatekeeperId)?.stopGeneration ?? 0; + slot.manualApprovals.push({...manualApproval, stopGeneration}); } + this.#start(gatekeeperId); return slot.promise; } /** @@ -153,7 +168,8 @@ export class ActionSyncDriver { let boundary = this.storage.actions.get(boundaryId); if (!boundary) throw new Error(`No such action: ${boundaryId}`); if (boundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); - return this.#enqueueDecision(boundary.gatekeeperId, async () => { + let queuedGeneration = this.#running.get(boundary.gatekeeperId)?.stopGeneration ?? 0; + return this.#enqueueDecision(boundary.gatekeeperId, async () => { let freshBoundary = this.storage.actions.get(boundaryId); if (!freshBoundary) throw new Error(`No such action: ${boundaryId}`); if (freshBoundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); @@ -174,6 +190,22 @@ export class ActionSyncDriver { return fresh; }); + // A stop recorded since this batch was selected revokes its authority over the action that + // failed: the selection was made against state the failure has changed. Vetoing that action + // is the way through it, so a batch already selecting it proceeds untouched. + let run = this.#running.get(freshBoundary.gatekeeperId)!; // owned by the running loop + if (run.stopGeneration > queuedGeneration) { + let vetoed = new Set(selected.map(record => record.id)); + for (let record of this.storage.actions.pendingByGatekeeper + .get(freshBoundary.gatekeeperId)) { + if (record.type !== "action" || record.action > freshBoundary.action) continue; + if ((run.stoppedActions?.get(record.action) ?? 0) > queuedGeneration && + !vetoed.has(record.id)) { + return {decided: [], stopped: true}; + } + } + } + // One transaction: an unstaged veto is indistinguishable from an undecided action, and the // pass below authorizes every pending action under the boundary -- so half a staged batch // would apply what the user vetoed. @@ -214,6 +246,12 @@ export class ActionSyncDriver { }); } + #start(gatekeeperId: number): void { + if (this.#running.has(gatekeeperId)) return; + this.#running.set(gatekeeperId, {stopGeneration: 0}); + void this.#run(gatekeeperId); + } + #enqueueDecision(gatekeeperId: number, operation: () => Promise): Promise { let {promise, resolve, reject} = Promise.withResolvers(); let queue = this.#decisions.get(gatekeeperId); @@ -225,10 +263,7 @@ export class ActionSyncDriver { reject(error); } }); - if (!this.#running.has(gatekeeperId)) { - this.#running.add(gatekeeperId); - void this.#run(gatekeeperId); - } + this.#start(gatekeeperId); return promise; } @@ -263,7 +298,7 @@ export class ActionSyncDriver { } async #applyOnce( - gatekeeperId: number, manualApprovals: ManualApproval[], + gatekeeperId: number, manualApprovals: QueuedManualApproval[], batch?: {frontier: number; resolvedBy: AiChatAuthorInfo}): Promise { // Snapshot both indexes before reconciling (see actionsAscending). The pending index was // backfilled by the action-index migration; vetoPending only exists on records written after @@ -278,6 +313,7 @@ export class ActionSyncDriver { let frontier = batch?.frontier ?? Math.max(-1, ...manualApprovals.map(({action}) => action)); let attribution = new Map(); let blocked: true | undefined; + let stopped: true | undefined; if (batch) { for (let record of pending) { if (record.action > frontier) break; @@ -286,11 +322,15 @@ export class ActionSyncDriver { } else { // Two authorities extend the old frontier and nothing else: the user's click on that exact // action, or an auto-approval rule they enabled for its kind. - let clicked = new Map(manualApprovals.map(manual => [manual.action, manual.resolvedBy])); + let clicked = new Map(manualApprovals.map(manual => [manual.action, manual])); + let stops = this.#running.get(gatekeeperId)?.stoppedActions; for (let record of pending) { - let resolvedBy = clicked.get(record.action); - if (resolvedBy) { - attribution.set(record.action, {resolvedBy, autoApproved: false}); + // 0 while this run has not stopped on this action. A click stamped before a newer stop + // was made against state that stop invalidated, so it authorizes nothing. + let stopGeneration = stops?.get(record.action) ?? 0; + let manual = clicked.get(record.action); + if (manual && manual.stopGeneration >= stopGeneration) { + attribution.set(record.action, {resolvedBy: manual.resolvedBy, autoApproved: false}); continue; } // A prior stop requires a click: unattended replay could repeat a side effect that landed. @@ -303,7 +343,15 @@ export class ActionSyncDriver { if (!rule) { if (record.action <= frontier) { frontier = record.action - 1; - blocked = true; + // A stop no queued request could have seen reports the failure itself; otherwise + // this is an ordinary undecided gate the clicker is told to go and resolve. + if (stopGeneration > 0 && !manualApprovals.some( + request => request.action >= record.action && + request.stopGeneration >= stopGeneration)) { + stopped = true; + } else { + blocked = true; + } } break; } @@ -313,7 +361,7 @@ export class ActionSyncDriver { } let sendVetoes = stagedVetoes.filter(veto => veto.action <= frontier); - if (attribution.size === 0 && sendVetoes.length === 0) return {decided: [], blocked}; + if (attribution.size === 0 && sendVetoes.length === 0) return {decided: [], blocked, stopped}; let decided: number[] = []; @@ -403,12 +451,17 @@ export class ActionSyncDriver { "The gatekeeper could not apply this action."; fresh.appliedAt = new Date(); this.storage.actions.put(fresh); + // Barrier for every request already queued behind it. Run state only: after a restart + // the persisted `failure` above is what holds the automatic path back. Always inside + // #run, which owns this entry for the duration. + let run = this.#running.get(gatekeeperId)!; + (run.stoppedActions ??= new Map()).set(stoppedAt, ++run.stopGeneration); logger.warn("apply stopped", { event: "action.sync.stopped", gatekeeperId, actionId: fresh.id, }); } } - return stoppedAt === undefined ? {decided, blocked} : {decided, blocked, stopped: true}; + return {decided, blocked, stopped: stoppedAt === undefined ? stopped : true}; } // Re-read before each mutation; earlier checkpoints and cascade refreshes may replace snapshots. diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index ba3da2da08..4f7cf0528e 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -2036,12 +2036,22 @@ export interface Overseer extends RpcTarget { * selected action records in `vetoes`. Every selected record must belong to the same connection * and be no later than the boundary in that Gatekeeper's local action order. Selections that * are already decided are ignored, so a concurrent decision can't fail the whole request. + * + * A request still waiting in the connection's queue when an in-range action fails is refused + * with ACTION_STOPPED rather than treated as a retry of that failure: it was selected before + * the failure existed. Selecting the failed action as a veto is the way through, and so is + * requesting again once its reason is on the card. A refusal decides nothing, stages no veto, + * and leaves every record as it was. */ applyActionsThrough(id: number, vetoes: number[]): Promise; /** - * Approve an action that is currently in the "pending" state. This performs the action and may - * also perform earlier pending actions from the same Gatekeeper connection. + * Approve an action that is currently in the "pending" state. This performs the action, and any + * earlier pending action from the same Gatekeeper connection that an auto-approval rule already + * authorizes; it never carries authority over an earlier action still awaiting manual review. + * + * An approval still waiting in the queue when an action fails is likewise not a retry of that + * failure: it is refused with ACTION_STOPPED, and retrying takes a fresh approval. */ approveAction(id: number): Promise; From de3c2078bdba2ebbf096cba9aada320495ded411 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sat, 19 Sep 2026 16:04:44 -0500 Subject: [PATCH 09/20] Report a gatekeeper stop ahead of an undecided gate A pass can be both blocked and stopped. An undecided gate lowers the frontier and stops planning, but the prefix already authorized under it still goes out, so the gatekeeper can fail on one of those actions and record a reason on its card. `approveAction` checked `blocked` first, so the caller was told an earlier action needed a decision -- true, but the lesser of the two problems, and it sent them the wrong way. Approving the gate then fails again: the failed action now carries `failure`, which disqualifies it from the rule path and makes it a gate of its own. Two round trips, and neither error pointed at the card that explains why. Prefer the stop. It is the deeper blocker, and the only one of the two with a persisted reason -- its copy sends the user to the cards, where the undecided gate is visible as well. `blocked` alone still reports blocked, and the batch wrapper is unaffected: it never reads `blocked`, because an explicit boundary authorizes its whole prefix and cannot gate. --- .../__tests__/actions.test.ts | 23 +++++++++++++++++++ packages/workshop-backend/src/overseer.ts | 6 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index 98e1e1927f..3d52659f4f 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -1227,6 +1227,29 @@ describe("Overseer action decisions", () => { expect(getAction(storage, 2).state).toBe("pending"); }); + it("reports the stop, not the gate, when both hold on one pass", async () => { + let storage = makeStorage(); + enableRule(storage); + putAction(storage, 1); // rule-authorized, below the gate + putAction(storage, 2, { autoApprovable: false }); // undecided gate + let clicked = putAction(storage, 3, { autoApprovable: false }); + let batch = makeBatchGatekeeper(); + batch.results.push({ stopped: { at: 1, reason: new Error("provider refused") } }); + let client = await makeClient(storage, batch.target); + + let error = await client.approveAction(clicked).catch(caught => caught); + + // The gate lowers the frontier, but the prefix under it still goes out -- so the pass is both + // blocked and stopped. Naming the gate would send the user to approve action 2 and meet the + // same failure again, never pointing at the card that explains it. + expect(batch.calls).toEqual([{ actionId: 1, vetoes: [] }]); + expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.stopped); + expect(getAction(storage, 1)).toMatchObject({ + state: "pending", failure: "provider refused" }); + expect(getAction(storage, 2).state).toBe("pending"); + expect(getAction(storage, 3).state).toBe("pending"); + }); + it("refuses a queued approval that was requested before the action failed", async () => { let storage = makeStorage(); let id = putAction(storage, 1, { autoApprovable: false }); diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index d61a8e576e..91af052b0f 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -11213,9 +11213,11 @@ class OverseerClientInterface extends RpcTarget implements Overseer { } // Still pending: an earlier undecided action held the frontier below this one, or the - // gatekeeper stopped at or below it. Either way, surface the reason the user can act on. - if (blocked) throw createActionError(ACTION_ERROR_CODES.blocked); + // gatekeeper stopped at or below it. Both at once means the gate lowered the frontier onto a + // prefix that then failed, and the stop wins: it sits deeper, and it is the one with a reason + // recorded on a card the user can read. if (stopped) throw createActionError(ACTION_ERROR_CODES.stopped); + if (blocked) throw createActionError(ACTION_ERROR_CODES.blocked); throw new Error("Couldn't apply this action; an earlier action on this connection needs attention."); } From b5372cf36b38ab909c8f8d41c0468c1b36c7e3a3 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 09:26:30 -0500 Subject: [PATCH 10/20] Keep a stale action refresh from dropping a newer failure On an unresumed reconnect the cold-open sweep refetches every cached card whose log can still change, racing the action subscription. Its guard only rejected a pending read over a resolved card, so two pending snapshots were treated as interchangeable. They stopped being interchangeable when a pending card gained a `failure`: a read taken before an apply stopped could land after the subscription delivered the stop, silently erasing the reason and leaving an unexplained pending card until something else touched the record or the page reloaded. Reject a read whose change stamp is older than the cached card's. Every mutation stamps `appliedAt`, including the one that records a stop, so it already orders the two snapshots; `createdAt` stands in for a record nothing has touched, which keeps an equally-fresh read applying as before. Resolution monotonicity stays as the separate check, since a resolution delivered without a stamp has no watermark to compare. --- .../src/ChatInterface.actions.test.tsx | 25 +++++++++++++++++++ .../workshop-frontend/src/ChatInterface.tsx | 13 ++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index 2aa6bf341d..c48f2e8fc7 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -191,6 +191,31 @@ describe('ChatInterface action refresh', () => { expect(document.body.textContent).toContain('Approved') expect(document.body.textContent).not.toContain('stale failure') }) + + it('does not let a stale refresh drop a failure recorded while it was in flight', async () => { + await cachePendingCard() + + let resolveFetch!: (message: AiChatMessage | null) => void + const fetched = new Promise(resolve => { resolveFetch = resolve }) + const second = makeOverseer() + const secondChat = withChatApi(second, vi.fn(() => fetched)) + await renderChat(second.overseer, 1) + await vi.waitFor(() => expect(secondChat.getChatMessage).toHaveBeenCalledWith(1, 0)) + await second.resolveSubscription() + await second.resolvePendingQuery({ entries: [entry(1), entry(2)] }) + // An apply stops while the refresh is in flight: the card stays pending, so resolution + // monotonicity says nothing -- only the stop's own stamp distinguishes the two reads. + await second.emit(entry(1, { + failure: 'page was deleted upstream', + appliedAt: new Date(1700005000000), + })) + flushFrames() + + await act(async () => resolveFetch(actionMessage)) + flushFrames() + + expect(document.body.textContent).toContain('page was deleted upstream') + }) }) describe('ChatInterface action failure note', () => { diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 31f7f51cfd..b0b04c43be 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -83,6 +83,7 @@ import { WorkpieceId, BlueprintOutput, MessageFormatRef, + actionChangeTime, } from "@gadgets/workshop-shared/api"; import { composeCodeChange, type CodeChange } from "@gadgets/workshop-shared/code-change"; import type { ChatChangeRow } from "./features/code/otClient"; @@ -3821,11 +3822,13 @@ function ChatInterface({ try { const fetched = await overseer.getChatMessage(location.chatId, location.sequence); if (cancelled || fetched?.type !== "action" || !fetched.actionLog) return; - // Resolution is monotonic: never regress a card another channel already resolved. - const current = getCachedActionMessage(location)?.msg; - if (fetched.actionLog.state === "pending" && - current?.actionLog && current.actionLog.state !== "pending") return; - if (applyActionLogUpdateToCachedMessages(fetched.actionLog)) scheduleUpdate(); + // Never regress a card a faster channel already advanced: it may have resolved it, or + // stamped a newer change this read predates (recording a stop stamps appliedAt). + const log = fetched.actionLog; + const current = getCachedActionMessage(location)?.msg.actionLog; + if (current && ((log.state === "pending" && current.state !== "pending") || + actionChangeTime(log) < actionChangeTime(current))) return; + if (applyActionLogUpdateToCachedMessages(log)) scheduleUpdate(); } catch (err) { console.error("Failed to refresh action card:", err); } From 57244762565fe1e902ae3c55b801a9eac305ef33 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 09:40:21 -0500 Subject: [PATCH 11/20] Stage each selected veto once `vetoIds` names a selection of records, and a selection is a set. Repeating an id said nothing the first copy had not: multiplicity carries no meaning here, staging is order-independent, and the batch has a single `resolvedBy`, so there is nowhere for a second copy to mean anything. What it did do was read and write the same record once per copy inside the staging transaction. Normalize the argument to a set before validating it. Nothing observable changes -- duplicate staging already converged on identical state, and the vetoes sent to the gatekeeper come from the vetoPendingByGatekeeper index rather than this list, so it never doubled a delivery. Validation is unmoved: `Set` preserves first-occurrence order, so an invalid id still aborts the call at the same place with the same error. --- packages/workshop-backend/src/actions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index 2a86c3ae64..32b3a73365 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -177,7 +177,8 @@ export class ActionSyncDriver { throw new Error("Action batch contains a different connection."); } - let selected = vetoIds.map(id => { + // A selection is a set: staging one record twice is work with nothing to say. + let selected = [...new Set(vetoIds)].map(id => { let fresh = this.storage.actions.get(id); if (!fresh) throw new Error(`No such action: ${id}`); if (fresh.type !== "action") throw new Error(`Not an action: ${id}`); From d02c813139cd5cf80d0c2996e2705b23f7d8336f Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 10:17:32 -0500 Subject: [PATCH 12/20] Report a pack build's lifetime failure with its code `buildPack()` rechecks the action's lifetime after awaiting the build, so a connection removed mid-build yields GIT_PACK_ACTION_UNAVAILABLE rather than a half-built stream. But the build can also reject: `buildPackForAction` pulls missing objects from the gatekeeper, and removing the connection breaks the very stub that pull is waiting on. The rejection then propagated ahead of the recheck, so the caller saw the pull's failure instead of the coded error the contract tells gatekeepers to expect -- and since they must propagate unknown errors, it bypassed the structured `stopped` handling those codes exist to drive. Recheck on the failure exit too, preferring the lifetime error when the action is gone and rethrowing the build's own failure when it is still live. An invalidated action's pull failure is a consequence of the invalidation, not independent information; a genuine pull failure is, and must not be masked as a lifetime error. The existing mid-build table covered only the build-succeeds half of this guard, so its sibling covers both directions of the other. --- .../__tests__/git-push-actions.test.ts | 46 +++++++++++++++++++ packages/workshop-backend/src/git-cache.ts | 10 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/workshop-backend/__tests__/git-push-actions.test.ts b/packages/workshop-backend/__tests__/git-push-actions.test.ts index 7aff752795..bba230c389 100644 --- a/packages/workshop-backend/__tests__/git-push-actions.test.ts +++ b/packages/workshop-backend/__tests__/git-push-actions.test.ts @@ -491,6 +491,52 @@ describe("push authorization through the Overseer chokepoints", () => { }); }); + it.each([ + { + mode: "invalidated", + invalidate: true, + assert: (build: Promise) => + expectGitPackCode(() => build, GIT_PACK_ERROR_CODES.actionUnavailable), + }, + { + mode: "still live", + invalidate: false, + assert: (build: Promise) => expect(build).rejects.toThrow("pull failed"), + }, + ])("attributes a failed pack build to the action's lifetime ($mode)", + async ({ mode, invalidate, assert }) => { + await inOverseer(`batch-pack-build-failed-${mode.replace(" ", "-")}`, async impl => { + impl.storage.gatekeepers.put({ id: GATEKEEPER, class: {} }); + const { head } = await seedPushableHistory(impl, GATEKEEPER, ` ${mode}`); + await impl.submitAction(GATEKEEPER, 81, pushDescription([head]), { from: "user" }); + const record = actionRecord(impl, GATEKEEPER, 81); + const builder = new GitPackBuilderImpl( + impl.gitCache, impl.storage, GATEKEEPER, [record]); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const original = impl.gitCache.buildPackForAction; + // Removing the connection breaks the stub the missing-object pull waits on, so the build + // reports the pull's own failure rather than the invalidation that caused it. + impl.gitCache.buildPackForAction = async () => { + started.resolve(); + await release.promise; + throw new Error("pull failed"); + }; + + try { + const build = builder.buildPack(81); + await started.promise; + if (invalidate) impl.removeGatekeeper(GATEKEEPER); + release.resolve(); + await assert(build); + } finally { + release.resolve(); + impl.gitCache.buildPackForAction = original; + builder[Symbol.dispose](); + } + }); + }); + it("hands sessions a gatekeeper-scoped cache via getGitCache()", async () => { await inOverseer("push-session-cache", async impl => { let { head, base } = await seedPushableHistory(impl); diff --git a/packages/workshop-backend/src/git-cache.ts b/packages/workshop-backend/src/git-cache.ts index 79de252055..c26aaa8f0d 100644 --- a/packages/workshop-backend/src/git-cache.ts +++ b/packages/workshop-backend/src/git-cache.ts @@ -1319,7 +1319,15 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis async buildPack(action: number): Promise> { const record = this.#requireAction(action); - const stream = await this.cache.buildPackForAction(this.gatekeeperId, record.id); + // An invalidation during the build can surface as the pull's own failure, so recheck on both + // exits: the coded lifetime error is the cause, and the one the contract promises. + let stream: ReadableStream; + try { + stream = await this.cache.buildPackForAction(this.gatekeeperId, record.id); + } catch (error) { + this.#requireAction(action); + throw error; + } try { this.#requireAction(action); } catch (error) { From 31ccf117281e83d50f38376d5e77f9d9cadfd9af Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 11:01:25 -0500 Subject: [PATCH 13/20] Trim guards the apply-through paths cannot reach A cleanup pass over the branch, removing checks whose failing branch no code path can enter, and docs that restated a rule already stated where it belongs. `isMethodMissing` guarded against a coded Git pack error whose message resembled workerd's method-missing prose. The four pack messages are fixed strings that contain no such text, so the guard, its import, and the test that had to hand-mutate an error's message to reach it are gone. `GitPackBuilderImpl` rechecked `action` and `gatekeeperId` on a record it looked up by a map keyed on exactly those values, populated from this connection's own pending plan; both fields are immutable on a record. The dispose-time map clear was dead behind the `#active` flag. The fourth code, GIT_PACK_ACTION_DECLARES_NO_PUSH, is folded into "not authorized": the constructor filters on `pushedCommits?.length`, so an empty declaration never enters the map, and the contract already tells implementers to omit the field rather than pass `[]`. The next commit closes the one producer that could forward `[]`. `applyThrough` re-read the boundary's connection inside the queue and compared it to the pre-queue read; a record never changes connection. `reject()` re-read the record after the gatekeeper call, but the decision queue holds across that await, so nothing can decide it meanwhile. The legacy veto loop logged and rethrew a failure the run loop already logs and batch callers already receive. Docs: the publish-order rule was stated three times; `applyActionsThrough` now points at the normative text on `submitAction`. The hand-written function types on the pack error exports now match their siblings in api.ts. Tests: the two-scenario pack test is split so a failure names its scenario, the frontend card renderer is shared between the pending and resolved cases, and the RPC-safe `expectGitPackCode` helper gains a comment saying why it must not become `expect().rejects` -- handing an RPC-stub promise to vitest leaves an unhandled rejection behind in workerd. --- .../__tests__/actions.test.ts | 21 ------------------ .../__tests__/git-push-actions.test.ts | 12 +++++----- packages/workshop-backend/src/actions.ts | 21 +++--------------- packages/workshop-backend/src/git-cache.ts | 10 ++------- .../src/ChatInterface.actions.test.tsx | 22 +++++++------------ packages/workshop-shared/src/gatekeeper.ts | 16 ++++---------- 6 files changed, 23 insertions(+), 79 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index 3d52659f4f..538bc34573 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -13,11 +13,6 @@ import { import type { ApplyActionsThroughResult } from "@gadgets/workshop-shared/gatekeeper"; import type { ManualApproval } from "../src/actions.js"; import { keyString } from "@gadgets/typed-storage"; -import { - createGitPackError, - getGitPackErrorCode, - GIT_PACK_ERROR_CODES, -} from "@gadgets/workshop-shared/gatekeeper"; import { FIXTURE_EPOCH, makeActionStorage as makeStorage, makeSubscriber, openFakeOverseer, putAction as putStoredAction, @@ -818,22 +813,6 @@ describe("ActionSyncDriver legacy fallback", () => { expect(isMethodMissing(error)).toBe(true); }); - it("does not replay a coded batch failure whose message resembles method-missing prose", - async () => { - let storage = makeStorage(); - putAction(storage, 1, { autoApprovable: false }); - let { target, results } = makeBatchGatekeeper(); - let failure = createGitPackError(GIT_PACK_ERROR_CODES.builderExpired); - failure.message = 'The RPC receiver does not implement "applyActionsThrough".'; - results.push(failure); - - let caught = await makeDriver(storage, target) - .apply(GK, { action: 1, resolvedBy: APPROVER }).catch(error => error); - - expect(getGitPackErrorCode(caught)).toBe(GIT_PACK_ERROR_CODES.builderExpired); - expect(getAction(storage, 1).state).toBe("pending"); - }); - it("falls back on workerd's method-missing TypeError, delivering vetoes then applies in " + "ascending order, and probes only once", async () => { let storage = makeStorage(); diff --git a/packages/workshop-backend/__tests__/git-push-actions.test.ts b/packages/workshop-backend/__tests__/git-push-actions.test.ts index bba230c389..654a7cf7f2 100644 --- a/packages/workshop-backend/__tests__/git-push-actions.test.ts +++ b/packages/workshop-backend/__tests__/git-push-actions.test.ts @@ -91,7 +91,6 @@ async function collect(stream: ReadableStream): Promise for (;;) { let { done, value } = await reader.read(); if (done) break; - chunks.push(value); } return concatBytes(chunks); @@ -117,6 +116,8 @@ function actionRecord(impl: any, gatekeeperId: number, localAction: number) return record; } +// Awaits inside a try/catch on purpose: handing an RPC-stub call's promise to `expect().rejects` +// leaves an unhandled rejection behind in workerd. async function expectGitPackCode( operation: () => Promise, expected: GitPackErrorCode): Promise { let caught: unknown; @@ -349,13 +350,10 @@ describe("push authorization through the Overseer chokepoints", () => { await collect(await builder.buildPack(foreignId + 3)), { maxObjectSize: 1 })) .toStrictEqual([]); - for (const selector of [own.id, nonPush.action]) { + for (const selector of [own.id, nonPush.action, noCommits.action]) { await expectGitPackCode( () => builder.buildPack(selector), GIT_PACK_ERROR_CODES.actionNotAuthorized); } - await expectGitPackCode( - () => builder.buildPack(noCommits.action), - GIT_PACK_ERROR_CODES.actionDeclaresNoPush); impl.storage.transaction(() => { zero.state = "rejected"; @@ -367,7 +365,7 @@ describe("push authorization through the Overseer chokepoints", () => { }); }); - it("reconciles partial results and preserves pending state when the response is lost", async () => { + it("reconciles partial results when the gatekeeper stops mid-batch", async () => { await inOverseer("batch-pack-stopped", async impl => { const firstHistory = await seedPushableHistory(impl, GATEKEEPER, " first"); const secondHistory = await seedPushableHistory(impl, GATEKEEPER, " second"); @@ -409,7 +407,9 @@ describe("push authorization through the Overseer chokepoints", () => { await receiver.releaseRetained(); } }); + }); + it("preserves pending state and marks when the batch response is lost", async () => { await inOverseer("batch-pack-response-lost", async impl => { const { head } = await seedPushableHistory(impl, GATEKEEPER, " response lost"); const receiver = installPackReceiver( diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index 32b3a73365..fee17dd297 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -9,7 +9,6 @@ import type { GitCache, GitPackBuilder, } from "@gadgets/workshop-shared/gatekeeper"; -import { getGitPackErrorCode } from "@gadgets/workshop-shared/gatekeeper"; import { createWorkshopLogger } from "./observability"; import type { ActionRecord, AutoApproveTagRecord, GatekeeperActionRecord } from "./overseer.js"; @@ -89,11 +88,10 @@ type StagedPass = PromiseWithResolvers & { * Returns whether `error` is workerd's code-less missing-`applyActionsThrough` RPC error. * * Production workerd includes `the method` in this error; Miniflare's real DO stub omits it. - * Neither runtime attaches a code, so these two migration-only message forms remain the narrow - * compatibility probe. Recognized application codes are authoritative and never trigger replay. + * Neither runtime attaches a code, so these two migration-only message forms are the probe. */ export function isMethodMissing(error: unknown): boolean { - return getGitPackErrorCode(error) === undefined && error instanceof Error && ( + return error instanceof Error && ( error.message.includes('does not implement the method "applyActionsThrough"') || error.message.includes('does not implement "applyActionsThrough"')); } @@ -167,15 +165,11 @@ export class ActionSyncDriver { resolvedBy: AiChatAuthorInfo): Promise { let boundary = this.storage.actions.get(boundaryId); if (!boundary) throw new Error(`No such action: ${boundaryId}`); - if (boundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); let queuedGeneration = this.#running.get(boundary.gatekeeperId)?.stopGeneration ?? 0; return this.#enqueueDecision(boundary.gatekeeperId, async () => { let freshBoundary = this.storage.actions.get(boundaryId); if (!freshBoundary) throw new Error(`No such action: ${boundaryId}`); if (freshBoundary.type !== "action") throw new Error(`Not an action: ${boundaryId}`); - if (freshBoundary.gatekeeperId !== boundary.gatekeeperId) { - throw new Error("Action batch contains a different connection."); - } // A selection is a set: staging one record twice is work with nothing to say. let selected = [...new Set(vetoIds)].map(id => { @@ -238,8 +232,6 @@ export class ActionSyncDriver { await this.getGatekeeper(record.gatekeeperId).rejectAction(fresh.action); - fresh = this.storage.actions.get(record.id); - if (fresh?.type !== "action" || fresh.state !== "pending") return; fresh.state = "rejected"; fresh.resolvedBy = resolvedBy; fresh.appliedAt = new Date(); @@ -507,14 +499,7 @@ export class ActionSyncDriver { // overseer always has, and this path never reports `invalidatedByVeto`, so an un-migrated // gatekeeper's cascades leave their dependants pending until they too are decided. for (let veto of vetoes) { - try { - await gatekeeper.rejectAction(veto); - } catch (error) { - logger.warn("legacy rejectAction failed", { - event: "action.sync.legacy.reject.failed", gatekeeperId, error, - }); - throw error; - } + await gatekeeper.rejectAction(veto); acknowledgeVeto(veto); } // Each approval is persisted as it lands: unlike a replayed frontier, a replayed per-action diff --git a/packages/workshop-backend/src/git-cache.ts b/packages/workshop-backend/src/git-cache.ts index c26aaa8f0d..3758c2823b 100644 --- a/packages/workshop-backend/src/git-cache.ts +++ b/packages/workshop-backend/src/git-cache.ts @@ -1290,8 +1290,7 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis ) { super(); for (const record of pendingPlan) { - if (record.gatekeeperId === gatekeeperId && - record.description.pushedCommits !== undefined) { + if (record.description.pushedCommits?.length) { this.#workspaceActionByLocalId.set(record.action, record.id); } } @@ -1306,14 +1305,10 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis throw createGitPackError(GIT_PACK_ERROR_CODES.actionNotAuthorized); } const record = this.storage.actions.get(workspaceId); - if (record?.type !== "action" || record.action !== action || - record.gatekeeperId !== this.gatekeeperId || record.state !== "pending" || + if (record?.type !== "action" || record.state !== "pending" || this.storage.gatekeepers.get(this.gatekeeperId) === undefined) { throw createGitPackError(GIT_PACK_ERROR_CODES.actionUnavailable); } - if (!record.description.pushedCommits?.length) { - throw createGitPackError(GIT_PACK_ERROR_CODES.actionDeclaresNoPush); - } return record; } @@ -1341,7 +1336,6 @@ export class GitPackBuilderImpl extends RpcTarget implements GitPackBuilder, Dis [Symbol.dispose](): void { this.#active = false; - this.#workspaceActionByLocalId.clear(); } } diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index c48f2e8fc7..6ff725d544 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -119,15 +119,15 @@ async function cachePendingCard(key?: string) { flushFrames() } -// Renders one resolved action card in a live chat, which is where its status label is derived. -async function renderResolvedCard(over: Record) { - const resolved = entry(1, over) +// Renders one action card in a live chat, which is where its status label and notes are derived. +async function renderCard(over: Record) { + const log = entry(1, over) const server = makeOverseer() const chat = withChatApi(server) await renderChat(server.overseer, 1) await server.resolveSubscription() - await server.resolvePendingQuery({ entries: [resolved] }) - chat.emitMessage({ ...actionMessage, actionLog: resolved } as AiChatMessage) + await server.resolvePendingQuery({ entries: [log] }) + chat.emitMessage({ ...actionMessage, actionLog: log } as AiChatMessage) flushFrames() } @@ -220,13 +220,7 @@ describe('ChatInterface action refresh', () => { describe('ChatInterface action failure note', () => { it("shows the gatekeeper's reason on a pending action card", async () => { - const failed = entry(1, { failure: 'page was deleted upstream' }) - const server = makeOverseer() - const chat = withChatApi(server) - await renderChat(server.overseer, 1) - await server.resolveSubscription() - await server.resolvePendingQuery({ entries: [failed] }) - chat.emitMessage({ ...actionMessage, actionLog: failed } as AiChatMessage) + await renderCard({ failure: 'page was deleted upstream' }) expect(document.body.textContent).toContain('page was deleted upstream') }) @@ -234,14 +228,14 @@ describe('ChatInterface action failure note', () => { describe('ChatInterface action status', () => { it('presents a cascade invalidation as invalidated rather than denied', async () => { - await renderResolvedCard({ state: 'rejected', cascadedFrom: 2 }) + await renderCard({ state: 'rejected', cascadedFrom: 2 }) expect(document.body.textContent).toContain('Invalidated') expect(document.body.textContent).not.toContain('Denied') }) it('presents a direct rejection as denied', async () => { - await renderResolvedCard({ state: 'rejected' }) + await renderCard({ state: 'rejected' }) expect(document.body.textContent).toContain('Denied') expect(document.body.textContent).not.toContain('Invalidated') diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 3f5c41e359..18ac468d46 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -967,9 +967,8 @@ export interface Gatekeeper extends DurableObject { * reported via `stopped`. An action whose `submitAction()` call has not yet completed must not * be applied: wait for that call, then apply it. Omitting it would be the silent skip above, and * reporting it as `stopped` would record a failure on an action that has not been attempted. - * That wait is for an action already published to the caller: `submitAction()` publishes - * retained actions in ascending ID order, so a frontier covers every retained action below it. - * It is not licence to fold a still-unpublished lower ID into an open pass. + * (Such an action is already visible to the caller: see the publish-order rule on + * `ApprovalQueue.submitAction()`.) * * Every ID in `vetoes` must be durably recorded before any action is applied, including when * processing stops: the caller clears its staged veto on any call that returns, so a veto lost @@ -1543,8 +1542,6 @@ export const GIT_PACK_ERROR_CODES = { actionNotAuthorized: "GIT_PACK_ACTION_NOT_AUTHORIZED", /** The selected action is no longer pending or its gatekeeper connection was removed. */ actionUnavailable: "GIT_PACK_ACTION_UNAVAILABLE", - /** The selected action declares no pushed commits, so it has no pack to build. */ - actionDeclaresNoPush: "GIT_PACK_ACTION_DECLARES_NO_PUSH", } as const; /** An expected `GitPackBuilder.buildPack()` failure code. */ @@ -1557,18 +1554,13 @@ const gitPackErrors = codedErrorFamily({ "Action is not authorized for Git pack building in this apply-through call.", [GIT_PACK_ERROR_CODES.actionUnavailable]: "Git pack action is no longer pending or its connection was removed.", - [GIT_PACK_ERROR_CODES.actionDeclaresNoPush]: - "Action declares no pushed commits, so it has no pack to build.", }); /** Creates an expected Git pack failure carrying its stable machine-readable code. */ -export const createGitPackError: ( - code: GitPackErrorCode, -) => Error & { code: GitPackErrorCode } = gitPackErrors.create; +export const createGitPackError = gitPackErrors.create; /** Classifies an expected Git pack failure by recognized `code` only. */ -export const getGitPackErrorCode: (error: unknown) => GitPackErrorCode | undefined = - gitPackErrors.getCode; +export const getGitPackErrorCode = gitPackErrors.getCode; /** * Invocation-scoped native-RPC capability for building packs for authorized declared pushes. From 0f6ed20678362494978884bb6bba2a7c1e3a7414 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 11:01:25 -0500 Subject: [PATCH 14/20] Keep an empty pushedCommits list off the wire The kit forwarded a provider's `pushedCommits` whenever it was truthy, and `[]` is truthy. A `describe()` that computed "commits to push" and found none would put `pushedCommits: []` on the wire, which the overseer reads as a push declaration: it verifies no ancestry and marks no objects for it, and the pack builder refuses to build for it, so the action could only stop at apply time with a message that did not name the mistake. Filter on length instead of presence. An empty list is "no git", the same as no key, and now produces the same wire shape. The kit was the only producer that could forward `[]`; the GitHub gatekeeper always declares one commit directly. The existing no-key test is parameterized over the empty list so the truthy hole stays pinned as observable wire behavior. --- packages/gatekeeper-kit/__tests__/actions.test.ts | 10 +++++++--- packages/gatekeeper-kit/src/actions.ts | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/gatekeeper-kit/__tests__/actions.test.ts b/packages/gatekeeper-kit/__tests__/actions.test.ts index ac84a52ca2..7a4e0fc124 100644 --- a/packages/gatekeeper-kit/__tests__/actions.test.ts +++ b/packages/gatekeeper-kit/__tests__/actions.test.ts @@ -828,9 +828,13 @@ describe("defineActions", () => { } }); - it("puts no pushedCommits key on the wire when the description declares none", async () => { - // Absent, not `undefined`: the overseer reads presence as "this action pushes". - const { actions } = bind(); + it.each([ + { declared: "no key", present: undefined }, + { declared: "an empty list", present: () => ({ ...presentation, pushedCommits: [] }) }, + ])("puts no pushedCommits key on the wire when the description declares $declared", + async ({ present }) => { + // Absent, not `undefined` or `[]`: the overseer reads presence as "this action pushes". + const { actions } = bind({ describe: present }); const submitAction = submitSpy(); await actions.submit(fakeQueue(submitAction), "execute", { sql: "one" }); diff --git a/packages/gatekeeper-kit/src/actions.ts b/packages/gatekeeper-kit/src/actions.ts index 6b3487b1d6..47a9799d13 100644 --- a/packages/gatekeeper-kit/src/actions.ts +++ b/packages/gatekeeper-kit/src/actions.ts @@ -717,8 +717,9 @@ export function defineActions>( description, implementsRevert, // Spread, so an action with no git, no kind, or no awaited decision puts no key on the - // wire at all. - ...(pushedCommits ? { pushedCommits } : {}), + // wire at all. An empty list is "no git" too: the overseer reads presence as a push, + // and `[]` is a push of nothing it refuses to build a pack for. + ...(pushedCommits?.length ? { pushedCommits } : {}), autoApprovable: definition.autoApprovable === true, ...(definition.kind ? { actionKind: definition.kind } : {}), ...(definition.delivery === "await-decision" ? { awaitDecision: true } : {}), From 2ff9a51e87da49d6a1ff9d36d93413dd00ed0c4a Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Sun, 20 Sep 2026 11:16:53 -0500 Subject: [PATCH 15/20] Withhold "Always approve" on an action that stopped The offer's own comment says it appears only when enabling a rule would actually apply this action, and it checked three of the conditions for that: a tagged action, on a connection, that the gatekeeper marked auto-approvable. A recorded failure is a fourth, added later -- it disqualifies the action from the rule path, since nothing unattended may retry a side effect whose outcome the gatekeeper never confirmed. So on a stopped card the button still appeared, the confirm dialog still promised application, and enabling the rule left that action pending, with any agent turn awaiting it still suspended. Check the failure in both gates. The rule stays creatable from the auto-approval panel and Approve still works on the card, so nothing is lost except a promise that could not be kept. The backend is untouched: refusing to replay a stopped action unattended is the behaviour the offer was misreporting, not a bug in it. --- packages/workshop-frontend/src/Activity.tsx | 2 +- .../src/ChatInterface.actions.test.tsx | 29 +++++++++++++++++++ .../workshop-frontend/src/ChatInterface.tsx | 7 +++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx index cf0c52a3ee..c9ccbade5f 100644 --- a/packages/workshop-frontend/src/Activity.tsx +++ b/packages/workshop-frontend/src/Activity.tsx @@ -238,7 +238,7 @@ export default function Activity({ const autoApproveTarget = record.type === 'action' && record.gatekeeperId !== undefined && record.description.actionKind !== undefined && - record.description.autoApprovable === true + record.description.autoApprovable === true && record.failure === undefined ? { actionId: record.id, gatekeeperId: record.gatekeeperId, diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index 6ff725d544..3951804c50 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -226,6 +226,35 @@ describe('ChatInterface action failure note', () => { }) }) +// A pending card a rule would actually apply: gatekeeper-bound, tagged, auto-approvable. +const ruleEligible = { + gatekeeperId: 1, + description: { + title: 'Action 1', + description: '', + implementsRevert: false, + actionKind: { tag: 'edit', label: 'Edits' }, + autoApprovable: true, + }, +} + +describe('ChatInterface always-approve offer', () => { + it('offers it on a card a rule would apply', async () => { + await renderCard(ruleEligible) + + expect(document.body.textContent).toContain('Always approve') + }) + + it('withholds it once the card carries a failure', async () => { + await renderCard({ ...ruleEligible, failure: 'page was deleted upstream' }) + + // A stop disqualifies the action from the rule path, so enabling one here would promise an + // application that never happens and leave an awaiting agent turn suspended. + expect(document.body.textContent).toContain('page was deleted upstream') + expect(document.body.textContent).not.toContain('Always approve') + }) +}) + describe('ChatInterface action status', () => { it('presents a cascade invalidation as invalidated rather than denied', async () => { await renderCard({ state: 'rejected', cascadedFrom: 2 }) diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index b0b04c43be..ffdecfc43f 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -4926,11 +4926,12 @@ function ChatInterface({ : "text-kumo-inactive"; // Auto-approval target: offer "Always approve this type" only when enabling a rule would // actually apply this action -- a tagged action on a connection that the gatekeeper marked - // auto-approvable. (A non-auto-approvable action stays a manual gate even with a rule; an - // auto-approvable action with an existing rule wouldn't still be pending.) + // auto-approvable, whose last attempt did not stop. (A non-auto-approvable action stays a + // manual gate even with a rule; a stopped one needs an explicit retry; an auto-approvable + // action with an existing rule wouldn't still be pending.) const autoApproveTarget = log.gatekeeperId !== undefined && log.description.actionKind !== undefined && - log.description.autoApprovable === true + log.description.autoApprovable === true && log.failure === undefined ? { actionId: msg.actionId, gatekeeperId: log.gatekeeperId, From 3afb24bb4e0e9d676c1bd532df1b6203a01ef5e0 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 22 Sep 2026 06:57:50 -0500 Subject: [PATCH 16/20] Derive the action driver's seams from the real types `ActionSyncStorage` restated the overseer's action schema by hand: a collection plus the two indexes the driver reads. A hand-mirrored shape can drift from the thing it mirrors, and the tests already build their storage from the production `makeOverseerStorage`, so the interface bought nothing the real type does not give. The `applyLegacyAction` hook existed for a narrower reason: the driver had no way to build an action-scoped `GitCacheImpl`, so the overseer supplied the whole call instead of just the cache. Derive the storage type with `Pick`, and give `createGitCache` the optional action id the legacy path needs so the driver can make its own call. Two hooks become one and the legacy call site now reads like the native one above it. Tests lose a seam they only had to route around: `makeDriver` no longer reimplements the apply, `putAction` defers to the shared fixture, and `rejectionOf`/`rejectBatchProbe` replace the try/catch and the hand-written missing-method TypeError that several suites had each spelled out. --- .../__tests__/actions.test.ts | 52 +++------------ .../workshop-backend/__tests__/fixtures.ts | 65 +++++++++++++++---- .../__tests__/git-push-actions.test.ts | 24 ++----- packages/workshop-backend/src/actions.ts | 21 +++--- packages/workshop-backend/src/overseer.ts | 5 +- 5 files changed, 75 insertions(+), 92 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index 538bc34573..b09cdcdfca 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -4,9 +4,7 @@ import { describe, it, expect, vi } from "vitest"; import { ActionSyncDriver, ActionSyncStorage, GatekeeperActionTarget, isMethodMissing, } from "../src/actions.js"; -import type { - ActionRecord, GatekeeperActionRecord, OverseerDurableObject, -} from "../src/overseer.js"; +import type { GatekeeperActionRecord, OverseerDurableObject } from "../src/overseer.js"; import { ACTION_ERROR_CODES, getActionErrorCode, type ActionLogEntry, type AiChatAuthorInfo, type Overseer, } from "@gadgets/workshop-shared/api"; @@ -15,7 +13,7 @@ import type { ManualApproval } from "../src/actions.js"; import { keyString } from "@gadgets/typed-storage"; import { FIXTURE_EPOCH, makeActionStorage as makeStorage, makeSubscriber, openFakeOverseer, - putAction as putStoredAction, + putAction as putStoredAction, rejectBatchProbe, rejectionOf, type PutActionOptions, } from "./fixtures.js"; vi.mock("capnweb-validate", () => ({ validateRpc: () => () => undefined })); @@ -40,33 +38,10 @@ function enableRule(storage: ActionSyncStorage, actionTag = "edit", gatekeeperId // so a test that confuses the two ID spaces fails loudly. `id` overrides that, for tests that need // the same local action id on two connections. function putAction( - storage: ActionSyncStorage, action: number, - opts: { gatekeeperId?: number; actionTag?: string; autoApprovable?: boolean; - state?: ActionRecord["state"]; chatId?: number; awaitDecision?: boolean; - suspendedTurn?: boolean; vetoPending?: true; resolvedBy?: AiChatAuthorInfo; - failure?: string; createdAt?: Date; id?: number } = {}): number { - let id = opts.id ?? action * 10; - storage.actions.put({ - id, - gatekeeperId: opts.gatekeeperId ?? GK, - caller: { from: "agent", chatId: opts.chatId ?? 1 }, - createdAt: opts.createdAt ?? new Date(), - state: opts.state ?? "pending", - type: "action", - action, - ...(opts.vetoPending ? { vetoPending: true } : {}), - ...(opts.suspendedTurn !== undefined ? { suspendedTurn: opts.suspendedTurn } : {}), - ...(opts.resolvedBy ? { resolvedBy: opts.resolvedBy } : {}), - ...(opts.failure !== undefined ? { failure: opts.failure } : {}), - description: { - title: `Action ${action}`, - description: `Action ${action} description`, - implementsRevert: true, - actionKind: { tag: opts.actionTag ?? "edit", label: "Edits" }, - autoApprovable: opts.autoApprovable ?? true, - ...(opts.awaitDecision ? { awaitDecision: true } : {}), - }, - }); + storage: ReturnType, action: number, + opts: Omit & { id?: number } = {}): number { + let { id = action * 10, ...rest } = opts; + putStoredAction(storage, id, { gatekeeperId: GK, ...rest, action }); return id; } @@ -111,8 +86,7 @@ function makeLegacyGatekeeper(opts: {failApply?: number[]} = {}) { let target = { async applyActionsThrough() { probes++; - throw new TypeError( - 'The RPC receiver does not implement the method "applyActionsThrough".'); + rejectBatchProbe(); }, async applyAction(action: number) { calls.push(`apply:${action}`); @@ -132,10 +106,6 @@ function makeDriver( return new ActionSyncDriver(storage, typeof target === "function" ? target : () => target, { createGitCache: vi.fn(), createGitPackBuilder: vi.fn(), - applyLegacyAction: async (gatekeeper, record) => { - let apply = gatekeeper.applyAction as unknown as (action: number) => Promise; - await apply(record.action); - }, persistApproved: record => storage.actions.put(record), persistRejected: record => storage.actions.put(record), }); @@ -804,14 +774,8 @@ describe("ActionSyncDriver legacy fallback", () => { // The DO itself lacks the client interface's batch method; probe workerd's actual rejection. const receiver = stub as unknown as Fetcher>; using call = receiver.applyActionsThrough(1, []); - let error: unknown; - try { - await call; - } catch (caught) { - error = caught; - } - expect(isMethodMissing(error)).toBe(true); + expect(isMethodMissing(await rejectionOf(call))).toBe(true); }); it("falls back on workerd's method-missing TypeError, delivering vetoes then applies in " + "ascending order, and probes only once", async () => { diff --git a/packages/workshop-backend/__tests__/fixtures.ts b/packages/workshop-backend/__tests__/fixtures.ts index 1eaa92e3e0..b44071cee0 100644 --- a/packages/workshop-backend/__tests__/fixtures.ts +++ b/packages/workshop-backend/__tests__/fixtures.ts @@ -6,7 +6,9 @@ import { RpcStub as NativeRpcStub } from "cloudflare:workers"; import type { RpcStub } from "capnweb"; import { createTypedStorage, collection } from "@gadgets/typed-storage"; import type { Collection, Singleton } from "@gadgets/typed-storage"; -import type { ActionLogEntry, ActionsSubscriber, Overseer } from "@gadgets/workshop-shared/api"; +import type { + ActionLogEntry, ActionsSubscriber, AiChatAuthorInfo, Overseer, +} from "@gadgets/workshop-shared/api"; import { OverseerDurableObject, makeOverseerStorage } from "../src/overseer.js"; import { createWorkshopLogger } from "../src/observability.js"; import type { ActionRecord } from "../src/overseer.js"; @@ -52,17 +54,25 @@ export function makeSubscriber(entry?: (record: ActionLogEntry) => Promise return { subscriber: subscriber as unknown as RpcStub, events }; } -/** Puts a record and keeps nextActionId ahead of it, as the real allocator does. */ +/** + * Puts a record and keeps nextActionId ahead of it, as the real allocator does. `action` is the + * gatekeeper-local ID (defaults to `id`); the remaining action-only options land on the record + * only when given, matching how the overseer writes them. + */ +export type PutActionOptions = { + state?: ActionRecord["state"], type?: ActionRecord["type"], gatekeeperId?: number, + actionTag?: string, autoApprovable?: boolean, createdAt?: Date, appliedAt?: Date, + action?: number, chatId?: number, awaitDecision?: boolean, suspendedTurn?: boolean, + vetoPending?: true, resolvedBy?: AiChatAuthorInfo, failure?: string, +}; + export function putAction( storage: { actions: Collection, nextActionId: Singleton }, - id: number, - opts: { state?: ActionRecord["state"], type?: ActionRecord["type"], gatekeeperId?: number, - actionTag?: string, autoApprovable?: boolean, createdAt?: Date, - appliedAt?: Date } = {}) { + id: number, opts: PutActionOptions = {}) { let base = { id, gatekeeperId: opts.gatekeeperId ?? 1, - caller: { from: "agent", chatId: 1 } as const, + caller: { from: "agent", chatId: opts.chatId ?? 1 } as const, resourceTitle: `Resource ${id}`, createdAt: opts.createdAt ?? new Date(FIXTURE_EPOCH + id), ...(opts.appliedAt !== undefined ? { appliedAt: opts.appliedAt } : {}), @@ -71,17 +81,46 @@ export function putAction( let description = { title: `Action ${id}`, description: `Action ${id} description` }; let type = opts.type ?? "action"; storage.actions.put( - type === "action" ? { ...base, type, action: id, description: { - ...description, - implementsRevert: true, - actionKind: { tag: opts.actionTag ?? "edit", label: "Edits" }, - autoApprovable: opts.autoApprovable ?? true, - } } + type === "action" ? { + ...base, + type, + action: opts.action ?? id, + ...(opts.vetoPending ? { vetoPending: true } : {}), + ...(opts.suspendedTurn !== undefined ? { suspendedTurn: opts.suspendedTurn } : {}), + ...(opts.resolvedBy ? { resolvedBy: opts.resolvedBy } : {}), + ...(opts.failure !== undefined ? { failure: opts.failure } : {}), + description: { + ...description, + implementsRevert: true, + actionKind: { tag: opts.actionTag ?? "edit", label: "Edits" }, + autoApprovable: opts.autoApprovable ?? true, + ...(opts.awaitDecision ? { awaitDecision: true } : {}), + }, + } : type === "observation" ? { ...base, type, description } : { ...base, type, description, enabled: true }); if (id >= storage.nextActionId.get()) storage.nextActionId.put(id + 1); } +/** + * Awaits `promise` and returns what it rejected with (undefined if it resolved). A try/catch on + * purpose: handing an RPC-stub call's promise to `expect().rejects` leaves an unhandled rejection + * behind in workerd. + */ +export async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +/** Fails a batch call the way workerd's stub does for a gatekeeper that predates the method. */ +export function rejectBatchProbe(): never { + throw new TypeError('The RPC receiver does not implement the method "applyActionsThrough".'); +} + /** * Forges a client interface over the given storage via open(). `role` picks the returned * interface class ("build" opens as the owner); `exports` supplies any ctx.exports entries the diff --git a/packages/workshop-backend/__tests__/git-push-actions.test.ts b/packages/workshop-backend/__tests__/git-push-actions.test.ts index 654a7cf7f2..67da761566 100644 --- a/packages/workshop-backend/__tests__/git-push-actions.test.ts +++ b/packages/workshop-backend/__tests__/git-push-actions.test.ts @@ -22,6 +22,7 @@ import { concatBytes, decodePackBytes, encodeLooseObject, gitObjectOid } from "../src/git-codec"; import { GitPackBuilderImpl } from "../src/git-cache.js"; import type { GitObjectMetadataRecord } from "../src/git-cache.js"; +import { rejectBatchProbe, rejectionOf } from "./fixtures.js"; declare module "cloudflare:workers" { interface ProvidedEnv { @@ -116,17 +117,9 @@ function actionRecord(impl: any, gatekeeperId: number, localAction: number) return record; } -// Awaits inside a try/catch on purpose: handing an RPC-stub call's promise to `expect().rejects` -// leaves an unhandled rejection behind in workerd. async function expectGitPackCode( operation: () => Promise, expected: GitPackErrorCode): Promise { - let caught: unknown; - try { - await operation(); - } catch (error) { - caught = error; - } - expect(getGitPackErrorCode(caught)).toBe(expected); + expect(getGitPackErrorCode(await rejectionOf(operation()))).toBe(expected); } describe("push authorization through the Overseer chokepoints", () => { @@ -145,10 +138,7 @@ describe("push authorization through the Overseer chokepoints", () => { // gatekeeper would: reads a pending commit (simulation view) and builds the pack. let sawPack: Uint8Array | undefined; impl.getGatekeeperFacet = () => ({ - async applyActionsThrough() { - throw new TypeError( - 'The RPC receiver does not implement the method "applyActionsThrough".'); - }, + async applyActionsThrough() { rejectBatchProbe(); }, async applyAction(action: number, cache: any) { expect(action).toBe(1); expect((await cache.get(head))!.type).toBe("commit"); @@ -417,12 +407,8 @@ describe("push authorization through the Overseer chokepoints", () => { try { await impl.submitAction(GATEKEEPER, 71, pushDescription([head]), { from: "user" }); const record = actionRecord(impl, GATEKEEPER, 71); - let caught: unknown; - try { - await impl.applyDecidedActions(GATEKEEPER, { action: 71, resolvedBy: USER }); - } catch (error) { - caught = error; - } + const caught = await rejectionOf( + impl.applyDecidedActions(GATEKEEPER, { action: 71, resolvedBy: USER })); expect(caught).toBeInstanceOf(Error); expect(getGitPackErrorCode(caught)).toBeUndefined(); diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index fee17dd297..c90d4b5ffe 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -1,7 +1,6 @@ // Serializes gatekeeper decisions. Explicit batches durably stage vetoes; immediate rejections // become terminal only after acknowledgement, and only authorized actions are applied. -import type { Collection, NonUniqueIndex, TypedStorage } from "@gadgets/typed-storage"; import type { AiChatAuthorInfo } from "@gadgets/workshop-shared/api"; import type { ApplyActionsThroughResult, @@ -10,17 +9,11 @@ import type { GitPackBuilder, } from "@gadgets/workshop-shared/gatekeeper"; import { createWorkshopLogger } from "./observability"; -import type { ActionRecord, AutoApproveTagRecord, GatekeeperActionRecord } from "./overseer.js"; +import type { ActionRecord, GatekeeperActionRecord, OverseerStorage } from "./overseer.js"; const logger = createWorkshopLogger("workshop.action.sync"); -export interface ActionSyncStorage extends TypedStorage { - actions: Collection & { - pendingByGatekeeper: NonUniqueIndex; - vetoPendingByGatekeeper: NonUniqueIndex; - }; - autoApproveTags: Collection; -} +export type ActionSyncStorage = Pick; /** * The slice of the gatekeeper stub surface the driver drives, derived from the RPC contract. @@ -30,17 +23,18 @@ export interface ActionSyncStorage extends TypedStorage { export type GatekeeperActionTarget = Pick>, "applyActionsThrough" | "applyAction" | "rejectAction">; +// The stub type widens the optional method with a `Promise` property read; this is the +// callable half. type LiveApplyActionsThrough = Extract unknown>; type ActionSyncHooks = { - createGitCache: (gatekeeperId: number) => GitCache; + /** Scoped to the gatekeeper, and to one action for the legacy per-action apply. */ + createGitCache: (gatekeeperId: number, actionId?: number) => GitCache; createGitPackBuilder: ( gatekeeperId: number, pendingPlan: readonly GatekeeperActionRecord[], ) => GitPackBuilder & Disposable; - applyLegacyAction: ( - gatekeeper: GatekeeperActionTarget, record: GatekeeperActionRecord) => Promise; persistApproved: (record: GatekeeperActionRecord) => void; persistRejected: (record: GatekeeperActionRecord) => void; }; @@ -507,7 +501,8 @@ export class ActionSyncDriver { // pending forever. for (let record of pendingPlan) { try { - await this.hooks.applyLegacyAction(gatekeeper, record); + await gatekeeper.applyAction( + record.action, this.hooks.createGitCache(gatekeeperId, record.id)); } catch (error) { return {stopped: { at: record.action, diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 91af052b0f..cc1fcadd75 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -2039,11 +2039,10 @@ class OverseerImpl implements AgentHooks { this.#actionSync = new ActionSyncDriver( this.storage, gatekeeperId => this.getGatekeeperFacet(gatekeeperId), { - createGitCache: gatekeeperId => new GitCacheImpl(this.gitCache, gatekeeperId), + createGitCache: (gatekeeperId, actionId) => + new GitCacheImpl(this.gitCache, gatekeeperId, actionId), createGitPackBuilder: (gatekeeperId, pendingPlan) => new GitPackBuilderImpl(this.gitCache, this.storage, gatekeeperId, pendingPlan), - applyLegacyAction: (gatekeeper, record) => gatekeeper.applyAction(record.action, - new GitCacheImpl(this.gitCache, record.gatekeeperId, record.id)), persistApproved: record => this.storage.transaction(() => { this.gitCache.convertPushMarksToOnRemote(record.id); this.storage.actions.put(record); From fa50dd7be12523a6b0f3b34cfc01a5d3a39b6990 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 22 Sep 2026 06:57:59 -0500 Subject: [PATCH 17/20] Derive the always-approve target in one place The chat card and the Activity row each decided independently whether to offer "Always approve this type", with the same four-part condition written out twice. Keeping two copies in step by hand is what let them drift: when a recorded failure became a reason to withhold the offer, both copies needed the new clause, and the type the confirmation dialog consumes was declared inline in both files as well. `autoApproveTargetOf` now owns that decision, beside `actionStatusLabel`, which is the same move for the same reason. Each caller narrows to an action entry and asks. The rationale for each clause lives with the code that applies it rather than in a comment duplicated next to each copy. --- packages/workshop-frontend/src/Activity.tsx | 23 ++----------- .../src/ChatInterface.actions.test.tsx | 32 ++++++++--------- .../workshop-frontend/src/ChatInterface.tsx | 25 ++------------ .../src/features/actions/actionStatus.ts | 34 ++++++++++++++++++- 4 files changed, 54 insertions(+), 60 deletions(-) diff --git a/packages/workshop-frontend/src/Activity.tsx b/packages/workshop-frontend/src/Activity.tsx index c9ccbade5f..59881c6744 100644 --- a/packages/workshop-frontend/src/Activity.tsx +++ b/packages/workshop-frontend/src/Activity.tsx @@ -3,9 +3,8 @@ import { Switch, useKumoToastManager } from '@cloudflare/kumo' import { CaretRight, Check, Eye, Lightning, ShieldCheck } from '@phosphor-icons/react' import { RpcStub } from 'capnweb' import { ActionLogEntry, Overseer, actionChangeTime } from '@gadgets/workshop-shared/api' -import { ActionKind } from '@gadgets/workshop-shared/gatekeeper' import { ActionFailureNote } from './ActionFailureNote' -import { actionStatusLabel } from './features/actions/actionStatus' +import { actionStatusLabel, autoApproveTargetOf, type AutoApproveTarget } from './features/actions/actionStatus' import { GatekeeperIcon } from './components/GatekeeperIcon' import { HookToggle } from './components/HookToggle' import { AlwaysApproveButton, ResolveButton } from './components/ResolveButton' @@ -173,13 +172,7 @@ export default function Activity({ const [processingActions, setProcessingActions] = useState>(new Set()) const [togglingHooks, setTogglingHooks] = useState>(new Set()) const [expandedActionId, setExpandedActionId] = useState(null) - const [confirmAutoApprove, setConfirmAutoApprove] = useState<{ - actionId: number - gatekeeperId: number - resourceTitle: string - actionKind: ActionKind - actionLabel: string - } | null>(null) + const [confirmAutoApprove, setConfirmAutoApprove] = useState(null) const toasts = useKumoToastManager() const history = useActionHistory(overseer, historyFilter, view === 'history') @@ -236,17 +229,7 @@ export default function Activity({
{pendingActions.map(record => { const autoApproveTarget = - record.type === 'action' && record.gatekeeperId !== undefined && - record.description.actionKind !== undefined && - record.description.autoApprovable === true && record.failure === undefined - ? { - actionId: record.id, - gatekeeperId: record.gatekeeperId, - resourceTitle: record.resourceTitle, - actionKind: record.description.actionKind, - actionLabel: record.description.title, - } - : undefined + record.type === 'action' ? autoApproveTargetOf(record) : undefined return ( ) { +// Renders action 1's card in a live chat, which is where its status label and notes are derived. +// `entries` is the pending page the session settles with; `linkKey` links the stub so a later +// session can resume (unlinked sessions never park a watermark). +async function renderCard( + over: Record = {}, + { entries, linkKey }: { entries?: ActionLogEntry[]; linkKey?: string } = {}, +) { const log = entry(1, over) const server = makeOverseer() const chat = withChatApi(server) + if (linkKey !== undefined) linkActionLog(server.overseer, linkKey) await renderChat(server.overseer, 1) await server.resolveSubscription() - await server.resolvePendingQuery({ entries: [log] }) + await server.resolvePendingQuery({ entries: entries ?? [log] }) chat.emitMessage({ ...actionMessage, actionLog: log } as AiChatMessage) flushFrames() } +// A first session that caches a pending card, with a second pending action behind it. +function cachePendingCard(linkKey?: string) { + return renderCard({}, { entries: [entry(1), entry(2)], linkKey }) +} + describe('ChatInterface action refresh', () => { it('shows a missed failure on a cached card after a stub swap', async () => { await cachePendingCard() diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index ffdecfc43f..1824cdb9f3 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -14,7 +14,7 @@ import { type PointerEvent as ReactPointerEvent, } from "react"; import { reportIssue } from './errorReporting' -import { actionStatusLabel } from './features/actions/actionStatus' +import { actionStatusLabel, autoApproveTargetOf, type AutoApproveTarget } from './features/actions/actionStatus' import { Dialog, DropdownMenu, @@ -87,7 +87,6 @@ import { } from "@gadgets/workshop-shared/api"; import { composeCodeChange, type CodeChange } from "@gadgets/workshop-shared/code-change"; import type { ChatChangeRow } from "./features/code/otClient"; -import { ActionKind } from "@gadgets/workshop-shared/gatekeeper"; import { useSlashCommandChoice, type OverseerSource, } from "./components/chat/slash-command-catalog"; @@ -4329,10 +4328,7 @@ function ChatInterface({ }, [overseer, selectedChatId, toasts]); // Pending "always approve this type" confirmation, opened from a pending action card. - const [autoApproveConfirm, setAutoApproveConfirm] = useState< - { actionId: number; gatekeeperId: number; resourceTitle: string; - actionKind: ActionKind; actionLabel: string } | null - >(null); + const [autoApproveConfirm, setAutoApproveConfirm] = useState(null); // Enable auto-approval of an action tag on its connection (gated by the confirm dialog). The // server applies the now-eligible pending action(s) in an apply pass, and the state flips to @@ -4924,22 +4920,7 @@ function ChatInterface({ const stateLabelCls = isRejected ? "text-kumo-danger" : "text-kumo-inactive"; - // Auto-approval target: offer "Always approve this type" only when enabling a rule would - // actually apply this action -- a tagged action on a connection that the gatekeeper marked - // auto-approvable, whose last attempt did not stop. (A non-auto-approvable action stays a - // manual gate even with a rule; a stopped one needs an explicit retry; an auto-approvable - // action with an existing rule wouldn't still be pending.) - const autoApproveTarget = - log.gatekeeperId !== undefined && log.description.actionKind !== undefined && - log.description.autoApprovable === true && log.failure === undefined - ? { - actionId: msg.actionId, - gatekeeperId: log.gatekeeperId, - resourceTitle: log.resourceTitle, - actionKind: log.description.actionKind, - actionLabel: log.description.title, - } - : undefined; + const autoApproveTarget = autoApproveTargetOf(log); const actionControls = isPending ? ( <> diff --git a/packages/workshop-frontend/src/features/actions/actionStatus.ts b/packages/workshop-frontend/src/features/actions/actionStatus.ts index b2e242195d..eef82990d1 100644 --- a/packages/workshop-frontend/src/features/actions/actionStatus.ts +++ b/packages/workshop-frontend/src/features/actions/actionStatus.ts @@ -1,4 +1,5 @@ -import type { ActionState } from '@gadgets/workshop-shared/api' +import type { ActionLogEntry, ActionState } from '@gadgets/workshop-shared/api' +import type { ActionKind } from '@gadgets/workshop-shared/gatekeeper' /** * How an action's outcome reads to the user. A cascade-invalidated action was taken down by an @@ -14,3 +15,34 @@ export function actionStatusLabel(action: { state: ActionState; cascadedFrom?: n if (action.state === 'approved') return 'Approved' return action.cascadedFrom === undefined ? 'Denied' : 'Invalidated' } + +/** What the "Always approve this type" confirmation needs to know about the action it came from. */ +export type AutoApproveTarget = { + actionId: number + gatekeeperId: number + resourceTitle: string + actionKind: ActionKind + actionLabel: string +} + +/** + * Offer "Always approve this type" only when enabling a rule would actually apply this action: a + * tagged action on a connection that the gatekeeper marked auto-approvable, whose last attempt did + * not stop. (A non-auto-approvable action stays a manual gate even with a rule; a stopped one needs + * an explicit retry; an auto-approvable action with an existing rule wouldn't still be pending.) + */ +export function autoApproveTargetOf( + log: ActionLogEntry & { type: 'action' }, +): AutoApproveTarget | undefined { + if (log.gatekeeperId === undefined || log.description.actionKind === undefined || + log.description.autoApprovable !== true || log.failure !== undefined) { + return undefined + } + return { + actionId: log.id, + gatekeeperId: log.gatekeeperId, + resourceTitle: log.resourceTitle, + actionKind: log.description.actionKind, + actionLabel: log.description.title, + } +} From 725b82dd7ffbdd109bd5e2599838145df711ec8d Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 22 Sep 2026 09:25:10 -0500 Subject: [PATCH 18/20] Report a veto of an already-applied action instead of ignoring it A gatekeeper that has already applied an action cannot honour a veto of it, and the contract told it to ignore the veto silently. The caller then acknowledges a rejection it never got: the record is written `rejected` for work the provider executed, and because `persistRejected` also runs `clearPushMarks`, a Git push additionally loses the `onRemote` proof that makes its objects re-pullable. The gatekeeper knows which it is; nothing asked it. Report those ids in `alreadyApplied` and reconcile them to applied. Reported in the result rather than thrown, matching `invalidatedByVeto`: a throw would abort every other veto and apply in the batch, and since the caller replays its staged vetoes it would throw again on each retry and strand them. The record keeps no resolver, because the pass that applied it lost its response before recording one and this pass only knows the vetoer, and it sheds any `failure` left by an earlier stop, since `ActionLogEntry.failure` is cleared when an action applies and both cards render the note whatever the state. Only ids this call actually sent are honoured, since vetoes beyond the frontier stay staged and undelivered by design. Inert until a gatekeeper implements `applyActionsThrough`, so populating it is an acceptance criterion of the first native port rather than something today's legacy path can exercise. --- .../__tests__/actions.test.ts | 45 +++++++++++++++++++ packages/workshop-backend/src/actions.ts | 30 ++++++++++++- packages/workshop-backend/src/overseer.ts | 5 ++- packages/workshop-shared/src/api.ts | 4 ++ packages/workshop-shared/src/gatekeeper.ts | 18 +++++++- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index b09cdcdfca..f2f3d70271 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -509,6 +509,36 @@ describe("ActionSyncDriver.apply", () => { expect(getAction(storage, 1).state).toBe("approved"); }); + it("records a veto the gatekeeper refused as already applied", async () => { + let storage = makeStorage(); + let a1 = putAction(storage, 1, { autoApprovable: false }); + let a2 = putAction(storage, 2, + { autoApprovable: false, failure: "an earlier attempt stopped here" }); + putAction(storage, 4, { state: "rejected", vetoPending: true, resolvedBy: REJECTER }); + + // Action 2 stopped once, was applied on the retry with the reply lost, and was then + // rejected. Recording that rejection would enter an executed action as denied, and the + // stale reason would ride along onto an approved card. + let { target, calls, results } = makeBatchGatekeeper(); + results.push({ alreadyApplied: [2, 4] }); + let { decided, vetoRefused } = await makeDriver(storage, target) + .applyThrough(a2, [a2], REJECTER); + + expect(calls).toEqual([{ actionId: 2, vetoes: [2] }]); + expect(decided.toSorted((a, b) => a - b)).toEqual([a1, a2]); + expect(vetoRefused).toBe(true); + expect(getAction(storage, 1).state).toBe("approved"); + let refused = getAction(storage, 2); + expect(refused.state).toBe("approved"); + expect(refused.vetoPending).toBeUndefined(); + // The approving pass never got to record who authorized it, and the vetoer did not. + expect(refused.resolvedBy).toBeUndefined(); + expect(refused.failure).toBeUndefined(); + // Action 4's veto sat beyond the boundary, so it was never sent and is not the gatekeeper's + // to refuse. + expect(getAction(storage, 4)).toMatchObject({ state: "rejected", vetoPending: true }); + }); + it("coalesces concurrent approvals into one follow-up pass at the highest frontier", async () => { let storage = makeStorage(); putAction(storage, 1, { autoApprovable: false }); @@ -935,6 +965,21 @@ describe("Overseer action decisions", () => { expect(storage.actions.get(boundary)).toMatchObject({ state: "rejected" }); }); + it("tells the rejecting client its veto was refused as already applied", async () => { + let storage = makeStorage(); + let boundary = putAction(storage, 1, { autoApprovable: false }); + let batch = makeBatchGatekeeper(); + batch.results.push({ alreadyApplied: [1] }); + let client = await makeClient(storage, batch.target); + + let error = await client.applyActionsThrough(boundary, [boundary]).catch(caught => caught); + + // Staging already showed the card denied, so the flip to approved has to be explained; + // nothing on the record itself says a rejection was asked for and refused. + expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.vetoRefused); + expect(storage.actions.get(boundary)).toMatchObject({ state: "approved" }); + }); + it("replays a recorded stop to a client resuming after the action was created", async () => { let storage = makeStorage(); let boundary = putAction(storage, 1, diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index c90d4b5ffe..0b1e998a37 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -63,6 +63,13 @@ export type PassResult = { * the failure and cannot retry it. */ stopped?: true; + + /** + * Set when the gatekeeper refused a veto because it had already applied that action. The + * record now reads approved, which is the opposite of what the user asked for, so the pass + * says so rather than letting the card flip unexplained. + */ + vetoRefused?: true; }; // A queued manual approval, stamped with the connection's stop count when it was admitted. A @@ -392,8 +399,25 @@ export class ActionSyncDriver { } stoppedFailure = undefined; } - for (let veto of sendVetoes) acknowledgeVeto(veto.action); let sentVetoes = new Map(sendVetoes.map(veto => [veto.action, veto])); + // A veto the gatekeeper refused because it had already applied that action. Acknowledging it + // would enter an executed action as rejected. The pass that applied it lost its response + // before recording an approver, and this one only knows the vetoer, so it records no one. + let vetoRefused: true | undefined; + for (let action of result.alreadyApplied ?? []) { + if (!sentVetoes.has(action)) continue; + let fresh = this.#freshAction(byAction, action); + if (fresh?.state !== "rejected") continue; + fresh.state = "approved"; + fresh.appliedAt = new Date(); + delete fresh.vetoPending; + delete fresh.resolvedBy; + delete fresh.failure; + this.hooks.persistApproved(fresh); + decided.push(fresh.id); + vetoRefused = true; + } + for (let veto of sendVetoes) acknowledgeVeto(veto.action); // Cascade invalidations first: an action inside the frontier can also be cascade-invalidated // by a veto delivered in this same pass, and then it was deleted, not applied -- marking it // rejected here keeps the approval loop below (which only touches pending records) from @@ -448,7 +472,9 @@ export class ActionSyncDriver { }); } } - return {decided, blocked, stopped: stoppedAt === undefined ? stopped : true}; + return { + decided, blocked, vetoRefused, stopped: stoppedAt === undefined ? stopped : true, + }; } // Re-read before each mutation; earlier checkpoints and cascade refreshes may replace snapshots. diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index cc1fcadd75..04c7b60414 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -11166,8 +11166,11 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // The batch is validated by the driver, inside its decision queue, where the records are // re-read fresh; a copy of those checks here could only ever act on stale reads. let profile = await this.#getClientProfile(); - let {decided, stopped} = await this.impl.applyActionBatch(id, vetoes, profile); + let {decided, stopped, vetoRefused} = await this.impl.applyActionBatch(id, vetoes, profile); await this.#resumeDecidedActionChats(decided); + // Ahead of the stop: a stop leaves its reason on the card, whereas an action the user + // rejected silently reading as applied has nothing on it to explain the reversal. + if (vetoRefused) throw createActionError(ACTION_ERROR_CODES.vetoRefused); if (stopped) throw createActionError(ACTION_ERROR_CODES.stopped); } diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index 4f7cf0528e..a9305d5262 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -384,6 +384,8 @@ export const ACTION_ERROR_CODES = { blocked: "ACTION_BLOCKED", /** The gatekeeper could not apply an action and recorded why on its card. */ stopped: "ACTION_STOPPED", + /** A rejection could not take effect because the gatekeeper had already applied the action. */ + vetoRefused: "ACTION_VETO_REFUSED", } as const; /** An expected action outcome failure code. */ @@ -396,6 +398,8 @@ export const ACTION_ERROR_MESSAGES: Record = { "An earlier action needs a decision before this one can be applied.", [ACTION_ERROR_CODES.stopped]: "Action could not be completed. Check this connection's action cards for the reason.", + [ACTION_ERROR_CODES.vetoRefused]: + "Some actions were already applied and could not be rejected.", }; const actionErrors = codedErrorFamily(ACTION_ERROR_MESSAGES); diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 18ac468d46..39aac75b67 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -809,6 +809,18 @@ export interface ApplyActionsThroughResult { * Gatekeeper to decide the right trade-off between implementation complexity and UX. */ invalidatedByVeto?: Array<{action: number, invalidatedBy: number}>; + + /** + * Vetoed action numbers this gatekeeper had already applied, so the veto did not take effect. + * Every entry must be an ID from this call's `vetoes`. Acknowledging one would record an + * executed action as rejected, which is why the refusal is reported rather than swallowed: the + * caller reconciles these to applied instead. + * + * Reported rather than thrown, so the rest of the batch still completes. A throw would abort + * every other veto and apply in the call, and the caller replays its staged vetoes, so it would + * throw again on each retry and strand them. + */ + alreadyApplied?: number[]; } /** @@ -987,8 +999,10 @@ export interface Gatekeeper extends DurableObject { * the gatekeeper is nevertheless expected to submit all actions for approval; there is no mode * in which it's OK to skip the check. * - * Calls must be idempotent. Missing IDs and vetoes of unknown or already-applied actions are - * ignored. A repeated request must re-report persisted invalidations attributable to its vetoes. + * Calls must be idempotent. Missing IDs and vetoes of unknown or already-rejected actions are + * ignored; a veto of an action this gatekeeper already applied goes in `alreadyApplied`. A + * repeated request must re-report persisted invalidations attributable to its vetoes, and + * repeat an `alreadyApplied` refusal for as long as the caller keeps sending that veto. */ applyActionsThrough?(actionId: number, vetoes: number[], context: ApplyActionContext): Promise; From 2d0d0ccdc11bb9997fb1e5063b698ccb38d0216a Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 22 Sep 2026 12:51:41 -0500 Subject: [PATCH 19/20] Say on the card that a refused veto was already applied A veto the gatekeeper refuses because it had already applied the action reconciles the record from rejected to approved, and `vetoRefused` tells the caller why. Only `applyActionsThrough` reads it. `approveAction` and the background rule passes both ride staged vetoes too, and neither reports anything, so the card flips from Denied to Approved with nothing on it to say the user asked for the opposite. Throwing from those routes is not the answer: `approveAction` returns early once the clicked action reads approved, so the check would have to precede that return and fail a click that succeeded, over a reversal on a different card. Mark the record instead, and give the marked state its own label. The flag rides the same path as `cascadedFrom`, the label comes out of the one helper both surfaces already share, and no route needs to learn to report anything. `actionStatusLabel` now covers both states that would otherwise read as a verdict nobody gave: taken down by an earlier rejection, and applied before a rejection could land. --- .../workshop-backend/__tests__/actions.test.ts | 11 +++++++---- packages/workshop-backend/src/actions.ts | 4 +++- packages/workshop-backend/src/overseer.ts | 4 ++++ .../src/ChatInterface.actions.test.tsx | 8 ++++++++ .../src/features/actions/actionStatus.ts | 14 ++++++++------ packages/workshop-shared/src/api.ts | 7 +++++++ 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index f2f3d70271..3c2d82e226 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -531,9 +531,11 @@ describe("ActionSyncDriver.apply", () => { let refused = getAction(storage, 2); expect(refused.state).toBe("approved"); expect(refused.vetoPending).toBeUndefined(); - // The approving pass never got to record who authorized it, and the vetoer did not. + // The approving pass never got to record who authorized it, and the vetoer did not. The + // marker is what keeps the card from reading as a decision someone made. expect(refused.resolvedBy).toBeUndefined(); expect(refused.failure).toBeUndefined(); + expect(refused.vetoRefused).toBe(true); // Action 4's veto sat beyond the boundary, so it was never sent and is not the gatekeeper's // to refuse. expect(getAction(storage, 4)).toMatchObject({ state: "rejected", vetoPending: true }); @@ -974,10 +976,11 @@ describe("Overseer action decisions", () => { let error = await client.applyActionsThrough(boundary, [boundary]).catch(caught => caught); - // Staging already showed the card denied, so the flip to approved has to be explained; - // nothing on the record itself says a rejection was asked for and refused. + // Staging already showed the card denied, so the flip to approved has to be explained: the + // caller hears it now, and the record carries it for everyone who only sees the card later. expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.vetoRefused); - expect(storage.actions.get(boundary)).toMatchObject({ state: "approved" }); + expect(storage.actions.get(boundary)) + .toMatchObject({ state: "approved", vetoRefused: true }); }); it("replays a recorded stop to a client resuming after the action was created", async () => { diff --git a/packages/workshop-backend/src/actions.ts b/packages/workshop-backend/src/actions.ts index 0b1e998a37..8090549cf4 100644 --- a/packages/workshop-backend/src/actions.ts +++ b/packages/workshop-backend/src/actions.ts @@ -402,7 +402,8 @@ export class ActionSyncDriver { let sentVetoes = new Map(sendVetoes.map(veto => [veto.action, veto])); // A veto the gatekeeper refused because it had already applied that action. Acknowledging it // would enter an executed action as rejected. The pass that applied it lost its response - // before recording an approver, and this one only knows the vetoer, so it records no one. + // before recording an approver, and this one only knows the vetoer, so it records no one and + // marks the record instead: the state says applied, which nobody chose. let vetoRefused: true | undefined; for (let action of result.alreadyApplied ?? []) { if (!sentVetoes.has(action)) continue; @@ -410,6 +411,7 @@ export class ActionSyncDriver { if (fresh?.state !== "rejected") continue; fresh.state = "approved"; fresh.appliedAt = new Date(); + fresh.vetoRefused = true; delete fresh.vetoPending; delete fresh.resolvedBy; delete fresh.failure; diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 04c7b60414..9293e2f9de 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -703,6 +703,9 @@ export type ActionRecord = { /** Outstanding veto from a previously staged rejection; cleared after delivery. */ vetoPending?: true; + /** Set when a gatekeeper refused this action's veto because it had already applied it. */ + vetoRefused?: true; + /** * Whether submitting this action suspended its agent turn. Absent on records written before the * field existed; `suspendedAgentTurn()` answers for those. @@ -1048,6 +1051,7 @@ function actionRecordToLog(record: ActionRecord): ActionLogEntry { autoApproved: record.autoApproved, cascadedFrom: record.cascadedFrom, failure: record.failure, + vetoRefused: record.vetoRefused, }; case "bindHook": return { diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index a51d044e0d..9098a73a80 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -267,4 +267,12 @@ describe('ChatInterface action status', () => { expect(document.body.textContent).toContain('Denied') expect(document.body.textContent).not.toContain('Invalidated') }) + + it('presents a refused veto as already applied rather than approved', async () => { + await renderCard({ state: 'approved', vetoRefused: true }) + + // The user asked for the opposite, so the bare state would read as a verdict they never gave. + expect(document.body.textContent).toContain('Already applied') + expect(document.body.textContent).not.toContain('Approved') + }) }) diff --git a/packages/workshop-frontend/src/features/actions/actionStatus.ts b/packages/workshop-frontend/src/features/actions/actionStatus.ts index eef82990d1..f73df0dbc3 100644 --- a/packages/workshop-frontend/src/features/actions/actionStatus.ts +++ b/packages/workshop-frontend/src/features/actions/actionStatus.ts @@ -2,17 +2,19 @@ import type { ActionLogEntry, ActionState } from '@gadgets/workshop-shared/api' import type { ActionKind } from '@gadgets/workshop-shared/gatekeeper' /** - * How an action's outcome reads to the user. A cascade-invalidated action was taken down by an - * earlier rejection rather than refused on its own merits, so it must not read as a decision anyone - * made about this action (see `cascadedFrom` in the API). + * How an action's outcome reads to the user. Two states do not describe a decision anyone made + * about this action: a cascade-invalidated one was taken down by an earlier rejection (see + * `cascadedFrom`), and a veto the gatekeeper refused leaves the record applied although the user + * asked for the opposite (see `vetoRefused`). Both would otherwise read as someone's verdict. * * Shared because deriving it per surface is what let the chat card and the Activity row disagree * about the same record. */ -export function actionStatusLabel(action: { state: ActionState; cascadedFrom?: number }): - 'Pending' | 'Approved' | 'Denied' | 'Invalidated' { +export function actionStatusLabel( + action: { state: ActionState; cascadedFrom?: number; vetoRefused?: true }, +): 'Pending' | 'Approved' | 'Denied' | 'Invalidated' | 'Already applied' { if (action.state === 'pending') return 'Pending' - if (action.state === 'approved') return 'Approved' + if (action.state === 'approved') return action.vetoRefused ? 'Already applied' : 'Approved' return action.cascadedFrom === undefined ? 'Denied' : 'Invalidated' } diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index a9305d5262..27b18dd783 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -1738,6 +1738,13 @@ export type ActionLogEntry = { * outcome the gatekeeper never confirmed. Cleared when the action applies. */ failure?: string; + + /** + * Set when the user rejected this action but the gatekeeper had already applied it, so the + * rejection could not be honoured. Only ever set alongside state "approved", which nobody + * chose: the pass that applied it lost its response before an approver was recorded. + */ + vetoRefused?: true; } | { type: "observation"; description: ObservationDescription; From 36b7438c851b975d08908040e2ed4e04d6ba7743 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 22 Sep 2026 13:46:10 -0500 Subject: [PATCH 20/20] Keep turns ended, and cards current, after a veto decision Three gaps around rejecting an action, each reachable from the card. A veto the gatekeeper refuses as already applied reconciles the record to approved, so the resume gate read it as an approval and could resume the turn once its siblings applied. The user asked to stop; a late refusal does not change that. The gate now treats `vetoRefused` like a rejection. Rejecting an action also unblocks any rule-approved actions queued behind it, but nothing ran a pass until the next unrelated trigger, leaving their turns suspended. `rejectAction` now runs the same background pass-and-resume that enabling a rule does, shared as `#applyDecidedInBackground`. A cold reconnect re-fetched only pending cards, so a Denied card whose veto was refused while disconnected kept reading Denied. It now re-fetches every card that is not approved. --- .../__tests__/actions.test.ts | 48 +++++++++++++++++-- .../workshop-backend/__tests__/fixtures.ts | 4 +- packages/workshop-backend/src/overseer.ts | 15 ++++-- .../src/ChatInterface.actions.test.tsx | 20 ++++++++ .../workshop-frontend/src/ChatInterface.tsx | 9 ++-- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/packages/workshop-backend/__tests__/actions.test.ts b/packages/workshop-backend/__tests__/actions.test.ts index 3c2d82e226..0435283e65 100644 --- a/packages/workshop-backend/__tests__/actions.test.ts +++ b/packages/workshop-backend/__tests__/actions.test.ts @@ -111,9 +111,11 @@ function makeDriver( }); } -function makeClient(storage: ActionSyncStorage, target: GatekeeperActionTarget) { +function makeClient( + storage: ActionSyncStorage, target: GatekeeperActionTarget, impl: Record = {}) { let driver = makeDriver(storage, target); return openFakeOverseer(storage, { impl: { + ...impl, applyDecidedActions: (gatekeeperId: number, approval?: ManualApproval) => driver.apply(gatekeeperId, approval), rejectPendingAction: (record: GatekeeperActionRecord, author: AiChatAuthorInfo) => @@ -969,10 +971,17 @@ describe("Overseer action decisions", () => { it("tells the rejecting client its veto was refused as already applied", async () => { let storage = makeStorage(); - let boundary = putAction(storage, 1, { autoApprovable: false }); + enableRule(storage); + let sibling = putAction(storage, 1, { chatId: 7, awaitDecision: true, suspendedTurn: true }); + let boundary = putAction(storage, 2, + { autoApprovable: false, chatId: 7, awaitDecision: true, suspendedTurn: true }); let batch = makeBatchGatekeeper(); - batch.results.push({ alreadyApplied: [1] }); - let client = await makeClient(storage, batch.target); + batch.results.push({ alreadyApplied: [2] }); + let notes = vi.fn(); + let client = await makeClient({ + ...storage, + chats: { list: () => [sibling, boundary].map(actionId => ({ type: "action", actionId })) }, + }, batch.target, { addChatMessages: notes }); let error = await client.applyActionsThrough(boundary, [boundary]).catch(caught => caught); @@ -981,6 +990,9 @@ describe("Overseer action decisions", () => { expect(getActionErrorCode(error)).toBe(ACTION_ERROR_CODES.vetoRefused); expect(storage.actions.get(boundary)) .toMatchObject({ state: "approved", vetoRefused: true }); + // Its sibling applied, but a veto means stop, even one that came too late. + expect(storage.actions.get(sibling)).toMatchObject({ state: "approved" }); + expect(notes).not.toHaveBeenCalled(); }); it("replays a recorded stop to a client resuming after the action was created", async () => { @@ -1143,6 +1155,34 @@ describe("Overseer action decisions", () => { ]); }); + it("applies rule-approved actions a rejection unblocks, resuming their chat", async () => { + let storage = makeStorage(); + enableRule(storage); + let blocker = putAction(storage, 1, { autoApprovable: false }); + let waiting = putAction(storage, 2, { chatId: 7, awaitDecision: true, suspendedTurn: true }); + let legacy = makeLegacyGatekeeper(); + let notes = vi.fn(); + let waits: Promise[] = []; + let client = await makeClient({ + ...storage, + chats: { list: () => [{ type: "action", actionId: waiting }] }, + }, legacy.target, { + ctx: { waitUntil: (promise: Promise) => waits.push(promise) }, + addChatMessages: notes, + waitForChatMessagePreparation: () => undefined, + }); + + await client.rejectAction(blocker); + await Promise.all(waits); + + expect(legacy.calls).toEqual(["reject:1", "apply:2"]); + expect(notes.mock.calls).toEqual([ + [7, expect.anything(), [expect.objectContaining({ + type: "message", message: expect.stringContaining("Action 2"), + })]], + ]); + }); + it("awaits legacy rejection of a later action without applying other pending actions", async () => { let storage = makeStorage(); enableRule(storage); diff --git a/packages/workshop-backend/__tests__/fixtures.ts b/packages/workshop-backend/__tests__/fixtures.ts index b44071cee0..aa485d5008 100644 --- a/packages/workshop-backend/__tests__/fixtures.ts +++ b/packages/workshop-backend/__tests__/fixtures.ts @@ -151,7 +151,9 @@ export async function openFakeOverseer( // which these tests never pass. authorizeCollaborator: async () => role, getSharingManager: async () => ({}), - ctx: { id: { toString: () => "workspace-id" }, exports: opts.exports ?? {} }, + ctx: { + id: { toString: () => "workspace-id" }, exports: opts.exports ?? {}, waitUntil: () => {}, + }, users: { idFromString: (id: string) => id, get: () => ({ diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 9293e2f9de..ebf9456611 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -11347,7 +11347,8 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // Only resume when every awaited action in the turn has been decided and all were approved. if (awaited.length === 0) return; // No awaited action in current turn. if (awaited.some(r => r.state === "pending")) return; // Still waiting on a decision. - if (awaited.some(r => r.state === "rejected")) return; // Denial leaves the turn ended. + // Denial leaves the turn ended, even one the gatekeeper refused because it had already applied. + if (awaited.some(r => r.state === "rejected" || r.vetoRefused)) return; // Persist one note for replay; raw action cards are not surfaced to the LLM. Concurrent // approvals could both pass the gate above and append duplicate notes (the DO input gate is @@ -11383,7 +11384,8 @@ class OverseerClientInterface extends RpcTarget implements Overseer { await this.impl.rejectPendingAction(action, profile); // Deny leaves the turn ended, like denyConnectionRequest. The rejected record also prevents a - // sibling approval from resuming this turn. + // sibling approval from resuming this turn. Rule-approved actions it unblocked apply now. + this.#applyDecidedInBackground(action.gatekeeperId); } // Enable auto-approval of actions carrying `actionKind` on the given gatekeeper. Stores the @@ -11403,8 +11405,13 @@ class OverseerClientInterface extends RpcTarget implements Overseer { actionKind, enabledBy: profile, }); - // Apply the currently-visible pending action(s) with this tag right away, resuming any turn - // that was suspended waiting on one. + // Apply the currently-visible pending action(s) with this tag right away. + this.#applyDecidedInBackground(gatekeeperId); + } + + // Runs an apply pass without holding up the caller, resuming any turn that was suspended waiting + // on an action it applies. + #applyDecidedInBackground(gatekeeperId: number): void { this.impl.ctx.waitUntil(this.impl.applyDecidedActions(gatekeeperId) .then(({decided}) => this.#resumeDecidedActionChats(decided))); } diff --git a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx index 9098a73a80..eb3817eea0 100644 --- a/packages/workshop-frontend/src/ChatInterface.actions.test.tsx +++ b/packages/workshop-frontend/src/ChatInterface.actions.test.tsx @@ -146,6 +146,26 @@ describe('ChatInterface action refresh', () => { expect(document.body.textContent).toContain('page was deleted while disconnected') }) + it('shows a veto refused while disconnected as already applied', async () => { + const vetoed = { state: 'rejected', appliedAt: new Date(1700005000000) } + await renderCard(vetoed, { entries: [] }) + + const refused = entry(1, { + state: 'approved', vetoRefused: true, appliedAt: new Date(1700006000000), + }) + const second = makeOverseer() + const secondChat = withChatApi(second, vi.fn(async () => + ({ ...actionMessage, actionLog: refused }) as AiChatMessage)) + await renderChat(second.overseer, 1) + await second.resolveSubscription() + await second.resolvePendingQuery({ entries: [] }) + await vi.waitFor(() => expect(secondChat.getChatMessage).toHaveBeenCalledWith(1, 0)) + flushFrames() + + expect(document.body.textContent).toContain('Already applied') + expect(document.body.textContent).not.toContain('Denied') + }) + it('lets a resumed reconnect replay the gap instead of refetching', async () => { await cachePendingCard('ws-chat-resume') diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 1824cdb9f3..ae24db5564 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -3804,16 +3804,17 @@ function ChatInterface({ }); // On a resumed reconnect the subscription replays the gap, so the entries above cover cached // cards. Otherwise (cold open, or the prior session never settled) re-fetch cached action - // cards whose log can still change: blank or pending cards (a resolution may have landed - // while we were away), and bindHook cards, which stay mutable after resolution (`enabled` - // toggles). Runs after useActionEntries, whose effect creates the store and its resumed flag. + // cards whose log can still change: blank cards, any not approved (a resolution may have landed + // while we were away, or a staged veto the gatekeeper refused flipped to applied), and bindHook + // cards, which stay mutable after resolution (`enabled` toggles). Runs after + // useActionEntries, whose effect creates the store and its resumed flag. useEffect(() => { if (actionLogResumed(overseer)) return; let cancelled = false; const targets = [...cacheRef.current.actionMessages.values()].flatMap((locations) => { const location = locations.values().next().value; const msg = location && getCachedActionMessage(location)?.msg; - return msg && (!msg.actionLog || msg.actionLog.state === "pending" || + return msg && (!msg.actionLog || msg.actionLog.state !== "approved" || msg.actionLog.type === "bindHook") ? [location] : []; });