@jr2/orchestrator 0.3.0 → 0.5.1

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 (354) hide show
  1. package/README.md +20 -2
  2. package/bin/server.ts +7 -4
  3. package/console/canvas.ts +27 -14
  4. package/console/components/app.ts +12 -1
  5. package/console/components/fleet.ts +52 -6
  6. package/console/components/machine-pane.ts +128 -2
  7. package/console/components/nav.ts +1 -1
  8. package/console/components/schema-form.ts +12 -4
  9. package/console/json-field.ts +23 -0
  10. package/console/main.ts +122 -39
  11. package/console/store.ts +321 -21
  12. package/console/style.css +111 -0
  13. package/docs/agents-block.md +5 -0
  14. package/docs/concepts/agents-and-turns.md +442 -0
  15. package/docs/concepts/architecture.md +230 -0
  16. package/docs/concepts/charts.md +143 -0
  17. package/docs/concepts/composition.md +357 -0
  18. package/docs/concepts/converge.md +254 -0
  19. package/docs/concepts/durable-runs.md +189 -0
  20. package/docs/concepts/effects.md +603 -0
  21. package/docs/concepts/gates.md +208 -0
  22. package/docs/concepts/machines-and-workflows.md +289 -0
  23. package/docs/concepts/pools-and-sources.md +230 -0
  24. package/docs/concepts/secrets-and-trust.md +222 -0
  25. package/docs/concepts/the-menu.md +378 -0
  26. package/docs/concepts/workspaces.md +678 -0
  27. package/docs/get-started/a-model-decides.md +563 -0
  28. package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
  29. package/docs/get-started/connect-a-model.md +248 -0
  30. package/docs/get-started/create-an-instance.md +194 -0
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
  32. package/docs/get-started/install.md +126 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +295 -0
  34. package/docs/guides/answer-a-gate.md +304 -0
  35. package/docs/guides/bind-open-parts.md +223 -0
  36. package/docs/guides/cancel-a-run.md +146 -0
  37. package/docs/guides/clean-up.md +206 -0
  38. package/docs/guides/fix-a-failed-converge.md +361 -0
  39. package/docs/guides/give-the-cluster-git-credentials.md +222 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
  41. package/docs/guides/let-an-agent-write-a-chart.md +226 -0
  42. package/docs/guides/look-inside-a-sandbox.md +174 -0
  43. package/docs/guides/mirror-the-kit-images.md +176 -0
  44. package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
  45. package/docs/guides/open-the-console.md +91 -0
  46. package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
  47. package/docs/guides/push-and-sign-a-branch.md +486 -0
  48. package/docs/guides/run-a-chart-by-name.md +270 -0
  49. package/docs/guides/run-a-command-or-call-a-url.md +544 -0
  50. package/docs/guides/run-one-worker-per-item.md +321 -0
  51. package/docs/guides/set-an-agents-access-and-dials.md +375 -0
  52. package/docs/guides/size-a-workspace.md +204 -0
  53. package/docs/guides/start-a-run-from-a-run.md +237 -0
  54. package/docs/guides/start-a-run.md +207 -0
  55. package/docs/guides/upgrade-the-kit-version.md +240 -0
  56. package/docs/guides/use-a-hosted-provider.md +208 -0
  57. package/docs/guides/use-a-local-model.md +313 -0
  58. package/docs/guides/watch-a-run.md +326 -0
  59. package/docs/guides/write-a-workflow.md +318 -0
  60. package/docs/index.md +44 -0
  61. package/docs/llms.txt +216 -0
  62. package/docs/reference/authoring-surface/agent.md +128 -0
  63. package/docs/reference/authoring-surface/answer.md +140 -0
  64. package/docs/reference/authoring-surface/arg.md +60 -0
  65. package/docs/reference/authoring-surface/basic.md +62 -0
  66. package/docs/reference/authoring-surface/bearer.md +53 -0
  67. package/docs/reference/authoring-surface/chartschema.md +23 -0
  68. package/docs/reference/authoring-surface/chartschemafor.md +48 -0
  69. package/docs/reference/authoring-surface/command.md +191 -0
  70. package/docs/reference/authoring-surface/customize.md +64 -0
  71. package/docs/reference/authoring-surface/defineconfig.md +41 -0
  72. package/docs/reference/authoring-surface/defineevent.md +114 -0
  73. package/docs/reference/authoring-surface/dispatch.md +126 -0
  74. package/docs/reference/authoring-surface/fail.md +83 -0
  75. package/docs/reference/authoring-surface/file.md +122 -0
  76. package/docs/reference/authoring-surface/frompick.md +277 -0
  77. package/docs/reference/authoring-surface/http.md +159 -0
  78. package/docs/reference/authoring-surface/isfailure.md +58 -0
  79. package/docs/reference/authoring-surface/jr2setup.md +237 -0
  80. package/docs/reference/authoring-surface/open.md +74 -0
  81. package/docs/reference/authoring-surface/openbody.md +86 -0
  82. package/docs/reference/authoring-surface/paramsof.md +125 -0
  83. package/docs/reference/authoring-surface/pool.md +186 -0
  84. package/docs/reference/authoring-surface/push.md +112 -0
  85. package/docs/reference/authoring-surface/secret.md +118 -0
  86. package/docs/reference/authoring-surface/sentby.md +83 -0
  87. package/docs/reference/authoring-surface/source.md +51 -0
  88. package/docs/reference/authoring-surface/text.md +62 -0
  89. package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
  90. package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
  91. package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
  92. package/docs/reference/authoring-surface/type-agentslots.md +12 -0
  93. package/docs/reference/authoring-surface/type-answeraction.md +12 -0
  94. package/docs/reference/authoring-surface/type-arg.md +19 -0
  95. package/docs/reference/authoring-surface/type-argnames.md +12 -0
  96. package/docs/reference/authoring-surface/type-binding.md +62 -0
  97. package/docs/reference/authoring-surface/type-bindingof.md +12 -0
  98. package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
  99. package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
  100. package/docs/reference/authoring-surface/type-ceilings.md +41 -0
  101. package/docs/reference/authoring-surface/type-chainlink.md +19 -0
  102. package/docs/reference/authoring-surface/type-chartaction.md +46 -0
  103. package/docs/reference/authoring-surface/type-chartagent.md +28 -0
  104. package/docs/reference/authoring-surface/type-chartevent.md +20 -0
  105. package/docs/reference/authoring-surface/type-chartguard.md +20 -0
  106. package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
  107. package/docs/reference/authoring-surface/type-chartjson.md +14 -0
  108. package/docs/reference/authoring-surface/type-chartstate.md +46 -0
  109. package/docs/reference/authoring-surface/type-charttext.md +40 -0
  110. package/docs/reference/authoring-surface/type-charttransition.md +22 -0
  111. package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
  112. package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
  113. package/docs/reference/authoring-surface/type-childat.md +12 -0
  114. package/docs/reference/authoring-surface/type-childslots.md +12 -0
  115. package/docs/reference/authoring-surface/type-commandenv.md +15 -0
  116. package/docs/reference/authoring-surface/type-commandinput.md +19 -0
  117. package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
  118. package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
  119. package/docs/reference/authoring-surface/type-customize.md +109 -0
  120. package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
  121. package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
  122. package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
  123. package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
  124. package/docs/reference/authoring-surface/type-duration.md +15 -0
  125. package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
  126. package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
  127. package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
  128. package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
  129. package/docs/reference/authoring-surface/type-eventdef.md +45 -0
  130. package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
  131. package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
  132. package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
  133. package/docs/reference/authoring-surface/type-failslot.md +14 -0
  134. package/docs/reference/authoring-surface/type-failure.md +19 -0
  135. package/docs/reference/authoring-surface/type-failureinput.md +20 -0
  136. package/docs/reference/authoring-surface/type-filebody.md +41 -0
  137. package/docs/reference/authoring-surface/type-frominput.md +14 -0
  138. package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
  139. package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
  140. package/docs/reference/authoring-surface/type-httpinput.md +12 -0
  141. package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
  142. package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
  143. package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
  144. package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
  145. package/docs/reference/authoring-surface/type-jr2config.md +126 -0
  146. package/docs/reference/authoring-surface/type-lends.md +38 -0
  147. package/docs/reference/authoring-surface/type-logicat.md +12 -0
  148. package/docs/reference/authoring-surface/type-markedif.md +12 -0
  149. package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
  150. package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
  151. package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
  152. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
  153. package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
  154. package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
  155. package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
  156. package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
  157. package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
  158. package/docs/reference/authoring-surface/type-openeffect.md +12 -0
  159. package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
  160. package/docs/reference/authoring-surface/type-openof.md +12 -0
  161. package/docs/reference/authoring-surface/type-opensecret.md +23 -0
  162. package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
  163. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
  164. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
  165. package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
  166. package/docs/reference/authoring-surface/type-poolspec.md +60 -0
  167. package/docs/reference/authoring-surface/type-pushinput.md +15 -0
  168. package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
  169. package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
  170. package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
  171. package/docs/reference/authoring-surface/type-quantity.md +14 -0
  172. package/docs/reference/authoring-surface/type-reached.md +12 -0
  173. package/docs/reference/authoring-surface/type-reposlot.md +70 -0
  174. package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
  175. package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
  176. package/docs/reference/authoring-surface/type-secret.md +25 -0
  177. package/docs/reference/authoring-surface/type-sender.md +19 -0
  178. package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
  179. package/docs/reference/authoring-surface/type-size.md +85 -0
  180. package/docs/reference/authoring-surface/type-slothandles.md +32 -0
  181. package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
  182. package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
  183. package/docs/reference/authoring-surface/type-syncstate.md +12 -0
  184. package/docs/reference/authoring-surface/type-text.md +19 -0
  185. package/docs/reference/authoring-surface/type-textpart.md +24 -0
  186. package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
  187. package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
  188. package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
  189. package/docs/reference/authoring-surface/type-unknown.md +21 -0
  190. package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
  191. package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
  192. package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
  193. package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
  194. package/docs/reference/authoring-surface/type-workspaced.md +94 -0
  195. package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
  196. package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
  197. package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
  198. package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
  199. package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
  200. package/docs/reference/authoring-surface/workspace.md +259 -0
  201. package/docs/reference/authoring-surface.md +197 -0
  202. package/docs/reference/cli.md +433 -0
  203. package/docs/reference/config.md +756 -0
  204. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
  205. package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
  206. package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
  207. package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
  208. package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
  209. package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
  210. package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
  211. package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
  212. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
  213. package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
  214. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
  215. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
  216. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
  217. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
  218. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
  219. package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
  220. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
  221. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
  222. package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
  223. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
  224. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
  225. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
  226. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
  227. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
  228. package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
  229. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
  230. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
  231. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
  232. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
  233. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
  234. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
  235. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
  236. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
  237. package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
  238. package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
  239. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
  240. package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
  241. package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
  242. package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
  243. package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
  244. package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
  245. package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
  246. package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
  247. package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
  248. package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
  249. package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
  250. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
  251. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
  252. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
  253. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
  254. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
  255. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
  256. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
  257. package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
  258. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
  259. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
  260. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
  261. package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
  262. package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
  263. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
  264. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
  265. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
  266. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
  267. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +550 -0
  268. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
  269. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
  270. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
  271. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
  272. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
  273. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
  274. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
  275. package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
  276. package/docs/reference/decisions.md +83 -0
  277. package/docs/reference/glossary.md +648 -0
  278. package/docs/reference/http-api.md +1348 -0
  279. package/docs/reference/machines.md +190 -0
  280. package/docs/reference/mechanism-events.md +72 -0
  281. package/docs/why.md +206 -0
  282. package/package.json +6 -4
  283. package/src/actor.ts +976 -61
  284. package/src/agent.ts +114 -38
  285. package/src/ambient.ts +4 -1
  286. package/src/assembly-checks.ts +347 -0
  287. package/src/assembly-tar.ts +494 -0
  288. package/src/assembly.ts +721 -0
  289. package/src/cause.ts +234 -0
  290. package/src/chart.ts +2182 -0
  291. package/src/charts.ts +748 -0
  292. package/src/config.ts +520 -162
  293. package/src/content-hash.ts +105 -0
  294. package/src/custodian.lua +6 -5
  295. package/src/custodian.ts +143 -39
  296. package/src/customize.ts +388 -55
  297. package/src/dispatch-effect.ts +945 -0
  298. package/src/door-compat.ts +217 -0
  299. package/src/durability.ts +11 -4
  300. package/src/effect-checks.ts +557 -0
  301. package/src/effect.ts +1202 -0
  302. package/src/effector-wire.ts +674 -0
  303. package/src/fail.ts +209 -0
  304. package/src/fingerprint.ts +4 -3
  305. package/src/gate.ts +8 -0
  306. package/src/git-clone.ts +65 -0
  307. package/src/git-push.ts +336 -0
  308. package/src/harness-client.ts +764 -48
  309. package/src/held-secrets.ts +82 -26
  310. package/src/holds.ts +156 -0
  311. package/src/http-effect.ts +1423 -0
  312. package/src/http.ts +916 -113
  313. package/src/images.ts +47 -21
  314. package/src/index.ts +62 -43
  315. package/src/instance.ts +505 -59
  316. package/src/internal.ts +84 -0
  317. package/src/kube-client.ts +333 -0
  318. package/src/machine-doc.ts +106 -6
  319. package/src/mechanism-routes.ts +168 -0
  320. package/src/model-keys.generated.ts +53 -0
  321. package/src/model-keys.ts +18 -58
  322. package/src/mount-digest.ts +53 -0
  323. package/src/names.ts +66 -8
  324. package/src/notices.ts +141 -0
  325. package/src/open-body.ts +1580 -0
  326. package/src/open-effect.ts +217 -0
  327. package/src/open.ts +59 -6
  328. package/src/parts.ts +709 -41
  329. package/src/party.ts +534 -0
  330. package/src/pick.ts +761 -0
  331. package/src/pool.ts +248 -37
  332. package/src/registration.ts +382 -19
  333. package/src/repo-fetch.ts +60 -85
  334. package/src/repos.ts +65 -75
  335. package/src/rest.ts +133 -0
  336. package/src/run-host.ts +2383 -115
  337. package/src/run-output.ts +38 -0
  338. package/src/sandbox-effect.ts +1126 -0
  339. package/src/sandbox-effector.ts +111 -0
  340. package/src/sandbox-kube.ts +1721 -0
  341. package/src/sandbox-watch.ts +260 -0
  342. package/src/secret-placement.ts +529 -0
  343. package/src/server.ts +284 -84
  344. package/src/setup.ts +819 -117
  345. package/src/size.ts +454 -0
  346. package/src/snapshot-store.ts +115 -20
  347. package/src/stub-harness.ts +49 -16
  348. package/src/sync-client.ts +54 -0
  349. package/src/timers.ts +191 -0
  350. package/src/tokens.ts +35 -4
  351. package/src/vocabulary.ts +135 -15
  352. package/src/wire.ts +238 -10
  353. package/src/workspace.ts +1195 -338
  354. package/src/sandbox-kubectl.ts +0 -1108
package/src/http.ts CHANGED
@@ -5,16 +5,25 @@
5
5
  // TWO dialects, one primitive (ADR-0013). The Orchestrator does not speak MCP — nothing does: the
6
6
  // Harness presents the Menu to its model itself, and reaches these routes through the Custodian in
7
7
  // its pod, which holds the Sandbox token. What is left here are two thin adapters over the same
8
- // registration table:
8
+ // registration table: the Gate resource of ADR-0011, for a human, a webhook or CI
9
+ // (`GET /runs/:id`, `POST /runs/:id/gates/:gate/events`), and the Agent's surface, for the Agent's
10
+ // Harness through its Custodian and nothing else (`/agents/:iid/*`). `/sandboxes/:name/fetch` also
11
+ // serves the Harness's pod, but it is not an adapter over the registration table.
9
12
  //
10
- // # human / webhook / CI — the Gate resource of ADR-0011
11
- // GET /runs/:id open gates: accepts + schemas + meta [Instance token]
12
- // POST /runs/:id/gates/:gate/events validate + deliver [Instance token]
13
+ // Every route, with its band, is a row of `HTTP_ROUTES` below. That table is data: the HTTP API
14
+ // Reference is generated from it (ADR-0072), and http-routes.test.ts holds it to the routes
15
+ // `createApp` registers, both ways.
13
16
  //
14
- // # the Agent's Harness, through its Custodian, and nothing else
15
- // GET /agents/:iid/surface accepts + schemas + semantics [Sandbox token]
16
- // POST /agents/:iid/events validate + deliver → the turn receipt [Sandbox token]
17
- // POST /sandboxes/:name/fetch ask the node cache to fetch one Repo [Sandbox token]
17
+ // # named Charts (ADR-0070): a Chart under a name is a Workflow `<wrapper>/<name>`
18
+ // GET /workflows/:wrapper/chart-schema the grammar, narrowed to the Open body [open]
19
+ // PUT /workflows/:wrapper/charts/:name admit + keep a runtime Chart [Instance token]
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]
22
+ // GET /workflows/:name/runs/:runId/machine the run's bound Machine, Charts compiled [open]
23
+ //
24
+ // # `jr2 up`, and nothing else (ADR-0069)
25
+ // PUT /assembly publish the next Assembly [publish token]
26
+ // GET /assembly the current Assembly and the held ones [open]
18
27
  //
19
28
  // The token is not decoration: an Agent has code execution in its Harness container and shares the
20
29
  // pod's network namespace, so it can reach these routes. A Sandbox token may deliver ONLY to an
@@ -41,16 +50,384 @@ import { Hono } from "hono";
41
50
  import type { Context, MiddlewareHandler } from "hono";
42
51
  import { accepts } from "hono/accepts";
43
52
  import { streamSSE } from "hono/streaming";
44
- import { EventValidationError, UnknownAddressError } from "./registration.ts";
53
+ import { z } from "zod";
54
+ import {
55
+ DISPATCHED_BY_HEADER,
56
+ EventValidationError,
57
+ NotTakenError,
58
+ OutstandingError,
59
+ UnknownAddressError,
60
+ type DispatchedBy,
61
+ } from "./registration.ts";
45
62
  import type { RepoStatus } from "./repos.ts";
46
63
  import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
47
64
  import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
48
- import { observe, type RunHost } from "./run-host.ts";
65
+ import {
66
+ DispatchKeyConflictError,
67
+ keyAnswer,
68
+ keyConflict,
69
+ observe,
70
+ UnknownWorkflowError,
71
+ type RunFeedEvent,
72
+ type RunHost,
73
+ type RunStatus,
74
+ type WorkflowFeedEvent,
75
+ } from "./run-host.ts";
49
76
  import { KIT_VERSION } from "./config.ts";
77
+ import type { AssemblyDoor } from "./assembly.ts";
78
+ import { ChartRefusal } from "./chart.ts";
79
+ import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
80
+
81
+ // ---- The route table (ADR-0072) -------------------------------------------------------------
82
+ // The routes `createApp` registers, as data. It is the source of the HTTP API Reference page, so a
83
+ // row says what a CALLER needs: the address, the credential, the body, the answer. Why a route is
84
+ // in its band stays in the comment above its handler. http-routes.test.ts compares the rows with
85
+ // the app's own registrations both ways and checks each row's band on the wire, so the table
86
+ // cannot drift from the code by forgetting.
87
+
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";
91
+
92
+ /** One route of the Orchestrator's HTTP API (ADR-0072). */
93
+ export type HttpRoute = {
94
+ method: "GET" | "POST" | "PUT" | "DELETE";
95
+ /** In Hono syntax, exactly as registered: `:param`, and `:param{regex}` for a constrained one. */
96
+ path: string;
97
+ band: HttpBand;
98
+ /** One line: what the route does. */
99
+ summary: string;
100
+ /** The request body, as a type name or in short; absent when the route reads none. */
101
+ body?: string;
102
+ /** The success answer, in short. Errors are `{ error }` with the status the handler says. */
103
+ response: string;
104
+ /** `true` when the answer is a Server-Sent Events stream. */
105
+ stream: boolean;
106
+ /** The decisions that shape the route, as bare ADR numbers: `"0014"`. */
107
+ adrs: readonly string[];
108
+ };
109
+
110
+ /** Every route of the Orchestrator's HTTP API, in the order `createApp` registers them. */
111
+ export const HTTP_ROUTES: readonly HttpRoute[] = [
112
+ {
113
+ method: "GET",
114
+ path: "/healthz",
115
+ band: "open",
116
+ summary: "Liveness, and what this instance is: its Kit version and image content hash.",
117
+ response: "{ ok: true, version, hash? }",
118
+ stream: false,
119
+ adrs: ["0009", "0019"],
120
+ },
121
+ {
122
+ method: "GET",
123
+ path: "/readyz",
124
+ band: "open",
125
+ summary: "Readiness.",
126
+ response: "{ ready: true }",
127
+ stream: false,
128
+ adrs: ["0009"],
129
+ },
130
+ {
131
+ method: "GET",
132
+ path: "/workflows",
133
+ band: "open",
134
+ summary:
135
+ "The Workflows a run can start, each with its origin, and each Chart wrapper's named Charts in its entry. A named Chart that cannot run now says why.",
136
+ response: "WorkflowListing[]",
137
+ stream: false,
138
+ adrs: ["0014", "0070"],
139
+ },
140
+ {
141
+ method: "GET",
142
+ path: "/assembly",
143
+ band: "open",
144
+ summary: "The current Assembly and the Assemblies that live runs hold.",
145
+ response: "AssemblyView",
146
+ stream: false,
147
+ adrs: ["0069"],
148
+ },
149
+ {
150
+ method: "PUT",
151
+ path: "/assembly",
152
+ band: "publishOnly",
153
+ summary:
154
+ "Publish the next Assembly. `jr2 up` calls it, and nothing else. A refused Assembly leaves the current one current.",
155
+ body: "the Assembly as a tar stream, with the headers `jr2-kit-version` and `jr2-assembly-hash`",
156
+ response:
157
+ "200 with the Assembly, its Workflows, the held Assemblies and the ones collected; 400, 409 or 422 with the reason",
158
+ stream: false,
159
+ adrs: ["0069"],
160
+ },
161
+ {
162
+ method: "GET",
163
+ path: "/workflows/:wrapper/chart-schema",
164
+ band: "open",
165
+ summary: "The Chart grammar, narrowed to the Open body of one Chart wrapper.",
166
+ response: "JSON Schema",
167
+ stream: false,
168
+ adrs: ["0070"],
169
+ },
170
+ {
171
+ method: "PUT",
172
+ path: "/workflows/:wrapper/charts/:name",
173
+ band: "instanceOnly",
174
+ summary: "Admit and keep a runtime Chart under a name. Its Workflow is `<wrapper>/<name>`.",
175
+ body: "the Chart text alone",
176
+ response: "201 { name, origin, hash } when new, 200 when it replaced one; 422 { error, report } when refused",
177
+ stream: false,
178
+ adrs: ["0070"],
179
+ },
180
+ {
181
+ method: "DELETE",
182
+ path: "/workflows/:wrapper/charts/:name",
183
+ band: "instanceOnly",
184
+ summary: "Remove a runtime Chart. A live run of it keeps its own text.",
185
+ response: "204",
186
+ stream: false,
187
+ adrs: ["0070"],
188
+ },
189
+ {
190
+ method: "POST",
191
+ path: "/workflows/:wrapper/charts/:name/runs",
192
+ band: "instanceOnly",
193
+ summary: "Start a run of a named Chart, as `POST /workflows/<wrapper>%2F<name>/runs` does.",
194
+ body: "the Chart wrapper's input, without the Chart",
195
+ response: "201 { runId, instanceId }; 409 when the named Chart cannot run now",
196
+ stream: false,
197
+ adrs: ["0070"],
198
+ },
199
+ {
200
+ method: "GET",
201
+ path: "/workflows/:name/runs/:runId/machine",
202
+ band: "open",
203
+ summary: "The Machine that a run binds, with each bound Chart compiled in, which the Console draws.",
204
+ response: "MachineDoc",
205
+ stream: false,
206
+ adrs: ["0070"],
207
+ },
208
+ {
209
+ method: "GET",
210
+ path: "/",
211
+ band: "open",
212
+ summary: "The Console page to a browser; a 404 to a JSON caller.",
213
+ response: "text/html (the Console shell)",
214
+ stream: false,
215
+ adrs: ["0032"],
216
+ },
217
+ {
218
+ method: "GET",
219
+ path: "/workflows/:name",
220
+ band: "open",
221
+ summary: "The Console page to a browser; to a JSON caller, the workflow and its declared input schema.",
222
+ response: "{ name, machineId, input: JSON Schema | null }, or text/html",
223
+ stream: false,
224
+ adrs: ["0032", "0033"],
225
+ },
226
+ {
227
+ method: "POST",
228
+ path: "/workflows/:name/runs",
229
+ band: "instanceOnly",
230
+ 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.",
232
+ body: "the workflow's input, checked against its declared input schema",
233
+ 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",
235
+ stream: false,
236
+ adrs: ["0033", "0058", "0070", "0071"],
237
+ },
238
+ {
239
+ method: "GET",
240
+ path: "/workflows/:name/machine",
241
+ band: "open",
242
+ summary: "The registered template Machine's structure, which the Console draws.",
243
+ response: "MachineDoc",
244
+ stream: false,
245
+ adrs: ["0014"],
246
+ },
247
+ {
248
+ method: "GET",
249
+ path: "/workflows/:name/runs",
250
+ band: "open",
251
+ summary: "The live runs of one workflow, as observations: identity and state value, no context.",
252
+ response: "RunObservation[]",
253
+ stream: false,
254
+ adrs: ["0014"],
255
+ },
256
+ {
257
+ method: "GET",
258
+ path: "/workflows/:name/events",
259
+ band: "open",
260
+ summary: "The activity of every run of one workflow, as observations, on one connection.",
261
+ response: "SSE: runs, status, emit (type only), retry, gone",
262
+ stream: true,
263
+ adrs: ["0014", "0022"],
264
+ },
265
+ {
266
+ method: "GET",
267
+ path: "/workflows/:name/runs/:runId/events",
268
+ band: "open",
269
+ summary: "One live run's activity, as observations, until it settles.",
270
+ response: "SSE: status, emit (type only), retry",
271
+ stream: true,
272
+ adrs: ["0014", "0022"],
273
+ },
274
+ {
275
+ method: "GET",
276
+ path: "/assets/style.css",
277
+ band: "open",
278
+ summary: "The Console's stylesheet.",
279
+ response: "text/css",
280
+ stream: false,
281
+ adrs: ["0032"],
282
+ },
283
+ {
284
+ method: "GET",
285
+ path: "/assets/elk.js",
286
+ band: "open",
287
+ summary: "The Console's graph layout library (the elkjs bundle).",
288
+ response: "text/javascript",
289
+ stream: false,
290
+ adrs: ["0032"],
291
+ },
292
+ {
293
+ method: "GET",
294
+ path: "/assets/:file{[\\w.-]+\\.ts}",
295
+ band: "open",
296
+ summary: "A Console source module, with its types erased.",
297
+ response: "text/javascript",
298
+ stream: false,
299
+ adrs: ["0034"],
300
+ },
301
+ {
302
+ method: "GET",
303
+ path: "/assets/components/:file{[\\w.-]+\\.ts}",
304
+ band: "open",
305
+ summary: "A Console view module, with its types erased.",
306
+ response: "text/javascript",
307
+ stream: false,
308
+ adrs: ["0034"],
309
+ },
310
+ {
311
+ method: "GET",
312
+ path: "/assets/vendor/:file",
313
+ band: "open",
314
+ summary: "Preact's browser ESM, which the Console's import map names.",
315
+ response: "text/javascript",
316
+ stream: false,
317
+ adrs: ["0034"],
318
+ },
319
+ {
320
+ method: "GET",
321
+ path: "/runs",
322
+ band: "instanceOnly",
323
+ summary: "The live runs, with their full status.",
324
+ response: "RunStatus[]",
325
+ stream: false,
326
+ adrs: ["0009", "0014"],
327
+ },
328
+ {
329
+ method: "GET",
330
+ path: "/repos",
331
+ band: "instanceOnly",
332
+ summary: "The Repos as the cluster reports them.",
333
+ response: "{ dataPlane, repos: RepoStatus[] }",
334
+ stream: false,
335
+ adrs: ["0048", "0051"],
336
+ },
337
+ {
338
+ method: "GET",
339
+ path: "/runs/resolve",
340
+ band: "instanceOnly",
341
+ summary: "The run ids that start with a prefix (`?prefix=`, at least 4 characters).",
342
+ response: "{ prefix, runIds: string[], truncated }",
343
+ stream: false,
344
+ adrs: ["0009"],
345
+ },
346
+ {
347
+ method: "GET",
348
+ path: "/runs/:runId",
349
+ band: "instanceOnly",
350
+ summary: "One run's status, live or settled, with its open Gates.",
351
+ response: "RunStatus & { gates: GateView[] }",
352
+ stream: false,
353
+ adrs: ["0009", "0011"],
354
+ },
355
+ {
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
+ method: "POST",
359
+ path: "/runs/:runId/gates/:gate/events",
360
+ band: "instanceOnly",
361
+ summary: "Deliver an event to an open Gate of a run.",
362
+ body: "{ type, ...payload }, checked against the Gate's schema for that event",
363
+ response: "{ ok: true }; 409 { error, acceptsNow } when the run does not take it now",
364
+ stream: false,
365
+ adrs: ["0011", "0013"],
366
+ },
367
+ {
368
+ method: "GET",
369
+ path: "/runs/:runId/events",
370
+ band: "instanceOnly",
371
+ 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",
373
+ stream: true,
374
+ adrs: ["0022", "0023", "0064", "0067"],
375
+ },
376
+ {
377
+ method: "POST",
378
+ path: "/runs/:runId/events",
379
+ band: "instanceOnly",
380
+ summary: "Cancel a run: it ends, and its Workspaces go unless `keep` is true.",
381
+ body: '{ type: "CANCEL", keep?: boolean }',
382
+ response: "{ ok: true }",
383
+ stream: false,
384
+ adrs: ["0002", "0025"],
385
+ },
386
+ {
387
+ method: "GET",
388
+ path: "/agents/:instanceId/surface",
389
+ 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",
392
+ stream: false,
393
+ adrs: ["0013", "0029"],
394
+ },
395
+ {
396
+ method: "POST",
397
+ path: "/agents/:instanceId/events",
398
+ band: "authenticated",
399
+ summary: "Deliver an Agent's pick into the state that invoked it.",
400
+ body: "{ type, ...payload }, checked against the pick's input schema",
401
+ response: 'AgentDeliveryReceipt; 409 { refused: "outstanding", outstanding?, allowed }',
402
+ stream: false,
403
+ adrs: ["0013", "0024", "0029"],
404
+ },
405
+ {
406
+ method: "GET",
407
+ path: "/agents/:instanceId/deliveries/:deliveryId",
408
+ band: "authenticated",
409
+ summary: "Long-poll the answer to an outstanding pick; 503 means ask again.",
410
+ response: "AgentDeliveryReceipt",
411
+ stream: false,
412
+ adrs: ["0013", "0024"],
413
+ },
414
+ {
415
+ method: "POST",
416
+ path: "/sandboxes/:name/fetch",
417
+ band: "authenticated",
418
+ summary: "Ask the node cache to fetch one Repo that this Sandbox mounts. A Sandbox token asks only for its own.",
419
+ body: "{ identity }",
420
+ response: "FetchAnswer: { fetched } | { stale, asOf }",
421
+ stream: false,
422
+ adrs: ["0053"],
423
+ },
424
+ ];
50
425
 
51
426
  /** A `POST /runs/:id/events` body: the down-channel event. CANCEL is all that is left of it
52
- * (ADR-0013): APPROVE and STEER rode the deferred/poll machinery, which is reserved, not built. */
53
- type RunEventBody = { type?: string };
427
+ * (ADR-0013): APPROVE and STEER rode the deferred/poll machinery: `deferred`
428
+ * is retired (ADR-0006), `poll` reserved but not built.
429
+ * `keep` leaves the cancelled run's Workspaces for inspection instead of destroying them (ADR-0025). */
430
+ type RunEventBody = { type?: string; keep?: unknown };
54
431
 
55
432
  /** `GET /runs/resolve` floor — a 1-char prefix is a table scan, not a question. The CLI enforces
56
433
  * the same floor on the argument; this one guards the scan regardless of who is calling. */
@@ -72,11 +449,54 @@ async function readJson(text: Promise<string>): Promise<Record<string, unknown>>
72
449
  }
73
450
  }
74
451
 
452
+ /**
453
+ * The `jr2-dispatched-by` header a foreign `dispatch` sends (ADR-0071). `instance` is required: a
454
+ * start that comes over the door with a key comes from another Orchestrator by construction (an
455
+ * in-process dispatch never reaches the door). Unknown fields are dropped, as a door drops them: a
456
+ * caller at a later Kit version may send more.
457
+ */
458
+ const DispatchedByHeader = z.object({
459
+ run: z.string().min(1),
460
+ slot: z.string().min(1),
461
+ effect: z.string().min(1),
462
+ depth: z.number().int().positive(),
463
+ instance: z.string().min(1),
464
+ });
465
+
466
+ /** The header's {@link DispatchedBy}, absent when no header was sent, or why it is refused. */
467
+ function dispatchedByOf(raw: string | undefined): { by?: DispatchedBy } | { error: string } {
468
+ if (raw === undefined) return {};
469
+ let json: unknown;
470
+ try {
471
+ json = JSON.parse(raw);
472
+ } catch (err) {
473
+ return { error: `the ${DISPATCHED_BY_HEADER} header is not JSON: ${errMessage(err)} (ADR-0071)` };
474
+ }
475
+ const parsed = DispatchedByHeader.safeParse(json);
476
+ if (!parsed.success) {
477
+ return {
478
+ error:
479
+ `the ${DISPATCHED_BY_HEADER} header must be { run, slot, effect, depth, instance }: ` +
480
+ `${parsed.error.issues.map((i) => `${i.path.join(".") || "(header)"}: ${i.message}`).join("; ")} (ADR-0071)`,
481
+ };
482
+ }
483
+ return { by: parsed.data };
484
+ }
485
+
75
486
  /** The message of a thrown error, for the `{ error }` 404 body. */
76
487
  function errMessage(err: unknown): string {
77
488
  return err instanceof Error ? err.message : String(err);
78
489
  }
79
490
 
491
+ /** The 503 body while a deferred restore is pending (ADR-0026, ADR-0069): ask again. */
492
+ function restorePendingMessage(what: string): string {
493
+ return `the Orchestrator has not restored its runs yet (no current Assembly) — ask again for ${what}`;
494
+ }
495
+
496
+ /** Why the publish token is refused everywhere but `PUT /assembly` (ADR-0069): it is not an
497
+ * Instance principal, so it reads no run, starts none, and speaks for no Agent. */
498
+ const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
499
+
80
500
  /** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
81
501
  type JR2Env = { Variables: { principal: Principal } };
82
502
 
@@ -231,36 +651,141 @@ const PING_MS = 15_000;
231
651
  * is the `error: terminated` an attached `jr2 run` hit). The frame is an SSE COMMENT (`:\n\n`):
232
652
  * every client ignores it, so it needs no place in the wire vocabulary.
233
653
  *
234
- * It is deliberately a **ping**, not a heartbeat or keepalive — CONTEXT.md puts both on the Lease's
654
+ * It is deliberately a **ping**, not a heartbeat or keepalive — GLOSSARY.md puts both on the Lease's
235
655
  * Avoid list, and this asserts nothing and expects no answer.
236
656
  *
237
657
  * It is NOT a liveness probe, and must not be mistaken for one: `stream.write` swallows its own
238
658
  * errors (hono's `StreamingApi`), so a failed write is indistinguishable from a good one. A peer
239
659
  * that goes away is detected by `stream.onAbort`, which is what every handler here wires to its
240
660
  * exit. The tick checks `aborted`/`closed` only so a ping that fires between the abort and the
241
- * teardown does not write into a dead stream. Returns its own clear fn.
661
+ * teardown does not write into a dead stream.
662
+ *
663
+ * The tick is also where a subscriber is judged (ADR-0022): a reader {@link Subscriber.behind} is
664
+ * closed here, and the ping itself goes out through the subscriber, so a reader stalled on a quiet
665
+ * feed falls behind too. Returns its own clear fn.
242
666
  */
243
- function pinger(stream: SseStream, done: () => void, everyMs = PING_MS): () => void {
667
+ function pinger(stream: SseStream, sub: Subscriber, done: () => void, everyMs = PING_MS): () => void {
244
668
  const timer = setInterval(() => {
245
669
  if (stream.aborted || stream.closed) return done();
246
- void stream.write(":\n\n");
670
+ if (sub.behind()) {
671
+ // Aborted, not drained: the frames it would never read are dropped with it.
672
+ stream.abort();
673
+ return done();
674
+ }
675
+ void sub.send(":\n\n");
247
676
  }, everyMs);
248
677
  return () => clearInterval(timer);
249
678
  }
250
679
 
251
- /** The slice of hono's `StreamingApi` the ping needs: the raw write (`writeSSE` cannot express a
252
- * comment frame) plus the two flags that say the peer is gone. */
253
- type SseStream = { write: (s: string) => Promise<unknown>; aborted: boolean; closed: boolean };
680
+ /** The slice of hono's `StreamingApi` the feeds need: the raw write (a frame is rendered here, and
681
+ * `writeSSE` cannot express a comment frame), the two flags that say the peer is gone, and the
682
+ * abort that closes a reader that fell behind. */
683
+ type SseStream = { write: (s: string) => Promise<unknown>; aborted: boolean; closed: boolean; abort: () => void };
684
+
685
+ /**
686
+ * How many writes one subscriber may leave unread across a whole ping interval before it is closed
687
+ * (ADR-0022). A reader this far behind is not catching up, and holding frames for it is a per-client
688
+ * queue the server does not keep: the feeds are level-triggered, so closing it loses no state — the
689
+ * reader reconnects (EventSource on its own retry, the CLI on the stream's end) and the opening
690
+ * frame is the whole current truth.
691
+ */
692
+ const BACKLOG_FRAMES = 64;
693
+
694
+ /**
695
+ * One SSE frame, spelled as hono's `writeSSE` spells it. JSON has no raw newline, so the payload is
696
+ * always one `data:` line.
697
+ */
698
+ function sseFrame(event: string, data: unknown, retry?: number): string {
699
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n${retry ? `retry: ${retry}\n` : ""}\n`;
700
+ }
701
+
702
+ /**
703
+ * A run that left the live set WITHOUT settling — stopped, so parked for the next boot's restore
704
+ * (ADR-0025) — reads through as `active`, which is the truth about the Machine and says nothing
705
+ * about the feed. `gone` is a fact, not an inference (ADR-0022): stated here, so a reader that
706
+ * re-attaches whenever a feed ends before a terminal status (the CLI) knows this end is the run's
707
+ * own and does not re-attach in a loop.
708
+ */
709
+ async function sayGoneIfParked(
710
+ stream: { writeSSE: (m: { event: string; data: string }) => Promise<unknown> },
711
+ status: RunStatus,
712
+ ): Promise<void> {
713
+ if (status.status !== "active") return;
714
+ await stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: status.runId }) });
715
+ }
716
+
717
+ /**
718
+ * Render each feed event ONCE, however many subscribers share it (ADR-0022). The host hands every
719
+ * listener of a feed the same event object, so the object is the key, and the frame is released
720
+ * with it. `undefined` is a rendering too — "this band does not carry it" — and is cached alike.
721
+ */
722
+ function renderOnce<E extends object, F>(render: (event: E) => F): (event: E) => F {
723
+ const frames = new WeakMap<E, F>();
724
+ return (event) => {
725
+ if (frames.has(event)) return frames.get(event) as F;
726
+ const frame = render(event);
727
+ frames.set(event, frame);
728
+ return frame;
729
+ };
730
+ }
731
+
732
+ /** One subscriber's writes, and the one question asked of them at each ping. */
733
+ type Subscriber = {
734
+ /** Write a shared frame as-is. */
735
+ send: (frame: string) => Promise<void>;
736
+ /** Whether {@link BACKLOG_FRAMES} writes sent before the previous ping are still unread. */
737
+ behind: () => boolean;
738
+ };
739
+
740
+ /**
741
+ * One subscriber (ADR-0022): the only state kept per client is a count of its writes. A reader is
742
+ * judged on LAG, never on burst size — one transition's Emits, or a Pool's statuses, reach every
743
+ * subscriber in one tick, and a reader that keeps up drains them long before the next ping. What is
744
+ * still unread a whole interval after it was sent is a reader that fell behind.
745
+ */
746
+ function subscriber(stream: SseStream): Subscriber {
747
+ let sent = 0;
748
+ let read = 0;
749
+ let sentByLastPing = 0;
750
+ return {
751
+ async send(frame) {
752
+ sent++;
753
+ try {
754
+ await stream.write(frame);
755
+ } finally {
756
+ read++; // a stream's writes settle in order, so this counts a prefix
757
+ }
758
+ },
759
+ behind() {
760
+ const unread = sentByLastPing - read;
761
+ sentByLastPing = sent;
762
+ return unread >= BACKLOG_FRAMES;
763
+ },
764
+ };
765
+ }
254
766
 
255
767
  export type CreateAppOptions = {
256
768
  /** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
257
769
  pingMs?: number;
258
770
  /**
259
- * Whether this instance has a data plane at all (ADR-0051): a registered Machine composes a
260
- * Sandbox AND the process is deployed in a cluster. `false` is an answer — "this instance runs
261
- * no Workspace" — and `jr2 status` says so rather than listing nothing.
771
+ * Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
772
+ * a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
773
+ * answer — "this instance runs no Workspace" — and `jr2 status` says so rather than listing
774
+ * nothing.
262
775
  */
263
776
  dataPlane?: boolean;
777
+ /**
778
+ * The publish door and the current Assembly (ADR-0069). Absent for the in-process tests that
779
+ * build an app straight over a host: `GET /assembly` then answers no current Assembly, and
780
+ * `PUT /assembly` answers 404.
781
+ */
782
+ assembly?: AssemblyDoor;
783
+ /**
784
+ * 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.
787
+ */
788
+ mounts?: () => Promise<Record<string, string>>;
264
789
  /**
265
790
  * The Repo resources as the cluster currently reports them (ADR-0051), read per request off the
266
791
  * port — a cache the agent cloned minutes after boot reads as present the next time anyone asks.
@@ -275,6 +800,17 @@ export type CreateAppOptions = {
275
800
  * ask, and the route says so rather than waiting on a cluster that is not there.
276
801
  */
277
802
  fetchRepo?: (sandbox: string, identity: string) => Promise<FetchAnswer>;
803
+ /** How long one poll of an outstanding pick waits for its answer before it answers `outstanding`
804
+ * (ADR-0013, ADR-0024). Default 25 s: every request stays far below undici's 300 s and the
805
+ * Custodian's idle limit. Tests shorten it. */
806
+ deliveryWaitMs?: number;
807
+ /**
808
+ * The named Charts of the Instance (ADR-0070): what `PUT`/`DELETE /workflows/:wrapper/charts/:name`
809
+ * change, what `GET /workflows/:wrapper/chart-schema` narrows the grammar from, and the
810
+ * unrunnable ones `GET /workflows` adds to the host's listing. Absent for the in-process tests
811
+ * that build an app straight over a host: those routes then answer 404.
812
+ */
813
+ charts?: NamedCharts;
278
814
  };
279
815
 
280
816
  /**
@@ -294,6 +830,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
294
830
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
295
831
  const principal = auth(bearerOf(c));
296
832
  if (!principal) return c.json({ error: "unauthorized" }, 401);
833
+ if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
297
834
  c.set("principal", principal);
298
835
  return next();
299
836
  };
@@ -312,6 +849,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
312
849
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
313
850
  const principal = auth(bearerOf(c));
314
851
  if (!principal) return c.json({ error: "unauthorized" }, 401);
852
+ if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
315
853
  if (principal.kind !== "instance") {
316
854
  return c.json({ error: "run state and control need the Instance token — a Sandbox token has neither" }, 403);
317
855
  }
@@ -323,16 +861,183 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
323
861
  // readiness probe's target and because identity is not run state — it is the same class of thing
324
862
  // as the route table, which is public by being served. The CLI probes it to explain a failure it
325
863
  // could otherwise only report as a bare status code (version skew reads as a nonsense 404).
326
- // `hash` is the image's content address (ADR-0019), absent for a host-booted fixture process,
327
- // which has no image to be addressed.
328
- app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: process.env.JR2_CONTENT_HASH }));
864
+ // `hash` is the current Assembly's (ADR-0069), absent while the process holds none.
865
+ app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
329
866
  app.get("/readyz", (c) => c.json({ ready: true }));
330
867
 
331
868
  // Structure, not state: the workflow listing, a template's Machine, and the Console shell are
332
869
  // unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
333
870
  // loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
334
871
  // below.
335
- app.get("/workflows", (c) => c.json(host.workflows()));
872
+ // Each Workflow with its origin, and each Chart wrapper's Charts inside its entry (ADR-0070); a
873
+ // named Chart that cannot run now is listed with why.
874
+ app.get("/workflows", (c) => {
875
+ const listing = host.workflowList();
876
+ return c.json(opts.charts ? opts.charts.listing(listing) : listing);
877
+ });
878
+
879
+ // The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
880
+ // 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.
883
+ app.get("/assembly", async (c) => {
884
+ const view = opts.assembly ? await opts.assembly.view() : { kitVersion: KIT_VERSION, current: null, held: [] };
885
+ return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
886
+ });
887
+
888
+ // The publish door (ADR-0069): the publish token ALONE. Publishing imports code into the process
889
+ // that holds the ServiceAccount, so the Instance token, which starts runs and answers Gates, is
890
+ // refused here by name. The checks after authentication are the door's (assembly.ts), each with
891
+ // its own status, and the old Assembly stays current through every refusal.
892
+ app.put("/assembly", async (c) => {
893
+ if (auth) {
894
+ const principal = auth(bearerOf(c));
895
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
896
+ if (principal.kind !== "publisher") {
897
+ return c.json(
898
+ {
899
+ error:
900
+ "publishing an Assembly needs the publish token (JR2_PUBLISH_TOKEN in the instance Secret), which " +
901
+ "`jr2 up` reads through the kube API — the Instance token does not publish (ADR-0069)",
902
+ },
903
+ 403,
904
+ );
905
+ }
906
+ }
907
+ if (!opts.assembly) return c.json({ error: "this instance has no publish door" }, 404);
908
+ const answer = await opts.assembly.publish({
909
+ kitVersion: c.req.header("jr2-kit-version"),
910
+ hash: c.req.header("jr2-assembly-hash"),
911
+ body: c.req.raw.body,
912
+ });
913
+ return c.json(answer.body, answer.status);
914
+ });
915
+
916
+ // ---- Named Charts (ADR-0070) ----------------------------------------------------------------
917
+ // Before every `/workflows/:name/*` route. A Chart is data and cannot reach the process, so a
918
+ // runtime Chart is kept behind the Instance token, not the publish token; the door that admits it
919
+ // is the one a run's Chart passes. A Chart file is the Assembly's own: no route changes one.
920
+
921
+ /** A `NamedChartError` as its answer. */
922
+ const chartError = (c: Context<JR2Env>, err: unknown): Response => {
923
+ if (err instanceof NamedChartError) return c.json(err.body, err.status);
924
+ throw err;
925
+ };
926
+ const noCharts = (c: Context<JR2Env>) => c.json({ error: "this instance keeps no named Charts" }, 404);
927
+
928
+ // The grammar narrowed to the wrapper's Open body: its lent names, guard, action and delay names,
929
+ // allowed models and Workspace accesses as `enum`s. Structure, like the Machine doc: open band.
930
+ app.get("/workflows/:wrapper/chart-schema", (c) => {
931
+ const wrapper = c.req.param("wrapper");
932
+ const schema = opts.charts?.schema(wrapper);
933
+ return schema ? c.json(schema) : c.json({ error: `no Chart wrapper "${wrapper}"` }, 404);
934
+ });
935
+
936
+ // The body is the Chart text alone. 201 when new, 200 when it replaced one: a live run of the
937
+ // old text holds its own.
938
+ app.put("/workflows/:wrapper/charts/:name", instanceOnly, async (c) => {
939
+ if (!opts.charts) return noCharts(c);
940
+ let text: unknown;
941
+ try {
942
+ text = JSON.parse(await c.req.text());
943
+ } catch (err) {
944
+ return c.json({ error: "refused", report: [`(chart): the body is not JSON: ${errMessage(err)}`] }, 422);
945
+ }
946
+ try {
947
+ const put = await opts.charts.put(c.req.param("wrapper"), c.req.param("name"), text);
948
+ // `unrunnable`: a publish bound a wrapper that refuses the text while it was written. It is
949
+ // kept on the volume and listed with that reason, as each publish's re-admission does.
950
+ return c.json(
951
+ {
952
+ name: put.name,
953
+ origin: "runtime",
954
+ hash: put.hash,
955
+ ...(put.unrunnable !== undefined ? { unrunnable: put.unrunnable } : {}),
956
+ },
957
+ put.created ? 201 : 200,
958
+ );
959
+ } catch (err) {
960
+ return chartError(c, err);
961
+ }
962
+ });
963
+
964
+ app.delete("/workflows/:wrapper/charts/:name", instanceOnly, async (c) => {
965
+ if (!opts.charts) return noCharts(c);
966
+ try {
967
+ await opts.charts.remove(c.req.param("wrapper"), c.req.param("name"));
968
+ return c.body(null, 204);
969
+ } catch (err) {
970
+ return chartError(c, err);
971
+ }
972
+ });
973
+
974
+ /**
975
+ * Start a run of a Workflow (ADR-0009). A named Chart that cannot run now is 409 with why
976
+ * (ADR-0070); a Chart in the input that the door refuses is 422 with the report; input failing
977
+ * the door schema is 400 (ADR-0033); an unknown Workflow is 404.
978
+ *
979
+ * A foreign `dispatch` sends `jr2-dispatched-by` (ADR-0071): its key makes a repeated start one
980
+ * 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
983
+ * Console send none. Any throw that is not a refusal is a defect, 500: a foreign caller reads a
984
+ * 404 as "nothing was started", and a defect may come after a start.
985
+ */
986
+ const startRun = async (c: Context<JR2Env>, name: string): Promise<Response> => {
987
+ const header = dispatchedByOf(c.req.header(DISPATCHED_BY_HEADER));
988
+ if ("error" in header) return c.json({ error: header.error }, 400);
989
+ const { by } = header;
990
+ 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);
994
+ }
995
+ const unrunnable = opts.charts?.get(name)?.unrunnable;
996
+ if (unrunnable !== undefined) return c.json({ error: `named Chart "${name}" cannot run: ${unrunnable}` }, 409);
997
+ const input = await readJson(c.req.text());
998
+ try {
999
+ const { runId, instanceId, existing } = await host.start(name, input, by ? { dispatchedBy: by } : {});
1000
+ return existing ? c.json({ runId, instanceId, existing }, 200) : c.json({ runId, instanceId }, 201);
1001
+ } catch (err) {
1002
+ if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
1003
+ if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1004
+ if (err instanceof DispatchKeyConflictError) return c.json({ error: errMessage(err) }, 409);
1005
+ if (err instanceof UnknownWorkflowError) return c.json({ error: errMessage(err) }, 404);
1006
+ return c.json({ error: errMessage(err) }, 500);
1007
+ }
1008
+ };
1009
+
1010
+ // 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) =>
1012
+ startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
1013
+ );
1014
+
1015
+ // The run's bound Machine (ADR-0070): the wrapper's doc with each bound body's Chart compiled in,
1016
+ // so the Console draws the states a Chart run moves through. Structure: open band, and only
1017
+ // through the Workflow the run is of, as the per-run feed is: an observer names the Workflow
1018
+ // it already knows. A settled run's doc is served too, so the Console draws where it ended.
1019
+ app.get("/workflows/:name/runs/:runId/machine", async (c) => {
1020
+ const runId = c.req.param("runId");
1021
+ const doc = await host.runMachine(runId);
1022
+ return doc && doc.workflow === c.req.param("name")
1023
+ ? c.json(doc)
1024
+ : c.json({ error: `no run "${runId}" of workflow "${c.req.param("name")}"` }, 404);
1025
+ });
1026
+
1027
+ /** A named Chart that is listed but cannot run now (ADR-0070): 409 with why, as its start is,
1028
+ * never a 404 for a name `GET /workflows` lists. */
1029
+ const unrunnableChart = (c: Context<JR2Env>, name: string): Response | undefined => {
1030
+ const named = opts.charts?.get(name);
1031
+ if (named?.unrunnable === undefined) return undefined;
1032
+ return c.json(
1033
+ {
1034
+ error: `named Chart "${name}" cannot run: ${named.unrunnable}`,
1035
+ unrunnable: named.unrunnable,
1036
+ chart: { origin: named.origin, hash: named.hash },
1037
+ },
1038
+ 409,
1039
+ );
1040
+ };
336
1041
 
337
1042
  // The two PAGE addresses (ADR-0032): `/` and `/workflows/:name` negotiate. A browser navigation
338
1043
  // (`Accept` prefers text/html) gets the one Console shell — the path carries the selection, the
@@ -351,34 +1056,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
351
1056
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
352
1057
  const name = c.req.param("name");
353
1058
  const doc = host.machine(name);
354
- if (!doc) return c.json({ error: `no workflow "${name}"` }, 404);
355
- return c.json({ name, machineId: doc.id, input: host.inputSchema(name) ?? null });
1059
+ if (!doc) return unrunnableChart(c, name) ?? c.json({ error: `no workflow "${name}"` }, 404);
1060
+ // A named Chart answers its door minus the Chart fields, and its origin and hash; a Chart
1061
+ // wrapper answers its Open bodies' declarations, for a client that writes Charts (ADR-0070).
1062
+ const split = splitChartName(name);
1063
+ const named = split && (opts.charts?.get(name) ?? host.namedChart(split.wrapper, split.name));
1064
+ const wrapper = split ? undefined : opts.charts?.wrapperOf(name);
1065
+ return c.json({
1066
+ name,
1067
+ machineId: doc.id,
1068
+ input: host.inputSchema(name) ?? null,
1069
+ ...(named ? { chart: { origin: named.origin, hash: named.hash } } : {}),
1070
+ ...(wrapper ? { openBodies: wrapper.bodies.map((b) => ({ slot: b.slot, ...b.declaration })) } : {}),
1071
+ });
356
1072
  });
357
1073
 
358
- // Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
1074
+ // Push work: start a run of a registered workflow. Unknown workflow → UnknownWorkflowError → 404.
359
1075
  // A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
360
1076
  // shape — the same error class, and the same wire mapping, as a gate delivery failing its
361
1077
  // schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
362
1078
  // run control, and a Sandbox token's scope is its own agent surface — the Custodian's route
363
1079
  // allowlist not forwarding this route is a second layer, not the control (ADR-0059).
364
- app.post("/workflows/:name/runs", instanceOnly, async (c) => {
365
- const name = c.req.param("name");
366
- const input = await readJson(c.req.text());
367
- try {
368
- const { runId, instanceId } = await host.start(name, input);
369
- return c.json({ runId, instanceId }, 201);
370
- } catch (err) {
371
- if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
372
- return c.json({ error: errMessage(err) }, 404);
373
- }
374
- });
1080
+ // A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
1081
+ // 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")));
375
1083
 
376
1084
  // The registered template Machine's structure — what the Console's diagram renders (structure
377
1085
  // is provider-independent, so the un-`provide()`d template is exactly right).
378
1086
  app.get("/workflows/:name/machine", (c) => {
379
1087
  const name = c.req.param("name");
380
1088
  const doc = host.machine(name);
381
- return doc ? c.json(doc) : c.json({ error: `no workflow "${name}"` }, 404);
1089
+ return doc ? c.json(doc) : (unrunnableChart(c, name) ?? c.json({ error: `no workflow "${name}"` }, 404));
382
1090
  });
383
1091
 
384
1092
  // ---- Observation (`GET /workflows/:name/runs*`) ---------------------------------------------
@@ -395,6 +1103,18 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
395
1103
 
396
1104
  app.get("/workflows/:name/runs", (c) => c.json(host.observations(c.req.param("name"))));
397
1105
 
1106
+ // The workflow feed, rendered once per event for every page attached to it (ADR-0022): the
1107
+ // `observe()` projection and its JSON happen once, not once per open page.
1108
+ const workflowFrame = renderOnce((ev: Exclude<WorkflowFeedEvent, { kind: "closed" }>) => {
1109
+ if (ev.kind === "gone") return sseFrame("gone", { runId: ev.runId });
1110
+ // The TYPE alone: an emit's payload is author data, the same class of thing as context.
1111
+ if (ev.kind === "emit") return sseFrame("emit", { runId: ev.runId, type: ev.event.type });
1112
+ // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
1113
+ // Instance token like `fault` (ADR-0014/0016).
1114
+ if (ev.kind === "retry") return sseFrame("retry", { runId: ev.runId, child: ev.child, attempt: ev.attempt });
1115
+ return sseFrame("status", observe(ev.status));
1116
+ });
1117
+
398
1118
  /**
399
1119
  * SSE: a whole WORKFLOW's activity (ADR-0022) — every run of it appearing, moving, emitting and
400
1120
  * leaving, on one connection that outlives all of them.
@@ -416,40 +1136,46 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
416
1136
  return streamSSE(c, async (stream) => {
417
1137
  await new Promise<void>((resolve) => {
418
1138
  const exit = closer(resolve);
1139
+ const sub = subscriber(stream);
419
1140
  // Subscribe and snapshot in one call, then write the snapshot in the same tick: nothing can
420
1141
  // start, move or finish in between, so the client's first frame is a complete picture.
421
1142
  const { runs, unsubscribe } = host.observeWorkflow(name, (ev) => {
422
1143
  if (ev.kind === "closed") return exit.done();
423
- if (ev.kind === "gone") {
424
- void stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: ev.runId }) });
425
- return;
426
- }
427
- if (ev.kind === "emit") {
428
- // The TYPE alone: an emit's payload is author data, the same class of thing as context.
429
- void stream.writeSSE({ event: "emit", data: JSON.stringify({ runId: ev.runId, type: ev.event.type }) });
430
- return;
431
- }
432
- if (ev.kind === "retry") {
433
- // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
434
- // Instance token like `fault` (ADR-0014/0016).
435
- void stream.writeSSE({
436
- event: "retry",
437
- data: JSON.stringify({ runId: ev.runId, child: ev.child, attempt: ev.attempt }),
438
- });
439
- return;
440
- }
441
- void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) });
1144
+ void sub.send(workflowFrame(ev));
442
1145
  });
443
1146
  exit.onExit(unsubscribe);
444
1147
  // `retry` steers the browser's own EventSource backoff. This feed never ends on its own, so
445
- // every close is a fault worth reconnecting from — the client does not decide that.
446
- void stream.writeSSE({ event: "runs", data: JSON.stringify(runs.map(observe)), retry: 2000 });
447
- exit.onExit(pinger(stream, exit.done, pingMs));
1148
+ // every close is a fault worth reconnecting from — the client does not decide that. The
1149
+ // snapshot is this subscriber's own, so it is the one frame rendered per client.
1150
+ void sub.send(sseFrame("runs", runs.map(observe), 2000));
1151
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
448
1152
  stream.onAbort(exit.done);
449
1153
  });
450
1154
  });
451
1155
  });
452
1156
 
1157
+ // The per-run feed in the OPEN band, rendered once per event for all its subscribers (ADR-0022).
1158
+ // `undefined` = this band does not carry it.
1159
+ const openRunFrame = renderOnce((ev: Exclude<RunFeedEvent, { kind: "closed" }>) => {
1160
+ // The TYPE alone: an emit's payload is author data, the same class of thing as context.
1161
+ if (ev.kind === "emit") return sseFrame("emit", { type: ev.event.type });
1162
+ // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
1163
+ // Instance token like `fault` (ADR-0014/0016).
1164
+ if (ev.kind === "retry") return sseFrame("retry", { child: ev.child, attempt: ev.attempt });
1165
+ // Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token class, so
1166
+ // the OPEN band never sees them (not even their types).
1167
+ if (ev.kind === "admission" || ev.kind === "pick") return undefined;
1168
+ // A wait for capacity's lines carry the scheduler's words (ADR-0064): behind the token. The
1169
+ // open band sees `placing` lit in the status's children, and nothing more.
1170
+ if (ev.kind === "placing" || ev.kind === "placed") return undefined;
1171
+ // A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
1172
+ // token.
1173
+ if (ev.kind === "child-error") return undefined;
1174
+ // An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
1175
+ if (ev.kind === "effect") return undefined;
1176
+ return sseFrame("status", observe(ev.status));
1177
+ });
1178
+
453
1179
  app.get("/workflows/:name/runs/:runId/events", async (c) => {
454
1180
  const name = c.req.param("name");
455
1181
  const runId = c.req.param("runId");
@@ -460,28 +1186,17 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
460
1186
  return streamSSE(c, async (stream) => {
461
1187
  await new Promise<void>((resolve) => {
462
1188
  const exit = closer(resolve);
1189
+ const sub = subscriber(stream);
463
1190
  exit.onExit(
464
1191
  host.subscribe(runId, (ev) => {
465
1192
  // The host is shutting down under a feed that has no end of its own.
466
1193
  if (ev.kind === "closed") return exit.done();
467
- if (ev.kind === "emit") {
468
- // The TYPE alone: an emit's payload is author data, the same class of thing as context.
469
- void stream.writeSSE({ event: "emit", data: JSON.stringify({ type: ev.event.type }) });
470
- return;
471
- }
472
- if (ev.kind === "retry") {
473
- // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind
474
- // the Instance token like `fault` (ADR-0014/0016).
475
- void stream.writeSSE({ event: "retry", data: JSON.stringify({ child: ev.child, attempt: ev.attempt }) });
476
- return;
477
- }
478
- // Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token
479
- // class, so the OPEN band never sees them (not even their types).
480
- if (ev.kind === "admission" || ev.kind === "pick") return;
1194
+ const frame = openRunFrame(ev);
1195
+ if (frame === undefined) return;
481
1196
  // Terminal frame must flush before the handler returns and closes the stream (see the
482
1197
  // guarded feed below for why the exit is chained off the write).
483
- const terminal = ev.status.status !== "active";
484
- void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) }).then(() => {
1198
+ const terminal = ev.kind === "status" && ev.status.status !== "active";
1199
+ void sub.send(frame).then(() => {
485
1200
  if (terminal) exit.done();
486
1201
  });
487
1202
  }),
@@ -489,7 +1204,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
489
1204
  // Race guard: settled between the liveness check and the subscribe, which then attached to
490
1205
  // nothing. No read-through on this route, so there is nothing to fall back to — just close.
491
1206
  if (host.status(runId) === undefined) return exit.done();
492
- exit.onExit(pinger(stream, exit.done, pingMs));
1207
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
493
1208
  stream.onAbort(exit.done);
494
1209
  });
495
1210
  });
@@ -587,10 +1302,38 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
587
1302
  } catch (err) {
588
1303
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
589
1304
  if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1305
+ // The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
1306
+ // names what the Gate takes now, as `GET /runs/:id` lists it.
1307
+ if (err instanceof NotTakenError) {
1308
+ const runId = c.req.param("runId");
1309
+ const gate = c.req.param("gate");
1310
+ const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
1311
+ return c.json({ error: errMessage(err), acceptsNow }, 409);
1312
+ }
590
1313
  throw err;
591
1314
  }
592
1315
  });
593
1316
 
1317
+ // The per-run feed in the Instance band, rendered once per event for all its subscribers
1318
+ // (ADR-0022).
1319
+ const runFrame = renderOnce((ev: Exclude<RunFeedEvent, { kind: "closed" }>) => {
1320
+ if (ev.kind === "emit") return sseFrame("emit", ev.event);
1321
+ // The Instance's own feed: the full telemetry, reason included (same trust class as `fault`).
1322
+ if (ev.kind === "retry") return sseFrame("retry", ev);
1323
+ // Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole. These stay
1324
+ // OFF the open workflow feed entirely (run-host.ts feeds them per-run).
1325
+ if (ev.kind === "admission" || ev.kind === "pick") return sseFrame(ev.kind, ev);
1326
+ // A wait for capacity starting and ending (ADR-0064) — Instance-token band too: the message is
1327
+ // the scheduler's or the quota's own words.
1328
+ if (ev.kind === "placing" || ev.kind === "placed") return sseFrame(ev.kind, ev);
1329
+ // A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
1330
+ // the operator reads it here, with its message.
1331
+ if (ev.kind === "child-error") return sseFrame(ev.kind, ev);
1332
+ // An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
1333
+ if (ev.kind === "effect") return sseFrame("effect", ev);
1334
+ return sseFrame("status", ev.status);
1335
+ });
1336
+
594
1337
  // SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
595
1338
  // then live until the terminal transition or client abort). A run that has already settled streams
596
1339
  // its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
@@ -601,35 +1344,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
601
1344
  if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
602
1345
  return streamSSE(c, async (stream) => {
603
1346
  await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
1347
+ await sayGoneIfParked(stream, finalStatus);
604
1348
  });
605
1349
  }
606
1350
  return streamSSE(c, async (stream) => {
607
1351
  await new Promise<void>((resolve) => {
608
1352
  const exit = closer(resolve);
1353
+ const sub = subscriber(stream);
609
1354
  exit.onExit(
610
1355
  host.subscribe(runId, (ev) => {
611
1356
  if (ev.kind === "closed") return exit.done();
612
- if (ev.kind === "emit") {
613
- void stream.writeSSE({ event: "emit", data: JSON.stringify(ev.event) });
614
- return;
615
- }
616
- if (ev.kind === "retry") {
617
- // The Instance's own feed: the full telemetry, reason included (same trust class as
618
- // `fault`).
619
- void stream.writeSSE({ event: "retry", data: JSON.stringify(ev) });
620
- return;
621
- }
622
- if (ev.kind === "admission" || ev.kind === "pick") {
623
- // Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole.
624
- // These stay OFF the open workflow feed entirely (run-host.ts feeds them per-run).
625
- void stream.writeSSE({ event: ev.kind, data: JSON.stringify(ev) });
626
- return;
627
- }
628
1357
  // Exiting lets the handler return, which CLOSES the stream — so on the terminal frame we
629
1358
  // must wait for the write to flush first, or a fire-and-forget write races the close and the
630
1359
  // final status is dropped (the very frame `jr2 run` blocks on). Chain the exit off the write.
631
- const terminal = ev.status.status !== "active";
632
- void stream.writeSSE({ event: "status", data: JSON.stringify(ev.status) }).then(() => {
1360
+ const terminal = ev.kind === "status" && ev.status.status !== "active";
1361
+ void sub.send(runFrame(ev)).then(() => {
633
1362
  if (terminal) exit.done();
634
1363
  });
635
1364
  }),
@@ -638,24 +1367,27 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
638
1367
  // which then attaches to nothing and never fires. Fall back to the terminal read-through.
639
1368
  if (host.status(runId) === undefined) {
640
1369
  exit.release();
641
- void host.read(runId).then((s) => {
642
- if (s) void stream.writeSSE({ event: "status", data: JSON.stringify(s) });
1370
+ void host.read(runId).then(async (s) => {
1371
+ if (s) {
1372
+ await stream.writeSSE({ event: "status", data: JSON.stringify(s) });
1373
+ await sayGoneIfParked(stream, s);
1374
+ }
643
1375
  exit.done();
644
1376
  });
645
1377
  return;
646
1378
  }
647
- exit.onExit(pinger(stream, exit.done, pingMs));
1379
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
648
1380
  stream.onAbort(exit.done);
649
1381
  });
650
1382
  });
651
1383
  });
652
1384
 
653
1385
  // Run control (ADR-0002). CANCEL is the only event left on this seam: APPROVE and STEER answered
654
- // held `deferred` calls and drained `poll` inboxes, and ADR-0013 reserves both semantics without
655
- // building them. Workflow-defined events reach a run through its GATES, not through here.
1386
+ // held calls and drained `poll` inboxes; a held call is now every pick answered at Rest (ADR-0024),
1387
+ // and `poll` stays reserved (ADR-0013). Workflow-defined events reach a run through its GATES.
656
1388
  //
657
- // It ENDS the run (ADR-0025): the Agents' turns end with it and the run does not come back on
658
- // the next restore. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
1389
+ // It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
1390
+ // 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
659
1391
  // deliberately not on the wire.
660
1392
  app.post("/runs/:runId/events", instanceOnly, async (c) => {
661
1393
  const runId = c.req.param("runId");
@@ -663,8 +1395,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
663
1395
  if (body.type !== "CANCEL") {
664
1396
  return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
665
1397
  }
1398
+ // Whether to keep is a fact of the moment of the cancel, never a config key (ADR-0025).
1399
+ if (body.keep !== undefined && typeof body.keep !== "boolean") {
1400
+ return c.json({ error: `"keep" must be a boolean` }, 400);
1401
+ }
666
1402
  try {
667
- await host.cancel(runId);
1403
+ await host.cancel(runId, { keep: body.keep === true });
668
1404
  return c.json({ ok: true });
669
1405
  } catch (err) {
670
1406
  return c.json({ error: errMessage(err) }, 404);
@@ -685,7 +1421,23 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
685
1421
  /** Guard: the surface must exist, and this principal must be allowed to speak for it. */
686
1422
  const agentRegistration = (c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events">) => {
687
1423
  const instanceId = c.req.param("instanceId");
1424
+ // Only a Turn that is over answers 404 (ADR-0026). While the host is stopping its runs are
1425
+ // parked for restore, not over, so every `/agents/*` call answers 503: the Harness re-asks a
1426
+ // surface read on ADR-0042's ladder and tells a pick to try again. BEFORE the lookup, because
1427
+ // a stopping host may already have lost the registration the question is about.
1428
+ if (host.stopping) {
1429
+ return {
1430
+ error: c.json({ error: `the Orchestrator is stopping — ask again for instance "${instanceId}"` }, 503),
1431
+ };
1432
+ }
688
1433
  const surface = host.agentSurface(instanceId);
1434
+ // A boot with no current Assembly listens before it restores (ADR-0069): until the first
1435
+ // publish restores the waiting runs, a registration this host does not have may be one of
1436
+ // theirs, and that Turn is not over either. AFTER the lookup, so a run that is hosted already
1437
+ // (re-attached, or started under the published Assembly) is served.
1438
+ if (!surface && host.restorePending) {
1439
+ return { error: c.json({ error: restorePendingMessage(`instance "${instanceId}"`) }, 503) };
1440
+ }
689
1441
  // The one catch point (ADR-0011): no live registration (settled run, exited state, unknown
690
1442
  // iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
691
1443
  // token learns nothing from it that it did not already know.
@@ -698,18 +1450,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
698
1450
  return { surface };
699
1451
  };
700
1452
 
701
- // This turn's menu: the events the invoking state accepts, their input schemas, their semantics.
702
- // A transition swaps the registration, which swaps this — so the Harness gets a state-scoped
703
- // toolset for free, and needs no `list_changed` to know it (flue re-lists on every submission).
1453
+ // The Agent's Menu — every pick it can be offered in this Machine, with input schemas and
1454
+ // semantics, fixed for the conversation — and this Turn's Allowed picks (ADR-0029). A transition
1455
+ // swaps the registration, which swaps the Allowed picks, never the Menu; the Harness reads both
1456
+ // per Submission and needs no `list_changed`.
704
1457
  app.get("/agents/:instanceId/surface", authenticated, (c) => {
705
1458
  const { surface, error } = agentRegistration(c);
706
- return error ?? c.json(surface);
1459
+ if (error) return error;
1460
+ host.agentSurfaceServed(c.req.param("instanceId"));
1461
+ return c.json(surface);
707
1462
  });
708
1463
 
709
1464
  // The Agent's pick, delivered into the state that invoked it. The receipt describes itself
710
1465
  // (ADR-0024): what was delivered, and whether that ended the turn — which the Harness renders as
711
- // prose. Its `deliveryId` still makes an outcome addressable after the fact, the room a deferred
712
- // result will need when it lands.
1466
+ // prose. Its `deliveryId` makes an outstanding pick addressable after the fact: its answer is
1467
+ // polled at `GET /agents/:iid/deliveries/:id`.
713
1468
  app.post("/agents/:instanceId/events", authenticated, async (c) => {
714
1469
  const { error } = agentRegistration(c);
715
1470
  if (error) return error;
@@ -718,11 +1473,59 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
718
1473
  return c.json(host.sendToAgent(c.req.param("instanceId"), body));
719
1474
  } catch (err) {
720
1475
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
721
- if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
1476
+ // One pick at a time (ADR-0024): refused, nothing delivered, and nothing is allowed until the
1477
+ // workflow is back at Rest. `outstanding` names the pick still running, when there is one; the
1478
+ // Harness writes the words.
1479
+ if (err instanceof OutstandingError) {
1480
+ return c.json(
1481
+ {
1482
+ error: errMessage(err),
1483
+ refused: "outstanding",
1484
+ ...(err.outstanding !== undefined ? { outstanding: err.outstanding } : {}),
1485
+ allowed: [],
1486
+ },
1487
+ 409,
1488
+ );
1489
+ }
1490
+ if (err instanceof EventValidationError) {
1491
+ // The Allowed picks ride the refusal (ADR-0029), so "Allowed now: …" can follow it.
1492
+ return c.json({ error: errMessage(err), ...(err.allowed ? { allowed: err.allowed } : {}) }, 400);
1493
+ }
722
1494
  throw err;
723
1495
  }
724
1496
  });
725
1497
 
1498
+ // An outstanding pick's answer (ADR-0013, ADR-0024), long-polled: the POST answered at once with a
1499
+ // delivery id, and the Harness asks here inside the pick's one tool call, up to `deliveryWaitMs`
1500
+ // per request, until the workflow is back at Rest in the Agent's state or has left it. A GET is
1501
+ // safe to repeat, so a reset or a restarting Orchestrator costs a retry, never the pick: a stopping
1502
+ // or parked host answers 503 — ask again — and the restored host answers from the saved hold.
1503
+ // Scoped like the pick: the Sandbox the registration had when the pick was made, because the
1504
+ // answer outlives a registration that a route out of the Agent's state ends.
1505
+ app.get("/agents/:instanceId/deliveries/:deliveryId", authenticated, async (c) => {
1506
+ const instanceId = c.req.param("instanceId");
1507
+ const deliveryId = c.req.param("deliveryId");
1508
+ if (host.stopping || host.parkedDelivery(instanceId, deliveryId)) {
1509
+ return c.json({ error: `the Orchestrator is stopping — ask again for delivery "${deliveryId}"` }, 503);
1510
+ }
1511
+ const scope = host.deliveryScope(instanceId, deliveryId);
1512
+ // Not restored yet is not over (ADR-0026, ADR-0069): the restored host answers from the hold.
1513
+ if (!scope && host.restorePending) {
1514
+ return c.json({ error: restorePendingMessage(`delivery "${deliveryId}"`) }, 503);
1515
+ }
1516
+ if (!scope) return c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
1517
+ if (!mayDeliverToAgent(principalOf(c), scope.sandbox)) {
1518
+ return c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403);
1519
+ }
1520
+ const receipt = await host.awaitDelivery(instanceId, deliveryId, opts.deliveryWaitMs, c.req.raw.signal);
1521
+ if (host.stopping) {
1522
+ return c.json({ error: `the Orchestrator is stopping — ask again for delivery "${deliveryId}"` }, 503);
1523
+ }
1524
+ return receipt
1525
+ ? c.json(receipt)
1526
+ : c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
1527
+ });
1528
+
726
1529
  // The pod's one route out (ADR-0053). A `git fetch` inside a Sandbox runs a program on the
727
1530
  // runtime volume, which asks the Custodian on localhost, which forwards to this. What lands here
728
1531
  // is one identity; what goes back is the landing, or the cache as it stands with git's own words