@jr2/orchestrator 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +142 -29
  14. package/docs/concepts/architecture.md +60 -34
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +13 -8
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +7 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +133 -50
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +14 -4
  23. package/docs/concepts/pools-and-sources.md +193 -17
  24. package/docs/concepts/secrets-and-trust.md +186 -62
  25. package/docs/concepts/the-menu.md +14 -0
  26. package/docs/concepts/workspaces.md +135 -56
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  32. package/docs/get-started/install.md +4 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +72 -15
  34. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  35. package/docs/guides/answer-a-gate.md +74 -37
  36. package/docs/guides/bind-open-parts.md +29 -12
  37. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  38. package/docs/guides/cancel-a-run.md +26 -16
  39. package/docs/guides/cut-a-worktree.md +560 -0
  40. package/docs/guides/fix-a-failed-converge.md +19 -7
  41. package/docs/guides/give-a-caller-a-scope.md +203 -0
  42. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  43. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  44. package/docs/guides/let-an-agent-write-a-chart.md +14 -7
  45. package/docs/guides/look-inside-a-sandbox.md +115 -42
  46. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  47. package/docs/guides/open-the-console.md +63 -14
  48. package/docs/guides/push-and-sign-a-branch.md +67 -21
  49. package/docs/guides/read-a-conversation.md +225 -0
  50. package/docs/guides/run-a-chart-by-name.md +17 -16
  51. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  52. package/docs/guides/run-one-worker-per-item.md +34 -27
  53. package/docs/guides/set-an-agents-access-and-dials.md +69 -22
  54. package/docs/guides/size-a-workspace.md +19 -6
  55. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  56. package/docs/guides/start-a-run-from-a-run.md +18 -16
  57. package/docs/guides/start-a-run.md +24 -18
  58. package/docs/guides/steer-an-agent.md +332 -0
  59. package/docs/guides/use-a-hosted-provider.md +7 -1
  60. package/docs/guides/watch-a-run.md +146 -117
  61. package/docs/guides/write-a-hook-kind.md +238 -0
  62. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  63. package/docs/guides/write-a-workflow.md +18 -8
  64. package/docs/llms.txt +110 -23
  65. package/docs/reference/authoring-surface/agent.md +35 -12
  66. package/docs/reference/authoring-surface/answer.md +20 -4
  67. package/docs/reference/authoring-surface/arg.md +28 -6
  68. package/docs/reference/authoring-surface/basic.md +1 -1
  69. package/docs/reference/authoring-surface/bearer.md +1 -1
  70. package/docs/reference/authoring-surface/branchname.md +15 -0
  71. package/docs/reference/authoring-surface/carry.md +23 -0
  72. package/docs/reference/authoring-surface/chartschema.md +1 -1
  73. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  74. package/docs/reference/authoring-surface/command.md +37 -22
  75. package/docs/reference/authoring-surface/customize.md +4 -1
  76. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  77. package/docs/reference/authoring-surface/defineevent.md +1 -1
  78. package/docs/reference/authoring-surface/dispatch.md +5 -4
  79. package/docs/reference/authoring-surface/fail.md +18 -5
  80. package/docs/reference/authoring-surface/file.md +37 -17
  81. package/docs/reference/authoring-surface/frompick.md +1 -1
  82. package/docs/reference/authoring-surface/gate.md +118 -0
  83. package/docs/reference/authoring-surface/group-agents.md +15 -0
  84. package/docs/reference/authoring-surface/group-charts.md +16 -0
  85. package/docs/reference/authoring-surface/group-composition.md +25 -0
  86. package/docs/reference/authoring-surface/group-config.md +17 -0
  87. package/docs/reference/authoring-surface/group-effects.md +48 -0
  88. package/docs/reference/authoring-surface/group-failure.md +13 -0
  89. package/docs/reference/authoring-surface/group-gates.md +16 -0
  90. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  91. package/docs/reference/authoring-surface/group-steering.md +12 -0
  92. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  93. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  94. package/docs/reference/authoring-surface/http.md +109 -9
  95. package/docs/reference/authoring-surface/isfailure.md +1 -1
  96. package/docs/reference/authoring-surface/isready.md +14 -0
  97. package/docs/reference/authoring-surface/jr2setup.md +14 -4
  98. package/docs/reference/authoring-surface/mcp.md +107 -0
  99. package/docs/reference/authoring-surface/open.md +41 -8
  100. package/docs/reference/authoring-surface/openbody.md +1 -1
  101. package/docs/reference/authoring-surface/paramsof.md +23 -5
  102. package/docs/reference/authoring-surface/pool.md +79 -11
  103. package/docs/reference/authoring-surface/push.md +31 -13
  104. package/docs/reference/authoring-surface/readyof.md +21 -0
  105. package/docs/reference/authoring-surface/reread.md +19 -0
  106. package/docs/reference/authoring-surface/secret.md +26 -5
  107. package/docs/reference/authoring-surface/sentby.md +1 -1
  108. package/docs/reference/authoring-surface/skip.md +22 -0
  109. package/docs/reference/authoring-surface/source.md +59 -4
  110. package/docs/reference/authoring-surface/steer.md +93 -0
  111. package/docs/reference/authoring-surface/text.md +1 -1
  112. package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
  113. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  114. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  115. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  116. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  117. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  118. package/docs/reference/authoring-surface/type-binding.md +6 -7
  119. package/docs/reference/authoring-surface/type-caller.md +40 -0
  120. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  121. package/docs/reference/authoring-surface/type-carry.md +19 -0
  122. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  123. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  124. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  125. package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
  126. package/docs/reference/authoring-surface/type-customize.md +20 -3
  127. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  128. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  129. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  130. package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
  131. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  132. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  133. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  134. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  135. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  136. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  137. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  138. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  139. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  140. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  141. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  142. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  143. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  144. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  145. package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
  146. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  147. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
  149. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  150. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  151. package/docs/reference/authoring-surface/type-lends.md +5 -1
  152. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  153. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  154. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  155. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  156. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  157. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  158. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  159. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  160. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  161. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  162. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  163. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  164. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  165. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  166. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  167. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  168. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  169. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  170. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  171. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  172. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  173. package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
  174. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  175. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  176. package/docs/reference/authoring-surface/type-reposlot.md +6 -6
  177. package/docs/reference/authoring-surface/type-reread.md +18 -0
  178. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  179. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  180. package/docs/reference/authoring-surface/type-sender.md +26 -1
  181. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  182. package/docs/reference/authoring-surface/type-size.md +1 -1
  183. package/docs/reference/authoring-surface/type-skip.md +19 -0
  184. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  185. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  186. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  187. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  188. package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
  189. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  190. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  191. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  192. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  193. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  194. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  195. package/docs/reference/authoring-surface/type-workspaced.md +30 -9
  196. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  197. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  198. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  199. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  200. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  201. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  202. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  203. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  204. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  205. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  206. package/docs/reference/authoring-surface/workspace.md +66 -38
  207. package/docs/reference/authoring-surface/worktree.md +71 -0
  208. package/docs/reference/authoring-surface.md +88 -147
  209. package/docs/reference/cli.md +413 -74
  210. package/docs/reference/config.md +248 -6
  211. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  212. package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
  213. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  214. package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
  215. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  216. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  217. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  218. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  219. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  220. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
  221. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
  222. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  223. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
  224. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  225. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
  226. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  227. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
  228. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
  229. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  230. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
  231. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
  232. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  233. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
  234. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
  235. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  236. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  237. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
  238. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  239. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
  240. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  241. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
  242. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
  243. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  244. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  245. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  246. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  247. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  248. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  249. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
  250. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
  251. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
  252. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
  253. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
  254. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  255. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  256. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
  257. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
  258. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
  259. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  260. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
  261. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  262. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  263. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  264. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
  265. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
  266. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  267. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  268. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  269. package/docs/reference/decisions.md +9 -4
  270. package/docs/reference/glossary.md +138 -45
  271. package/docs/reference/hooks.md +1077 -0
  272. package/docs/reference/http-api.md +988 -123
  273. package/docs/reference/machines.md +0 -1
  274. package/docs/why.md +7 -5
  275. package/package.json +10 -3
  276. package/src/actor.ts +194 -42
  277. package/src/agent.ts +56 -6
  278. package/src/ambient.ts +3 -5
  279. package/src/assembly-checks.ts +530 -4
  280. package/src/callers.ts +245 -0
  281. package/src/chart.ts +238 -26
  282. package/src/charts.ts +9 -2
  283. package/src/config.ts +566 -7
  284. package/src/conversations.ts +453 -0
  285. package/src/custodian.lua +17 -5
  286. package/src/custodian.ts +257 -77
  287. package/src/customize.ts +154 -7
  288. package/src/dispatch-effect.ts +142 -26
  289. package/src/durability.ts +2 -0
  290. package/src/duration.ts +20 -0
  291. package/src/effect-checks.ts +52 -4
  292. package/src/effect.ts +401 -132
  293. package/src/effector-wire.ts +278 -30
  294. package/src/fail.ts +17 -4
  295. package/src/gate-brand.ts +16 -0
  296. package/src/gate.ts +634 -48
  297. package/src/harness-client.ts +84 -35
  298. package/src/harness-env.ts +33 -0
  299. package/src/held-secrets.ts +197 -39
  300. package/src/holds.ts +5 -2
  301. package/src/hook-kinds/discord.ts +92 -0
  302. package/src/hook-kinds/door.ts +107 -0
  303. package/src/hook-kinds/github.ts +89 -0
  304. package/src/hook-kinds/gitlab.ts +72 -0
  305. package/src/hook-kinds/hook.ts +7 -0
  306. package/src/hook-kinds/index.ts +465 -0
  307. package/src/hook-kinds/slack.ts +151 -0
  308. package/src/hook-kinds/webhook.ts +113 -0
  309. package/src/hooks.ts +506 -0
  310. package/src/http-effect.ts +164 -70
  311. package/src/http.ts +933 -99
  312. package/src/index.ts +30 -5
  313. package/src/instance.ts +196 -26
  314. package/src/internal.ts +23 -3
  315. package/src/kube-client.ts +6 -3
  316. package/src/machine-doc.ts +29 -5
  317. package/src/mcp-effect.ts +424 -0
  318. package/src/model-keys.ts +24 -0
  319. package/src/mount-digest.ts +41 -7
  320. package/src/names.ts +82 -7
  321. package/src/notices.ts +576 -59
  322. package/src/open-body.ts +110 -30
  323. package/src/open-effect.ts +17 -4
  324. package/src/open.ts +40 -7
  325. package/src/parts.ts +125 -35
  326. package/src/party.ts +35 -1
  327. package/src/pick.ts +43 -9
  328. package/src/pool.ts +1172 -172
  329. package/src/registration.ts +249 -17
  330. package/src/repo-identity.ts +7 -3
  331. package/src/repos.ts +20 -2
  332. package/src/run-host.ts +1614 -190
  333. package/src/run-list.ts +191 -0
  334. package/src/sandbox-effect.ts +217 -66
  335. package/src/sandbox-kube.ts +116 -21
  336. package/src/secret-placement.ts +248 -35
  337. package/src/server.ts +53 -4
  338. package/src/setup.ts +158 -38
  339. package/src/snapshot-store.ts +565 -48
  340. package/src/steer.ts +111 -0
  341. package/src/stub-harness.ts +6 -2
  342. package/src/tokens.ts +362 -46
  343. package/src/vocabulary.ts +23 -4
  344. package/src/wire.ts +27 -10
  345. package/src/workspace.ts +314 -259
  346. package/src/worktree.ts +249 -0
  347. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  348. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
  349. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  350. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/run-host.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // persisted — so the snapshot is JSON-safe and restore re-attaches by rewriting the child's
20
20
  // persisted input (drop `prompt`, set `attach` from the ledger) rather than re-POSTing the prompt.
21
21
 
22
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
23
23
  import { z } from "zod";
24
24
  import {
25
25
  createActor,
@@ -30,25 +30,36 @@ import {
30
30
  type AnyMachineSnapshot,
31
31
  type AnyStateMachine,
32
32
  } from "xstate";
33
- import { SENDER_KEY, type EventDef, type EventSemantics, type Sender } from "@jr2/agent-protocol";
33
+ import {
34
+ SENDER_KEY,
35
+ type EventDef,
36
+ type EventSemantics,
37
+ type Sender,
38
+ type SenderCallerField,
39
+ } from "@jr2/agent-protocol";
34
40
  import { partiesAt, seatOf, senderText } from "./party.ts";
41
+ import { GATE_ANSWERS_KEPT, type CallerRecord, type GateAnswer, type RunStarter } from "./callers.ts";
35
42
  import { placeAnswers, type AnswerSeat } from "./pick.ts";
36
43
  import { inputSchemaOf } from "./vocabulary.ts";
37
- import type { EchoEvent, EchoStatusChild, LiveConversation, PickAnswer } from "./wire.ts";
44
+ import type { EchoEvent, EchoStatusChild, LiveConversation, Notice, PickAnswer } from "./wire.ts";
38
45
  import { answerOf, childErrorOf, replyOf, type Hold } from "./holds.ts";
39
46
  import { PickCauses } from "./cause.ts";
40
47
  import { restOf, runningBelow } from "./rest.ts";
41
48
  import {
42
49
  acceptsNow,
43
50
  agentAddress,
51
+ agentSlot,
44
52
  allowedPicks,
45
53
  bindRun,
54
+ dispatchKeyOf,
46
55
  EventValidationError,
47
56
  gateAddress,
48
57
  NotTakenError,
49
58
  OutstandingError,
50
59
  RegistrationTable,
51
60
  refuseSenderField,
61
+ slotPath,
62
+ type SenderCaller,
52
63
  UnknownAddressError,
53
64
  wouldMove,
54
65
  targetsOf,
@@ -59,8 +70,10 @@ import {
59
70
  type RetryTelemetry,
60
71
  type RunBinding,
61
72
  type RunMarker,
73
+ type SourceVerdictLine,
62
74
  } from "./registration.ts";
63
- import { NoticeLedger, type NoticeLedgerState } from "./notices.ts";
75
+ import { NoticeLedger, type LedgerBody, type NoticeLedgerState, type SteeringRecord } from "./notices.ts";
76
+ import { isAgent } from "./agent.ts";
64
77
  import { pendingTimers, startAndRearm, watchTimers, type PendingTimer } from "./timers.ts";
65
78
  import {
66
79
  DEFAULT_LEASE_INTERVAL_MS,
@@ -82,8 +95,24 @@ import {
82
95
  type MachineDoc,
83
96
  type MachineStateDoc,
84
97
  } from "./machine-doc.ts";
85
- import type { SnapshotStore } from "./snapshot-store.ts";
98
+ import type { EndedRow, SnapshotStore, StandingHead } from "./snapshot-store.ts";
99
+ import {
100
+ encodeCursor,
101
+ isAfter,
102
+ LIVE_STATUS,
103
+ newestFirst,
104
+ placingOf,
105
+ shownOfRow,
106
+ type EndedStatus,
107
+ type RunListQuery,
108
+ type RunPage,
109
+ type RunSummary,
110
+ type ShownStatus,
111
+ } from "./run-list.ts";
112
+ import { grantOpens } from "./tokens.ts";
113
+ import { HAND_OVER, standingCarryOf, standingPoolOf, standingTaken, standingTook } from "./pool.ts";
86
114
  import type { AgentAdmission } from "./actor.ts";
115
+ import type { AnyHandler } from "./gate.ts";
87
116
  import { dropSavedTails, type EffectRow } from "./effect.ts";
88
117
  import type { EffectorPlacement, EffectorTarget } from "./http-effect.ts";
89
118
  import { reattachAgentRuns, serializeSnapshot } from "./durability.ts";
@@ -221,8 +250,21 @@ function chartDrift(machine: AnyStateMachine, routes: readonly BoundChartRoute[]
221
250
 
222
251
  /** The serializable identity of a run — what reconcile sees and what restore rebuilds from.
223
252
  * `dispatchedBy` is the run that started it through a `dispatch` Effect, and this run's depth in
224
- * that chain, as the caller sent it (ADR-0071); absent for a run a human or a client started. */
225
- export type RunRecord = { runId: string; workflow: string; instanceId: string; dispatchedBy?: DispatchedBy };
253
+ * that chain, as the caller sent it (ADR-0071); absent for a run a human or a client started.
254
+ * `startedBy` is who started it (ADR-0078): a Caller, a run, or jr2. Behind the token, like
255
+ * `context`: {@link observe} never carries it. Absent for a run that a host started with no
256
+ * starter, as a test does. */
257
+ export type RunRecord = {
258
+ runId: string;
259
+ workflow: string;
260
+ instanceId: string;
261
+ /** When the run started, ISO-8601 (ADR-0009): the order of `GET /runs`. */
262
+ startedAt: string;
263
+ dispatchedBy?: DispatchedBy;
264
+ startedBy?: RunStarter;
265
+ /** The Caller whose `jr2 run` resumed this standing run (ADR-0017, ADR-0078). */
266
+ resumedBy?: CallerRecord;
267
+ };
226
268
 
227
269
  /** A start of a name no Workflow is registered under: the door answers 404 (ADR-0009), and an
228
270
  * in-process `dispatch` settles `refused` with status 404 (ADR-0071). */
@@ -230,33 +272,9 @@ export class UnknownWorkflowError extends Error {
230
272
  override name = "UnknownWorkflowError";
231
273
  }
232
274
 
233
- /** A start whose idempotency key a run of ANOTHER Workflow already carries (ADR-0071): the door
234
- * answers 409, and an in-process `dispatch` settles `refused` with status 409. Never answered as
235
- * the existing run: the caller asked for a run of one Workflow and would wait on a run of another. */
236
- export class DispatchKeyConflictError extends Error {
237
- override name = "DispatchKeyConflictError";
238
- }
239
-
240
- /** The run a key names (ADR-0071), with its Workflow so a start of another can be refused. */
241
- export type DispatchedRun = { runId: string; instanceId: string; workflow: string };
242
-
243
- /** The run `found` as the answer to a start of `workflow` under its key, or the 409 (ADR-0071). */
244
- export function keyAnswer(
245
- found: DispatchedRun,
246
- workflow: string,
247
- effect: string,
248
- ): { runId: string; instanceId: string } {
249
- if (found.workflow !== workflow) throw new DispatchKeyConflictError(keyConflict(found, workflow, effect));
250
- return { runId: found.runId, instanceId: found.instanceId };
251
- }
252
-
253
- /** Why a start of `workflow` under `effect` is refused, when the key's run is of another. */
254
- export function keyConflict(found: DispatchedRun, workflow: string, effect: string): string {
255
- return (
256
- `the key "${effect}" already started run ${found.runId} of "${found.workflow}", not of "${workflow}" — ` +
257
- "one key is one start of one Workflow (ADR-0071)"
258
- );
259
- }
275
+ /** The run a key names (ADR-0071). The key is scoped to the callee Workflow, so the run is always
276
+ * of the Workflow that was asked. */
277
+ export type DispatchedRun = { runId: string; instanceId: string };
260
278
 
261
279
  /**
262
280
  * One live child MACHINE of a run, and its own children below it. A workflow's root machine is
@@ -375,6 +393,69 @@ function runSandboxes(actor: AnyActorRef, runId: string, opts: { placing?: boole
375
393
  return out;
376
394
  }
377
395
 
396
+ /** One Agent slot of a live run (ADR-0002), as `GET /runs/:runId/agents` lists it: `agent` is the
397
+ * slot (`<machine path>/<agent name>`), `open` says that a Turn of it is open now, and `steerable`
398
+ * that a Caller may send it a Steering message. */
399
+ export type RunAgent = { agent: string; open: boolean; steerable: boolean };
400
+
401
+ /** The `steering` field of a status: the record, when the run got a message. */
402
+ function steeringOf(record: readonly SteeringRecord[]): { steering?: SteeringRecord[] } {
403
+ return record.length > 0 ? { steering: record.map((r) => ({ ...r })) } : {};
404
+ }
405
+
406
+ /** Is this actor a Machine (not a promise, a callback, or an Agent)? */
407
+ function isMachineActor(ref: AnyActorRef): boolean {
408
+ const logic = (ref as { logic?: unknown }).logic;
409
+ return typeof logic === "object" && logic !== null && "root" in logic;
410
+ }
411
+
412
+ /**
413
+ * Every Agent slot of every live Machine instance under `actor`, at any depth (ADR-0002): the
414
+ * Agents that each Machine carries in its `actors`, by the slot their Turns are registered with
415
+ * (`agentSlot`). A Machine that is not active holds none: it can take no Turn.
416
+ */
417
+ function liveSlots(actor: AnyActorRef): Array<{ agent: string; steerable: boolean }> {
418
+ const out: Array<{ agent: string; steerable: boolean }> = [];
419
+ const walk = (ref: AnyActorRef): void => {
420
+ const snap = ref.getSnapshot() as { status?: string; children?: Record<string, AnyActorRef> } | undefined;
421
+ if (snap?.status !== "active") return;
422
+ if (isMachineActor(ref)) {
423
+ const actors = ((ref as { logic?: AnyStateMachine }).logic?.implementations.actors ?? {}) as Record<
424
+ string,
425
+ unknown
426
+ >;
427
+ for (const [name, logic] of Object.entries(actors)) {
428
+ if (isAgent(logic)) out.push({ agent: agentSlot(ref, name), steerable: logic.definition.steerable !== false });
429
+ }
430
+ }
431
+ for (const child of Object.values(snap.children ?? {})) if (child) walk(child);
432
+ };
433
+ walk(actor);
434
+ return out;
435
+ }
436
+
437
+ /** Why `POST /runs/:runId/steering` refuses a message (ADR-0002): `status` is the HTTP status, and
438
+ * `agents` the run's slots when the slot named none of them. */
439
+ export class SteeringRefused extends Error {
440
+ readonly status: 403 | 409;
441
+ readonly agents?: RunAgent[];
442
+ constructor(message: string, status: 403 | 409, agents?: RunAgent[]) {
443
+ super(message);
444
+ this.status = status;
445
+ if (agents !== undefined) this.agents = agents;
446
+ }
447
+ }
448
+
449
+ /** Why `POST /runs/:runId/gates/:gate/events` refuses a Caller (ADR-0078): the Gate is open, and
450
+ * the Caller's `answer` scope does not open it. `gate` is the Gate's name, its invoke id. */
451
+ export class GateRefused extends Error {
452
+ readonly gate: string;
453
+ constructor(gate: string) {
454
+ super(`the "answer" scope does not open Gate "${gate}"`);
455
+ this.gate = gate;
456
+ }
457
+ }
458
+
378
459
  /**
379
460
  * Stop every actor under `actor`, at any depth, deepest first. A machine that ended in `error` has
380
461
  * stopped processing — its own stop is a no-op — so its children are reached through its snapshot
@@ -404,14 +485,14 @@ type Sending = {
404
485
 
405
486
  /**
406
487
  * Whose pick started a child (ADR-0067): the Agent's slot, and the invoke id of the Agent's state the
407
- * pick was made at, which tells one slot's two regions apart. `invoke` is absent when the event that
488
+ * pick was made at, which tells apart two states that invoke one slot, one after the other. `invoke` is absent when the event that
408
489
  * started the child named the Agent and not where (a pick taken outside its own send).
409
490
  */
410
- type StartedBy = { agent: string; invoke?: string };
491
+ type ChildAgent = { agent: string; invoke?: string };
411
492
 
412
493
  /**
413
494
  * The key of the child `childId` of `parent` in the run's record of which Agent started each child
414
- * (`startedBy`, ADR-0067): the invoke ids from below the root down to `parent`, then `childId`, each
495
+ * (`childAgents`, ADR-0067): the invoke ids from below the root down to `parent`, then `childId`, each
415
496
  * encoded and joined by `/`. An invoke id is local to its Machine — two Machines of one tree, and
416
497
  * every worker of a pool, may each have a child `job` — and the chain is not. A child of the root
417
498
  * keeps its bare id. The ids are the snapshot's, so a restore gives the same key.
@@ -424,16 +505,16 @@ function childKey(parent: AnyActorRef, childId: string): string {
424
505
 
425
506
  /**
426
507
  * The Agent whose event `event` is, as `taker` takes it (ADR-0024, ADR-0067): a pick's sender, or
427
- * the Agent whose pick started the child of `taker` whose done or error event it is (`startedBy`).
508
+ * the Agent whose pick started the child of `taker` whose done or error event it is (`childAgents`).
428
509
  * Undefined when the event names no Agent: a timer, a Gate's answer, an outside send, a child no
429
510
  * Agent's pick started. `sending`'s invoke is where a pick in its own send was made.
430
511
  */
431
512
  function agentOfEvent(
432
513
  event: AnyEventObject,
433
- startedBy: Record<string, StartedBy>,
514
+ childAgents: Record<string, ChildAgent>,
434
515
  taker: AnyActorRef,
435
516
  sending?: Sending,
436
- ): StartedBy | undefined {
517
+ ): ChildAgent | undefined {
437
518
  const sender = (event as { [SENDER_KEY]?: Sender })[SENDER_KEY];
438
519
  if (sender?.party === "agent") {
439
520
  const invoke = sending?.reg.sender?.id === sender.id ? sending.reg.invokeId : undefined;
@@ -442,7 +523,7 @@ function agentOfEvent(
442
523
  const child = /^xstate\.(?:done|error)\.actor\.(.+)$/.exec(event.type)?.[1];
443
524
  if (child === undefined) return undefined;
444
525
  const key = childKey(taker, child);
445
- return Object.hasOwn(startedBy, key) ? startedBy[key] : undefined;
526
+ return Object.hasOwn(childAgents, key) ? childAgents[key] : undefined;
446
527
  }
447
528
 
448
529
  /** How many of `snapshot`'s active states invoke an Agent. With two or more, an event that names no
@@ -464,12 +545,12 @@ function liveChildKeys(actor: AnyActorRef, into = new Set<string>()): Set<string
464
545
  return into;
465
546
  }
466
547
 
467
- /** `startedBy` as the save keeps it: only children still in the run's tree (ADR-0067). Read at the
548
+ /** `childAgents` as the save keeps it: only children still in the run's tree (ADR-0067). Read at the
468
549
  * save, a microtask after the snapshot, so a done event the Machine took has run its route. */
469
- function liveStartedBy(startedBy: Record<string, StartedBy>, actor: AnyActorRef): Record<string, StartedBy> {
550
+ function liveChildAgents(childAgents: Record<string, ChildAgent>, actor: AnyActorRef): Record<string, ChildAgent> {
470
551
  const live = liveChildKeys(actor);
471
- for (const id of Object.keys(startedBy)) if (!live.has(id)) delete startedBy[id];
472
- return startedBy;
552
+ for (const id of Object.keys(childAgents)) if (!live.has(id)) delete childAgents[id];
553
+ return childAgents;
473
554
  }
474
555
 
475
556
  /** An `answer` action's value as the hold keeps it: JSON, because it rides the run blob and the
@@ -490,11 +571,84 @@ function heldOf(holds: Iterable<Hold>): RunHeld[] {
490
571
  .map((hold) => ({ agent: hold.agent, pick: hold.pick, since: hold.since }));
491
572
  }
492
573
 
574
+ /** The Caller of a Gate's event as its Sender carries it (ADR-0011): `via` and `name` are always
575
+ * checked, because an authenticator made them; `onBehalfOf` only for a Hook, whose signature covers
576
+ * the payload that it comes from. */
577
+ function senderCallerOf(by: CallerRecord): SenderCaller {
578
+ const checked: SenderCallerField[] = ["via", "name"];
579
+ if (by.via === "hook" && by.onBehalfOf !== undefined) checked.push("onBehalfOf");
580
+ return {
581
+ via: by.via,
582
+ name: by.name,
583
+ ...(by.onBehalfOf !== undefined ? { onBehalfOf: by.onBehalfOf } : {}),
584
+ checked,
585
+ };
586
+ }
587
+
588
+ /** What a Hook's fan-in did (ADR-0011): the Gates that took the delivery, the ones that did not and
589
+ * why, and how many open Gates of its kind and key gave nothing. */
590
+ export type HookFanIn = {
591
+ delivered: Array<{ run: string; workflow: string; gate: string; event: string }>;
592
+ refused: Array<{ run: string; gate: string; event?: string; reason: string }>;
593
+ ignored: number;
594
+ /** Each Source's verdict (ADR-0017), one line for each Pool whose Source hears the kind and key. */
595
+ sources: SourceLine[];
596
+ /** The standing Workflows whose Source hears the kind and key, and that have no live run and are
597
+ * not paused (ADR-0017): a hand-over waits out its back-off. The door answers 503. */
598
+ waiting?: string[];
599
+ /** For a delivery whose answer a person reads: the `reply` of each target that has one, in the
600
+ * order of the fan-out (ADR-0011). The door joins them; it never puts them in the outcome. */
601
+ replies?: HookReply[];
602
+ };
603
+
604
+ /**
605
+ * What one target's `reply` gave (ADR-0011): its place in the join (a Source or a Gate, its
606
+ * Workflow and its run), and its text, or the error of a `reply` that threw or gave no text. A
607
+ * `skipped` Source's text shows only when no other target gave one.
608
+ */
609
+ export type HookReply = {
610
+ target: "source" | "gate";
611
+ workflow: string;
612
+ run: string;
613
+ /** The Gate's id, or the Pool's `source` Gate's id. */
614
+ gate: string;
615
+ text?: string;
616
+ skipped?: true;
617
+ error?: string;
618
+ };
619
+
620
+ /** Run one `reply`: plain text, or nothing, or the error that the hook log keeps. */
621
+ function runReply(place: Omit<HookReply, "text" | "error">, run: () => unknown): HookReply {
622
+ let given: unknown;
623
+ try {
624
+ given = run();
625
+ } catch (err) {
626
+ return { ...place, error: `its reply threw: ${err instanceof Error ? err.message : String(err)}` };
627
+ }
628
+ if (given === undefined || given === "") return place;
629
+ if (typeof given !== "string") return { ...place, error: `its reply gave ${typeof given}, not text` };
630
+ return { ...place, text: given };
631
+ }
632
+
633
+ /** One Source's verdict on one delivery (ADR-0017): the Pool's run, its Workflow, the `source`
634
+ * Gate's id, and the verdict. */
635
+ export type SourceLine = { pool: string; workflow: string; source: string } & SourceVerdictLine;
636
+
637
+ /** Where a kept conversation's entries may come from ({@link RunHost.conversationPlace}). No
638
+ * `agent` when the run's ledger does not name the Instance ID. */
639
+ export type ConversationPlace = {
640
+ runId: string;
641
+ live: boolean;
642
+ agent?: string;
643
+ sandbox?: string;
644
+ slot?: string;
645
+ };
646
+
493
647
  /** A run's current observable state. `fault` carries the error message when status is "error"
494
648
  * (e.g. a gate invoked with a name outside the workflow's manifest — ADR-0011). A run that ends
495
649
  * "failed" (ADR-0073) has no `fault`: its `output` is the Failure's `{ reason, detail? }`. */
496
650
  export type RunStatus = RunRecord & {
497
- status: string;
651
+ status: ShownStatus;
498
652
  value: unknown;
499
653
  context: unknown;
500
654
  /** The live child machines beneath the root — where most of a run actually is. */
@@ -504,7 +658,7 @@ export type RunStatus = RunRecord & {
504
658
  * never carries it — the open band sees only that a Workspace is in `placing`. */
505
659
  waiting: RunWaiting[];
506
660
  /** Every pick of this run that is outstanding (ADR-0024): the Agent, its pick, and since when —
507
- * the Console's "held since". There is no default time bound on a held Turn; this is how a
661
+ * the Console's "outstanding since". There is no default time bound on a held Turn; this is how a
508
662
  * human sees one. Behind the token, like `context`: a pick names what the Agent did. */
509
663
  held: RunHeld[];
510
664
  fault?: string;
@@ -518,6 +672,9 @@ export type RunStatus = RunRecord & {
518
672
  /** The hash of the Assembly the run started under (ADR-0069). Absent for a run no Assembly
519
673
  * started. */
520
674
  assembly?: string;
675
+ /** The space guard deleted the run's kept conversations (ADR-0065, ADR-0023): `jr2 logs` and
676
+ * the Console show this in place of them. The run's row stays. */
677
+ swept?: "conversation swept: no space";
521
678
  /** The run binds at least one Chart (ADR-0070): its Machine is the bound one, `runMachine`. */
522
679
  bound?: true;
523
680
  /** The hashes of the Charts the run binds now, distinct and sorted (ADR-0070). A run can bind a
@@ -536,8 +693,30 @@ export type RunStatus = RunRecord & {
536
693
  * direction, read off the caller's own ledger rows. A foreign dispatch's run is a run of the
537
694
  * Orchestrator its target names. Absent when it started none. */
538
695
  dispatched?: Dispatched[];
696
+ /** The Caller that cancelled the run (ADR-0078). Behind the token: {@link observe} never
697
+ * carries it. */
698
+ cancelledBy?: CallerRecord;
699
+ /** The run that took over from this standing run (ADR-0017): a hand-over ends the run `done`. */
700
+ handedOverTo?: string;
701
+ /** The answers to the run's Gates, oldest first, each with the Caller that gave it (ADR-0011,
702
+ * ADR-0078): the last {@link GATE_ANSWERS_KEPT}. An event that a Gate's `look` gives is not an
703
+ * answer, and is not here. Behind the token. Absent when no Gate was answered. */
704
+ answers?: GateAnswer[];
705
+ /** The run's Steering messages, oldest first (ADR-0002): each one's Agent slot, author, text,
706
+ * time and outcome — the newest 100, and each one still held. Behind the token, like `context`.
707
+ * Absent when the run got none. */
708
+ steering?: SteeringRecord[];
709
+ /** The Worktrees this run's cuts made, oldest first (ADR-0075), read off its ledger's settled
710
+ * `ready` rows, so an Agent's `git switch` does not change them. A re-cut of one path and branch
711
+ * is one entry. Behind the token, like `context`: a branch name is the workflow's data. Absent
712
+ * when the run cut none. */
713
+ worktrees?: RunWorktree[];
539
714
  };
540
715
 
716
+ /** One Worktree a `worktree()` Effect of this run cut (ADR-0075): its Repo Slot, its path, and the
717
+ * branch of the cut. `branch` is `null` for a detached Worktree. */
718
+ export type RunWorktree = { slot: string; path: string; branch: string | null };
719
+
541
720
  /** One run a `dispatch` Effect of this run started (ADR-0071): its slot, the run, and when the
542
721
  * ledger row was written. */
543
722
  export type Dispatched = { slot: string; run: string; at: string };
@@ -574,7 +753,7 @@ export type RunObservation = {
574
753
  /** The run that dispatched this one, its slot, and the chain's depth (ADR-0071). Routing — two
575
754
  * ids, a slot name and a number — the class of thing `runId` and `value` are, so ADR-0014's line
576
755
  * is unmoved. The idempotency key is not: it names the caller's ledger, which is the caller's. */
577
- dispatchedBy?: Omit<DispatchedBy, "effect">;
756
+ dispatchedBy?: Omit<DispatchedBy, "effect" | "key" | "hook">;
578
757
  /** The runs it dispatched, by slot (ADR-0071): the same routing as `dispatchedBy`, the other
579
758
  * direction. */
580
759
  dispatched?: Array<{ slot: string; run: string }>;
@@ -596,8 +775,10 @@ export function observe(status: RunStatus): RunObservation {
596
775
  }
597
776
 
598
777
  /** A {@link DispatchedBy} without its idempotency key (ADR-0071): what the open band carries. */
599
- function routingOf(by: DispatchedBy): Omit<DispatchedBy, "effect"> {
600
- const { effect: _key, ...routing } = by;
778
+ function routingOf(by: DispatchedBy): Omit<DispatchedBy, "effect" | "key" | "hook"> {
779
+ // The key names the caller's ledger, or a Pool's item, and the Hook delivery is the workflow's
780
+ // data: neither is routing (ADR-0014, ADR-0017).
781
+ const { effect: _effect, key: _key, hook: _hook, ...routing } = by;
601
782
  return routing;
602
783
  }
603
784
 
@@ -637,6 +818,34 @@ export type GateView = {
637
818
  meta?: Record<string, unknown>;
638
819
  };
639
820
 
821
+ /** One entry of the Gate inbox (`GET /gates` — ADR-0011): an open Gate as {@link GateView} gives it,
822
+ * with its run, the run's Workflow, and when it opened, ISO-8601. `hook` names the Hook kinds that
823
+ * the Gate hears, when it hears one. Never the run's context (ADR-0014). */
824
+ export type GateInboxEntry = { runId: string; workflow: string } & GateView & {
825
+ openedAt: string;
826
+ hook?: string[];
827
+ };
828
+
829
+ /** The name of a Gate (ADR-0078): its invoke id, which its events carry as their sender. */
830
+ function gateNameOf(reg: Registration): string {
831
+ return reg.sender?.id ?? reg.invokeId ?? reg.id;
832
+ }
833
+
834
+ /** A Gate's registration as callers discover it (ADR-0011). */
835
+ function gateViewOf(reg: Registration): GateView {
836
+ return {
837
+ gate: reg.id,
838
+ path: reg.path ?? [],
839
+ accepts: [...reg.defs.values()].map((def) => ({
840
+ name: def.name,
841
+ description: def.description,
842
+ input: z.toJSONSchema(def.input),
843
+ })),
844
+ acceptsNow: acceptsNow(reg),
845
+ meta: reg.meta,
846
+ };
847
+ }
848
+
640
849
  /** One event on an Agent's Menu, as the surface serves it: a tool, its `input` schema the tool's
641
850
  * parameters. `semantics` rides along so the Harness refuses a reserved one (ADR-0013). */
642
851
  /** One pick on the surface. `briefing` marks a pick whose parameter is a Chart: jr2's text on how a
@@ -668,6 +877,9 @@ export type AgentSurfaceView = {
668
877
  instanceId: string;
669
878
  runId: string;
670
879
  sandbox?: string;
880
+ /** The slot this surface belongs to (ADR-0076): its Agent's actor path and agent name — the slot
881
+ * the Harness must state to speak here. Absent on a registration no pod hosts. */
882
+ slot?: string;
671
883
  menu: SurfaceEvent[];
672
884
  allowed: string[];
673
885
  };
@@ -790,6 +1002,10 @@ function echoEventOf(event: RunFeedEvent, target: string): EchoEvent | undefined
790
1002
  * every one fed after it. A superseded status drops out; nothing else moves. So a Harness that
791
1003
  * answers slowly holds at most one status plus the markers since its last push.
792
1004
  *
1005
+ * A status the same as the last one pushed is dropped, with the pending one: the feed sends a
1006
+ * status on each inspection event, and an Agent actor's own transitions move nothing that the echo
1007
+ * shows, so without this each line of the log would print two times or more.
1008
+ *
793
1009
  * Fire-and-forget: a failed push goes to `report` and the outbox drains on — nothing here can
794
1010
  * fail a turn, a state, or a run. Exported for its unit test; {@link RunHost} is its one caller.
795
1011
  */
@@ -799,10 +1015,15 @@ export function echoOutbox(
799
1015
  ): (events: EchoEvent[]) => void {
800
1016
  let pending: EchoEvent[] = [];
801
1017
  let flushing = false;
1018
+ // The last status that left in a push, as its JSON: the level the log shows now.
1019
+ let shown: string | undefined;
802
1020
  const flush = async (): Promise<void> => {
803
1021
  while (pending.length > 0) {
804
1022
  const batch = pending;
805
1023
  pending = [];
1024
+ // A batch holds at most one status (see the enqueue below).
1025
+ const status = batch.find((p) => p.kind === "status");
1026
+ if (status) shown = JSON.stringify(status);
806
1027
  try {
807
1028
  await push(batch);
808
1029
  } catch (err) {
@@ -813,7 +1034,10 @@ export function echoOutbox(
813
1034
  };
814
1035
  return (events) => {
815
1036
  for (const event of events) {
816
- if (event.kind === "status") pending = pending.filter((p) => p.kind !== "status");
1037
+ if (event.kind === "status") {
1038
+ pending = pending.filter((p) => p.kind !== "status");
1039
+ if (JSON.stringify(event) === shown) continue;
1040
+ }
817
1041
  pending.push(event);
818
1042
  }
819
1043
  if (flushing || pending.length === 0) return;
@@ -922,17 +1146,87 @@ export type RunHostOptions = {
922
1146
  /** The wall clock a run's timer table is written and read on (ADR-0007, timers.ts): each pending
923
1147
  * timer's deadline is a time on it. Default: `Date.now`. Tests pass a clock that does not step. */
924
1148
  now?: () => number;
1149
+ /** The bound of a standing run and the back-off after its `error` (ADR-0017). Tests shrink them. */
1150
+ standing?: StandingOptions;
1151
+ };
1152
+
1153
+ /** The bound of a standing run, and the wait before a hand-over from a run in which no item that
1154
+ * it took started a run (ADR-0017). */
1155
+ export type StandingOptions = {
1156
+ /** Hand the run over once it has taken this many items, each one a dispatch. When no item that it
1157
+ * took itself started a run (a carried key does not count), the hand-over waits out the
1158
+ * back-off, as after `error`. Default: 500. */
1159
+ dispatches?: number;
1160
+ /** Hand the run over once it is this old, in milliseconds. Default: one day. */
1161
+ ageMs?: number;
1162
+ /** The wait before the hand-over after the `n`-th run in a row that ended `error`, ended
1163
+ * `failed` (ADR-0073, as `error`) or took its bound, and in which no item that it took itself
1164
+ * started a run (a carried key does not count). n is from 1; a run in which such an item started
1165
+ * a run waits as n = 1. Default: 1 s, 4 s, then 16 s. */
1166
+ backoffMs?: (n: number) => number;
925
1167
  };
926
1168
 
1169
+ /** The bound of a standing run (ADR-0017): 500 dispatches or one day. Not measured past the size
1170
+ * of a save: each dispatch keeps a ledger row of about 217 bytes, so 500 is about 110 KB. */
1171
+ export const STANDING_BOUND = { dispatches: 500, ageMs: 24 * 60 * 60 * 1000 } as const;
1172
+ /** How many hand-overs jr2 makes from runs in a row that end `error`, end `failed` or take their
1173
+ * bound, in which no item that the run took itself started a run (ADR-0017). When the next such
1174
+ * run ends, jr2 pauses the standing Pool. Not measured. */
1175
+ export const STANDING_ERRORS_PAUSE = 3;
1176
+ /** How many times one delivery follows a standing Pool's hand-overs to the new run's Source. After
1177
+ * more hand-overs in one delivery's fan-in, the door answers 503, as for a Pool with no live run. */
1178
+ const STANDING_HOPS = 3;
1179
+ const standingBackoff = (n: number): number => Math.min(60_000, 1000 * 4 ** (n - 1));
1180
+
1181
+ /** A standing Workflow as `jr2 up` and `jr2 status` print it (ADR-0017): its Pool, its callee, its
1182
+ * state, its run, and how many items it holds. `live`: a run is live. `paused`: a cancel (`why`:
1183
+ * `cancel`), or a fourth run in a row in which no item that the run took started a run, after 3
1184
+ * hand-overs: the last one ended `error` or `failed` (`errors`), or took its bound (`bound`). The
1185
+ * held items are in the blob of `run`.
1186
+ * `waiting`: no run is live, and jr2 starts one after the back-off. Structure and counts only: no
1187
+ * key and no item, so it is on the open band with the Assembly. */
1188
+ export type StandingView = {
1189
+ workflow: string;
1190
+ pool: string;
1191
+ callee: string;
1192
+ state: "live" | "paused" | "waiting";
1193
+ run?: string;
1194
+ held: number;
1195
+ why?: "cancel" | "errors" | "bound";
1196
+ };
1197
+
1198
+ /** jr2 starts a standing Workflow's run, and a Caller's start only resumes a paused one (ADR-0017):
1199
+ * the door answers 409 with the message. */
1200
+ export class StandingRefused extends Error {
1201
+ override name = "StandingRefused";
1202
+ }
1203
+
927
1204
  /** What we persist per run: the machine snapshot wrapped with the run metadata restore needs.
928
1205
  * `agents` is the admission LEDGER (ADR-0016) — iid → durable admission, reported by the Agent's Actor
929
1206
  * through the run binding and saved in the same blob (same store, same atomicity). */
930
1207
  type RunBlob = {
931
1208
  workflow: string;
932
1209
  instanceId: string;
1210
+ /** When the run started (ADR-0009). The store copies it into a column of its own, which orders
1211
+ * `GET /runs`. Absent in a blob written before the field. */
1212
+ startedAt?: string;
933
1213
  /** The run that dispatched this one (ADR-0071), its key included: the store finds a run by its
934
1214
  * key here (`findByDispatch`), so a re-sent start answers the run it already made. */
935
1215
  dispatchedBy?: DispatchedBy;
1216
+ /** Who started the run (ADR-0078). */
1217
+ startedBy?: RunStarter;
1218
+ /** The Caller that cancelled the run (ADR-0078). */
1219
+ cancelledBy?: CallerRecord;
1220
+ /** The Caller whose `jr2 run` resumed this standing run (ADR-0017). */
1221
+ resumedBy?: CallerRecord;
1222
+ /** The run that took over from this standing run at a hand-over (ADR-0017). */
1223
+ handedOverTo?: string;
1224
+ /** The last answers to the run's Gates, with their Callers (ADR-0078). */
1225
+ answers?: GateAnswer[];
1226
+ /** When each open Gate opened, ISO-8601, by its gate id (ADR-0011): what `GET /gates` orders by.
1227
+ * Kept in the blob so that a restored run's Gate keeps the time it first opened, not the time of
1228
+ * the restore. A Gate that closed is not in it. */
1229
+ gatesOpenedAt?: Record<string, string>;
936
1230
  /** The shape of the Machine that WROTE this snapshot (ADR-0030). `workflow` says which def to
937
1231
  * look up; this says whether the def found there is still the one this snapshot can be read by.
938
1232
  * Absent means written before the stamp existed, which restore treats as drift — the point is to
@@ -961,8 +1255,10 @@ type RunBlob = {
961
1255
  * The pending NOTICES (ADR-0062): what the next Turn in a scope must be told — a memory kill in
962
1256
  * its Workspace, a conversation that is new. The Orchestrator is their only keeper, so they ride
963
1257
  * this blob beside the two ledgers, for the same reasons: the run's exact lifetime, and the
964
- * snapshot's atomicity. A notice leaves it in the save that ledgers the admission that carried
965
- * it, so a restart before that save delivers it again and a restart after does not.
1258
+ * snapshot's atomicity. A notice is cleared for a conversation in the save that ledgers the
1259
+ * admission that carried it there, so a restart before that save delivers it again and a restart
1260
+ * after does not. A conversation notice then leaves it; a Workspace notice stays, with the
1261
+ * conversations that heard it, for the Workspace's other conversations.
966
1262
  */
967
1263
  notices?: NoticeLedgerState;
968
1264
  /**
@@ -997,7 +1293,7 @@ type RunBlob = {
997
1293
  * outstanding or not. Same blob, so a restore names the same Agent. A child no longer in the tree
998
1294
  * is dropped at the save.
999
1295
  */
1000
- startedBy?: Record<string, StartedBy>;
1296
+ childAgents?: Record<string, ChildAgent>;
1001
1297
  fault?: string;
1002
1298
  /** The Failure that ended the run `failed` (ADR-0073), as plain data: the run's output. xstate
1003
1299
  * keeps the error of an errored snapshot, and serializes it to `{}`, so it is kept here. */
@@ -1021,13 +1317,33 @@ type LiveRun = {
1021
1317
  holds: Map<string, Hold>;
1022
1318
  /** The Effect ledger (ADR-0067): persisted as `RunBlob.effects`, seeded on restore. */
1023
1319
  effects: Record<string, EffectRow>;
1024
- /** Which Agent started each live child (ADR-0067): persisted as `RunBlob.startedBy`, seeded on restore. */
1025
- startedBy: Record<string, StartedBy>;
1320
+ /** Which Agent started each live child (ADR-0067): persisted as `RunBlob.childAgents`, seeded on restore. */
1321
+ childAgents: Record<string, ChildAgent>;
1026
1322
  /** The error that killed the run, if it errored (xstate serializes Error to `{}`, so the
1027
1323
  * message is captured here at the observer and persisted onto the blob for `read`). */
1028
1324
  fault?: string;
1029
1325
  /** The Failure that ended the run, if it failed (ADR-0073): persisted as `RunBlob.failure`. */
1030
1326
  failure?: FailureInput;
1327
+ /** The bound Machine as it was while the run last bound a Chart (ADR-0069, ADR-0070), and the
1328
+ * hashes of those Charts: the terminal write keeps this doc, because the terminal snapshot binds
1329
+ * no Chart. Not persisted: a restored run takes it again at its next save. */
1330
+ lastBound?: { hashes: string[]; doc: MachineDoc };
1331
+ /** The Caller that cancelled the run (ADR-0078): persisted as `RunBlob.cancelledBy`. */
1332
+ cancelledBy?: CallerRecord;
1333
+ /** The end that the host gives the run while it stops its actor ({@link RunHost.end}). Not
1334
+ * persisted: the save that follows the stop writes it as the run's status. */
1335
+ ending?: EndedStatus;
1336
+ /** The run that took over from this standing run (ADR-0017): persisted as `RunBlob.handedOverTo`. */
1337
+ handedOverTo?: string;
1338
+ /** This standing run took its bound of items and none of them started a run (ADR-0017): jr2 ended
1339
+ * it `done`, and its hand-over waits out the back-off. Not persisted: the head's streak keeps it. */
1340
+ boundWithNoRun?: boolean;
1341
+ /** The last answers to the run's Gates (ADR-0078): persisted as `RunBlob.answers`, seeded on
1342
+ * restore. */
1343
+ answers: GateAnswer[];
1344
+ /** When each open Gate opened, by its gate id (ADR-0011): persisted as `RunBlob.gatesOpenedAt`,
1345
+ * seeded on restore before the actor starts, so a Gate that registers again keeps its time. */
1346
+ gatesOpenedAt: Map<string, string>;
1031
1347
  /** Per-run observers fed by `persist()` (status) and the actor's `emit` (emit) — SSE/CLI watch. */
1032
1348
  listeners: Set<(e: RunFeedEvent) => void>;
1033
1349
  /** The run's feed-so-far (ADR-0023): what a Workspace attaching mid-run gets replayed as its
@@ -1043,7 +1359,19 @@ type LiveRun = {
1043
1359
  export class RunHost {
1044
1360
  /** The internal registration table both delivery surfaces share (ADR-0011). Callback actors
1045
1361
  * (`gate`, the Agent slots) reach it via the run binding, not this field. */
1046
- private readonly table = new RegistrationTable();
1362
+ // A Gate's `openedAt` (ADR-0011): recorded when it registers, unless its run's blob already
1363
+ // holds one (a restore), and dropped when it closes. A run that a host parks keeps the time in
1364
+ // its last save, because a stopped run is not saved again.
1365
+ private readonly table = new RegistrationTable({
1366
+ opened: (reg) => {
1367
+ if (reg.kind !== "gate") return;
1368
+ const opened = this.runs.get(reg.runId)?.gatesOpenedAt;
1369
+ if (opened && !opened.has(reg.id)) opened.set(reg.id, new Date(this.now()).toISOString());
1370
+ },
1371
+ closed: (reg) => {
1372
+ if (reg.kind === "gate") this.runs.get(reg.runId)?.gatesOpenedAt.delete(reg.id);
1373
+ },
1374
+ });
1047
1375
 
1048
1376
  private readonly store: SnapshotStore;
1049
1377
  private readonly reconcile: (run: RunRecord) => boolean | Promise<boolean>;
@@ -1062,8 +1390,9 @@ export class RunHost {
1062
1390
  * hosted again (`store.live()` row order) or while the callee is not hosted at all.
1063
1391
  */
1064
1392
  private readonly settleListeners = new Map<string, Set<(status: RunStatus) => void>>();
1065
- /** Idempotency key → the start in flight under it (ADR-0071): a second start with the same key
1066
- * before the first has spawned its run answers the first's run, never a second. */
1393
+ /** (Workflow, idempotency key) → the start in flight under it (ADR-0071): a second start of the
1394
+ * same Workflow with the same key before the first has spawned its run answers the first's run,
1395
+ * never a second. */
1067
1396
  private readonly dispatching = new Map<string, Promise<DispatchedRun>>();
1068
1397
  private readonly echoFactory?: (
1069
1398
  endpoint: string,
@@ -1125,6 +1454,14 @@ export class RunHost {
1125
1454
  private readonly parked = new Set<string>();
1126
1455
  #stopping = false;
1127
1456
  #restorePending = false;
1457
+ /** The bound and the back-off of standing runs (ADR-0017). */
1458
+ private readonly standingOpts: Required<StandingOptions>;
1459
+ /** The head of each standing Workflow (ADR-0017), read from the store at first use and written
1460
+ * through to it. */
1461
+ #heads: Map<string, StandingHead> | undefined;
1462
+ /** Each standing Workflow's timer: the back-off before a hand-over after `error`, or the age
1463
+ * bound of its live run. One a Workflow. */
1464
+ private readonly standingTimers = new Map<string, ReturnType<typeof setTimeout>>();
1128
1465
 
1129
1466
  constructor(opts: RunHostOptions) {
1130
1467
  this.store = opts.store;
@@ -1145,6 +1482,11 @@ export class RunHost {
1145
1482
  : undefined;
1146
1483
  this.liveSetIntervalMs = opts.liveSetIntervalMs ?? DEFAULT_LEASE_INTERVAL_MS;
1147
1484
  this.now = opts.now ?? Date.now;
1485
+ this.standingOpts = {
1486
+ dispatches: opts.standing?.dispatches ?? STANDING_BOUND.dispatches,
1487
+ ageMs: opts.standing?.ageMs ?? STANDING_BOUND.ageMs,
1488
+ backoffMs: opts.standing?.backoffMs ?? standingBackoff,
1489
+ };
1148
1490
  }
1149
1491
 
1150
1492
  /** Register a workflow so `start`/`restore` can run it. Re-registering replaces (dev reload).
@@ -1241,42 +1583,52 @@ export class RunHost {
1241
1583
  /**
1242
1584
  * The run's bound Machine (ADR-0070): the Workflow's Machine with each bound Open body's subgraph
1243
1585
  * the compiled Chart's (its `src` stays the slot key, so the live child joins it as before) —
1244
- * from the live actor's bound Machine, or, for a stored run, compiled again from the text its
1245
- * blob holds. Undefined for an unknown run, and for a stored run whose Workflow is not registered.
1586
+ * from the live actor's bound Machine; for an ended run, the copy its terminal write kept
1587
+ * (ADR-0069); for a stored run with no copy, compiled again from the text its blob holds. Undefined
1588
+ * for an unknown run, and for a stored run with no copy whose Workflow is not registered.
1246
1589
  */
1247
1590
  async runMachine(runId: string): Promise<MachineDoc | undefined> {
1248
1591
  const live = this.runs.get(runId);
1249
- let workflow: string;
1250
- let machine: AnyStateMachine;
1251
- let bodies: Array<{ route: BoundChartRoute; compiled: AnyStateMachine | string }>;
1252
- if (live) {
1253
- workflow = live.record.workflow;
1254
- machine = live.def.machine;
1255
- const refs = new Map<string, AnyActorRef>();
1256
- const collect = (ref: AnyActorRef, path: string[]): void => {
1257
- for (const [id, child] of Object.entries(childrenOfRef(ref))) {
1258
- refs.set([...path, id].join("/"), child);
1259
- collect(child, [...path, id]);
1260
- }
1261
- };
1262
- collect(live.actor, []);
1263
- bodies = boundChartRoutesOf(live.actor.getSnapshot()).map((route) => {
1264
- const ref = refs.get(route.path.join("/"));
1265
- return { route, compiled: (ref && boundMachineOf(ref)) ?? "the live body holds no compiled Chart" };
1266
- });
1267
- } else {
1268
- const stored = await this.store.load(runId);
1269
- const blob = stored?.snapshot as RunBlob | null | undefined;
1270
- if (!blob) return undefined;
1271
- const def = this.workflowDefs.get(blob.workflow) ?? this.workflowDefs.get(wrapperName(blob.workflow));
1272
- if (!def) return undefined;
1273
- workflow = blob.workflow;
1274
- machine = def.machine;
1275
- bodies = boundChartRoutesOf(blob.snapshot).map((route) => ({
1276
- route,
1277
- compiled: compileStored(def.machine, route),
1278
- }));
1279
- }
1592
+ if (live) return this.liveMachineDoc(live);
1593
+ // An ended run's Machine as its terminal write kept it (ADR-0069): the Machine it ran on, which
1594
+ // can be of an Assembly that is no longer held.
1595
+ const kept = this.store.keptMachine(runId);
1596
+ if (kept) return { ...(kept.doc as MachineDoc), workflow: kept.workflow };
1597
+ const stored = await this.store.load(runId);
1598
+ const blob = stored?.snapshot as RunBlob | null | undefined;
1599
+ if (!blob) return undefined;
1600
+ const def = this.workflowDefs.get(blob.workflow) ?? this.workflowDefs.get(wrapperName(blob.workflow));
1601
+ if (!def) return undefined;
1602
+ return this.boundDoc(
1603
+ blob.workflow,
1604
+ def.machine,
1605
+ boundChartRoutesOf(blob.snapshot).map((route) => ({ route, compiled: compileStored(def.machine, route) })),
1606
+ );
1607
+ }
1608
+
1609
+ /** A live run's bound Machine, from its own def (the Assembly it holds) and its live actor. */
1610
+ private liveMachineDoc(live: LiveRun): MachineDoc {
1611
+ const refs = new Map<string, AnyActorRef>();
1612
+ const collect = (ref: AnyActorRef, path: string[]): void => {
1613
+ for (const [id, child] of Object.entries(childrenOfRef(ref))) {
1614
+ refs.set([...path, id].join("/"), child);
1615
+ collect(child, [...path, id]);
1616
+ }
1617
+ };
1618
+ collect(live.actor, []);
1619
+ const bodies = boundChartRoutesOf(live.actor.getSnapshot()).map((route) => {
1620
+ const ref = refs.get(route.path.join("/"));
1621
+ return { route, compiled: (ref && boundMachineOf(ref)) ?? "the live body holds no compiled Chart" };
1622
+ });
1623
+ return this.boundDoc(live.record.workflow, live.def.machine, bodies);
1624
+ }
1625
+
1626
+ /** The Workflow's Machine doc with each bound body's Chart compiled in. */
1627
+ private boundDoc(
1628
+ workflow: string,
1629
+ machine: AnyStateMachine,
1630
+ bodies: Array<{ route: BoundChartRoute; compiled: AnyStateMachine | string }>,
1631
+ ): MachineDoc {
1280
1632
  const doc = serializeMachine(workflow, machine);
1281
1633
  const notDrawn: NonNullable<MachineDoc["chartsNotDrawn"]> = [];
1282
1634
  for (const { route, compiled } of bodies) {
@@ -1315,53 +1667,65 @@ export class RunHost {
1315
1667
  * Start a fresh run of a registered workflow; returns its durable ids.
1316
1668
  *
1317
1669
  * With `dispatchedBy` (ADR-0071), the start is a `dispatch` Effect's, and its `effect` is the
1318
- * idempotency key: a run that already carries the key is answered, `existing`, and none is
1319
- * started. That is what lets a caller re-send a start whose answer it lost. The key is looked for
1670
+ * idempotency key, scoped to `workflow`: a run of `workflow` that already carries the key is
1671
+ * answered, `existing`, and none is started. The same key in another Workflow is another run. That is what lets a caller re-send a start whose answer it lost. The key is looked for
1320
1672
  * among the runs this process hosts first — a run's first save is a microtask after its spawn —
1321
- * then in the store. A key on a row the weekly sweep removed (ADR-0065) starts a new run: known,
1673
+ * then in the store. A key on a row the sweep removed (ADR-0065) starts a new run: known,
1322
1674
  * and not guarded. The depth is recorded as sent: the caller states the callee's.
1675
+ *
1676
+ * `startedBy` is who starts it (ADR-0078), which the run records. Absent with `dispatchedBy`, the
1677
+ * start is a local dispatch's, and the run records the caller's run and slot.
1323
1678
  */
1324
1679
  async start(
1325
1680
  workflow: string,
1326
1681
  input: Record<string, unknown> = {},
1327
- opts: { dispatchedBy?: DispatchedBy } = {},
1682
+ opts: { dispatchedBy?: DispatchedBy; startedBy?: RunStarter } = {},
1328
1683
  ): Promise<{ runId: string; instanceId: string; existing?: true }> {
1329
1684
  const by = opts.dispatchedBy;
1330
- if (!by) return this.startFresh(workflow, input);
1331
- const inFlight = this.dispatching.get(by.effect);
1332
- if (inFlight) return { ...keyAnswer(await inFlight, workflow, by.effect), existing: true };
1685
+ const startedBy: RunStarter | undefined =
1686
+ opts.startedBy ?? (by ? { kind: "run", run: by.run, slot: by.slot } : undefined);
1687
+ if (!by) return this.startFresh(workflow, input, undefined, startedBy);
1688
+ // The starts in flight are held by the key in its Workflow's scope.
1689
+ const scoped = JSON.stringify([workflow, dispatchKeyOf(by)]);
1690
+ const inFlight = this.dispatching.get(scoped);
1691
+ if (inFlight) return { ...(await inFlight), existing: true };
1333
1692
  let existing = false;
1334
1693
  const started = (async (): Promise<DispatchedRun> => {
1335
- const found = await this.dispatched(by.effect);
1694
+ const found = await this.dispatched(workflow, dispatchKeyOf(by));
1336
1695
  if (found) {
1337
1696
  existing = true;
1338
1697
  return found;
1339
1698
  }
1340
- return { ...(await this.startFresh(workflow, input, by)), workflow };
1699
+ return this.startFresh(workflow, input, by, startedBy);
1341
1700
  })();
1342
- this.dispatching.set(by.effect, started);
1701
+ this.dispatching.set(scoped, started);
1343
1702
  try {
1344
- const ids = keyAnswer(await started, workflow, by.effect);
1703
+ const ids = await started;
1345
1704
  return existing ? { ...ids, existing: true } : ids;
1346
1705
  } finally {
1347
- this.dispatching.delete(by.effect);
1706
+ this.dispatching.delete(scoped);
1348
1707
  }
1349
1708
  }
1350
1709
 
1351
1710
  /**
1352
- * The run that carries the idempotency key `effect` (ADR-0071), or undefined: a hosted run first,
1353
- * then a stored one, in any status.
1711
+ * The run of `workflow` that carries the idempotency key `key` (ADR-0071), or undefined: a hosted
1712
+ * run first, then a stored one, in any status, while its row lives. The key is scoped to the
1713
+ * Workflow: a run of another Workflow under the same key is not this one.
1354
1714
  */
1355
- async dispatched(effect: string): Promise<DispatchedRun | undefined> {
1715
+ async dispatched(workflow: string, key: string): Promise<DispatchedRun | undefined> {
1356
1716
  for (const run of this.runs.values()) {
1357
- if (run.record.dispatchedBy?.effect === effect) {
1358
- return { runId: run.record.runId, instanceId: run.record.instanceId, workflow: run.record.workflow };
1717
+ if (
1718
+ run.record.workflow === workflow &&
1719
+ run.record.dispatchedBy !== undefined &&
1720
+ dispatchKeyOf(run.record.dispatchedBy) === key
1721
+ ) {
1722
+ return { runId: run.record.runId, instanceId: run.record.instanceId };
1359
1723
  }
1360
1724
  }
1361
- const runId = await this.store.findByDispatch(effect);
1725
+ const runId = await this.store.findByDispatch(workflow, key);
1362
1726
  if (runId === undefined) return undefined;
1363
1727
  const blob = (await this.store.load(runId))?.snapshot as RunBlob | null | undefined;
1364
- return blob ? { runId, instanceId: blob.instanceId, workflow: blob.workflow } : undefined;
1728
+ return blob ? { runId, instanceId: blob.instanceId } : undefined;
1365
1729
  }
1366
1730
 
1367
1731
  /**
@@ -1376,11 +1740,16 @@ export class RunHost {
1376
1740
  by: DispatchedBy,
1377
1741
  ): Promise<DispatchStart> {
1378
1742
  // The key first: a re-sent start answers its run whatever the Assembly holds now.
1379
- const found = await this.dispatched(by.effect);
1380
- if (found && found.workflow !== workflow) {
1381
- return { ok: false, status: 409, report: [keyConflict(found, workflow, by.effect)] };
1382
- }
1743
+ const found = await this.dispatched(workflow, dispatchKeyOf(by));
1383
1744
  if (found) return { ok: true, runId: found.runId, existing: true };
1745
+ // jr2 starts a standing Workflow's run (ADR-0017): a dispatch never starts one.
1746
+ if (this.isStanding(workflow)) {
1747
+ return {
1748
+ ok: false,
1749
+ status: 409,
1750
+ report: [`"${workflow}" is a standing Workflow: jr2 starts its run, and a dispatch cannot (ADR-0017)`],
1751
+ };
1752
+ }
1384
1753
  const unrunnable = this.unrunnableChart?.(workflow);
1385
1754
  if (unrunnable !== undefined) {
1386
1755
  return { ok: false, status: 409, report: [`named Chart "${workflow}" cannot run: ${unrunnable}`] };
@@ -1398,7 +1767,6 @@ export class RunHost {
1398
1767
  };
1399
1768
  }
1400
1769
  if (err instanceof UnknownWorkflowError) return { ok: false, status: 404, report: [err.message] };
1401
- if (err instanceof DispatchKeyConflictError) return { ok: false, status: 409, report: [err.message] };
1402
1770
  throw err;
1403
1771
  }
1404
1772
  }
@@ -1409,7 +1777,20 @@ export class RunHost {
1409
1777
  workflow: string,
1410
1778
  input: Record<string, unknown>,
1411
1779
  dispatchedBy?: DispatchedBy,
1780
+ startedBy?: RunStarter,
1412
1781
  ): Promise<{ runId: string; instanceId: string }> {
1782
+ return this.startNow(workflow, input, dispatchedBy, startedBy);
1783
+ }
1784
+
1785
+ /** {@link startFresh}, synchronous: a standing Pool's hand-over starts the new run and ends the old
1786
+ * one in one step (ADR-0017). Throws what the door refuses. */
1787
+ private startNow(
1788
+ workflow: string,
1789
+ input: Record<string, unknown>,
1790
+ dispatchedBy?: DispatchedBy,
1791
+ startedBy?: RunStarter,
1792
+ resumedBy?: CallerRecord,
1793
+ ): { runId: string; instanceId: string } {
1413
1794
  const def = this.workflowDefs.get(workflow);
1414
1795
  if (!def) throw new UnknownWorkflowError(`no workflow registered as "${workflow}"`);
1415
1796
 
@@ -1436,7 +1817,15 @@ export class RunHost {
1436
1817
 
1437
1818
  const runId = this.newId();
1438
1819
  const instanceId = this.newId();
1439
- const record: RunRecord = { runId, workflow, instanceId, ...(dispatchedBy ? { dispatchedBy } : {}) };
1820
+ const record: RunRecord = {
1821
+ runId,
1822
+ workflow,
1823
+ instanceId,
1824
+ startedAt: new Date(this.now()).toISOString(),
1825
+ ...(dispatchedBy ? { dispatchedBy } : {}),
1826
+ ...(startedBy ? { startedBy } : {}),
1827
+ ...(resumedBy ? { resumedBy } : {}),
1828
+ };
1440
1829
 
1441
1830
  const machine = this.assemble(def, instanceId);
1442
1831
  // Every Chart field of a Chart wrapper's door is admitted BEFORE the run exists (ADR-0070): a
@@ -1594,7 +1983,11 @@ export class RunHost {
1594
1983
  runId: stored.runId,
1595
1984
  workflow: blob.workflow,
1596
1985
  instanceId: blob.instanceId,
1986
+ // A blob written before the field has its row's start: its first save.
1987
+ startedAt: blob.startedAt ?? stored.startedAt ?? new Date(this.now()).toISOString(),
1597
1988
  ...(blob.dispatchedBy ? { dispatchedBy: blob.dispatchedBy } : {}),
1989
+ ...(blob.startedBy ? { startedBy: blob.startedBy } : {}),
1990
+ ...(blob.resumedBy ? { resumedBy: blob.resumedBy } : {}),
1598
1991
  };
1599
1992
 
1600
1993
  try {
@@ -1631,8 +2024,14 @@ export class RunHost {
1631
2024
  // …and the Effect ledger (ADR-0067): a restored Effect reads its row and never runs twice.
1632
2025
  blob.effects ?? {},
1633
2026
  // …and which Agent started each child (ADR-0067): its done event names the same Agent.
1634
- blob.startedBy ?? {},
2027
+ blob.childAgents ?? {},
1635
2028
  );
2029
+ // …and the answers to its Gates, with their Callers (ADR-0078): the record of who acted.
2030
+ const restored = this.runs.get(stored.runId);
2031
+ if (restored) restored.answers = [...(blob.answers ?? [])];
2032
+ // …and when each of its open Gates opened (ADR-0011), before the actor starts and the
2033
+ // Gates register again.
2034
+ if (restored) restored.gatesOpenedAt = new Map(Object.entries(blob.gatesOpenedAt ?? {}));
1636
2035
  // `start()`, then the pending timers again (ADR-0007, see `startAndRearm`). The next save
1637
2036
  // records them again.
1638
2037
  startAndRearm(actor, blob.timers ?? [], this.now());
@@ -1720,6 +2119,62 @@ export class RunHost {
1720
2119
  next();
1721
2120
  }
1722
2121
 
2122
+ /**
2123
+ * Where a kept conversation's entries may come from (ADR-0023, ADR-0076): the run, the Agent, the
2124
+ * pod and the slot that the run's admission ledger names for the Instance ID. The live ledger, the
2125
+ * ledger of a run that restore left unhosted, or the ledger in an ended run's row: the ledger
2126
+ * outlives the Turn and the run, so a batch that comes after the Turn's sweep or the run's end is
2127
+ * still placed. `live` says whether the run is live, so a batch that comes before its admission is
2128
+ * ledgered is asked again, not dropped. Undefined for a run that is not known.
2129
+ */
2130
+ async conversationPlace(instanceId: string): Promise<ConversationPlace | undefined> {
2131
+ const runId = instanceId.split("/")[0] ?? "";
2132
+ let agents: Record<string, AgentAdmission> | undefined = this.runs.get(runId)?.agents ?? this.unhosted.get(runId);
2133
+ let live = agents !== undefined;
2134
+ if (!agents) {
2135
+ const stored = await this.store.load(runId);
2136
+ if (!stored) return undefined;
2137
+ live = stored.status === "live";
2138
+ agents = (stored.snapshot as RunBlob | null | undefined)?.agents ?? {};
2139
+ }
2140
+ // A row is keyed by the invocation's Instance ID and names the newest conversation of it. A
2141
+ // runaway reroll (ADR-0035) replaces the conversation (`<key>-r<n>`), so a late batch of a
2142
+ // conversation that the reroll ended (the key itself, or an earlier `<key>-r<m>`) is placed by
2143
+ // the same row: that batch tells how the run went wrong. An exact match wins.
2144
+ const rows = Object.entries(agents);
2145
+ const row =
2146
+ rows.find(([key, a]) => (a.instanceId ?? key) === instanceId) ??
2147
+ rows.find(
2148
+ ([key]) =>
2149
+ instanceId === key || (instanceId.startsWith(`${key}-r`) && /^\d+$/.test(instanceId.slice(key.length + 2))),
2150
+ );
2151
+ if (row) {
2152
+ const [, admission] = row;
2153
+ if (admission.agent === undefined) return { runId, live };
2154
+ return {
2155
+ runId,
2156
+ live,
2157
+ agent: admission.agent,
2158
+ ...(admission.sandbox !== undefined ? { sandbox: admission.sandbox } : {}),
2159
+ ...(admission.slot !== undefined ? { slot: admission.slot } : {}),
2160
+ };
2161
+ }
2162
+ return { runId, live };
2163
+ }
2164
+
2165
+ /** Whether a run is live: hosted, left unhosted by a restore, or a `live` row (ADR-0065). The
2166
+ * space guard never sweeps such a run's conversations. */
2167
+ async runIsLive(runId: string): Promise<boolean> {
2168
+ if (this.runs.has(runId) || this.unhosted.has(runId)) return true;
2169
+ return (await this.store.load(runId))?.status === "live";
2170
+ }
2171
+
2172
+ /** The run of the Turn that is registered for this Instance ID, or undefined when none is. A
2173
+ * cheap read: it asks no guard and builds no Menu. */
2174
+ agentRun(instanceId: string): string | undefined {
2175
+ return this.table.lookup(agentAddress(instanceId))?.runId;
2176
+ }
2177
+
1723
2178
  /**
1724
2179
  * An agent instance's LIVE surface (`GET /agents/:iid/surface` — ADR-0013): what the state that
1725
2180
  * invoked this Agent accepts, right now. Undefined once nothing is registered (the state exited,
@@ -1740,6 +2195,7 @@ export class RunHost {
1740
2195
  instanceId,
1741
2196
  runId: reg.runId,
1742
2197
  sandbox: reg.sandbox,
2198
+ ...(reg.slot !== undefined ? { slot: reg.slot } : {}),
1743
2199
  menu: [...menuOf(reg).values()].map((def) => ({
1744
2200
  name: def.name,
1745
2201
  description: def.description,
@@ -1757,6 +2213,80 @@ export class RunHost {
1757
2213
  this.table.lookup(agentAddress(instanceId))?.served?.();
1758
2214
  }
1759
2215
 
2216
+ /** The notices this Turn's conversation hears at a step boundary (ADR-0062): its pending notices
2217
+ * in their audience, reserved for this read until the Harness acknowledges them
2218
+ * ({@link noticesHeard}) or the Turn ends. Empty for a registration that hears none. */
2219
+ async boundaryNotices(instanceId: string): Promise<Array<{ id: string; notice: LedgerBody }>> {
2220
+ return (await this.table.lookup(agentAddress(instanceId))?.boundaryNotices?.()) ?? [];
2221
+ }
2222
+
2223
+ /** The Harness appended these step-boundary notices to the Turn (ADR-0062): they are heard in its
2224
+ * conversation, ledgered in one save. Answers how many were this conversation's reservations. */
2225
+ noticesHeard(instanceId: string, ids: readonly string[]): number {
2226
+ return this.table.lookup(agentAddress(instanceId))?.noticesHeard?.(ids) ?? 0;
2227
+ }
2228
+
2229
+ /**
2230
+ * The Agent slots of a live run (ADR-0002): each slot of each live Machine instance, whether a
2231
+ * Turn of it is open, and whether a Caller may steer it. Undefined for a run that is not live.
2232
+ */
2233
+ agents(runId: string): RunAgent[] | undefined {
2234
+ const run = this.runs.get(runId);
2235
+ if (!run) return undefined;
2236
+ const open = new Set(
2237
+ this.table
2238
+ .byRun(runId)
2239
+ .filter((reg) => reg.kind === "agent" && reg.slot !== undefined)
2240
+ .map((reg) => reg.slot!),
2241
+ );
2242
+ return liveSlots(run.actor).map((s) => ({ agent: s.agent, open: open.has(s.agent), steerable: s.steerable }));
2243
+ }
2244
+
2245
+ /**
2246
+ * A Caller's Steering message to one Agent slot of a live run (ADR-0002, ADR-0062). It is held
2247
+ * in the run's notice ledger, saved at once: the slot's open Turn hears it at its next step
2248
+ * boundary, or its next Turn in the same Machine instance at admission. Refused (409) when the
2249
+ * run is not live or no live Machine instance holds the slot, and (403) when the Agent is not
2250
+ * steerable. Answers the message's id, and whether a Turn of the slot is open now.
2251
+ */
2252
+ steer(runId: string, agent: string, text: string, by: CallerRecord): { id: string; agent: string; open: boolean } {
2253
+ const run = this.runs.get(runId);
2254
+ if (!run) throw new SteeringRefused(`run "${runId}" is not live: no Agent of it can hear a message`, 409);
2255
+ const agents = this.agents(runId) ?? [];
2256
+ const slot = agents.find((a) => a.agent === agent);
2257
+ if (!slot) {
2258
+ throw new SteeringRefused(
2259
+ `no live Machine instance of run "${runId}" holds the Agent slot "${agent}"` +
2260
+ (agents.length > 0 ? ` (its slots: ${agents.map((a) => a.agent).join(", ")})` : " (it has none now)"),
2261
+ 409,
2262
+ agents,
2263
+ );
2264
+ }
2265
+ if (!slot.steerable) {
2266
+ throw new SteeringRefused(
2267
+ `the Agent slot "${agent}" is not steerable: its definition says steerable: false`,
2268
+ 403,
2269
+ );
2270
+ }
2271
+ const id = run.notices.steer(
2272
+ {
2273
+ kind: "steering",
2274
+ scope: "slot",
2275
+ text,
2276
+ author: {
2277
+ kind: "caller",
2278
+ via: by.via,
2279
+ name: by.shown ?? by.name,
2280
+ ...(by.onBehalfOf !== undefined ? { onBehalfOf: by.onBehalfOf } : {}),
2281
+ },
2282
+ at: new Date(this.now()).toISOString(),
2283
+ },
2284
+ agent,
2285
+ );
2286
+ this.persist(run);
2287
+ return { id, agent, open: slot.open };
2288
+ }
2289
+
1760
2290
  /**
1761
2291
  * Deliver one event from an Agent's Harness (`POST /agents/:iid/events` — ADR-0013). Validation
1762
2292
  * and delivery are the table's; this only agent-scopes the address and mints the receipt. (A
@@ -1890,6 +2420,7 @@ export class RunHost {
1890
2420
  agent: invoking.sender?.id ?? "",
1891
2421
  since,
1892
2422
  ...(invoking.sandbox !== undefined ? { sandbox: invoking.sandbox } : {}),
2423
+ ...(invoking.slot !== undefined ? { slot: invoking.slot } : {}),
1893
2424
  ...(invoking.invokeId !== undefined ? { invokeId: invoking.invokeId } : {}),
1894
2425
  ...(conversation !== undefined ? { conversation } : {}),
1895
2426
  ...(sending && sending.chain.length > 0 ? { chain: [...sending.chain] } : {}),
@@ -2073,8 +2604,8 @@ export class RunHost {
2073
2604
  * its own send, else the one outstanding. On a transition below no Agent's state (`null`): the
2074
2605
  * event that took the transition names the Agent — the sender of a pick, or the Agent whose pick
2075
2606
  * started the child whose done or error event it is — and that Agent's pick is the one shaped, and
2076
- * no other; none when that Agent has no pick being answered. When one slot is invoked in two
2077
- * regions, the event names an invoke too, and only the pick at that invoke is shaped: the sending
2607
+ * no other; none when that Agent has no pick being answered. When one slot is invoked by two
2608
+ * states, the event names an invoke too, and only the pick at that invoke is shaped: the sending
2078
2609
  * pick, or the pick at the invoke whose pick started the child; none when that pick is not being
2079
2610
  * answered (its Turn ended, and its child runs on). An event that names no Agent (a timer,
2080
2611
  * a Gate's answer, an outside send) shapes the pick in its own send. Outside one, it shapes the one
@@ -2087,7 +2618,7 @@ export class RunHost {
2087
2618
  seat: AnswerSeat,
2088
2619
  runId: string,
2089
2620
  holds: Map<string, Hold>,
2090
- startedBy: Record<string, StartedBy>,
2621
+ childAgents: Record<string, ChildAgent>,
2091
2622
  event?: AnyEventObject,
2092
2623
  ): Sending | Hold | undefined {
2093
2624
  const seatedFor = (reg: Registration | undefined): boolean => {
@@ -2101,12 +2632,12 @@ export class RunHost {
2101
2632
  const sending = this.sending && this.sending.reg.runId === runId ? this.sending : undefined;
2102
2633
  const open = [...holds.values()].filter((hold) => hold.answer === undefined && seatedFor(regOf(hold)));
2103
2634
  if (seat === null && event !== undefined) {
2104
- const named = agentOfEvent(event, startedBy, machine, sending);
2635
+ const named = agentOfEvent(event, childAgents, machine, sending);
2105
2636
  if (named !== undefined) {
2106
2637
  const { agent } = named;
2107
2638
  const own = sending && sending.reg.sender?.id === agent ? sending : undefined;
2108
2639
  // That Agent's picks being answered: the one in its own send, and the ones outstanding at
2109
- // its other invokes (one slot invoked in two regions). The event names the invoke of the
2640
+ // its other invokes (one slot invoked by two states). The event names the invoke of the
2110
2641
  // pick that sent it or started its child, and only the pick at that invoke is shaped; none
2111
2642
  // when that pick is not being answered, and each other pick keeps its default answer.
2112
2643
  const held = open.filter((hold) => hold.agent === agent && hold.instanceId !== own?.reg.id);
@@ -2136,10 +2667,10 @@ export class RunHost {
2136
2667
  snapshot: AnyMachineSnapshot,
2137
2668
  childId: string,
2138
2669
  holds: Map<string, Hold>,
2139
- ): StartedBy | undefined {
2670
+ ): ChildAgent | undefined {
2140
2671
  const node = seatOf(snapshot, childId);
2141
2672
  if (node === undefined) return undefined;
2142
- const byState = new Map<unknown, StartedBy>();
2673
+ const byState = new Map<unknown, ChildAgent>();
2143
2674
  for (const hold of holds.values()) {
2144
2675
  if (hold.answer !== undefined) continue;
2145
2676
  const reg = this.holdRegs.get(hold) ?? this.table.lookup(agentAddress(hold.instanceId));
@@ -2177,10 +2708,16 @@ export class RunHost {
2177
2708
  return this.parked.has(`${instanceId} ${deliveryId}`);
2178
2709
  }
2179
2710
 
2180
- /** The Sandbox that may read a delivery's answer (ADR-0013), or undefined for an unknown one. */
2181
- deliveryScope(instanceId: string, deliveryId: string): { sandbox?: string } | undefined {
2711
+ /** The pod and slot that may read a delivery's answer (ADR-0013, ADR-0076), or undefined for an
2712
+ * unknown one. */
2713
+ deliveryScope(instanceId: string, deliveryId: string): { sandbox?: string; slot?: string } | undefined {
2182
2714
  const found = this.holdAt(instanceId, deliveryId);
2183
- return found && { ...(found.hold.sandbox !== undefined ? { sandbox: found.hold.sandbox } : {}) };
2715
+ return (
2716
+ found && {
2717
+ ...(found.hold.sandbox !== undefined ? { sandbox: found.hold.sandbox } : {}),
2718
+ ...(found.hold.slot !== undefined ? { slot: found.hold.slot } : {}),
2719
+ }
2720
+ );
2184
2721
  }
2185
2722
 
2186
2723
  /**
@@ -2234,22 +2771,59 @@ export class RunHost {
2234
2771
  }
2235
2772
 
2236
2773
  /** A run's open gates as callers discover them (`GET /runs/:id` — ADR-0011). Settled/unknown
2237
- * run → empty: a gate is a LIVE surface, it does not outlive its state. */
2238
- gates(runId: string): GateView[] {
2774
+ * run → empty: a gate is a LIVE surface, it does not outlive its state. `opens`, when given, keeps
2775
+ * only the Gates whose name (their invoke id) it opens: the Gates that a Caller may answer
2776
+ * (ADR-0078). */
2777
+ gates(runId: string, opens?: (name: string) => boolean): GateView[] {
2239
2778
  return this.table
2240
2779
  .byRun(runId)
2241
- .filter((reg) => reg.kind === "gate")
2242
- .map((reg) => ({
2243
- gate: reg.id,
2244
- path: reg.path ?? [],
2245
- accepts: [...reg.defs.values()].map((def) => ({
2246
- name: def.name,
2247
- description: def.description,
2248
- input: z.toJSONSchema(def.input),
2249
- })),
2250
- acceptsNow: acceptsNow(reg),
2251
- meta: reg.meta,
2252
- }));
2780
+ .filter((reg) => reg.kind === "gate" && (opens === undefined || opens(gateNameOf(reg))))
2781
+ .map(gateViewOf);
2782
+ }
2783
+
2784
+ /** The name of a run's open Gate (ADR-0078): its invoke id, the static name that the `answer`
2785
+ * scope names it by. Undefined when the run has no open Gate `gate`. */
2786
+ gateName(runId: string, gate: string): string | undefined {
2787
+ const reg = this.table.lookup(gateAddress(runId, gate));
2788
+ return reg?.kind === "gate" ? gateNameOf(reg) : undefined;
2789
+ }
2790
+
2791
+ /**
2792
+ * The Gate inbox (`GET /gates`, ADR-0011): every open Gate of every live run that `opens` opens
2793
+ * (the Gates that the Caller may answer, by the run's Workflow and the Gate's name, ADR-0078),
2794
+ * of one Workflow when `workflow` names it, oldest first. Never the run's context.
2795
+ */
2796
+ gateInbox(opens: (workflow: string, name: string) => boolean, workflow?: string): GateInboxEntry[] {
2797
+ const out: GateInboxEntry[] = [];
2798
+ for (const run of this.runs.values()) {
2799
+ const runWorkflow = run.record.workflow;
2800
+ if (workflow !== undefined && runWorkflow !== workflow) continue;
2801
+ for (const reg of this.table.byRun(run.record.runId)) {
2802
+ // A Gate that hears only Hooks (a Pool's `source` Gate) takes no event from a Caller: it is no
2803
+ // entry of a person's inbox.
2804
+ if (reg.kind !== "gate" || reg.defs.size === 0 || !opens(runWorkflow, gateNameOf(reg))) continue;
2805
+ let openedAt = run.gatesOpenedAt.get(reg.id);
2806
+ if (openedAt === undefined) {
2807
+ openedAt = new Date(this.now()).toISOString();
2808
+ run.gatesOpenedAt.set(reg.id, openedAt);
2809
+ }
2810
+ const hooks = Object.entries(reg.hooked?.hooks ?? {})
2811
+ .filter(([, keys]) => Object.keys(keys).length > 0)
2812
+ .map(([kind]) => kind)
2813
+ .sort();
2814
+ out.push({
2815
+ runId: run.record.runId,
2816
+ workflow: runWorkflow,
2817
+ ...gateViewOf(reg),
2818
+ openedAt,
2819
+ ...(hooks.length ? { hook: hooks } : {}),
2820
+ });
2821
+ }
2822
+ }
2823
+ // Oldest first; one time is ordered by run and Gate, so two reads give one order.
2824
+ return out.sort(
2825
+ (a, b) => a.openedAt.localeCompare(b.openedAt) || a.runId.localeCompare(b.runId) || a.gate.localeCompare(b.gate),
2826
+ );
2253
2827
  }
2254
2828
 
2255
2829
  /**
@@ -2258,13 +2832,29 @@ export class RunHost {
2258
2832
  * the run TOOK the event (ADR-0013): one that would run another party's writing, or that no
2259
2833
  * writing would take with this payload, throws {@link NotTakenError} naming what the Gate takes
2260
2834
  * now, and nothing is delivered.
2835
+ *
2836
+ * `by` is the Caller that answers (ADR-0078). The event's Sender carries it, with the fields that
2837
+ * jr2 checked (ADR-0011), and the run records it as the answer's `answeredBy`.
2838
+ *
2839
+ * `opens`, when given, is the Caller's `answer` scope for the run's Workflow, by the Gate's name
2840
+ * (its invoke id). An open Gate that it does not open throws {@link GateRefused}, and the 404 lists
2841
+ * only the Gates that it opens. The check reads the registration that the delivery goes to, in the
2842
+ * same synchronous step, so a Gate that opens while the request is in flight is checked too.
2261
2843
  */
2262
- sendToGate(runId: string, gate: string, event: { type?: unknown } & Record<string, unknown>): void {
2844
+ sendToGate(
2845
+ runId: string,
2846
+ gate: string,
2847
+ event: { type?: unknown } & Record<string, unknown>,
2848
+ by?: CallerRecord,
2849
+ delivery?: string,
2850
+ opens?: (name: string) => boolean,
2851
+ ): void {
2852
+ const reg = this.table.lookup(gateAddress(runId, gate));
2853
+ if (reg?.kind === "gate" && opens !== undefined && !opens(gateNameOf(reg))) throw new GateRefused(gateNameOf(reg));
2263
2854
  const { type, ...payload } = event;
2264
2855
  if (typeof type !== "string" || !type) {
2265
2856
  throw new EventValidationError(`event body must carry a string "type" (one of the gate's accepted names)`);
2266
2857
  }
2267
- const reg = this.table.lookup(gateAddress(runId, gate));
2268
2858
  const refused = (why: string): NotTakenError =>
2269
2859
  new NotTakenError(
2270
2860
  `gate "${gate}" is open, and the run does not take "${type}" now: ${why} — not delivered ` +
@@ -2276,10 +2866,24 @@ export class RunHost {
2276
2866
  // which refuses it with its own words.
2277
2867
  const def = reg?.defs.get(type);
2278
2868
  const parsed = def && !(payload && SENDER_KEY in payload) ? def.input.safeParse(payload ?? {}) : undefined;
2279
- if (reg && parsed?.success && !wouldMove(reg.invoker, { type, ...parsed.data, [SENDER_KEY]: reg.sender })) {
2869
+ const caller = by ? senderCallerOf(by) : undefined;
2870
+ const sender = reg?.sender && caller ? { ...reg.sender, ...caller } : reg?.sender;
2871
+ if (reg && parsed?.success && !wouldMove(reg.invoker, { type, ...parsed.data, [SENDER_KEY]: sender })) {
2280
2872
  throw refused("no writing of it would move the run with this payload");
2281
2873
  }
2282
- this.table.deliver(gateAddress(runId, gate), type, payload);
2874
+ this.table.deliver(gateAddress(runId, gate), type, payload, caller);
2875
+ // Taken: the run records who answered (ADR-0078), in the save that this transition schedules.
2876
+ const run = this.runs.get(runId);
2877
+ if (run && by) {
2878
+ run.answers.push({
2879
+ gate,
2880
+ type,
2881
+ at: new Date(this.now()).toISOString(),
2882
+ answeredBy: by,
2883
+ ...(delivery !== undefined ? { delivery } : {}),
2884
+ });
2885
+ if (run.answers.length > GATE_ANSWERS_KEPT) run.answers.splice(0, run.answers.length - GATE_ANSWERS_KEPT);
2886
+ }
2283
2887
  } catch (err) {
2284
2888
  if (err instanceof NotTakenError && err.writings.length) {
2285
2889
  throw refused(
@@ -2292,7 +2896,7 @@ export class RunHost {
2292
2896
  );
2293
2897
  }
2294
2898
  if (err instanceof UnknownAddressError) {
2295
- const open = this.gates(runId).map((g) => g.gate);
2899
+ const open = this.gates(runId, opens).map((g) => g.gate);
2296
2900
  throw new UnknownAddressError(
2297
2901
  `no open gate "${gate}" on run "${runId}"${open.length ? ` (open: ${open.join(", ")})` : ""}`,
2298
2902
  );
@@ -2301,6 +2905,162 @@ export class RunHost {
2301
2905
  }
2302
2906
  }
2303
2907
 
2908
+ /**
2909
+ * A Hook's fan-in (ADR-0011): give one delivery to every open Gate, in every live run of a Workflow
2910
+ * that `may` names, whose handlers hear `kind` and `key`, and to nothing else. Each handler reads
2911
+ * `{ payload, meta, caller }` and gives one event of its Gate's `delivers`, or `undefined` (not a
2912
+ * target: `ignored`). The event goes in as `jr2 send` sends it, with the Hook as its Caller, and
2913
+ * the run records the answer with the delivery's id. A handler that throws, an event that is not
2914
+ * in `delivers`, and an event that the run does not take now are each a line in `refused` and a
2915
+ * `hook-refused` mark on that run's feed: the run goes on. A Gate that already took this delivery
2916
+ * (a pair in the run's answers) is passed over, so a restore never gives it twice.
2917
+ *
2918
+ * Synchronous, as a handler is: the registrations are read once, and a Gate that a delivery before
2919
+ * it in the same fan-in closed is passed over.
2920
+ */
2921
+ async deliverHook(d: {
2922
+ hook: string;
2923
+ kind: string;
2924
+ key: string;
2925
+ delivery: string;
2926
+ payload: unknown;
2927
+ caller: CallerRecord;
2928
+ may: (workflow: string) => boolean;
2929
+ /** A person reads the answer (ADR-0011): run the `reply` of each target. */
2930
+ person?: boolean;
2931
+ }): Promise<HookFanIn> {
2932
+ const out: HookFanIn = { delivered: [], refused: [], ignored: 0, sources: [] };
2933
+ const replies: HookReply[] = [];
2934
+ const targets: Array<{ run: LiveRun; reg: Registration; handler: AnyHandler }> = [];
2935
+ const sources: Array<{ run: LiveRun; reg: Registration }> = [];
2936
+ for (const run of this.runs.values()) {
2937
+ if (!d.may(run.record.workflow)) continue;
2938
+ for (const reg of this.table.byRun(run.record.runId)) {
2939
+ if (reg.kind !== "gate") continue;
2940
+ const handler = reg.hooked?.hooks[d.kind]?.[d.key];
2941
+ if (handler) targets.push({ run, reg, handler });
2942
+ if (reg.source?.hears(d.kind, d.key)) sources.push({ run, reg });
2943
+ }
2944
+ }
2945
+ for (const { run, reg, handler } of targets) {
2946
+ const runId = run.record.runId;
2947
+ // Closed by an earlier target of this fan-in, or the run ended.
2948
+ if (this.table.lookup(reg.address) !== reg || this.runs.get(runId) !== run) continue;
2949
+ if (run.answers.some((a) => a.delivery === d.delivery && a.gate === reg.id)) continue;
2950
+ // The Gate's `reply` runs after the run answers, from that Gate's own outcome (ADR-0011).
2951
+ const gateReply = d.person ? reg.hooked?.reply : undefined;
2952
+ const place = { target: "gate" as const, workflow: run.record.workflow, run: runId, gate: reg.id };
2953
+ const meta = reg.meta;
2954
+ const refuse = (reason: string, event?: string, given?: unknown): void => {
2955
+ out.refused.push({ run: runId, gate: reg.id, ...(event !== undefined ? { event } : {}), reason });
2956
+ if (gateReply) {
2957
+ const outcome = {
2958
+ state: "refused" as const,
2959
+ ...(given !== undefined ? { event: given } : {}),
2960
+ reason,
2961
+ run: runId,
2962
+ };
2963
+ replies.push(runReply(place, () => gateReply(outcome as never, { meta, caller: d.caller })));
2964
+ }
2965
+ this.feed(run, {
2966
+ kind: "hook-refused",
2967
+ hook: d.hook,
2968
+ delivery: d.delivery,
2969
+ gate: reg.id,
2970
+ ...(event !== undefined ? { event } : {}),
2971
+ reason,
2972
+ });
2973
+ };
2974
+ let event: unknown;
2975
+ try {
2976
+ event = handler({ payload: d.payload, meta: reg.meta, caller: d.caller });
2977
+ } catch (err) {
2978
+ refuse(`its handler threw: ${err instanceof Error ? err.message : String(err)}`);
2979
+ continue;
2980
+ }
2981
+ if (event === undefined) {
2982
+ out.ignored++;
2983
+ continue;
2984
+ }
2985
+ const type = (event as { type?: unknown } | null)?.type;
2986
+ if (typeof type !== "string" || !reg.hooked?.delivers.includes(type)) {
2987
+ refuse(
2988
+ `its handler gave ${typeof type === "string" ? `"${type}"` : "no event type"}, which is not in the Gate's ` +
2989
+ `delivers (${reg.hooked?.delivers.join(", ")})`,
2990
+ typeof type === "string" ? type : undefined,
2991
+ );
2992
+ continue;
2993
+ }
2994
+ try {
2995
+ this.sendToGate(runId, reg.id, event as { type: string } & Record<string, unknown>, d.caller, d.delivery);
2996
+ } catch (err) {
2997
+ refuse(err instanceof Error ? err.message : String(err), type, event);
2998
+ continue;
2999
+ }
3000
+ out.delivered.push({ run: runId, workflow: run.record.workflow, gate: reg.id, event: type });
3001
+ if (gateReply) {
3002
+ const outcome = { state: "delivered" as const, event, run: runId };
3003
+ replies.push(runReply(place, () => gateReply(outcome as never, { meta, caller: d.caller })));
3004
+ }
3005
+ }
3006
+ // The Sources (ADR-0017), after the Gates: each one's handler, its key check and its hold. A
3007
+ // held item is durable before the answer leaves, so the run is saved before the next line.
3008
+ // A hand-over can end a standing run while a Source awaits (its key lookup, or an earlier
3009
+ // Source's save): the bound, a publish or the age timer. The start and the end of a hand-over are
3010
+ // one synchronous step, so the delivery goes on to the new run's Source; with no live run, the
3011
+ // last step below answers `paused` or `waiting` (503), and the delivery claims nothing.
3012
+ const handedOn: string[] = [];
3013
+ for (const first of sources) {
3014
+ let { run, reg } = first;
3015
+ for (let hops = 0; ; hops++) {
3016
+ const runId = run.record.runId;
3017
+ if (this.table.lookup(reg.address) !== reg || this.runs.get(runId) !== run) {
3018
+ const next = this.standingSourceAfter(run, d.kind, d.key);
3019
+ if (!next) break;
3020
+ if (hops >= STANDING_HOPS) {
3021
+ handedOn.push(run.record.workflow); // the door answers 503, and the delivery claims nothing
3022
+ break;
3023
+ }
3024
+ ({ run, reg } = next);
3025
+ continue;
3026
+ }
3027
+ const seat = reg.source!;
3028
+ const { item, ...line } = await seat.hear({
3029
+ hook: d.hook,
3030
+ kind: d.kind,
3031
+ key: d.key,
3032
+ delivery: d.delivery,
3033
+ payload: d.payload,
3034
+ caller: d.caller,
3035
+ });
3036
+ // A skip from a run that ended while it was heard: ask the new run's Source.
3037
+ if (line.verdict === "skipped" && this.runs.get(runId) !== run && this.isStanding(run.record.workflow)) {
3038
+ continue;
3039
+ }
3040
+ out.sources.push({ pool: runId, workflow: run.record.workflow, source: reg.id, ...line });
3041
+ if (line.verdict === "held" && this.runs.get(runId) === run) await this.persist(run);
3042
+ if (d.person && seat.reply) {
3043
+ const place = { target: "source" as const, workflow: run.record.workflow, run: runId, gate: reg.id };
3044
+ const verdict = { ...line, ...(item !== undefined ? { item } : {}) };
3045
+ const said = runReply(place, () => seat.reply!(verdict, d.caller));
3046
+ replies.push(line.verdict === "skipped" ? { ...said, skipped: true } : said);
3047
+ }
3048
+ break;
3049
+ }
3050
+ }
3051
+ // A standing Pool with no live run (ADR-0017): a paused one says `paused` and holds nothing, so a
3052
+ // read Source finds the item at the resume; one in its back-off makes the door answer 503, so
3053
+ // the service sends the delivery again.
3054
+ const without = this.standingWithoutRun(d.kind, d.key, d.may);
3055
+ for (const p of without.paused) {
3056
+ out.sources.push({ pool: p.run, workflow: p.workflow, source: "source", verdict: "paused" });
3057
+ }
3058
+ const waiting = [...new Set([...without.waiting, ...handedOn])];
3059
+ if (waiting.length) out.waiting = waiting;
3060
+ if (d.person) out.replies = replies;
3061
+ return out;
3062
+ }
3063
+
2304
3064
  /**
2305
3065
  * Observe a live run's feed (the `GET /runs/:id/events` SSE — ADR-0009): status deltas after every
2306
3066
  * transition, plus the author's `emit`s. Returns an unsubscribe fn. The current status is **replayed
@@ -2380,12 +3140,22 @@ export class RunHost {
2380
3140
  * Only when something waits on it (ADR-0071).
2381
3141
  */
2382
3142
  private settledUnhosted(runId: string, status: "lost" | "drifted", reason: string, blob?: RunBlob): void {
3143
+ // No Lease runs for a run that a restore ended without hosting it, so its Sandboxes (kept, as a
3144
+ // drift keeps them) are marked here, and `jr2 workspace rm` can remove them (ADR-0021).
3145
+ this.sandbox?.releaseRun?.(runId).catch((err: unknown) => {
3146
+ console.error(
3147
+ `run ${runId}: its Sandboxes were not marked as held by no run: ` +
3148
+ (err instanceof Error ? err.message : String(err)),
3149
+ );
3150
+ });
2383
3151
  if (!this.settleListeners.has(runId)) return;
2384
3152
  const bare: RunStatus = {
2385
3153
  runId,
2386
3154
  workflow: blob?.workflow ?? "",
2387
3155
  instanceId: blob?.instanceId ?? "",
3156
+ startedAt: blob?.startedAt ?? "",
2388
3157
  ...(blob?.dispatchedBy ? { dispatchedBy: blob.dispatchedBy } : {}),
3158
+ ...(blob?.startedBy ? { startedBy: blob.startedBy } : {}),
2389
3159
  status,
2390
3160
  value: undefined,
2391
3161
  context: undefined,
@@ -2472,7 +3242,7 @@ export class RunHost {
2472
3242
  * terminal run reports its `done`/`error` status + final context here rather than 404-ing. Returns
2473
3243
  * undefined only for a genuinely unknown run (or one marked `lost` with no row saved before, so no
2474
3244
  * snapshot: `markLost` keeps a saved row's blob, whose key must keep answering, ADR-0071) — and a
2475
- * swept one is unknown: a finished run's row lives a week (ADR-0065).
3245
+ * swept one is unknown: a finished run's row lives for `runs.retain` (ADR-0065).
2476
3246
  */
2477
3247
  async read(runId: string): Promise<RunStatus | undefined> {
2478
3248
  const live = this.status(runId);
@@ -2484,11 +3254,14 @@ export class RunHost {
2484
3254
  // The STORE row is the authority on the run's lifecycle, the snapshot on the Machine's: a
2485
3255
  // cancelled run's actor reports xstate's "stopped", which is mechanism, not an outcome
2486
3256
  // (ADR-0025). While the row still says "live", the Machine's own status is the answer.
2487
- const status = stored.status === "live" ? (snap.status ?? stored.status) : stored.status;
3257
+ // A live row's snapshot is `active`: the save that ends a run writes its row's end too.
3258
+ const status =
3259
+ stored.status === "live" ? shownOfMachine(snap.status ?? "active", undefined) : shownOfRow(stored.status);
2488
3260
  return {
2489
3261
  runId,
2490
3262
  workflow: blob.workflow,
2491
3263
  instanceId: blob.instanceId,
3264
+ startedAt: blob.startedAt ?? stored.startedAt ?? "",
2492
3265
  status,
2493
3266
  value: snap.value,
2494
3267
  context: snap.context,
@@ -2500,6 +3273,12 @@ export class RunHost {
2500
3273
  ...(blob.assembly !== undefined ? { assembly: blob.assembly } : {}),
2501
3274
  ...boundFields(snap),
2502
3275
  ...(blob.dispatchedBy ? { dispatchedBy: blob.dispatchedBy } : {}),
3276
+ ...(blob.startedBy ? { startedBy: blob.startedBy } : {}),
3277
+ ...(blob.resumedBy ? { resumedBy: blob.resumedBy } : {}),
3278
+ ...(blob.cancelledBy ? { cancelledBy: blob.cancelledBy } : {}),
3279
+ ...(blob.handedOverTo ? { handedOverTo: blob.handedOverTo } : {}),
3280
+ ...(blob.answers?.length ? { answers: blob.answers } : {}),
3281
+ ...steeringOf(blob.notices?.steering ?? []),
2503
3282
  // The Machine's output once the row says the run is over (ADR-0071), never while it is live;
2504
3283
  // for a failed run, its Failure (ADR-0073).
2505
3284
  ...(stored.status !== "live" && blob.failure !== undefined ? { output: blob.failure } : {}),
@@ -2507,13 +3286,113 @@ export class RunHost {
2507
3286
  ? { output: snap.output }
2508
3287
  : {}),
2509
3288
  ...dispatchedField(blob.effects ?? {}),
3289
+ ...worktreesField(blob.effects ?? {}),
2510
3290
  };
2511
3291
  }
2512
3292
 
3293
+ /**
3294
+ * The first Caller of a run's chain of starts (ADR-0078): its `startedBy` when that is a Caller,
3295
+ * else the first Caller of the run that dispatched it, and so on, live or stored. Undefined when
3296
+ * jr2 started the chain, or a run of it is no longer known. `jr2 status` walks the same chain.
3297
+ */
3298
+ async firstCaller(runId: string): Promise<CallerRecord | undefined> {
3299
+ const seen = new Set<string>();
3300
+ for (let id: string | undefined = runId; id !== undefined && !seen.has(id); ) {
3301
+ seen.add(id);
3302
+ const by: RunStarter | undefined = (await this.read(id))?.startedBy;
3303
+ if (by?.kind === "caller") {
3304
+ const { kind: _kind, ...caller } = by;
3305
+ return caller;
3306
+ }
3307
+ id = by?.kind === "run" ? by.run : undefined;
3308
+ }
3309
+ return undefined;
3310
+ }
3311
+
2513
3312
  list(): RunStatus[] {
2514
3313
  return [...this.runs.keys()].map((id) => this.status(id)).filter((s): s is RunStatus => s !== undefined);
2515
3314
  }
2516
3315
 
3316
+ /**
3317
+ * One page of the run listing (`GET /runs`, ADR-0009): the live runs from the registry, the
3318
+ * store's `live` rows that the registry does not hold (`active`, waiting for restore), and the
3319
+ * ended runs from the store's listing columns, in one order, newest start first. `readable` is
3320
+ * the Workflows that the Caller may read (`"all"`, or the names of its grant, which also open
3321
+ * their Charts): a run of another Workflow is left out. The store is asked for one row past the
3322
+ * page, so `next` is known with no second query.
3323
+ */
3324
+ async history(query: RunListQuery, readable: "all" | readonly string[]): Promise<RunPage> {
3325
+ // The rule of every other read route (`callerMay`); the store's SQL is the same rule.
3326
+ const opens = (workflow: string): boolean => grantOpens(readable, workflow);
3327
+ const live = query.live
3328
+ ? this.list()
3329
+ .filter((s) => s.status === LIVE_STATUS && opens(s.workflow))
3330
+ .filter((s) => query.workflow === undefined || s.workflow === query.workflow)
3331
+ .filter((s) => query.since === undefined || s.startedAt >= query.since)
3332
+ .filter((s) => query.after === undefined || isAfter(s, query.after))
3333
+ .map((s) => this.summaryOf(s))
3334
+ : [];
3335
+ const filter = {
3336
+ ...(readable !== "all" ? { workflows: readable } : {}),
3337
+ ...(query.workflow !== undefined ? { workflow: query.workflow } : {}),
3338
+ ...(query.since !== undefined ? { since: query.since } : {}),
3339
+ ...(query.after !== undefined ? { after: query.after } : {}),
3340
+ };
3341
+ const summary = (row: EndedRow, ended: boolean): RunSummary => ({
3342
+ runId: row.runId,
3343
+ workflow: row.workflow,
3344
+ status: ended ? shownOfRow(row.status) : LIVE_STATUS,
3345
+ ...(row.reason !== undefined ? { reason: row.reason } : ended ? {} : { reason: "waiting for restore" }),
3346
+ ...(row.startedBy !== undefined ? { startedBy: row.startedBy as RunStarter } : {}),
3347
+ startedAt: row.startedAt,
3348
+ ...(ended ? { endedAt: row.endedAt } : {}),
3349
+ ...(row.assembly !== undefined ? { assembly: row.assembly } : {}),
3350
+ openGates: 0,
3351
+ ...(row.dispatchedBy !== undefined ? { dispatchedBy: row.dispatchedBy as DispatchedBy } : {}),
3352
+ });
3353
+ // A `live` row that the registry does not hold: a restore deferred or failed (ADR-0069,
3354
+ // ADR-0030). Each hosted run takes at most one of the rows asked for, so asking for that many
3355
+ // more leaves a full page.
3356
+ const waiting = query.live
3357
+ ? (await this.store.liveRows({ ...filter, limit: query.limit + 1 + this.runs.size }))
3358
+ .filter((row) => !this.runs.has(row.runId))
3359
+ .map((row) => summary(row, false))
3360
+ : [];
3361
+ const ended = query.ended
3362
+ ? (await this.store.ended({ statuses: query.ended, ...filter, limit: query.limit + 1 })).map((row) =>
3363
+ summary(row, true),
3364
+ )
3365
+ : [];
3366
+ // A run is in one half: `persist()` writes the terminal row and drops the run from the registry
3367
+ // in one synchronous step. The live one wins all the same.
3368
+ const liveIds = new Set(live.map((r) => r.runId));
3369
+ const merged = [...live, ...[...waiting, ...ended].filter((r) => !liveIds.has(r.runId))].sort(newestFirst);
3370
+ const runs = merged.slice(0, query.limit);
3371
+ const last = runs.at(-1);
3372
+ return merged.length > query.limit && last ? { runs, next: encodeCursor(last) } : { runs };
3373
+ }
3374
+
3375
+ /** A live run's summary (ADR-0009): its open Gates counted now, its Workspaces in `placing`. */
3376
+ private summaryOf(status: RunStatus): RunSummary {
3377
+ const placing = placingOf(status.value, status.children);
3378
+ return {
3379
+ runId: status.runId,
3380
+ workflow: status.workflow,
3381
+ status: status.status,
3382
+ ...(status.reason !== undefined
3383
+ ? { reason: status.reason }
3384
+ : status.fault !== undefined
3385
+ ? { reason: status.fault }
3386
+ : {}),
3387
+ ...(status.startedBy ? { startedBy: status.startedBy } : {}),
3388
+ startedAt: status.startedAt,
3389
+ ...(status.assembly !== undefined ? { assembly: status.assembly } : {}),
3390
+ openGates: this.gates(status.runId).length,
3391
+ ...(placing.length ? { placing } : {}),
3392
+ ...(status.dispatchedBy ? { dispatchedBy: status.dispatchedBy } : {}),
3393
+ };
3394
+ }
3395
+
2517
3396
  /**
2518
3397
  * Run ids sharing a prefix — the live registry unioned with the store — backing abbreviated run
2519
3398
  * ids in the CLI (ADR-0009). The live half is not belt-and-braces: `persist()` is scheduled on a
@@ -2582,6 +3461,21 @@ export class RunHost {
2582
3461
  for (const listener of listeners) listener({ kind: "closed" });
2583
3462
  }
2584
3463
  this.workflowListeners.clear();
3464
+ for (const hook of [...this.closeHooks]) hook();
3465
+ this.closeHooks.clear();
3466
+ }
3467
+
3468
+ private readonly closeHooks = new Set<() => void>();
3469
+
3470
+ /** Run `fn` when `close()` begins, so a request that has no natural end (a follow of a kept
3471
+ * conversation) can end and the server can close. Returns the call that removes `fn`. */
3472
+ onClose(fn: () => void): () => void {
3473
+ if (this.#stopping) {
3474
+ fn();
3475
+ return () => {};
3476
+ }
3477
+ this.closeHooks.add(fn);
3478
+ return () => this.closeHooks.delete(fn);
2585
3479
  }
2586
3480
 
2587
3481
  /**
@@ -2628,18 +3522,17 @@ export class RunHost {
2628
3522
  * fault a run whose Agents were stopped on purpose.
2629
3523
  *
2630
3524
  * It then destroys every Sandbox the run owns, unless `keep` (`jr2 send <run> --event CANCEL
2631
- * --keep`): kept, they age out at the operator's idle timeout, and `jr2 ssh` reaches them until
3525
+ * --keep`): kept, they age out at the operator's idle timeout, and `jr2 workspace shell` reaches them until
2632
3526
  * then. A faulted run never comes through here, and keeps its Workspaces (ADR-0012).
2633
3527
  */
2634
- async cancel(runId: string, opts: { keep?: boolean } = {}): Promise<void> {
3528
+ async cancel(runId: string, opts: { keep?: boolean; by?: CallerRecord } = {}): Promise<void> {
2635
3529
  const run = this.runs.get(runId);
2636
3530
  if (!run) return;
3531
+ // The Caller that cancelled it (ADR-0078), saved with the terminal snapshot below.
3532
+ if (opts.by) run.cancelledBy = opts.by;
2637
3533
  // Named BEFORE the stop, while the tree still says which Workspaces are live.
2638
3534
  const sandboxes = opts.keep ? [] : runSandboxes(run.actor, runId);
2639
- run.actor.stop();
2640
- // Persist AFTER the stop: the snapshot is final, and `persist` fans out the last status,
2641
- // announces `gone`, and untracks — the same terminal path a run that settled on its own takes.
2642
- this.persist(run, "cancelled");
3535
+ this.end(run, "cancelled");
2643
3536
  // The run is over, so nothing can use its Workspaces, and each holds its whole Size (ADR-0025).
2644
3537
  // An actor stop skips the wrapper's `teardown` state, so the host runs the same idempotent
2645
3538
  // delete here, after the stop, so no lease renews what was deleted. `keep` leaves them to the
@@ -2657,8 +3550,24 @@ export class RunHost {
2657
3550
  const run = this.runs.get(runId);
2658
3551
  if (!run || run.binding !== binding || run.fault !== undefined) return;
2659
3552
  run.fault = fault;
3553
+ this.end(run, "error");
3554
+ }
3555
+
3556
+ /**
3557
+ * End a live run that the HOST ends, not its Machine: stop its actor, then save it with `status`.
3558
+ * The save comes AFTER the stop, so the snapshot is final; it feeds the last status, announces
3559
+ * `gone` and untracks, as for a run that ended on its own.
3560
+ *
3561
+ * The run is marked first, because the stop is not quiet. xstate gives the root the status
3562
+ * `stopped` before it stops the children, and each Agent invocation that stops ends its Turn in
3563
+ * the notice ledger, whose hook saves the run (ADR-0024, ADR-0062). That save would write and
3564
+ * feed `stopped`, and untrack the run before this end is said. While the mark is set, `persist`
3565
+ * writes nothing: the save below carries what the stop changed.
3566
+ */
3567
+ private end(run: LiveRun, status: EndedStatus): void {
3568
+ run.ending = status;
2660
3569
  run.actor.stop();
2661
- this.persist(run, "error");
3570
+ this.persist(run, status);
2662
3571
  }
2663
3572
 
2664
3573
  /**
@@ -2690,6 +3599,346 @@ export class RunHost {
2690
3599
  this.announce(run.record.workflow, { kind: "gone", runId: run.record.runId });
2691
3600
  }
2692
3601
 
3602
+ // --- standing Pools (ADR-0017) ---
3603
+
3604
+ /** The heads of the standing Workflows, by Workflow. */
3605
+ private get heads(): Map<string, StandingHead> {
3606
+ if (!this.#heads) this.#heads = new Map(this.store.standingHeads().map((h) => [h.workflow, h]));
3607
+ return this.#heads;
3608
+ }
3609
+
3610
+ private writeHead(head: StandingHead): void {
3611
+ this.heads.set(head.workflow, head);
3612
+ this.store.writeStanding(head);
3613
+ }
3614
+
3615
+ /** Is `workflow` a standing Workflow of the registry: its Machine a standing Pool (ADR-0017)? */
3616
+ isStanding(workflow: string): boolean {
3617
+ return standingPoolOf(this.workflowDefs.get(workflow)?.machine) !== undefined;
3618
+ }
3619
+
3620
+ /** The hosted runs of `workflow`, oldest first. */
3621
+ private hostedOf(workflow: string): LiveRun[] {
3622
+ return [...this.runs.values()].filter((r) => r.record.workflow === workflow);
3623
+ }
3624
+
3625
+ /** The live standing run of `workflow`: the head when this process hosts it, else the newest
3626
+ * hosted run. */
3627
+ private standingRun(workflow: string): LiveRun | undefined {
3628
+ const hosted = this.hostedOf(workflow);
3629
+ const head = this.heads.get(workflow);
3630
+ return hosted.find((r) => r.record.runId === head?.run) ?? hosted.at(-1);
3631
+ }
3632
+
3633
+ private clearStandingTimer(workflow: string): void {
3634
+ const timer = this.standingTimers.get(workflow);
3635
+ if (timer !== undefined) clearTimeout(timer);
3636
+ this.standingTimers.delete(workflow);
3637
+ }
3638
+
3639
+ private setStandingTimer(workflow: string, ms: number, fn: () => void): void {
3640
+ this.clearStandingTimer(workflow);
3641
+ const timer = setTimeout(
3642
+ () => {
3643
+ this.standingTimers.delete(workflow);
3644
+ if (!this.#stopping) fn();
3645
+ },
3646
+ Math.max(0, ms),
3647
+ );
3648
+ // A day's bound never holds the process: the next boot arms it again from the head's `since`.
3649
+ (timer as { unref?: () => void }).unref?.();
3650
+ this.standingTimers.set(workflow, timer);
3651
+ }
3652
+
3653
+ /** Arm the age bound of a standing Workflow's live run: at `since` + the bound, hand it over. */
3654
+ private armAge(workflow: string, run: LiveRun): void {
3655
+ const since = Date.parse(this.heads.get(workflow)?.since ?? "") || this.now();
3656
+ this.setStandingTimer(workflow, since + this.standingOpts.ageMs - this.now(), () => {
3657
+ if (this.runs.get(run.record.runId) === run && this.heads.get(workflow)?.run === run.record.runId) {
3658
+ this.handOverLive(run, "hand-over");
3659
+ }
3660
+ });
3661
+ }
3662
+
3663
+ /**
3664
+ * Keep one live run of each standing Workflow under the current registry (ADR-0017). At boot,
3665
+ * after the restore, and at each publish, after the swap. A live run of an older Assembly (or of
3666
+ * another Machine) is handed over. A run that ended `error`, `drifted` or `lost` is handed over
3667
+ * from its blob. A paused Workflow gets no run, and nor does one in its back-off. A Workflow with
3668
+ * no head gets its first run, which records `why`.
3669
+ */
3670
+ async keepStanding(why: "boot" | "publish"): Promise<void> {
3671
+ for (const [workflow, def] of [...this.workflowDefs]) {
3672
+ if (!standingPoolOf(def.machine)) continue;
3673
+ try {
3674
+ await this.keepOne(workflow, def, why);
3675
+ } catch (err) {
3676
+ // One standing Workflow that cannot start is one Workflow, not the boot (ADR-0030).
3677
+ console.error(
3678
+ `standing workflow "${workflow}": no run was started: ${err instanceof Error ? err.message : String(err)}`,
3679
+ );
3680
+ }
3681
+ }
3682
+ }
3683
+
3684
+ private async keepOne(workflow: string, def: WorkflowDef, why: "boot" | "publish"): Promise<void> {
3685
+ const head = this.heads.get(workflow);
3686
+ const live = this.standingRun(workflow);
3687
+ if (live) {
3688
+ if (head?.run !== live.record.runId) {
3689
+ this.writeHead({ workflow, run: live.record.runId, since: new Date(this.now()).toISOString(), streak: 0 });
3690
+ }
3691
+ // A hand-over at every publish: the Assembly is one hash, and a handler has no stamp of its own.
3692
+ if (live.def.assembly !== def.assembly || live.def.machine !== def.machine) {
3693
+ this.handOverLive(live, "hand-over");
3694
+ } else {
3695
+ this.armAge(workflow, live);
3696
+ }
3697
+ return;
3698
+ }
3699
+ if (head?.paused) return;
3700
+ if (this.standingTimers.has(workflow)) return; // in its back-off
3701
+ if (head) {
3702
+ if (this.unhosted.has(head.run)) return; // restore left it live to try again
3703
+ const stored = await this.store.load(head.run);
3704
+ const blob = stored?.snapshot as RunBlob | null | undefined;
3705
+ // A live row that this process does not host is not ended: never a second run beside it.
3706
+ if (stored?.status === "live") return;
3707
+ if (stored && blob) {
3708
+ if (stored.status === "cancelled") {
3709
+ this.writeHead({ ...head, paused: "cancel" });
3710
+ return;
3711
+ }
3712
+ this.handOverFrom(workflow, blob.snapshot, "hand-over", { streak: head.streak });
3713
+ return;
3714
+ }
3715
+ }
3716
+ this.startStanding(workflow, why, {});
3717
+ }
3718
+
3719
+ /** Start a standing run with what a hand-over carries, and make it the head. */
3720
+ private startStanding(
3721
+ workflow: string,
3722
+ why: "boot" | "publish" | "hand-over" | "resume",
3723
+ opts: { context?: unknown; dropHeld?: boolean; resumedBy?: CallerRecord; streak?: number },
3724
+ ): { runId: string; instanceId: string } {
3725
+ this.clearStandingTimer(workflow);
3726
+ const carry = standingCarryOf(opts.context, { dropHeld: opts.dropHeld === true });
3727
+ const ids = this.startNow(workflow, { [HAND_OVER]: carry }, undefined, { kind: "jr2", why }, opts.resumedBy);
3728
+ this.writeHead({
3729
+ workflow,
3730
+ run: ids.runId,
3731
+ since: new Date(this.now()).toISOString(),
3732
+ streak: opts.streak ?? 0,
3733
+ });
3734
+ const run = this.runs.get(ids.runId);
3735
+ if (run) this.armAge(workflow, run);
3736
+ return ids;
3737
+ }
3738
+
3739
+ /**
3740
+ * Hand a live standing run over (ADR-0017): start the new run with its held items and active keys,
3741
+ * and end it `done` with `handedOverTo`, in one synchronous step, so no delivery and no settle
3742
+ * falls between the two runs.
3743
+ */
3744
+ private handOverLive(run: LiveRun, why: "hand-over"): void {
3745
+ const workflow = run.record.workflow;
3746
+ const ids = this.startStanding(workflow, why, { context: run.actor.getSnapshot().context });
3747
+ run.handedOverTo = ids.runId;
3748
+ this.end(run, "done");
3749
+ }
3750
+
3751
+ /** Hand over from a run that is no longer live, by its saved context: `error`, `drifted`, `lost`,
3752
+ * or a paused run that a Caller resumes. */
3753
+ private handOverFrom(
3754
+ workflow: string,
3755
+ snapshot: unknown,
3756
+ why: "hand-over" | "resume",
3757
+ opts: { dropHeld?: boolean; resumedBy?: CallerRecord; streak?: number },
3758
+ ): { runId: string; instanceId: string } {
3759
+ const context = (snapshot as { context?: unknown } | null | undefined)?.context;
3760
+ return this.startStanding(workflow, why, { ...opts, context });
3761
+ }
3762
+
3763
+ /**
3764
+ * What the end of a standing run means (ADR-0017), from `persist`: a cancel pauses the Workflow;
3765
+ * an `error` or a `failed` (ADR-0073) hands it over after the back-off, and so does a run that
3766
+ * took its bound with no run started. One rule for each of these ends: a run in which an item that
3767
+ * it took itself (not a carried key) started a run starts the count again, and any other run
3768
+ * counts. After 3 hand-overs from counted runs in a row, the next counted run pauses it. A
3769
+ * hand-over's own end, and a run that is not the head, mean nothing more.
3770
+ */
3771
+ private standingEnded(run: LiveRun, status: EndedStatus): void {
3772
+ const workflow = run.record.workflow;
3773
+ const head = this.heads.get(workflow);
3774
+ if (head?.run !== run.record.runId || run.handedOverTo !== undefined) return;
3775
+ this.clearStandingTimer(workflow);
3776
+ if (status === "cancelled") {
3777
+ this.writeHead({ ...head, paused: "cancel" });
3778
+ return;
3779
+ }
3780
+ const atBound = run.boundWithNoRun === true;
3781
+ if (!atBound && status !== "error" && status !== "failed") return;
3782
+ // One rule for each end that counts: only a run in which an item that it took itself (not a
3783
+ // carried key) started a run starts the count again. Any other run is part of a poison loop.
3784
+ const streak = atBound || !tookOf(run).started ? head.streak + 1 : 0;
3785
+ if (streak > STANDING_ERRORS_PAUSE) {
3786
+ this.writeHead({ ...head, streak, paused: atBound ? "bound" : "errors" });
3787
+ const last = atBound ? `took its bound of ${this.standingOpts.dispatches} items` : `ended ${status}`;
3788
+ console.error(
3789
+ `standing workflow "${workflow}": paused — in ${streak} runs in a row, no item that the run took started a ` +
3790
+ `run, after ${STANDING_ERRORS_PAUSE} hand-overs; the last one ${last}; \`jr2 run ${workflow}\` resumes it (ADR-0017)`,
3791
+ );
3792
+ return;
3793
+ }
3794
+ this.writeHead({ ...head, streak });
3795
+ const context = run.actor.getSnapshot().context;
3796
+ this.setStandingTimer(workflow, this.standingOpts.backoffMs(Math.max(1, streak)), () => {
3797
+ if (this.heads.get(workflow)?.run !== run.record.runId || this.hostedOf(workflow).length > 0) return;
3798
+ if (!this.isStanding(workflow)) return;
3799
+ try {
3800
+ this.startStanding(workflow, "hand-over", { context, streak });
3801
+ } catch (err) {
3802
+ console.error(
3803
+ `standing workflow "${workflow}": the hand-over after ${atBound ? "the bound with no run started" : status} failed: ` +
3804
+ (err instanceof Error ? err.message : String(err)),
3805
+ );
3806
+ }
3807
+ });
3808
+ }
3809
+
3810
+ /**
3811
+ * A live standing run that took its bound of items, each one a dispatch, is handed over after the
3812
+ * save that counted the last one (ADR-0017). When none of the items it took itself started a run
3813
+ * (a carried key's run does not count: it was started before), the
3814
+ * run ends `done` and the hand-over waits out the back-off, as after `error`: an item that ends
3815
+ * with no run is known only in its run's life, so a backlog of such items fills the bound again
3816
+ * in each new run, and a hand-over at once would loop with no pause and no end. While no item has
3817
+ * an answer yet (each one in flight), jr2 waits: the save of the first answer asks again.
3818
+ */
3819
+ private checkBound(run: LiveRun): void {
3820
+ const workflow = run.record.workflow;
3821
+ if (this.heads.get(workflow)?.run !== run.record.runId) return;
3822
+ // The items it took, not the keys a hand-over carried: each carried key is sent again, so a
3823
+ // count of its ledger's rows would hand a run with `cap` at the bound over again at once.
3824
+ if (standingTaken(run.actor.getSnapshot().context) < this.standingOpts.dispatches) return;
3825
+ const took = tookOf(run);
3826
+ if (!took.started && took.answered === 0) return;
3827
+ queueMicrotask(() => {
3828
+ if (this.runs.get(run.record.runId) !== run || this.heads.get(workflow)?.run !== run.record.runId) return;
3829
+ if (!this.isStanding(workflow)) return;
3830
+ if (tookOf(run).started) {
3831
+ this.handOverLive(run, "hand-over");
3832
+ return;
3833
+ }
3834
+ run.boundWithNoRun = true;
3835
+ this.end(run, "done");
3836
+ });
3837
+ }
3838
+
3839
+ /**
3840
+ * Resume a paused standing Workflow (ADR-0017): a hand-over from the run that the pause ended,
3841
+ * with its held items (none with `dropHeld`) and its active keys. The new run records the Caller
3842
+ * as `resumedBy`. Throws {@link StandingRefused} when the Workflow is live, or not paused.
3843
+ */
3844
+ async resumeStanding(
3845
+ workflow: string,
3846
+ by: CallerRecord,
3847
+ opts: { dropHeld?: boolean } = {},
3848
+ ): Promise<{ runId: string; instanceId: string }> {
3849
+ if (!this.isStanding(workflow)) throw new StandingRefused(`"${workflow}" is not a standing Workflow`);
3850
+ const live = this.standingRun(workflow);
3851
+ if (live) {
3852
+ throw new StandingRefused(
3853
+ `"${workflow}" is a standing Workflow, and its run ${live.record.runId} is live: jr2 keeps its run, and ` +
3854
+ "`jr2 run` only resumes a paused one (ADR-0017)",
3855
+ );
3856
+ }
3857
+ const head = this.heads.get(workflow);
3858
+ if (!head?.paused) {
3859
+ throw new StandingRefused(
3860
+ `"${workflow}" is a standing Workflow, and it is not paused: jr2 starts its next run after the back-off, and ` +
3861
+ "`jr2 run` only resumes a paused one (ADR-0017)",
3862
+ );
3863
+ }
3864
+ const blob = (await this.store.load(head.run))?.snapshot as RunBlob | null | undefined;
3865
+ // Asked again after the read: another resume may have started the run.
3866
+ if (this.standingRun(workflow) || !this.heads.get(workflow)?.paused) {
3867
+ throw new StandingRefused(`"${workflow}" was resumed a moment ago`);
3868
+ }
3869
+ return this.handOverFrom(workflow, blob?.snapshot, "resume", {
3870
+ resumedBy: by,
3871
+ dropHeld: opts.dropHeld === true,
3872
+ streak: 0,
3873
+ });
3874
+ }
3875
+
3876
+ /** Each standing Workflow of the registry, as `jr2 up` and `jr2 status` print it (ADR-0017). */
3877
+ async standingView(): Promise<StandingView[]> {
3878
+ const out: StandingView[] = [];
3879
+ for (const [workflow, def] of this.workflowDefs) {
3880
+ const info = standingPoolOf(def.machine);
3881
+ if (!info) continue;
3882
+ const head = this.heads.get(workflow);
3883
+ const live = this.standingRun(workflow);
3884
+ const base = { workflow, pool: info.id, callee: info.callee };
3885
+ if (live) {
3886
+ const held = standingCarryOf(live.actor.getSnapshot().context).held.length;
3887
+ out.push({ ...base, state: "live", run: live.record.runId, held });
3888
+ continue;
3889
+ }
3890
+ let held = 0;
3891
+ if (head) {
3892
+ const blob = (await this.store.load(head.run).catch(() => null))?.snapshot as RunBlob | null | undefined;
3893
+ held = standingCarryOf((blob?.snapshot as { context?: unknown } | undefined)?.context).held.length;
3894
+ }
3895
+ out.push({
3896
+ ...base,
3897
+ state: head?.paused ? "paused" : "waiting",
3898
+ ...(head ? { run: head.run } : {}),
3899
+ held,
3900
+ ...(head?.paused ? { why: head.paused } : {}),
3901
+ });
3902
+ }
3903
+ return out;
3904
+ }
3905
+
3906
+ /** The live run that took over from a standing run that a hand-over ended (ADR-0017), and its
3907
+ * Source's registration, or undefined when the Workflow does not stand or has no live run. */
3908
+ private standingSourceAfter(
3909
+ ended: LiveRun,
3910
+ kind: string,
3911
+ key: string,
3912
+ ): { run: LiveRun; reg: Registration } | undefined {
3913
+ const workflow = ended.record.workflow;
3914
+ if (!this.isStanding(workflow)) return undefined;
3915
+ const run = this.standingRun(workflow);
3916
+ if (!run || run === ended) return undefined;
3917
+ for (const reg of this.table.byRun(run.record.runId)) {
3918
+ if (reg.kind === "gate" && reg.source?.hears(kind, key)) return { run, reg };
3919
+ }
3920
+ return undefined;
3921
+ }
3922
+
3923
+ /** The standing Workflows that hear a Hook's kind and key and have no live run (ADR-0017): the
3924
+ * paused ones, by their head, and the ones waiting out a back-off. */
3925
+ private standingWithoutRun(
3926
+ kind: string,
3927
+ key: string,
3928
+ may: (workflow: string) => boolean,
3929
+ ): { paused: Array<{ workflow: string; run: string }>; waiting: string[] } {
3930
+ const paused: Array<{ workflow: string; run: string }> = [];
3931
+ const waiting: string[] = [];
3932
+ for (const [workflow, def] of this.workflowDefs) {
3933
+ const info = standingPoolOf(def.machine);
3934
+ if (!info || !info.hears(kind, key) || !may(workflow) || this.standingRun(workflow)) continue;
3935
+ const head = this.heads.get(workflow);
3936
+ if (head?.paused) paused.push({ workflow, run: head.run });
3937
+ else waiting.push(workflow);
3938
+ }
3939
+ return { paused, waiting };
3940
+ }
3941
+
2693
3942
  // --- internals ---
2694
3943
 
2695
3944
  /** Put one event on the run's feed: buffer it for ADR-0023's backfill, then fan it out. The
@@ -2746,13 +3995,17 @@ export class RunHost {
2746
3995
  const snap = run.actor.getSnapshot();
2747
3996
  return {
2748
3997
  ...run.record,
2749
- status: snap.status,
3998
+ status: shownOfMachine(snap.status, run.ending),
2750
3999
  value: snap.value,
2751
4000
  context: snap.context,
2752
4001
  children: runChildren(snap),
2753
4002
  waiting: runWaiting(snap, run.actor.logic),
2754
4003
  held: heldOf(run.holds.values()),
2755
4004
  fault: run.fault,
4005
+ ...(run.cancelledBy ? { cancelledBy: run.cancelledBy } : {}),
4006
+ ...(run.handedOverTo ? { handedOverTo: run.handedOverTo } : {}),
4007
+ ...(run.answers.length ? { answers: [...run.answers] } : {}),
4008
+ ...steeringOf(run.notices.steeringRecord()),
2756
4009
  ...(run.def.assembly !== undefined ? { assembly: run.def.assembly } : {}),
2757
4010
  ...boundFields(snap),
2758
4011
  // A Machine that reached its final state has its output (ADR-0071); an active one has none.
@@ -2762,6 +4015,7 @@ export class RunHost {
2762
4015
  ? { output: snap.output }
2763
4016
  : {}),
2764
4017
  ...dispatchedField(run.effects),
4018
+ ...worktreesField(run.effects),
2765
4019
  };
2766
4020
  }
2767
4021
 
@@ -2789,7 +4043,7 @@ export class RunHost {
2789
4043
  noticeState?: NoticeLedgerState,
2790
4044
  holdState: Hold[] = [],
2791
4045
  effects: Record<string, EffectRow> = {},
2792
- startedBy: Record<string, StartedBy> = {},
4046
+ childAgents: Record<string, ChildAgent> = {},
2793
4047
  ): AnyActor {
2794
4048
  const notices = new NoticeLedger(noticeState, this.newId);
2795
4049
  const holds = new Map(holdState.map((hold) => [hold.instanceId, hold]));
@@ -2811,10 +4065,12 @@ export class RunHost {
2811
4065
  workflow: record.workflow,
2812
4066
  ...(this.orchestratorName !== undefined ? { orchestratorName: this.orchestratorName } : {}),
2813
4067
  depth: record.dispatchedBy?.depth ?? 0,
4068
+ firstCaller: () => this.firstCaller(record.runId),
2814
4069
  // A `dispatch` with no target (ADR-0071): the run host itself, with no wire and no token.
2815
4070
  dispatch: {
2816
4071
  start: (workflow, input, by) => this.dispatchStart(workflow, input, by),
2817
4072
  settled: (runId, signal) => this.settledOf(runId, signal),
4073
+ lookup: (workflow, key) => this.dispatched(workflow, key),
2818
4074
  },
2819
4075
  ...(def.assembly !== undefined ? { assembly: def.assembly } : {}),
2820
4076
  table: this.table,
@@ -2830,10 +4086,20 @@ export class RunHost {
2830
4086
  // moment the Harness admits it, and the ledger hits the store in the same RunBlob save. An
2831
4087
  // admission arriving around stop/untrack still lands in `agents` but skips the save,
2832
4088
  // exactly like the persist scheduler's tracked-run guard.
2833
- recordAdmission: (instanceId, admission, delivered = []) => {
4089
+ recordAdmission: (instanceId, admission, delivered = [], taker, member) => {
2834
4090
  agents[instanceId] = admission;
2835
- // The notices this admission carried are delivered by THIS save (ADR-0062).
2836
- notices.delivered(delivered);
4091
+ // The notices this admission carried are delivered by THIS save, in the conversation it
4092
+ // rode (ADR-0062): a Workspace notice stays pending for the rest of its audience. And the
4093
+ // conversation is a member of its Workspace's audiences from now on, in the same save.
4094
+ notices.delivered(delivered, taker);
4095
+ if (member !== undefined && taker !== undefined) {
4096
+ notices.admitted(member.workspace, {
4097
+ taker,
4098
+ slot: member.slot,
4099
+ continued: member.continued,
4100
+ since: new Date().toISOString(),
4101
+ });
4102
+ }
2837
4103
  const run = this.runs.get(record.runId);
2838
4104
  if (run && run.agents === agents) this.persist(run);
2839
4105
  },
@@ -2845,6 +4111,9 @@ export class RunHost {
2845
4111
  // invoke would otherwise restore into the burned conversation.
2846
4112
  epochOf: (conversation) => epochs[conversation] ?? 0,
2847
4113
  bumpEpoch: (conversation, reason) => {
4114
+ // The burned conversation can take no Turn again: it leaves every audience (ADR-0062).
4115
+ const burned = epochs[conversation] ? `${conversation}/${epochs[conversation]}` : conversation;
4116
+ notices.forget(burned);
2848
4117
  epochs[conversation] = (epochs[conversation] ?? 0) + 1;
2849
4118
  // The next `continue` lands on a virgin conversation and is told so, with the fault's own
2850
4119
  // words (ADR-0062) — raised in the SAME save as the bump, so no restart can split them.
@@ -2854,18 +4123,60 @@ export class RunHost {
2854
4123
  },
2855
4124
  // The notices' other halves (ADR-0062). A raise is saved at the write, like the ledgers; a
2856
4125
  // take is a reservation in memory, delivered by `recordAdmission` or released on failure.
2857
- raiseNotice: (notice, to, source, older) => {
2858
- if (!notices.raise(notice, to, source, older)) return;
4126
+ raiseNotice: (notice, to, source, older, raiseOpts) => {
4127
+ if (!notices.raise(notice, to, source, older, raiseOpts)) return;
2859
4128
  const run = this.runs.get(record.runId);
2860
4129
  if (run && run.notices === notices) this.persist(run);
2861
4130
  },
2862
- restoreNotice: (notice, to, source) => {
2863
- if (!notices.restore(notice, to, source)) return;
4131
+ restoreNotice: (notice, to, source, taker) => {
4132
+ if (!notices.restore(notice, to, source, taker)) return;
2864
4133
  const run = this.runs.get(record.runId);
2865
4134
  if (run && run.notices === notices) this.persist(run);
2866
4135
  },
4136
+ // The Machine's `steer` action (ADR-0002): saved with the transition that runs it.
4137
+ steer: (machine, agentName, text, from) => {
4138
+ const actors = ((machine as { logic?: AnyStateMachine }).logic?.implementations.actors ?? {}) as Record<
4139
+ string,
4140
+ unknown
4141
+ >;
4142
+ if (!isAgent(actors[agentName])) {
4143
+ throw new Error(
4144
+ `steer("${agentName}", …): this Machine carries no Agent "${agentName}" — a Steering message goes to ` +
4145
+ "an Agent slot of the Machine whose transition sends it (ADR-0002)",
4146
+ );
4147
+ }
4148
+ notices.steer(
4149
+ {
4150
+ kind: "steering",
4151
+ scope: "slot",
4152
+ text,
4153
+ author: { kind: "machine", ...(from !== undefined ? { from: { party: from.party, id: from.id } } : {}) },
4154
+ at: new Date(this.now()).toISOString(),
4155
+ },
4156
+ agentSlot(machine, agentName),
4157
+ );
4158
+ schedule();
4159
+ },
2867
4160
  takeNotices: (scope) => notices.take(scope),
2868
- releaseNotices: (ids) => notices.release(ids),
4161
+ releaseNotices: (ids, taker) => notices.release(ids, taker),
4162
+ peekNotices: (scope) => notices.peek(scope),
4163
+ acknowledgeNotices: (ids, taker) => {
4164
+ const heard = notices.acknowledge(ids, taker);
4165
+ const run = this.runs.get(record.runId);
4166
+ if (heard > 0 && run && run.notices === notices) this.persist(run);
4167
+ return heard;
4168
+ },
4169
+ noticesTold: (told) => {
4170
+ let changed = false;
4171
+ for (const { source, takers } of told) changed = notices.told(source, takers) || changed;
4172
+ const run = this.runs.get(record.runId);
4173
+ if (changed && run && run.notices === notices) this.persist(run);
4174
+ },
4175
+ noticeTurnEnded: (workspace, takers) => {
4176
+ if (!notices.ended(workspace, takers)) return;
4177
+ const run = this.runs.get(record.runId);
4178
+ if (run && run.notices === notices) this.persist(run);
4179
+ },
2869
4180
  // Absorbed-retry attempts go straight to the run's observers (SSE/CLI watch) — they are
2870
4181
  // feed events, not machine events (ADR-0016: the workflow sees only the terminal fault).
2871
4182
  telemetry: (event) => {
@@ -2909,12 +4220,12 @@ export class RunHost {
2909
4220
  // Agent whose state the action is written below — the one in its own send first, else the one
2910
4221
  // outstanding — and the value that replaces its default answer.
2911
4222
  chainOf: (machine, seat, event) => {
2912
- const pick = this.answeringIn(machine, seat, record.runId, holds, startedBy, event);
4223
+ const pick = this.answeringIn(machine, seat, record.runId, holds, childAgents, event);
2913
4224
  if (pick === undefined) return undefined;
2914
4225
  return "reg" in pick ? [...pick.chain] : [...(pick.chain ?? [])];
2915
4226
  },
2916
4227
  setAnswer: (machine, seat, value, event) => {
2917
- const pick = this.answeringIn(machine, seat, record.runId, holds, startedBy, event);
4228
+ const pick = this.answeringIn(machine, seat, record.runId, holds, childAgents, event);
2918
4229
  if (pick === undefined) return;
2919
4230
  // Not saved here: the action runs inside a transition, and the persist that follows it carries
2920
4231
  // the value — a save now would read the Machine before its route, and answer too early.
@@ -3003,8 +4314,8 @@ export class RunHost {
3003
4314
  const agent = sending.reg.sender?.id;
3004
4315
  const invoke = sending.reg.invokeId;
3005
4316
  const key = childKey(child._parent, child.id);
3006
- if (agent !== undefined) startedBy[key] = invoke !== undefined ? { agent, invoke } : { agent };
3007
- else delete startedBy[key];
4317
+ if (agent !== undefined) childAgents[key] = invoke !== undefined ? { agent, invoke } : { agent };
4318
+ else delete childAgents[key];
3008
4319
  }
3009
4320
  }
3010
4321
  // …and so are the children its later work starts: a child started while the Machine takes an
@@ -3020,12 +4331,17 @@ export class RunHost {
3020
4331
  const after = childRefsOf(snapshot);
3021
4332
  childrenAfter.set(taker, after);
3022
4333
  const started = [...after].filter((c) => !before.has(c)).map((c) => c.id);
4334
+ // A Machine instance that left the tree ended (ADR-0002): the Steering messages held for
4335
+ // its slots, and for the slots of the Machines below it, retire "not delivered".
4336
+ for (const gone of before) {
4337
+ if (!after.has(gone) && isMachineActor(gone) && notices.retire(slotPath(gone))) schedule();
4338
+ }
3023
4339
  if (started.length > 0 && !this.sending) {
3024
- const agent = agentOfEvent(ev.event as AnyEventObject, startedBy, taker);
4340
+ const agent = agentOfEvent(ev.event as AnyEventObject, childAgents, taker);
3025
4341
  for (const id of started) {
3026
4342
  const owner = agent ?? this.outstandingAbove(taker, snapshot as AnyMachineSnapshot, id, holds);
3027
- if (owner !== undefined) startedBy[childKey(taker, id)] = owner;
3028
- else delete startedBy[childKey(taker, id)];
4343
+ if (owner !== undefined) childAgents[childKey(taker, id)] = owner;
4344
+ else delete childAgents[childKey(taker, id)];
3029
4345
  }
3030
4346
  }
3031
4347
  }
@@ -3144,11 +4460,19 @@ export class RunHost {
3144
4460
  }
3145
4461
  if (ev.type === "@xstate.snapshot") {
3146
4462
  causes.taken(ev.actorRef as AnyActorRef, ev.event as AnyEventObject);
4463
+ // A Machine instance that reached its end — `done`, or `error` — takes no Turn again, even
4464
+ // while its parent still holds it (ADR-0002). A stop is not an end here: a host that stops
4465
+ // parks the run, and its restore goes on.
4466
+ const ended = (ev as { snapshot?: { status?: string } }).snapshot?.status;
4467
+ const ref = ev.actorRef as AnyActorRef;
4468
+ if ((ended === "done" || ended === "error") && isMachineActor(ref)) {
4469
+ notices.retire(ref._parent === undefined ? undefined : slotPath(ref));
4470
+ }
3147
4471
  schedule();
3148
4472
  }
3149
4473
  },
3150
4474
  });
3151
- live = this.track(record, actor, def, agents, epochs, notices, binding, holds, effects, startedBy);
4475
+ live = this.track(record, actor, def, agents, epochs, notices, binding, holds, effects, childAgents);
3152
4476
  return actor;
3153
4477
  }
3154
4478
 
@@ -3162,7 +4486,7 @@ export class RunHost {
3162
4486
  binding: RunBinding,
3163
4487
  holds: Map<string, Hold>,
3164
4488
  effects: Record<string, EffectRow>,
3165
- startedBy: Record<string, StartedBy>,
4489
+ childAgents: Record<string, ChildAgent>,
3166
4490
  ): LiveRun {
3167
4491
  const run: LiveRun = {
3168
4492
  record,
@@ -3173,8 +4497,10 @@ export class RunHost {
3173
4497
  notices,
3174
4498
  holds,
3175
4499
  effects,
3176
- startedBy,
4500
+ childAgents,
3177
4501
  binding,
4502
+ answers: [],
4503
+ gatesOpenedAt: new Map(),
3178
4504
  listeners: new Set(),
3179
4505
  feedSoFar: [],
3180
4506
  };
@@ -3228,18 +4554,38 @@ export class RunHost {
3228
4554
  /**
3229
4555
  * Persist the run's snapshot after a transition; drop it from the registry once final.
3230
4556
  *
3231
- * `terminal` is the run-lifecycle verdict the MACHINE cannot supply. Three callers pass it:
3232
- * `cancel()` passes "cancelled", because xstate only knows its actor was stopped (ADR-0025); the
3233
- * error observer passes "failed" for a Failure that no state caught, because xstate gives a
3234
- * Failure and a defect the same `error` status (ADR-0073); and `faultUnrouted()` passes "error"
3235
- * for a mechanism event that no transition took, because the host stops the actor and xstate
3236
- * then knows only that it was stopped (ADR-0074). It is the stored status and the one the feeds report.
3237
- */
3238
- private persist(run: LiveRun, terminal?: string): void {
4557
+ * `terminal` is the run-lifecycle verdict the MACHINE cannot supply. {@link end} passes the end
4558
+ * of a run whose actor the host stops, because xstate then knows only that it was stopped:
4559
+ * "cancelled" for a cancel (ADR-0025), "error" for a mechanism event that no transition took
4560
+ * (ADR-0074), and "done" for a standing run's hand-over (ADR-0017). The error observer passes
4561
+ * "failed" for a Failure that no state caught, because xstate gives a Failure and a defect the
4562
+ * same `error` status (ADR-0073). It is the stored status and the one the feeds report.
4563
+ */
4564
+ private persist(run: LiveRun, terminal?: EndedStatus): Promise<void> {
4565
+ // A save that names no end, of an actor that the host stops, writes nothing. When the host ends
4566
+ // the run, its own save names the end ({@link end}). When it parks the run, the last save stays,
4567
+ // `live`, for restore (ADR-0025). xstate's `stopped` is never a run status.
4568
+ const machineStatus = (run.actor.getSnapshot() as { status?: string }).status ?? "active";
4569
+ if (terminal === undefined && (run.ending !== undefined || machineStatus === "stopped")) return Promise.resolve();
3239
4570
  // The holds first: an answer read at Rest, and a notice a route out raised, ride this same save.
3240
4571
  this.settleHolds(run);
3241
4572
  const snapshot = run.actor.getPersistedSnapshot();
3242
- const machineStatus = (snapshot as { status?: string }).status ?? "active";
4573
+ // A Machine that ends on its own ends `done` or `error`.
4574
+ const status: "live" | EndedStatus =
4575
+ terminal ?? (machineStatus === "active" ? "live" : machineStatus === "done" ? "done" : "error");
4576
+ // A run that ended holds no Steering message: each one still held retires, "not delivered"
4577
+ // (ADR-0002). Before the blob is built, so this save records it.
4578
+ if (status !== "live") run.notices.retire();
4579
+ // While a Chart is bound, take the bound Machine: the terminal snapshot binds none, because
4580
+ // xstate stops the Open body first, so the terminal write keeps this one (ADR-0069). Taken
4581
+ // again only when the Charts change, or when the last doc could not draw one of them.
4582
+ const hashes = status === "live" ? boundFields(snapshot).chartHashes : undefined;
4583
+ if (
4584
+ hashes &&
4585
+ (hashes.join(":") !== run.lastBound?.hashes.join(":") || run.lastBound.doc.chartsNotDrawn !== undefined)
4586
+ ) {
4587
+ run.lastBound = { hashes, doc: this.liveMachineDoc(run) };
4588
+ }
3243
4589
  const serialized = serializeSnapshot(snapshot, {
3244
4590
  stripContext: (ctx) => ctx, // see ADR-0007: live infra lives in `.provide` closures, not context
3245
4591
  stripChildInput: (input) => input,
@@ -3247,7 +4593,14 @@ export class RunHost {
3247
4593
  const blob: RunBlob = {
3248
4594
  workflow: run.record.workflow,
3249
4595
  instanceId: run.record.instanceId,
4596
+ startedAt: run.record.startedAt,
3250
4597
  ...(run.record.dispatchedBy ? { dispatchedBy: run.record.dispatchedBy } : {}),
4598
+ ...(run.record.startedBy ? { startedBy: run.record.startedBy } : {}),
4599
+ ...(run.record.resumedBy ? { resumedBy: run.record.resumedBy } : {}),
4600
+ ...(run.cancelledBy ? { cancelledBy: run.cancelledBy } : {}),
4601
+ ...(run.handedOverTo ? { handedOverTo: run.handedOverTo } : {}),
4602
+ ...(run.answers.length ? { answers: run.answers } : {}),
4603
+ ...(run.gatesOpenedAt.size ? { gatesOpenedAt: Object.fromEntries(run.gatesOpenedAt) } : {}),
3251
4604
  // Stamped on every save, off the registered TEMPLATE (never the per-run `.provide()` result —
3252
4605
  // providers do not change shape, and `fingerprintOf` memoizes per machine object). ADR-0030.
3253
4606
  machine: fingerprintOf(run.def.machine),
@@ -3262,14 +4615,13 @@ export class RunHost {
3262
4615
  timers: pendingTimers(run.actor, this.now()),
3263
4616
  holds: [...run.holds.values()],
3264
4617
  effects: run.effects,
3265
- startedBy: liveStartedBy(run.startedBy, run.actor),
4618
+ childAgents: liveChildAgents(run.childAgents, run.actor),
3266
4619
  fault: run.fault,
3267
4620
  ...(run.failure !== undefined ? { failure: run.failure } : {}),
3268
4621
  };
3269
- const status = terminal ?? (machineStatus === "active" ? "live" : machineStatus);
3270
4622
  // A save that fails is this run's fault, never the process's: an unhandled rejection would end
3271
4623
  // the Orchestrator and every run on it. The run keeps its last saved snapshot.
3272
- this.store.save(run.record.runId, blob, status).catch((err: unknown) => {
4624
+ const saved = this.store.save(run.record.runId, blob, status).catch((err: unknown) => {
3273
4625
  console.error(
3274
4626
  `run ${run.record.runId}: its snapshot was not saved: ${err instanceof Error ? err.message : String(err)}`,
3275
4627
  );
@@ -3277,6 +4629,19 @@ export class RunHost {
3277
4629
  // The save is on disk (the store writes synchronously, ADR-0065): a settled row whose transition
3278
4630
  // it carries keeps no tail in the next one (ADR-0067).
3279
4631
  dropSavedTails(run.effects, snapshot);
4632
+ // An ended run holds no Assembly, so its row keeps the Machine it ran on (ADR-0069): the Console
4633
+ // draws an ended run on its own Machine after a publish collects that Assembly. A failure here
4634
+ // costs the drawing, never the run's end.
4635
+ if (status !== "live") {
4636
+ try {
4637
+ const doc = run.lastBound?.doc ?? this.liveMachineDoc(run);
4638
+ this.store.keepMachine(run.record.runId, keptMachineKey(doc), doc);
4639
+ } catch (err) {
4640
+ console.error(
4641
+ `run ${run.record.runId}: its Machine was not kept: ${err instanceof Error ? err.message : String(err)}`,
4642
+ );
4643
+ }
4644
+ }
3280
4645
 
3281
4646
  // Feed per-run observers (SSE/CLI watch). On the terminal transition emit the final status
3282
4647
  // BEFORE untrack drops the run from the registry, then drop the now-useless listener set.
@@ -3295,12 +4660,28 @@ export class RunHost {
3295
4660
  run.listeners.clear();
3296
4661
  // After the save and the untrack: a listener that reads the run back reads it settled.
3297
4662
  this.fireSettled(run.record.runId, runStatus);
4663
+ // A standing run's end (ADR-0017): a pause, or a hand-over after the back-off.
4664
+ this.standingEnded(run, status);
3298
4665
  } else {
3299
4666
  this.announce(run.record.workflow, { kind: "status", status: runStatus });
4667
+ this.checkBound(run);
3300
4668
  }
4669
+ return saved;
3301
4670
  }
3302
4671
  }
3303
4672
 
4673
+ /**
4674
+ * The shown status of a hosted run, off its Machine's status (ADR-0025). A Machine that ends on its
4675
+ * own is `done` or `error`. xstate's `stopped` is mechanism: the run shows the end that the host
4676
+ * gives it, and with none, it is a park, which leaves the run live.
4677
+ */
4678
+ function shownOfMachine(machine: string, ending: EndedStatus | undefined): ShownStatus {
4679
+ if (machine === "active") return LIVE_STATUS;
4680
+ if (machine === "done") return "done";
4681
+ if (machine === "stopped") return ending ?? LIVE_STATUS;
4682
+ return "error";
4683
+ }
4684
+
3304
4685
  /** A status that is no longer live (ADR-0071): the store's terminal words. A live row reads its
3305
4686
  * Machine's `active`; the store's own `live` is never a status a reader is handed, but is not
3306
4687
  * settled either. */
@@ -3331,8 +4712,20 @@ function atOrBelow(actor: AnyActorRef, ref: AnyActorRef): boolean {
3331
4712
  return false;
3332
4713
  }
3333
4714
 
3334
- /** A status's bound fields (ADR-0070): `bound`, and the hashes of the Charts bound, distinct and
3335
- * sorted. Both absent when the snapshot binds no Chart. */
4715
+ /** What the items that a standing run took itself did (ADR-0017): whether one started a run, and
4716
+ * how many ended. A live worker's run is on its ledger row (ADR-0071). */
4717
+ function tookOf(run: LiveRun): { started: boolean; answered: number } {
4718
+ return standingTook(run.actor.getSnapshot(), (effectId) => {
4719
+ const row = run.effects[effectId];
4720
+ if (row?.kind !== "dispatch") return undefined;
4721
+ const r =
4722
+ row.state === "settled"
4723
+ ? (row.output as { run?: unknown } | null | undefined)?.run
4724
+ : (row as { run?: unknown }).run;
4725
+ return typeof r === "string" && r !== "" ? r : undefined;
4726
+ });
4727
+ }
4728
+
3336
4729
  /** The runs a run's `dispatch` Effects started (ADR-0071), from its ledger: an intent row the
3337
4730
  * start's answer re-wrote with its run, a settled row whose output names one (`started`,
3338
4731
  * `settled`), and an aborted row that kept it. A row that never learned its run names none. */
@@ -3350,6 +4743,37 @@ function dispatchedField(effects: Record<string, EffectRow>): { dispatched?: Dis
3350
4743
  return out.length ? { dispatched: out } : {};
3351
4744
  }
3352
4745
 
4746
+ /** The Worktrees a run's cuts made (ADR-0075), from the settled `ready` rows of its ledger, in the
4747
+ * order of each Worktree's first cut. A cut also reads a Worktree, so one path and branch has many
4748
+ * rows and one entry. */
4749
+ function worktreesField(effects: Record<string, EffectRow>): { worktrees?: RunWorktree[] } {
4750
+ const rows = Object.values(effects).flatMap((row) =>
4751
+ row.state === "settled" && row.cut !== undefined && row.repo !== undefined
4752
+ ? [{ at: row.at, slot: row.repo, ...row.cut }]
4753
+ : [],
4754
+ );
4755
+ rows.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
4756
+ const out = new Map<string, RunWorktree>();
4757
+ for (const { slot, path, branch } of rows) {
4758
+ const key = JSON.stringify([path, branch]);
4759
+ if (!out.has(key)) out.set(key, { slot, path, branch });
4760
+ }
4761
+ return out.size ? { worktrees: [...out.values()] } : {};
4762
+ }
4763
+
4764
+ /**
4765
+ * The key an ended run's kept Machine is stored under (ADR-0069): a digest of the doc itself, with
4766
+ * the Workflow name blanked, so a Chart wrapper and its named Chart share one copy, and two Machines
4767
+ * that differ in any part the doc draws (a guard, a description, an Effect's argv) keep two.
4768
+ */
4769
+ function keptMachineKey(doc: MachineDoc): string {
4770
+ return createHash("sha256")
4771
+ .update(JSON.stringify({ ...doc, workflow: "" }))
4772
+ .digest("hex");
4773
+ }
4774
+
4775
+ /** A status's bound fields (ADR-0070): `bound`, and the hashes of the Charts bound, distinct and
4776
+ * sorted. Both absent when the snapshot binds no Chart. */
3353
4777
  function boundFields(snap: unknown): { bound?: true; chartHashes?: string[] } {
3354
4778
  const hashes = [...new Set(boundChartRoutesOf(snap).map((route) => route.chartHash))].sort();
3355
4779
  return hashes.length ? { bound: true, chartHashes: hashes } : {};