@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
package/src/http.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // GET /workflows/:wrapper/chart-schema the grammar, narrowed to the Open body [open]
19
19
  // PUT /workflows/:wrapper/charts/:name admit + keep a runtime Chart [Instance token]
20
20
  // DELETE /workflows/:wrapper/charts/:name remove a runtime Chart [Instance token]
21
- // POST /workflows/:wrapper/charts/:name/runs start the named Chart [Instance token]
21
+ // POST /workflows/:wrapper/charts/:name/runs start the named Chart [start]
22
22
  // GET /workflows/:name/runs/:runId/machine the run's bound Machine, Charts compiled [open]
23
23
  //
24
24
  // # `jr2 up`, and nothing else (ADR-0069)
@@ -30,17 +30,21 @@
30
30
  // agent surface recorded against its own Sandbox — never to a Gate. That is what stops an Agent
31
31
  // from approving its own review.
32
32
  //
33
- // THREE bands of access, not two (ADR-0014) — the middleware is what says which:
33
+ // The bands of access (ADR-0014, ADR-0078) — the middleware is what says which:
34
34
  //
35
35
  // open structure + observation. `/workflows`, a template's Machine, the Console shell,
36
36
  // and the run PROJECTIONS below (`GET /workflows/:name/runs*`). No context, no
37
37
  // control.
38
38
  // authenticated any principal we minted a token for. The Agent's surface lives here, scoped
39
39
  // further per-registration by `mayDeliverToAgent`.
40
- // instanceOnly the Instance token ALONE. Run control — starting a run included (ADR-0058) — and
41
- // full run state (`/runs*`): a Sandbox token authenticates but is refused, because
40
+ // guarded a CALLER (ADR-0078) with the route's one scope, for the Workflow of the run or
41
+ // the door it addresses: run state, run control, starting a run and Gates. A
42
+ // Sandbox token authenticates but is refused, because it is never a Caller:
42
43
  // starting work, reading another feature's context, or cancelling a run is not on
43
44
  // the Agent's surface any more than a Gate is.
45
+ // instanceOnly the Instance token's Caller ALONE: the routes no scope names (the runtime Chart
46
+ // routes, `GET /repos`, a Hook's log).
47
+ // hook `POST /hooks/:name` (ADR-0011): no bearer token, and the Hook's own verifier.
44
48
 
45
49
  import { readFile, stat } from "node:fs/promises";
46
50
  import { createRequire } from "node:module";
@@ -49,10 +53,13 @@ import tsBlankSpace from "ts-blank-space";
49
53
  import { Hono } from "hono";
50
54
  import type { Context, MiddlewareHandler } from "hono";
51
55
  import { accepts } from "hono/accepts";
56
+ import { cors } from "hono/cors";
52
57
  import { streamSSE } from "hono/streaming";
53
58
  import { z } from "zod";
54
59
  import {
55
60
  DISPATCHED_BY_HEADER,
61
+ dispatchKeyOf,
62
+ KEY_ONLY_HEADER,
56
63
  EventValidationError,
57
64
  NotTakenError,
58
65
  OutstandingError,
@@ -60,20 +67,33 @@ import {
60
67
  type DispatchedBy,
61
68
  } from "./registration.ts";
62
69
  import type { RepoStatus } from "./repos.ts";
70
+ import { parseRunListQuery } from "./run-list.ts";
71
+ import { CONVERSATION_SWEPT, type ConversationEntry, type ConversationStore } from "./conversations.ts";
63
72
  import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
64
73
  import {
65
- isAgentPrincipal,
74
+ callerHasScope,
75
+ callerMay,
76
+ callerMayAnswer,
77
+ callerMayAny,
78
+ instanceCaller,
79
+ isInstancePrincipal,
66
80
  mayAskForSandbox,
67
81
  mayDeliverToAgent,
82
+ ON_BEHALF_OF_HEADER,
83
+ ON_BEHALF_OF_MAX,
68
84
  SLOT_HEADER,
69
85
  type Authenticator,
86
+ type Caller,
70
87
  type Principal,
71
88
  } from "./tokens.ts";
89
+ import type { Scope } from "./config.ts";
90
+ import { callerRecord } from "./callers.ts";
91
+ import { STEERING_TEXT_MAX } from "./steer.ts";
72
92
  import {
73
- DispatchKeyConflictError,
74
- keyAnswer,
75
- keyConflict,
93
+ GateRefused,
76
94
  observe,
95
+ StandingRefused,
96
+ SteeringRefused,
77
97
  UnknownWorkflowError,
78
98
  type RunFeedEvent,
79
99
  type RunHost,
@@ -83,6 +103,7 @@ import {
83
103
  import { KIT_VERSION } from "./config.ts";
84
104
  import type { AssemblyDoor } from "./assembly.ts";
85
105
  import { ChartRefusal } from "./chart.ts";
106
+ import { HOOK_LOG_DEFAULT, HOOK_LOG_MAX, type HookDoor } from "./hooks.ts";
86
107
  import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
87
108
 
88
109
  // ---- The route table (ADR-0072) -------------------------------------------------------------
@@ -92,9 +113,12 @@ import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./
92
113
  // the app's own registrations both ways and checks each row's band on the wire, so the table
93
114
  // cannot drift from the code by forgetting.
94
115
 
95
- /** The access bands of ADR-0014, named for the credential each requires, and the publish door's
96
- * band (ADR-0069): the publish token alone. */
97
- export type HttpBand = "open" | "authenticated" | "instanceOnly" | "publishOnly";
116
+ /** The access bands of ADR-0014, named for what each checks, the publish door's band (ADR-0069):
117
+ * the publish token alone, and the Hook's band (ADR-0011, ADR-0078): the Hook's own verifier, and
118
+ * no bearer token. `guarded` takes a Caller with the route's {@link HttpRoute.scope}
119
+ * (ADR-0078); `instanceOnly` takes the Instance token's Caller alone, for a route that no scope
120
+ * names. */
121
+ export type HttpBand = "open" | "authenticated" | "guarded" | "instanceOnly" | "publishOnly" | "hook";
98
122
 
99
123
  /** One route of the Orchestrator's HTTP API (ADR-0072). */
100
124
  export type HttpRoute = {
@@ -102,6 +126,10 @@ export type HttpRoute = {
102
126
  /** In Hono syntax, exactly as registered: `:param`, and `:param{regex}` for a constrained one. */
103
127
  path: string;
104
128
  band: HttpBand;
129
+ /** A `guarded` route's scope (ADR-0078): the one a Caller needs, for the Workflow of the run or
130
+ * the door that the route addresses; a list when any one of them opens the route (each opens its
131
+ * own part of the answer); `any` for a route that takes any Caller. */
132
+ scope?: Scope | readonly Scope[] | "any";
105
133
  /** One line: what the route does. */
106
134
  summary: string;
107
135
  /** The request body, as a type name or in short; absent when the route reads none. */
@@ -134,6 +162,38 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
134
162
  stream: false,
135
163
  adrs: ["0009"],
136
164
  },
165
+ {
166
+ method: "GET",
167
+ path: "/whoami",
168
+ band: "guarded",
169
+ scope: "any",
170
+ summary: "The request's Caller: how jr2 knows it, its name, what it may do, and the party it says it acts for.",
171
+ response: "Caller",
172
+ stream: false,
173
+ adrs: ["0078"],
174
+ },
175
+ {
176
+ method: "POST",
177
+ path: "/hooks/:name",
178
+ band: "hook",
179
+ summary:
180
+ "A delivery of an outside service to the Hook that the current Assembly's config names. The Hook's verifier authenticates it, and it goes to the open Gates whose handlers hear its kind and event key. 413 for a body above 2 MiB, before the verifier and with no more of the body read than that; 401 for a delivery that fails the verifier, 404 for a name that no Hook has, 503 with no current Assembly, and 503 when a standing Pool that hears it has no live run and is not paused, or when the delivery followed the Pool's hand-overs 3 times and the Pool handed its run over again. A 503 claims no delivery id.",
181
+ body: "the service's own request",
182
+ response:
183
+ "the kind's answer to a handshake, or HookOutcome: { delivery, seen, delivered, refused, ignored, sources, payloadRefused? }",
184
+ stream: false,
185
+ adrs: ["0011", "0078"],
186
+ },
187
+ {
188
+ method: "GET",
189
+ path: "/hooks/:name",
190
+ band: "instanceOnly",
191
+ summary:
192
+ "A Hook's log, newest first: each delivery that jr2 keeps (`runs.retain`, 30 days by default), with what the door did with it. `?limit=` (default 50, at most 1000).",
193
+ response: "{ hook, declared, lines: HookLogLine[] }",
194
+ stream: false,
195
+ adrs: ["0011", "0065"],
196
+ },
137
197
  {
138
198
  method: "GET",
139
199
  path: "/workflows",
@@ -148,10 +208,11 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
148
208
  method: "GET",
149
209
  path: "/assembly",
150
210
  band: "open",
151
- summary: "The current Assembly and the Assemblies that live runs hold.",
152
- response: "AssemblyView",
211
+ summary:
212
+ "The current Assembly, the Assemblies that live runs hold, and each standing Workflow: live, paused or waiting, its run, and how many items it holds.",
213
+ response: "AssemblyView & { standing?: StandingView[] }",
153
214
  stream: false,
154
- adrs: ["0069"],
215
+ adrs: ["0017", "0069"],
155
216
  },
156
217
  {
157
218
  method: "PUT",
@@ -161,7 +222,7 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
161
222
  "Publish the next Assembly. `jr2 up` calls it, and nothing else. A refused Assembly leaves the current one current.",
162
223
  body: "the Assembly as a tar stream, with the headers `jr2-kit-version` and `jr2-assembly-hash`",
163
224
  response:
164
- "200 with the Assembly, its Workflows, the held Assemblies and the ones collected; 400, 409 or 422 with the reason",
225
+ "200 with the Assembly, its Workflows, the held Assemblies, the ones collected and each standing Workflow's state; 400, 409 or 422 with the reason",
165
226
  stream: false,
166
227
  adrs: ["0069"],
167
228
  },
@@ -196,12 +257,13 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
196
257
  {
197
258
  method: "POST",
198
259
  path: "/workflows/:wrapper/charts/:name/runs",
199
- band: "instanceOnly",
260
+ band: "guarded",
261
+ scope: "start",
200
262
  summary: "Start a run of a named Chart, as `POST /workflows/<wrapper>%2F<name>/runs` does.",
201
263
  body: "the Chart wrapper's input, without the Chart",
202
264
  response: "201 { runId, instanceId }; 409 when the named Chart cannot run now",
203
265
  stream: false,
204
- adrs: ["0070"],
266
+ adrs: ["0070", "0078"],
205
267
  },
206
268
  {
207
269
  method: "GET",
@@ -221,6 +283,16 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
221
283
  stream: false,
222
284
  adrs: ["0032"],
223
285
  },
286
+ {
287
+ method: "GET",
288
+ path: "/console/config",
289
+ band: "open",
290
+ summary:
291
+ "What the Console needs before a login: each JWT issuer that offers a login, and its client id. Both are public values.",
292
+ response: "{ login: Array<{ issuer, clientId }> }",
293
+ stream: false,
294
+ adrs: ["0032", "0078"],
295
+ },
224
296
  {
225
297
  method: "GET",
226
298
  path: "/workflows/:name",
@@ -233,14 +305,15 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
233
305
  {
234
306
  method: "POST",
235
307
  path: "/workflows/:name/runs",
236
- band: "instanceOnly",
308
+ band: "guarded",
309
+ scope: "start",
237
310
  summary:
238
- "Start a run of a registered workflow. A `dispatch` from another Orchestrator sends the header `jr2-dispatched-by`, and a repeated start with its key answers the run that it started.",
311
+ "Start a run of a registered workflow. A `dispatch` from another Orchestrator sends the header `jr2-dispatched-by`, and a repeated start with its key answers the run that it started. With the header `jr2-key-only: true` too, the start is a key-only ask: it answers the run under the key, and starts nothing. For a standing Workflow, jr2 starts the run: the start only resumes a paused one, and `?drop-held=true` resumes it with no held item.",
239
312
  body: "the workflow's input, checked against its declared input schema",
240
313
  response:
241
- "201 { runId, instanceId }; 200 { runId, instanceId, existing: true } for a repeated dispatch; 422 { error, report } when the door refuses a Chart in the input",
314
+ "201 { runId, instanceId }; 201 { runId, instanceId, resumed: true } for a resumed standing Workflow; 200 { runId, instanceId, existing: true } for a repeated dispatch; 200 { existing: false } for a key-only ask with no run under the key; 409 for a standing Workflow that is not paused, or a dispatch to one; 422 { error, report } when the door refuses a Chart in the input",
242
315
  stream: false,
243
- adrs: ["0033", "0058", "0070", "0071"],
316
+ adrs: ["0017", "0033", "0058", "0070", "0071", "0078"],
244
317
  },
245
318
  {
246
319
  method: "GET",
@@ -326,11 +399,14 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
326
399
  {
327
400
  method: "GET",
328
401
  path: "/runs",
329
- band: "instanceOnly",
330
- summary: "The live runs, with their full status.",
331
- response: "RunStatus[]",
402
+ band: "guarded",
403
+ scope: "read",
404
+ summary:
405
+ "A page of the runs of the Workflows that the Caller may read, newest first: live runs by default; " +
406
+ "?status=live|ended|all|<list> ?workflow ?since ?cursor ?limit.",
407
+ response: "{ runs: RunSummary[], next? }",
332
408
  stream: false,
333
- adrs: ["0009", "0014"],
409
+ adrs: ["0009", "0014", "0078"],
334
410
  },
335
411
  {
336
412
  method: "GET",
@@ -344,59 +420,135 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
344
420
  {
345
421
  method: "GET",
346
422
  path: "/runs/resolve",
347
- band: "instanceOnly",
348
- summary: "The run ids that start with a prefix (`?prefix=`, at least 4 characters).",
423
+ band: "guarded",
424
+ scope: "any",
425
+ summary:
426
+ "The run ids that start with a prefix (`?prefix=`, at least 4 characters), of the Workflows in any scope of the Caller.",
349
427
  response: "{ prefix, runIds: string[], truncated }",
350
428
  stream: false,
351
- adrs: ["0009"],
429
+ adrs: ["0009", "0078"],
430
+ },
431
+ {
432
+ method: "GET",
433
+ path: "/gates",
434
+ band: "guarded",
435
+ scope: "answer",
436
+ summary:
437
+ "The Gate inbox: every open Gate of every live run that the Caller may answer, oldest first, never a run's context; ?workflow.",
438
+ response: "{ gates: GateInboxEntry[] }",
439
+ stream: false,
440
+ adrs: ["0011", "0014", "0078"],
352
441
  },
353
442
  {
354
443
  method: "GET",
355
444
  path: "/runs/:runId",
356
- band: "instanceOnly",
357
- summary: "One run's status, live or settled, with its open Gates.",
358
- response: "RunStatus & { gates: GateView[] }",
445
+ band: "guarded",
446
+ scope: ["read", "answer"],
447
+ summary:
448
+ "One run's status, live or settled, with its open Gates. `read` opens the status, and `answer` opens the Gates that the Caller may answer: with `answer` alone the answer is the run's observation and those Gates, and never its context.",
449
+ response:
450
+ "RunStatus & { gates: GateView[] }; RunStatus with `read` alone; RunObservation & { gates: GateView[] } with `answer` alone",
359
451
  stream: false,
360
- adrs: ["0009", "0011"],
452
+ adrs: ["0009", "0011", "0078"],
361
453
  },
362
454
  {
363
- // `authenticated` in the code, and the handler refuses a Sandbox token itself (ADR-0013): the
364
- // credential the route requires is the Instance token's, so this is its band.
365
455
  method: "POST",
366
456
  path: "/runs/:runId/gates/:gate/events",
367
- band: "instanceOnly",
368
- summary: "Deliver an event to an open Gate of a run.",
457
+ band: "guarded",
458
+ scope: "answer",
459
+ summary:
460
+ "Deliver an event to an open Gate of a run. A Caller whose `answer` names single Gates of the Workflow is refused 403 for another Gate.",
369
461
  body: "{ type, ...payload }, checked against the Gate's schema for that event",
370
462
  response: "{ ok: true }; 409 { error, acceptsNow } when the run does not take it now",
371
463
  stream: false,
372
- adrs: ["0011", "0013"],
464
+ adrs: ["0011", "0013", "0078"],
373
465
  },
374
466
  {
375
467
  method: "GET",
376
468
  path: "/runs/:runId/events",
377
- band: "instanceOnly",
469
+ band: "guarded",
470
+ scope: "read",
378
471
  summary: "One run's full activity until it settles; a settled run's final status, once.",
379
- response: "SSE: status, emit, retry, admission, pick, placing, placed, child-error, effect, gone",
472
+ response:
473
+ "SSE: status, emit, retry, admission, pick, placing, placed, child-error, looked, hook-refused, pool, effect, gone",
380
474
  stream: true,
381
- adrs: ["0022", "0023", "0064", "0067"],
475
+ adrs: ["0011", "0022", "0023", "0064", "0067", "0078"],
476
+ },
477
+ {
478
+ method: "GET",
479
+ path: "/runs/:runId/conversations",
480
+ band: "guarded",
481
+ scope: "read",
482
+ summary:
483
+ "A run's kept conversations, the first first: one row for each conversation of each Agent, Menu-only Agents too, with its Agent slot.",
484
+ response: 'Array<{ agent, slot?, instanceId, open, bytes, cut, swept?: "no space" }>',
485
+ stream: false,
486
+ adrs: ["0023", "0065"],
487
+ },
488
+ {
489
+ method: "GET",
490
+ path: "/runs/:runId/conversations/:instanceId",
491
+ band: "guarded",
492
+ scope: "read",
493
+ summary:
494
+ "The entries of one kept conversation after a sequence number (`?after=<seq>`, `?limit=`, 1000 by default). The Instance ID is URL-encoded.",
495
+ response: "Array<{ id, seq, at, agent, kind, text }>",
496
+ stream: false,
497
+ adrs: ["0023"],
498
+ },
499
+ {
500
+ method: "GET",
501
+ path: "/runs/:runId/conversations/:instanceId/events",
502
+ band: "guarded",
503
+ scope: "read",
504
+ summary:
505
+ "Follow one kept conversation: its entries after `?after=<seq>`, then each new one, until the conversation is not open.",
506
+ response: "SSE: entry, end",
507
+ stream: true,
508
+ adrs: ["0023"],
382
509
  },
383
510
  {
384
511
  method: "POST",
385
512
  path: "/runs/:runId/events",
386
- band: "instanceOnly",
513
+ band: "guarded",
514
+ scope: "cancel",
387
515
  summary: "Cancel a run: it ends, and its Workspaces go unless `keep` is true.",
388
516
  body: '{ type: "CANCEL", keep?: boolean }',
389
517
  response: "{ ok: true }",
390
518
  stream: false,
391
- adrs: ["0002", "0025"],
519
+ adrs: ["0002", "0025", "0078"],
520
+ },
521
+ {
522
+ method: "GET",
523
+ path: "/runs/:runId/agents",
524
+ band: "guarded",
525
+ scope: "steer",
526
+ summary:
527
+ "The Agent slots of a run that a Steering message can go to: one row for each Agent of each live Machine instance.",
528
+ response: "Array<{ agent, open, steerable }>; [] for a run that is not live",
529
+ stream: false,
530
+ adrs: ["0002", "0078"],
531
+ },
532
+ {
533
+ method: "POST",
534
+ path: "/runs/:runId/steering",
535
+ band: "guarded",
536
+ scope: "steer",
537
+ summary:
538
+ "Send a Steering message to one Agent slot of a live run: its open Turn hears it at a step boundary, or else its next Turn.",
539
+ body: "{ agent, text }, `text` at most 16 KiB",
540
+ response:
541
+ "202 { id, agent, open }; 413 for a longer text; 409 { error, agents? } when no live Machine instance holds the slot; 403 when the Agent is not steerable",
542
+ stream: false,
543
+ adrs: ["0002", "0062", "0078"],
392
544
  },
393
545
  {
394
546
  method: "GET",
395
547
  path: "/agents/:instanceId/surface",
396
548
  band: "authenticated",
397
549
  summary:
398
- "An Agent's Menu, this Turn's Allowed picks and the conversation's pending notices. A control token reads only its own pod's, for the slot it states.",
399
- response: "AgentSurfaceView & { notices: Array<{ id, notice }> }",
550
+ "An Agent's Menu, this Turn's Allowed picks, the conversation's pending notices and its slot's Steering messages. A control token reads only its own pod's, for the slot it states.",
551
+ response: "AgentSurfaceView & { notices: Array<{ id, notice }>, steering?: Array<{ id, message }> }",
400
552
  stream: false,
401
553
  adrs: ["0013", "0029"],
402
554
  },
@@ -423,12 +575,25 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
423
575
  method: "POST",
424
576
  path: "/agents/:instanceId/notices",
425
577
  band: "authenticated",
426
- summary: "Acknowledge the notices a step boundary appended to the Turn: they are heard in its conversation.",
578
+ summary:
579
+ "Acknowledge the notices and Steering messages a step boundary appended to the Turn: they are heard in its conversation.",
427
580
  body: "{ heard: string[] }",
428
581
  response: "{ heard: number }",
429
582
  stream: false,
430
583
  adrs: ["0062", "0076"],
431
584
  },
585
+ {
586
+ method: "POST",
587
+ path: "/agents/:instanceId/conversation",
588
+ band: "authenticated",
589
+ summary:
590
+ "Keep a batch of a conversation's entries. Only the control token of the pod that the run's admission ledger names, for the ledger's slot; never the Instance token. An entry kept already adds nothing.",
591
+ body: "{ entries: Array<{ id, seq, at, kind, text }> }, at most 4 MiB",
592
+ response:
593
+ "{ kept, cut }; 409 while the run's ledger does not name the conversation yet (send again); 404 for a run that is not known",
594
+ stream: false,
595
+ adrs: ["0023", "0076"],
596
+ },
432
597
  {
433
598
  method: "POST",
434
599
  path: "/sandboxes/:name/fetch",
@@ -451,6 +616,17 @@ type RunEventBody = { type?: string; keep?: unknown };
451
616
  * the same floor on the argument; this one guards the scan regardless of who is calling. */
452
617
  const MIN_RUN_ID_PREFIX = 4;
453
618
 
619
+ /** How many candidates `GET /runs/resolve` reads for a Caller whose scopes name Workflows: the ids
620
+ * of other Workflows must not fill its answer. */
621
+ const RESOLVE_SCAN = 200;
622
+
623
+ /** The items of `items` for which `keep` answers true, in order. */
624
+ async function filterAsync<T>(items: readonly T[], keep: (item: T) => Promise<boolean>): Promise<T[]> {
625
+ const out: T[] = [];
626
+ for (const item of items) if (await keep(item)) out.push(item);
627
+ return out;
628
+ }
629
+
454
630
  /** How many ambiguous candidates are worth showing. Proving ambiguity takes 2; letting the caller
455
631
  * PICK is the point, so the listing goes deeper before it truncates. */
456
632
  const RESOLVE_LIMIT = 10;
@@ -515,6 +691,15 @@ function restorePendingMessage(what: string): string {
515
691
  * Instance principal, so it reads no run, starts none, and speaks for no Agent. */
516
692
  const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
517
693
 
694
+ /** Why a principal that is not a Caller is refused on a Caller's route (ADR-0014, ADR-0078). */
695
+ const NOT_A_CALLER =
696
+ "this route needs a Caller, and a Sandbox token is not one: no Agent reaches run state, run control or a Gate";
697
+
698
+ /** A Caller as a 403 names it: `token/ci`. */
699
+ function callerLabel(caller: Caller): string {
700
+ return `the Caller ${caller.via}/${caller.name}`;
701
+ }
702
+
518
703
  /** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
519
704
  type JR2Env = { Variables: { principal: Principal } };
520
705
 
@@ -661,6 +846,28 @@ function closer(resolve: () => void) {
661
846
  /** How often an otherwise-silent feed writes a ping. */
662
847
  const PING_MS = 15_000;
663
848
 
849
+ /** How many entries one read of a kept conversation gives when the query names no `limit`, and the
850
+ * most it can name (ADR-0023). */
851
+ const CONVERSATION_PAGE_DEFAULT = 1_000;
852
+ const CONVERSATION_PAGE_MAX = 5_000;
853
+ /** The largest batch of entries the Harness may send (ADR-0023): it sends less than 1 MiB. */
854
+ const CONVERSATION_BATCH_MAX = 4 * 1024 * 1024;
855
+ /** How long a followed conversation that is not open stays followed for a late batch. */
856
+ const FOLLOW_GRACE_MS = 5_000;
857
+
858
+ /** A batch of a conversation's entries, as the Harness sends it (ADR-0023). */
859
+ const conversationBatch = z.object({
860
+ entries: z.array(
861
+ z.object({
862
+ id: z.string().min(1).max(200),
863
+ seq: z.number().int().min(0),
864
+ at: z.string().max(64),
865
+ kind: z.string().min(1).max(200),
866
+ text: z.string(),
867
+ }),
868
+ ),
869
+ });
870
+
664
871
  /**
665
872
  * Keep a quiet feed alive.
666
873
  *
@@ -785,6 +992,9 @@ function subscriber(stream: SseStream): Subscriber {
785
992
  export type CreateAppOptions = {
786
993
  /** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
787
994
  pingMs?: number;
995
+ /** How long a followed conversation that is not open waits for a late batch before its feed
996
+ * ends (ADR-0023). Tests shorten it. */
997
+ followGraceMs?: number;
788
998
  /**
789
999
  * Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
790
1000
  * a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
@@ -800,8 +1010,10 @@ export type CreateAppOptions = {
800
1010
  assembly?: AssemblyDoor;
801
1011
  /**
802
1012
  * The digests of the ConfigMaps this process reads through a mount (ADR-0069), as
803
- * `readMountDigests` reads them. `GET /assembly` reports them, so `jr2 up` can wait for the
804
- * kubelet before it publishes. Absent when not deployed: nothing is mounted.
1013
+ * `readMountDigests` reads them, and of the digests Secrets as it loaded them (ADR-0078,
1014
+ * `loadedDigests`). `GET /assembly` reports them, so `jr2 up` can wait for the kubelet before it
1015
+ * publishes, and `jr2 up` and `jr2 caller rotate` before a token they wrote works. Absent when
1016
+ * not deployed: nothing is mounted.
805
1017
  */
806
1018
  mounts?: () => Promise<Record<string, string>>;
807
1019
  /**
@@ -829,6 +1041,20 @@ export type CreateAppOptions = {
829
1041
  * that build an app straight over a host: those routes then answer 404.
830
1042
  */
831
1043
  charts?: NamedCharts;
1044
+ /** The origins a browser app may call from (ADR-0078), read per request: the current Assembly's
1045
+ * `cors`. Absent: none. */
1046
+ cors?: () => readonly string[];
1047
+ /** The JWT issuers the Console offers a login against (ADR-0032), read per request: the current
1048
+ * Assembly's issuers with `login: true`, each with its `audience` as the client id. Absent: none. */
1049
+ consoleLogin?: () => ReadonlyArray<{ issuer: string; clientId: string }>;
1050
+ /** The Hook's door (ADR-0011, ADR-0078): `POST /hooks/:name` and `GET /hooks/:name`. Absent for
1051
+ * the in-process tests that build an app straight over a host: `POST` then answers 503, as with
1052
+ * no current Assembly, and `GET` answers no line. */
1053
+ hooks?: HookDoor;
1054
+ /** The kept conversations (ADR-0023): `POST /agents/:iid/conversation` writes them, and the
1055
+ * `/runs/:runId/conversations` routes read them. Absent for the in-process tests that build an
1056
+ * app straight over a host: the write answers 404, and a run lists no conversation. */
1057
+ conversations?: ConversationStore;
832
1058
  };
833
1059
 
834
1060
  /**
@@ -840,6 +1066,7 @@ export type CreateAppOptions = {
840
1066
  */
841
1067
  export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOptions = {}): Hono<JR2Env> {
842
1068
  const pingMs = opts.pingMs ?? PING_MS;
1069
+ const followGraceMs = opts.followGraceMs ?? FOLLOW_GRACE_MS;
843
1070
  const app = new Hono<JR2Env>();
844
1071
 
845
1072
  /** Authenticate, or refuse. There is no anonymous principal (ADR-0013) — an open surface would
@@ -847,35 +1074,134 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
847
1074
  const authenticated: MiddlewareHandler<JR2Env> = async (c, next) => {
848
1075
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
849
1076
  // The slot a control request states (ADR-0076): a control token without one resolves to nothing.
850
- const principal = auth(bearerOf(c), c.req.header(SLOT_HEADER));
1077
+ const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
851
1078
  if (!principal) return c.json({ error: "unauthorized" }, 401);
852
1079
  if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
853
1080
  c.set("principal", principal);
854
1081
  return next();
855
1082
  };
856
1083
  /** The principal `authenticated` resolved. An unconfigured `auth` means full trust. */
857
- const principalOf = (c: Context<JR2Env>): Principal => c.get("principal") ?? { kind: "instance" };
1084
+ const principalOf = (c: Context<JR2Env>): Principal =>
1085
+ c.get("principal") ?? { kind: "caller", caller: instanceCaller() };
1086
+ /** The Caller a `guarded` middleware resolved. */
1087
+ const callerOf = (c: Context<JR2Env>): Caller => {
1088
+ const principal = principalOf(c);
1089
+ if (principal.kind !== "caller") throw new Error("callerOf: the route is not in the guarded band");
1090
+ return principal.caller;
1091
+ };
1092
+
1093
+ /**
1094
+ * Authenticate a CALLER, and require one of `scopes` for at least one Workflow (ADR-0014,
1095
+ * ADR-0078). A Sandbox token is a token we minted, but it is never a Caller: on the run surface it
1096
+ * would let a Custodian's credential read every run's context (other features' branches, tickets,
1097
+ * verdicts) and CANCEL any run, and neither is on the Agent's surface (ADR-0013). The Workflow of
1098
+ * the run or the door is the handler's to check, with `mayFor`, because only the handler knows it.
1099
+ * `any` takes every Caller. The Caller's `jr2-on-behalf-of` claim is read here, once.
1100
+ */
1101
+ const guarded =
1102
+ (scopes: Scope | readonly Scope[] | "any"): MiddlewareHandler<JR2Env> =>
1103
+ async (c, next) => {
1104
+ const onBehalfOf = c.req.header(ON_BEHALF_OF_HEADER)?.trim();
1105
+ if (onBehalfOf !== undefined && onBehalfOf.length > ON_BEHALF_OF_MAX) {
1106
+ return c.json(
1107
+ { error: `the ${ON_BEHALF_OF_HEADER} header is longer than ${ON_BEHALF_OF_MAX} characters` },
1108
+ 400,
1109
+ );
1110
+ }
1111
+ let caller: Caller;
1112
+ if (!auth) {
1113
+ caller = instanceCaller(); // no authenticator configured: tests only (see the doc comment)
1114
+ } else {
1115
+ const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
1116
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
1117
+ if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
1118
+ if (principal.kind !== "caller") return c.json({ error: NOT_A_CALLER }, 403);
1119
+ caller = principal.caller;
1120
+ }
1121
+ // A Hook's `onBehalfOf` comes from its signed payload, never from a header (ADR-0078).
1122
+ if (onBehalfOf && caller.via !== "hook") caller = { ...caller, onBehalfOf };
1123
+ if (scopes !== "any") {
1124
+ const list: readonly Scope[] = typeof scopes === "string" ? [scopes] : scopes;
1125
+ if (!list.some((s) => callerHasScope(caller, s))) {
1126
+ return c.json(
1127
+ {
1128
+ error: `${callerLabel(caller)} has no ${list.map((s) => `"${s}"`).join(" or ")} scope for any Workflow (ADR-0078)`,
1129
+ scope: list.length === 1 ? list[0] : list,
1130
+ },
1131
+ 403,
1132
+ );
1133
+ }
1134
+ }
1135
+ c.set("principal", { kind: "caller", caller });
1136
+ return next();
1137
+ };
1138
+
1139
+ /** The 403 for a Caller that may not answer the Gate named `name` (its invoke id) of `workflow`,
1140
+ * or undefined when it may. It names the scope, the Workflow and the Gate (ADR-0078). */
1141
+ const mayAnswer = (c: Context<JR2Env>, workflow: string, name: string): Response | undefined => {
1142
+ const caller = callerOf(c);
1143
+ if (callerMayAnswer(caller, workflow, name)) return undefined;
1144
+ return c.json(
1145
+ {
1146
+ error: `${callerLabel(caller)} needs the "answer" scope for Gate "${name}" of Workflow "${workflow}" (ADR-0078)`,
1147
+ scope: "answer",
1148
+ workflow,
1149
+ gate: name,
1150
+ },
1151
+ 403,
1152
+ );
1153
+ };
1154
+
1155
+ /** The 403 for a Caller without `scope` on `workflow`, or undefined when it has it. It names the
1156
+ * scope and the Workflow that the request needed (ADR-0078). */
1157
+ const mayFor = (c: Context<JR2Env>, scope: Scope, workflow: string): Response | undefined => {
1158
+ const caller = callerOf(c);
1159
+ if (callerMay(caller, scope, workflow)) return undefined;
1160
+ return c.json(
1161
+ {
1162
+ error: `${callerLabel(caller)} needs the "${scope}" scope for Workflow "${workflow}" (ADR-0078)`,
1163
+ scope,
1164
+ workflow,
1165
+ },
1166
+ 403,
1167
+ );
1168
+ };
858
1169
 
859
1170
  /**
860
- * Authenticate, AND require the INSTANCE token (ADR-0014). A Sandbox token is a token we minted,
861
- * so `authenticated` alone lets it through — which on the run surface is too much: it would let a
862
- * Custodian's credential read every run's context (other features' branches, tickets, verdicts) and
863
- * CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
864
- * registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
865
- * the other direction.
1171
+ * Authenticate, AND require the Instance token's Caller (ADR-0014): the routes that no scope
1172
+ * names — the runtime Chart routes and `GET /repos`. A named token is refused here, whatever its
1173
+ * scopes, and so is every principal that is not a Caller.
866
1174
  */
867
1175
  const instanceOnly: MiddlewareHandler<JR2Env> = async (c, next) => {
868
1176
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
869
- const principal = auth(bearerOf(c), c.req.header(SLOT_HEADER));
1177
+ const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
870
1178
  if (!principal) return c.json({ error: "unauthorized" }, 401);
871
1179
  if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
872
- if (principal.kind !== "instance") {
873
- return c.json({ error: "run state and control need the Instance token — a Sandbox token has neither" }, 403);
1180
+ if (principal.kind !== "caller") return c.json({ error: NOT_A_CALLER }, 403);
1181
+ if (!isInstancePrincipal(principal)) {
1182
+ return c.json(
1183
+ { error: `this route needs the Instance token: no scope opens it, so ${callerLabel(principal.caller)} cannot` },
1184
+ 403,
1185
+ );
874
1186
  }
875
1187
  c.set("principal", principal);
876
1188
  return next();
877
1189
  };
878
1190
 
1191
+ // The origins a browser app may call from (ADR-0078): an allowlist, empty by default, read per
1192
+ // request so a publish changes it. The Console is served from this origin and needs no entry. A
1193
+ // preflight from an origin that is not listed gets no CORS header, so the browser refuses it.
1194
+ const allowedOrigins = opts.cors ?? (() => []);
1195
+ app.use(
1196
+ "*",
1197
+ cors({
1198
+ origin: (origin) => (origin !== "" && allowedOrigins().includes(origin) ? origin : null),
1199
+ allowMethods: ["GET", "POST", "PUT", "DELETE"],
1200
+ allowHeaders: ["authorization", "content-type", ON_BEHALF_OF_HEADER, DISPATCHED_BY_HEADER, KEY_ONLY_HEADER],
1201
+ maxAge: 600,
1202
+ }),
1203
+ );
1204
+
879
1205
  // Liveness, and the one place an instance says WHAT IT IS. Unauthenticated because it is the
880
1206
  // readiness probe's target and because identity is not run state — it is the same class of thing
881
1207
  // as the route table, which is public by being served. The CLI probes it to explain a failure it
@@ -884,6 +1210,42 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
884
1210
  app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
885
1211
  app.get("/readyz", (c) => c.json({ ready: true }));
886
1212
 
1213
+ // The request's Caller record (ADR-0078): what a credential may do, asked before it fails. Any
1214
+ // Caller: it says nothing that the credential's holder does not already hold.
1215
+ app.get("/whoami", guarded("any"), (c) => c.json(callerOf(c)));
1216
+
1217
+ // The Hook's door (ADR-0011, ADR-0078). Outside the bands: no bearer token opens it, and the
1218
+ // Hook's own verifier authenticates each delivery, in hooks.ts. The raw bytes, never a parsed
1219
+ // body, because a signature covers the bytes. The door reads the body only after it knows the
1220
+ // Hook, and no more of it than its limit (`limitedBody`).
1221
+ app.post("/hooks/:name", async (c) => {
1222
+ if (!opts.hooks) {
1223
+ return c.json({ error: "the Orchestrator has no current Assembly, so no Hook is declared now — ask again" }, 503);
1224
+ }
1225
+ const answer = await opts.hooks.deliver(c.req.param("name"), c.req.raw.headers, limitedBody(c.req.raw));
1226
+ // The follow-up (the reply to a Slack click) goes after the answer: the service waits for the
1227
+ // answer, not for the post (ADR-0011). It never rejects.
1228
+ const after = answer.after;
1229
+ if (after) setImmediate(() => void after());
1230
+ const status = answer.status as 200 | 400 | 401 | 404 | 413 | 429 | 503;
1231
+ if (answer.body === null) return c.body(null, status);
1232
+ if (typeof answer.body === "string") return c.text(answer.body, status);
1233
+ // As text, not `c.json`: hono's JSON type of a recursive `Json` does not end.
1234
+ return c.body(JSON.stringify(answer.body), status, { "content-type": "application/json" });
1235
+ });
1236
+
1237
+ // A Hook's log (ADR-0011): the Instance token alone. A line can carry a payload's issues and a
1238
+ // handler's words, and no scope names the act of reading what an outside service sent.
1239
+ app.get("/hooks/:name", instanceOnly, (c) => {
1240
+ const raw = c.req.query("limit");
1241
+ const limit = raw === undefined ? HOOK_LOG_DEFAULT : Number(raw);
1242
+ if (!Number.isInteger(limit) || limit < 1 || limit > HOOK_LOG_MAX) {
1243
+ return c.json({ error: `limit is a whole number from 1 to ${HOOK_LOG_MAX}` }, 400);
1244
+ }
1245
+ const name = c.req.param("name");
1246
+ return c.json(opts.hooks ? opts.hooks.log(name, limit) : { hook: name, declared: false, lines: [] });
1247
+ });
1248
+
887
1249
  // Structure, not state: the workflow listing, a template's Machine, and the Console shell are
888
1250
  // unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
889
1251
  // loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
@@ -897,10 +1259,15 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
897
1259
 
898
1260
  // The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
899
1261
  // and a Kit version are identity, the class of thing `/healthz` already serves.
900
- // `mounts` is what this process reads from its mounted ConfigMaps, so a publish can wait until
901
- // the kubelet has delivered the data the converge wrote.
1262
+ // `mounts` is what this process reads from its mounted ConfigMaps and holds from its digests
1263
+ // Secrets, so a command can wait until the kubelet has delivered the data it wrote. A digest of
1264
+ // digests: no token value.
902
1265
  app.get("/assembly", async (c) => {
903
- const view = opts.assembly ? await opts.assembly.view() : { kitVersion: KIT_VERSION, current: null, held: [] };
1266
+ const base = opts.assembly ? await opts.assembly.view() : { kitVersion: KIT_VERSION, current: null, held: [] };
1267
+ // The standing Workflows (ADR-0017): each one's state and how many items it holds, which
1268
+ // `jr2 up` and `jr2 status` print. Counts and run ids, as `held` has: no key and no item.
1269
+ const standing = await host.standingView();
1270
+ const view = standing.length ? { ...base, standing } : base;
904
1271
  return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
905
1272
  });
906
1273
 
@@ -910,7 +1277,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
910
1277
  // its own status, and the old Assembly stays current through every refusal.
911
1278
  app.put("/assembly", async (c) => {
912
1279
  if (auth) {
913
- const principal = auth(bearerOf(c));
1280
+ const principal = await auth(bearerOf(c));
914
1281
  if (!principal) return c.json({ error: "unauthorized" }, 401);
915
1282
  if (principal.kind !== "publisher") {
916
1283
  return c.json(
@@ -929,6 +1296,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
929
1296
  hash: c.req.header("jr2-assembly-hash"),
930
1297
  body: c.req.raw.body,
931
1298
  });
1299
+ // A publish that landed names each standing Workflow's state after it (ADR-0017): `jr2 up` prints
1300
+ // it, the paused ones with how many items they hold.
1301
+ if (answer.status === 200) {
1302
+ const standing = await host.standingView();
1303
+ if (standing.length) return c.json({ ...(answer.body as object), standing }, 200);
1304
+ }
932
1305
  return c.json(answer.body, answer.status);
933
1306
  });
934
1307
 
@@ -997,37 +1370,81 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
997
1370
  *
998
1371
  * A foreign `dispatch` sends `jr2-dispatched-by` (ADR-0071): its key makes a repeated start one
999
1372
  * run, answered `200 { runId, instanceId, existing: true }` where a new one is `201`. The key is
1000
- * asked first, so a re-send answers its run even when the name would be refused now; a key a
1001
- * run of another Workflow carries is 409. A header that does not parse is 400. `jr2 run` and the
1373
+ * asked first, so a re-send answers its run even when the name would be refused now. The key is
1374
+ * scoped to the Workflow named: the same key in another Workflow is another run. A header that
1375
+ * does not parse is 400. `jr2 run` and the
1002
1376
  * Console send none. Any throw that is not a refusal is a defect, 500: a foreign caller reads a
1003
1377
  * 404 as "nothing was started", and a defect may come after a start.
1378
+ *
1379
+ * With `jr2-key-only: true` as well, the start is a key-only ask (ADR-0071): the Instance
1380
+ * Effector sends it, with no body, when the calling slot's schema refuses the body. The door
1381
+ * asks the key as above, and with no run under it answers `200 { existing: false }` and starts
1382
+ * nothing: not a 404, which reads as a refused name, and not a 5xx, which reads as a defect.
1383
+ * The header without `jr2-dispatched-by`, or with another value, is 400.
1004
1384
  */
1005
1385
  const startRun = async (c: Context<JR2Env>, name: string): Promise<Response> => {
1386
+ // The scope first (ADR-0078): a Caller without `start` on this Workflow learns no key's run.
1387
+ const refused = mayFor(c, "start", name);
1388
+ if (refused) return refused;
1006
1389
  const header = dispatchedByOf(c.req.header(DISPATCHED_BY_HEADER));
1007
1390
  if ("error" in header) return c.json({ error: header.error }, 400);
1008
1391
  const { by } = header;
1392
+ const keyOnly = c.req.header(KEY_ONLY_HEADER);
1393
+ if (keyOnly !== undefined && (keyOnly !== "true" || !by)) {
1394
+ return c.json(
1395
+ {
1396
+ error: `the ${KEY_ONLY_HEADER} header is "true", with a ${DISPATCHED_BY_HEADER} header that carries the key (ADR-0071)`,
1397
+ },
1398
+ 400,
1399
+ );
1400
+ }
1009
1401
  if (by) {
1010
- const found = await host.dispatched(by.effect);
1011
- if (found && found.workflow !== name) return c.json({ error: keyConflict(found, name, by.effect) }, 409);
1012
- if (found) return c.json({ ...keyAnswer(found, name, by.effect), existing: true }, 200);
1402
+ const found = await host.dispatched(name, dispatchKeyOf(by));
1403
+ if (found) return c.json({ runId: found.runId, instanceId: found.instanceId, existing: true }, 200);
1404
+ // A key-only ask, and no run has the key: nothing is read, nothing is started.
1405
+ if (keyOnly !== undefined) return c.json({ existing: false }, 200);
1013
1406
  }
1014
1407
  const unrunnable = opts.charts?.get(name)?.unrunnable;
1015
1408
  if (unrunnable !== undefined) return c.json({ error: `named Chart "${name}" cannot run: ${unrunnable}` }, 409);
1409
+ // jr2 starts a standing Workflow's run (ADR-0017): a Caller's start only resumes a paused one,
1410
+ // and a dispatch never starts one. `?drop-held=true` resumes with no held item.
1411
+ if (host.isStanding(name)) {
1412
+ if (by) {
1413
+ return c.json(
1414
+ { error: `"${name}" is a standing Workflow: jr2 starts its run, and a dispatch cannot (ADR-0017)` },
1415
+ 409,
1416
+ );
1417
+ }
1418
+ try {
1419
+ const resumed = await host.resumeStanding(name, callerRecord(callerOf(c)), {
1420
+ dropHeld: c.req.query("drop-held") === "true",
1421
+ });
1422
+ return c.json({ ...resumed, resumed: true }, 201);
1423
+ } catch (err) {
1424
+ if (err instanceof StandingRefused) return c.json({ error: errMessage(err) }, 409);
1425
+ return c.json({ error: errMessage(err) }, 500);
1426
+ }
1427
+ }
1016
1428
  const input = await readJson(c.req.text());
1017
1429
  try {
1018
- const { runId, instanceId, existing } = await host.start(name, input, by ? { dispatchedBy: by } : {});
1430
+ // The run records its Caller (ADR-0078). A foreign dispatch's too: the Caller is the one whose
1431
+ // credential the other Instance sent, with the party it says the start acts for.
1432
+ const startedBy = { kind: "caller" as const, ...callerRecord(callerOf(c)) };
1433
+ const { runId, instanceId, existing } = await host.start(name, input, {
1434
+ ...(by ? { dispatchedBy: by } : {}),
1435
+ startedBy,
1436
+ });
1019
1437
  return existing ? c.json({ runId, instanceId, existing }, 200) : c.json({ runId, instanceId }, 201);
1020
1438
  } catch (err) {
1021
1439
  if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
1022
1440
  if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1023
- if (err instanceof DispatchKeyConflictError) return c.json({ error: errMessage(err) }, 409);
1024
1441
  if (err instanceof UnknownWorkflowError) return c.json({ error: errMessage(err) }, 404);
1025
1442
  return c.json({ error: errMessage(err) }, 500);
1026
1443
  }
1027
1444
  };
1028
1445
 
1029
1446
  // The documented start of a named Chart; `POST /workflows/<wrapper>%2F<name>/runs` is the same.
1030
- app.post("/workflows/:wrapper/charts/:name/runs", instanceOnly, (c) =>
1447
+ app.post("/workflows/:wrapper/charts/:name/runs", guarded("start"), (c) =>
1031
1448
  startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
1032
1449
  );
1033
1450
 
@@ -1070,6 +1487,11 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1070
1487
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
1071
1488
  return c.json({ error: "no JSON resource at / — the Console is the HTML dialect" }, 404);
1072
1489
  });
1490
+ // What the Console needs before a login (ADR-0032): the issuers the current config offers a login
1491
+ // against, each with its client id (the issuer's `audience`). Open, because the page reads it
1492
+ // before it holds a credential, and because both values are public: a public client has no
1493
+ // secret, and an issuer's URL is in every JWT it signs. Read per request, so a publish changes it.
1494
+ app.get("/console/config", (c) => c.json({ login: opts.consoleLogin?.() ?? [] }));
1073
1495
  app.get("/workflows/:name", (c) => {
1074
1496
  // The shell for ANY name — an unknown workflow surfaces in-page via its 404'd /machine fetch.
1075
1497
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
@@ -1098,7 +1520,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1098
1520
  // allowlist not forwarding this route is a second layer, not the control (ADR-0059).
1099
1521
  // A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
1100
1522
  // run starts, and a refusal is 422 with the report. A named Chart rides the path encoded.
1101
- app.post("/workflows/:name/runs", instanceOnly, (c) => startRun(c, c.req.param("name")));
1523
+ app.post("/workflows/:name/runs", guarded("start"), (c) => startRun(c, c.req.param("name")));
1102
1524
 
1103
1525
  // The registered template Machine's structure — what the Console's diagram renders (structure
1104
1526
  // is provider-independent, so the un-`provide()`d template is exactly right).
@@ -1190,6 +1612,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1190
1612
  // A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
1191
1613
  // token.
1192
1614
  if (ev.kind === "child-error") return undefined;
1615
+ // A look's mark (ADR-0011) names a Gate id, which a workflow may compute from its data: behind
1616
+ // the token.
1617
+ if (ev.kind === "looked") return undefined;
1618
+ // A Hook's refusal (ADR-0011) carries a handler's own words: behind the token.
1619
+ if (ev.kind === "hook-refused") return undefined;
1620
+ // A Pool's marks (ADR-0017) name its items' keys, which are the workflow's data: behind the token.
1621
+ if (ev.kind === "pool") return undefined;
1193
1622
  // An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
1194
1623
  if (ev.kind === "effect") return undefined;
1195
1624
  return sseFrame("status", observe(ev.status));
@@ -1257,7 +1686,30 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1257
1686
  });
1258
1687
  });
1259
1688
 
1260
- app.get("/runs", instanceOnly, (c) => c.json(host.list()));
1689
+ // The run listing (ADR-0009): live runs by default, the history with `status`, newest start
1690
+ // first, a page at a time. Of the Workflows that the Caller may read (ADR-0078): a run of another
1691
+ // Workflow is left out, not refused, so one Caller's list says nothing of another's runs.
1692
+ app.get("/runs", guarded("read"), async (c) => {
1693
+ const query = parseRunListQuery(
1694
+ {
1695
+ status: c.req.query("status"),
1696
+ workflow: c.req.query("workflow"),
1697
+ since: c.req.query("since"),
1698
+ cursor: c.req.query("cursor"),
1699
+ limit: c.req.query("limit"),
1700
+ },
1701
+ Date.now(),
1702
+ );
1703
+ if ("error" in query) return c.json({ error: query.error }, 400);
1704
+ const page = await host.history(query, callerOf(c).may.read ?? []);
1705
+ // A run whose kept conversations the space guard deleted says so (ADR-0065).
1706
+ const swept = opts.conversations?.sweptRuns(page.runs.map((r) => r.runId)) ?? new Set<string>();
1707
+ if (swept.size === 0) return c.json(page);
1708
+ return c.json({
1709
+ ...page,
1710
+ runs: page.runs.map((r) => (swept.has(r.runId) ? { ...r, swept: CONVERSATION_SWEPT } : r)),
1711
+ });
1712
+ });
1261
1713
 
1262
1714
  // The Repos as the cluster currently reports them (ADR-0048/0051) — what `jr2 status` renders,
1263
1715
  // and the place ADR-0047's "register the key, the cache agent retries" points at. Instance band,
@@ -1279,13 +1731,24 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1279
1731
  // ambiguous hash, and it holds down what this newly reveals — settled run ids are now visible to
1280
1732
  // an Instance-token holder, which `GET /runs`'s deferred `?all` had withheld. Deliberately not on
1281
1733
  // the open observation routes: prefix probing there would be a run-id enumeration oracle.
1282
- app.get("/runs/resolve", instanceOnly, async (c) => {
1734
+ //
1735
+ // Any Caller, for the Workflows in any of its scopes (ADR-0014): a Caller resolves only the ids it
1736
+ // could address. A Caller with a scope for every Workflow needs no filter; any other reads each
1737
+ // candidate's Workflow, over a wider scan, so the ids of other Workflows do not fill its answer.
1738
+ app.get("/runs/resolve", guarded("any"), async (c) => {
1283
1739
  const prefix = c.req.query("prefix") ?? "";
1284
1740
  if (prefix.length < MIN_RUN_ID_PREFIX) {
1285
1741
  return c.json({ error: `prefix must be at least ${MIN_RUN_ID_PREFIX} characters` }, 400);
1286
1742
  }
1743
+ const caller = callerOf(c);
1744
+ const everyWorkflow = Object.values(caller.may).some((grant) => grant === "all");
1287
1745
  // Scan one past the cap so `truncated` is knowable without a second COUNT.
1288
- const found = await host.candidates(prefix, RESOLVE_LIMIT + 1);
1746
+ const found = everyWorkflow
1747
+ ? await host.candidates(prefix, RESOLVE_LIMIT + 1)
1748
+ : await filterAsync(await host.candidates(prefix, RESOLVE_SCAN), async (id) => {
1749
+ const workflow = (await host.read(id))?.workflow;
1750
+ return workflow !== undefined && callerMayAny(caller, workflow);
1751
+ });
1289
1752
  return c.json({
1290
1753
  prefix,
1291
1754
  runIds: found.slice(0, RESOLVE_LIMIT),
@@ -1293,39 +1756,76 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1293
1756
  });
1294
1757
  });
1295
1758
 
1759
+ // The Gate inbox (ADR-0011): the open Gates of every live run that the Caller may answer, oldest
1760
+ // first, with each Gate's run, Workflow and the time it opened. A Gate the Caller may not answer is
1761
+ // left out, not refused, so one Caller's inbox says nothing of another's Gates (ADR-0078). It never
1762
+ // gives a run's context (ADR-0014): a person who answers a Gate reads its `meta`.
1763
+ app.get("/gates", guarded("answer"), (c) => {
1764
+ const caller = callerOf(c);
1765
+ const workflow = c.req.query("workflow") || undefined;
1766
+ return c.json({ gates: host.gateInbox((w, name) => callerMayAnswer(caller, w, name), workflow) });
1767
+ });
1768
+
1296
1769
  // Read-through (ADR-0009): a completed run's final status lives in the store after the registry
1297
1770
  // drops it, so this serves terminal runs too — only a genuinely unknown run is a 404. The status
1298
1771
  // carries the run's OPEN GATES (ADR-0011) — the discovery listing external callers act on
1299
1772
  // (`jr2 send` menus, UI inbox cards, webhook translators matching on meta). Settled run → [].
1300
- app.get("/runs/:runId", instanceOnly, async (c) => {
1773
+ //
1774
+ // `read` opens the status, `answer` opens the Gates (ADR-0014): a Caller with `answer` alone gets
1775
+ // the run's observation and its Gates, and never its context — a person who answers a Gate reads
1776
+ // the Gate's `meta`, which the author wrote for that person.
1777
+ app.get("/runs/:runId", guarded(["read", "answer"]), async (c) => {
1301
1778
  const runId = c.req.param("runId");
1302
- const status = await host.read(runId);
1303
- return status ? c.json({ ...status, gates: host.gates(runId) }) : c.json({ error: `no run "${runId}"` }, 404);
1779
+ const found = await host.read(runId);
1780
+ if (!found) return c.json({ error: `no run "${runId}"` }, 404);
1781
+ // A run whose kept conversations the space guard deleted says so (ADR-0065).
1782
+ const status = opts.conversations?.sweptRuns([runId]).has(runId) ? { ...found, swept: CONVERSATION_SWEPT } : found;
1783
+ const caller = callerOf(c);
1784
+ const read = callerMay(caller, "read", status.workflow);
1785
+ const answer = callerMay(caller, "answer", status.workflow);
1786
+ if (!read && !answer) return mayFor(c, "read", status.workflow)!;
1787
+ if (!answer) return c.json(status);
1788
+ // Only the Gates that the Caller may answer (ADR-0078): a scope that names one Gate of the
1789
+ // Workflow does not show the others.
1790
+ const gates = host.gates(runId, (name) => callerMayAnswer(caller, status.workflow, name));
1791
+ if (!read) return c.json({ ...observe(status), gates });
1792
+ return c.json({ ...status, gates });
1304
1793
  });
1305
1794
 
1306
1795
  // Gates delivery (ADR-0011): validate the body against the gate's named schema and deliver into
1307
1796
  // the gated state. Unknown gate (never opened, state exited, run settled) → 404; a name the gate
1308
1797
  // doesn't accept, or a payload failing its schema → 400 naming what IS accepted.
1309
1798
  //
1310
- // A Gate is a HUMAN's decision (or a webhook's, or CI's). Every Agent principal — a control
1311
- // token or a fetch token — is refused here unconditionally: this is the exact line between "the
1312
- // Agent reports an outcome" and "the Agent approves its own PR" (ADR-0013, ADR-0076).
1313
- app.post("/runs/:runId/gates/:gate/events", authenticated, async (c) => {
1314
- if (isAgentPrincipal(principalOf(c))) {
1315
- return c.json({ error: "a Sandbox token cannot deliver to a gate — gates are not on the Agent's surface" }, 403);
1316
- }
1799
+ // A Gate is a HUMAN's decision (or a Hook's, or CI's): a Caller with `answer` on the run's
1800
+ // Workflow. Every Agent principal — a control token or a fetch token — is refused by the band,
1801
+ // because it is never a Caller: this is the exact line between "the Agent reports an outcome" and
1802
+ // "the Agent approves its own PR" (ADR-0013, ADR-0076). A run that is not live has no open Gate,
1803
+ // and the delivery below answers it 404.
1804
+ app.post("/runs/:runId/gates/:gate/events", guarded("answer"), async (c) => {
1805
+ const runId = c.req.param("runId");
1806
+ const gate = c.req.param("gate");
1807
+ // The body first: no `await` comes between the scope check and the delivery below, so a Gate
1808
+ // that opens while the body is in flight is checked too (ADR-0078).
1317
1809
  const body = await readJson(c.req.text());
1810
+ const caller = callerOf(c);
1811
+ const live = host.status(runId);
1812
+ if (live) {
1813
+ const refused = mayFor(c, "answer", live.workflow);
1814
+ if (refused) return refused;
1815
+ }
1318
1816
  try {
1319
- host.sendToGate(c.req.param("runId"), c.req.param("gate"), body);
1817
+ // The Gate's name, its invoke id, is what a scope that names single Gates names (ADR-0078).
1818
+ // The delivery checks it against the registration that it delivers to.
1819
+ const opens = live ? (name: string) => callerMayAnswer(caller, live.workflow, name) : undefined;
1820
+ host.sendToGate(runId, gate, body, callerRecord(caller), undefined, opens);
1320
1821
  return c.json({ ok: true });
1321
1822
  } catch (err) {
1823
+ if (err instanceof GateRefused) return mayAnswer(c, live!.workflow, err.gate)!;
1322
1824
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
1323
1825
  if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1324
1826
  // The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
1325
1827
  // names what the Gate takes now, as `GET /runs/:id` lists it.
1326
1828
  if (err instanceof NotTakenError) {
1327
- const runId = c.req.param("runId");
1328
- const gate = c.req.param("gate");
1329
1829
  const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
1330
1830
  return c.json({ error: errMessage(err), acceptsNow }, 409);
1331
1831
  }
@@ -1348,6 +1848,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1348
1848
  // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
1349
1849
  // the operator reads it here, with its message.
1350
1850
  if (ev.kind === "child-error") return sseFrame(ev.kind, ev);
1851
+ // A Gate's look gave its event and the run took it (ADR-0011).
1852
+ if (ev.kind === "looked") return sseFrame(ev.kind, ev);
1853
+ // A Hook's delivery reached an open Gate, and the Gate did not take it (ADR-0011).
1854
+ if (ev.kind === "hook-refused") return sseFrame(ev.kind, ev);
1855
+ // What a Pool of runs did with an item (ADR-0017): started, settled, or a held item refused.
1856
+ if (ev.kind === "pool") return sseFrame(ev.kind, ev);
1351
1857
  // An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
1352
1858
  if (ev.kind === "effect") return sseFrame("effect", ev);
1353
1859
  return sseFrame("status", ev.status);
@@ -1355,12 +1861,19 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1355
1861
 
1356
1862
  // SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
1357
1863
  // then live until the terminal transition or client abort). A run that has already settled streams
1358
- // its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
1359
- app.get("/runs/:runId/events", instanceOnly, async (c) => {
1864
+ // its final status once and closes (so `jr2 watch` works on a finished run). Unknown run → 404.
1865
+ app.get("/runs/:runId/events", guarded("read"), async (c) => {
1360
1866
  const runId = c.req.param("runId");
1361
- if (host.status(runId) === undefined) {
1867
+ const live = host.status(runId);
1868
+ if (live) {
1869
+ const refused = mayFor(c, "read", live.workflow);
1870
+ if (refused) return refused;
1871
+ }
1872
+ if (live === undefined) {
1362
1873
  const finalStatus = await host.read(runId);
1363
1874
  if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
1875
+ const refused = mayFor(c, "read", finalStatus.workflow);
1876
+ if (refused) return refused;
1364
1877
  return streamSSE(c, async (stream) => {
1365
1878
  await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
1366
1879
  await sayGoneIfParked(stream, finalStatus);
@@ -1408,8 +1921,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1408
1921
  // It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
1409
1922
  // next restore, and its Workspaces are destroyed unless the body says `keep: true`. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
1410
1923
  // deliberately not on the wire.
1411
- app.post("/runs/:runId/events", instanceOnly, async (c) => {
1924
+ app.post("/runs/:runId/events", guarded("cancel"), async (c) => {
1412
1925
  const runId = c.req.param("runId");
1926
+ const known = await host.read(runId);
1927
+ if (known) {
1928
+ const refused = mayFor(c, "cancel", known.workflow);
1929
+ if (refused) return refused;
1930
+ }
1413
1931
  const body = (await readJson(c.req.text())) as RunEventBody;
1414
1932
  if (body.type !== "CANCEL") {
1415
1933
  return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
@@ -1419,13 +1937,202 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1419
1937
  return c.json({ error: `"keep" must be a boolean` }, 400);
1420
1938
  }
1421
1939
  try {
1422
- await host.cancel(runId, { keep: body.keep === true });
1940
+ await host.cancel(runId, { keep: body.keep === true, by: callerRecord(callerOf(c)) });
1423
1941
  return c.json({ ok: true });
1424
1942
  } catch (err) {
1425
1943
  return c.json({ error: errMessage(err) }, 404);
1426
1944
  }
1427
1945
  });
1428
1946
 
1947
+ // Steering messages (ADR-0002): free text from a Caller to one Agent slot of a run, which the
1948
+ // Agent hears once, at a step boundary of its open Turn or at its next Turn's admission. The list
1949
+ // says where a message can go, so a Caller never guesses an Instance ID; the same scope opens both.
1950
+ app.get("/runs/:runId/agents", guarded("steer"), async (c) => {
1951
+ const runId = c.req.param("runId");
1952
+ const known = await host.read(runId);
1953
+ if (!known) return c.json({ error: `no run "${runId}"` }, 404);
1954
+ const refused = mayFor(c, "steer", known.workflow);
1955
+ if (refused) return refused;
1956
+ return c.json(host.agents(runId) ?? []);
1957
+ });
1958
+
1959
+ app.post("/runs/:runId/steering", guarded("steer"), async (c) => {
1960
+ const runId = c.req.param("runId");
1961
+ const known = await host.read(runId);
1962
+ if (!known) return c.json({ error: `no run "${runId}"` }, 404);
1963
+ const refused = mayFor(c, "steer", known.workflow);
1964
+ if (refused) return refused;
1965
+ const body = (await readJson(c.req.text())) as { agent?: unknown; text?: unknown };
1966
+ if (typeof body.agent !== "string" || body.agent === "") {
1967
+ return c.json(
1968
+ { error: `a Steering message names an Agent slot: { agent, text } (see GET /runs/${runId}/agents)` },
1969
+ 400,
1970
+ );
1971
+ }
1972
+ if (typeof body.text !== "string" || body.text.trim() === "") {
1973
+ return c.json({ error: "a Steering message has a text: { agent, text }" }, 400);
1974
+ }
1975
+ if (new TextEncoder().encode(body.text).length > STEERING_TEXT_MAX) {
1976
+ return c.json({ error: `the text is longer than ${STEERING_TEXT_MAX} bytes (16 KiB)` }, 413);
1977
+ }
1978
+ try {
1979
+ return c.json(host.steer(runId, body.agent, body.text, callerRecord(callerOf(c))), 202);
1980
+ } catch (err) {
1981
+ if (err instanceof SteeringRefused) {
1982
+ return c.json({ error: err.message, ...(err.agents !== undefined ? { agents: err.agents } : {}) }, err.status);
1983
+ }
1984
+ throw err;
1985
+ }
1986
+ });
1987
+
1988
+ // ---- The kept conversations (ADR-0023) ----------------------------------------------------------
1989
+ // What the Harness sent of each conversation while its Turns ran, for as long as the run is kept
1990
+ // (ADR-0065). `read` opens all three, as it opens the run's status (ADR-0014). A conversation is
1991
+ // open while a Turn of it is registered on that live run, and the host is not going away.
1992
+ const conversationOpen = (runId: string, instanceId: string): boolean =>
1993
+ !host.stopping && host.agentRun(instanceId) === runId;
1994
+
1995
+ /** The run, read and in the Caller's scope, or the answer that refuses it. An Instance ID that is
1996
+ * not of the run is refused before the store is read: the scope is the run's Workflow. */
1997
+ const conversationsRun = async (
1998
+ c: Context<JR2Env>,
1999
+ runId: string,
2000
+ instanceId?: string,
2001
+ ): Promise<Response | undefined> => {
2002
+ if (instanceId !== undefined && !instanceId.startsWith(`${runId}/`)) {
2003
+ return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
2004
+ }
2005
+ const known = await host.read(runId);
2006
+ if (!known) return c.json({ error: `no run "${runId}"` }, 404);
2007
+ return mayFor(c, "read", known.workflow);
2008
+ };
2009
+
2010
+ app.get("/runs/:runId/conversations", guarded("read"), async (c) => {
2011
+ const runId = c.req.param("runId");
2012
+ const refused = await conversationsRun(c, runId);
2013
+ if (refused) return refused;
2014
+ const kept = opts.conversations?.list(runId) ?? [];
2015
+ // Each conversation's Agent slot, from the run's admission ledger: the address `jr2 logs` and
2016
+ // `jr2 steer` take (ADR-0002, ADR-0009). Absent for a row that the ledger does not name.
2017
+ const slots = await Promise.all(kept.map(({ instanceId }) => host.conversationPlace(instanceId)));
2018
+ return c.json(
2019
+ kept.map(({ agent, instanceId, bytes, cut, swept }, i) => ({
2020
+ agent,
2021
+ ...(slots[i]?.slot !== undefined ? { slot: slots[i].slot } : {}),
2022
+ instanceId,
2023
+ open: conversationOpen(runId, instanceId),
2024
+ bytes,
2025
+ cut,
2026
+ ...(swept !== undefined ? { swept } : {}),
2027
+ })),
2028
+ );
2029
+ });
2030
+
2031
+ /** `?after=<seq>` (-1, the default, is from the first entry) and `?limit=`, or the 400. */
2032
+ const entryQuery = (c: Context<JR2Env>): { after: number; limit: number } | { error: string } => {
2033
+ const after = c.req.query("after");
2034
+ const limit = c.req.query("limit");
2035
+ const a = after === undefined ? -1 : Number(after);
2036
+ const l = limit === undefined ? CONVERSATION_PAGE_DEFAULT : Number(limit);
2037
+ if (!Number.isInteger(a) || a < -1)
2038
+ return { error: `after is a sequence number (an integer of -1 or more), not "${after}"` };
2039
+ if (!Number.isInteger(l) || l < 1 || l > CONVERSATION_PAGE_MAX) {
2040
+ return { error: `limit is an integer from 1 to ${CONVERSATION_PAGE_MAX}, not "${limit}"` };
2041
+ }
2042
+ return { after: a, limit: l };
2043
+ };
2044
+
2045
+ app.get("/runs/:runId/conversations/:instanceId", guarded("read"), async (c) => {
2046
+ const runId = c.req.param("runId");
2047
+ const instanceId = c.req.param("instanceId");
2048
+ const refused = await conversationsRun(c, runId, instanceId);
2049
+ if (refused) return refused;
2050
+ const query = entryQuery(c);
2051
+ if ("error" in query) return c.json({ error: query.error }, 400);
2052
+ const store = opts.conversations;
2053
+ if (!store?.conversation(runId, instanceId)) {
2054
+ return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
2055
+ }
2056
+ return c.json(store.entries(instanceId, query.after, query.limit));
2057
+ });
2058
+
2059
+ // Follow one conversation: what is kept after `after`, then each new entry as it is kept. It ends
2060
+ // when the conversation is not open (its Turn is not registered, or its run ended) and no entry
2061
+ // came for a short time, so a batch that the Harness sends at its Turn's end is still read; and
2062
+ // when the conversation is cut or swept.
2063
+ app.get("/runs/:runId/conversations/:instanceId/events", guarded("read"), async (c) => {
2064
+ const runId = c.req.param("runId");
2065
+ const instanceId = c.req.param("instanceId");
2066
+ const refused = await conversationsRun(c, runId, instanceId);
2067
+ if (refused) return refused;
2068
+ const query = entryQuery(c);
2069
+ if ("error" in query) return c.json({ error: query.error }, 400);
2070
+ const store = opts.conversations;
2071
+ const known = store?.conversation(runId, instanceId);
2072
+ // A conversation that is open and has no entry kept yet is followed too.
2073
+ if (!store || (!known && !conversationOpen(runId, instanceId))) {
2074
+ return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
2075
+ }
2076
+ return streamSSE(c, async (stream) => {
2077
+ await new Promise<void>((resolve) => {
2078
+ const exit = closer(resolve);
2079
+ const sub = subscriber(stream);
2080
+ let last = query.after;
2081
+ let quietSince = Date.now();
2082
+ const send = (entries: ConversationEntry[]) => {
2083
+ for (const entry of entries) {
2084
+ if (entry.seq <= last) continue;
2085
+ last = entry.seq;
2086
+ quietSince = Date.now();
2087
+ void sub.send(`event: entry\ndata: ${JSON.stringify(entry)}\n\n`);
2088
+ }
2089
+ };
2090
+ // Subscribe before the replay, so an entry kept between the two is not lost; `last` drops
2091
+ // what both give.
2092
+ const held: ConversationEntry[] = [];
2093
+ let replaying = true;
2094
+ exit.onExit(
2095
+ store.follow(instanceId, (entries) => {
2096
+ if (replaying) held.push(...entries);
2097
+ else send(entries);
2098
+ }),
2099
+ );
2100
+ for (;;) {
2101
+ const page = store.entries(instanceId, last, CONVERSATION_PAGE_MAX);
2102
+ send(page);
2103
+ if (page.length < CONVERSATION_PAGE_MAX) break;
2104
+ }
2105
+ replaying = false;
2106
+ send(held);
2107
+ let ending = false;
2108
+ const end = () => {
2109
+ if (ending) return;
2110
+ ending = true;
2111
+ const now = store.conversation(runId, instanceId);
2112
+ void sub
2113
+ .send(
2114
+ `event: end\ndata: ${JSON.stringify({ cut: now?.cut ?? false, ...(now?.swept ? { swept: now.swept } : {}) })}\n\n`,
2115
+ )
2116
+ .then(exit.done);
2117
+ };
2118
+ const check = () => {
2119
+ if (stream.aborted || stream.closed) return exit.done();
2120
+ // The Orchestrator is going away: the server waits for this request, so end it now.
2121
+ if (host.stopping) return end();
2122
+ const now = store.conversation(runId, instanceId);
2123
+ if (now?.cut || now?.swept) return end();
2124
+ if (!conversationOpen(runId, instanceId) && Date.now() - quietSince >= followGraceMs) end();
2125
+ };
2126
+ const timer = setInterval(check, Math.min(followGraceMs, 1_000));
2127
+ exit.onExit(() => clearInterval(timer));
2128
+ exit.onExit(host.onClose(check));
2129
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
2130
+ stream.onAbort(exit.done);
2131
+ check();
2132
+ });
2133
+ });
2134
+ });
2135
+
1429
2136
  // ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
1430
2137
  // Served to ONE caller: the Agent's Harness, through the Custodian in its pod. The Harness renders
1431
2138
  // `surface` as the Menu and turns a pick into an `events` POST. The Orchestrator therefore keeps
@@ -1482,8 +2189,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1482
2189
  const { surface, error } = agentRegistration(c);
1483
2190
  if (error) return error;
1484
2191
  host.agentSurfaceServed(c.req.param("instanceId"));
1485
- const notices = await host.boundaryNotices(c.req.param("instanceId"));
1486
- return c.json({ ...surface, notices });
2192
+ // A notice and a Steering message ride the read apart (ADR-0062): the Harness frames a message
2193
+ // by its author. One acknowledgment hears both, by the ids the read named.
2194
+ const held = await host.boundaryNotices(c.req.param("instanceId"));
2195
+ const notices = held.filter((h) => h.notice.kind !== "steering");
2196
+ const steering = held.flatMap((h) => (h.notice.kind === "steering" ? [{ id: h.id, message: h.notice }] : []));
2197
+ return c.json({ ...surface, notices, ...(steering.length > 0 ? { steering } : {}) });
1487
2198
  });
1488
2199
 
1489
2200
  // The Harness appended a step boundary's notices to the Turn (ADR-0062, ADR-0076): they are heard
@@ -1500,6 +2211,56 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1500
2211
  return c.json({ heard: host.noticesHeard(c.req.param("instanceId"), heard as string[]) });
1501
2212
  });
1502
2213
 
2214
+ // A batch of a conversation's entries (ADR-0023), from the Harness that runs it, through its
2215
+ // Custodian. Only the control token of the pod that the run's admission ledger names, for the
2216
+ // ledger's slot: the ledger and not the live registration, because the ledger outlives the Turn
2217
+ // and the run, and the last batch of a Turn often comes after its sweep. The Instance token does
2218
+ // not open it: a holder of it can speak as an Agent, but it cannot write an Agent's kept
2219
+ // conversation. A batch that is sent again adds nothing (the store keeps each `seq` once).
2220
+ app.post("/agents/:instanceId/conversation", authenticated, async (c) => {
2221
+ const instanceId = c.req.param("instanceId");
2222
+ const store = opts.conversations;
2223
+ if (!store) return c.json({ error: "this Orchestrator keeps no conversation" }, 404);
2224
+ const principal = principalOf(c);
2225
+ if (auth && principal.kind !== "agent") {
2226
+ return c.json(
2227
+ { error: `only the Harness of the conversation's pod writes it: this token cannot (ADR-0023)` },
2228
+ 403,
2229
+ );
2230
+ }
2231
+ const text = await c.req.text();
2232
+ if (Buffer.byteLength(text) > CONVERSATION_BATCH_MAX) {
2233
+ return c.json({ error: `a batch is at most ${CONVERSATION_BATCH_MAX} bytes: send fewer entries` }, 413);
2234
+ }
2235
+ const parsed = conversationBatch.safeParse(await readJson(Promise.resolve(text)));
2236
+ if (!parsed.success) {
2237
+ return c.json(
2238
+ {
2239
+ error: `a batch is { entries: Array<{ id, seq, at, kind, text }> }: ${parsed.error.issues[0]?.message ?? "no entries"}`,
2240
+ },
2241
+ 400,
2242
+ );
2243
+ }
2244
+ const place = await host.conversationPlace(instanceId);
2245
+ if (!place) return c.json({ error: `no run for instance "${instanceId}"` }, 404);
2246
+ if (place.agent === undefined) {
2247
+ // A live run whose ledger does not name the conversation yet: the batch came before the
2248
+ // admission's save. The Harness sends it again.
2249
+ if (place.live) return c.json({ error: `the run does not name instance "${instanceId}" yet — send again` }, 409);
2250
+ return c.json({ error: `run "${place.runId}" has no conversation "${instanceId}"` }, 404);
2251
+ }
2252
+ if (
2253
+ principal.kind === "agent" &&
2254
+ (place.sandbox === undefined ||
2255
+ place.sandbox !== principal.sandbox ||
2256
+ place.slot === undefined ||
2257
+ place.slot !== principal.slot)
2258
+ ) {
2259
+ return c.json({ error: `this token cannot write the conversation of instance "${instanceId}"` }, 403);
2260
+ }
2261
+ return c.json(store.append(place.runId, instanceId, place.agent, parsed.data.entries));
2262
+ });
2263
+
1503
2264
  // The Agent's pick, delivered into the state that invoked it. The receipt describes itself
1504
2265
  // (ADR-0024): what was delivered, and whether that ended the turn — which the Harness renders as
1505
2266
  // prose. Its `deliveryId` makes an outstanding pick addressable after the fact: its answer is
@@ -1599,3 +2360,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1599
2360
 
1600
2361
  return app;
1601
2362
  }
2363
+
2364
+ /**
2365
+ * The body of a request as the Hook's door reads it (ADR-0011): at most `max` bytes, and
2366
+ * `undefined` when the body is longer. A `content-length` above `max` reads nothing; else the
2367
+ * stream is read until it ends or passes `max`, and then it is cancelled. So an unbounded body is
2368
+ * never kept in memory.
2369
+ */
2370
+ export function limitedBody(req: Request): (max: number) => Promise<Uint8Array | undefined> {
2371
+ return async (max) => {
2372
+ const declared = Number(req.headers.get("content-length") ?? "");
2373
+ if (Number.isFinite(declared) && declared > max) return undefined;
2374
+ if (req.body === null) return new Uint8Array();
2375
+ const reader = req.body.getReader();
2376
+ const chunks: Uint8Array[] = [];
2377
+ let size = 0;
2378
+ for (;;) {
2379
+ const { done, value } = await reader.read();
2380
+ if (done) break;
2381
+ size += value.byteLength;
2382
+ if (size > max) {
2383
+ await reader.cancel().catch(() => {});
2384
+ return undefined;
2385
+ }
2386
+ chunks.push(value);
2387
+ }
2388
+ const out = new Uint8Array(size);
2389
+ let at = 0;
2390
+ for (const chunk of chunks) {
2391
+ out.set(chunk, at);
2392
+ at += chunk.byteLength;
2393
+ }
2394
+ return out;
2395
+ };
2396
+ }