@jr2/orchestrator 0.6.0 → 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 (301) 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 +86 -9
  14. package/docs/concepts/architecture.md +41 -28
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +7 -5
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +4 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +50 -22
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +5 -4
  23. package/docs/concepts/pools-and-sources.md +187 -11
  24. package/docs/concepts/secrets-and-trust.md +73 -33
  25. package/docs/concepts/the-menu.md +11 -0
  26. package/docs/concepts/workspaces.md +6 -5
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
  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/guides/add-tools-to-the-sandbox.md +55 -11
  32. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  33. package/docs/guides/answer-a-gate.md +74 -37
  34. package/docs/guides/bind-open-parts.md +24 -8
  35. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  36. package/docs/guides/cancel-a-run.md +26 -16
  37. package/docs/guides/fix-a-failed-converge.md +18 -6
  38. package/docs/guides/give-a-caller-a-scope.md +203 -0
  39. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  41. package/docs/guides/let-an-agent-write-a-chart.md +9 -6
  42. package/docs/guides/look-inside-a-sandbox.md +101 -31
  43. package/docs/guides/open-the-console.md +63 -14
  44. package/docs/guides/read-a-conversation.md +225 -0
  45. package/docs/guides/run-a-chart-by-name.md +17 -16
  46. package/docs/guides/run-one-worker-per-item.md +15 -9
  47. package/docs/guides/set-an-agents-access-and-dials.md +2 -0
  48. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  49. package/docs/guides/start-a-run-from-a-run.md +18 -16
  50. package/docs/guides/start-a-run.md +24 -18
  51. package/docs/guides/steer-an-agent.md +332 -0
  52. package/docs/guides/use-a-hosted-provider.md +1 -1
  53. package/docs/guides/watch-a-run.md +127 -121
  54. package/docs/guides/write-a-hook-kind.md +238 -0
  55. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  56. package/docs/guides/write-a-workflow.md +17 -7
  57. package/docs/llms.txt +81 -15
  58. package/docs/reference/authoring-surface/agent.md +1 -1
  59. package/docs/reference/authoring-surface/answer.md +1 -1
  60. package/docs/reference/authoring-surface/arg.md +1 -1
  61. package/docs/reference/authoring-surface/basic.md +1 -1
  62. package/docs/reference/authoring-surface/bearer.md +1 -1
  63. package/docs/reference/authoring-surface/branchname.md +1 -1
  64. package/docs/reference/authoring-surface/carry.md +23 -0
  65. package/docs/reference/authoring-surface/chartschema.md +1 -1
  66. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  67. package/docs/reference/authoring-surface/command.md +1 -1
  68. package/docs/reference/authoring-surface/customize.md +4 -1
  69. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  70. package/docs/reference/authoring-surface/defineevent.md +1 -1
  71. package/docs/reference/authoring-surface/dispatch.md +5 -4
  72. package/docs/reference/authoring-surface/fail.md +1 -1
  73. package/docs/reference/authoring-surface/file.md +1 -1
  74. package/docs/reference/authoring-surface/frompick.md +1 -1
  75. package/docs/reference/authoring-surface/gate.md +118 -0
  76. package/docs/reference/authoring-surface/group-agents.md +15 -0
  77. package/docs/reference/authoring-surface/group-charts.md +16 -0
  78. package/docs/reference/authoring-surface/group-composition.md +25 -0
  79. package/docs/reference/authoring-surface/group-config.md +17 -0
  80. package/docs/reference/authoring-surface/group-effects.md +48 -0
  81. package/docs/reference/authoring-surface/group-failure.md +13 -0
  82. package/docs/reference/authoring-surface/group-gates.md +16 -0
  83. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  84. package/docs/reference/authoring-surface/group-steering.md +12 -0
  85. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  86. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  87. package/docs/reference/authoring-surface/http.md +1 -1
  88. package/docs/reference/authoring-surface/isfailure.md +1 -1
  89. package/docs/reference/authoring-surface/isready.md +1 -1
  90. package/docs/reference/authoring-surface/jr2setup.md +13 -3
  91. package/docs/reference/authoring-surface/mcp.md +107 -0
  92. package/docs/reference/authoring-surface/open.md +5 -3
  93. package/docs/reference/authoring-surface/openbody.md +1 -1
  94. package/docs/reference/authoring-surface/paramsof.md +1 -1
  95. package/docs/reference/authoring-surface/pool.md +77 -9
  96. package/docs/reference/authoring-surface/push.md +1 -1
  97. package/docs/reference/authoring-surface/readyof.md +1 -1
  98. package/docs/reference/authoring-surface/reread.md +19 -0
  99. package/docs/reference/authoring-surface/secret.md +1 -1
  100. package/docs/reference/authoring-surface/sentby.md +1 -1
  101. package/docs/reference/authoring-surface/skip.md +22 -0
  102. package/docs/reference/authoring-surface/source.md +56 -1
  103. package/docs/reference/authoring-surface/steer.md +93 -0
  104. package/docs/reference/authoring-surface/text.md +1 -1
  105. package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
  106. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  107. package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
  108. package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
  109. package/docs/reference/authoring-surface/type-binding.md +1 -1
  110. package/docs/reference/authoring-surface/type-caller.md +40 -0
  111. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  112. package/docs/reference/authoring-surface/type-carry.md +19 -0
  113. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  114. package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
  115. package/docs/reference/authoring-surface/type-customize.md +16 -1
  116. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  117. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  118. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  119. package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
  120. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  121. package/docs/reference/authoring-surface/type-framepath.md +1 -1
  122. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  123. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  124. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  125. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  126. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  127. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  128. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  129. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  130. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  131. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  132. package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
  133. package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
  134. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  135. package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
  136. package/docs/reference/authoring-surface/type-lends.md +5 -1
  137. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  138. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  139. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  140. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  141. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  142. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  143. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  144. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  145. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  146. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  147. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  148. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  149. package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
  150. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  151. package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
  152. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  153. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  154. package/docs/reference/authoring-surface/type-reposlot.md +1 -1
  155. package/docs/reference/authoring-surface/type-reread.md +18 -0
  156. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  157. package/docs/reference/authoring-surface/type-sender.md +26 -1
  158. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  159. package/docs/reference/authoring-surface/type-size.md +1 -1
  160. package/docs/reference/authoring-surface/type-skip.md +19 -0
  161. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  162. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  163. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  164. package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
  165. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  166. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  167. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  168. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  169. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  170. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  171. package/docs/reference/authoring-surface/type-workspaced.md +1 -1
  172. package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
  173. package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
  174. package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
  175. package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
  176. package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
  177. package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
  178. package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
  179. package/docs/reference/authoring-surface/workspace.md +1 -1
  180. package/docs/reference/authoring-surface/worktree.md +1 -1
  181. package/docs/reference/authoring-surface.md +88 -167
  182. package/docs/reference/cli.md +413 -74
  183. package/docs/reference/config.md +235 -2
  184. package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
  185. package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
  186. package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
  187. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  188. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  189. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  190. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  191. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
  192. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
  193. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  194. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
  195. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
  196. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  197. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
  198. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
  199. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  200. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
  201. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
  202. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  203. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
  204. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
  205. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  206. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  207. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
  208. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  209. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
  210. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  211. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
  212. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
  213. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  214. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  215. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  216. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
  217. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
  218. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
  219. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
  220. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
  221. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  222. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  223. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +51 -23
  224. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
  225. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
  226. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  227. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
  228. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  229. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  230. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
  231. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
  232. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  233. 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
  234. package/docs/reference/decisions.md +6 -4
  235. package/docs/reference/glossary.md +96 -42
  236. package/docs/reference/hooks.md +1077 -0
  237. package/docs/reference/http-api.md +976 -149
  238. package/docs/why.md +2 -1
  239. package/package.json +10 -3
  240. package/src/actor.ts +36 -10
  241. package/src/agent.ts +50 -0
  242. package/src/assembly-checks.ts +530 -4
  243. package/src/callers.ts +245 -0
  244. package/src/chart.ts +52 -7
  245. package/src/config.ts +550 -1
  246. package/src/conversations.ts +453 -0
  247. package/src/custodian.ts +8 -2
  248. package/src/customize.ts +143 -4
  249. package/src/dispatch-effect.ts +142 -26
  250. package/src/durability.ts +2 -0
  251. package/src/duration.ts +20 -0
  252. package/src/effect-checks.ts +2 -1
  253. package/src/effect.ts +60 -7
  254. package/src/effector-wire.ts +272 -27
  255. package/src/gate-brand.ts +16 -0
  256. package/src/gate.ts +634 -48
  257. package/src/harness-client.ts +10 -4
  258. package/src/held-secrets.ts +72 -23
  259. package/src/holds.ts +2 -2
  260. package/src/hook-kinds/discord.ts +92 -0
  261. package/src/hook-kinds/door.ts +107 -0
  262. package/src/hook-kinds/github.ts +89 -0
  263. package/src/hook-kinds/gitlab.ts +72 -0
  264. package/src/hook-kinds/hook.ts +7 -0
  265. package/src/hook-kinds/index.ts +465 -0
  266. package/src/hook-kinds/slack.ts +151 -0
  267. package/src/hook-kinds/webhook.ts +113 -0
  268. package/src/hooks.ts +506 -0
  269. package/src/http-effect.ts +76 -38
  270. package/src/http.ts +889 -94
  271. package/src/index.ts +16 -5
  272. package/src/instance.ts +196 -26
  273. package/src/internal.ts +22 -3
  274. package/src/machine-doc.ts +26 -1
  275. package/src/mcp-effect.ts +424 -0
  276. package/src/mount-digest.ts +41 -7
  277. package/src/names.ts +64 -2
  278. package/src/notices.ts +138 -10
  279. package/src/open-body.ts +25 -11
  280. package/src/open-effect.ts +17 -4
  281. package/src/open.ts +4 -2
  282. package/src/parts.ts +87 -9
  283. package/src/party.ts +2 -1
  284. package/src/pick.ts +2 -2
  285. package/src/pool.ts +1142 -163
  286. package/src/registration.ts +203 -13
  287. package/src/run-host.ts +1509 -171
  288. package/src/run-list.ts +191 -0
  289. package/src/sandbox-kube.ts +53 -5
  290. package/src/secret-placement.ts +239 -33
  291. package/src/server.ts +53 -4
  292. package/src/setup.ts +72 -32
  293. package/src/snapshot-store.ts +565 -48
  294. package/src/steer.ts +111 -0
  295. package/src/stub-harness.ts +6 -2
  296. package/src/tokens.ts +263 -22
  297. package/src/wire.ts +17 -0
  298. package/src/workspace.ts +50 -6
  299. package/src/worktree.ts +3 -3
  300. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  301. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/console/main.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // registered workflow, each expandable to its runs); the center is the selected workflow's Machine,
3
3
  // fetched from GET /workflows/:name/machine, laid out with elkjs (loaded as a UMD script ->
4
4
  // window.ELK), rendered as nested SVG (canvas.ts) and live-highlighted by the selected run; the
5
- // right drawer is Attention (the gate inbox) and Activity (the emit log).
5
+ // right drawer is Attention (the gate inbox) and Activity (the emit log); the bottom drawer is the
6
+ // selected run's kept conversations, one tab for each Agent slot, with the Steering box.
6
7
  //
7
8
  // The ADDRESS carries the selection: `/` and `/workflows/:name` serve these same bytes, the path
8
9
  // says which workflow is selected, pushState moves it on click and popstate restores it. ONE
@@ -13,11 +14,19 @@
13
14
  // makes reattach convergent with no cursor (store.ts folds every frame idempotently).
14
15
  //
15
16
  // The page as SERVED is ADR-0014's observer: open structure and observation, no credential in the
16
- // bytes. Control is EARNED per tab: the human types the Instance token into the nav (sessionStorage
17
- // — survives a reload, dies with the tab, never a cookie), and only then does the page speak on the
18
- // guarded surface: POST /workflows/:name/runs (start), GET /runs/:id (gate discovery, re-fetched on
19
- // each frame), POST /runs/:id/gates/:gate/events (delivery). Any later 401 drops the whole page
20
- // back to observer mode. That is ADR-0032 end to end — the bands themselves never moved.
17
+ // bytes. Control is EARNED per tab: the person types a Caller's token into the nav, or logs in
18
+ // against an issuer the Instance declares (OIDC code + PKCE, login.ts), and the credential lives in
19
+ // sessionStorage — survives a reload, dies with the tab, never a cookie. The page asks
20
+ // `GET /whoami` what the credential may do, and renders only the controls its scopes open
21
+ // (ADR-0078): start (POST /workflows/:name/runs), the Gate cards (GET /gates read again on each
22
+ // frame, POST /runs/:id/gates/:gate/events), cancel (POST /runs/:id/events), the ended runs in the
23
+ // rail (GET /runs?status=ended), the conversations (GET /runs/:id/conversations and its routes,
24
+ // ADR-0023), the Steering box (GET /runs/:id/agents, POST /runs/:id/steering). Any later 401 drops
25
+ // the whole page back to observer mode. That is ADR-0032 end to end — the bands themselves never
26
+ // moved.
27
+ //
28
+ // The guarded streams (a conversation's `/events`) are read with `fetch`, never an EventSource: an
29
+ // EventSource cannot send the credential, and the credential never rides a URL or a cookie.
21
30
  //
22
31
  // STORE-FIRST, VDOM-PAINTED (ADR-0034): everything the page believes lives in the pure reducer
23
32
  // (store.ts) and arrives there as frames — wire frames and page facts alike. `dispatch` is the one
@@ -28,26 +37,49 @@
28
37
  //
29
38
  // A run that binds a Chart (ADR-0070) draws ITS Machine, not the Workflow's: the definition's doc
30
39
  // has the Open body as one opaque child, and a Chart one cannot watch is a Chart one cannot trust.
31
- // So the doc the center pane draws is chosen per selection (`machineDocSource`): the Workflow's
32
- // Machine, or, when the selected run's observation says `bound`, the run's own from
40
+ // A run on an older Assembly (ADR-0069) draws its own Machine too: the current Machine's states are
41
+ // not the ones it moves through. So the doc the center pane draws is chosen per selection
42
+ // (`selectedDocSource`): the Workflow's Machine, or the run's own from
33
43
  // `GET /workflows/:name/runs/:runId/machine`, cached per run id. The live child still joins its
34
- // subgraph by `src`, which the per-run doc keeps as the body's slot key.
44
+ // subgraph by `src`, which the per-run doc keeps as the body's slot key. A `drifted` run has no
45
+ // Machine to draw: the pane says why in place of the graph.
35
46
 
36
47
  import { h, render } from "preact";
37
48
  import {
49
+ activeSlot,
38
50
  applyFrame,
39
51
  emptyStore,
40
52
  failureReason,
41
53
  machineDocSource,
42
- selectedRun,
54
+ may,
55
+ selectedDocSource,
56
+ selectedRunDrift,
57
+ slotConversations,
58
+ type CallerView,
43
59
  type Frame,
44
- type GateCard,
60
+ type InboxGate,
45
61
  type ObservedRun,
62
+ type RunSummaryView,
46
63
  type Store,
47
64
  type HeldLine,
65
+ type StarterView,
48
66
  type WaitingLine,
49
67
  type WorkflowListing,
50
68
  } from "./store.ts";
69
+ import { readSse, type AgentLine, type ConversationEntry, type ConversationRow } from "./conversation.ts";
70
+ import {
71
+ authorizeUrl,
72
+ credentialOf,
73
+ loginReturn,
74
+ loginScopes,
75
+ pkceChallenge,
76
+ randomValue,
77
+ redirectUri,
78
+ tokenRequestBody,
79
+ type ConsoleConfig,
80
+ type LoginIssuer,
81
+ type PendingLogin,
82
+ } from "./login.ts";
51
83
  import { zoomFit, zoomIn, zoomOut, zoomReset, type MachineDoc } from "./canvas.ts";
52
84
  import { App, type AppApi, type MachineView } from "./components/app.ts";
53
85
 
@@ -56,28 +88,39 @@ let store: Store = emptyStore();
56
88
  let view: MachineView = { doc: null, placeholder: null, note: null };
57
89
  /** The nav's zoom readout — canvas.ts reports through `api.onScale`; a render carries it. */
58
90
  let zoomPct = 100;
91
+ /** The issuers the nav offers a login against (`GET /console/config`), and why the last login
92
+ * failed, if it did. Working state of the bootstrap, like the zoom: what the page KNOWS of a run
93
+ * stays in the store. */
94
+ let logins: LoginIssuer[] = [];
95
+ let loginNote: string | null = null;
59
96
 
60
97
  const root = document.getElementById("root")!;
61
98
 
62
99
  /** One render from the top, whatever moved — store or working state; the vdom diffs the rest. */
63
100
  function rerender(): void {
64
- render(h(App, { store, view, zoomPct, initialToken, schemas: inputSchemas, api }), root);
101
+ render(h(App, { store, view, zoomPct, initialToken, logins, loginNote, schemas: inputSchemas, api }), root);
65
102
  }
66
103
 
67
104
  /** Fold one frame and re-render. Every state change in this file goes through here — a page that
68
105
  * writes anywhere else holds a private belief, which is the bug class store.ts exists to end. */
69
106
  function dispatch(frame: Frame): void {
107
+ const before = store;
70
108
  store = applyFrame(store, frame);
71
109
  rerender();
72
110
  // A frame can change which doc the pane draws: a new selection, a run selected, a run that now
73
111
  // binds a Chart. The check is a key compare; only a change fetches.
74
112
  void syncMachine();
113
+ // A frame can change the selected run, or the slot the bottom drawer shows: read what it needs.
114
+ syncTalk();
115
+ // A new selection: read its details (waits, picks, failure, starter) and, for an ended run opened
116
+ // from the rail, its last status, so the graph lights where it ended.
117
+ if (store.selectedRunId !== before.selectedRunId) readSelected();
75
118
  }
76
119
 
77
- // ---- The token (ADR-0032) -----------------------------------------------------------------------
120
+ // ---- The credential (ADR-0032) ------------------------------------------------------------------
78
121
  // The VALUE lives here (sessionStorage) and rides only in Authorization headers; the store holds
79
- // its STATE. Entry is validated with the cheapest guarded read (`GET /runs`), whose response also
80
- // happens to be the full run list — which seeds the gate inbox without waiting for a frame.
122
+ // its STATE and the Caller it is. Entry is validated with `GET /whoami`, which answers any Caller
123
+ // and says what it may do (ADR-0078) — a typed token and a login's JWT alike.
81
124
 
82
125
  const TOKEN_KEY = "jr2.console.token";
83
126
  const token = (): string => sessionStorage.getItem(TOKEN_KEY) ?? "";
@@ -110,21 +153,27 @@ let tokenSerial = 0;
110
153
  async function validateToken(): Promise<void> {
111
154
  const serial = ++tokenSerial;
112
155
  dispatch({ kind: "token", state: "checking" });
113
- let res: Response | null;
156
+ let caller: CallerView | null = null;
114
157
  try {
115
- res = await fetch("/runs", { headers: { authorization: `Bearer ${token()}` } });
158
+ const res = await fetch("/whoami", { headers: { authorization: `Bearer ${token()}` } });
159
+ if (res.ok) caller = (await res.json()) as CallerView;
116
160
  } catch {
117
- res = null; // network trouble reads as invalid; re-entering the token retries
161
+ // network trouble reads as invalid; re-entering the token retries
118
162
  }
119
163
  if (serial !== tokenSerial) return; // a newer entry owns the badge — this answer is nobody's
120
- if (!res?.ok) return dispatch({ kind: "token", state: "invalid" });
164
+ if (!caller) return dispatch({ kind: "token", state: "invalid" });
165
+ dispatch({ kind: "caller", caller });
121
166
  dispatch({ kind: "token", state: "live" });
122
- const runs = (await res.json()) as Array<{ runId: string; workflow: string }>;
123
- if (serial !== tokenSerial) return;
124
- for (const r of runs) void refreshRun(r.runId, r.workflow);
167
+ // Seed the inbox, the ended runs, the conversations and the run details from what the page
168
+ // already holds: the frames that carried them came before the credential, so they read nothing.
169
+ refreshInbox();
170
+ if (store.workflow) void refreshHistory(store.workflow);
171
+ if (store.selectedRunId) void refreshTalk(store.selectedRunId);
172
+ readSelected();
125
173
  }
126
174
 
127
175
  function setToken(value: string): void {
176
+ loginNote = null;
128
177
  if (!value) {
129
178
  tokenSerial++; // strand any in-flight validation of the token this just cleared
130
179
  sessionStorage.removeItem(TOKEN_KEY);
@@ -135,45 +184,477 @@ function setToken(value: string): void {
135
184
  void validateToken();
136
185
  }
137
186
 
138
- // ---- Gate discovery and waits (frame-triggered — ADR-0032, ADR-0064) ----------------------------
187
+ // ---- The login (ADR-0032, ADR-0078) -------------------------------------------------------------
188
+ // OIDC, the code flow with PKCE, as a public client: the page goes to the issuer, the issuer comes
189
+ // back to `/` with a code, and the page exchanges the code for an ID token, which it then keeps and
190
+ // sends as it keeps and sends a typed token. What it holds between the two halves (the verifier,
191
+ // the state, where to go back to) is in sessionStorage too, and goes when the return reads it.
192
+
193
+ const LOGIN_KEY = "jr2.console.login";
194
+
195
+ /** The issuers that offer a login, off the open route. A failure offers none: a typed token still
196
+ * works. */
197
+ async function loadLogins(): Promise<void> {
198
+ try {
199
+ const res = await fetch("/console/config");
200
+ if (res.ok) logins = ((await res.json()) as ConsoleConfig).login ?? [];
201
+ } catch {
202
+ logins = [];
203
+ }
204
+ }
205
+
206
+ /** Go to the issuer. Its discovery document names where; the page asks it for the scopes it lists. */
207
+ async function login(issuer: LoginIssuer): Promise<void> {
208
+ loginNote = null;
209
+ try {
210
+ const res = await fetch(`${issuer.issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`);
211
+ if (!res.ok) throw new Error(`the issuer's discovery document answered ${res.status}`);
212
+ const discovery = (await res.json()) as {
213
+ authorization_endpoint?: string;
214
+ token_endpoint?: string;
215
+ scopes_supported?: string[];
216
+ };
217
+ if (!discovery.authorization_endpoint || !discovery.token_endpoint) {
218
+ throw new Error("the issuer's discovery document names no authorization or token endpoint");
219
+ }
220
+ if (!globalThis.crypto?.subtle) throw new Error("a login needs the Console on https or on localhost");
221
+ const pending: PendingLogin = {
222
+ issuer: issuer.issuer,
223
+ clientId: issuer.clientId,
224
+ state: randomValue(),
225
+ verifier: randomValue(),
226
+ tokenEndpoint: discovery.token_endpoint,
227
+ returnTo: location.pathname,
228
+ };
229
+ sessionStorage.setItem(LOGIN_KEY, JSON.stringify(pending));
230
+ location.assign(
231
+ authorizeUrl({
232
+ endpoint: discovery.authorization_endpoint,
233
+ clientId: issuer.clientId,
234
+ redirectUri: redirectUri(location.origin),
235
+ scope: loginScopes(discovery.scopes_supported),
236
+ state: pending.state,
237
+ challenge: await pkceChallenge(pending.verifier),
238
+ }),
239
+ );
240
+ } catch (err) {
241
+ loginNote = `login failed: ${err instanceof Error ? err.message : String(err)}`;
242
+ rerender();
243
+ }
244
+ }
245
+
246
+ /** The return from the issuer, at boot: exchange the code, keep the ID token, and put the address
247
+ * back where the person logged in from. A return that does not match the pending login (another
248
+ * tab's, or a forged one) is refused, and nothing is kept. */
249
+ async function finishLogin(): Promise<void> {
250
+ const back = loginReturn(location.search);
251
+ if (!back) return;
252
+ const raw = sessionStorage.getItem(LOGIN_KEY);
253
+ sessionStorage.removeItem(LOGIN_KEY);
254
+ let pending: PendingLogin | null = null;
255
+ try {
256
+ pending = raw ? (JSON.parse(raw) as PendingLogin) : null;
257
+ } catch {
258
+ pending = null;
259
+ }
260
+ history.replaceState({}, "", pending?.returnTo ?? "/");
261
+ if (!pending || back.state !== pending.state) {
262
+ loginNote = "login failed: the return does not match a login that this tab started";
263
+ return;
264
+ }
265
+ if ("error" in back) {
266
+ loginNote = `login failed: ${back.error}`;
267
+ return;
268
+ }
269
+ try {
270
+ const res = await fetch(pending.tokenEndpoint, {
271
+ method: "POST",
272
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
273
+ body: tokenRequestBody({
274
+ code: back.code,
275
+ redirectUri: redirectUri(location.origin),
276
+ clientId: pending.clientId,
277
+ verifier: pending.verifier,
278
+ }),
279
+ });
280
+ const jwt = res.ok ? credentialOf(await res.json()) : null;
281
+ if (!jwt) throw new Error(`the issuer's token endpoint answered ${res.status} with no ID token`);
282
+ sessionStorage.setItem(TOKEN_KEY, jwt);
283
+ } catch (err) {
284
+ loginNote = `login failed: ${err instanceof Error ? err.message : String(err)}`;
285
+ }
286
+ }
287
+
288
+ // ---- The Gate inbox (frame-triggered — ADR-0011, ADR-0032) --------------------------------------
289
+
290
+ /** One read of `key` at a time, and one more after it when something asked meanwhile, with the
291
+ * arguments of the last ask: a burst of frames that each ask for one run's reads makes two reads,
292
+ * not one for each frame. Fetch bookkeeping, not belief. */
293
+ function coalesced<A extends unknown[]>(
294
+ read: (key: string, ...args: A) => Promise<void>,
295
+ ): (key: string, ...args: A) => void {
296
+ const asked = new Map<string, A | null>(); // a key in flight: the arguments of the ask after it
297
+ const ask = (key: string, ...args: A): void => {
298
+ if (asked.has(key)) {
299
+ asked.set(key, args);
300
+ return;
301
+ }
302
+ asked.set(key, null);
303
+ read(key, ...args)
304
+ .catch(() => {
305
+ // transient — the next frame reads again
306
+ })
307
+ .finally(() => {
308
+ const again = asked.get(key);
309
+ asked.delete(key);
310
+ if (again) ask(key, ...again);
311
+ });
312
+ };
313
+ return ask;
314
+ }
315
+
316
+ /** The inbox read in flight, and whether a frame asked for another while it was out. Fetch
317
+ * bookkeeping like `docs`, not belief: a feed frame and a fleet snapshot each ask, so one read at a
318
+ * time, and one more after it when something asked meanwhile, keeps a burst of frames to two reads.
319
+ * The read that STARTED last speaks: an answer that a newer read superseded is dropped. */
320
+ let inboxReading = false;
321
+ let inboxAgain = false;
322
+
323
+ /** Read `GET /gates`, every open Gate that the Caller may answer, and fold the WHOLE answer in.
324
+ * Called on every frame that touches a run — that is the synchronization: a Gate opens with a
325
+ * state entry and closes with its exit, and every entry/exit lands a frame. A delivery's success
326
+ * is the next read coming back without the card; nothing here concludes anything on its own. */
327
+ function refreshInbox(): void {
328
+ // Observer mode, or a Caller with no `answer`: the server would refuse the read (ADR-0032).
329
+ if (!may(store, "answer")) return;
330
+ if (inboxReading) {
331
+ inboxAgain = true;
332
+ return;
333
+ }
334
+ inboxReading = true;
335
+ // The credential this read speaks for: an answer that lands after another Caller went live is
336
+ // the old Caller's, and is dropped (the read that follows speaks for the new one).
337
+ const serial = tokenSerial;
338
+ void (async () => {
339
+ try {
340
+ const res = await gfetch("/gates");
341
+ if (serial !== tokenSerial) inboxAgain = true;
342
+ else if (res?.ok) dispatch({ kind: "inbox", gates: ((await res.json()) as { gates: InboxGate[] }).gates });
343
+ } catch {
344
+ // transient — the next frame reads again
345
+ } finally {
346
+ inboxReading = false;
347
+ if (inboxAgain) {
348
+ inboxAgain = false;
349
+ refreshInbox();
350
+ }
351
+ }
352
+ })();
353
+ }
354
+
355
+ // ---- Run details: waits, picks, failure, starter (frame-triggered — ADR-0064, ADR-0024) ---------
139
356
 
140
357
  /** Per-run re-fetch serials — fetch bookkeeping like `docs`, not belief. The browser runs these
141
- * requests on parallel connections, so answers can land out of ORDER: a re-fetch triggered by the
142
- * delivery's own transition frame (gates now empty) can resolve before the one an earlier frame
143
- * launched (gate still open), and folding the straggler would resurrect a card the server already
144
- * emptied — pinning the ⚑ and inviting a second delivery until the run's next frame, which a run
145
- * parked in a long agent step may not land for minutes. Only the latest-STARTED re-fetch may
358
+ * requests on parallel connections, so answers can land out of ORDER, and an older answer folded
359
+ * last would bring back a wait the server already cleared. Only the latest-STARTED re-fetch may
146
360
  * speak for a run; superseded answers are dropped before they reach the store. */
147
361
  const runFetchSerial = new Map<string, number>();
148
362
 
149
- /** Re-read one run's open gates off the guarded surface and fold the WHOLE answer in. Called on
150
- * every frame that touches the run — that is the synchronization: a gate opens with a state entry
151
- * and closes with its exit, and every entry/exit lands a frame. A delivery's success is the next
152
- * re-fetch coming back empty; nothing here concludes anything on its own. The same answer carries
153
- * why the run's Workspaces in `placing` wait (ADR-0064), folded as its own frame by the same rules:
154
- * a Workspace enters and leaves `placing` with a frame too. */
155
- async function refreshRun(runId: string, workflow: string): Promise<void> {
156
- if (store.token !== "live") return; // observer mode: the inbox does not exist
363
+ /** Re-read one run's status off the guarded surface: why its Workspaces in `placing` wait
364
+ * (ADR-0064), its outstanding picks (ADR-0024), its Failure's reason (ADR-0073) and who started it
365
+ * (ADR-0078), each folded as its own frame. Called for the selected run only, on its selection and
366
+ * on every frame that touches it: a Workspace enters and leaves `placing` with a frame. For an ended run that the page does not
367
+ * watch, the same answer is its last status (ADR-0032). */
368
+ async function readRun(runId: string, workflow: string): Promise<void> {
369
+ // Observer mode, or a Caller that may not read this Workflow: the server would refuse the read,
370
+ // so the page does not make it (ADR-0032).
371
+ if (!may(store, "read", workflow)) return;
157
372
  const serial = (runFetchSerial.get(runId) ?? 0) + 1;
158
373
  runFetchSerial.set(runId, serial);
159
374
  const res = await gfetch(`/runs/${encodeURIComponent(runId)}`);
160
375
  if (!res) return; // 401 already dropped the page to observer
161
- // !ok: settled + evicted — no gates, and nothing waits.
376
+ // !ok: swept — nothing waits.
162
377
  const body = res.ok
163
- ? ((await res.json()) as {
164
- status?: string;
378
+ ? ((await res.json()) as ObservedRun & {
165
379
  output?: unknown;
166
- gates?: GateCard[];
167
380
  waiting?: WaitingLine[];
168
381
  held?: HeldLine[];
382
+ startedBy?: StarterView;
169
383
  })
170
- : {};
384
+ : null;
171
385
  if (runFetchSerial.get(runId) !== serial) return; // superseded — a newer re-fetch owns the answer
172
- dispatch({ kind: "gates", runId, workflow, gates: body.gates ?? [] });
173
- dispatch({ kind: "waiting", runId, workflow, waiting: body.waiting ?? [], held: body.held ?? [] });
174
- // The reason of a run that ended `failed` (ADR-0073). A 404 says nothing about it, so a reason
175
- // the page has stays.
176
- if (res.ok) dispatch({ kind: "failure", runId, reason: failureReason(body) });
386
+ dispatch({ kind: "waiting", runId, workflow, waiting: body?.waiting ?? [], held: body?.held ?? [] });
387
+ if (!body) return; // a 404 says nothing of the run's end or its starter, so what the page has stays
388
+ // An ended run: its last status, held with the runs the page watched end (the store keeps it
389
+ // only for a run that is not live and that the rail lists).
390
+ if (body.status !== "active") {
391
+ dispatch({
392
+ kind: "endedStatus",
393
+ status: {
394
+ runId: body.runId,
395
+ workflow: body.workflow,
396
+ status: body.status,
397
+ value: body.value,
398
+ children: body.children ?? [],
399
+ ...(body.bound ? { bound: true as const } : {}),
400
+ ...(body.chartHashes ? { chartHashes: body.chartHashes } : {}),
401
+ ...(body.dispatchedBy ? { dispatchedBy: body.dispatchedBy } : {}),
402
+ ...(body.dispatched ? { dispatched: body.dispatched } : {}),
403
+ },
404
+ });
405
+ }
406
+ // The reason of a run that ended `failed` (ADR-0073), and who started the run (ADR-0078).
407
+ dispatch({ kind: "failure", runId, reason: failureReason(body) });
408
+ dispatch({ kind: "starter", runId, startedBy: body.startedBy ?? null });
409
+ }
410
+
411
+ /** {@link readRun}, one read for each run at a time: status frames come on every save. */
412
+ const refreshRun = coalesced(readRun);
413
+
414
+ /** Read the selected run's details. The Console shows waits, picks, a failure and a starter for the
415
+ * selected run only, so it reads no other run's. */
416
+ function readSelected(): void {
417
+ const id = store.selectedRunId;
418
+ if (!id || !store.workflow) return;
419
+ void refreshRun(id, store.workflow);
420
+ }
421
+
422
+ // ---- The ended runs of the selection (ADR-0009, ADR-0032) ---------------------------------------
423
+
424
+ /** Serial of the history reads, so an answer for a Workflow the reader left is dropped. */
425
+ let historySerial = 0;
426
+
427
+ /** Read the selected Workflow's ended runs (`GET /runs?status=ended&workflow=`), the first page:
428
+ * what the rail lists under the live runs. Needs `read`; without it the rail lists live runs only. */
429
+ async function refreshHistory(workflow: string): Promise<void> {
430
+ if (!may(store, "read", workflow)) return;
431
+ const serial = ++historySerial;
432
+ try {
433
+ const res = await gfetch(`/runs?status=ended&workflow=${encodeURIComponent(workflow)}`);
434
+ if (!res?.ok || serial !== historySerial) return;
435
+ const { runs } = (await res.json()) as { runs: RunSummaryView[] };
436
+ dispatch({ kind: "history", workflow, runs });
437
+ } catch {
438
+ // transient — the next snapshot reads again
439
+ }
440
+ }
441
+
442
+ // ---- The Assembly (ADR-0069) --------------------------------------------------------------------
443
+
444
+ /** Read the current Assembly and the one each live run holds (open): what marks a run "older
445
+ * Assembly". An Orchestrator with no Assembly answers `current: null`, and nothing is marked. */
446
+ async function readAssembly(): Promise<void> {
447
+ try {
448
+ const res = await fetch("/assembly");
449
+ if (!res.ok) return;
450
+ const body = (await res.json()) as {
451
+ current: { hash: string } | null;
452
+ held?: Array<{ hash: string; runs: string[] }>;
453
+ };
454
+ dispatch({ kind: "assembly", current: body.current?.hash ?? null, held: body.held ?? [] });
455
+ } catch {
456
+ // transient — the next snapshot reads again
457
+ }
458
+ }
459
+
460
+ /** {@link readAssembly}, one read at a time. */
461
+ const assemblyRead = coalesced(() => readAssembly());
462
+ function refreshAssembly(): void {
463
+ assemblyRead("assembly");
464
+ }
465
+
466
+ /** The live runs a frame named that the Assembly was read again for: once for each run, so a run
467
+ * that holds no Assembly does not make a read on each frame. */
468
+ const assemblyAsked = new Set<string>();
469
+
470
+ /** A live run that the page does not know the Assembly of: it started after the last read, maybe
471
+ * on an Assembly published since. Its Machine is drawn from the Assembly it holds, so read again. */
472
+ function heldUnknown(runs: ObservedRun[]): void {
473
+ if (store.assembly.current === null) return; // an Orchestrator with no Assembly marks nothing
474
+ let ask = false;
475
+ for (const r of runs) {
476
+ if (r.status !== "active" || store.assembly.held.has(r.runId) || assemblyAsked.has(r.runId)) continue;
477
+ assemblyAsked.add(r.runId);
478
+ ask = true;
479
+ }
480
+ if (ask) refreshAssembly();
481
+ }
482
+
483
+ // ---- The kept conversations (ADR-0023, ADR-0032) ------------------------------------------------
484
+ // The bottom drawer shows the selected run's conversations, one tab for each Agent slot. The list
485
+ // is read on each frame of the run; the entries of the shown slot are read when the drawer is open:
486
+ // a closed conversation as pages, an open one as a stream that follows the Turn. Fetch bookkeeping,
487
+ // not belief: the entries themselves are in the store.
488
+
489
+ /** The run the conversations were last read for. */
490
+ let talkRun: string | null = null;
491
+ /** The open streams, by Instance ID. */
492
+ const followers = new Map<string, AbortController>();
493
+ /** The conversations whose kept pages are read, or are being read. */
494
+ const loaded = new Set<string>();
495
+ /** The conversations whose stream ended and that the list has not said again are open: a stream
496
+ * starts again only after a list read after its end, so a stale `open` does not start a loop. */
497
+ const ended = new Set<string>();
498
+ /** The size of each conversation at the last list read: a closed conversation that grew after the
499
+ * page read it (a batch the Harness sent late) is read again after what the page holds. */
500
+ const rowBytes = new Map<string, number>();
501
+ /** Serial of the list reads of the selected run. */
502
+ let talkSerial = 0;
503
+
504
+ /** The entries the Console asks for in one page of a conversation route (`limit`; at most `CONVERSATION_PAGE_MAX` in http.ts). */
505
+ const ENTRY_PAGE = 1000;
506
+
507
+ function stopFollowers(keep: (instanceId: string) => boolean = () => false): void {
508
+ for (const [iid, ctl] of followers) {
509
+ if (keep(iid)) continue;
510
+ ctl.abort();
511
+ followers.delete(iid);
512
+ }
513
+ }
514
+
515
+ /** Bring the reads in line with the selection: another run starts from nothing; the same run reads
516
+ * what the shown slot needs. Safe to call on every frame — a read starts only once. */
517
+ function syncTalk(): void {
518
+ const runId = store.selectedRunId;
519
+ if (runId !== talkRun) {
520
+ stopFollowers();
521
+ loaded.clear();
522
+ ended.clear();
523
+ rowBytes.clear();
524
+ talkRun = runId;
525
+ if (runId) void refreshTalk(runId);
526
+ return;
527
+ }
528
+ if (store.token !== "live") {
529
+ // A credential that went drops the entries (`store.talk`), so the next live one reads each
530
+ // conversation again.
531
+ loaded.clear();
532
+ ended.clear();
533
+ rowBytes.clear();
534
+ }
535
+ if (!runId || !store.bottom.open || store.token !== "live") return stopFollowers();
536
+ const shown = slotConversations(store, activeSlot(store));
537
+ const ids = new Set(shown.map((c) => c.row.instanceId));
538
+ stopFollowers((iid) => ids.has(iid));
539
+ for (const { row, ended: end } of shown) {
540
+ const iid = row.instanceId;
541
+ if (followers.has(iid) || row.swept || end?.cut || end?.swept) continue;
542
+ if (row.open && !ended.has(iid)) void follow(runId, iid);
543
+ else if (!loaded.has(iid)) void loadConversation(runId, iid);
544
+ }
545
+ }
546
+
547
+ /** Read the selected run's conversation list (`read`) and its Agent slots for Steering (`steer`,
548
+ * live runs only). The answer for a run the reader left is dropped. */
549
+ async function readTalk(runId: string): Promise<void> {
550
+ const workflow = store.workflow;
551
+ if (!workflow || store.token !== "live") return;
552
+ const serial = ++talkSerial;
553
+ if (may(store, "read", workflow)) {
554
+ try {
555
+ const res = await gfetch(`/runs/${encodeURIComponent(runId)}/conversations`);
556
+ if (res?.ok && serial === talkSerial && store.selectedRunId === runId) {
557
+ const rows = (await res.json()) as ConversationRow[];
558
+ ended.clear();
559
+ for (const row of rows) {
560
+ const before = rowBytes.get(row.instanceId);
561
+ if (!row.open && before !== undefined && row.bytes > before) loaded.delete(row.instanceId);
562
+ rowBytes.set(row.instanceId, row.bytes);
563
+ }
564
+ dispatch({ kind: "conversations", runId, rows });
565
+ }
566
+ } catch {
567
+ // transient — the next frame reads again
568
+ }
569
+ }
570
+ if (may(store, "steer", workflow) && store.runs.has(runId)) {
571
+ try {
572
+ const res = await gfetch(`/runs/${encodeURIComponent(runId)}/agents`);
573
+ if (res?.ok && store.selectedRunId === runId) {
574
+ dispatch({ kind: "agents", runId, agents: (await res.json()) as AgentLine[] });
575
+ }
576
+ } catch {
577
+ // transient — the next frame reads again
578
+ }
579
+ }
580
+ }
581
+
582
+ /** {@link readTalk}, one read for each run at a time: status frames come on every save. */
583
+ const refreshTalk = coalesced(readTalk);
584
+
585
+ /** The last sequence number the page holds of a conversation, or -1. */
586
+ function lastSeq(instanceId: string): number {
587
+ const kept = store.talk?.entries.get(instanceId);
588
+ return kept?.length ? kept[kept.length - 1]!.seq : -1;
589
+ }
590
+
591
+ /** The conversation route of one Instance ID: the ID is one URL-encoded segment. */
592
+ function conversationPath(runId: string, instanceId: string): string {
593
+ return `/runs/${encodeURIComponent(runId)}/conversations/${encodeURIComponent(instanceId)}`;
594
+ }
595
+
596
+ /** Read a conversation that is not open, a page at a time, after what the page holds. */
597
+ async function loadConversation(runId: string, instanceId: string): Promise<void> {
598
+ loaded.add(instanceId);
599
+ try {
600
+ for (;;) {
601
+ const res = await gfetch(
602
+ `${conversationPath(runId, instanceId)}?after=${lastSeq(instanceId)}&limit=${ENTRY_PAGE}`,
603
+ );
604
+ if (!res?.ok || store.selectedRunId !== runId) return;
605
+ const entries = (await res.json()) as ConversationEntry[];
606
+ dispatch({ kind: "entries", runId, instanceId, entries });
607
+ if (entries.length < ENTRY_PAGE) return;
608
+ }
609
+ } catch {
610
+ loaded.delete(instanceId); // the next sync reads again
611
+ }
612
+ }
613
+
614
+ /** Follow an open conversation (`/events`): what is kept after what the page holds, then each new
615
+ * entry, until the server ends the stream (the Turn closed, or the conversation was cut or swept)
616
+ * or the page stops it. Then the list is read again, so the tab's dot and the next Turn are seen. */
617
+ async function follow(runId: string, instanceId: string): Promise<void> {
618
+ const ctl = new AbortController();
619
+ followers.set(instanceId, ctl);
620
+ let finished = false;
621
+ try {
622
+ const res = await fetch(`${conversationPath(runId, instanceId)}/events?after=${lastSeq(instanceId)}`, {
623
+ headers: { authorization: `Bearer ${token()}`, accept: "text/event-stream" },
624
+ signal: ctl.signal,
625
+ });
626
+ if (res.status === 401) return dispatch({ kind: "token", state: "invalid" });
627
+ if (!res.ok || !res.body) {
628
+ finished = true;
629
+ return;
630
+ }
631
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
632
+ let buffer = "";
633
+ for (;;) {
634
+ const { value, done } = await reader.read();
635
+ if (done) break;
636
+ const { events, rest } = readSse(buffer + value);
637
+ buffer = rest;
638
+ for (const ev of events) {
639
+ if (ev.event === "entry") {
640
+ dispatch({ kind: "entries", runId, instanceId, entries: [JSON.parse(ev.data) as ConversationEntry] });
641
+ } else if (ev.event === "end") {
642
+ const end = JSON.parse(ev.data) as { cut?: boolean; swept?: string };
643
+ dispatch({ kind: "conversationEnd", runId, instanceId, cut: end.cut === true, swept: end.swept });
644
+ }
645
+ }
646
+ }
647
+ finished = true;
648
+ } catch {
649
+ // aborted by the page, or the connection dropped: the next sync decides again
650
+ } finally {
651
+ if (followers.get(instanceId) === ctl) followers.delete(instanceId);
652
+ if (finished && store.selectedRunId === runId) {
653
+ ended.add(instanceId);
654
+ loaded.add(instanceId);
655
+ void refreshTalk(runId);
656
+ }
657
+ }
177
658
  }
178
659
 
179
660
  // ---- The two writes (start-run + gate delivery — ADR-0033) --------------------------------------
@@ -189,6 +670,7 @@ async function startRun(name: string, body: Record<string, unknown>): Promise<st
189
670
  if (!res) return "unauthorized — the page dropped to observer";
190
671
  if (!res.ok) return ((await res.json()) as { error?: string }).error ?? `HTTP ${res.status}`;
191
672
  dispatch({ kind: "startForm", workflow: null });
673
+ refreshInbox(); // a run can open a Gate at its first state
192
674
  // The run ANNOUNCES itself: the feed if this workflow is selected, the next snapshot if not —
193
675
  // but a reader who just started a run should not wait 10s to see it in the rail.
194
676
  if (name !== store.workflow) void snapshotWorkflow(name);
@@ -209,7 +691,33 @@ async function deliverGate(
209
691
  if (!res) return "unauthorized — the page dropped to observer";
210
692
  if (!res.ok) return ((await res.json()) as { error?: string }).error ?? `HTTP ${res.status}`;
211
693
  // Deliberately NO local bookkeeping: the delivery moves the Machine, the move lands a frame, the
212
- // frame triggers the re-fetch, and the re-fetch empties the card (ADR-0032).
694
+ // frame triggers the inbox read, and the read leaves the card out (ADR-0032).
695
+ return null;
696
+ }
697
+
698
+ // ---- Cancel and steer (ADR-0002, ADR-0078) ------------------------------------------------------
699
+ // Same contract as the two writes above. A cancel lands a frame like any transition; a Steering
700
+ // message moves nothing the page can see, so its answer is the only news of it.
701
+
702
+ async function cancelRun(runId: string): Promise<string | null> {
703
+ const res = await gfetch(`/runs/${encodeURIComponent(runId)}/events`, {
704
+ method: "POST",
705
+ headers: { "content-type": "application/json" },
706
+ body: JSON.stringify({ type: "CANCEL" }),
707
+ });
708
+ if (!res) return "unauthorized — the page dropped to observer";
709
+ if (!res.ok) return ((await res.json()) as { error?: string }).error ?? `HTTP ${res.status}`;
710
+ return null;
711
+ }
712
+
713
+ async function steer(runId: string, agent: string, text: string): Promise<string | null> {
714
+ const res = await gfetch(`/runs/${encodeURIComponent(runId)}/steering`, {
715
+ method: "POST",
716
+ headers: { "content-type": "application/json" },
717
+ body: JSON.stringify({ agent, text }),
718
+ });
719
+ if (!res) return "unauthorized — the page dropped to observer";
720
+ if (!res.ok) return ((await res.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${res.status}`;
213
721
  return null;
214
722
  }
215
723
 
@@ -266,6 +774,7 @@ async function selectWorkflow(
266
774
  }
267
775
  dispatch({ kind: "select", workflow: name }); // the dispatch starts the doc's fetch (syncMachine)
268
776
  if (runId) dispatch({ kind: "selectRun", runId });
777
+ if (moved && name) void refreshHistory(name);
269
778
  if (!reconnect || !name) return;
270
779
  connectFeed(name);
271
780
  await syncMachine();
@@ -299,19 +808,29 @@ async function fetchDoc(source: { key: string; path: string }): Promise<MachineD
299
808
  async function syncMachine(): Promise<void> {
300
809
  const name = store.workflow;
301
810
  if (!name) return;
302
- const source = machineDocSource(name, selectedRun(store));
303
- if (source.key === shownKey) return;
811
+ // A drifted run has no Machine that this Orchestrator can read (ADR-0030): say why, and draw none.
812
+ const drift = selectedRunDrift(store);
813
+ if (drift !== null) {
814
+ const key = `drift:${store.selectedRunId}`;
815
+ if (shownKey === key) return;
816
+ shownKey = key;
817
+ view = { doc: null, placeholder: null, note: { text: `drifted: ${drift}`, notice: true } };
818
+ rerender();
819
+ return;
820
+ }
821
+ const source = selectedDocSource(store);
822
+ if (!source || source.key === shownKey) return;
304
823
  shownKey = source.key;
305
824
  let doc = await fetchDoc(source);
306
825
  if (shownKey !== source.key) return; // the reader moved on while the fetch was out
307
826
  let note: MachineView["note"] = null;
308
827
  if (!doc && source.key.startsWith("run:")) {
309
828
  // The run's own Machine could not be read (an Orchestrator older than the route, or the run
310
- // left the store): draw the Workflow's, and say the Chart's states are not in it.
311
- doc = await fetchDoc(machineDocSource(name, null));
829
+ // left the store): draw the Workflow's, and say the run's own states are not in it.
830
+ doc = await fetchDoc(machineDocSource(name, null, false, store.assembly.current));
312
831
  if (shownKey !== source.key) return;
313
832
  note = {
314
- text: "this run binds a Chart, and its Machine could not be read: the Chart's states are not shown",
833
+ text: "this run's own Machine could not be read: this is the current Machine, and the run's states can differ",
315
834
  notice: true,
316
835
  };
317
836
  }
@@ -365,20 +884,30 @@ function connectFeed(name: string): void {
365
884
  feed.addEventListener("runs", (e) => {
366
885
  const runs = JSON.parse((e as MessageEvent<string>).data) as ObservedRun[];
367
886
  dispatch({ kind: "runs", runs });
368
- for (const r of runs) void refreshRun(r.runId, name);
887
+ heldUnknown(runs);
888
+ refreshInbox();
889
+ readSelected();
890
+ if (store.selectedRunId) void refreshTalk(store.selectedRunId);
369
891
  });
370
892
  feed.addEventListener("status", (e) => {
371
893
  const status = JSON.parse((e as MessageEvent<string>).data) as ObservedRun;
372
894
  dispatch({ kind: "status", status });
373
- void refreshRun(status.runId, name);
895
+ heldUnknown([status]);
896
+ refreshInbox();
897
+ if (status.runId === store.selectedRunId) void refreshRun(status.runId, name);
898
+ // A Turn opens and closes with a state entry and exit: the slot's dot and its stream follow.
899
+ if (status.runId === store.selectedRunId) void refreshTalk(status.runId);
374
900
  });
375
901
  feed.addEventListener("gone", (e) => {
376
902
  const { runId } = JSON.parse((e as MessageEvent<string>).data) as { runId: string };
377
903
  dispatch({ kind: "gone", runId });
378
904
  // The reducer already dropped the card (a gate exists only while its state is entered —
379
- // ADR-0011); the re-fetch is the same frame-triggered discipline as `runs`/`status`, and its
380
- // 404 converges on the same empty answer.
381
- void refreshRun(runId, name);
905
+ // ADR-0011); the inbox read is the same frame-triggered discipline as `runs`/`status`. The run
906
+ // is an ended run now: the rail's list of them is read again.
907
+ refreshInbox();
908
+ if (runId === store.selectedRunId) void refreshRun(runId, name);
909
+ void refreshHistory(name);
910
+ if (runId === store.selectedRunId) void refreshTalk(runId);
382
911
  });
383
912
  feed.addEventListener("emit", (e) => {
384
913
  const { runId, type } = JSON.parse((e as MessageEvent<string>).data) as { runId: string; type: string };
@@ -390,8 +919,9 @@ function connectFeed(name: string): void {
390
919
 
391
920
  // ---- Fleet snapshots ----------------------------------------------------------------------------
392
921
  // The unselected workflows have no feed — never more than one EventSource. They get REST snapshots
393
- // (`GET /workflows/:name/runs`) on an interval and on tab focus, folded through the same reducer
394
- // (each snapshot also re-triggers its runs' gate re-fetches, so the inbox is fleet-wide).
922
+ // (`GET /workflows/:name/runs`) on an interval and on tab focus, folded through the same reducer.
923
+ // Each round reads the inbox once more (it is fleet-wide), the Assembly, and the selection's ended
924
+ // runs and conversations.
395
925
 
396
926
  const SNAPSHOT_MS = 10_000;
397
927
 
@@ -401,7 +931,6 @@ async function snapshotWorkflow(name: string): Promise<void> {
401
931
  if (!res.ok) return;
402
932
  const runs = (await res.json()) as ObservedRun[];
403
933
  dispatch({ kind: "fleet", workflow: name, runs });
404
- for (const r of runs) void refreshRun(r.runId, name);
405
934
  } catch {
406
935
  // transient — the next tick tries again
407
936
  }
@@ -414,6 +943,10 @@ async function snapshotFleet(): Promise<void> {
414
943
  } catch {
415
944
  // transient — the next tick tries again
416
945
  }
946
+ void refreshAssembly();
947
+ refreshInbox();
948
+ if (store.workflow) void refreshHistory(store.workflow);
949
+ if (store.selectedRunId) void refreshTalk(store.selectedRunId);
417
950
  for (const name of store.workflows) {
418
951
  if (name === store.workflow) continue; // the selection's feed is fresher than any snapshot
419
952
  await snapshotWorkflow(name);
@@ -429,7 +962,10 @@ const api: AppApi = {
429
962
  toggleStartForm,
430
963
  startRun,
431
964
  deliverGate,
965
+ cancelRun,
966
+ steer,
432
967
  setToken,
968
+ login: (issuer) => void login(issuer),
433
969
  onScale: (pct) => {
434
970
  if (pct === zoomPct) return;
435
971
  zoomPct = pct;
@@ -445,7 +981,10 @@ async function boot(): Promise<void> {
445
981
  addEventListener("popstate", () => void selectWorkflow(workflowFromPath(), { push: false }));
446
982
  addEventListener("focus", () => void snapshotFleet());
447
983
  setInterval(() => void snapshotFleet(), SNAPSHOT_MS);
984
+ // A return from the issuer first: it puts the address back, which the selection below reads.
985
+ await finishLogin();
448
986
  rerender(); // the shell first — the fetches below fill it in
987
+ void loadLogins().then(rerender);
449
988
 
450
989
  // The fleet first (the rail names everything), then the token (unlock is async and must not gate
451
990
  // observation), then the selection the address carries — deep links work on load.
@@ -456,6 +995,7 @@ async function boot(): Promise<void> {
456
995
  // the interval retries; the rail says "no workflows registered" until then
457
996
  }
458
997
  if (token()) void validateToken();
998
+ void refreshAssembly();
459
999
  await selectWorkflow(workflowFromPath(), { push: false });
460
1000
  void snapshotFleet();
461
1001
  }