From 3fd29cf59a257510fcb2ae2ee23bc32d2dac7849 Mon Sep 17 00:00:00 2001 From: Jannik Luhn Date: Tue, 22 Sep 2026 23:11:22 +0200 Subject: [PATCH] feat: `templateHandler` takes a post phase, rather than being spread into one `SignalHandler` has had `post` all along, and the template Handler's own doc comment sold the workaround: `{ ...templateHandler(options), post }` is a Handler. It is, and it is worse than it reads. The spread drops the payload type. `TPayload` reaches `session` and `data`, but a `post` written outside the call is typed by the map it lands in, which is `Record` and so `Signal`. The Operator has to re-annotate the signal by hand, naming a type the other options never made them name, and an annotation that is wrong is an assertion about a payload nothing checked. The spread also works only because `handle` never touches `this`, which nothing enforces and nothing tests, so an implementation detail had become part of the contract. So `post` is an option, carried to the Worker as written. Nothing wraps it and nothing calls it from here: the same argument that keeps `session` and `data` outside the try/catch keeps this outside it too, because putting our sentence about a template on somebody else's bug is what that argument is against. The Worker already records it as `the post phase failed: ...`. The spread still works and is now the long way round. It is spread into the returned object rather than assigned, because `exactOptionalPropertyTypes` refuses an explicit `undefined` against `post?`, and because a Handler given no post phase must not carry the property at all: the Worker asks `handler.post !== undefined` before running one, so the two spellings are not the same Handler. That absence is the assertion with teeth and it is the first of the new tests. The rest are the two outcomes a template Handler can reach, through a real worker: `failed: false` after a Run that ran, and `failed: true` for a template that did not render and so produced no Run at all, which is the case a post phase is written for, a Signal never being retried. And a throwing post phase, whose words reach the Signal log as its own, saying nothing about a template that rendered perfectly well. `scripts/check-package.ts` carries the option in the annotated `main.ts` and reads `signal.payload.userId` in it, so the narrowing is proved from the installed package and not only in this tree. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/check-package.ts | 7 +- site/architecture.md | 3 +- src/signals/template-handler.test.ts | 107 ++++++++++++++++++++++++++- src/signals/template-handler.ts | 33 +++++++-- 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/scripts/check-package.ts b/scripts/check-package.ts index e98ba1f..04640b6 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -1024,13 +1024,18 @@ try { "//. The options are annotated separately, so a field that went missing from", "// the declaration fails here rather than being silently ignored. What is", "// being checked is that the declaration accepts template source, a Session-naming", - "// function, a data function, helpers and partials.", + "// function, a data function, helpers, partials and a post phase, and that the", + "// payload type reaches the post phase the way it reaches the other two that are", + "// handed a Signal.", "const promptOptions: TemplateHandlerOptions = {", ' template: "You were told: {{said}}\\n{{> footer}}",', ' session: (signal: Signal) => "user_" + signal.payload.userId,', " data: (signal: Signal) => ({ said: signal.payload.text }),", " helpers: { shout: (value: string) => value.toUpperCase() },", ' partials: { footer: "-- sent by the Gateway" },', + " post: (signal: Signal, outcome: PostOutcome) => {", + ' if (outcome.failed) log.warn({ userId: signal.payload.userId }, "nothing was sent");', + " },", "};", "const fromTemplate: SignalHandler = templateHandler(promptOptions);", "", diff --git a/site/architecture.md b/site/architecture.md index 7f38ed0..fee1e76 100644 --- a/site/architecture.md +++ b/site/architecture.md @@ -278,7 +278,8 @@ the person who asked. `templateHandler` is the common case. It renders a Handlebars template into one Prompt. The template is source text, never a path, and it compiles when the Gateway is built. A template that -does not compile therefore fails construction rather than a Signal. +does not compile therefore fails construction rather than a Signal. It takes a post phase as an +option and hands it over as written. ### At-most-once diff --git a/src/signals/template-handler.test.ts b/src/signals/template-handler.test.ts index 1b2efc6..e143b4a 100644 --- a/src/signals/template-handler.test.ts +++ b/src/signals/template-handler.test.ts @@ -24,7 +24,7 @@ import { applySchema } from "../test-support/apply-schema.ts"; import { createTestDatabase, type TestDatabase } from "../test-support/database.ts"; import { type FakeRuntime, fakeRuntime } from "../test-support/fake-runtime.ts"; import { waitUntil } from "../test-support/wait.ts"; -import type { Prompt, Signal, SignalHandler, SignalHandlers } from "./handlers.ts"; +import type { PostOutcome, Prompt, Signal, SignalHandler, SignalHandlers } from "./handlers.ts"; import * as signalsSchema from "./schema/index.ts"; import { signals } from "./schema/index.ts"; import { templateHandler } from "./template-handler.ts"; @@ -354,6 +354,42 @@ describe("a Handler's Handlebars environment", () => { }); }); +/** + * The post phase, as far as it can be seen without a worker: the Handler carries the + * Operator's function and nothing more. + * + * The absence assertion is the one with teeth. The worker asks `handler.post !== undefined` + * before running a post phase, so a Handler built without one that carries the property + * anyway would run a phase nobody wrote, and `exactOptionalPropertyTypes` is the only thing + * standing between those two spellings. + */ +describe("the template Handler's post phase", () => { + it("is absent from a Handler built without one", () => { + const handler = handlerOver("a Prompt that renders"); + + assert.equal("post" in handler, false, "no post phase should be carried"); + assert.equal(handler.post, undefined); + }); + + it("is the Operator's own function, carried across unwrapped", async () => { + const seen: { signal: Signal<{ readonly userId: string }>; outcome: PostOutcome }[] = []; + const post = (signal: Signal<{ readonly userId: string }>, outcome: PostOutcome): void => { + seen.push({ signal, outcome }); + }; + const handler = templateHandler<{ readonly userId: string }>({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post, + }); + + assert.equal(handler.post, post, "the function should reach the Handler as it was written"); + const signal = aSignal({ userId: "u_1" }); + await handler.post?.(signal, { failed: true }); + assert.deepEqual(seen, [{ signal, outcome: { failed: true } }]); + }); +}); + /** * The two criteria that are about the Signal log rather than the Handler. * @@ -468,4 +504,73 @@ describe("the template Handler under the worker", () => { ); }); }); + + /** + * What the post phase is told, for the two outcomes a template Handler can reach: a Run + * that ran, and a template that did not render and so produced no Run at all. The second + * is the whole reason an Operator writes one here, since a Signal is never retried. + */ + it("runs the post phase after the Run, and again when no Run was ever created", async () => { + const outcomes: Record = {}; + const post = + (kind: string) => + (_signal: Signal, outcome: PostOutcome): void => { + outcomes[kind] = outcome; + }; + const handlers: SignalHandlers = { + "prompt.good": templateHandler({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post: post("prompt.good"), + }), + "prompt.unsupplied": templateHandler({ + template: "Hello {{whoever}}", + session: () => null, + data: () => ({}), + post: post("prompt.unsupplied"), + }), + }; + + await withWorker(handlers, async (worker, runtime) => { + const good = await emit(worker, "prompt.good"); + const unsupplied = await emit(worker, "prompt.unsupplied"); + + assert.deepEqual(await settled(good), { state: "done", error: null }); + assert.equal((await settled(unsupplied)).state, "failed"); + + assert.deepEqual(outcomes, { + "prompt.good": { failed: false }, + "prompt.unsupplied": { failed: true }, + }); + // The Run had finished before its post phase was told about it. + assert.deepEqual(runtime.texts(), ["a Prompt that renders"]); + }); + }); + + /** + * A post phase that throws reaches the Signal log in the Operator's own words. Nothing + * here wraps it, so the reason must not read as a template that failed to render: the + * template rendered, and a reader chasing the wrong file is the damage. + */ + it("records what a failing post phase said, and says nothing about the template", async () => { + const handler = templateHandler({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post: () => { + throw new Error("the notification could not be delivered"); + }, + }); + + await withWorker({ "prompt.render": handler }, async (worker, runtime) => { + const signalId = await emit(worker, "prompt.render"); + const outcome = await settled(signalId); + + assert.equal(outcome.state, "failed"); + assert.equal(outcome.error, "the post phase failed: the notification could not be delivered"); + // The Prompt itself was rendered and run: only the phase after it failed. + assert.deepEqual(runtime.texts(), ["a Prompt that renders"]); + }); + }); }); diff --git a/src/signals/template-handler.ts b/src/signals/template-handler.ts index fe9ffcb..1585204 100644 --- a/src/signals/template-handler.ts +++ b/src/signals/template-handler.ts @@ -25,13 +25,13 @@ * `#if` block with none throws from the built-in helper, which runs only with a context, so it * stays a render failure and there is no place to move it to. * - * The two Operator callbacks are awaited outside the try/catch below on purpose. An error thrown by - * one of them is theirs to recognise, and wrapping it in a sentence about a template would put our - * words on their bug. + * The Operator's own callbacks are awaited outside the try/catch below on purpose, and `post` is + * handed to the Worker unwrapped for the same reason. An error thrown by one of them is theirs to + * recognise, and wrapping it in a sentence about a template would put our words on their bug. */ import Handlebars from "handlebars"; -import type { Prompt, Signal, SignalHandler } from "./handlers.ts"; +import type { PostOutcome, Prompt, Signal, SignalHandler } from "./handlers.ts"; export type TemplateHandlerOptions = { /** @@ -81,6 +81,21 @@ export type TemplateHandlerOptions = { * hold inside them too. */ readonly partials?: Readonly>; + + /** + * The post phase, run once after every Run arising from the Signal has finished, whether they + * succeeded, failed or were never created. It produces no Prompts. + * + * It is the whole of the framework's failure handling, because nothing retries a Signal: a + * Signal that came to nothing came to nothing for good, and telling somebody so is written here + * or nowhere. `outcome.failed` is true if any Run failed and true as well if the render failed + * before there were any. + * + * It is carried to the Signal Worker as written and never called from here, and what it throws + * is not wrapped: the Worker records it as `the post phase failed: ...`, beside whatever else + * went wrong. What it closes over is yours, exactly as the Handler contract has it. + */ + readonly post?: (signal: Signal, outcome: PostOutcome) => void | Promise; }; // Both are load-bearing and both are argued in the file header. What they do to a template is @@ -91,8 +106,9 @@ const compileOptions: CompileOptions = { noEscape: true, strict: true }; * Builds a Signal Handler that renders one Prompt per Signal from a Handlebars template. * * One Prompt, always. It never fans a Signal out across several Sessions and never declines one, - * although the Handler contract allows both. It has no post phase either, and gains one by being - * spread: `{ ...templateHandler(options), post }` is a Handler. + * although the Handler contract allows both. A post phase it does carry: `post` is an option here + * and reaches the Worker as the Handler's own, so the Signal's payload is narrowed in it the way + * it is in `session` and `data`. * * A template that compiles and then does not render fails the Signal, with a message naming the * Signal's kind. Handlebars names the variable, the line and the column, and never says which @@ -141,6 +157,11 @@ export function templateHandler( return [{ session, text }]; }, + + // Spread rather than `post: options.post`, which `exactOptionalPropertyTypes` refuses against + // an optional method: a Handler that was given no post phase must not carry the property at + // all, because the Worker asks `handler.post !== undefined` before running one. + ...(options.post === undefined ? {} : { post: options.post }), }; }