@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
@@ -0,0 +1,945 @@
1
+ // The `dispatch` Effect (ADR-0071): a run starts a run of a registered Workflow, as an act of the
2
+ // caller. An actor slot beside `command()` and `http()` (ADR-0067):
3
+ //
4
+ // actors: {
5
+ // review: dispatch("review", { input: reviewDoor.pick({ branch: true }), wait: true }),
6
+ // ops: dispatch("task/review-loop", { target: { url: "https://ops.example.com", token: secret("ops") } }),
7
+ // }
8
+ //
9
+ // The callee is named as `jr2 run` names it — a constant on the slot, never a param: the name is
10
+ // authority, the input is data. The invoke's input is the run's body, whole. Two placements, read
11
+ // from the target: with none the Effect runs in the Orchestrator's own process and starts the run
12
+ // through the run host (`RunBinding.dispatch`), with no wire and no token; with one it runs in the
13
+ // Instance Effector, which holds the token as an Effect secret and calls the other Orchestrator's
14
+ // door. A packaged Machine leaves the target Open (`target: open`) for its composer to bind.
15
+ //
16
+ // What this module adds to the Effect's restart safety (effect.ts) for this kind:
17
+ //
18
+ // - THE KEY. The ledger row's id rides every start as the door's idempotency key (in-process
19
+ // through the binding, foreign in the `jr2-dispatched-by` header), and the door answers a
20
+ // repeat with the run the key already started. So a dispatch is idempotent by construction: a
21
+ // restore that cannot tell whether its start arrived sends it again, and gets the same run.
22
+ // - THE RUN ON THE ROW. Once the start's answer names the run, the intent row is written again
23
+ // with it, saved at the write: a restore with it settles `started` at once, or resumes the
24
+ // wait on that run, and never starts another.
25
+ // - D1. When its state exits, the wait stops and the abort is recorded, "outcome unknown". The
26
+ // run it started is never touched: the two runs are independent.
27
+ // - REACH IS OUTPUT, DEFECTS ARE ERRORS, as for `http`: `started | settled | refused |
28
+ // unreachable | unknown` are output; an absent run host or Effector, input that fails the
29
+ // slot's schema, and a refused or malformed wire are `onError`.
30
+
31
+ import { randomUUID } from "node:crypto";
32
+ import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
33
+ import { z } from "zod";
34
+ import type {
35
+ DispatchOutput,
36
+ DispatchParts,
37
+ Duration,
38
+ EffectLogic,
39
+ EffectMarker,
40
+ EffectRow,
41
+ EffectSnapshot,
42
+ OpenDispatchParts,
43
+ Unknown,
44
+ } from "./effect.ts";
45
+ import {
46
+ createEffectorClient,
47
+ DispatchOutputShape,
48
+ EffectorForged,
49
+ EffectorMalformed,
50
+ EffectorNotSent,
51
+ EffectorRefused,
52
+ MAX_START_BYTES,
53
+ nodeEffectorTransport,
54
+ ORIGIN,
55
+ WORKFLOW_NAME,
56
+ type DispatchStartRequest,
57
+ type EffectorPort,
58
+ type EffectState,
59
+ type Replica,
60
+ } from "./effector-wire.ts";
61
+ import { OPEN_SECRET, secret, type EffectorPortFactory, type NamedSecret } from "./http-effect.ts";
62
+ import { boundRun, type AnyActorSystem, type DispatchedBy } from "./registration.ts";
63
+ import { open } from "./open.ts";
64
+ import { effectBindingShape, openEffectLogic, type OpenOf } from "./open-effect.ts";
65
+
66
+ export { DISPATCHED_BY_HEADER, type DispatchedBy, type DispatchStart } from "./registration.ts";
67
+
68
+ // --- The definition ------------------------------------------------------------------------------
69
+
70
+ /** The definition of a `dispatch` Effect (ADR-0071). Every field is optional. */
71
+ export type DispatchDefinition<S extends z.ZodObject | undefined = undefined> = {
72
+ /** A zod schema of the run input of the Workflow. It types the `input` of the invoke and
73
+ * `fromPick`, and `jr2 up` compares it with the input that the Workflow declares. Without it,
74
+ * only the Workflow checks the input. */
75
+ input?: S;
76
+ /** `true`: end when the new run ends (`settled`). `false`, the default: end when the
77
+ * Orchestrator accepts the run (`started`). */
78
+ wait?: boolean;
79
+ /** How long the Effect waits: by default 30s for the start, and 10m with `wait`. */
80
+ timeout?: Duration;
81
+ /** A different Orchestrator: `url` is its origin, and `token` is the secret that holds its
82
+ * Instance token. Or `open`, which the composer binds with `customize(<import>, { effects: {
83
+ * <slot>: { target: { url, token } } } })`. Without it, the run starts in this Orchestrator. */
84
+ target?: { url: string; token: NamedSecret } | typeof open;
85
+ };
86
+
87
+ /** A dispatch's input: the run's body — the slot's schema's type, or any JSON object. */
88
+ export type DispatchInput<S> = S extends z.ZodObject ? z.infer<S> : Record<string, unknown>;
89
+
90
+ /** The Open part a dispatch definition states: its target, when it is `open`. */
91
+ export type DispatchOpenParts<T> = T extends typeof open ? "target" : never;
92
+
93
+ const DEFAULT_START_TIMEOUT_MS = 30_000;
94
+ const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60_000;
95
+ /** How long a re-asked request waits, at most, after a transport failure. */
96
+ const RETRY_MAX_MS = 10_000;
97
+ /** The longest delay one `setTimeout` holds (2^31-1 ms, about 24.8 days): Node fires a longer one
98
+ * at once, so a longer bound is armed again in steps of this (`atDeadline`). */
99
+ const MAX_TIMER_MS = 2 ** 31 - 1;
100
+ /** A start sent again after its bound passed (a restore of a late row) still gets this long to be
101
+ * answered: the key finds the run it started, if it did (ADR-0071). */
102
+ const START_FLOOR_MS = 1_000;
103
+ /** How long past the slot's bound the Orchestrator follows a foreign dispatch, at most: the
104
+ * Effector settles at the bound, and its answer has to travel back (ADR-0071). */
105
+ const FOREIGN_GRACE_MS = 10_000;
106
+
107
+ /** The definition's plain data, checked: every refusal names what to write instead. */
108
+ export function dispatchParts(workflow: string, def: DispatchDefinition<any> = {}): DispatchParts {
109
+ if (def.target === open) {
110
+ throw new Error("dispatchParts: an Open target has no url — read it with openDispatchParts");
111
+ }
112
+ const { target: _target, ...rest } = openDispatchParts(workflow, { ...def, target: open });
113
+ if (def.target === undefined) return withInputSchema(rest, def.input);
114
+ const target = targetOf(workflow, def.target);
115
+ return withInputSchema({ ...rest, target, secrets: [target.token] }, def.input);
116
+ }
117
+
118
+ /** An Open target's plain data (ADR-0054): every other part checked as `dispatchParts` checks it,
119
+ * the sentinel where the target goes, and no secret until `customize()` binds the token. */
120
+ export function openDispatchParts(workflow: string, def: DispatchDefinition<any> = {}): OpenDispatchParts {
121
+ if (typeof workflow !== "string" || !WORKFLOW_NAME.test(workflow)) {
122
+ throw new Error(
123
+ `dispatch(${JSON.stringify(workflow)}): the Workflow is named as \`jr2 run\` names it — a Machine's filename ` +
124
+ 'stem ("review"), or "<wrapper>/<name>" for a named Chart: lowercase letters, digits and "-" (ADR-0071)',
125
+ );
126
+ }
127
+ if (def.wait !== undefined && typeof def.wait !== "boolean") {
128
+ throw new Error(`dispatch("${workflow}", { wait }): wait is true or false — a slot that needs both declares two`);
129
+ }
130
+ const wait = def.wait === true;
131
+ const input = def.input as z.ZodType | undefined;
132
+ if (input !== undefined && !isObjectSchema(input)) {
133
+ throw new Error(
134
+ `dispatch("${workflow}", { input }): the input is a z.object(...) — the run's body is a JSON object (ADR-0071)`,
135
+ );
136
+ }
137
+ return withInputSchema(
138
+ {
139
+ kind: "dispatch",
140
+ workflow,
141
+ wait,
142
+ timeoutMs:
143
+ def.timeout === undefined
144
+ ? wait
145
+ ? DEFAULT_WAIT_TIMEOUT_MS
146
+ : DEFAULT_START_TIMEOUT_MS
147
+ : parseDuration(workflow, def.timeout),
148
+ target: open,
149
+ inputKeys: input === undefined ? undefined : Object.keys((input as unknown as { shape: object }).shape),
150
+ secrets: [],
151
+ openSecret: false,
152
+ },
153
+ input,
154
+ );
155
+ }
156
+
157
+ /** The key the slot's input schema rides the parts under, as JSON Schema: what `jr2 up` and the
158
+ * publish door compare with the callee's door (ADR-0071, door-compat.ts; parts.ts reads it). A
159
+ * `Symbol.for` key, so a second copy of this module reads it, and not enumerable, so the parts stay
160
+ * the plain data they print as. Written here, by the zod that built the schema. */
161
+ const DISPATCH_INPUT = Symbol.for("jr2.dispatch.input");
162
+
163
+ function withInputSchema<P extends object>(parts: P, input: z.ZodType | undefined): P {
164
+ if (input === undefined) return parts;
165
+ // `io: "output"`: what the driver SENDS, which is the slot schema's parse (`inputOf`), its
166
+ // defaults filled — so a defaulted field is required here, and one left optional is not, which
167
+ // is what the door's `required` is compared with (door-compat.ts). The door is served as
168
+ // `io: "input"`: what it takes. A part zod cannot draw is `{}`, which the comparison reads as
169
+ // "any".
170
+ const schema = z.toJSONSchema(input, { io: "output", unrepresentable: "any" });
171
+ Object.defineProperty(parts, DISPATCH_INPUT, { value: schema, enumerable: false });
172
+ return parts;
173
+ }
174
+
175
+ /** A target, checked (ADR-0071, D-J): an origin — no path, query, fragment or userinfo, and no
176
+ * secret in it — and a token that is a named secret. The whole target is the Open part, so
177
+ * `secret(open)` inside one is refused. */
178
+ function targetOf(workflow: string, target: unknown): { url: string; token: string } {
179
+ const where = `dispatch("${workflow}", { target })`;
180
+ if (typeof target !== "object" || target === null) {
181
+ throw new Error(`${where}: a target is { url: "https://<host>", token: secret("<name>") }, or open (ADR-0071)`);
182
+ }
183
+ const { url, token } = target as { url?: unknown; token?: unknown };
184
+ if (typeof url !== "string") {
185
+ throw new Error(
186
+ `${where}: the url is a string origin, "https://<host>[:<port>]" — a secret never sits in it; the token is ` +
187
+ "`token: secret(...)` (ADR-0071)",
188
+ );
189
+ }
190
+ let parsed: URL | undefined;
191
+ try {
192
+ parsed = new URL(url);
193
+ } catch {
194
+ parsed = undefined;
195
+ }
196
+ const bare = url.replace(/\/$/, "");
197
+ if (
198
+ parsed === undefined ||
199
+ (parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
200
+ parsed.username !== "" ||
201
+ parsed.password !== "" ||
202
+ !ORIGIN.test(bare)
203
+ ) {
204
+ throw new Error(
205
+ `${where}: "${url}" is not an origin — write the scheme and the host, with a port if it is not the scheme's, ` +
206
+ 'and no path, query or userinfo: "https://ops.example.com" or "http://jr2-orchestrator.ops.svc:4000"; ' +
207
+ "the Effector appends the door's path (ADR-0071)",
208
+ );
209
+ }
210
+ if (typeof token !== "object" || token === null || typeof (token as { secret?: unknown }).secret !== "string") {
211
+ throw new Error(`${where}: the token is a secret("<name>") holding the target's Instance token (ADR-0071)`);
212
+ }
213
+ const name = (token as { secret: string }).secret;
214
+ if (name === OPEN_SECRET) {
215
+ throw new Error(
216
+ `${where}: secret(open) in a target — the target is the Open part, not its secret: leave the whole target ` +
217
+ "Open (`target: open`) and let the composer bind its url and token together (ADR-0071)",
218
+ );
219
+ }
220
+ secret(name);
221
+ return { url: parsed.origin, token: name };
222
+ }
223
+
224
+ function isObjectSchema(schema: z.ZodType): boolean {
225
+ const shape = (schema as { shape?: unknown }).shape;
226
+ return (
227
+ typeof shape === "object" && shape !== null && typeof (schema as { safeParse?: unknown }).safeParse === "function"
228
+ );
229
+ }
230
+
231
+ /**
232
+ * The unbound logic of a dispatch whose target is Open (ADR-0054, ADR-0071), or undefined for one
233
+ * whose target is fixed or absent. Binding builds the Effect again, whole, with `rebuild` over the
234
+ * definition with the bound target, so the bound one is checked as any definition is.
235
+ */
236
+ export function openDispatchOf<S extends z.ZodObject | undefined>(
237
+ workflow: string,
238
+ def: DispatchDefinition<S>,
239
+ rebuild: (def: DispatchDefinition<S>) => unknown,
240
+ ): EffectLogic<unknown, unknown> | undefined {
241
+ if (def.target !== open) return undefined;
242
+ const parts = openDispatchParts(workflow, def);
243
+ return openEffectLogic(parts, ["target"], (binding) => {
244
+ if (binding.url !== undefined || binding.argv !== undefined || binding.secret !== undefined) {
245
+ throw new Error(
246
+ `a dispatch's Open part is its target, its url and token together — bind it with ` +
247
+ `${effectBindingShape(["target"])} (ADR-0071)`,
248
+ );
249
+ }
250
+ if (binding.target === undefined) {
251
+ throw new Error(`a dispatch's Open target is bound with ${effectBindingShape(["target"])} (ADR-0071)`);
252
+ }
253
+ return rebuild({ ...def, target: binding.target }) as EffectLogic<unknown, unknown>;
254
+ });
255
+ }
256
+
257
+ /** The wire request for one foreign start (ADR-0071): the target's origin, the Workflow, the body,
258
+ * the token by name, and who dispatched it. */
259
+ export function dispatchStartOf(
260
+ parts: DispatchParts,
261
+ input: Record<string, unknown>,
262
+ dispatchedBy: DispatchedBy & { instance: string },
263
+ ): DispatchStartRequest {
264
+ if (parts.target === undefined) throw new Error("dispatchStartOf: an in-process dispatch has no wire request");
265
+ return {
266
+ kind: "dispatch",
267
+ url: [{ text: parts.target.url }],
268
+ workflow: parts.workflow,
269
+ input,
270
+ auth: { bearer: { secret: parts.target.token } },
271
+ dispatchedBy,
272
+ wait: parts.wait,
273
+ timeoutMs: parts.timeoutMs,
274
+ };
275
+ }
276
+
277
+ // --- The actor logic -----------------------------------------------------------------------------
278
+
279
+ type Settle = { type: "jr2.effect.settle"; output: unknown };
280
+ type Fault = { type: "jr2.effect.error"; error: unknown };
281
+
282
+ /** A dispatch's run: its abort, and — for a foreign one, once its Effector is known — the port its
283
+ * stop goes through (ADR-0071). */
284
+ type Running = { controller: AbortController; port?: EffectorPort };
285
+ const running = new WeakMap<AnyActorRef, Running>();
286
+
287
+ /** How long a stop's DELETE may take: the Effector answers it with the wait's outcome, within its
288
+ * poll's hold. */
289
+ const STOP_WAIT_MS = 30_000;
290
+
291
+ /**
292
+ * A `dispatch` Effect's slot logic over an injected port factory — the seam `dispatch()` and the
293
+ * unit tests share, as `httpWith` is for an http Effect. The factory is read only by a dispatch
294
+ * with a target.
295
+ */
296
+ export function dispatchWith<
297
+ S extends z.ZodObject | undefined = undefined,
298
+ const T extends DispatchDefinition<S>["target"] = undefined,
299
+ >(
300
+ portFactory: EffectorPortFactory,
301
+ workflow: string,
302
+ def?: DispatchDefinition<S> & { target?: T },
303
+ ): EffectLogic<DispatchInput<S>, DispatchOutput> & OpenOf<DispatchOpenParts<T>>;
304
+ export function dispatchWith<S extends z.ZodObject | undefined = undefined>(
305
+ portFactory: EffectorPortFactory,
306
+ workflow: string,
307
+ def: DispatchDefinition<S> = {},
308
+ ): EffectLogic<DispatchInput<S>, DispatchOutput> {
309
+ const unbound = openDispatchOf(workflow, def, (bound) => dispatchWith(portFactory, workflow, bound));
310
+ if (unbound) return unbound as EffectLogic<DispatchInput<S>, DispatchOutput>;
311
+ const parts = dispatchParts(workflow, def);
312
+ type I = DispatchInput<S>;
313
+ type O = DispatchOutput;
314
+ const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
315
+ getInitialSnapshot: (_scope, input) => ({
316
+ status: "active",
317
+ output: undefined,
318
+ error: undefined,
319
+ input,
320
+ effectId: randomUUID(),
321
+ }),
322
+ transition: (snapshot, event, { self, system }) => {
323
+ if (snapshot.status !== "active") return snapshot;
324
+ if (event.type === "jr2.effect.settle") {
325
+ return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
326
+ }
327
+ if (event.type === "jr2.effect.error") {
328
+ return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
329
+ }
330
+ if (event.type === "xstate.stop") {
331
+ stopped(self, system, snapshot.effectId);
332
+ return { ...snapshot, status: "stopped", input: undefined };
333
+ }
334
+ return snapshot;
335
+ },
336
+ start: (snapshot, { self, system }) => {
337
+ if (snapshot.status !== "active") return;
338
+ const run: Running = { controller: new AbortController() };
339
+ running.set(self, run);
340
+ void drive(self, system, snapshot, parts, def, portFactory, run).catch((error: unknown) => {
341
+ if (run.controller.signal.aborted) return;
342
+ if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
343
+ });
344
+ },
345
+ getPersistedSnapshot: (snapshot) => snapshot,
346
+ };
347
+ return Object.assign(logic, { effect: parts });
348
+ }
349
+
350
+ const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
351
+
352
+ /** The statuses a settled dispatch names, in the store's own words (ADR-0025, ADR-0071). */
353
+ type SettledStatus = Extract<DispatchOutput, { outcome: "settled" }>["status"];
354
+
355
+ async function drive<I, O>(
356
+ self: AnyActorRef,
357
+ system: AnyActorSystem,
358
+ snapshot: EffectSnapshot<O, I>,
359
+ parts: DispatchParts,
360
+ def: DispatchDefinition<z.ZodObject | undefined>,
361
+ portFactory: EffectorPortFactory,
362
+ run: Running,
363
+ ): Promise<void> {
364
+ const binding = boundRun(system);
365
+ const id = snapshot.effectId;
366
+ const slot = slotOf(self);
367
+ const signal = run.controller.signal;
368
+ const now = (): string => new Date().toISOString();
369
+ const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
370
+ binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
371
+ let settledOnce = false;
372
+ const settle = (output: DispatchOutput): void => {
373
+ if (settledOnce || signal.aborted || self.getSnapshot().status !== "active") return;
374
+ settledOnce = true;
375
+ // The settled row first, saved at the write: a restart before the transition is saved delivers
376
+ // this output, and starts nothing.
377
+ binding?.recordEffect?.(id, { state: "settled", kind: "dispatch", slot, output, at: now() });
378
+ mark({ stage: "settled", output });
379
+ self.send({ type: "jr2.effect.settle", output } satisfies Settle);
380
+ };
381
+ const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
382
+ const placement = parts.target === undefined ? "orchestrator" : "instance";
383
+ const named = `${parts.workflow}${parts.target ? ` at ${parts.target.url}` : ""}`;
384
+
385
+ const row = binding?.effectRow?.(id);
386
+ if (row?.state === "settled") {
387
+ await Promise.resolve();
388
+ if (!signal.aborted && self.getSnapshot().status === "active") {
389
+ self.send({ type: "jr2.effect.settle", output: row.output } satisfies Settle);
390
+ }
391
+ return;
392
+ }
393
+ if (row?.state === "aborted") {
394
+ settle(unknown("its state exited while it ran; not started again"));
395
+ return;
396
+ }
397
+ const prior = row?.state === "intent" && row.kind === "dispatch" ? row : undefined;
398
+ // The intent row keeps the time it was first written: the wait's bound runs from the start, across
399
+ // a restore.
400
+ const at = prior?.at ?? now();
401
+ let started: string | undefined = prior?.run;
402
+ const intent = (): EffectRow => ({
403
+ state: "intent",
404
+ kind: "dispatch",
405
+ slot,
406
+ placement,
407
+ idempotent: true,
408
+ wait: parts.wait,
409
+ ...(started !== undefined ? { run: started } : {}),
410
+ at,
411
+ });
412
+ /** The run the start's answer names, written to the intent row, saved at the write: a restore
413
+ * reads it and never starts another. */
414
+ const pin = (runId: string, existing: boolean): void => {
415
+ if (signal.aborted || started === runId) return;
416
+ started = runId;
417
+ binding?.recordEffect?.(id, intent());
418
+ mark({ stage: "output", text: `${named}: run ${runId}${existing ? " (the key found it: started before)" : ""}` });
419
+ };
420
+ const deadline = Date.parse(at) + parts.timeoutMs;
421
+
422
+ if (prior?.run !== undefined && !parts.wait) {
423
+ // Started before the restart, and the answer is the run: nothing to send.
424
+ await Promise.resolve();
425
+ settle({ outcome: "started", run: prior.run });
426
+ return;
427
+ }
428
+
429
+ if (parts.target === undefined) {
430
+ const host = binding?.dispatch;
431
+ if (!host || !binding) {
432
+ throw new Error(
433
+ `dispatch "${slot}" starts a run through its Orchestrator's run host, and this run has none — a dispatch ` +
434
+ "with no target runs only under a RunHost (ADR-0071)",
435
+ );
436
+ }
437
+ if (prior?.run !== undefined) {
438
+ // In a wait at an Orchestrator restart: the run is on the row, so the wait resumes on it.
439
+ mark({ stage: "started", text: `${named}: waiting for run ${prior.run} again, after a restart` });
440
+ await waitIn(prior.run);
441
+ return;
442
+ }
443
+ // Fresh, or in flight with no run on the row: the key makes a second start the same run.
444
+ const input = inputOf();
445
+ if (!prior) binding.recordEffect?.(id, intent());
446
+ mark({
447
+ stage: "started",
448
+ text: prior ? `${named} (sent again under its key: a run it started is answered)` : named,
449
+ });
450
+ // The depth is the callee's: one below this run, which the host records as sent (ADR-0071).
451
+ const by: DispatchedBy = { run: binding.runId, slot, effect: id, depth: (binding.depth ?? 0) + 1 };
452
+ // The slot's bound holds the start too (ADR-0071): a run host that does not answer in it is
453
+ // `unknown` — the start may have been taken, and a restore's re-send under the key would find it.
454
+ const answer = await beforeDeadline(
455
+ host.start(parts.workflow, input, by),
456
+ prior ? Math.max(deadline, Date.now() + START_FLOOR_MS) : deadline,
457
+ signal,
458
+ );
459
+ if (signal.aborted) return;
460
+ if (answer === undefined) {
461
+ settle(
462
+ unknown(
463
+ `the run host did not answer the start of ${parts.workflow} within the slot's timeout; a run may have started`,
464
+ ),
465
+ );
466
+ return;
467
+ }
468
+ if (!answer.ok) {
469
+ settle({ outcome: "refused", status: answer.status, report: [...answer.report] });
470
+ return;
471
+ }
472
+ pin(answer.runId, answer.existing);
473
+ if (!parts.wait) {
474
+ settle({ outcome: "started", run: answer.runId });
475
+ return;
476
+ }
477
+ await waitIn(answer.runId);
478
+ return;
479
+ }
480
+
481
+ // Foreign: the Instance Effector holds the token and calls the other Orchestrator's door.
482
+ const target = binding?.effector?.("instance");
483
+ if (!target) {
484
+ throw new Error(
485
+ `dispatch "${slot}" calls another Orchestrator from the Instance Effector, and this Orchestrator knows none — ` +
486
+ "`jr2 up` converges it when a registered Machine carries a dispatch with a target (ADR-0071)",
487
+ );
488
+ }
489
+ const port = portFactory(target);
490
+ // The wait's stop goes through this port when its state exits (ADR-0071).
491
+ run.port = port;
492
+ // Fresh: nothing was sent. The body is checked before the intent row, so a defect in the input is
493
+ // the Machine's error and sends nothing.
494
+ const req = prior ? undefined : requestFor(false);
495
+ const big = req && tooLarge(req);
496
+ if (big) {
497
+ settle(big);
498
+ return;
499
+ }
500
+ // The slot's bound holds the foreign path too (ADR-0071): `cut` ends every send, find and poll
501
+ // at the bound — and a little past it, for the Effector's own answer at the bound to come back.
502
+ // The Effect then settles `unreachable` when no replica ever took the start, else `unknown`.
503
+ const cutter = new AbortController();
504
+ const cut = cutter.signal;
505
+ const onAbort = (): void => cutter.abort();
506
+ signal.addEventListener("abort", onAbort);
507
+ const foreignBound = deadline + Math.min(parts.timeoutMs, FOREIGN_GRACE_MS);
508
+ const disarm = atDeadline(prior ? Math.max(foreignBound, Date.now() + START_FLOOR_MS) : foreignBound, () =>
509
+ cutter.abort(),
510
+ );
511
+ /** Whether any send may have reached a replica: a restore's row may have been sent before. */
512
+ let reached = prior !== undefined;
513
+ const expired = (): void => {
514
+ disarm();
515
+ signal.removeEventListener("abort", onAbort);
516
+ if (signal.aborted || !cut.aborted) return;
517
+ settle(
518
+ reached
519
+ ? unknown(`no outcome came from the Instance Effector within the slot's timeout; a run may have started`)
520
+ : {
521
+ outcome: "unreachable",
522
+ reason: "no Instance Effector replica took the start within the slot's timeout; nothing was started",
523
+ },
524
+ );
525
+ };
526
+ /** Ask every replica. Undefined when an answer was forged (the Effect is settled then) or the
527
+ * bound cut it. */
528
+ const findAll = async (): Promise<{ found: EffectState | undefined } | undefined> => {
529
+ try {
530
+ return { found: await retrying(() => port.find(id, cut), cut) };
531
+ } catch (err) {
532
+ if (cut.aborted) return undefined;
533
+ if (!(err instanceof EffectorForged)) throw err;
534
+ settle(
535
+ unknown(
536
+ `${err.message}: the Orchestrator's key for this Effector is not the Effector's, or another process ` +
537
+ "answered; not sent again",
538
+ ),
539
+ );
540
+ return undefined;
541
+ }
542
+ };
543
+
544
+ if (prior) {
545
+ // In flight at an Orchestrator restart: follow the replica that holds it; when none does, send
546
+ // it again — the key makes the door answer with the run it started, if it did.
547
+ try {
548
+ const asked = await findAll();
549
+ if (!asked || cut.aborted) return;
550
+ if (asked.found) {
551
+ await follow(asked.found);
552
+ return;
553
+ }
554
+ mark({ stage: "started", text: `${named} (sent again under its key: a run it started is answered)` });
555
+ await send(requestFor(true));
556
+ return;
557
+ } finally {
558
+ expired();
559
+ }
560
+ }
561
+
562
+ binding?.recordEffect?.(id, intent());
563
+ mark({ stage: "started", text: named });
564
+ try {
565
+ await send(req!);
566
+ } finally {
567
+ expired();
568
+ }
569
+
570
+ /** The run's body: the invoke's input, parsed by the slot's schema when it has one. */
571
+ function inputOf(): Record<string, unknown> {
572
+ let input: unknown = snapshot.input ?? {};
573
+ if (def.input !== undefined) {
574
+ const parsed = def.input.safeParse(input);
575
+ if (!parsed.success) {
576
+ throw new Error(
577
+ `dispatch "${slot}": its input does not match the slot's input schema — ` +
578
+ parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
579
+ );
580
+ }
581
+ input = parsed.data;
582
+ }
583
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
584
+ throw new Error(`dispatch "${slot}": its input is the run's body, a JSON object (ADR-0071)`);
585
+ }
586
+ return input as Record<string, unknown>;
587
+ }
588
+
589
+ /** The start, as the wire carries it. Sent `again`, its bound is what is left of the slot's,
590
+ * counted from the intent row's first write: a re-send never starts the bound over (ADR-0071). At
591
+ * least 1 ms: the key still answers the run, and the Effector settles at once with it. */
592
+ function requestFor(again: boolean): DispatchStartRequest {
593
+ const by = {
594
+ run: binding?.runId ?? "",
595
+ slot,
596
+ effect: id,
597
+ depth: (binding?.depth ?? 0) + 1,
598
+ // The caller is foreign to the door it calls: it names its Orchestrator, as opaque text the
599
+ // callee serves on its open band — never an Instance ID (ADR-0071).
600
+ instance: binding?.orchestratorName ?? "an Orchestrator with no name",
601
+ };
602
+ const timeoutMs = again ? Math.max(1, Math.floor(deadline - Date.now())) : parts.timeoutMs;
603
+ return dispatchStartOf({ ...parts, timeoutMs }, inputOf(), by);
604
+ }
605
+
606
+ /** The in-process wait (ADR-0071): the run host tells when the run leaves `live`; the slot's
607
+ * bound ends the wait with the run still live, never the run. */
608
+ async function waitIn(runId: string): Promise<void> {
609
+ const host = binding!.dispatch!;
610
+ const bounded = new AbortController();
611
+ const abort = (): void => bounded.abort();
612
+ signal.addEventListener("abort", abort);
613
+ let timedOut = false;
614
+ const disarmWait = atDeadline(deadline, () => {
615
+ timedOut = true;
616
+ bounded.abort();
617
+ });
618
+ try {
619
+ const status = await host.settled(runId, bounded.signal);
620
+ if (signal.aborted) return;
621
+ settle({
622
+ outcome: "settled",
623
+ run: runId,
624
+ status: status.status as SettledStatus,
625
+ ...(status.output !== undefined ? { output: status.output } : {}),
626
+ });
627
+ } catch (err) {
628
+ if (signal.aborted) return;
629
+ if (!timedOut) throw err;
630
+ settle({ outcome: "settled", run: runId, status: "live" });
631
+ } finally {
632
+ disarmWait();
633
+ signal.removeEventListener("abort", abort);
634
+ }
635
+ }
636
+
637
+ /** Send the start, and follow it. A start that never reached a replica is sent again; one whose
638
+ * answer did not come is looked for on every replica, and sent again when none holds it — the
639
+ * key makes that safe (ADR-0071). */
640
+ async function send(first: DispatchStartRequest): Promise<void> {
641
+ let req = first;
642
+ for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
643
+ let state: EffectState;
644
+ try {
645
+ state = await port.start(id, req, cut);
646
+ reached = true;
647
+ } catch (err) {
648
+ if (cut.aborted) return;
649
+ if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
650
+ if (!(err instanceof EffectorNotSent)) {
651
+ reached = true;
652
+ const asked = await findAll();
653
+ if (!asked || cut.aborted) return;
654
+ if (asked.found) {
655
+ await follow(asked.found);
656
+ return;
657
+ }
658
+ }
659
+ await sleep(wait, cut);
660
+ if (cut.aborted) return;
661
+ req = requestFor(true);
662
+ continue;
663
+ }
664
+ await follow(state);
665
+ return;
666
+ }
667
+ }
668
+
669
+ /** Poll the replica that holds it to its outcome — with `wait`, the Effector's own poll of the
670
+ * door runs as long as the slot's bound. A replica that no longer knows it sends it again. */
671
+ async function follow(first: EffectState): Promise<void> {
672
+ let state = first;
673
+ let replica: Replica = first.replica;
674
+ for (;;) {
675
+ // The door answered, and the wait runs: the intent row names the callee from now on.
676
+ if (state.run !== undefined) pin(state.run, false);
677
+ if (state.state === "settled" && state.output) {
678
+ const parsed = DispatchOutputShape.safeParse(state.output);
679
+ if (!parsed.success) throw new EffectorMalformed("the Effector's output for a dispatch is not a dispatch's");
680
+ const output = parsed.data as DispatchOutput;
681
+ if (output.outcome === "started" || output.outcome === "settled") pin(output.run, false);
682
+ settle(output);
683
+ return;
684
+ }
685
+ if (state.state === "unknown") {
686
+ const asked = await findAll();
687
+ if (!asked || cut.aborted) return;
688
+ if (!asked.found) {
689
+ await send(requestFor(true));
690
+ return;
691
+ }
692
+ state = asked.found;
693
+ replica = asked.found.replica;
694
+ continue;
695
+ }
696
+ try {
697
+ state = await port.poll(replica, id, cut);
698
+ } catch (err) {
699
+ if (cut.aborted) return;
700
+ if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
701
+ state = { effect: id, replica, state: "unknown" };
702
+ }
703
+ if (cut.aborted) return;
704
+ }
705
+ }
706
+ }
707
+
708
+ /**
709
+ * The state that invoked this Effect exited (D1) — or the host is parking the run. Parking keeps
710
+ * the row for the restore. An exit stops the wait and records the abort, "outcome unknown"; the run
711
+ * the Effect started runs on, untouched: the two runs are independent (ADR-0071). A foreign wait
712
+ * runs in the Instance Effector, so it is stopped there too: one signed DELETE, best-effort, and
713
+ * the Effector's poll of the door ends — it never sends anything to the callee.
714
+ */
715
+ function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
716
+ const run = running.get(self);
717
+ running.delete(self);
718
+ run?.controller.abort();
719
+ const binding = boundRun(system);
720
+ if (binding?.hostStopping) return;
721
+ const row = binding?.effectRow?.(effectId);
722
+ if (row?.state !== "intent" || row.kind !== "dispatch") return;
723
+ // Sent at once, while a start may still be on the wire: the Effector keeps a stop of an id it
724
+ // does not hold, and the start that arrives after it goes to the door and is not waited for.
725
+ const port = row.placement === "instance" ? run?.port : undefined;
726
+ port?.stop(effectId, AbortSignal.timeout(STOP_WAIT_MS)).catch((err: unknown) => {
727
+ binding?.marker?.({
728
+ kind: "effect",
729
+ slot: row.slot,
730
+ effect: effectId,
731
+ stage: "aborted",
732
+ text: `the stop did not reach the Instance Effector (${err instanceof Error ? err.message : String(err)}); it may poll the run until its timeout`,
733
+ });
734
+ });
735
+ // The run it started stays on the row: the callee runs on, and the caller still names it (ADR-0071).
736
+ const aborted: EffectRow = {
737
+ state: "aborted",
738
+ kind: "dispatch",
739
+ slot: row.slot,
740
+ ...(row.run !== undefined ? { run: row.run } : {}),
741
+ at: new Date().toISOString(),
742
+ };
743
+ binding?.recordEffect?.(effectId, aborted, "with-transition");
744
+ binding?.marker?.({
745
+ kind: "effect",
746
+ slot: row.slot,
747
+ effect: effectId,
748
+ stage: "aborted",
749
+ text:
750
+ row.run !== undefined
751
+ ? `its state exited while it ran: no longer waited for; run ${row.run} runs on; outcome unknown`
752
+ : "its state exited while it ran: no longer followed; the run may have started; outcome unknown",
753
+ });
754
+ }
755
+
756
+ /** A start over the bytes the Effector reads is output `refused` (ADR-0068): nothing is sent. */
757
+ function tooLarge(req: DispatchStartRequest): DispatchOutput | undefined {
758
+ const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
759
+ if (bytes <= MAX_START_BYTES) return undefined;
760
+ return {
761
+ outcome: "refused",
762
+ status: 413,
763
+ report: [`the start is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads; nothing was sent`],
764
+ };
765
+ }
766
+
767
+ /**
768
+ * Call `fire` once `deadline` (epoch ms) has passed: a timer armed again in steps of at most
769
+ * `MAX_TIMER_MS`, so a bound past 24.8 days holds — one `setTimeout` that long fires at once.
770
+ * Answers the disarm.
771
+ */
772
+ function atDeadline(deadline: number, fire: () => void): () => void {
773
+ let timer: ReturnType<typeof setTimeout> | undefined;
774
+ let disarmed = false;
775
+ const arm = (): void => {
776
+ if (disarmed) return;
777
+ const left = deadline - Date.now();
778
+ if (left <= 0) {
779
+ fire();
780
+ return;
781
+ }
782
+ timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
783
+ };
784
+ timer = setTimeout(arm, Math.min(Math.max(0, deadline - Date.now()), MAX_TIMER_MS));
785
+ return () => {
786
+ disarmed = true;
787
+ clearTimeout(timer);
788
+ };
789
+ }
790
+
791
+ /** `work`'s answer, or undefined when `deadline` passes or `signal` aborts first. */
792
+ async function beforeDeadline<T>(work: Promise<T>, deadline: number, signal: AbortSignal): Promise<T | undefined> {
793
+ let disarm = (): void => {};
794
+ let onAbort = (): void => {};
795
+ const ended = new Promise<undefined>((resolve) => {
796
+ disarm = atDeadline(deadline, () => resolve(undefined));
797
+ onAbort = () => resolve(undefined);
798
+ signal.addEventListener("abort", onAbort);
799
+ });
800
+ try {
801
+ return await Promise.race([work, ended]);
802
+ } finally {
803
+ disarm();
804
+ signal.removeEventListener("abort", onAbort);
805
+ }
806
+ }
807
+
808
+ async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
809
+ for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
810
+ try {
811
+ return await call();
812
+ } catch (err) {
813
+ if (
814
+ signal.aborted ||
815
+ err instanceof EffectorRefused ||
816
+ err instanceof EffectorMalformed ||
817
+ err instanceof EffectorForged
818
+ ) {
819
+ throw err;
820
+ }
821
+ await sleep(wait, signal);
822
+ if (signal.aborted) throw err;
823
+ }
824
+ }
825
+ }
826
+
827
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
828
+ return new Promise((resolve) => {
829
+ const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
830
+ signal.addEventListener("abort", () => {
831
+ clearTimeout(timer);
832
+ resolve();
833
+ });
834
+ });
835
+ }
836
+
837
+ function parseDuration(workflow: string, d: Duration): number {
838
+ const n = Number.parseFloat(d);
839
+ if (!(n > 0) || !/^[0-9.]+[smh]$/.test(d)) {
840
+ throw new Error(`dispatch("${workflow}"): timeout "${d}" is not a positive duration (like "30s", "10m", "1h")`);
841
+ }
842
+ return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
843
+ }
844
+
845
+ // THE authoring surface for a `dispatch` Effect (ADR-0071). With no target it starts the run in
846
+ // this Orchestrator, through the run host; with one it runs in the Instance Effector, which
847
+ // `jr2 up` converges because this definition is placed there.
848
+ /**
849
+ * Make a `dispatch` Effect: it starts a run of a Workflow. Put it in `actors`, and invoke it from a
850
+ * state: `actors: { triage: dispatch("triage", { input }) }`. The input of the invoke is the run
851
+ * input of the new run, as `jr2 run --input` gives it.
852
+ *
853
+ * The new run is a run of its own, with its own id, its own Workspaces and its own `CANCEL`. Its
854
+ * record names this run as the run that dispatched it. A `CANCEL` of this run does not end it.
855
+ *
856
+ * With no `target`, the run starts in this Orchestrator, under the current Assembly. With
857
+ * `target: { url, token }`, it starts on the Orchestrator at `url`, and the Effect runs in the
858
+ * Instance Effector. Every outcome is output ({@link DispatchOutput}): `started`, `settled`,
859
+ * `refused`, `unreachable` or `unknown`. Each start carries a key, so a start that jr2 sends again
860
+ * after a restart starts no second run.
861
+ *
862
+ * It refuses:
863
+ * - a name that is not a Workflow name: ``dispatch("<name>"): the Workflow is named as `jr2 run`
864
+ * names it — a Machine's filename stem ("review"), or "<wrapper>/<name>" for a named Chart:
865
+ * lowercase letters, digits and "-" (ADR-0071)``
866
+ * - a `wait` that is not a boolean: `dispatch("<name>", { wait }): wait is true or false — a slot
867
+ * that needs both declares two`
868
+ * - an `input` that is not a `z.object()`: `dispatch("<name>", { input }): the input is a
869
+ * z.object(...) — the run's body is a JSON object (ADR-0071)`
870
+ * - a `timeout` that is not a duration: `dispatch("<name>"): timeout "<timeout>" is not a positive
871
+ * duration (like "30s", "10m", "1h")`
872
+ * - a `target` url that is not an origin: `dispatch("<name>", { target }): "<url>" is not an
873
+ * origin — write the scheme and the host, with a port if it is not the scheme's, and no path,
874
+ * query or userinfo`, then two examples
875
+ * - `secret(open)` in a target: `dispatch("<name>", { target }): secret(open) in a target — the
876
+ * target is the Open part, not its secret`, then the fix
877
+ *
878
+ * `jr2 up` refuses a dispatch with no target whose Workflow the Assembly does not have, and an
879
+ * `input` schema that the input of that Workflow does not take.
880
+ *
881
+ * @param workflow - The Workflow to start, as `jr2 run` names it: `"triage"`, or
882
+ * `"<wrapper>/<name>"` for a named Chart.
883
+ * @param def - Optional. Each field tells what it does.
884
+ * @returns An Effect, for `actors`.
885
+ *
886
+ * @example
887
+ * ```ts
888
+ * import { assign } from "xstate";
889
+ * import { z } from "zod";
890
+ * import { defineEvent, dispatch, jr2Setup } from "@jr2/orchestrator";
891
+ *
892
+ * const triageInput = z.object({ request: z.string() });
893
+ *
894
+ * const report = defineEvent({
895
+ * name: "report",
896
+ * description: "Report a request. A run of triage starts for it.",
897
+ * input: triageInput,
898
+ * });
899
+ *
900
+ * export const machine = jr2Setup({
901
+ * types: {} as { context: { request: string } },
902
+ * events: [report],
903
+ * // `wait: true`: the Effect ends when the run of triage ends.
904
+ * actors: { triage: dispatch("triage", { input: triageInput, wait: true }) },
905
+ * }).createMachine({
906
+ * id: "intake",
907
+ * context: { request: "" },
908
+ * initial: "listening",
909
+ * states: {
910
+ * listening: {
911
+ * invoke: { src: "gate" },
912
+ * on: { report: { target: "starting", actions: assign({ request: ({ event }) => event.request }) } },
913
+ * },
914
+ * starting: {
915
+ * invoke: {
916
+ * src: "triage",
917
+ * input: ({ context }) => ({ request: context.request }),
918
+ * onDone: [
919
+ * { guard: ({ event }) => event.output.outcome === "settled", target: "done" },
920
+ * { target: "listening" },
921
+ * ],
922
+ * },
923
+ * },
924
+ * done: { type: "final" },
925
+ * },
926
+ * });
927
+ * ```
928
+ *
929
+ * @see docs/guides/start-a-run-from-a-run.md
930
+ * @see docs/concepts/effects.md
931
+ * @see docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md
932
+ */
933
+ export function dispatch<
934
+ S extends z.ZodObject | undefined = undefined,
935
+ const T extends DispatchDefinition<S>["target"] = undefined,
936
+ >(
937
+ workflow: string,
938
+ def?: DispatchDefinition<S> & { target?: T },
939
+ ): EffectLogic<DispatchInput<S>, DispatchOutput> & OpenOf<DispatchOpenParts<T>> {
940
+ return dispatchWith(
941
+ (target) => createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) }),
942
+ workflow,
943
+ def,
944
+ );
945
+ }