@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,674 @@
1
+ // The Effector's wire, from the Orchestrator's side (ADR-0068): the request it encodes, the
2
+ // answers it decodes, the signatures on both, and the client that speaks it.
3
+ //
4
+ // The types are written by hand, here and in Go (`effector/internal/wire`). Here each zod shape is
5
+ // the one definition, and each type is inferred from it.
6
+ // The golden fixtures in `effector/testdata/wire/` keep the two equal: these tests encode each
7
+ // request fixture's shape and decode each answer; the Go tests do the reverse. An answer that does
8
+ // not decode strictly is a jr2 defect, never an outcome.
9
+ //
10
+ // No bearer: every request is signed with the placement's key (`effectorKey`), and every answer
11
+ // must carry the Effector's signature over its status, the request's method, path and nonce, and
12
+ // its body — an answer bound to this request. An unsigned or mis-signed answer is not the Effector's.
13
+
14
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { lookup } from "node:dns/promises";
16
+ import { z } from "zod";
17
+
18
+ // --- The request (POST /effects/<id>) ------------------------------------------------------------
19
+ //
20
+ // Each zod shape is the one definition of its part of the wire; each type is inferred from it.
21
+
22
+ export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
23
+ export const HttpMethodShape = z.enum(HTTP_METHODS);
24
+ /** The method of an `http` Effect: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS`. */
25
+ export type HttpMethod = z.infer<typeof HttpMethodShape>;
26
+
27
+ /** One part of a string the Effector builds: literal text, or a secret it holds, by name. A
28
+ * secret's value is never on the wire. */
29
+ export const TextPartShape = z.union([z.strictObject({ text: z.string() }), z.strictObject({ secret: z.string() })]);
30
+ /** One part of a {@link Text}: `{ text }`, literal text, or `{ secret }`, the name of an Effect
31
+ * secret. The `text` template makes the parts. */
32
+ export type TextPart = z.infer<typeof TextPartShape>;
33
+
34
+ export const WireAuthShape = z.union([
35
+ z.strictObject({ bearer: z.strictObject({ secret: z.string() }) }),
36
+ z.strictObject({ basic: z.strictObject({ user: z.array(TextPartShape), password: z.array(TextPartShape) }) }),
37
+ z.strictObject({ text: z.array(TextPartShape) }),
38
+ ]);
39
+ export type WireAuth = z.infer<typeof WireAuthShape>;
40
+
41
+ /** A Worktree file (a file-body `http`, ADR-0067): the Worktree, relative to the work
42
+ * root, and the file, relative to the Worktree. The Sandbox Effector opens both beneath, with no
43
+ * symlink, and refuses a FIFO and a file with more than one link — output, with nothing sent. */
44
+ export const FileRefShape = z.strictObject({
45
+ worktree: z.string(),
46
+ path: z.string(),
47
+ contentType: z.string().optional(),
48
+ });
49
+ export type FileRef = z.infer<typeof FileRefShape>;
50
+
51
+ export const WireBodyShape = z.union([
52
+ z.strictObject({ json: z.unknown() }),
53
+ z.strictObject({ text: z.string() }),
54
+ z.strictObject({ file: FileRefShape }),
55
+ ]);
56
+ export type WireBody = z.infer<typeof WireBodyShape>;
57
+
58
+ /** The headers the Effector sets itself, which a definition may not: they frame or route the
59
+ * request. A `proxy-*` name is one too. The Go side's `framing` is the same list; the reject
60
+ * fixtures hold one start per name, which each side refuses. */
61
+ export const FRAMING_HEADERS: ReadonlySet<string> = new Set([
62
+ "host",
63
+ "content-length",
64
+ "transfer-encoding",
65
+ "connection",
66
+ "upgrade",
67
+ "te",
68
+ "trailer",
69
+ "keep-alive",
70
+ ]);
71
+ const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
72
+
73
+ /** A header an Effect may set: a lowercase name that does not frame or route the request. */
74
+ export const isEffectHeader = (name: string): boolean =>
75
+ HEADER_NAME.test(name) && !FRAMING_HEADERS.has(name) && !name.startsWith("proxy-");
76
+
77
+ /** An `http` start; its body may be a Worktree file (ADR-0067), which only a Sandbox Effector
78
+ * serves. */
79
+ export const HttpStartShape = z.strictObject({
80
+ kind: z.literal("http"),
81
+ method: HttpMethodShape,
82
+ url: z.array(TextPartShape),
83
+ path: z.array(z.string()).optional(),
84
+ headers: z.record(z.string().refine(isEffectHeader), z.array(TextPartShape)).optional(),
85
+ auth: WireAuthShape.optional(),
86
+ body: WireBodyShape.optional(),
87
+ timeoutMs: z.number().int(),
88
+ maxBody: z.number().int(),
89
+ });
90
+ export type HttpStartRequest = z.infer<typeof HttpStartShape>;
91
+
92
+ /** Where one Repo Slot's repositories are in a Sandbox pod (ADR-0004, ADR-0051): the Agent's
93
+ * common git dir and Worktree, relative to `/work`, and the node cache's key under `/repos`. */
94
+ export const RepoRefShape = z.strictObject({ gitDir: z.string(), worktree: z.string(), cache: z.string() });
95
+ export type RepoRef = z.infer<typeof RepoRefShape>;
96
+
97
+ /** One push's published commit and the Agent's commit it was made from: a ledger push row's pair,
98
+ * with that push's own `squashed` — what its published commit is, which a later push that maps the
99
+ * same commit names (ADR-0067). */
100
+ export const PairShape = z.strictObject({
101
+ published: z.string(),
102
+ source: z.string(),
103
+ squashed: z.enum(["signed", "unsigned"]).optional(),
104
+ });
105
+ export type Pair = z.infer<typeof PairShape>;
106
+
107
+ /** A `push` start: the Workspace's branch to the Repo Slot's Binding, with the push credential of
108
+ * the `git.credentials` entry the url falls under — the Effector's config holds the entries, and
109
+ * matches the url as the fence does (`credential` names one by its `match` instead). `pin` is the
110
+ * commit; absent, the Effector reads the Agent's branch tip and its answer carries it (`pinned`).
111
+ */
112
+ export const PushStartShape = z.strictObject({
113
+ kind: z.literal("push"),
114
+ push: z.strictObject({
115
+ repo: RepoRefShape,
116
+ url: z.string(),
117
+ branch: z.string(),
118
+ credential: z.string().optional(),
119
+ pin: z.string().optional(),
120
+ sign: z.boolean().optional(),
121
+ squash: z.string().optional(),
122
+ force: z.boolean().optional(),
123
+ /** Every pair the ledger holds for the Workspace's Repo Slot, oldest push first (ADR-0067):
124
+ * omitted when there is none, never empty. */
125
+ pairs: z.array(PairShape).min(1).optional(),
126
+ }),
127
+ timeoutMs: z.number().int(),
128
+ });
129
+ export type PushStartRequest = z.infer<typeof PushStartShape>;
130
+
131
+ /** One variable of a credentialed command's env: a secret's value, or (`path`) its file's path. */
132
+ export const EnvRefShape = z.strictObject({ secret: z.string(), path: z.boolean().optional() });
133
+ export type EnvRef = z.infer<typeof EnvRefShape>;
134
+
135
+ /** A credentialed `command` start: argv against the pinned commit, with only the env named. */
136
+ export const CommandStartShape = z.strictObject({
137
+ kind: z.literal("command"),
138
+ command: z.strictObject({
139
+ repo: RepoRefShape,
140
+ branch: z.string(),
141
+ pin: z.string().optional(),
142
+ argv: z.array(z.string()).min(1),
143
+ env: z.record(z.string(), EnvRefShape),
144
+ tail: z.number().int(),
145
+ }),
146
+ timeoutMs: z.number().int(),
147
+ });
148
+ export type CommandStartRequest = z.infer<typeof CommandStartShape>;
149
+
150
+ /** A string the Effector builds, as parts (the `text` template's wire form). */
151
+ export const TextShape = z.array(TextPartShape);
152
+
153
+ /** A secret the Effector holds, by name. */
154
+ export const SecretRefShape = z.strictObject({ secret: z.string() });
155
+
156
+ /** A Workflow's name as `jr2 run` names it (ADR-0071): a Machine's filename stem, or
157
+ * `<wrapper>/<name>` for a named Chart. The Go side's `Validate` holds the same rule. */
158
+ export const WORKFLOW_NAME = /^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/;
159
+
160
+ /** A dispatch target's origin: a scheme and a host — a DNS name or IPv4 address, or a bracketed
161
+ * IPv6 address — a port when it is not the scheme's, and no path, query, fragment, userinfo or any
162
+ * other character no origin has — the Effector appends the door's path (ADR-0071). The Go side's
163
+ * `origin` is the same rule. */
164
+ export const ORIGIN = /^https?:\/\/(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:[0-9]{1,5})?$/;
165
+
166
+ /**
167
+ * Which run started a run, and its place in the chain (ADR-0071): the caller's run and slot, the
168
+ * caller's ledger row id (`effect`, the door's idempotency key), the dispatched run's depth — one
169
+ * below its caller, stated by the caller and recorded as sent, so at least 1 — and, for a caller
170
+ * on another Orchestrator, that Instance as opaque text.
171
+ */
172
+ export const DispatchedByShape = z.strictObject({
173
+ run: z.string(),
174
+ slot: z.string(),
175
+ effect: z.string(),
176
+ depth: z.number().int().positive(),
177
+ instance: z.string().optional(),
178
+ });
179
+
180
+ /**
181
+ * A `dispatch` start (ADR-0071), which only the Instance Effector serves: a run of `workflow` on the
182
+ * Orchestrator at `url` (its origin, one text part), the body `input` whole, the Instance token as a
183
+ * bearer by name, `dispatchedBy` as the `jr2-dispatched-by` header — `instance` set, since the
184
+ * caller is foreign by construction — and, with `wait`, a poll of `GET /runs/<id>` until the run
185
+ * ends or `timeoutMs` passes.
186
+ */
187
+ export const DispatchStartShape = z.strictObject({
188
+ kind: z.literal("dispatch"),
189
+ url: TextShape.refine(
190
+ (parts) => parts.length === 1 && "text" in parts[0]! && ORIGIN.test(parts[0].text),
191
+ "a dispatch url is one text part, an origin: http(s)://host[:port]",
192
+ ),
193
+ workflow: z.string().regex(WORKFLOW_NAME),
194
+ input: z.record(z.string(), z.unknown()),
195
+ auth: z.strictObject({ bearer: SecretRefShape }),
196
+ // Every id non-empty, as the door's header shape requires (http.ts): an empty one would be the
197
+ // door's 400, which reads as a defect of the input. The effect is a ledger row id, the key. The Go
198
+ // side's `validateDispatch` holds the same rule.
199
+ dispatchedBy: DispatchedByShape.extend({
200
+ run: z.string().min(1),
201
+ slot: z.string().min(1),
202
+ effect: z
203
+ .string()
204
+ .regex(/^[A-Za-z0-9._-]{1,128}$/)
205
+ .refine((id) => id !== "." && id !== ".."),
206
+ instance: z.string().min(1),
207
+ }),
208
+ wait: z.boolean(),
209
+ timeoutMs: z.number().int().positive(),
210
+ });
211
+ export type DispatchStartRequest = z.infer<typeof DispatchStartShape>;
212
+
213
+ /** Every start: the http kinds, a push, a credentialed command, a dispatch. */
214
+ export const StartRequestShape = z.union([HttpStartShape, PushStartShape, CommandStartShape, DispatchStartShape]);
215
+ export type StartRequest = z.infer<typeof StartRequestShape>;
216
+
217
+ /** The most bytes of a start's body the Effector reads (its `maxRequestBody`). A longer body
218
+ * cannot be verified, so the Effector refuses it as unsigned; the Orchestrator never sends one. */
219
+ export const MAX_START_BYTES = 4 << 20;
220
+
221
+ // --- The answers ---------------------------------------------------------------------------------
222
+
223
+ /** The `response` outcome: a status came back. The body is there when it is text, bounded and
224
+ * scrubbed; a body that is not text is its size and content type only. */
225
+ export const HttpResponseShape = z.strictObject({
226
+ outcome: z.literal("response"),
227
+ status: z.number().int(),
228
+ headers: z.record(z.string(), z.string()),
229
+ contentType: z.string(),
230
+ size: z.number().int().nonnegative(),
231
+ body: z.string().optional(),
232
+ truncated: z.boolean().optional(),
233
+ });
234
+ export type HttpResponse = z.infer<typeof HttpResponseShape>;
235
+
236
+ /**
237
+ * An `http` Effect's output. Every outcome is output (ADR-0067): `response`; `unreachable` (nothing
238
+ * was sent); `refused` (the Effector refused it and sent nothing — a path segment that is `.`, `..`
239
+ * or empty, a host no Effect placed there names); `unknown` (it may have been sent, and no answer
240
+ * came — or jr2 cannot tell, after a restart or a state exit).
241
+ */
242
+ export const HttpOutputShape = z.union([
243
+ HttpResponseShape,
244
+ z.strictObject({ outcome: z.enum(["unreachable", "refused", "unknown"]), reason: z.string() }),
245
+ ]);
246
+ /**
247
+ * The output of an `http` Effect: what the `onDone` of its state gets as `event.output`. Every
248
+ * outcome is output. A 4xx or 5xx status is output too. Route on `outcome`:
249
+ *
250
+ * - `response`: an answer came back. `status` is its HTTP status. `headers`, `contentType` and
251
+ * `size` tell about the body. `body` is there when the body is text: at most `maxBody` bytes
252
+ * (`truncated` is `true` when jr2 cut it). A body that is not text gives only its size and
253
+ * content type. jr2 replaces each value of an Effect secret that the Effector holds, and its
254
+ * usual encodings, with `[secret:<name>]`, in the body and the headers.
255
+ * - `unreachable`: the call did not go out, for example because the name did not resolve, or the
256
+ * connection or TLS failed. `reason` tells why.
257
+ * - `refused`: jr2 or the Effector refused the call, and nothing was sent. For example, a path
258
+ * segment that is `.`, `..` or empty, or a request that is too large. `reason` tells why.
259
+ * - `unknown`: the call can have gone out, but no answer came, or jr2 cannot tell after a restart.
260
+ * `reason` tells why. jr2 does not send it again unless it is marked `idempotent`.
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * import { fail, http, jr2Setup, type HttpOutput } from "@jr2/orchestrator";
265
+ *
266
+ * const ok = (output: HttpOutput): boolean => output.outcome === "response" && output.status < 300;
267
+ *
268
+ * export const machine = jr2Setup({
269
+ * actors: { health: http({ method: "GET", url: "https://app.example.com/healthz" }), fail: fail() },
270
+ * }).createMachine({
271
+ * id: "health",
272
+ * initial: "checking",
273
+ * states: {
274
+ * checking: {
275
+ * invoke: {
276
+ * src: "health",
277
+ * onDone: [
278
+ * { guard: ({ event }) => ok(event.output), target: "healthy" },
279
+ * { target: "unhealthy" },
280
+ * ],
281
+ * },
282
+ * },
283
+ * healthy: { type: "final" },
284
+ * // Not a final state: the run ends `failed`, and `jr2 run` exits 1.
285
+ * unhealthy: { invoke: { src: "fail", input: { reason: "the app is not healthy" } } },
286
+ * },
287
+ * });
288
+ * ```
289
+ *
290
+ * @see docs/concepts/effects.md
291
+ * @see docs/guides/run-a-command-or-call-a-url.md
292
+ * @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
293
+ */
294
+ export type HttpOutput = z.infer<typeof HttpOutputShape>;
295
+
296
+ /**
297
+ * A `push` Effect's output (ADR-0067). `pushed`: the published commit, its source (the Agent's
298
+ * commit), what the remote held before, how the new commits were found after a signed push
299
+ * (`ancestry | pair | cherry | force`), `unchanged` when the remote held it already, `newCommits`
300
+ * (how many commits the published commit adds to what jr2 found held, the remote's branch and the
301
+ * Repo cache; 0 when jr2 found the published commit already held, which the Agent reads in words,
302
+ * menu.ts), `squashed` when the published
303
+ * commit is one squashed commit (`signed | unsigned`; absent for signed copies; what the published
304
+ * commit is, an earlier push's included, never only this push's options), and — when the published
305
+ * commit is not the Agent's own — the published commits as a bundle for the sync. `rejected`: a
306
+ * stage and git's words. `diverged`: the published commit is not in the Agent's history and
307
+ * nothing paired it; nothing was pushed.
308
+ */
309
+ export const PushOutputShape = z.union([
310
+ z.strictObject({
311
+ outcome: z.literal("pushed"),
312
+ published: z.string(),
313
+ source: z.string(),
314
+ previous: z.string().optional(),
315
+ via: z.string().optional(),
316
+ unchanged: z.boolean().optional(),
317
+ // Optional: a live Sandbox keeps the runtime it started with (ADR-0037), so its older Effector
318
+ // sends no count after `jr2 up`. With no count the Harness makes no claim (menu.ts).
319
+ newCommits: z.number().int().nonnegative().optional(),
320
+ squashed: z.enum(["signed", "unsigned"]).optional(),
321
+ bundle: z.string().optional(),
322
+ }),
323
+ z.strictObject({
324
+ outcome: z.literal("rejected"),
325
+ stage: z.string(),
326
+ reason: z.string(),
327
+ detail: z.string().optional(),
328
+ }),
329
+ z.strictObject({ outcome: z.literal("diverged"), reason: z.string(), published: z.string(), source: z.string() }),
330
+ z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
331
+ ]);
332
+ /** @internal */
333
+ export type PushWireOutput = z.infer<typeof PushOutputShape>;
334
+
335
+ /** A credentialed command's output, as the command kind's (ADR-0067), and `dirty` when the
336
+ * Worktree held uncommitted edits it did not see. A stopped command's (`DELETE`, ADR-0067) is
337
+ * `exited` with the signal the stop sent last, and the code when the program caught SIGTERM and
338
+ * exited with one. */
339
+ export const CommandOutputShape = z.union([
340
+ z.strictObject({
341
+ outcome: z.literal("exited"),
342
+ code: z.number().int(),
343
+ tail: z.string(),
344
+ dirty: z.boolean().optional(),
345
+ }),
346
+ z.strictObject({
347
+ outcome: z.literal("exited"),
348
+ signal: z.string(),
349
+ code: z.number().int().optional(),
350
+ tail: z.string(),
351
+ dirty: z.boolean().optional(),
352
+ }),
353
+ z.strictObject({ outcome: z.literal("timeout"), tail: z.string(), dirty: z.boolean().optional() }),
354
+ z.strictObject({ outcome: z.literal("killed"), signal: z.string(), tail: z.string(), dirty: z.boolean().optional() }),
355
+ z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
356
+ ]);
357
+ export type CommandWireOutput = z.infer<typeof CommandOutputShape>;
358
+
359
+ /**
360
+ * A `dispatch` Effect's output on the wire (ADR-0071): its own outcome set. `settled.status` is the
361
+ * callee's status as the door served it, in the store's words, and `output` the callee Machine's,
362
+ * passed through scrubbed; `refused` is the door's status and its report, told from an http
363
+ * `refused` (which has a `reason`) by its fields.
364
+ */
365
+ export const DispatchOutputShape = z.discriminatedUnion("outcome", [
366
+ z.strictObject({ outcome: z.literal("started"), run: z.string() }),
367
+ z.strictObject({
368
+ outcome: z.literal("settled"),
369
+ run: z.string(),
370
+ // The store's words (ADR-0025) and the timeout's `live`, as `DispatchOutput` types them for the
371
+ // Machine: a door that serves another word (another Kit version) is a malformed answer, the
372
+ // invoke's error, never a status the Machine's type does not name (ADR-0071).
373
+ status: z.enum(["done", "failed", "error", "cancelled", "lost", "drifted", "live"]),
374
+ output: z.unknown().optional(),
375
+ }),
376
+ z.strictObject({ outcome: z.literal("refused"), status: z.number().int(), report: z.array(z.string()) }),
377
+ z.strictObject({ outcome: z.literal("unreachable"), reason: z.string() }),
378
+ z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
379
+ ]);
380
+ export type DispatchWireOutput = z.infer<typeof DispatchOutputShape>;
381
+
382
+ /** The replica that holds an Effect, and where to poll it. */
383
+ export const ReplicaShape = z.strictObject({ name: z.string(), address: z.string() });
384
+ export type Replica = z.infer<typeof ReplicaShape>;
385
+
386
+ /** Every answer of `POST` and `GET /effects/<id>`. */
387
+ export const EffectStateShape = z.strictObject({
388
+ effect: z.string(),
389
+ replica: ReplicaShape,
390
+ state: z.enum(["running", "settled", "unknown"]),
391
+ /** The commit a push or a credentialed command runs against, read when it started: the
392
+ * Orchestrator writes it to the ledger's intent row. */
393
+ pinned: z.string().optional(),
394
+ /** The run a dispatch started, once the door answered and while its wait runs: the Orchestrator
395
+ * writes it to the ledger's intent row, so the caller names its callee during the wait (ADR-0071). */
396
+ run: z.string().optional(),
397
+ output: z.union([HttpOutputShape, PushOutputShape, CommandOutputShape, DispatchOutputShape]).optional(),
398
+ });
399
+ export type EffectState = z.infer<typeof EffectStateShape>;
400
+
401
+ /** A refusal's body: the reason, and the replica when it names one. */
402
+ export const RefusalShape = z.strictObject({ error: z.string(), replica: ReplicaShape.optional() });
403
+
404
+ // --- Signatures ----------------------------------------------------------------------------------
405
+
406
+ export const HEADER_TIME = "x-jr2-time";
407
+ export const HEADER_NONCE = "x-jr2-nonce";
408
+ export const HEADER_SIGNATURE = "x-jr2-signature";
409
+
410
+ const digest = (body: string): string => createHash("sha256").update(body, "utf8").digest("hex");
411
+ const mac = (key: Buffer | string, parts: string[]): string =>
412
+ createHmac("sha256", key).update(parts.join("\n"), "utf8").digest("base64url");
413
+
414
+ /** A request's signature: over its method, the address it is sent to (the replica's `host:port`),
415
+ * its path, time, nonce and the sha-256 of its body. */
416
+ export function requestMac(
417
+ key: Buffer | string,
418
+ method: string,
419
+ address: string,
420
+ path: string,
421
+ time: string,
422
+ nonce: string,
423
+ body: string,
424
+ ): string {
425
+ return mac(key, ["jr2-effector-request", method, address, path, time, nonce, digest(body)]);
426
+ }
427
+
428
+ /** An answer's signature: over its status, the request's method, path and nonce, and its body. */
429
+ export function responseMac(
430
+ key: Buffer | string,
431
+ status: number,
432
+ method: string,
433
+ path: string,
434
+ nonce: string,
435
+ body: string,
436
+ ): string {
437
+ return mac(key, ["jr2-effector-response", String(status), method, path, nonce, digest(body)]);
438
+ }
439
+
440
+ // --- The transport -------------------------------------------------------------------------------
441
+
442
+ /** One raw exchange with one replica. */
443
+ export type RawAnswer = { status: number; headers: Record<string, string>; body: string };
444
+
445
+ /**
446
+ * How the client reaches a placement's replicas — the seam the unit tests inject (ADR-0010): the
447
+ * real one dials the headless Service's addresses; a test's answers in-process.
448
+ */
449
+ export interface EffectorTransport {
450
+ /** Every replica's address now (`host:port`). */
451
+ replicas(signal?: AbortSignal): Promise<string[]>;
452
+ /** One request to one replica. Rejects on a transport failure; a status is an answer. */
453
+ send(
454
+ address: string,
455
+ method: "POST" | "GET" | "DELETE",
456
+ path: string,
457
+ headers: Record<string, string>,
458
+ body: string,
459
+ signal?: AbortSignal,
460
+ ): Promise<RawAnswer>;
461
+ }
462
+
463
+ /** The request certainly never reached a replica (no address, the connection was refused, the name
464
+ * did not resolve), or the replica that got it refused to start it because it is stopping: sending
465
+ * it again starts it at most once. */
466
+ export class EffectorNotSent extends Error {}
467
+
468
+ /** The Effector refused the request as malformed (400): a jr2 or config defect, never retried. */
469
+ export class EffectorRefused extends Error {}
470
+
471
+ /** An answer that is not the Effector's: unsigned, or signed with another key. No retry changes the
472
+ * key it is checked with, so the Effect that gets one settles `unknown` (ADR-0068). */
473
+ export class EffectorForged extends Error {}
474
+
475
+ /** An answer the placement's key verifies that is not the wire's shape: a jr2 defect, never an
476
+ * outcome, never retried. */
477
+ export class EffectorMalformed extends Error {}
478
+
479
+ /** The Effector's wire, as an Effect drives it. */
480
+ export interface EffectorPort {
481
+ /** POST: start the Effect on some replica, or attach to it. Rejects with `EffectorNotSent` only
482
+ * when no replica can have started it; any other rejection may follow a start. */
483
+ start(id: string, req: StartRequest, signal: AbortSignal): Promise<EffectState>;
484
+ /** GET on the replica that holds it: settled, still running after the hold, or `unknown`. */
485
+ poll(replica: Replica, id: string, signal: AbortSignal): Promise<EffectState>;
486
+ /** Ask every replica: the state of the one that holds it, or undefined when every replica
487
+ * answered that it holds no such Effect. Rejects when a replica could not answer. */
488
+ find(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
489
+ /** DELETE: stop a credentialed command whose state exited (ADR-0067), on the replica that holds
490
+ * it — its state once stopped, or undefined when no replica holds it. Sent once to each replica,
491
+ * signed like every request. A push and an http Effect have no stop. */
492
+ stop(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
493
+ }
494
+
495
+ /** The signed client over a transport. `key` is the placement's (`effectorKey`). */
496
+ export function createEffectorClient(opts: {
497
+ key: Buffer | string;
498
+ transport: EffectorTransport;
499
+ now?: () => number;
500
+ }): EffectorPort {
501
+ const { key, transport } = opts;
502
+ const now = opts.now ?? Date.now;
503
+
504
+ const exchange = async (
505
+ address: string,
506
+ method: "POST" | "GET" | "DELETE",
507
+ id: string,
508
+ body: string,
509
+ signal: AbortSignal,
510
+ ): Promise<RawAnswer> => {
511
+ const path = `/effects/${encodeURIComponent(id)}`;
512
+ const time = String(now());
513
+ const nonce = randomBytes(16).toString("hex");
514
+ const headers = {
515
+ "content-type": "application/json",
516
+ [HEADER_TIME]: time,
517
+ [HEADER_NONCE]: nonce,
518
+ // Signed for the replica it is sent to: another replica, or this one after a restart, refuses
519
+ // it sent again (ADR-0068).
520
+ [HEADER_SIGNATURE]: requestMac(key, method, address, path, time, nonce, body),
521
+ };
522
+ const answer = await transport.send(address, method, path, headers, body, signal);
523
+ const signature = answer.headers[HEADER_SIGNATURE] ?? "";
524
+ const want = responseMac(key, answer.status, method, path, nonce, answer.body);
525
+ if (!sameString(signature, want)) {
526
+ throw new EffectorForged(
527
+ `the answer to ${method} ${path} from ${address} (${answer.status}) does not carry the Effector's signature`,
528
+ );
529
+ }
530
+ return answer;
531
+ };
532
+ const state = (answer: RawAnswer, what: string): EffectState => {
533
+ const parsed = EffectStateShape.safeParse(parseJson(answer.body));
534
+ if (!parsed.success) throw new EffectorMalformed(`the Effector's answer to ${what} is not the wire's shape`);
535
+ return parsed.data;
536
+ };
537
+ const refusal = (answer: RawAnswer): string => {
538
+ const parsed = RefusalShape.safeParse(parseJson(answer.body));
539
+ return parsed.success ? parsed.data.error : `status ${answer.status}`;
540
+ };
541
+
542
+ const ask = async (address: string, id: string, signal: AbortSignal): Promise<EffectState> => {
543
+ const answer = await exchange(address, "GET", id, "", signal);
544
+ if (answer.status === 200 || answer.status === 404) return state(answer, "a poll");
545
+ throw new Error(`the Effector at ${address} answered a poll with ${answer.status}: ${refusal(answer)}`);
546
+ };
547
+
548
+ return {
549
+ start: async (id, req, signal) => {
550
+ const body = JSON.stringify(req);
551
+ const addresses = shuffle(await transport.replicas(signal).catch(notSent));
552
+ if (addresses.length === 0) throw new EffectorNotSent("no Effector replica is serving");
553
+ let last: unknown;
554
+ for (const address of addresses) {
555
+ let answer: RawAnswer;
556
+ try {
557
+ answer = await exchange(address, "POST", id, body, signal);
558
+ } catch (err) {
559
+ // A replica that was never reached is the one failure the next replica may follow.
560
+ if (err instanceof EffectorNotSent || (!signal.aborted && neverReached(err))) {
561
+ last = err;
562
+ continue;
563
+ }
564
+ throw err;
565
+ }
566
+ if (answer.status === 202 || answer.status === 200) return state(answer, "a start");
567
+ if (answer.status === 503) {
568
+ // A stopping replica started nothing: another may take it.
569
+ last = new EffectorNotSent(`the Effector replica at ${address} is stopping`);
570
+ continue;
571
+ }
572
+ if (answer.status === 400) throw new EffectorRefused(`the Effector refused the request: ${refusal(answer)}`);
573
+ throw new Error(`the Effector answered a start with ${answer.status}: ${refusal(answer)}`);
574
+ }
575
+ throw last instanceof EffectorNotSent
576
+ ? last
577
+ : new EffectorNotSent(`no Effector replica took it: ${(last as Error | undefined)?.message ?? "none"}`);
578
+ },
579
+ poll: (replica, id, signal) => ask(replica.address, id, signal),
580
+ find: async (id, signal) => {
581
+ const addresses = await transport.replicas(signal);
582
+ const states = await Promise.all(addresses.map((address) => ask(address, id, signal)));
583
+ return states.find((s) => s.state !== "unknown");
584
+ },
585
+ stop: async (id, signal) => {
586
+ for (const address of await transport.replicas(signal)) {
587
+ const answer = await exchange(address, "DELETE", id, "", signal);
588
+ if (answer.status === 404) continue;
589
+ if (answer.status === 200) return state(answer, "a stop");
590
+ throw new Error(`the Effector at ${address} answered a stop with ${answer.status}: ${refusal(answer)}`);
591
+ }
592
+ return undefined;
593
+ },
594
+ };
595
+ }
596
+
597
+ /** A transport rejection that certainly never reached the replica becomes `EffectorNotSent`. */
598
+ function notSent(err: unknown): never {
599
+ throw new EffectorNotSent(`the Effector's replicas could not be listed: ${(err as Error)?.message ?? err}`, {
600
+ cause: err,
601
+ });
602
+ }
603
+
604
+ /** Errnos that mean no connection was ever made (the same reading as the Harness client's). */
605
+ const NEVER_REACHED_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"]);
606
+
607
+ export function neverReached(err: unknown, depth = 0): boolean {
608
+ if (depth > 4 || typeof err !== "object" || err === null) return false;
609
+ const { code, syscall, cause, errors } = err as {
610
+ code?: unknown;
611
+ syscall?: unknown;
612
+ cause?: unknown;
613
+ errors?: unknown;
614
+ };
615
+ if (syscall === "connect" || syscall === "getaddrinfo") return true;
616
+ if (typeof code === "string" && (code === "UND_ERR_CONNECT_TIMEOUT" || NEVER_REACHED_CODES.has(code))) return true;
617
+ if (Array.isArray(errors) && errors.some((nested) => neverReached(nested, depth + 1))) return true;
618
+ return neverReached(cause, depth + 1);
619
+ }
620
+
621
+ function parseJson(body: string): unknown {
622
+ try {
623
+ return JSON.parse(body);
624
+ } catch {
625
+ return undefined;
626
+ }
627
+ }
628
+
629
+ function sameString(a: string, b: string): boolean {
630
+ const left = Buffer.from(a);
631
+ const right = Buffer.from(b);
632
+ return left.length === right.length && timingSafeEqual(left, right);
633
+ }
634
+
635
+ function shuffle<T>(items: T[]): T[] {
636
+ const out = [...items];
637
+ for (let i = out.length - 1; i > 0; i--) {
638
+ const j = Math.floor(Math.random() * (i + 1));
639
+ [out[i], out[j]] = [out[j]!, out[i]!];
640
+ }
641
+ return out;
642
+ }
643
+
644
+ /**
645
+ * The real transport: the placement's headless Service, `host:port`. Its replicas are the
646
+ * addresses its name resolves to — every pod, a stopping one included, because the Service
647
+ * publishes addresses that are not ready (a stopping replica still answers for the Effects
648
+ * it finishes). A request goes to one pod's address over plain HTTP: the signature, not TLS, is
649
+ * what an answer is trusted by.
650
+ */
651
+ export function nodeEffectorTransport(service: string, fetchImpl: typeof fetch = fetch): EffectorTransport {
652
+ const cut = service.lastIndexOf(":");
653
+ const host = service.slice(0, cut);
654
+ const port = service.slice(cut + 1);
655
+ return {
656
+ // `lookup` (getaddrinfo), not `resolve4`: it reads the pod's resolv.conf search list, so the
657
+ // namespace-relative name resolves as every other in-cluster address the Orchestrator dials.
658
+ replicas: async () => (await lookup(host, { all: true, family: 4 })).map((a) => `${a.address}:${port}`),
659
+ send: async (address, method, path, headers, body, signal) => {
660
+ const res = await fetchImpl(`http://${address}${path}`, {
661
+ method,
662
+ headers,
663
+ ...(method === "POST" ? { body } : {}),
664
+ ...(signal ? { signal } : {}),
665
+ });
666
+ const text = await res.text();
667
+ const out: Record<string, string> = {};
668
+ res.headers.forEach((value, name) => {
669
+ out[name.toLowerCase()] = value;
670
+ });
671
+ return { status: res.status, headers: out, body: text };
672
+ },
673
+ };
674
+ }