@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/store.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // What the page BELIEVES, kept apart from what it has painted (canvas.ts's `shown`/`queue`).
2
2
  //
3
3
  // A pure reducer over everything the Console hears — the selected workflow's feed frames
4
- // (ADR-0022), fleet snapshots, gate re-fetches (ADR-0032), the token's state, the reader's own
5
- // selections — no DOM, no fetch, no module state — so the interesting cases are testable under
4
+ // (ADR-0022), fleet snapshots, the Gate inbox (ADR-0032), the ended runs, the kept conversations,
5
+ // the token's state, the reader's own selections — no DOM, no fetch, no module state — so the interesting cases are testable under
6
6
  // `node:test` with no jsdom and no build step. The renderer downstream is already a pure function
7
7
  // of (doc, status); this is the other half.
8
8
  //
@@ -12,9 +12,11 @@
12
12
  // The feed is LEVEL-TRIGGERED: every `status` carries a whole observation and `runs` carries the
13
13
  // whole live set, so nothing here merges patches or replays a log. Re-delivery of any frame is
14
14
  // idempotent by construction, which is the entire reconnect story — there is no cursor to resume
15
- // from and no gap to detect. The gate inbox rides the same idiom one band up: every `gates` frame
16
- // is a whole re-fetch of one run's open gates, so an emptied card is a fact the server stated,
17
- // never bookkeeping this side did after a delivery (ADR-0032).
15
+ // from and no gap to detect. The Gate inbox rides the same idiom one band up: every `inbox` frame
16
+ // is a whole read of `GET /gates`, every open Gate the Caller may answer, so an emptied card is a
17
+ // fact the server stated, never bookkeeping this side did after a delivery (ADR-0032).
18
+
19
+ import { mergeEntries, type AgentLine, type ConversationEntry, type ConversationRow } from "./conversation.ts";
18
20
 
19
21
  /** A run as the OPEN observation band reports it (ADR-0014): identity and where it is, nothing of
20
22
  * what it is carrying. Mirrors `RunObservation` in run-host.ts. */
@@ -75,12 +77,49 @@ export type FleetEntry = {
75
77
  shadows?: { origin: WorkflowOrigin; unrunnable?: string };
76
78
  };
77
79
 
78
- /** One open gate as `GET /runs/:id` reports it — mirrors `GateView` in run-host.ts. */
80
+ /** One open gate as `GET /gates` reports it — mirrors `GateView` in run-host.ts, with the time it
81
+ * opened. */
79
82
  export type GateCard = {
80
83
  gate: string;
81
84
  path: string[];
82
85
  accepts: Array<{ name: string; description?: string; input: unknown }>;
86
+ acceptsNow?: string[];
83
87
  meta?: Record<string, unknown>;
88
+ openedAt?: string;
89
+ /** The Hook kinds that this Gate hears (ADR-0011). */
90
+ hook?: string[];
91
+ };
92
+
93
+ /** One entry of `GET /gates` (ADR-0011): an open Gate that the Caller may answer, with its run —
94
+ * mirrors `GateInboxEntry` in run-host.ts. Never the run's context. */
95
+ export type InboxGate = GateCard & { runId: string; workflow: string };
96
+
97
+ /** One run of `GET /runs` (ADR-0009) — mirrors `RunSummary` in run-list.ts. The Console lists the
98
+ * ended ones of the selected Workflow under its live runs. */
99
+ export type RunSummaryView = {
100
+ runId: string;
101
+ workflow: string;
102
+ status: string;
103
+ /** Why a run is `drifted` or `lost`, a Failure's reason, or a fault (ADR-0030, ADR-0073). */
104
+ reason?: string;
105
+ startedAt?: string;
106
+ endedAt?: string;
107
+ /** The hash of the Assembly the run started under (ADR-0069). */
108
+ assembly?: string;
109
+ openGates?: number;
110
+ dispatchedBy?: DispatchedByView;
111
+ swept?: string;
112
+ };
113
+
114
+ /** One row of a Workflow's run list in the rail: a live run, a run that this page watched end, or
115
+ * an ended run that `GET /runs` lists. */
116
+ export type RailRun = {
117
+ runId: string;
118
+ status: string;
119
+ /** The run is not live. */
120
+ settled: boolean;
121
+ reason?: string;
122
+ dispatchedBy?: DispatchedByView;
84
123
  };
85
124
 
86
125
  /** One Workspace waiting for capacity as `GET /runs/:id` reports it (ADR-0064) — mirrors
@@ -88,13 +127,41 @@ export type GateCard = {
88
127
  export type WaitingLine = { child: string; on: "node" | "quota"; message: string; since: string };
89
128
 
90
129
  /** One outstanding pick as `GET /runs/:id` reports it (ADR-0024) — mirrors `RunHeld` in
91
- * run-host.ts. There is no default time bound on a held Turn: "held since" is how a human sees one.
130
+ * run-host.ts. There is no default time bound on a held Turn: "outstanding since" is how a human
131
+ * sees one.
92
132
  * Guarded like the reason a Workspace waits: a pick names what an Agent did. */
93
133
  export type HeldLine = { agent: string; pick: string; since: string };
94
134
 
95
135
  /** The credential's state — never its value, which stays in sessionStorage (ADR-0032). */
96
136
  export type TokenState = "none" | "checking" | "live" | "invalid";
97
137
 
138
+ /** One scope of a Caller (ADR-0078): the five acts. Mirrors `Scope` in config.ts. */
139
+ export type ScopeName = "start" | "answer" | "cancel" | "read" | "steer";
140
+
141
+ /** The Workflows a scope opens: every one, or the named ones. Mirrors `ScopeGrant` in config.ts. */
142
+ export type ScopeGrant = "all" | readonly string[];
143
+
144
+ /** What the `answer` scope opens (ADR-0078): every Gate, or a list of Workflow names and of
145
+ * `{ workflow, gates }` items that name single Gates. Mirrors `AnswerGrant` in config.ts. */
146
+ export type AnswerGrant = "all" | ReadonlyArray<string | { workflow: string; gates: readonly string[] }>;
147
+
148
+ /** The page's Caller as `GET /whoami` gives it (ADR-0078) — mirrors `Caller` in tokens.ts. What it
149
+ * `may` do decides which controls render (ADR-0032); the server still checks each request. */
150
+ export type CallerView = {
151
+ via: string;
152
+ name: string;
153
+ /** The name that jr2 shows, when it is not `name` (a JWT's `email`). */
154
+ shown?: string;
155
+ onBehalfOf?: string;
156
+ may: Partial<Record<Exclude<ScopeName, "answer">, ScopeGrant>> & { answer?: AnswerGrant };
157
+ };
158
+
159
+ /** Who started a run, as `GET /runs/:id` gives it (ADR-0078) — mirrors `RunStarter` in callers.ts. */
160
+ export type StarterView =
161
+ | { kind: "caller"; via: string; name: string; shown?: string; onBehalfOf?: string }
162
+ | { kind: "run"; run: string; slot: string }
163
+ | { kind: "jr2"; why: string };
164
+
98
165
  /** One frame folded into the store: the workflow feed's wire frames (ADR-0022), plus the page's
99
166
  * own facts — fleet snapshots, gate re-fetches, token state, the reader's selections (ADR-0032). */
100
167
  export type Frame =
@@ -110,9 +177,19 @@ export type Frame =
110
177
  | { kind: "fleet"; workflow: string; runs: ObservedRun[] }
111
178
  | { kind: "toggleWorkflow"; workflow: string }
112
179
  | { kind: "token"; state: TokenState }
113
- | { kind: "gates"; runId: string; workflow: string; gates: GateCard[] }
180
+ | { kind: "caller"; caller: CallerView }
181
+ | { kind: "inbox"; gates: InboxGate[] }
182
+ | { kind: "history"; workflow: string; runs: RunSummaryView[] }
183
+ | { kind: "endedStatus"; status: ObservedRun }
184
+ | { kind: "assembly"; current: string | null; held: Array<{ hash: string; runs: string[] }> }
185
+ | { kind: "conversations"; runId: string; rows: ConversationRow[] }
186
+ | { kind: "agents"; runId: string; agents: AgentLine[] }
187
+ | { kind: "entries"; runId: string; instanceId: string; entries: ConversationEntry[] }
188
+ | { kind: "conversationEnd"; runId: string; instanceId: string; cut: boolean; swept?: string }
189
+ | { kind: "bottom"; open?: boolean; slot?: string | null }
114
190
  | { kind: "waiting"; runId: string; workflow: string; waiting: WaitingLine[]; held?: HeldLine[] }
115
191
  | { kind: "failure"; runId: string; reason: string | null }
192
+ | { kind: "starter"; runId: string; startedBy: StarterView | null }
116
193
  | { kind: "inboxScope"; all: boolean }
117
194
  | { kind: "startForm"; workflow: string | null };
118
195
 
@@ -137,16 +214,35 @@ export type Frame =
137
214
  * `token` the credential's STATE, never its value — the secret stays in sessionStorage
138
215
  * (ADR-0032), outside anything a test would snapshot. "none" | "checking" | "live" |
139
216
  * "invalid". Anything but "live" means observer mode.
140
- * `gates` the gate inbox: runId -> { workflow, gates } from `GET /runs/:id` re-fetches. Guarded
141
- * data (Instance band), so it cannot outlive the credential that read it.
217
+ * `gates` the Gate inbox: runId -> { workflow, gates }, from `GET /gates` (ADR-0011), which lists
218
+ * only the Gates that the Caller may answer. Each read replaces it whole. Guarded data,
219
+ * so it cannot outlive the credential that read it.
142
220
  * `waiting` why each run's Workspaces in `placing` wait (ADR-0064): runId -> { workflow, waiting },
143
- * off the SAME `GET /runs/:id` re-fetch as `gates`, under the same rules — guarded, whole
221
+ * off a `GET /runs/:id` re-fetch, under the rules of `gates` — guarded, whole
144
222
  * per run, gone with the credential. The open band sees `placing` lit and nothing more.
145
223
  * `failures` the reason of each run this page knows that ended `failed` (ADR-0073): runId -> reason,
146
224
  * off the SAME `GET /runs/:id` re-fetch, under the same rules — guarded (the open band
147
225
  * sees the `failed` status and its color, never the output), gone with the credential.
148
226
  * A failed run is a settled run, so `gone` keeps it; it goes with the run when the run
149
227
  * leaves both `runs` and `settled`.
228
+ * `caller` the page's Caller as `GET /whoami` answered it (ADR-0078): who the page acts as, and
229
+ * what it may do. Null without a live credential; gone with it. The token's VALUE is
230
+ * never here — this is what the server said the value is.
231
+ * `starters` who started each run this page knows (ADR-0078): runId -> starter, off the SAME
232
+ * `GET /runs/:id` re-fetch as `failures`, under the same rules — guarded, gone with the
233
+ * credential, gone with the run.
234
+ * `opened` the last status of each ended run that the person opened from the rail and that this
235
+ * page did not watch end (`GET /runs/:id`, ADR-0032). Guarded (`read`): gone with the
236
+ * credential, with a change of Caller and with the selection. At most {@link SETTLED_CAP}.
237
+ * `history` the selected Workflow's ended runs, from `GET /runs?status=ended&workflow=` (ADR-0009):
238
+ * what the rail lists under the live runs. Needs `read`; guarded, gone with the
239
+ * credential and with the selection.
240
+ * `assembly` the current Assembly's hash and the hash each live run holds (`GET /assembly`, open):
241
+ * what marks a run "older Assembly" (ADR-0069).
242
+ * `talk` the SELECTED run's kept conversations (ADR-0023): its rows, its Agent slots for
243
+ * Steering, and the entries read so far by Instance ID. Guarded, and gone with the
244
+ * credential or when the selection moves to another run.
245
+ * `bottom` the bottom drawer: open or closed, and the Agent slot it shows (null: the first).
150
246
  * `inboxAll` the widen control: false = the inbox follows the selection, true = every workflow.
151
247
  * `startFormFor` which workflow's start-run form is open, if any.
152
248
  * `selectedNodeId` the diagram node the reader clicked — an elk node id (scope + state id), one at
@@ -158,6 +254,7 @@ export type Frame =
158
254
  export type Store = {
159
255
  runs: Map<string, ObservedRun>;
160
256
  settled: Map<string, ObservedRun>;
257
+ opened: Map<string, ObservedRun>;
161
258
  emits: Array<{ runId: string; type: string }>;
162
259
  selectedRunId: string | null;
163
260
  selectedNodeId: string | null;
@@ -171,10 +268,26 @@ export type Store = {
171
268
  gates: Map<string, { workflow: string; gates: GateCard[] }>;
172
269
  waiting: Map<string, { workflow: string; waiting: WaitingLine[]; held: HeldLine[] }>;
173
270
  failures: Map<string, string>;
271
+ caller: CallerView | null;
272
+ starters: Map<string, StarterView>;
273
+ history: { workflow: string; runs: RunSummaryView[] } | null;
274
+ assembly: { current: string | null; held: Map<string, string> };
275
+ talk: Talk | null;
276
+ bottom: { open: boolean; slot: string | null };
174
277
  inboxAll: boolean;
175
278
  startFormFor: string | null;
176
279
  };
177
280
 
281
+ /** The selected run's kept conversations (see {@link Store}). `ended` holds each conversation whose
282
+ * stream ended, and whether it was cut or swept. */
283
+ export type Talk = {
284
+ runId: string;
285
+ rows: ConversationRow[];
286
+ agents: AgentLine[];
287
+ entries: Map<string, ConversationEntry[]>;
288
+ ended: Map<string, { cut: boolean; swept?: string }>;
289
+ };
290
+
178
291
  /** How many finished runs stay on the page. Enough to see what just happened, bounded so a page
179
292
  * left open for a week does not grow without end. */
180
293
  export const SETTLED_CAP = 20;
@@ -187,6 +300,7 @@ export function emptyStore(): Store {
187
300
  return {
188
301
  runs: new Map(),
189
302
  settled: new Map(),
303
+ opened: new Map(),
190
304
  emits: [],
191
305
  selectedRunId: null,
192
306
  selectedNodeId: null,
@@ -200,31 +314,47 @@ export function emptyStore(): Store {
200
314
  gates: new Map(),
201
315
  waiting: new Map(),
202
316
  failures: new Map(),
317
+ caller: null,
318
+ starters: new Map(),
319
+ history: null,
320
+ assembly: { current: null, held: new Map() },
321
+ talk: null,
322
+ bottom: { open: false, slot: null },
203
323
  inboxAll: false,
204
324
  startFormFor: null,
205
325
  };
206
326
  }
207
327
 
208
328
  /** Fold one frame into the store, returning a new one. Unknown frame kinds are ignored, so a server
209
- * that learns a new frame type does not break a page still running yesterday's script. */
329
+ * that learns a new frame type does not break a page still running yesterday's script. The kept
330
+ * conversations belong to the selected run: when a frame moves the selection to another run, they
331
+ * go. */
210
332
  export function applyFrame(store: Store, frame: Frame): Store {
333
+ const next = foldFrame(store, frame);
334
+ if (next.talk && next.talk.runId !== next.selectedRunId) return { ...next, talk: null };
335
+ return next;
336
+ }
337
+
338
+ function foldFrame(store: Store, frame: Frame): Store {
211
339
  switch (frame.kind) {
212
340
  case "runs": {
213
341
  // The server's whole truth about what is live. Runs that ended during a disconnect are simply
214
342
  // ABSENT — no `gone` was delivered for them and none is needed; they were never ours to settle.
215
- // The same wholeness settles the inbox: a selected-workflow run the frame does not mention can
216
- // never be re-fetched again, so its card leaves with it. A failure stays while its run is live
217
- // or settled here.
343
+ // The same wholeness settles the waits: a selected-workflow run the frame does not mention can
344
+ // never be re-fetched again, so its waits leave with it. A failure stays while its run is
345
+ // live, settled here or listed as ended.
218
346
  const runs = new Map(frame.runs.map((r): [string, ObservedRun] => [r.runId, r]));
219
- const orphans = [...store.failures.keys()].filter((id) => !runs.has(id) && !store.settled.has(id));
347
+ const orphan = (id: string): boolean => !runs.has(id) && !store.settled.has(id) && !inHistory(store, id);
220
348
  let failures = store.failures;
221
- for (const id of orphans) failures = mapWithout(failures, id);
349
+ for (const id of [...failures.keys()].filter(orphan)) failures = mapWithout(failures, id);
350
+ let starters = store.starters;
351
+ for (const id of [...starters.keys()].filter(orphan)) starters = mapWithout(starters, id);
222
352
  return select({
223
353
  ...store,
224
354
  runs,
225
- gates: dropAbsent(store.gates, store.workflow, new Set(frame.runs.map((r) => r.runId))),
226
355
  waiting: dropAbsent(store.waiting, store.workflow, new Set(frame.runs.map((r) => r.runId))),
227
356
  failures,
357
+ starters,
228
358
  });
229
359
  }
230
360
 
@@ -242,14 +372,7 @@ export function applyFrame(store: Store, frame: Frame): Store {
242
372
  if (!departing) return store.gates === gates && store.waiting === waiting ? store : { ...store, gates, waiting };
243
373
  const runs = new Map(store.runs);
244
374
  runs.delete(frame.runId);
245
- const settled = new Map(store.settled).set(frame.runId, departing);
246
- let failures = store.failures;
247
- while (settled.size > SETTLED_CAP) {
248
- const evicted = settled.keys().next().value!;
249
- settled.delete(evicted);
250
- failures = mapWithout(failures, evicted);
251
- }
252
- return select({ ...store, runs, settled, gates, waiting, failures });
375
+ return select(settle({ ...store, runs, gates, waiting }, departing));
253
376
  }
254
377
 
255
378
  case "emit":
@@ -281,12 +404,17 @@ export function applyFrame(store: Store, frame: Frame): Store {
281
404
  expanded,
282
405
  runs: new Map(),
283
406
  settled: new Map(),
407
+ opened: new Map(),
284
408
  emits: [],
285
409
  selectedRunId: null,
286
410
  selectedNodeId: null,
287
411
  connection: "connecting",
288
- // Each failure belongs to a run of the old selection's `runs` or `settled`, which go.
412
+ // Each failure and starter belongs to a run of the old selection's `runs` or `settled`,
413
+ // which go; so does its list of ended runs.
289
414
  failures: new Map(),
415
+ starters: new Map(),
416
+ history: null,
417
+ bottom: { ...store.bottom, slot: null },
290
418
  };
291
419
  }
292
420
 
@@ -302,12 +430,11 @@ export function applyFrame(store: Store, frame: Frame): Store {
302
430
 
303
431
  case "fleet": {
304
432
  // One unselected workflow's REST snapshot — the same wholeness contract as a `runs` frame,
305
- // so it settles that workflow's inbox cards the same way (a run the snapshot does not carry
306
- // will never be re-fetched again).
433
+ // so it settles that workflow's waits the same way (a run the snapshot does not carry will
434
+ // never be re-fetched again).
307
435
  const fleet = new Map(store.fleet).set(frame.workflow, frame.runs);
308
436
  const present = new Set(frame.runs.map((r) => r.runId));
309
- const gates = dropAbsent(store.gates, frame.workflow, present);
310
- return { ...store, fleet, gates, waiting: dropAbsent(store.waiting, frame.workflow, present) };
437
+ return { ...store, fleet, waiting: dropAbsent(store.waiting, frame.workflow, present) };
311
438
  }
312
439
 
313
440
  case "toggleWorkflow": {
@@ -320,24 +447,152 @@ export function applyFrame(store: Store, frame: Frame): Store {
320
447
  // Guarded data cannot outlive the credential that read it: leaving "live" for "none" or
321
448
  // "invalid" (the any-later-401 drop of ADR-0032) empties the inbox. "checking" keeps it — a
322
449
  // re-validation of a token that turns out fine should not flash the inbox empty.
450
+ // The Caller goes with it: a page with no credential acts as nobody. A selected run that only
451
+ // the guarded listing named goes too.
323
452
  if (frame.state === "none" || frame.state === "invalid") {
324
- return { ...store, token: frame.state, gates: new Map(), waiting: new Map(), failures: new Map() };
453
+ return select({
454
+ ...store,
455
+ token: frame.state,
456
+ gates: new Map(),
457
+ waiting: new Map(),
458
+ failures: new Map(),
459
+ caller: null,
460
+ starters: new Map(),
461
+ history: null,
462
+ opened: new Map(),
463
+ talk: null,
464
+ });
325
465
  }
326
466
  return { ...store, token: frame.state };
327
467
 
328
- case "gates": {
329
- // A whole re-fetch of one run's open gates (`GET /runs/:id` on a frame — ADR-0032). Empty
330
- // means the card LEAVES: that is how a delivery's success shows, and the only way it does.
331
- // No credential, no inbox: a re-fetch that was in flight when the token dropped must not
332
- // repopulate the map the `token` frame just emptied (invisible while locked, but it would
333
- // surface intact on the next unlock — guarded data outliving the credential that read it).
468
+ case "caller": {
469
+ // What `GET /whoami` said the credential is (ADR-0078). It comes before the `token` frame
470
+ // that makes the page live, so the first live render already knows which controls to show.
471
+ // A card that the new Caller's `answer` does not open goes, as the `gates` fold would refuse it.
472
+ const next = {
473
+ ...store,
474
+ caller: frame.caller,
475
+ gates: keepWhere(store.gates, (entry) => grantOpens(frame.caller?.may.answer, entry.workflow)),
476
+ };
477
+ if (sameCaller(store.caller, frame.caller)) return next;
478
+ // Another Caller: what the old one read does not belong to the new one (the page reads it
479
+ // again for the new one when it goes live). The selection stays only where the page can
480
+ // still say something about it.
481
+ return select({
482
+ ...next,
483
+ waiting: new Map(),
484
+ failures: new Map(),
485
+ starters: new Map(),
486
+ history: null,
487
+ opened: new Map(),
488
+ talk: null,
489
+ });
490
+ }
491
+
492
+ case "inbox": {
493
+ // A whole read of `GET /gates` (ADR-0011, ADR-0032): every open Gate that the Caller may
494
+ // answer, oldest first. It replaces the inbox, so a card that is not in it LEAVES: that is
495
+ // how a delivery's success shows, and the only way it does. No credential, no inbox: a read
496
+ // that was in flight when the token dropped must not fill the map the `token` frame just
497
+ // emptied (guarded data outliving the credential that read it).
334
498
  if (store.token !== "live") return store;
335
- if (!frame.gates.length) return { ...store, gates: mapWithout(store.gates, frame.runId) };
336
- const gates = new Map(store.gates);
337
- gates.set(frame.runId, { workflow: frame.workflow, gates: frame.gates });
499
+ const gates = new Map<string, { workflow: string; gates: GateCard[] }>();
500
+ for (const { runId, workflow, ...card } of frame.gates) {
501
+ // The server lists only what the Caller may answer; a card is a send form, so the page
502
+ // checks the Workflow again and holds no form that would meet a 403 (ADR-0032).
503
+ if (!may(store, "answer", workflow)) continue;
504
+ const entry = gates.get(runId) ?? { workflow, gates: [] };
505
+ entry.gates.push(card);
506
+ gates.set(runId, entry);
507
+ }
338
508
  return { ...store, gates };
339
509
  }
340
510
 
511
+ case "history": {
512
+ // The selected Workflow's ended runs (`GET /runs?status=ended`, ADR-0009): whole, newest
513
+ // first. Guarded (`read`), and only for the Workflow the page still shows.
514
+ if (store.token !== "live" || frame.workflow !== store.workflow || !may(store, "read", frame.workflow)) {
515
+ return store;
516
+ }
517
+ const live = new Set(store.runs.keys());
518
+ return select({
519
+ ...store,
520
+ history: { workflow: frame.workflow, runs: frame.runs.filter((r) => !live.has(r.runId)) },
521
+ });
522
+ }
523
+
524
+ case "endedStatus": {
525
+ // The last status of an ended run that the person opened from the rail (ADR-0032): read off
526
+ // `GET /runs/:id`, held with the runs this page watched end, so the graph lights where the run
527
+ // ended. Not for a live run (the feed speaks for it), and not for another Workflow's run.
528
+ const status = frame.status;
529
+ if (store.token !== "live" || store.runs.has(status.runId) || status.workflow !== store.workflow) return store;
530
+ // A run this page watched end: its last status joins what the open feed said of it.
531
+ if (store.settled.has(status.runId)) return select(settle(store, status));
532
+ // A run only the guarded listing names: held apart, so it goes with the credential.
533
+ if (!inHistory(store, status.runId) || !may(store, "read", status.workflow)) return store;
534
+ const opened = new Map(store.opened);
535
+ opened.delete(status.runId);
536
+ opened.set(status.runId, status);
537
+ while (opened.size > SETTLED_CAP) opened.delete(opened.keys().next().value!);
538
+ return select({ ...store, opened });
539
+ }
540
+
541
+ case "assembly": {
542
+ // Open structure (`GET /assembly`, ADR-0069): the current Assembly, and each live run's.
543
+ const held = new Map<string, string>();
544
+ for (const { hash, runs } of frame.held) for (const runId of runs) held.set(runId, hash);
545
+ // `GET /assembly` names live runs only. A run that this page watched end keeps the hash it
546
+ // held while it was live, so it stays drawn on its own Machine, with its mark.
547
+ for (const runId of store.settled.keys()) {
548
+ const was = store.assembly.held.get(runId);
549
+ if (was !== undefined && !held.has(runId)) held.set(runId, was);
550
+ }
551
+ return { ...store, assembly: { current: frame.current, held } };
552
+ }
553
+
554
+ case "conversations": {
555
+ // The selected run's kept conversations, whole (ADR-0023). Guarded (`read`), and only for
556
+ // the run the page shows: a list that comes back after the selection moved is dropped.
557
+ if (store.token !== "live" || frame.runId !== store.selectedRunId) return store;
558
+ const talk = store.talk ?? emptyTalk(frame.runId);
559
+ return { ...store, talk: { ...talk, rows: frame.rows } };
560
+ }
561
+
562
+ case "agents": {
563
+ // The selected run's Agent slots as Steering sees them (ADR-0002): which ones may be steered.
564
+ if (store.token !== "live" || frame.runId !== store.selectedRunId) return store;
565
+ const talk = store.talk ?? emptyTalk(frame.runId);
566
+ return { ...store, talk: { ...talk, agents: frame.agents } };
567
+ }
568
+
569
+ case "entries": {
570
+ // Entries of one conversation, off a page or the stream: added in sequence order, each once.
571
+ if (store.token !== "live" || frame.runId !== store.selectedRunId || !frame.entries.length) return store;
572
+ const talk = store.talk ?? emptyTalk(frame.runId);
573
+ const kept = talk.entries.get(frame.instanceId) ?? [];
574
+ const merged = mergeEntries(kept, frame.entries);
575
+ if (merged === kept) return store;
576
+ return { ...store, talk: { ...talk, entries: new Map(talk.entries).set(frame.instanceId, merged) } };
577
+ }
578
+
579
+ case "conversationEnd": {
580
+ // The stream of one conversation ended: its Turn closed, or it was cut or swept.
581
+ if (store.token !== "live" || frame.runId !== store.selectedRunId) return store;
582
+ const talk = store.talk ?? emptyTalk(frame.runId);
583
+ const end = { cut: frame.cut, ...(frame.swept !== undefined ? { swept: frame.swept } : {}) };
584
+ return { ...store, talk: { ...talk, ended: new Map(talk.ended).set(frame.instanceId, end) } };
585
+ }
586
+
587
+ case "bottom":
588
+ return {
589
+ ...store,
590
+ bottom: {
591
+ open: frame.open ?? store.bottom.open,
592
+ slot: frame.slot === undefined ? store.bottom.slot : frame.slot,
593
+ },
594
+ };
595
+
341
596
  case "waiting": {
342
597
  // The same guarded re-fetch's other half (ADR-0064), folded the same way: whole per run, and
343
598
  // nothing folded without the credential that read it.
@@ -365,11 +620,25 @@ export function applyFrame(store: Store, frame: Frame): Store {
365
620
  const failures = mapWithout(store.failures, frame.runId);
366
621
  return failures === store.failures ? store : { ...store, failures };
367
622
  }
368
- if (!store.runs.has(frame.runId) && !store.settled.has(frame.runId)) return store;
623
+ if (!store.runs.has(frame.runId) && !store.settled.has(frame.runId) && !store.opened.has(frame.runId))
624
+ return store;
369
625
  if (store.failures.get(frame.runId) === frame.reason) return store;
370
626
  return { ...store, failures: new Map(store.failures).set(frame.runId, frame.reason) };
371
627
  }
372
628
 
629
+ case "starter": {
630
+ // Who started the run (ADR-0078), off the same guarded re-fetch as the failure, folded by the
631
+ // same rules: nothing without the credential, nothing for a run the page does not hold.
632
+ if (store.token !== "live") return store;
633
+ if (frame.startedBy === null) {
634
+ const starters = mapWithout(store.starters, frame.runId);
635
+ return starters === store.starters ? store : { ...store, starters };
636
+ }
637
+ if (!store.runs.has(frame.runId) && !store.settled.has(frame.runId) && !store.opened.has(frame.runId))
638
+ return store;
639
+ return { ...store, starters: new Map(store.starters).set(frame.runId, frame.startedBy) };
640
+ }
641
+
373
642
  case "inboxScope":
374
643
  return { ...store, inboxAll: frame.all };
375
644
 
@@ -381,6 +650,49 @@ export function applyFrame(store: Store, frame: Frame): Store {
381
650
  }
382
651
  }
383
652
 
653
+ /** No conversation read yet for `runId`. */
654
+ function emptyTalk(runId: string): Talk {
655
+ return { runId, rows: [], agents: [], entries: new Map(), ended: new Map() };
656
+ }
657
+
658
+ /** `runId` is one of the selected Workflow's ended runs that `GET /runs` listed, and the Caller
659
+ * may still read them. */
660
+ function inHistory(store: Store, runId: string): boolean {
661
+ return listedEnded(store).some((r) => r.runId === runId);
662
+ }
663
+
664
+ /** The ended runs that `GET /runs` listed, when the Caller may read the selected Workflow; else
665
+ * none (ADR-0032: without `read`, the rail lists live runs only). */
666
+ function listedEnded(store: Store): RunSummaryView[] {
667
+ // The Caller's grant, not `may`: a re-validation (`checking`) of the same Caller keeps the rows.
668
+ if (!store.history || !grantOpens(store.caller?.may.read, store.history.workflow)) return [];
669
+ return store.history.runs;
670
+ }
671
+
672
+ /** The same Caller, so what the page read for one is still the other's. */
673
+ function sameCaller(a: CallerView | null, b: CallerView | null): boolean {
674
+ return JSON.stringify(a) === JSON.stringify(b);
675
+ }
676
+
677
+ /** Hold `run` with the runs that this page watched end, newest last, at most {@link SETTLED_CAP}.
678
+ * A run that the cap drops takes its failure and starter with it, unless `GET /runs` still lists
679
+ * it. */
680
+ function settle(store: Store, run: ObservedRun): Store {
681
+ const settled = new Map(store.settled);
682
+ settled.delete(run.runId);
683
+ settled.set(run.runId, run);
684
+ let failures = store.failures;
685
+ let starters = store.starters;
686
+ while (settled.size > SETTLED_CAP) {
687
+ const evicted = settled.keys().next().value!;
688
+ settled.delete(evicted);
689
+ if (inHistory(store, evicted)) continue;
690
+ failures = mapWithout(failures, evicted);
691
+ starters = mapWithout(starters, evicted);
692
+ }
693
+ return { ...store, settled, failures, starters };
694
+ }
695
+
384
696
  /** A copy of `map` without `key` — or `map` itself when the key was never there, so a caller can
385
697
  * cheaply tell "nothing changed". */
386
698
  function mapWithout<K, V>(map: Map<K, V>, key: K): Map<K, V> {
@@ -390,6 +702,17 @@ function mapWithout<K, V>(map: Map<K, V>, key: K): Map<K, V> {
390
702
  return next;
391
703
  }
392
704
 
705
+ /** `map` without the entries `keep` refuses — or `map` itself when it keeps them all. */
706
+ function keepWhere<K, V>(map: Map<K, V>, keep: (entry: V) => boolean): Map<K, V> {
707
+ let next = map;
708
+ for (const [key, entry] of map) {
709
+ if (keep(entry)) continue;
710
+ if (next === map) next = new Map(map);
711
+ next.delete(key);
712
+ }
713
+ return next;
714
+ }
715
+
393
716
  /** Drop `workflow`'s guarded entries (inbox cards, waits) for runs a whole-set frame did not
394
717
  * mention. Those runs will never be re-fetched again (frames and snapshots are per-run triggers),
395
718
  * so an entry kept here would be stale forever — this is the level-triggered idiom applied to the
@@ -417,9 +740,8 @@ function dropAbsent<V extends { workflow: string }>(
417
740
  * A settled run still counts as selectable — a reader watching a run to its end keeps watching it.
418
741
  */
419
742
  function select(store: Store): Store {
420
- if (store.selectedRunId && (store.runs.has(store.selectedRunId) || store.settled.has(store.selectedRunId))) {
421
- return store;
422
- }
743
+ const id = store.selectedRunId;
744
+ if (id && (store.runs.has(id) || store.settled.has(id) || store.opened.has(id) || inHistory(store, id))) return store;
423
745
  const next = store.runs.keys().next();
424
746
  return { ...store, selectedRunId: next.done ? null : next.value };
425
747
  }
@@ -427,24 +749,58 @@ function select(store: Store): Store {
427
749
  /** The run the page is currently showing, live or settled — whichever half still holds it. */
428
750
  export function selectedRun(store: Store): ObservedRun | null {
429
751
  if (store.selectedRunId === null) return null;
430
- return store.runs.get(store.selectedRunId) ?? store.settled.get(store.selectedRunId) ?? null;
752
+ const id = store.selectedRunId;
753
+ return (
754
+ store.runs.get(id) ?? store.settled.get(id) ?? (inHistory(store, id) ? store.opened.get(id) : undefined) ?? null
755
+ );
431
756
  }
432
757
 
433
- /** The selected workflow's run list as the rail draws it: live runs first, then what we watched
434
- * leave (newest first). One ordered list keeps the renderer from having to know there are two
435
- * maps behind it. */
436
- export function runList(store: Store): Array<{ run: ObservedRun; settled: boolean }> {
758
+ /** A run row of the rail, from a run the page holds. */
759
+ function railRun(run: ObservedRun, settled: boolean): RailRun {
760
+ return {
761
+ runId: run.runId,
762
+ status: run.status,
763
+ settled,
764
+ ...(run.dispatchedBy ? { dispatchedBy: run.dispatchedBy } : {}),
765
+ };
766
+ }
767
+
768
+ /** The selected workflow's run list as the rail draws it: live runs first, then the ended runs —
769
+ * those this page watched end (newest first), then those `GET /runs` lists that the page did not
770
+ * watch (ADR-0032). One ordered list keeps the renderer from having to know there are three
771
+ * sources behind it. An ended row has the listing's status and reason when the listing has it. */
772
+ export function runList(store: Store): RailRun[] {
773
+ const listed = new Map(listedEnded(store).map((r) => [r.runId, r]));
774
+ const ended = (run: ObservedRun): RailRun => {
775
+ const summary = listed.get(run.runId);
776
+ return summary ? summaryRow(summary, run.dispatchedBy) : railRun(run, true);
777
+ };
778
+ const watched = [...store.settled.values()].reverse();
779
+ const seen = new Set(watched.map((r) => r.runId));
437
780
  return [
438
- ...[...store.runs.values()].map((run) => ({ run, settled: false })),
439
- ...[...store.settled.values()].reverse().map((run) => ({ run, settled: true })),
781
+ ...[...store.runs.values()].map((run) => railRun(run, false)),
782
+ ...watched.filter((run) => !store.runs.has(run.runId)).map(ended),
783
+ ...[...listed.values()].filter((r) => !seen.has(r.runId) && !store.runs.has(r.runId)).map((r) => summaryRow(r)),
440
784
  ];
441
785
  }
442
786
 
443
- /** One workflow's rail rows: the feed's view for the selection, the REST snapshot for the rest.
444
- * Same shape either way, so the painter draws a run row without knowing which side fed it. */
445
- export function fleetRuns(store: Store, workflow: string): Array<{ run: ObservedRun; settled: boolean }> {
787
+ /** An ended run's rail row, from its `GET /runs` summary. */
788
+ function summaryRow(summary: RunSummaryView, dispatchedBy = summary.dispatchedBy): RailRun {
789
+ return {
790
+ runId: summary.runId,
791
+ status: summary.status,
792
+ settled: true,
793
+ ...(summary.reason !== undefined ? { reason: summary.reason } : {}),
794
+ ...(dispatchedBy ? { dispatchedBy } : {}),
795
+ };
796
+ }
797
+
798
+ /** One workflow's rail rows: the feed's view (and the ended runs) for the selection, the REST
799
+ * snapshot for the rest. Same shape either way, so the painter draws a run row without knowing
800
+ * which side fed it. */
801
+ export function fleetRuns(store: Store, workflow: string): RailRun[] {
446
802
  if (workflow === store.workflow) return runList(store);
447
- return (store.fleet.get(workflow) ?? []).map((run) => ({ run, settled: false }));
803
+ return (store.fleet.get(workflow) ?? []).map((run) => railRun(run, false));
448
804
  }
449
805
 
450
806
  /**
@@ -495,26 +851,82 @@ export function fleetEntries(listing: readonly WorkflowListing[]): FleetEntry[]
495
851
 
496
852
  /**
497
853
  * Which Machine doc the center pane draws (ADR-0070), as a cache key and the route that serves it.
498
- * A run that binds a Chart draws its own Machine, the Chart compiled into the Open body, so its doc
499
- * is per run id; every other selection draws the Workflow's Machine, shared by all its runs. The
854
+ * A run that binds a Chart draws its own Machine, the Chart compiled into the Open body, and so does
855
+ * a run on an older Assembly or a run that is not live (`own`), so their doc is per run id; every
856
+ * other selection draws the
857
+ * Workflow's Machine, shared by all its runs. The
500
858
  * Workflow name rides the route as one encoded segment (`<wrapper>%2F<name>` for a named Chart).
501
859
  */
502
860
  export function machineDocSource(
503
861
  workflow: string,
504
862
  run: Pick<ObservedRun, "runId" | "bound" | "chartHashes"> | null,
863
+ own = false,
864
+ current: string | null = null,
505
865
  ): { key: string; path: string } {
506
866
  const base = `/workflows/${encodeURIComponent(workflow)}`;
507
- if (run?.bound === true) {
867
+ // A run on an older Assembly, or an ended run, is drawn on its own Machine too (ADR-0069): the
868
+ // current Machine's states are not the states it moves through, and an ended run's status binds
869
+ // no Chart any more.
870
+ if (run && (run.bound === true || own)) {
508
871
  // The run id and the Charts it binds now: a second Chart in the run's life is a new doc.
509
872
  const charts = run.chartHashes?.length ? `:${run.chartHashes.join(",")}` : "";
510
873
  return { key: `run:${run.runId}${charts}`, path: `${base}/runs/${encodeURIComponent(run.runId)}/machine` };
511
874
  }
512
- return { key: `workflow:${workflow}`, path: `${base}/machine` };
875
+ // The current Assembly is in the key: after a publish, the Workflow's Machine is a new doc.
876
+ return { key: `workflow:${workflow}@${current ?? ""}`, path: `${base}/machine` };
877
+ }
878
+
879
+ /** The Assembly hash that `runId` holds, when it is not the current one (ADR-0069): from
880
+ * `GET /assembly` for a live run, and from the `GET /runs` summary for an ended one. Null when the
881
+ * run is on the current Assembly, or the page does not know the run's Assembly. */
882
+ export function olderAssembly(store: Store, runId: string): string | null {
883
+ const current = store.assembly.current;
884
+ if (current === null) return null;
885
+ const hash = heldAssembly(store, runId);
886
+ return hash !== undefined && hash !== current ? hash : null;
887
+ }
888
+
889
+ /** The Assembly hash that `runId` holds, as far as the page knows: from `GET /assembly` (kept for a
890
+ * run this page watched end), else from the `GET /runs` summary. */
891
+ export function heldAssembly(store: Store, runId: string): string | undefined {
892
+ return store.assembly.held.get(runId) ?? listedEnded(store).find((r) => r.runId === runId)?.assembly;
893
+ }
894
+
895
+ /** The mark of a run on an older Assembly, as the rail and the Machine pane show it (ADR-0032):
896
+ * "older Assembly · <short hash>". Null for a run on the current one. */
897
+ export function olderAssemblyMark(store: Store, runId: string): string | null {
898
+ const hash = olderAssembly(store, runId);
899
+ return hash === null ? null : `older Assembly · ${hash.slice(0, 8)}`;
900
+ }
901
+
902
+ /** Why the SELECTED run is `drifted` (ADR-0030): it has no Machine to draw, so the pane shows this
903
+ * in place of the graph. Null for every other run. */
904
+ export function selectedRunDrift(store: Store): string | null {
905
+ const id = store.selectedRunId;
906
+ if (id === null || store.runs.has(id)) return null;
907
+ const summary = listedEnded(store).find((r) => r.runId === id);
908
+ const status = summary?.status ?? store.settled.get(id)?.status ?? store.opened.get(id)?.status;
909
+ if (status !== "drifted") return null;
910
+ return summary?.reason ?? "this run's Machine cannot be read";
911
+ }
912
+
913
+ /** The doc that the center pane draws for the selection: the selected run's own Machine when it
914
+ * binds a Chart or is on an older Assembly, else the Workflow's (ADR-0069, ADR-0070). Null with no
915
+ * selected Workflow. */
916
+ export function selectedDocSource(store: Store): { key: string; path: string } | null {
917
+ if (!store.workflow) return null;
918
+ const run = selectedRun(store);
919
+ const id = store.selectedRunId;
920
+ // A run that is not live is drawn on the Machine its terminal write kept (ADR-0069): its status
921
+ // binds no Chart any more, and its Assembly can be gone. So is a run on an older Assembly.
922
+ const own = id !== null && (!store.runs.has(id) || olderAssembly(store, id) !== null);
923
+ const subject = run ?? (own && id !== null ? { runId: id } : null);
924
+ return machineDocSource(store.workflow, subject, own, store.assembly.current);
513
925
  }
514
926
 
515
927
  /** The fleet row's badge for a dispatched run (ADR-0071): `↳ <run8>`, the caller's run id cut as
516
928
  * the row cuts its own. Null for a run nobody dispatched. */
517
- export function dispatchBadge(run: ObservedRun): string | null {
929
+ export function dispatchBadge(run: ObservedRun | RailRun): string | null {
518
930
  const by = run.dispatchedBy;
519
931
  return by ? `↳ ${by.run.slice(0, 8)}` : null;
520
932
  }
@@ -584,8 +996,8 @@ export function selectedRunFailure(store: Store): string | null {
584
996
  return store.failures.get(store.selectedRunId) ?? null;
585
997
  }
586
998
 
587
- /** The SELECTED run's outstanding picks, each with since when it is held (ADR-0024) — empty in
588
- * observer mode. */
999
+ /** The SELECTED run's outstanding picks, each with since when it is outstanding (ADR-0024) — empty
1000
+ * in observer mode. */
589
1001
  export function selectedRunHeld(store: Store): HeldLine[] {
590
1002
  if (store.selectedRunId === null) return [];
591
1003
  return store.waiting.get(store.selectedRunId)?.held ?? [];
@@ -628,9 +1040,144 @@ export function docSeats(doc: SeatedBody | null): { seats: Seat[]; warnings: str
628
1040
  }
629
1041
 
630
1042
  /** What the nav badge counts: every open gate everywhere — attention is global even when the
631
- * inbox view is filtered. */
1043
+ * inbox view is filtered. Only gates that the Caller may answer are in the inbox, so the badge
1044
+ * counts only those. */
632
1045
  export function gateCount(store: Store): number {
633
1046
  let n = 0;
634
1047
  for (const entry of store.gates.values()) n += entry.gates.length;
635
1048
  return n;
636
1049
  }
1050
+
1051
+ // ---- The Caller (ADR-0032, ADR-0078) ------------------------------------------------------------
1052
+
1053
+ /** Whether a grant opens `workflow`: for `answer`, at least one Gate of it (ADR-0078). A Chart
1054
+ * wrapper's name opens each of its named Charts (`plan` opens `plan/weekly`), because a named Chart
1055
+ * runs its wrapper's Machine (ADR-0070). The same rule as `grantOpens` in tokens.ts, which is what
1056
+ * the server checks. The server lists only the Gates of a run that the Caller may answer, so the
1057
+ * Console does not check the Gate's name. */
1058
+ function grantOpens(grant: AnswerGrant | undefined, workflow: string): boolean {
1059
+ if (grant === undefined) return false;
1060
+ if (grant === "all") return true;
1061
+ const cut = workflow.indexOf("/");
1062
+ return grant.some((item) => {
1063
+ const name = typeof item === "string" ? item : item.workflow;
1064
+ return name === workflow || (cut > 0 && name === workflow.slice(0, cut));
1065
+ });
1066
+ }
1067
+
1068
+ /** One item of a grant as the nav's title says it: a Workflow name, or `deploy (approve, ship)`
1069
+ * for an item that names single Gates. */
1070
+ function grantItem(item: string | { workflow: string; gates: readonly string[] }): string {
1071
+ return typeof item === "string" ? item : `${item.workflow} (${item.gates.join(", ")})`;
1072
+ }
1073
+
1074
+ /**
1075
+ * Whether the page's Caller has `scope` for `workflow` — what decides that a control renders
1076
+ * (ADR-0032): the start button with `start`, the Gate cards with `answer`, cancel with `cancel`, a
1077
+ * run's full state with `read`, the Steering box with `steer`. False with no live credential. With
1078
+ * no `workflow`, whether the scope opens at least one Workflow. A hidden control is so that a
1079
+ * person does not meet a 403, never a guard: the server checks each request.
1080
+ */
1081
+ export function may(store: Store, scope: ScopeName, workflow?: string): boolean {
1082
+ if (store.token !== "live" || !store.caller) return false;
1083
+ const grant = store.caller.may[scope];
1084
+ if (workflow === undefined) return grant !== undefined && (grant === "all" || grant.length > 0);
1085
+ return grantOpens(grant, workflow);
1086
+ }
1087
+
1088
+ /** Who the page acts as, as the nav shows it (ADR-0032): the name that jr2 shows (a JWT's `email`),
1089
+ * else `<via>/<name>`, and the party it says it acts for. Null with no live credential. */
1090
+ export function callerLabel(store: Store): string | null {
1091
+ const caller = store.token === "live" ? store.caller : null;
1092
+ if (!caller) return null;
1093
+ const who = caller.shown ?? `${caller.via}/${caller.name}`;
1094
+ return caller.onBehalfOf !== undefined ? `${who} for ${caller.onBehalfOf}` : who;
1095
+ }
1096
+
1097
+ /** The scopes of the page's Caller, one line for the nav's title: `start: all · answer: a, b`. */
1098
+ export function callerScopes(caller: CallerView | null): string {
1099
+ if (!caller) return "";
1100
+ const lines = (["start", "answer", "cancel", "read", "steer"] as const).flatMap((scope) => {
1101
+ const grant = caller.may[scope];
1102
+ if (grant === undefined || (grant !== "all" && grant.length === 0)) return [];
1103
+ return [`${scope}: ${grant === "all" ? "all" : grant.map(grantItem).join(", ")}`];
1104
+ });
1105
+ return lines.length ? lines.join(" · ") : "no scope";
1106
+ }
1107
+
1108
+ /** The SELECTED run's starter as the Machine pane's strip says it (ADR-0078): `started by
1109
+ * token/ci`, `started by run <run8> (<slot>)`, `started by jr2 (boot)`. Null when the page does
1110
+ * not know it: no live credential with `read`, or a run from an older Orchestrator. */
1111
+ export function selectedRunStarter(store: Store): string | null {
1112
+ if (store.selectedRunId === null) return null;
1113
+ const by = store.starters.get(store.selectedRunId);
1114
+ if (!by) return null;
1115
+ if (by.kind === "run") return `started by run ${by.run.slice(0, 8)} (${by.slot})`;
1116
+ if (by.kind === "jr2") return `started by jr2 (${by.why})`;
1117
+ const shown = by.shown !== undefined ? ` (${by.shown})` : "";
1118
+ const behalf = by.onBehalfOf !== undefined ? ` on behalf of ${by.onBehalfOf}` : "";
1119
+ return `started by ${by.via}/${by.name}${shown}${behalf}`;
1120
+ }
1121
+
1122
+ // ---- The kept conversations (ADR-0023, ADR-0032) ------------------------------------------------
1123
+
1124
+ /** One tab of the bottom drawer: an Agent slot of the selected run, a dot when its Turn is open,
1125
+ * and whether a Steering message can go to it. */
1126
+ export type SlotTab = { slot: string; open: boolean; steerable: boolean };
1127
+
1128
+ /** A conversation's slot: the ledger's slot, else the Agent's name. */
1129
+ function slotOf(row: ConversationRow): string {
1130
+ return row.slot ?? row.agent;
1131
+ }
1132
+
1133
+ /** The bottom drawer's tabs: each Agent slot of the selected run, in the order its first
1134
+ * conversation began, then the slots that `GET /runs/:id/agents` names and no conversation has
1135
+ * yet. */
1136
+ export function slotTabs(store: Store): SlotTab[] {
1137
+ const talk = store.talk;
1138
+ if (!talk) return [];
1139
+ const tabs = new Map<string, SlotTab>();
1140
+ for (const row of talk.rows) {
1141
+ const slot = slotOf(row);
1142
+ const tab = tabs.get(slot) ?? { slot, open: false, steerable: false };
1143
+ tab.open ||= row.open;
1144
+ tabs.set(slot, tab);
1145
+ }
1146
+ for (const line of talk.agents) {
1147
+ const tab = tabs.get(line.agent) ?? { slot: line.agent, open: false, steerable: false };
1148
+ tab.open ||= line.open;
1149
+ tab.steerable = line.steerable;
1150
+ tabs.set(line.agent, tab);
1151
+ }
1152
+ return [...tabs.values()];
1153
+ }
1154
+
1155
+ /** The slot that the bottom drawer shows: the one the person chose while the run has it, else the
1156
+ * first slot with an open Turn, else the first slot. Null when the run has none. */
1157
+ export function activeSlot(store: Store): string | null {
1158
+ const tabs = slotTabs(store);
1159
+ const chosen = store.bottom.slot;
1160
+ if (chosen !== null && tabs.some((t) => t.slot === chosen)) return chosen;
1161
+ return (tabs.find((t) => t.open) ?? tabs[0])?.slot ?? null;
1162
+ }
1163
+
1164
+ /** One conversation of a slot as the drawer shows it: its row, the entries read so far, and how
1165
+ * its stream ended, if it did. */
1166
+ export type SlotConversation = {
1167
+ row: ConversationRow;
1168
+ entries: ConversationEntry[];
1169
+ ended?: { cut: boolean; swept?: string };
1170
+ };
1171
+
1172
+ /** The conversations of `slot`, the first first: a slot whose conversation began again (a new
1173
+ * conversation after a fault, ADR-0057) has more than one. */
1174
+ export function slotConversations(store: Store, slot: string | null): SlotConversation[] {
1175
+ const talk = store.talk;
1176
+ if (!talk || slot === null) return [];
1177
+ return talk.rows
1178
+ .filter((row) => slotOf(row) === slot)
1179
+ .map((row) => {
1180
+ const ended = talk.ended.get(row.instanceId);
1181
+ return { row, entries: talk.entries.get(row.instanceId) ?? [], ...(ended ? { ended } : {}) };
1182
+ });
1183
+ }