@jr2/orchestrator 0.5.1 → 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 (350) 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 +142 -29
  14. package/docs/concepts/architecture.md +60 -34
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +13 -8
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +7 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +133 -50
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +14 -4
  23. package/docs/concepts/pools-and-sources.md +193 -17
  24. package/docs/concepts/secrets-and-trust.md +186 -62
  25. package/docs/concepts/the-menu.md +14 -0
  26. package/docs/concepts/workspaces.md +135 -56
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
  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/get-started/have-your-assistant-write-a-machine.md +111 -40
  32. package/docs/get-started/install.md +4 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +72 -15
  34. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  35. package/docs/guides/answer-a-gate.md +74 -37
  36. package/docs/guides/bind-open-parts.md +29 -12
  37. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  38. package/docs/guides/cancel-a-run.md +26 -16
  39. package/docs/guides/cut-a-worktree.md +560 -0
  40. package/docs/guides/fix-a-failed-converge.md +19 -7
  41. package/docs/guides/give-a-caller-a-scope.md +203 -0
  42. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  43. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  44. package/docs/guides/let-an-agent-write-a-chart.md +14 -7
  45. package/docs/guides/look-inside-a-sandbox.md +115 -42
  46. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  47. package/docs/guides/open-the-console.md +63 -14
  48. package/docs/guides/push-and-sign-a-branch.md +67 -21
  49. package/docs/guides/read-a-conversation.md +225 -0
  50. package/docs/guides/run-a-chart-by-name.md +17 -16
  51. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  52. package/docs/guides/run-one-worker-per-item.md +34 -27
  53. package/docs/guides/set-an-agents-access-and-dials.md +69 -22
  54. package/docs/guides/size-a-workspace.md +19 -6
  55. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  56. package/docs/guides/start-a-run-from-a-run.md +18 -16
  57. package/docs/guides/start-a-run.md +24 -18
  58. package/docs/guides/steer-an-agent.md +332 -0
  59. package/docs/guides/use-a-hosted-provider.md +7 -1
  60. package/docs/guides/watch-a-run.md +146 -117
  61. package/docs/guides/write-a-hook-kind.md +238 -0
  62. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  63. package/docs/guides/write-a-workflow.md +18 -8
  64. package/docs/llms.txt +110 -23
  65. package/docs/reference/authoring-surface/agent.md +35 -12
  66. package/docs/reference/authoring-surface/answer.md +20 -4
  67. package/docs/reference/authoring-surface/arg.md +28 -6
  68. package/docs/reference/authoring-surface/basic.md +1 -1
  69. package/docs/reference/authoring-surface/bearer.md +1 -1
  70. package/docs/reference/authoring-surface/branchname.md +15 -0
  71. package/docs/reference/authoring-surface/carry.md +23 -0
  72. package/docs/reference/authoring-surface/chartschema.md +1 -1
  73. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  74. package/docs/reference/authoring-surface/command.md +37 -22
  75. package/docs/reference/authoring-surface/customize.md +4 -1
  76. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  77. package/docs/reference/authoring-surface/defineevent.md +1 -1
  78. package/docs/reference/authoring-surface/dispatch.md +5 -4
  79. package/docs/reference/authoring-surface/fail.md +18 -5
  80. package/docs/reference/authoring-surface/file.md +37 -17
  81. package/docs/reference/authoring-surface/frompick.md +1 -1
  82. package/docs/reference/authoring-surface/gate.md +118 -0
  83. package/docs/reference/authoring-surface/group-agents.md +15 -0
  84. package/docs/reference/authoring-surface/group-charts.md +16 -0
  85. package/docs/reference/authoring-surface/group-composition.md +25 -0
  86. package/docs/reference/authoring-surface/group-config.md +17 -0
  87. package/docs/reference/authoring-surface/group-effects.md +48 -0
  88. package/docs/reference/authoring-surface/group-failure.md +13 -0
  89. package/docs/reference/authoring-surface/group-gates.md +16 -0
  90. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  91. package/docs/reference/authoring-surface/group-steering.md +12 -0
  92. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  93. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  94. package/docs/reference/authoring-surface/http.md +109 -9
  95. package/docs/reference/authoring-surface/isfailure.md +1 -1
  96. package/docs/reference/authoring-surface/isready.md +14 -0
  97. package/docs/reference/authoring-surface/jr2setup.md +14 -4
  98. package/docs/reference/authoring-surface/mcp.md +107 -0
  99. package/docs/reference/authoring-surface/open.md +41 -8
  100. package/docs/reference/authoring-surface/openbody.md +1 -1
  101. package/docs/reference/authoring-surface/paramsof.md +23 -5
  102. package/docs/reference/authoring-surface/pool.md +79 -11
  103. package/docs/reference/authoring-surface/push.md +31 -13
  104. package/docs/reference/authoring-surface/readyof.md +21 -0
  105. package/docs/reference/authoring-surface/reread.md +19 -0
  106. package/docs/reference/authoring-surface/secret.md +26 -5
  107. package/docs/reference/authoring-surface/sentby.md +1 -1
  108. package/docs/reference/authoring-surface/skip.md +22 -0
  109. package/docs/reference/authoring-surface/source.md +59 -4
  110. package/docs/reference/authoring-surface/steer.md +93 -0
  111. package/docs/reference/authoring-surface/text.md +1 -1
  112. package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
  113. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  114. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  115. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  116. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  117. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  118. package/docs/reference/authoring-surface/type-binding.md +6 -7
  119. package/docs/reference/authoring-surface/type-caller.md +40 -0
  120. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  121. package/docs/reference/authoring-surface/type-carry.md +19 -0
  122. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  123. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  124. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  125. package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
  126. package/docs/reference/authoring-surface/type-customize.md +20 -3
  127. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  128. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  129. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  130. package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
  131. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  132. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  133. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  134. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  135. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  136. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  137. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  138. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  139. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  140. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  141. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  142. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  143. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  144. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  145. package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
  146. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  147. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
  149. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  150. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  151. package/docs/reference/authoring-surface/type-lends.md +5 -1
  152. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  153. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  154. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  155. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  156. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  157. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  158. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  159. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  160. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  161. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  162. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  163. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  164. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  165. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  166. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  167. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  168. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  169. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  170. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  171. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  172. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  173. package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
  174. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  175. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  176. package/docs/reference/authoring-surface/type-reposlot.md +6 -6
  177. package/docs/reference/authoring-surface/type-reread.md +18 -0
  178. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  179. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  180. package/docs/reference/authoring-surface/type-sender.md +26 -1
  181. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  182. package/docs/reference/authoring-surface/type-size.md +1 -1
  183. package/docs/reference/authoring-surface/type-skip.md +19 -0
  184. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  185. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  186. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  187. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  188. package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
  189. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  190. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  191. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  192. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  193. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  194. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  195. package/docs/reference/authoring-surface/type-workspaced.md +30 -9
  196. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  197. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  198. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  199. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  200. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  201. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  202. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  203. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  204. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  205. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  206. package/docs/reference/authoring-surface/workspace.md +66 -38
  207. package/docs/reference/authoring-surface/worktree.md +71 -0
  208. package/docs/reference/authoring-surface.md +88 -147
  209. package/docs/reference/cli.md +413 -74
  210. package/docs/reference/config.md +248 -6
  211. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  212. package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
  213. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  214. package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
  215. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  216. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  217. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  218. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  219. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  220. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
  221. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
  222. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  223. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
  224. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  225. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
  226. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  227. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
  228. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
  229. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  230. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
  231. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
  232. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  233. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
  234. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
  235. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  236. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  237. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
  238. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  239. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
  240. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  241. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
  242. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
  243. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  244. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  245. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  246. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  247. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  248. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  249. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
  250. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
  251. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
  252. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
  253. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
  254. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  255. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  256. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
  257. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
  258. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
  259. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  260. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
  261. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  262. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  263. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  264. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
  265. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
  266. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  267. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  268. 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
  269. package/docs/reference/decisions.md +9 -4
  270. package/docs/reference/glossary.md +138 -45
  271. package/docs/reference/hooks.md +1077 -0
  272. package/docs/reference/http-api.md +988 -123
  273. package/docs/reference/machines.md +0 -1
  274. package/docs/why.md +7 -5
  275. package/package.json +10 -3
  276. package/src/actor.ts +194 -42
  277. package/src/agent.ts +56 -6
  278. package/src/ambient.ts +3 -5
  279. package/src/assembly-checks.ts +530 -4
  280. package/src/callers.ts +245 -0
  281. package/src/chart.ts +238 -26
  282. package/src/charts.ts +9 -2
  283. package/src/config.ts +566 -7
  284. package/src/conversations.ts +453 -0
  285. package/src/custodian.lua +17 -5
  286. package/src/custodian.ts +257 -77
  287. package/src/customize.ts +154 -7
  288. package/src/dispatch-effect.ts +142 -26
  289. package/src/durability.ts +2 -0
  290. package/src/duration.ts +20 -0
  291. package/src/effect-checks.ts +52 -4
  292. package/src/effect.ts +401 -132
  293. package/src/effector-wire.ts +278 -30
  294. package/src/fail.ts +17 -4
  295. package/src/gate-brand.ts +16 -0
  296. package/src/gate.ts +634 -48
  297. package/src/harness-client.ts +84 -35
  298. package/src/harness-env.ts +33 -0
  299. package/src/held-secrets.ts +197 -39
  300. package/src/holds.ts +5 -2
  301. package/src/hook-kinds/discord.ts +92 -0
  302. package/src/hook-kinds/door.ts +107 -0
  303. package/src/hook-kinds/github.ts +89 -0
  304. package/src/hook-kinds/gitlab.ts +72 -0
  305. package/src/hook-kinds/hook.ts +7 -0
  306. package/src/hook-kinds/index.ts +465 -0
  307. package/src/hook-kinds/slack.ts +151 -0
  308. package/src/hook-kinds/webhook.ts +113 -0
  309. package/src/hooks.ts +506 -0
  310. package/src/http-effect.ts +164 -70
  311. package/src/http.ts +933 -99
  312. package/src/index.ts +30 -5
  313. package/src/instance.ts +196 -26
  314. package/src/internal.ts +23 -3
  315. package/src/kube-client.ts +6 -3
  316. package/src/machine-doc.ts +29 -5
  317. package/src/mcp-effect.ts +424 -0
  318. package/src/model-keys.ts +24 -0
  319. package/src/mount-digest.ts +41 -7
  320. package/src/names.ts +82 -7
  321. package/src/notices.ts +576 -59
  322. package/src/open-body.ts +110 -30
  323. package/src/open-effect.ts +17 -4
  324. package/src/open.ts +40 -7
  325. package/src/parts.ts +125 -35
  326. package/src/party.ts +35 -1
  327. package/src/pick.ts +43 -9
  328. package/src/pool.ts +1172 -172
  329. package/src/registration.ts +249 -17
  330. package/src/repo-identity.ts +7 -3
  331. package/src/repos.ts +20 -2
  332. package/src/run-host.ts +1614 -190
  333. package/src/run-list.ts +191 -0
  334. package/src/sandbox-effect.ts +217 -66
  335. package/src/sandbox-kube.ts +116 -21
  336. package/src/secret-placement.ts +248 -35
  337. package/src/server.ts +53 -4
  338. package/src/setup.ts +158 -38
  339. package/src/snapshot-store.ts +565 -48
  340. package/src/steer.ts +111 -0
  341. package/src/stub-harness.ts +6 -2
  342. package/src/tokens.ts +362 -46
  343. package/src/vocabulary.ts +23 -4
  344. package/src/wire.ts +27 -10
  345. package/src/workspace.ts +314 -259
  346. package/src/worktree.ts +249 -0
  347. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  348. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
  349. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  350. 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,13 +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
- import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
65
73
  import {
66
- DispatchKeyConflictError,
67
- keyAnswer,
68
- keyConflict,
74
+ callerHasScope,
75
+ callerMay,
76
+ callerMayAnswer,
77
+ callerMayAny,
78
+ instanceCaller,
79
+ isInstancePrincipal,
80
+ mayAskForSandbox,
81
+ mayDeliverToAgent,
82
+ ON_BEHALF_OF_HEADER,
83
+ ON_BEHALF_OF_MAX,
84
+ SLOT_HEADER,
85
+ type Authenticator,
86
+ type Caller,
87
+ type Principal,
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";
92
+ import {
93
+ GateRefused,
69
94
  observe,
95
+ StandingRefused,
96
+ SteeringRefused,
70
97
  UnknownWorkflowError,
71
98
  type RunFeedEvent,
72
99
  type RunHost,
@@ -76,6 +103,7 @@ import {
76
103
  import { KIT_VERSION } from "./config.ts";
77
104
  import type { AssemblyDoor } from "./assembly.ts";
78
105
  import { ChartRefusal } from "./chart.ts";
106
+ import { HOOK_LOG_DEFAULT, HOOK_LOG_MAX, type HookDoor } from "./hooks.ts";
79
107
  import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
80
108
 
81
109
  // ---- The route table (ADR-0072) -------------------------------------------------------------
@@ -85,9 +113,12 @@ import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./
85
113
  // the app's own registrations both ways and checks each row's band on the wire, so the table
86
114
  // cannot drift from the code by forgetting.
87
115
 
88
- /** The access bands of ADR-0014, named for the credential each requires, and the publish door's
89
- * band (ADR-0069): the publish token alone. */
90
- 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";
91
122
 
92
123
  /** One route of the Orchestrator's HTTP API (ADR-0072). */
93
124
  export type HttpRoute = {
@@ -95,6 +126,10 @@ export type HttpRoute = {
95
126
  /** In Hono syntax, exactly as registered: `:param`, and `:param{regex}` for a constrained one. */
96
127
  path: string;
97
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";
98
133
  /** One line: what the route does. */
99
134
  summary: string;
100
135
  /** The request body, as a type name or in short; absent when the route reads none. */
@@ -127,6 +162,38 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
127
162
  stream: false,
128
163
  adrs: ["0009"],
129
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
+ },
130
197
  {
131
198
  method: "GET",
132
199
  path: "/workflows",
@@ -141,10 +208,11 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
141
208
  method: "GET",
142
209
  path: "/assembly",
143
210
  band: "open",
144
- summary: "The current Assembly and the Assemblies that live runs hold.",
145
- 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[] }",
146
214
  stream: false,
147
- adrs: ["0069"],
215
+ adrs: ["0017", "0069"],
148
216
  },
149
217
  {
150
218
  method: "PUT",
@@ -154,7 +222,7 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
154
222
  "Publish the next Assembly. `jr2 up` calls it, and nothing else. A refused Assembly leaves the current one current.",
155
223
  body: "the Assembly as a tar stream, with the headers `jr2-kit-version` and `jr2-assembly-hash`",
156
224
  response:
157
- "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",
158
226
  stream: false,
159
227
  adrs: ["0069"],
160
228
  },
@@ -189,12 +257,13 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
189
257
  {
190
258
  method: "POST",
191
259
  path: "/workflows/:wrapper/charts/:name/runs",
192
- band: "instanceOnly",
260
+ band: "guarded",
261
+ scope: "start",
193
262
  summary: "Start a run of a named Chart, as `POST /workflows/<wrapper>%2F<name>/runs` does.",
194
263
  body: "the Chart wrapper's input, without the Chart",
195
264
  response: "201 { runId, instanceId }; 409 when the named Chart cannot run now",
196
265
  stream: false,
197
- adrs: ["0070"],
266
+ adrs: ["0070", "0078"],
198
267
  },
199
268
  {
200
269
  method: "GET",
@@ -214,6 +283,16 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
214
283
  stream: false,
215
284
  adrs: ["0032"],
216
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
+ },
217
296
  {
218
297
  method: "GET",
219
298
  path: "/workflows/:name",
@@ -226,14 +305,15 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
226
305
  {
227
306
  method: "POST",
228
307
  path: "/workflows/:name/runs",
229
- band: "instanceOnly",
308
+ band: "guarded",
309
+ scope: "start",
230
310
  summary:
231
- "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.",
232
312
  body: "the workflow's input, checked against its declared input schema",
233
313
  response:
234
- "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",
235
315
  stream: false,
236
- adrs: ["0033", "0058", "0070", "0071"],
316
+ adrs: ["0017", "0033", "0058", "0070", "0071", "0078"],
237
317
  },
238
318
  {
239
319
  method: "GET",
@@ -319,11 +399,14 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
319
399
  {
320
400
  method: "GET",
321
401
  path: "/runs",
322
- band: "instanceOnly",
323
- summary: "The live runs, with their full status.",
324
- 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? }",
325
408
  stream: false,
326
- adrs: ["0009", "0014"],
409
+ adrs: ["0009", "0014", "0078"],
327
410
  },
328
411
  {
329
412
  method: "GET",
@@ -337,58 +420,135 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
337
420
  {
338
421
  method: "GET",
339
422
  path: "/runs/resolve",
340
- band: "instanceOnly",
341
- 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.",
342
427
  response: "{ prefix, runIds: string[], truncated }",
343
428
  stream: false,
344
- 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"],
345
441
  },
346
442
  {
347
443
  method: "GET",
348
444
  path: "/runs/:runId",
349
- band: "instanceOnly",
350
- summary: "One run's status, live or settled, with its open Gates.",
351
- 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",
352
451
  stream: false,
353
- adrs: ["0009", "0011"],
452
+ adrs: ["0009", "0011", "0078"],
354
453
  },
355
454
  {
356
- // `authenticated` in the code, and the handler refuses a Sandbox token itself (ADR-0013): the
357
- // credential the route requires is the Instance token's, so this is its band.
358
455
  method: "POST",
359
456
  path: "/runs/:runId/gates/:gate/events",
360
- band: "instanceOnly",
361
- 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.",
362
461
  body: "{ type, ...payload }, checked against the Gate's schema for that event",
363
462
  response: "{ ok: true }; 409 { error, acceptsNow } when the run does not take it now",
364
463
  stream: false,
365
- adrs: ["0011", "0013"],
464
+ adrs: ["0011", "0013", "0078"],
366
465
  },
367
466
  {
368
467
  method: "GET",
369
468
  path: "/runs/:runId/events",
370
- band: "instanceOnly",
469
+ band: "guarded",
470
+ scope: "read",
371
471
  summary: "One run's full activity until it settles; a settled run's final status, once.",
372
- 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",
474
+ stream: true,
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",
373
507
  stream: true,
374
- adrs: ["0022", "0023", "0064", "0067"],
508
+ adrs: ["0023"],
375
509
  },
376
510
  {
377
511
  method: "POST",
378
512
  path: "/runs/:runId/events",
379
- band: "instanceOnly",
513
+ band: "guarded",
514
+ scope: "cancel",
380
515
  summary: "Cancel a run: it ends, and its Workspaces go unless `keep` is true.",
381
516
  body: '{ type: "CANCEL", keep?: boolean }',
382
517
  response: "{ ok: true }",
383
518
  stream: false,
384
- 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"],
385
544
  },
386
545
  {
387
546
  method: "GET",
388
547
  path: "/agents/:instanceId/surface",
389
548
  band: "authenticated",
390
- summary: "An Agent's Menu and this Turn's Allowed picks. A Sandbox token reads only its own Sandbox's.",
391
- response: "AgentSurfaceView",
549
+ summary:
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 }> }",
392
552
  stream: false,
393
553
  adrs: ["0013", "0029"],
394
554
  },
@@ -411,11 +571,34 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
411
571
  stream: false,
412
572
  adrs: ["0013", "0024"],
413
573
  },
574
+ {
575
+ method: "POST",
576
+ path: "/agents/:instanceId/notices",
577
+ band: "authenticated",
578
+ summary:
579
+ "Acknowledge the notices and Steering messages a step boundary appended to the Turn: they are heard in its conversation.",
580
+ body: "{ heard: string[] }",
581
+ response: "{ heard: number }",
582
+ stream: false,
583
+ adrs: ["0062", "0076"],
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
+ },
414
597
  {
415
598
  method: "POST",
416
599
  path: "/sandboxes/:name/fetch",
417
600
  band: "authenticated",
418
- summary: "Ask the node cache to fetch one Repo that this Sandbox mounts. A Sandbox token asks only for its own.",
601
+ summary: "Ask the node cache to fetch one Repo that this Sandbox mounts. A fetch token asks only for its own.",
419
602
  body: "{ identity }",
420
603
  response: "FetchAnswer: { fetched } | { stale, asOf }",
421
604
  stream: false,
@@ -433,6 +616,17 @@ type RunEventBody = { type?: string; keep?: unknown };
433
616
  * the same floor on the argument; this one guards the scan regardless of who is calling. */
434
617
  const MIN_RUN_ID_PREFIX = 4;
435
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
+
436
630
  /** How many ambiguous candidates are worth showing. Proving ambiguity takes 2; letting the caller
437
631
  * PICK is the point, so the listing goes deeper before it truncates. */
438
632
  const RESOLVE_LIMIT = 10;
@@ -497,6 +691,15 @@ function restorePendingMessage(what: string): string {
497
691
  * Instance principal, so it reads no run, starts none, and speaks for no Agent. */
498
692
  const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
499
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
+
500
703
  /** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
501
704
  type JR2Env = { Variables: { principal: Principal } };
502
705
 
@@ -643,6 +846,28 @@ function closer(resolve: () => void) {
643
846
  /** How often an otherwise-silent feed writes a ping. */
644
847
  const PING_MS = 15_000;
645
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
+
646
871
  /**
647
872
  * Keep a quiet feed alive.
648
873
  *
@@ -767,6 +992,9 @@ function subscriber(stream: SseStream): Subscriber {
767
992
  export type CreateAppOptions = {
768
993
  /** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
769
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;
770
998
  /**
771
999
  * Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
772
1000
  * a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
@@ -782,8 +1010,10 @@ export type CreateAppOptions = {
782
1010
  assembly?: AssemblyDoor;
783
1011
  /**
784
1012
  * The digests of the ConfigMaps this process reads through a mount (ADR-0069), as
785
- * `readMountDigests` reads them. `GET /assembly` reports them, so `jr2 up` can wait for the
786
- * 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.
787
1017
  */
788
1018
  mounts?: () => Promise<Record<string, string>>;
789
1019
  /**
@@ -811,6 +1041,20 @@ export type CreateAppOptions = {
811
1041
  * that build an app straight over a host: those routes then answer 404.
812
1042
  */
813
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;
814
1058
  };
815
1059
 
816
1060
  /**
@@ -822,41 +1066,142 @@ export type CreateAppOptions = {
822
1066
  */
823
1067
  export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOptions = {}): Hono<JR2Env> {
824
1068
  const pingMs = opts.pingMs ?? PING_MS;
1069
+ const followGraceMs = opts.followGraceMs ?? FOLLOW_GRACE_MS;
825
1070
  const app = new Hono<JR2Env>();
826
1071
 
827
1072
  /** Authenticate, or refuse. There is no anonymous principal (ADR-0013) — an open surface would
828
1073
  * hand every Agent in the cluster a delivery API, which is the hole this ADR exists to close. */
829
1074
  const authenticated: MiddlewareHandler<JR2Env> = async (c, next) => {
830
1075
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
831
- const principal = auth(bearerOf(c));
1076
+ // The slot a control request states (ADR-0076): a control token without one resolves to nothing.
1077
+ const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
832
1078
  if (!principal) return c.json({ error: "unauthorized" }, 401);
833
1079
  if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
834
1080
  c.set("principal", principal);
835
1081
  return next();
836
1082
  };
837
1083
  /** The principal `authenticated` resolved. An unconfigured `auth` means full trust. */
838
- 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
+ };
839
1169
 
840
1170
  /**
841
- * Authenticate, AND require the INSTANCE token (ADR-0014). A Sandbox token is a token we minted,
842
- * so `authenticated` alone lets it through — which on the run surface is too much: it would let a
843
- * Custodian's credential read every run's context (other features' branches, tickets, verdicts) and
844
- * CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
845
- * registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
846
- * 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.
847
1174
  */
848
1175
  const instanceOnly: MiddlewareHandler<JR2Env> = async (c, next) => {
849
1176
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
850
- const principal = auth(bearerOf(c));
1177
+ const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
851
1178
  if (!principal) return c.json({ error: "unauthorized" }, 401);
852
1179
  if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
853
- if (principal.kind !== "instance") {
854
- 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
+ );
855
1186
  }
856
1187
  c.set("principal", principal);
857
1188
  return next();
858
1189
  };
859
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
+
860
1205
  // Liveness, and the one place an instance says WHAT IT IS. Unauthenticated because it is the
861
1206
  // readiness probe's target and because identity is not run state — it is the same class of thing
862
1207
  // as the route table, which is public by being served. The CLI probes it to explain a failure it
@@ -865,6 +1210,42 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
865
1210
  app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
866
1211
  app.get("/readyz", (c) => c.json({ ready: true }));
867
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
+
868
1249
  // Structure, not state: the workflow listing, a template's Machine, and the Console shell are
869
1250
  // unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
870
1251
  // loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
@@ -878,10 +1259,15 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
878
1259
 
879
1260
  // The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
880
1261
  // and a Kit version are identity, the class of thing `/healthz` already serves.
881
- // `mounts` is what this process reads from its mounted ConfigMaps, so a publish can wait until
882
- // 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.
883
1265
  app.get("/assembly", async (c) => {
884
- 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;
885
1271
  return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
886
1272
  });
887
1273
 
@@ -891,7 +1277,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
891
1277
  // its own status, and the old Assembly stays current through every refusal.
892
1278
  app.put("/assembly", async (c) => {
893
1279
  if (auth) {
894
- const principal = auth(bearerOf(c));
1280
+ const principal = await auth(bearerOf(c));
895
1281
  if (!principal) return c.json({ error: "unauthorized" }, 401);
896
1282
  if (principal.kind !== "publisher") {
897
1283
  return c.json(
@@ -910,6 +1296,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
910
1296
  hash: c.req.header("jr2-assembly-hash"),
911
1297
  body: c.req.raw.body,
912
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
+ }
913
1305
  return c.json(answer.body, answer.status);
914
1306
  });
915
1307
 
@@ -978,37 +1370,81 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
978
1370
  *
979
1371
  * A foreign `dispatch` sends `jr2-dispatched-by` (ADR-0071): its key makes a repeated start one
980
1372
  * run, answered `200 { runId, instanceId, existing: true }` where a new one is `201`. The key is
981
- * asked first, so a re-send answers its run even when the name would be refused now; a key a
982
- * 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
983
1376
  * Console send none. Any throw that is not a refusal is a defect, 500: a foreign caller reads a
984
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.
985
1384
  */
986
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;
987
1389
  const header = dispatchedByOf(c.req.header(DISPATCHED_BY_HEADER));
988
1390
  if ("error" in header) return c.json({ error: header.error }, 400);
989
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
+ }
990
1401
  if (by) {
991
- const found = await host.dispatched(by.effect);
992
- if (found && found.workflow !== name) return c.json({ error: keyConflict(found, name, by.effect) }, 409);
993
- 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);
994
1406
  }
995
1407
  const unrunnable = opts.charts?.get(name)?.unrunnable;
996
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
+ }
997
1428
  const input = await readJson(c.req.text());
998
1429
  try {
999
- 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
+ });
1000
1437
  return existing ? c.json({ runId, instanceId, existing }, 200) : c.json({ runId, instanceId }, 201);
1001
1438
  } catch (err) {
1002
1439
  if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
1003
1440
  if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1004
- if (err instanceof DispatchKeyConflictError) return c.json({ error: errMessage(err) }, 409);
1005
1441
  if (err instanceof UnknownWorkflowError) return c.json({ error: errMessage(err) }, 404);
1006
1442
  return c.json({ error: errMessage(err) }, 500);
1007
1443
  }
1008
1444
  };
1009
1445
 
1010
1446
  // The documented start of a named Chart; `POST /workflows/<wrapper>%2F<name>/runs` is the same.
1011
- app.post("/workflows/:wrapper/charts/:name/runs", instanceOnly, (c) =>
1447
+ app.post("/workflows/:wrapper/charts/:name/runs", guarded("start"), (c) =>
1012
1448
  startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
1013
1449
  );
1014
1450
 
@@ -1051,6 +1487,11 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1051
1487
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
1052
1488
  return c.json({ error: "no JSON resource at / — the Console is the HTML dialect" }, 404);
1053
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?.() ?? [] }));
1054
1495
  app.get("/workflows/:name", (c) => {
1055
1496
  // The shell for ANY name — an unknown workflow surfaces in-page via its 404'd /machine fetch.
1056
1497
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
@@ -1079,7 +1520,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1079
1520
  // allowlist not forwarding this route is a second layer, not the control (ADR-0059).
1080
1521
  // A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
1081
1522
  // run starts, and a refusal is 422 with the report. A named Chart rides the path encoded.
1082
- 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")));
1083
1524
 
1084
1525
  // The registered template Machine's structure — what the Console's diagram renders (structure
1085
1526
  // is provider-independent, so the un-`provide()`d template is exactly right).
@@ -1171,6 +1612,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1171
1612
  // A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
1172
1613
  // token.
1173
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;
1174
1622
  // An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
1175
1623
  if (ev.kind === "effect") return undefined;
1176
1624
  return sseFrame("status", observe(ev.status));
@@ -1238,7 +1686,30 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1238
1686
  });
1239
1687
  });
1240
1688
 
1241
- 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
+ });
1242
1713
 
1243
1714
  // The Repos as the cluster currently reports them (ADR-0048/0051) — what `jr2 status` renders,
1244
1715
  // and the place ADR-0047's "register the key, the cache agent retries" points at. Instance band,
@@ -1260,13 +1731,24 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1260
1731
  // ambiguous hash, and it holds down what this newly reveals — settled run ids are now visible to
1261
1732
  // an Instance-token holder, which `GET /runs`'s deferred `?all` had withheld. Deliberately not on
1262
1733
  // the open observation routes: prefix probing there would be a run-id enumeration oracle.
1263
- 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) => {
1264
1739
  const prefix = c.req.query("prefix") ?? "";
1265
1740
  if (prefix.length < MIN_RUN_ID_PREFIX) {
1266
1741
  return c.json({ error: `prefix must be at least ${MIN_RUN_ID_PREFIX} characters` }, 400);
1267
1742
  }
1743
+ const caller = callerOf(c);
1744
+ const everyWorkflow = Object.values(caller.may).some((grant) => grant === "all");
1268
1745
  // Scan one past the cap so `truncated` is knowable without a second COUNT.
1269
- 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
+ });
1270
1752
  return c.json({
1271
1753
  prefix,
1272
1754
  runIds: found.slice(0, RESOLVE_LIMIT),
@@ -1274,39 +1756,76 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1274
1756
  });
1275
1757
  });
1276
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
+
1277
1769
  // Read-through (ADR-0009): a completed run's final status lives in the store after the registry
1278
1770
  // drops it, so this serves terminal runs too — only a genuinely unknown run is a 404. The status
1279
1771
  // carries the run's OPEN GATES (ADR-0011) — the discovery listing external callers act on
1280
1772
  // (`jr2 send` menus, UI inbox cards, webhook translators matching on meta). Settled run → [].
1281
- 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) => {
1282
1778
  const runId = c.req.param("runId");
1283
- const status = await host.read(runId);
1284
- 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 });
1285
1793
  });
1286
1794
 
1287
1795
  // Gates delivery (ADR-0011): validate the body against the gate's named schema and deliver into
1288
1796
  // the gated state. Unknown gate (never opened, state exited, run settled) → 404; a name the gate
1289
1797
  // doesn't accept, or a payload failing its schema → 400 naming what IS accepted.
1290
1798
  //
1291
- // A Gate is a HUMAN's decision (or a webhook's, or CI's). An Agent holding a Sandbox token is
1292
- // refused here unconditionally — this is the exact line between "the Agent reports an outcome"
1293
- // and "the Agent approves its own PR" (ADR-0013).
1294
- app.post("/runs/:runId/gates/:gate/events", authenticated, async (c) => {
1295
- if (principalOf(c).kind === "sandbox") {
1296
- return c.json({ error: "a Sandbox token cannot deliver to a gate — gates are not on the Agent's surface" }, 403);
1297
- }
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).
1298
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
+ }
1299
1816
  try {
1300
- 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);
1301
1821
  return c.json({ ok: true });
1302
1822
  } catch (err) {
1823
+ if (err instanceof GateRefused) return mayAnswer(c, live!.workflow, err.gate)!;
1303
1824
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
1304
1825
  if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1305
1826
  // The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
1306
1827
  // names what the Gate takes now, as `GET /runs/:id` lists it.
1307
1828
  if (err instanceof NotTakenError) {
1308
- const runId = c.req.param("runId");
1309
- const gate = c.req.param("gate");
1310
1829
  const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
1311
1830
  return c.json({ error: errMessage(err), acceptsNow }, 409);
1312
1831
  }
@@ -1329,6 +1848,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1329
1848
  // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
1330
1849
  // the operator reads it here, with its message.
1331
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);
1332
1857
  // An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
1333
1858
  if (ev.kind === "effect") return sseFrame("effect", ev);
1334
1859
  return sseFrame("status", ev.status);
@@ -1336,12 +1861,19 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1336
1861
 
1337
1862
  // SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
1338
1863
  // then live until the terminal transition or client abort). A run that has already settled streams
1339
- // its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
1340
- 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) => {
1341
1866
  const runId = c.req.param("runId");
1342
- 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) {
1343
1873
  const finalStatus = await host.read(runId);
1344
1874
  if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
1875
+ const refused = mayFor(c, "read", finalStatus.workflow);
1876
+ if (refused) return refused;
1345
1877
  return streamSSE(c, async (stream) => {
1346
1878
  await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
1347
1879
  await sayGoneIfParked(stream, finalStatus);
@@ -1389,8 +1921,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1389
1921
  // It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
1390
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
1391
1923
  // deliberately not on the wire.
1392
- app.post("/runs/:runId/events", instanceOnly, async (c) => {
1924
+ app.post("/runs/:runId/events", guarded("cancel"), async (c) => {
1393
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
+ }
1394
1931
  const body = (await readJson(c.req.text())) as RunEventBody;
1395
1932
  if (body.type !== "CANCEL") {
1396
1933
  return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
@@ -1400,13 +1937,202 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1400
1937
  return c.json({ error: `"keep" must be a boolean` }, 400);
1401
1938
  }
1402
1939
  try {
1403
- await host.cancel(runId, { keep: body.keep === true });
1940
+ await host.cancel(runId, { keep: body.keep === true, by: callerRecord(callerOf(c)) });
1404
1941
  return c.json({ ok: true });
1405
1942
  } catch (err) {
1406
1943
  return c.json({ error: errMessage(err) }, 404);
1407
1944
  }
1408
1945
  });
1409
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
+
1410
2136
  // ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
1411
2137
  // Served to ONE caller: the Agent's Harness, through the Custodian in its pod. The Harness renders
1412
2138
  // `surface` as the Menu and turns a pick into an `events` POST. The Orchestrator therefore keeps
@@ -1419,7 +2145,9 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1419
2145
  // and delivery are implemented once, in `registration.ts`.
1420
2146
 
1421
2147
  /** Guard: the surface must exist, and this principal must be allowed to speak for it. */
1422
- const agentRegistration = (c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events">) => {
2148
+ const agentRegistration = (
2149
+ c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events" | "/agents/:instanceId/notices">,
2150
+ ) => {
1423
2151
  const instanceId = c.req.param("instanceId");
1424
2152
  // Only a Turn that is over answers 404 (ADR-0026). While the host is stopping its runs are
1425
2153
  // parked for restore, not over, so every `/agents/*` call answers 503: the Harness re-asks a
@@ -1442,9 +2170,10 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1442
2170
  // iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
1443
2171
  // token learns nothing from it that it did not already know.
1444
2172
  if (!surface) return { error: c.json({ error: `no live agent surface for instance "${instanceId}"` }, 404) };
1445
- if (!mayDeliverToAgent(principalOf(c), surface.sandbox)) {
1446
- // A Sandbox token for a DIFFERENT Sandbox (or for a workspace-less run, which no Sandbox
1447
- // owns). This is the check that keeps one feature's coder out of another's reviewer.
2173
+ if (!mayDeliverToAgent(principalOf(c), surface)) {
2174
+ // A control token for a DIFFERENT pod (or for a workspace-less run, which no pod owns), a
2175
+ // stated slot that is not this registration's (ADR-0076), or a fetch token. The first is the
2176
+ // check that keeps one feature's coder out of another's reviewer.
1448
2177
  return { error: c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403) };
1449
2178
  }
1450
2179
  return { surface };
@@ -1454,11 +2183,82 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1454
2183
  // semantics, fixed for the conversation — and this Turn's Allowed picks (ADR-0029). A transition
1455
2184
  // swaps the registration, which swaps the Allowed picks, never the Menu; the Harness reads both
1456
2185
  // per Submission and needs no `list_changed`.
1457
- app.get("/agents/:instanceId/surface", authenticated, (c) => {
2186
+ // The same read at a step boundary carries the conversation's pending notices in its audience
2187
+ // (ADR-0062), reserved for this read: the Harness appends them and acknowledges them below.
2188
+ app.get("/agents/:instanceId/surface", authenticated, async (c) => {
1458
2189
  const { surface, error } = agentRegistration(c);
1459
2190
  if (error) return error;
1460
2191
  host.agentSurfaceServed(c.req.param("instanceId"));
1461
- return c.json(surface);
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 } : {}) });
2198
+ });
2199
+
2200
+ // The Harness appended a step boundary's notices to the Turn (ADR-0062, ADR-0076): they are heard
2201
+ // in that conversation, ledgered in one save. An id this conversation did not reserve is ignored,
2202
+ // so the call says nothing a caller did not already know.
2203
+ app.post("/agents/:instanceId/notices", authenticated, async (c) => {
2204
+ const { error } = agentRegistration(c);
2205
+ if (error) return error;
2206
+ const body = await readJson(c.req.text());
2207
+ const heard = (body as { heard?: unknown }).heard;
2208
+ if (!Array.isArray(heard) || heard.some((id) => typeof id !== "string")) {
2209
+ return c.json({ error: "an acknowledgment names the notices heard: { heard: string[] }" }, 400);
2210
+ }
2211
+ return c.json({ heard: host.noticesHeard(c.req.param("instanceId"), heard as string[]) });
2212
+ });
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));
1462
2262
  });
1463
2263
 
1464
2264
  // The Agent's pick, delivered into the state that invoked it. The receipt describes itself
@@ -1514,7 +2314,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1514
2314
  return c.json({ error: restorePendingMessage(`delivery "${deliveryId}"`) }, 503);
1515
2315
  }
1516
2316
  if (!scope) return c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
1517
- if (!mayDeliverToAgent(principalOf(c), scope.sandbox)) {
2317
+ if (!mayDeliverToAgent(principalOf(c), scope)) {
1518
2318
  return c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403);
1519
2319
  }
1520
2320
  const receipt = await host.awaitDelivery(instanceId, deliveryId, opts.deliveryWaitMs, c.req.raw.signal);
@@ -1560,3 +2360,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
1560
2360
 
1561
2361
  return app;
1562
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
+ }