Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d59ea2e
Add the apply-through action contracts
ndisidore Sep 17, 2026
c61e29b
Replace per-action approval with serialized batch action sync
ndisidore Sep 17, 2026
e815d12
Show why an action was not applied
ndisidore Sep 17, 2026
1c0dcaa
Simplify the apply-through plumbing
ndisidore Sep 17, 2026
a797797
Report out-of-order gatekeeper action submissions
ndisidore Sep 18, 2026
b31713c
Derive an action's status label in one place
ndisidore Sep 18, 2026
45b5c18
Require gatekeepers to publish retained actions in order
ndisidore Sep 18, 2026
fd9b213
Refuse queued approvals that predate a new failure
ndisidore Sep 18, 2026
c73ca32
Report a gatekeeper stop ahead of an undecided gate
ndisidore Sep 19, 2026
94c78fb
Keep a stale action refresh from dropping a newer failure
ndisidore Sep 20, 2026
1fb9fa6
Stage each selected veto once
ndisidore Sep 20, 2026
ee415a3
Report a pack build's lifetime failure with its code
ndisidore Sep 20, 2026
d6d59c1
Trim guards the apply-through paths cannot reach
ndisidore Sep 20, 2026
40717ef
Keep an empty pushedCommits list off the wire
ndisidore Sep 20, 2026
b40ded2
Withhold "Always approve" on an action that stopped
ndisidore Sep 20, 2026
2c22652
Derive the action driver's seams from the real types
ndisidore Sep 22, 2026
41c7606
Derive the always-approve target in one place
ndisidore Sep 22, 2026
4d740aa
Report a veto of an already-applied action instead of ignoring it
ndisidore Sep 22, 2026
32346e0
Say on the card that a refused veto was already applied
ndisidore Sep 22, 2026
c7dd21f
Keep turns ended, and cards current, after a veto decision
ndisidore Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/gatekeeper-kit/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,9 +889,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" });
Expand Down
8 changes: 5 additions & 3 deletions packages/gatekeeper-kit/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,10 +720,12 @@ export function defineActions<Host, M extends Record<string, unknown>>(
description,
implementsRevert,
// Spread, so an action with no fields, no git, no kind, no awaited decision, or no claim
// of completeness puts no key on the wire at all. A push is never complete, whatever the
// hook claims: the approver sees commit ids, not the bytes they carry.
// of completeness puts no key on the 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. A push is never complete, whatever the hook claims: the approver sees commit
// ids, not the bytes they carry.
...(fields?.length ? { fields } : {}),
...(pushedCommits ? { pushedCommits } : {}),
...(pushedCommits?.length ? { pushedCommits } : {}),
...(descriptionIsComplete === true && !pushedCommits?.length
? { descriptionIsComplete: true }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
//
// The fixture gatekeeper's session drives this through the real ApprovalQueue funnel:
// `writeValue()` submits a `set-value` action with the given verdict and resolves once the action
// is decided, and `applyAction` succeeds, so the drain's submit -> auto-approve -> apply round
// trip is the real one.
// is decided, and `applyAction` succeeds, so the apply pass's submit -> auto-approve -> apply
// round trip is the real one.

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { RpcStub } from "capnweb";
Expand Down Expand Up @@ -89,8 +89,9 @@ async function listWrites(ws: Workspace): Promise<Array<ActionLogEntry & { type:
.toSorted((a, b) => a.id - b.id);
}

// The drain runs via ctx.waitUntil after submit, so "did not auto-approve" needs a settle window.
// One further RPC round trip plus a beat is far beyond the drain's synchronous storage work.
// Auto-approval runs in an apply pass via ctx.waitUntil after submit, so "did not auto-approve"
// needs a settle window. One further RPC round trip plus a beat is far beyond that pass's
// synchronous storage work.
async function settle(ws: Workspace): Promise<void> {
await ws.overseer.listActions();
await new Promise(resolve => setTimeout(resolve, 300));
Expand All @@ -101,7 +102,7 @@ describe("auto-approval policy", () => {
await withSession(async publicApi => {
const ws = await newWorkspace(publicApi, "auto-happy");
await ws.overseer.setAutoApprovedActionKind(ws.gatekeeperId, SET_VALUE);
// Resolves once the action is decided -- here, by the drain, with no human involved.
// Resolves once the action is decided -- here, by an apply pass, with no human involved.
await expect(ws.session.writeValue(1, { autoApprovable: true }))
.resolves.toEqual(expect.any(Number));

Expand Down
20 changes: 3 additions & 17 deletions packages/workshop-backend/__tests__/action-log-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
let events: Array<number | "ready"> = [];
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<ActionsSubscriber>, 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"}).
Expand Down
Loading
Loading