@jr2/orchestrator 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +86 -9
  14. package/docs/concepts/architecture.md +41 -28
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +7 -5
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +4 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +50 -22
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +5 -4
  23. package/docs/concepts/pools-and-sources.md +187 -11
  24. package/docs/concepts/secrets-and-trust.md +73 -33
  25. package/docs/concepts/the-menu.md +11 -0
  26. package/docs/concepts/workspaces.md +6 -5
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/guides/add-tools-to-the-sandbox.md +55 -11
  32. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  33. package/docs/guides/answer-a-gate.md +74 -37
  34. package/docs/guides/bind-open-parts.md +24 -8
  35. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  36. package/docs/guides/cancel-a-run.md +26 -16
  37. package/docs/guides/fix-a-failed-converge.md +18 -6
  38. package/docs/guides/give-a-caller-a-scope.md +203 -0
  39. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  41. package/docs/guides/let-an-agent-write-a-chart.md +9 -6
  42. package/docs/guides/look-inside-a-sandbox.md +101 -31
  43. package/docs/guides/open-the-console.md +63 -14
  44. package/docs/guides/read-a-conversation.md +225 -0
  45. package/docs/guides/run-a-chart-by-name.md +17 -16
  46. package/docs/guides/run-one-worker-per-item.md +15 -9
  47. package/docs/guides/set-an-agents-access-and-dials.md +2 -0
  48. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  49. package/docs/guides/start-a-run-from-a-run.md +18 -16
  50. package/docs/guides/start-a-run.md +24 -18
  51. package/docs/guides/steer-an-agent.md +332 -0
  52. package/docs/guides/use-a-hosted-provider.md +1 -1
  53. package/docs/guides/watch-a-run.md +127 -121
  54. package/docs/guides/write-a-hook-kind.md +238 -0
  55. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  56. package/docs/guides/write-a-workflow.md +17 -7
  57. package/docs/llms.txt +81 -15
  58. package/docs/reference/authoring-surface/agent.md +1 -1
  59. package/docs/reference/authoring-surface/answer.md +1 -1
  60. package/docs/reference/authoring-surface/arg.md +1 -1
  61. package/docs/reference/authoring-surface/basic.md +1 -1
  62. package/docs/reference/authoring-surface/bearer.md +1 -1
  63. package/docs/reference/authoring-surface/branchname.md +1 -1
  64. package/docs/reference/authoring-surface/carry.md +23 -0
  65. package/docs/reference/authoring-surface/chartschema.md +1 -1
  66. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  67. package/docs/reference/authoring-surface/command.md +1 -1
  68. package/docs/reference/authoring-surface/customize.md +4 -1
  69. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  70. package/docs/reference/authoring-surface/defineevent.md +1 -1
  71. package/docs/reference/authoring-surface/dispatch.md +5 -4
  72. package/docs/reference/authoring-surface/fail.md +1 -1
  73. package/docs/reference/authoring-surface/file.md +1 -1
  74. package/docs/reference/authoring-surface/frompick.md +1 -1
  75. package/docs/reference/authoring-surface/gate.md +118 -0
  76. package/docs/reference/authoring-surface/group-agents.md +15 -0
  77. package/docs/reference/authoring-surface/group-charts.md +16 -0
  78. package/docs/reference/authoring-surface/group-composition.md +25 -0
  79. package/docs/reference/authoring-surface/group-config.md +17 -0
  80. package/docs/reference/authoring-surface/group-effects.md +48 -0
  81. package/docs/reference/authoring-surface/group-failure.md +13 -0
  82. package/docs/reference/authoring-surface/group-gates.md +16 -0
  83. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  84. package/docs/reference/authoring-surface/group-steering.md +12 -0
  85. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  86. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  87. package/docs/reference/authoring-surface/http.md +1 -1
  88. package/docs/reference/authoring-surface/isfailure.md +1 -1
  89. package/docs/reference/authoring-surface/isready.md +1 -1
  90. package/docs/reference/authoring-surface/jr2setup.md +13 -3
  91. package/docs/reference/authoring-surface/mcp.md +107 -0
  92. package/docs/reference/authoring-surface/open.md +5 -3
  93. package/docs/reference/authoring-surface/openbody.md +1 -1
  94. package/docs/reference/authoring-surface/paramsof.md +1 -1
  95. package/docs/reference/authoring-surface/pool.md +77 -9
  96. package/docs/reference/authoring-surface/push.md +1 -1
  97. package/docs/reference/authoring-surface/readyof.md +1 -1
  98. package/docs/reference/authoring-surface/reread.md +19 -0
  99. package/docs/reference/authoring-surface/secret.md +1 -1
  100. package/docs/reference/authoring-surface/sentby.md +1 -1
  101. package/docs/reference/authoring-surface/skip.md +22 -0
  102. package/docs/reference/authoring-surface/source.md +56 -1
  103. package/docs/reference/authoring-surface/steer.md +93 -0
  104. package/docs/reference/authoring-surface/text.md +1 -1
  105. package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
  106. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  107. package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
  108. package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
  109. package/docs/reference/authoring-surface/type-binding.md +1 -1
  110. package/docs/reference/authoring-surface/type-caller.md +40 -0
  111. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  112. package/docs/reference/authoring-surface/type-carry.md +19 -0
  113. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  114. package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
  115. package/docs/reference/authoring-surface/type-customize.md +16 -1
  116. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  117. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  118. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  119. package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
  120. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  121. package/docs/reference/authoring-surface/type-framepath.md +1 -1
  122. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  123. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  124. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  125. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  126. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  127. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  128. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  129. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  130. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  131. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  132. package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
  133. package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
  134. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  135. package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
  136. package/docs/reference/authoring-surface/type-lends.md +5 -1
  137. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  138. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  139. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  140. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  141. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  142. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  143. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  144. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  145. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  146. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  147. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  148. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  149. package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
  150. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  151. package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
  152. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  153. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  154. package/docs/reference/authoring-surface/type-reposlot.md +1 -1
  155. package/docs/reference/authoring-surface/type-reread.md +18 -0
  156. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  157. package/docs/reference/authoring-surface/type-sender.md +26 -1
  158. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  159. package/docs/reference/authoring-surface/type-size.md +1 -1
  160. package/docs/reference/authoring-surface/type-skip.md +19 -0
  161. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  162. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  163. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  164. package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
  165. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  166. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  167. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  168. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  169. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  170. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  171. package/docs/reference/authoring-surface/type-workspaced.md +1 -1
  172. package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
  173. package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
  174. package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
  175. package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
  176. package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
  177. package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
  178. package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
  179. package/docs/reference/authoring-surface/workspace.md +1 -1
  180. package/docs/reference/authoring-surface/worktree.md +1 -1
  181. package/docs/reference/authoring-surface.md +88 -167
  182. package/docs/reference/cli.md +413 -74
  183. package/docs/reference/config.md +235 -2
  184. package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
  185. package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
  186. package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
  187. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  188. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  189. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  190. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  191. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
  192. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
  193. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  194. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
  195. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
  196. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  197. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
  198. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
  199. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  200. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
  201. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
  202. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  203. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
  204. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
  205. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  206. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  207. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
  208. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  209. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
  210. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  211. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
  212. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
  213. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  214. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  215. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  216. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
  217. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
  218. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
  219. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
  220. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
  221. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  222. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  223. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +51 -23
  224. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
  225. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
  226. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  227. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
  228. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  229. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  230. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
  231. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
  232. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  233. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  234. package/docs/reference/decisions.md +6 -4
  235. package/docs/reference/glossary.md +96 -42
  236. package/docs/reference/hooks.md +1077 -0
  237. package/docs/reference/http-api.md +976 -149
  238. package/docs/why.md +2 -1
  239. package/package.json +10 -3
  240. package/src/actor.ts +36 -10
  241. package/src/agent.ts +50 -0
  242. package/src/assembly-checks.ts +530 -4
  243. package/src/callers.ts +245 -0
  244. package/src/chart.ts +52 -7
  245. package/src/config.ts +550 -1
  246. package/src/conversations.ts +453 -0
  247. package/src/custodian.ts +8 -2
  248. package/src/customize.ts +143 -4
  249. package/src/dispatch-effect.ts +142 -26
  250. package/src/durability.ts +2 -0
  251. package/src/duration.ts +20 -0
  252. package/src/effect-checks.ts +2 -1
  253. package/src/effect.ts +60 -7
  254. package/src/effector-wire.ts +272 -27
  255. package/src/gate-brand.ts +16 -0
  256. package/src/gate.ts +634 -48
  257. package/src/harness-client.ts +10 -4
  258. package/src/held-secrets.ts +72 -23
  259. package/src/holds.ts +2 -2
  260. package/src/hook-kinds/discord.ts +92 -0
  261. package/src/hook-kinds/door.ts +107 -0
  262. package/src/hook-kinds/github.ts +89 -0
  263. package/src/hook-kinds/gitlab.ts +72 -0
  264. package/src/hook-kinds/hook.ts +7 -0
  265. package/src/hook-kinds/index.ts +465 -0
  266. package/src/hook-kinds/slack.ts +151 -0
  267. package/src/hook-kinds/webhook.ts +113 -0
  268. package/src/hooks.ts +506 -0
  269. package/src/http-effect.ts +76 -38
  270. package/src/http.ts +889 -94
  271. package/src/index.ts +16 -5
  272. package/src/instance.ts +196 -26
  273. package/src/internal.ts +22 -3
  274. package/src/machine-doc.ts +26 -1
  275. package/src/mcp-effect.ts +424 -0
  276. package/src/mount-digest.ts +41 -7
  277. package/src/names.ts +64 -2
  278. package/src/notices.ts +138 -10
  279. package/src/open-body.ts +25 -11
  280. package/src/open-effect.ts +17 -4
  281. package/src/open.ts +4 -2
  282. package/src/parts.ts +87 -9
  283. package/src/party.ts +2 -1
  284. package/src/pick.ts +2 -2
  285. package/src/pool.ts +1142 -163
  286. package/src/registration.ts +203 -13
  287. package/src/run-host.ts +1509 -171
  288. package/src/run-list.ts +191 -0
  289. package/src/sandbox-kube.ts +53 -5
  290. package/src/secret-placement.ts +239 -33
  291. package/src/server.ts +53 -4
  292. package/src/setup.ts +72 -32
  293. package/src/snapshot-store.ts +565 -48
  294. package/src/steer.ts +111 -0
  295. package/src/stub-harness.ts +6 -2
  296. package/src/tokens.ts +263 -22
  297. package/src/wire.ts +17 -0
  298. package/src/workspace.ts +50 -6
  299. package/src/worktree.ts +3 -3
  300. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  301. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
@@ -0,0 +1,113 @@
1
+ // `@jr2/orchestrator/hooks/webhook`: the general kind, for a service that has no kind of its own (ADR-0078). It
2
+ // makes a kind from data, through `defineHookKind`, as each other kind is made. So it gives a kind
3
+ // nothing that the interface does not give a kind from another package.
4
+ //
5
+ // It makes a kind, not a Hook: the service's protocol is the same for each Instance, so the
6
+ // Instance writes it one time, in a module of its own that `jr2.config.ts` imports for its Hooks
7
+ // and the Workflows import for their handlers. The Hook in the config gives only the credential
8
+ // and `may`.
9
+ //
10
+ // What it cannot do: a handshake, a body that is not JSON, a signature over more than the raw
11
+ // body, a signed timestamp, and an answer that a person reads. A service that needs one of them
12
+ // needs a kind module.
13
+
14
+ import {
15
+ at,
16
+ defineHookKind,
17
+ json,
18
+ type Events,
19
+ type HookKindOf,
20
+ type HookRequest,
21
+ type MacAlg,
22
+ type Verify,
23
+ } from "./index.ts";
24
+
25
+ /** Where a value is in a delivery: a header (lowercase), or a dotted path in the JSON body. */
26
+ export type Field = { readonly header: string } | { readonly json: string };
27
+
28
+ /** How the general kind verifies. `mac` is over the raw body only. */
29
+ export type WebhookVerify =
30
+ | {
31
+ readonly mac: {
32
+ /** The header of the signature (lowercase). */
33
+ readonly header: string;
34
+ /** `sha256` when it is not given. */
35
+ readonly alg?: MacAlg;
36
+ /** The text before the signature in the header's value, such as `sha256=`. */
37
+ readonly prefix?: string;
38
+ /** `hex` when it is not given. */
39
+ readonly encoding?: "hex" | "base64";
40
+ };
41
+ }
42
+ | { readonly token: { readonly header: string } };
43
+
44
+ type VerifyOf<W extends WebhookVerify> = W extends { readonly mac: unknown }
45
+ ? Extract<Verify, { readonly mac: unknown }>
46
+ : Extract<Verify, { readonly token: unknown }>;
47
+
48
+ /** The text of a field's value: a string, or a number as a string. */
49
+ function pick(req: HookRequest, body: unknown, f: Field): string | undefined {
50
+ const v = "header" in f ? req.headers[f.header.toLowerCase()] : at(body, f.json);
51
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
52
+ return typeof v === "string" && v !== "" ? v : undefined;
53
+ }
54
+
55
+ /**
56
+ * A kind from data.
57
+ * - `id`: the kind's id. The Instance chooses it; it must not be the id of another kind.
58
+ * - `events`: a zod schema for each event key that a handler can hear. The door parses with it, as
59
+ * for each kind (`Events` in `@jr2/orchestrator/hooks`).
60
+ * - `verify`: `mac` over the raw body (the header, its prefix, the algorithm, the encoding), or
61
+ * `token` in a header.
62
+ * - `key`: the event key, from one field; or `{ name, action }`, which gives `<name>.<action>`, or
63
+ * `<name>` when the delivery has no action.
64
+ * - `delivery`: the delivery id. `onBehalfOf`: optional.
65
+ *
66
+ * The answer is the outcome JSON.
67
+ */
68
+ export function webhook<const Id extends string, const E extends Events, const W extends WebhookVerify>(spec: {
69
+ readonly id: Id;
70
+ readonly events: E;
71
+ readonly verify: W;
72
+ readonly key: Field | { readonly name: Field; readonly action?: Field };
73
+ readonly delivery: Field;
74
+ readonly onBehalfOf?: Field;
75
+ }): HookKindOf<Id, E, VerifyOf<W>> {
76
+ const sv = spec.verify as WebhookVerify;
77
+ let verify: Verify;
78
+ if ("mac" in sv) {
79
+ const mac = sv.mac;
80
+ const header = mac.header.toLowerCase();
81
+ const prefix = mac.prefix ?? "";
82
+ verify = {
83
+ mac: {
84
+ alg: mac.alg ?? "sha256",
85
+ signed: (req) => {
86
+ const raw = req.headers[header];
87
+ if (raw === undefined || !raw.startsWith(prefix)) return { unsigned: `no ${header} header` };
88
+ const value = raw.slice(prefix.length);
89
+ return { bytes: [req.body], signature: mac.encoding === "base64" ? { base64: value } : { hex: value } };
90
+ },
91
+ },
92
+ };
93
+ } else {
94
+ verify = { token: { header: sv.token.header.toLowerCase() } };
95
+ }
96
+ const k = spec.key;
97
+ return defineHookKind({
98
+ api: 1,
99
+ id: spec.id,
100
+ events: spec.events,
101
+ verify,
102
+ read: (req) => {
103
+ const body = json(req);
104
+ const name = "name" in k ? pick(req, body, k.name) : pick(req, body, k);
105
+ const action = "name" in k && k.action ? pick(req, body, k.action) : undefined;
106
+ const id = pick(req, body, spec.delivery);
107
+ if (name === undefined) throw new Error("the delivery has no event name");
108
+ if (id === undefined) throw new Error("the delivery has no delivery id");
109
+ const by = spec.onBehalfOf ? pick(req, body, spec.onBehalfOf) : undefined;
110
+ return { key: action ? `${name}.${action}` : name, id, payload: body, ...(by ? { onBehalfOf: by } : {}) };
111
+ },
112
+ }) as unknown as HookKindOf<Id, E, VerifyOf<W>>;
113
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,506 @@
1
+ // The Hook's door (ADR-0011, ADR-0078): `POST /hooks/:name`, one stable address for each Hook that
2
+ // the current Assembly's `jr2.config.ts` declares. A Hook is a door for one outside service: it
3
+ // checks that a delivery comes from the service, and decides nothing. Only a Gate's handler makes an
4
+ // event of a delivery.
5
+ //
6
+ // The order of the door, each step before the next:
7
+ //
8
+ // 1. The Hook. No current Assembly is 503: no config says which Hooks exist. A name that the
9
+ // config does not declare is 404.
10
+ // 2. The body's size. A body above `HOOK_BODY_MAX` (2 MiB) is 413, and the door reads no more
11
+ // of it than the limit. The hook log keeps a `too-large` line, which claims no delivery id.
12
+ // This is the one route with no bearer token that takes a body, and a `mac` check sends the
13
+ // body to the Instance Effector, whose wire takes no more than 4 MiB of base64.
14
+ // 3. The verifier, one of three, a closed set. `token`: the sha-256 of the header's value against
15
+ // the digest that `jr2 up` gave, then the header is removed, so no code of the kind reads it.
16
+ // `ed25519`: the signature against the public key in the config. `mac`: the door builds the
17
+ // bytes that the kind names, and the Instance Effector, which holds the secret, answers "match"
18
+ // or "no match" (ADR-0068). A signed timestamp more than 300 s from the door's clock fails the
19
+ // verifier. Each failure is 401, and nothing else happens: nothing is logged.
20
+ // 4. The kind reads the request: a handshake, which gets the kind's answer, or a delivery. A
21
+ // request that the kind cannot read is 400.
22
+ // 5. The parse, with the kind's schema for the event key. A payload that fails it reaches no
23
+ // handler, and the answer is 200, because a second try fails the same way. The parse comes
24
+ // before the repeat check, so a payload that the parse refused is parsed again when it comes
25
+ // again.
26
+ // 6. The repeat check: a delivery whose id OR body digest this Hook's log holds is `seen`, and
27
+ // nothing is delivered.
28
+ // 7. The fan-in (`RunHost.deliverHook`) to the open Gates of the Workflows that the Hook may
29
+ // answer, and to the Sources of their Pools (ADR-0017). The answer is the outcome: 503 when a
30
+ // standing Pool that hears the kind and key has no live run and is not paused, or when the
31
+ // delivery followed its hand-overs 3 times and it handed over again, 429 when every Source's
32
+ // verdict is `hold-full`, and then the delivery does not claim its id.
33
+ // 8. The answer. A kind whose answer no person reads (a forge, a Slack event) gets the outcome
34
+ // JSON with the status of the rules. A chat command or click (the kind's `readBy: "person"`)
35
+ // gets 200 and the kind's envelope around one text: the `reply` of each target, joined
36
+ // (`joinReplies`), else the door's own text (`doorText`), with the cause of a 429 or a 503
37
+ // after it, cut to the kind's `maxText`. A follow-up that the envelope gives (the reply to a
38
+ // Slack click) goes out after the answer, to a url under the kind's `posts` only.
39
+ //
40
+ // The hook log (snapshot-store.ts) keeps a line for each step after the verifier, and for a body
41
+ // that is too large, for `runs.retain`, with the status that the rules give.
42
+
43
+ import { createHash, createPublicKey, timingSafeEqual, verify as verifySignature } from "node:crypto";
44
+ import type { Hook, HookKind, HookParts, HookRequest, Json, Signed, Unsigned } from "./hook-kinds/index.ts";
45
+ import { hookParts, hookRequest, schemaFor, signatureBytes, signedBytes } from "./hook-kinds/door.ts";
46
+ import { randomBytes } from "node:crypto";
47
+ import type { Answer, Delivery } from "./hook-kinds/index.ts";
48
+ import type { CallerRecord } from "./callers.ts";
49
+ import { createEffectorClient, type EffectState, type EffectorTransport, type HookMac } from "./effector-wire.ts";
50
+ import type { HookFanIn, HookReply, RunHost } from "./run-host.ts";
51
+ import type { HookLogLine, SnapshotStore } from "./snapshot-store.ts";
52
+ import { callerMay, ON_BEHALF_OF_MAX } from "./tokens.ts";
53
+
54
+ /** How far a signed timestamp may be from the door's clock, in seconds: jr2's, the same for every
55
+ * kind, and no config changes it (ADR-0078). */
56
+ export const HOOK_WINDOW_S = 300;
57
+
58
+ /** The largest body that the door reads, in bytes (ADR-0011). Above it the answer is 413, before
59
+ * the verifier. A `mac` check sends the body to the Instance Effector as base64, and its wire takes
60
+ * at most 4 MiB, so the limit keeps a correctly signed delivery from a false 503. */
61
+ export const HOOK_BODY_MAX = 2 * 1024 * 1024;
62
+
63
+ /**
64
+ * The body of a delivery: its bytes, or a reader that gives at most `max` bytes, and `undefined`
65
+ * when the body is longer. The door reads it after the Hook is known, so a request to no Hook
66
+ * reads no body.
67
+ */
68
+ export type HookBody = Uint8Array | ((max: number) => Promise<Uint8Array | undefined>);
69
+
70
+ /** How many lines of a Hook's log `GET /hooks/:name` gives by default, and at most. */
71
+ export const HOOK_LOG_DEFAULT = 50;
72
+ export const HOOK_LOG_MAX = 1000;
73
+
74
+ /**
75
+ * The answer to one delivery (ADR-0011): `delivery` is its id, `seen` says it was a repeat,
76
+ * `delivered` the Gates that took it, `refused` the Gates that did not and why, `ignored` how many
77
+ * open Gates of its kind and key gave nothing, and `sources` each Source's verdict (ADR-0017).
78
+ * `payloadRefused` names the kind and the event key of a payload that the parse refused, with zod's
79
+ * issues.
80
+ */
81
+ export type HookOutcome = {
82
+ delivery: string;
83
+ seen: boolean;
84
+ delivered: HookFanIn["delivered"];
85
+ refused: HookFanIn["refused"];
86
+ ignored: number;
87
+ sources: HookFanIn["sources"];
88
+ payloadRefused?: { kind: string; key: string; issues: unknown[] };
89
+ };
90
+
91
+ /** What the door answers: a status and a body. A string body is `text/plain`, `null` is empty.
92
+ * `after`: the follow-up, which the server starts after it sent the answer (ADR-0011). It never
93
+ * rejects: a failed post is a line in the hook log. */
94
+ export type HookAnswer = { status: number; body: Json; after?: () => Promise<void> };
95
+
96
+ /** The follow-up post of a kind's answer (ADR-0011, ADR-0068): one POST of `json` to `url`, with no
97
+ * secret, one try, through the Instance Effector's `http` call. It rejects with the reason when the
98
+ * post did not get a 2xx answer. */
99
+ export type HookPost = (req: { url: string; json: Json }) => Promise<void>;
100
+
101
+ export type HookDoorOptions = {
102
+ host: RunHost;
103
+ store: SnapshotStore;
104
+ /** The Hooks of the current Assembly's config, by name; `undefined` while there is no current
105
+ * Assembly. Read on each delivery, so a publish changes them. */
106
+ hooks: () => Readonly<Record<string, Hook>> | undefined;
107
+ /** The digests of the `token` Hooks' secrets, by secret name (`jr2-hook-digests`). */
108
+ digests: () => Readonly<Record<string, string>>;
109
+ /** The `mac` check through the Instance Effector. Absent: no Instance Effector, so a `mac` Hook
110
+ * answers 503. */
111
+ mac?: HookMac;
112
+ /** The follow-up post through the Instance Effector. Absent: no Instance Effector, so a
113
+ * follow-up fails, and the hook log says so. */
114
+ post?: HookPost;
115
+ /** The door's clock, in milliseconds. A test sets it. */
116
+ now?: () => number;
117
+ };
118
+
119
+ /** The door's two acts: a delivery, and the log of a Hook. */
120
+ export type HookDoor = {
121
+ deliver: (name: string, headers: Headers, body: HookBody) => Promise<HookAnswer>;
122
+ log: (name: string, limit: number) => { hook: string; declared: boolean; lines: HookLogLine[] };
123
+ };
124
+
125
+ const sha256 = (bytes: Uint8Array | string): string => createHash("sha256").update(bytes).digest("hex");
126
+
127
+ function sameHex(a: string, b: string): boolean {
128
+ const left = Buffer.from(a.toLowerCase());
129
+ const right = Buffer.from(b.toLowerCase());
130
+ return left.length === right.length && timingSafeEqual(left, right);
131
+ }
132
+
133
+ const refusal = (status: number, error: string): HookAnswer => ({ status, body: { error } });
134
+
135
+ /** Build the door over the run host and the store. */
136
+ export function hookDoor(opts: HookDoorOptions): HookDoor {
137
+ const now = opts.now ?? Date.now;
138
+
139
+ /** The verifier's answer: the request that the kind reads, or the door's refusal. */
140
+ const verify = async (
141
+ name: string,
142
+ parts: HookParts,
143
+ headers: Headers,
144
+ body: Uint8Array,
145
+ ): Promise<HookRequest | HookAnswer> => {
146
+ const v = parts.kind.verify;
147
+ if ("token" in v) {
148
+ const sent = headers.get(v.token.header);
149
+ const secret = "secret" in parts.credential ? parts.credential.secret.secret : undefined;
150
+ const digest = secret === undefined ? undefined : opts.digests()[secret];
151
+ if (digest === undefined) {
152
+ // A fault of the deploy, not of the service: say it where the operator reads.
153
+ console.error(`hook ${name}: no digest for its secret "${secret}" — \`jr2 up\` places it (ADR-0078)`);
154
+ }
155
+ if (sent === null || digest === undefined || !sameHex(sha256(sent), digest)) {
156
+ return refusal(401, "the delivery does not carry the Hook's token");
157
+ }
158
+ return hookRequest(headers, body, v.token.header);
159
+ }
160
+ const req = hookRequest(headers, body);
161
+ const signedOf = "mac" in v ? v.mac.signed : v.ed25519.signed;
162
+ let signed: Signed | Unsigned;
163
+ try {
164
+ signed = signedOf(req);
165
+ } catch (err) {
166
+ signed = { unsigned: err instanceof Error ? err.message : String(err) };
167
+ }
168
+ if ("unsigned" in signed) return refusal(401, `the delivery is not signed: ${signed.unsigned}`);
169
+ if (signed.at !== undefined) {
170
+ const off = Math.abs(now() / 1000 - signed.at);
171
+ if (!Number.isFinite(off) || off > HOOK_WINDOW_S) {
172
+ return refusal(401, `the delivery's signed time is more than ${HOOK_WINDOW_S} s from the door's clock`);
173
+ }
174
+ }
175
+ const signature = signatureBytes(signed);
176
+ if (signature === undefined) return refusal(401, "the delivery's signature is not well formed");
177
+ const bytes = signedBytes(signed);
178
+ if ("ed25519" in v) {
179
+ if (!("publicKey" in parts.credential)) return refusal(401, "the Hook has no public key");
180
+ let ok = false;
181
+ try {
182
+ const key = createPublicKey({
183
+ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(parts.credential.publicKey, "hex").toString("base64url") },
184
+ format: "jwk",
185
+ });
186
+ ok = verifySignature(null, bytes, key, signature);
187
+ } catch {
188
+ ok = false;
189
+ }
190
+ return ok ? req : refusal(401, "the delivery's signature does not match");
191
+ }
192
+ // `mac`: only the Instance Effector holds the secret (ADR-0068).
193
+ if (!opts.mac) {
194
+ console.error(`hook ${name}: a mac Hook needs the Instance Effector, and this Orchestrator has none`);
195
+ return refusal(503, "the Hook's signature cannot be checked now: no Instance Effector (ADR-0068)");
196
+ }
197
+ const secret = "secret" in parts.credential ? parts.credential.secret.secret : "";
198
+ let match: boolean;
199
+ try {
200
+ match = await opts.mac({ secret, alg: v.mac.alg, bytes, signature });
201
+ } catch (err) {
202
+ console.error(`hook ${name}: the mac check failed: ${err instanceof Error ? err.message : String(err)}`);
203
+ return refusal(503, "the Hook's signature cannot be checked now: the Instance Effector did not answer");
204
+ }
205
+ return match ? req : refusal(401, "the delivery's signature does not match");
206
+ };
207
+
208
+ const write = (line: Omit<HookLogLine, "at">, claimed?: number): void => {
209
+ try {
210
+ opts.store.writeHookLine({ ...line, at: new Date(now()).toISOString() }, claimed);
211
+ } catch (err) {
212
+ console.error(`hook ${line.hook}: the hook log was not written: ${err instanceof Error ? err.message : err}`);
213
+ }
214
+ };
215
+
216
+ const deliver = async (name: string, headers: Headers, source: HookBody): Promise<HookAnswer> => {
217
+ const hooks = opts.hooks();
218
+ if (hooks === undefined) {
219
+ return refusal(503, "the Orchestrator has no current Assembly, so no Hook is declared now — ask again");
220
+ }
221
+ const hook = Object.hasOwn(hooks, name) ? hooks[name] : undefined;
222
+ if (hook === undefined) return refusal(404, `no Hook named "${name}" in the current Assembly's config`);
223
+ const parts = hookParts(hook);
224
+ const kind: HookKind = parts.kind;
225
+
226
+ const body = typeof source === "function" ? await source(HOOK_BODY_MAX) : source;
227
+ if (body === undefined || body.byteLength > HOOK_BODY_MAX) {
228
+ // Before the verifier, so no delivery id is known and none is claimed: a service that sends
229
+ // the same body again gets the same answer.
230
+ write({ hook: name, kind: "too-large", status: 413, detail: { limit: HOOK_BODY_MAX } });
231
+ return refusal(413, `the delivery's body is larger than ${HOOK_BODY_MAX} bytes, the most the door reads`);
232
+ }
233
+
234
+ const verified = await verify(name, parts, headers, body);
235
+ if ("status" in verified) return verified;
236
+
237
+ let read: ReturnType<HookKind["read"]>;
238
+ try {
239
+ read = kind.read(verified);
240
+ } catch (err) {
241
+ const error = err instanceof Error ? err.message : String(err);
242
+ write({ hook: name, kind: "unreadable", status: 400, digest: sha256(body), detail: { error } });
243
+ return refusal(400, `the ${kind.id} kind cannot read the delivery: ${error}`);
244
+ }
245
+ if ("handshake" in read) {
246
+ write({ hook: name, kind: "handshake", status: 200 });
247
+ return { status: 200, body: read.handshake.body };
248
+ }
249
+ const id = typeof read.id === "string" ? read.id : "";
250
+ const key = typeof read.key === "string" ? read.key : "";
251
+ const digest = sha256(body);
252
+ if (id === "" || key === "") {
253
+ const error = "the kind gave no delivery id or no event key";
254
+ write({ hook: name, kind: "unreadable", status: 400, digest, detail: { error } });
255
+ return refusal(400, `the ${kind.id} kind cannot read the delivery: ${error}`);
256
+ }
257
+ const empty = { delivered: [], refused: [], ignored: 0, sources: [] };
258
+ // Who reads the answer (ADR-0011): a person for a chat command or click, else the service. Only
259
+ // a kind with an envelope can say "person".
260
+ const delivery: Delivery = read;
261
+ const person = delivery.readBy === "person" && typeof kind.answer === "function";
262
+ /** The answer to a delivery that the door read: the outcome for a service, the envelope for a
263
+ * person. The hook log keeps `status`, the status of the rules, in both cases. */
264
+ const answerOf = (outcome: HookOutcome, status: number, replies?: HookReply[], waiting?: string[]): HookAnswer => {
265
+ if (!person) return { status, body: outcome as unknown as Json };
266
+ const text = cutText(chatText(outcome, { status, replies: replies ?? [], waiting: waiting ?? [] }), kind.maxText);
267
+ let envelope: Answer;
268
+ try {
269
+ envelope = kind.answer!(text, delivery);
270
+ } catch (err) {
271
+ const error = err instanceof Error ? err.message : String(err);
272
+ console.error(`hook ${name}: the ${kind.id} kind's answer threw: ${error}`);
273
+ return { status: 200, body: text };
274
+ }
275
+ const then = envelope.then;
276
+ if (then === undefined) return { status: 200, body: envelope.body };
277
+ const fail = (error: string): void =>
278
+ write({ hook: name, kind: "follow-up", status: 0, delivery: id, key, detail: { post: then.post, error } });
279
+ // The door posts only under a host that the kind declares (ADR-0078): the Effector's fence
280
+ // holds those hosts alone, and a kind that gives another url gets no post.
281
+ if (!(kind.posts ?? []).some((prefix) => then.post.startsWith(prefix))) {
282
+ fail(`the ${kind.id} kind gave a follow-up to a url outside its posts (${(kind.posts ?? []).join(", ")})`);
283
+ return { status: 200, body: envelope.body };
284
+ }
285
+ const post = opts.post;
286
+ const after = async (): Promise<void> => {
287
+ if (!post) return fail("no Instance Effector, so the door cannot post a follow-up (ADR-0068)");
288
+ try {
289
+ await post({ url: then.post, json: then.json });
290
+ } catch (err) {
291
+ fail(err instanceof Error ? err.message : String(err));
292
+ }
293
+ };
294
+ return { status: 200, body: envelope.body, after };
295
+ };
296
+
297
+ // The parse before the repeat check (ADR-0011): a refused payload never claims its id.
298
+ const schema = schemaFor(kind, key);
299
+ let payload: unknown = read.payload;
300
+ if (schema) {
301
+ const parsed = schema.safeParse(read.payload);
302
+ if (!parsed.success) {
303
+ const issues = parsed.error.issues.map((i) => ({ path: i.path.map(String), message: i.message }));
304
+ const outcome: HookOutcome = {
305
+ delivery: id,
306
+ seen: false,
307
+ ...empty,
308
+ payloadRefused: { kind: kind.id, key, issues },
309
+ };
310
+ write({ hook: name, kind: "payload-refused", status: 200, delivery: id, digest, key, detail: outcome });
311
+ return answerOf(outcome, 200);
312
+ }
313
+ payload = parsed.data;
314
+ }
315
+
316
+ const claimed = opts.store.claimHookDelivery(name, id, digest);
317
+ if (claimed === undefined) {
318
+ const outcome: HookOutcome = { delivery: id, seen: true, ...empty };
319
+ write({ hook: name, kind: "seen", status: 200, delivery: id, digest, key, detail: outcome });
320
+ return answerOf(outcome, 200);
321
+ }
322
+
323
+ // The Hook as a Caller (ADR-0078): `answer` alone, and `onBehalfOf` from the signed payload.
324
+ const onBehalfOf =
325
+ typeof read.onBehalfOf === "string" && read.onBehalfOf !== ""
326
+ ? read.onBehalfOf.slice(0, ON_BEHALF_OF_MAX)
327
+ : undefined;
328
+ const caller: CallerRecord = { via: "hook", name, ...(onBehalfOf !== undefined ? { onBehalfOf } : {}) };
329
+ const may = { via: "hook" as const, name, may: { answer: parts.may.answer } };
330
+ // A key with no schema reaches nothing: no handler can hear it (ADR-0011).
331
+ const fan: HookFanIn = schema
332
+ ? await opts.host.deliverHook({
333
+ hook: name,
334
+ kind: kind.id,
335
+ key,
336
+ delivery: id,
337
+ payload,
338
+ caller,
339
+ may: (workflow) => callerMay(may, "answer", workflow),
340
+ ...(person ? { person: true } : {}),
341
+ })
342
+ : { delivered: [], refused: [], ignored: 0, sources: [] };
343
+ const { waiting, replies, ...fanned } = fan;
344
+ const outcome: HookOutcome = { delivery: id, seen: false, ...fanned };
345
+ // A `reply` that threw, or gave no text, is a line of the log's detail, never of the outcome.
346
+ const failed = (replies ?? []).filter((r) => r.error !== undefined);
347
+ const detail = failed.length
348
+ ? { ...outcome, replyErrors: failed.map(({ run, gate, error }) => ({ run, gate, error })) }
349
+ : outcome;
350
+ // A standing Pool that hears this kind and key has no live run and is not paused (ADR-0017): it
351
+ // waits out the back-off of a hand-over. 503, so the service sends it again, and the delivery
352
+ // does not claim its id, so that send is heard. The Gates that took it pass it over then.
353
+ if (waiting?.length) {
354
+ write({ hook: name, kind: "no-live-run", status: 503, delivery: id, digest, key, detail }, claimed);
355
+ return answerOf(outcome, 503, replies, waiting);
356
+ }
357
+ // Every Source holds as many items as it may (ADR-0017): 429, so the service sends it again. The
358
+ // delivery does not claim its id, so that send is heard: a Gate that took it is passed over by
359
+ // the run's answers, and a Pool that held its item answers `existing`.
360
+ if (fan.sources.length > 0 && fan.sources.every((line) => line.verdict === "hold-full")) {
361
+ write({ hook: name, kind: "hold-full", status: 429, delivery: id, digest, key, detail }, claimed);
362
+ return answerOf(outcome, 429, replies);
363
+ }
364
+ write({ hook: name, kind: "delivered", status: 200, delivery: id, digest, key, detail }, claimed);
365
+ return answerOf(outcome, 200, replies);
366
+ };
367
+
368
+ const log: HookDoor["log"] = (name, limit) => {
369
+ const hooks = opts.hooks();
370
+ return {
371
+ hook: name,
372
+ declared: hooks !== undefined && Object.hasOwn(hooks, name),
373
+ lines: opts.store.hookLog(name, limit),
374
+ };
375
+ };
376
+
377
+ return { deliver, log };
378
+ }
379
+
380
+ // --- The text that a person reads (ADR-0011) ------------------------------------------------------
381
+
382
+ /** Compare two strings by their code units: the same order on every host, with no locale. */
383
+ const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
384
+
385
+ /**
386
+ * Join the `reply` texts of the targets (ADR-0011): Sources first (what the act brought), then Gates
387
+ * (what the act answered), and in each by Workflow name, then by run id. Empty texts and exact
388
+ * repeats are dropped. The text of a `skipped` verdict shows only when no other target gave a text,
389
+ * because a skip says "not for me". The texts are joined by a newline; `""` when no target gave a
390
+ * text.
391
+ */
392
+ export function joinReplies(replies: readonly HookReply[]): string {
393
+ const sorted = [...replies].sort(
394
+ (a, b) =>
395
+ (a.target === b.target ? 0 : a.target === "source" ? -1 : 1) ||
396
+ byText(a.workflow, b.workflow) ||
397
+ byText(a.run, b.run),
398
+ );
399
+ const said = sorted.filter((r): r is HookReply & { text: string } => typeof r.text === "string" && r.text !== "");
400
+ const spoke = said.filter((r) => r.skipped !== true);
401
+ const shown = spoke.length > 0 ? spoke : said;
402
+ return [...new Set(shown.map((r) => r.text))].join("\n");
403
+ }
404
+
405
+ /** The Workflows of a list of Source lines, distinct and sorted, as a person reads them. */
406
+ const named = (workflows: readonly string[]): string => [...new Set(workflows)].sort(byText).join(", ");
407
+
408
+ /** Why a delivery whose answer a person reads did not count, for a 429 or a 503 (ADR-0011): the
409
+ * rules give that status, the person gets 200, and this text says why. */
410
+ function causeText(outcome: HookOutcome, status: number, waiting: readonly string[]): string | undefined {
411
+ if (status === 429) {
412
+ const full = outcome.sources.filter((s) => s.verdict === "hold-full").map((s) => s.workflow);
413
+ return `jr2 cannot take this now: the Pool of ${named(full)} holds as many items as it can. Try again later.`;
414
+ }
415
+ if (status === 503) {
416
+ return `jr2 cannot take this now: the Pool of ${named(waiting)} has no live run. Try again in a minute.`;
417
+ }
418
+ return undefined;
419
+ }
420
+
421
+ /**
422
+ * The door's own text, from the outcome (ADR-0011), for a delivery whose answer a person reads and
423
+ * where no target gave a text: a repeat, a payload that the parse refused, a delivery that reached
424
+ * nothing, or what the fan-in did. It is the same for every service, so the kind never sees the
425
+ * outcome.
426
+ */
427
+ export function doorText(outcome: HookOutcome): string {
428
+ if (outcome.seen) return "jr2 got this before, and did not do it again.";
429
+ if (outcome.payloadRefused) {
430
+ const { kind, key, issues } = outcome.payloadRefused;
431
+ const first = issues[0] as { path?: string[]; message?: string } | undefined;
432
+ const why = first?.message ? ` (${first.path?.length ? `${first.path.join(".")}: ` : ""}${first.message})` : "";
433
+ return `jr2 cannot use this: the ${kind} payload "${key}" is not in the form that jr2 knows${why}.`;
434
+ }
435
+ const lines: string[] = [];
436
+ for (const s of outcome.sources) {
437
+ const at = `${s.workflow} ${s.pool}`;
438
+ if (s.verdict === "held") lines.push(`${at}: took the item ${s.key}.`);
439
+ else if (s.verdict === "existing")
440
+ lines.push(`${at}: the item ${s.key} is already in jr2${s.run ? ` (run ${s.run})` : ""}.`);
441
+ else if (s.verdict === "hold-full") lines.push(`${at}: cannot hold the item ${s.key} now.`);
442
+ else if (s.verdict === "reread") lines.push(`${at}: reads its items again.`);
443
+ else if (s.verdict === "paused") lines.push(`The Pool of ${s.workflow} is paused, so it took nothing.`);
444
+ }
445
+ for (const d of outcome.delivered) lines.push(`${d.workflow} ${d.run}: ${d.gate} took "${d.event}".`);
446
+ for (const r of outcome.refused) lines.push(`${r.run}: ${r.gate} did not take it: ${r.reason}`);
447
+ if (lines.length > 0) return lines.join("\n");
448
+ // Only skips, or nothing: a skip's reason is the text of its Source's `reply`, if it has one.
449
+ const skipped = outcome.sources.filter((s) => s.verdict === "skipped");
450
+ if (skipped.length > 0)
451
+ return skipped.map((s) => `${s.workflow}: did nothing: ${s.reason ?? "no reason"}`).join("\n");
452
+ return "Nothing in jr2 waits for this now.";
453
+ }
454
+
455
+ /**
456
+ * The one text of a delivery whose answer a person reads (ADR-0011): the joined `reply` texts, else
457
+ * the door's own text; and for a 429 or a 503, the cause after it, so the text always says why.
458
+ */
459
+ export function chatText(
460
+ outcome: HookOutcome,
461
+ at: { status: number; replies: readonly HookReply[]; waiting: readonly string[] },
462
+ ): string {
463
+ const joined = joinReplies(at.replies);
464
+ const cause = causeText(outcome, at.status, at.waiting);
465
+ if (joined === "") return cause ?? doorText(outcome);
466
+ return cause === undefined || joined.split("\n").includes(cause) ? joined : `${joined}\n${cause}`;
467
+ }
468
+
469
+ /** Cut a text to `max` characters (code points, so no character is cut in two), the last one an
470
+ * ellipsis when the text was longer. No `max`: the text whole. */
471
+ export function cutText(text: string, max: number | undefined): string {
472
+ if (max === undefined) return text;
473
+ const points = Array.from(text);
474
+ if (points.length <= max) return text;
475
+ return `${points.slice(0, Math.max(0, max - 1)).join("")}…`;
476
+ }
477
+
478
+ // --- The follow-up post (ADR-0011, ADR-0068) --------------------------------------------------------
479
+
480
+ /** How long the door waits for a follow-up post to settle. */
481
+ const FOLLOW_UP_MS = 10_000;
482
+
483
+ /**
484
+ * The follow-up post through the Instance Effector's signed wire: one `http` call, with no secret,
485
+ * sent once. It has no slot, no ledger row and no run, so its id is new for each post. The
486
+ * Effector's fence takes it only to a host that a Hook's kind declares in `posts` (`jr2 up` writes
487
+ * them, ADR-0068). It rejects with the reason of an outcome that is not a 2xx response.
488
+ */
489
+ export function createHookPost(opts: { key: Buffer | string; transport: EffectorTransport }): HookPost {
490
+ const client = createEffectorClient(opts);
491
+ return async ({ url, json }) => {
492
+ const id = `hook-follow-up-${randomBytes(12).toString("hex")}`;
493
+ const signal = AbortSignal.timeout(FOLLOW_UP_MS);
494
+ let state: EffectState = await client.start(
495
+ id,
496
+ { kind: "http", method: "POST", url: [{ text: url }], body: { json }, timeoutMs: FOLLOW_UP_MS, maxBody: 4096 },
497
+ signal,
498
+ );
499
+ while (state.state === "running") state = await client.poll(state.replica, id, signal);
500
+ const out = state.output;
501
+ if (state.state !== "settled" || out === undefined) throw new Error("the Instance Effector lost the post");
502
+ if (!("outcome" in out)) throw new Error("the Instance Effector gave the post an output of another kind");
503
+ if (out.outcome !== "response") throw new Error(`the post is ${out.outcome}: ${"reason" in out ? out.reason : ""}`);
504
+ if (out.status < 200 || out.status > 299) throw new Error(`the post got ${out.status}`);
505
+ };
506
+ }