Skip to content

Commit d2bab4f

Browse files
committed
New hooks based on persistent stubs
1 parent 163b11e commit d2bab4f

36 files changed

Lines changed: 8562 additions & 2255 deletions

.agents/skills/write-gatekeeper/SKELETON.md

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
GatekeeperUser,
1111
GatekeeperVendor as GatekeeperVendorIface,
1212
Gatekeeper,
13-
HookInitiator,
13+
HookController, // Remove if no hooks
14+
HookInitiator, // Remove if no hooks
1415
ResourceDescription,
1516
ApprovalQueue,
1617
VendorDescription,
@@ -295,11 +296,17 @@ class MyConfiguratorUI extends RpcTarget {
295296
}
296297

297298
// ---------------------------------------------------------------------------
298-
// Hook type — remove this section if the gatekeeper doesn't support hooks.
299-
// Intersect the hook interface from types.d.ts with WorkerEntrypoint so it satisfies the
300-
// Gatekeeper generic constraint (Hook extends WorkerEntrypoint).
301-
302-
type MyHook = WorkerEntrypoint & MyHookIface;
299+
// Hook type — remove this section (and HookController/HookInitiator imports, the `subscribe`
300+
// method, the `hookTsType` field, and MyHookControllerImpl) if the gatekeeper doesn't push events.
301+
//
302+
// The hook interface from types.d.ts is implemented by the Gadget as an RpcTarget. Intersect it
303+
// with RpcTarget so it satisfies the HookController/HookInitiator generic constraints (Hook
304+
// extends RpcTarget).
305+
//
306+
// Note that you can also use a plain function as a hook type, e.g. `RpcStub<() => Promise<void>>`.
307+
// In that case you would not need to merge the type with `RpcTarget`.
308+
309+
type MyHook = RpcTarget & MyHookIface;
303310

304311
// ---------------------------------------------------------------------------
305312
// GatekeeperImpl DO — per-resource instance, runs as a facet of the Overseer
@@ -310,7 +317,7 @@ type MyGatekeeperImplProps = {
310317
};
311318

312319
export class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>
313-
implements Gatekeeper<MySession, MyHook> {
320+
implements Gatekeeper<MySession> {
314321

315322
async describe(): Promise<ResourceDescription> {
316323
return {
@@ -330,6 +337,7 @@ export class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>
330337
async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> {
331338
return new MySessionImpl(
332339
approvalQueue.dup(), // Always dup() before storing
340+
this.ctx,
333341
// ... API client, props, etc.
334342
);
335343
}
@@ -351,24 +359,77 @@ export class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>
351359
// TODO: Undo the action (look up what was done from own storage)
352360
throw new Error("Revert not implemented");
353361
}
362+
}
363+
364+
// ---------------------------------------------------------------------------
365+
// HookController — remove if the gatekeeper doesn't push events.
366+
//
367+
// A WorkerEntrypoint the overseer uses to enable/disable the hook after the user approves it.
368+
// It is constructed with `props` carrying the specifics of that particular registration, so it
369+
// needs no other state. If your gatekeeper offers several kinds of hooks, give each its own
370+
// controller class.
371+
372+
// Bind-time details for a single hook registration, baked into the controller's props.
373+
type MyHookProps = {
374+
// TODO: e.g. an event kind, a filter, a sub-resource id, etc. — whatever the registration
375+
// method received and the controller/event source will need later.
376+
filter?: string;
377+
};
378+
379+
type MyHookControllerImplProps = MyGatekeeperImplProps & MyHookProps;
380+
381+
export class MyHookControllerImpl extends WorkerEntrypoint<Env, MyHookControllerImplProps>
382+
implements HookController<MyHook> {
383+
// Called when the user enables the hook. Store `initiator` somewhere it can be reached when an
384+
// event arrives — typically an event-source DO. Don't store other state until now; everything
385+
// else is already in `this.ctx.props`. If already enabled, replace the previous initiator.
386+
async enable(initiator: Fetcher<HookInitiator<MyHook>>): Promise<void> {
387+
// TODO: persist `initiator` (e.g. forward it to an event-source DO keyed by props).
388+
}
354389

355-
async setHook(hook: Fetcher<HookInitiator<MyHook>> | null): Promise<void> {
356-
// Remove the Hook type parameter from Gatekeeper<> above if hooks are not supported.
357-
// If hooks are supported, store the HookInitiator Fetcher and call its startHook()
358-
// method when an event arrives. startHook() returns {hook, approvalQueue} -- use the
359-
// approvalQueue to register observations/actions, then call methods on the hook.
390+
// Called when the hook is disabled or deleted. Forget the stored initiator and clean up all
391+
// related state. May never be called again, though the overseer may later call enable() afresh.
392+
async disable(): Promise<void> {
393+
// TODO: forget the stored initiator.
360394
}
361395
}
362396

397+
// Event delivery (sketch). When the external event arrives — e.g. in the event-source DO that
398+
// holds the `initiator` — invoke the hook like so:
399+
//
400+
// async onEvent(initiator: Fetcher<HookInitiator<MyHook>>, event: MyEvent) {
401+
// // startHook() begins a fresh session and returns the callback (re-bound to it) plus an
402+
// // ApprovalQueue. `using` disposes the result (and its stubs) at end of scope.
403+
// using result = initiator.startHook();
404+
//
405+
// // A hook event is almost always an observation. (Register actions too if invoking the
406+
// // callback can cause side effects.) Pipeline through the not-yet-resolved promise.
407+
// await result.approvalQueue.authorizeObservation({
408+
// title: "TODO: short event summary",
409+
// description: "TODO: details about the event being delivered",
410+
// });
411+
//
412+
// // Deliver the event to the Gadget's callback.
413+
// await result.callback.onMyEvent(event);
414+
// }
415+
363416
// ---------------------------------------------------------------------------
364417
// SessionImpl — the RPC interface exposed to the Gadget
365418

366419
class MySessionImpl extends RpcTarget implements MySession {
367-
#approvalQueue: ApprovalQueue;
420+
#approvalQueue: RpcStub<ApprovalQueue>;
421+
#ctx: DurableObjectState<MyGatekeeperImplProps>;
368422

369-
constructor(approvalQueue: ApprovalQueue) {
423+
constructor(
424+
approvalQueue: RpcStub<ApprovalQueue>,
425+
ctx: DurableObjectState<MyGatekeeperImplProps>) {
370426
super();
371427
this.#approvalQueue = approvalQueue;
428+
this.#ctx = ctx;
429+
}
430+
431+
[Symbol.dispose]() {
432+
this.#approvalQueue[Symbol.dispose]();
372433
}
373434

374435
// Example: observation (read). Fetch data, then authorize before returning.
@@ -397,6 +458,22 @@ class MySessionImpl extends RpcTarget implements MySession {
397458

398459
// TODO: Update cache/simulation state so subsequent reads reflect this
399460
}
461+
462+
// Example: hook registration — remove if the gatekeeper doesn't push events.
463+
// `callback` is a persistent stub the Gadget created with ctx.restore(). Construct a controller
464+
// whose props capture the specifics of THIS registration (e.g. `filter`), then hand it, the
465+
// callback, and a user-facing description to the overseer via bindHook(). For multiple hook
466+
// kinds, pick the appropriate controller class here. Do NOT store the callback yourself — it is
467+
// bound to this session and would be revoked when the session ends.
468+
async subscribe(callback: RpcStub<MyHook>, filter?: string): Promise<void> {
469+
let controller = this.#ctx.exports.MyHookControllerImpl({
470+
props: { ...this.#ctx.props, filter },
471+
});
472+
await this.#approvalQueue.bindHook(controller, callback, {
473+
title: "TODO: short hook title",
474+
description: `TODO: what events this hook delivers${filter ? ` (filter: ${filter})` : ""}`,
475+
});
476+
}
400477
}
401478
```
402479

@@ -417,6 +494,8 @@ class MySessionImpl extends RpcTarget implements MySession {
417494
}
418495
```
419496

497+
Only Durable Object classes go in `new_sqlite_classes`. `MyHookControllerImpl` is a `WorkerEntrypoint`, so it needs no migration entry — but, like all entrypoints, it must be `export`ed from the worker's main module. If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.
498+
420499
## Creating the `types.txt` symlink
421500

422501
```bash

.agents/skills/write-gatekeeper/SKILL.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ A Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget an
99

1010
- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.
1111
- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.
12-
- **Instance** (`Gatekeeper<Session, Hook>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.
12+
- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.
1313

1414
Read `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.
1515

@@ -40,7 +40,7 @@ Study the service's API docs. Identify:
4040

4141
### Step 2: Design the Session types
4242

43-
Create `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events).
43+
Create `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).
4444

4545
Before designing, read `packages/workshop-shared/node_modules/capnweb/README.md` to understand what Cap'n Web RPC supports — this determines what types and patterns are expressible in the Session interface.
4646

@@ -175,6 +175,32 @@ Keep in mind that the agent calling the API (or the agent writing a gadget to ca
175175

176176
For concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.
177177

178+
## Hooks (push notifications)
179+
180+
Some services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.
181+
182+
`gatekeeper-email` is the canonical reference implementation. Read it alongside the `HookController`, `HookInitiator`, and `ApprovalQueue.bindHook()` JSDoc in `gatekeeper.ts`.
183+
184+
### The pieces
185+
186+
- **Hook interface** (in `types.d.ts`): the methods the Gadget implements to receive events, e.g. `EmailHook.receiveEmail(email)`. It is implemented by the Gadget as an **`RpcTarget`** (or a plain function), *not* a `WorkerEntrypoint`. Reference it from `describe()` via `hookTsType`.
187+
- **Session method**: a method like `subscribe(callback)` that the Gadget (or, more commonly, an agent in a one-off `executeCode` call) uses to register interest. The `callback` is a **persistent stub** (created by the Gadget with `ctx.restore()`), so it can be stored and re-invoked long after the session ends.
188+
- **`HookController`** (a `WorkerEntrypoint` you implement): lets the overseer `enable()` / `disable()` the hook. All the state it needs must live in its `props`, so it is constructed via `this.ctx.exports.MyHookControllerImpl({props})` **at bind time**, immediately before calling `bindHook()` — see below.
189+
- **`HookInitiator`** (provided to you by the overseer): you call `startHook()` on it when an event arrives.
190+
191+
### Lifecycle
192+
193+
1. **Register.** The Gadget calls your Session method (e.g. `subscribe(callback, filter)`). Inside it, construct a `HookController` whose `props` capture the specifics of *this* registration, then call `approvalQueue.bindHook(controller, callback, description)`. The overseer stores the callback and records the hook (initially **disabled**). Do **not** store the callback yourself — it is bound to the current session and would be revoked when the session ends.
194+
2. **Enable.** When the user approves the hook in the Workshop UI, the overseer calls `controller.enable(initiator)`. Store the `initiator` Fetcher somewhere it can be reached when events arrive (e.g. an event-source DO). Avoid storing any other state until enabled; everything else should already be in the controller's `props`.
195+
3. **Deliver.** When the event occurs, call `initiator.startHook()`. This returns `{callback, approvalQueue}` bound to a fresh session. Call `authorizeObservation()` (a hook event is almost always an observation; register actions too if the callback's return value triggers side effects), then invoke the `callback` to deliver the event to the Gadget.
196+
4. **Disable / delete.** The overseer calls `controller.disable()`. Forget the stored `initiator` and clean up all related state — `disable()` may never be called again, though the overseer may later call `enable()` afresh.
197+
198+
Because the callback is a persistent stub tied to a session, the gatekeeper never stores it directly; the overseer hands it back (re-bound to a new session) each time you call `startHook()`. See the SKELETON for the full code shape.
199+
200+
### Documentation
201+
202+
When defining a session interface with hooks, it's important to include comments that clearly state when a method expects to be passed a *persistent* stub created with `ctx.restore()`, as opposed to a regular RpcStub. The caller needs to do extra work to make sure the stub they provide you is persistent.
203+
178204
## Tips
179205

180206
- `types.txt` must be a **symlink** to `types.d.ts`, never a copy.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
"jsonc-parser": "^3.3.1",
1919
"oxlint": "^1.71.0",
2020
"typescript": "^5.9.3",
21-
"wrangler": "^4.92.0"
21+
"wrangler": "^4.103.0"
2222
}
2323
}

packages/gatekeeper-cloudflare/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
},
1818
"devDependencies": {
1919
"typescript": "^5.9.3",
20-
"wrangler": "^4.92.0"
20+
"wrangler": "^4.103.0"
2121
}
2222
}

0 commit comments

Comments
 (0)