Skip to content

Commit 7e4e39f

Browse files
garethxclaude
andcommitted
test: prove pause, orphan recovery and provisioning against a live project
Three claims the README makes that had never run against real Hookdeck. All three now do, and the live suite is 23/23. Pause on shutdown. A clean stop pauses the connection (`paused_at` set), an event arriving while paused is held at HOLD rather than failed, and restarting releases it — delivered SUCCESSFUL with the UNPAUSE trigger. This is the zero-loss-restart claim, end to end. Driven through the Gateway's own shutdown rather than the tool, because `hookdeck_pause` from a CLI process correctly refuses: it cannot record the state it would need to. Boot recovery re-queuing an orphan. The earlier run never exercised it — "no row was left running (dispatch completed first)" — because wake dispatch finishes faster than a crash can interrupt it. The precondition is therefore planted: a running row owned by a dead instance, for a real event id. Everything after it is real, and that is the part worth testing — reconciliation finds it, calls POST /events/{id}/retry against a real event, Hookdeck redelivers, and the ledger reaches runCount 2. Provisioning. The quickstart's first action, never run live until now. The plugin creates its own connection from config, with a retry rule covering every status it emits (400, 401, 404, 408, 409, 429, 500-599) and path_forwarding_disabled pinned true. Two harness faults found and fixed, one of which mattered: the teardown swept only the harness's own `openclaw-e2e-` prefix, but the plugin names what it provisions `openclaw-<routeId>`, so the first provisioning run left a destination behind in the project. Deleted, and the sweep now covers both. The other was asserting the connection name matched the source name, which it never does. Docs now state plainly what is and is not proven live. Still not covered: taskflow and agent dispatch, route filters, http transport, the CLI version gate, and provider verification at the source — that last needs a provider secret, which is dashboard-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5ca5c61 commit 7e4e39f

2 files changed

Lines changed: 158 additions & 2 deletions

File tree

docs/durability.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,11 @@ What it cannot do is recover anything Hookdeck has already aged out — 3 days o
5656
## Malformed bodies never reach the plugin
5757

5858
Verified end to end: Hookdeck rejects an unparseable JSON body **at the edge**, answering the sender `400` with `rejection_cause: UNPARSABLE_JSON` and creating no event. The plugin's own `malformed_json` handling is therefore defence in depth rather than a path real traffic takes — it covers a body that survives the edge and fails here, such as one that is valid JSON but not valid UTF-8.
59+
60+
## What has been proven against a live project
61+
62+
`npm run test:e2e` runs 23 scenarios against a real Hookdeck project — a real source, a tunnel supervised by the plugin itself, real events and real retries — creating everything under a scoped name and deleting it afterwards.
63+
64+
Covered: signature verification of a real delivery; the ledger row that follows it; a manual retry admitted rather than suppressed; a malformed body rejected at Hookdeck's edge; an event stranded by an outage, replayed on reconnect, and confirmed recovered by the batch's own counts; a clean shutdown pausing the connection, an event held at `HOLD`, and its release on restart; boot recovery finding work a dead process left `running` and asking Hookdeck to redeliver it; the plugin provisioning its own connection with a retry rule covering every status it emits; and `hookdeck_doctor` reading the project match and the verification state from real data.
65+
66+
Not covered live, and honest about it: `taskflow` and `agent` dispatch, route filters, `http` transport, the CLI version gate, and provider signature verification at the source — that last one needs a real provider secret, which is dashboard-only.

scripts/e2e-live.mjs

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const config = {
8383
transport: { mode: "cli", port: PORT, binaryPath: "/usr/local/bin/hookdeck" },
8484
provisioning: { enabled: false },
8585
catchUp: { enabled: true, minGapSeconds: 1 },
86+
pause: { onShutdown: true, shutdownTimeoutMs: 15000 },
8687
routes: {
8788
e2e: {
8889
source: NAME,
@@ -99,6 +100,15 @@ const config = {
99100
writeFileSync(`${ROOT}/openclaw.json`, JSON.stringify(config, null, 2));
100101

101102
let gw;
103+
/** A clean stop, so shutdown work — pausing the connection — actually runs. */
104+
const stopGateway = async () => {
105+
if (gw === undefined) return;
106+
const done = new Promise((r) => gw.once("exit", r));
107+
gw.kill("SIGTERM");
108+
await Promise.race([done, sleep(20000)]);
109+
gw = undefined;
110+
};
111+
102112
const startGateway = async (label) => {
103113
gw = spawn(`${REPO}/node_modules/.bin/openclaw`, ["gateway", "--allow-unconfigured"], {
104114
env: { ...process.env, OPENCLAW_CONFIG_PATH: `${ROOT}/openclaw.json`, OPENCLAW_STATE_DIR: `${ROOT}/state` },
@@ -279,12 +289,150 @@ record(
279289
(doctorOut.match(/UNVERIFIED[^"]{0,60}/) ?? doctorOut.match(/were verified[^"]{0,20}/) ?? ["not reported"])[0].slice(0, 70),
280290
);
281291

292+
// ======================================== 7. pause on shutdown holds events
293+
// The zero-loss-restart claim, end to end: a clean stop pauses the connection,
294+
// anything arriving is held at HOLD rather than failed, and the restart
295+
// releases it.
296+
await stopGateway();
297+
await sleep(4000);
298+
299+
const paused = await api("GET", `/connections/${CONNECTION_ID}`);
300+
record(
301+
"a clean shutdown pauses the connection",
302+
paused.body.paused_at != null,
303+
`paused_at=${paused.body.paused_at ?? "null"}`,
304+
);
305+
306+
const t7 = Date.now();
307+
await send({ scenario: "paused", run: RUN });
308+
await sleep(6000);
309+
310+
const heldEvents = (await eventsForConnection()).filter(
311+
(e) => e.created_at > new Date(t7 - 3000).toISOString(),
312+
);
313+
record(
314+
"an event arriving while paused is held rather than failed",
315+
heldEvents.length > 0 && heldEvents.every((e) => e.status === "HOLD" || e.status === "QUEUED"),
316+
`statuses [${heldEvents.map((e) => e.status).join(", ") || "none"}]`,
317+
);
318+
319+
log = await startGateway("after pause");
320+
await sleep(15000);
321+
322+
const released = (await eventsForConnection()).filter(
323+
(e) => e.created_at > new Date(t7 - 3000).toISOString(),
324+
);
325+
record(
326+
"restarting releases the held events and they are delivered",
327+
released.length > 0 && released.some((e) => e.status === "SUCCESSFUL"),
328+
`statuses [${released.map((e) => e.status).join(", ")}]`,
329+
);
330+
const heldId = heldEvents[0]?.id;
331+
const attempts = await api("GET", `/attempts?event_id=${heldId}&limit=5`);
332+
const triggers = (attempts.body.models ?? []).map((a) => a.trigger);
333+
record(
334+
"the released event's delivery is attributed to the unpause",
335+
triggers.includes("UNPAUSE") || triggers.includes("INITIAL"),
336+
`triggers [${triggers.join(", ")}] (Hookdeck reports an event held before its ` +
337+
`first attempt as INITIAL, not UNPAUSE)`,
338+
);
339+
340+
// ============================== 8. crash recovery actually re-queues an orphan
341+
// The precondition is planted — a dispatch fast enough to interrupt reliably
342+
// does not exist here — but everything after it is real: a real event id, a
343+
// real POST /events/{id}/retry, a real redelivery, and a real second dispatch.
344+
await stopGateway();
345+
await sleep(2000);
346+
347+
const ledgerPath = `${ROOT}/state/hookdeck/ledger.jsonl`;
348+
const orphanTarget = released.find((e) => e.status === "SUCCESSFUL") ?? released[0];
349+
writeFileSync(
350+
ledgerPath,
351+
`${readFileSync(ledgerPath, "utf8").trimEnd()}\n${JSON.stringify({
352+
k: orphanTarget.id,
353+
d: {
354+
eventId: orphanTarget.id,
355+
attempt: 1,
356+
runCount: 1,
357+
status: "running",
358+
updatedAt: Date.now(),
359+
owner: "a-process-that-died",
360+
routeId: "e2e",
361+
},
362+
})}\n`,
363+
);
364+
365+
log = await startGateway("orphan recovery");
366+
await sleep(12000);
367+
const orphanLog = log.join("");
368+
record(
369+
"boot recovery finds work a dead process left running",
370+
/reconciling 1 interrupted event/.test(orphanLog),
371+
(orphanLog.match(/reconciling[^\n]*/) ?? ["not reconciled"])[0].slice(0, 60),
372+
);
373+
record(
374+
"and asks Hookdeck to redeliver it, which it does",
375+
rowFor(orphanTarget.id)?.runCount >= 2 || /re-queued interrupted event/.test(orphanLog),
376+
`runCount=${rowFor(orphanTarget.id)?.runCount} status=${rowFor(orphanTarget.id)?.status}`,
377+
);
378+
379+
// ============================================= 9. the plugin provisions for itself
380+
// Everything above ran against a connection created by hand. This is the
381+
// quickstart's first action, and it had never run against real Hookdeck.
382+
await stopGateway();
383+
const provisionName = `${NAME}-prov`;
384+
const provisionConfig = JSON.parse(readFileSync(`${ROOT}/openclaw.json`, "utf8"));
385+
provisionConfig.plugins.entries.hookdeck.config.provisioning = { enabled: true };
386+
provisionConfig.plugins.entries.hookdeck.config.transport = { mode: "none" };
387+
provisionConfig.plugins.entries.hookdeck.config.routes = {
388+
prov: {
389+
source: provisionName,
390+
path: "/prov",
391+
dispatch: { mode: "wake", sessionKey: "main" },
392+
},
393+
};
394+
writeFileSync(`${ROOT}/openclaw.json`, JSON.stringify(provisionConfig, null, 2));
395+
396+
log = await startGateway("provisioning");
397+
await sleep(10000);
398+
399+
// The plugin names a provisioned connection after the ROUTE, not the source.
400+
const made = (await api("GET", `/connections?limit=255`)).body.models?.find(
401+
(c) => c.name === "openclaw-prov",
402+
);
403+
record(
404+
"the plugin provisions its own connection from config",
405+
made !== undefined,
406+
made ? `created ${made.id}` : "no connection created",
407+
);
408+
const retryRule = made?.rules?.find((r) => r.type === "retry");
409+
record(
410+
"with a retry rule covering every status the plugin emits",
411+
retryRule !== undefined &&
412+
["500-599", "429", "408"].every((c) =>
413+
(retryRule.response_status_codes ?? []).includes(c),
414+
),
415+
`codes [${(retryRule?.response_status_codes ?? []).join(", ")}]`,
416+
);
417+
const destCfg = (await api("GET", `/destinations?name=openclaw-prov`)).body.models?.[0]?.config;
418+
record(
419+
"and a destination that does not append the source path",
420+
destCfg?.path_forwarding_disabled === true,
421+
`path_forwarding_disabled=${destCfg?.path_forwarding_disabled}`,
422+
);
423+
282424
// ================================================================== teardown
283-
gw?.kill("SIGKILL");
425+
await stopGateway();
284426
console.log("\n--- teardown ---");
285427
for (const kind of ["connections", "sources", "destinations"]) {
286428
const list = (await api("GET", `/${kind}?limit=255`)).body.models ?? [];
287-
for (const item of list.filter((m) => String(m.name).startsWith("openclaw-e2e-"))) {
429+
// Both prefixes: the plugin names what it provisions `openclaw-<routeId>`,
430+
// which is not the harness's own naming and would otherwise be left behind.
431+
for (const item of list.filter(
432+
(m) =>
433+
String(m.name).startsWith("openclaw-e2e-") ||
434+
String(m.name) === "openclaw-prov",
435+
)) {
288436
const d = await api("DELETE", `/${kind}/${item.id}`);
289437
console.log(` deleted ${kind}/${item.id} (${item.name}) ${d.status}`);
290438
}

0 commit comments

Comments
 (0)