@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
@@ -0,0 +1,453 @@
1
+ // The kept conversations (ADR-0023, ADR-0065): what the Harness sends of each conversation while
2
+ // its Turn runs, kept in `conversations.db` beside `state.db` on the state volume, for as long as
3
+ // the run is kept. A second SQLite file, because its writes are larger and more frequent than a
4
+ // snapshot's, and its sweep frees pages: neither may slow the snapshot's synchronous write.
5
+ //
6
+ // conversations one row per conversation: its run, its Agent, the bytes kept, `cut`, `swept`
7
+ // entries one row per entry, keyed by (instance_id, seq): a batch sent again adds nothing
8
+ //
9
+ // The rows are plain JSON (`entry` is the entry as the Harness sent it, with the Agent from the
10
+ // ledger), so a person or a tool can query the file with SQLite alone. The file is made with
11
+ // `auto_vacuum = INCREMENTAL`, and each delete frees its pages, so the file gets smaller after a
12
+ // sweep. There is no compression now: when it comes, it is Node's own zstd on the entry text, with
13
+ // the queryable columns kept.
14
+ //
15
+ // The cap is per conversation: an entry that would take it past `capBytes` is not kept. One marker
16
+ // entry takes its place, and every later entry of that conversation is dropped. The cap keeps the
17
+ // start of the conversation, which tells why the Agent did what it did.
18
+
19
+ import { readdirSync, statfsSync, statSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import type { SnapshotStore } from "./snapshot-store.ts";
23
+
24
+ /** One entry of a kept conversation (ADR-0023): one body of the print, its label as `kind` and the
25
+ * rest as `text`. `seq` goes up for as long as the Instance ID is kept; a new record starts it at
26
+ * the clock (the Harness's keep.ts). */
27
+ export type ConversationEntry = {
28
+ id: string;
29
+ seq: number;
30
+ at: string;
31
+ agent: string;
32
+ kind: string;
33
+ text: string;
34
+ };
35
+
36
+ /** What an entry is, as the Harness sends it: the Agent comes from the ledger, not the body. */
37
+ export type SentEntry = Omit<ConversationEntry, "agent">;
38
+
39
+ /** One kept conversation, as `GET /runs/:runId/conversations` lists it (without `open`, which is the
40
+ * host's to say). */
41
+ export type KeptConversation = {
42
+ agent: string;
43
+ instanceId: string;
44
+ bytes: number;
45
+ cut: boolean;
46
+ /** Why the conversation's entries are gone while its run is kept: the space guard swept them. */
47
+ swept?: typeof SWEPT_NO_SPACE;
48
+ };
49
+
50
+ /** The one reason the guard sweeps a conversation (ADR-0065). */
51
+ export const SWEPT_NO_SPACE = "no space";
52
+
53
+ /** What a run says when the guard swept its conversations (ADR-0065). */
54
+ export const CONVERSATION_SWEPT = "conversation swept: no space" as const;
55
+
56
+ /** The cap of one kept conversation (ADR-0023). */
57
+ export const CONVERSATION_CAP_BYTES = 8 * 1024 * 1024;
58
+
59
+ /** The `kind` of the marker entry that a cut leaves (ADR-0023). */
60
+ export const CUT_KIND = "cut";
61
+
62
+ /** An append's outcome: how many entries are new, and whether the conversation is cut. */
63
+ export type AppendResult = { kept: number; cut: boolean };
64
+
65
+ type ConversationRow = {
66
+ run_id: string;
67
+ agent: string;
68
+ bytes: number;
69
+ cut: number;
70
+ swept: string | null;
71
+ };
72
+
73
+ /** The listener of one conversation's new entries (the SSE follow). */
74
+ type EntryListener = (entries: ConversationEntry[]) => void;
75
+
76
+ export class ConversationStore {
77
+ private readonly db: DatabaseSync;
78
+ private readonly capBytes: number;
79
+ private readonly listeners = new Map<string, Set<EntryListener>>();
80
+ /** Set by the guard: the volume is past its last bound, and no entry is kept (ADR-0065). */
81
+ #full = false;
82
+
83
+ constructor(path: string, opts: { capBytes?: number } = {}) {
84
+ this.db = new DatabaseSync(path);
85
+ this.capBytes = opts.capBytes ?? CONVERSATION_CAP_BYTES;
86
+ }
87
+
88
+ init(): void {
89
+ // Before the first table, or it has no effect: a file's vacuum mode is set when it is made.
90
+ this.db.exec(`PRAGMA auto_vacuum = INCREMENTAL`);
91
+ this.db.exec(`PRAGMA journal_mode = WAL`);
92
+ this.db.exec(`PRAGMA synchronous = NORMAL`);
93
+ this.db.exec(`
94
+ CREATE TABLE IF NOT EXISTS conversations (
95
+ instance_id TEXT PRIMARY KEY,
96
+ run_id TEXT NOT NULL,
97
+ agent TEXT NOT NULL,
98
+ bytes INTEGER NOT NULL DEFAULT 0,
99
+ cut INTEGER NOT NULL DEFAULT 0,
100
+ swept TEXT,
101
+ created_at TEXT NOT NULL,
102
+ updated_at TEXT NOT NULL
103
+ )
104
+ `);
105
+ this.db.exec(`CREATE INDEX IF NOT EXISTS conversations_run ON conversations (run_id, created_at)`);
106
+ this.db.exec(`
107
+ CREATE TABLE IF NOT EXISTS entries (
108
+ instance_id TEXT NOT NULL,
109
+ seq INTEGER NOT NULL,
110
+ id TEXT NOT NULL,
111
+ entry TEXT NOT NULL,
112
+ PRIMARY KEY (instance_id, seq)
113
+ ) WITHOUT ROWID
114
+ `);
115
+ // The entry id dedupes (ADR-0023): a batch sent again adds nothing, and an entry of a new
116
+ // record whose seq meets an old one (a pod on a node whose clock is behind) is still kept.
117
+ this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS entries_id ON entries (instance_id, id)`);
118
+ }
119
+
120
+ /** The volume is past the guard's last bound: no entry is kept, and each conversation that gets
121
+ * one more is cut with a marker, as at its cap (ADR-0065). */
122
+ get full(): boolean {
123
+ return this.#full;
124
+ }
125
+ set full(value: boolean) {
126
+ this.#full = value;
127
+ }
128
+
129
+ /**
130
+ * Keep a batch of one conversation's entries. An entry whose `(instanceId, seq)` is kept already
131
+ * adds nothing, so a batch sent again is harmless. An entry that would take the conversation past
132
+ * its cap, or that comes while the volume is full, is replaced by one marker, and the conversation
133
+ * is cut: no later entry is kept. A conversation that the guard swept keeps nothing more.
134
+ */
135
+ append(runId: string, instanceId: string, agent: string, sent: readonly SentEntry[]): AppendResult {
136
+ const now = new Date().toISOString();
137
+ const added: ConversationEntry[] = [];
138
+ let kept = 0;
139
+ this.db.exec("BEGIN IMMEDIATE");
140
+ let row: ConversationRow;
141
+ try {
142
+ const found = this.row(instanceId);
143
+ if (!found) {
144
+ this.db
145
+ .prepare(
146
+ `INSERT INTO conversations (instance_id, run_id, agent, bytes, cut, created_at, updated_at)
147
+ VALUES (?, ?, ?, 0, 0, ?, ?)`,
148
+ )
149
+ .run(instanceId, runId, agent, now, now);
150
+ }
151
+ row = found ?? { run_id: runId, agent, bytes: 0, cut: 0, swept: null };
152
+ if (row.cut || row.swept !== null) {
153
+ this.db.exec("COMMIT");
154
+ return { kept: 0, cut: row.cut === 1 };
155
+ }
156
+ const hasId = this.db.prepare(`SELECT 1 FROM entries WHERE instance_id = ? AND id = ?`);
157
+ const hasSeq = this.db.prepare(`SELECT 1 FROM entries WHERE instance_id = ? AND seq = ?`);
158
+ const last = this.db.prepare(`SELECT MAX(seq) AS seq FROM entries WHERE instance_id = ?`);
159
+ const insertRow = this.db.prepare(`INSERT INTO entries (instance_id, seq, id, entry) VALUES (?, ?, ?, ?)`);
160
+ for (const e of [...sent].sort((a, b) => a.seq - b.seq)) {
161
+ if (hasId.get(instanceId, e.id)) continue;
162
+ // Another entry has this seq: a new record whose clock is behind. Keep it after the last.
163
+ const seq = hasSeq.get(instanceId, e.seq)
164
+ ? ((last.get(instanceId) as { seq: number | null }).seq ?? 0) + 1
165
+ : e.seq;
166
+ const entry: ConversationEntry = { id: e.id, seq, at: e.at, agent, kind: e.kind, text: e.text };
167
+ const json = JSON.stringify(entry);
168
+ const size = Buffer.byteLength(json);
169
+ if (this.#full || row.bytes + size > this.capBytes) {
170
+ const marker: ConversationEntry = {
171
+ id: `${CUT_KIND}-${seq}`,
172
+ seq,
173
+ at: now,
174
+ agent,
175
+ kind: CUT_KIND,
176
+ text: this.#full
177
+ ? "the Orchestrator keeps no more of this conversation: its state volume is full (ADR-0065)"
178
+ : `the Orchestrator keeps no more of this conversation: it is at its cap of ${capText(this.capBytes)} (ADR-0023)`,
179
+ };
180
+ insertRow.run(instanceId, seq, marker.id, JSON.stringify(marker));
181
+ added.push(marker);
182
+ row.cut = 1;
183
+ break;
184
+ }
185
+ insertRow.run(instanceId, seq, e.id, json);
186
+ added.push(entry);
187
+ kept += 1;
188
+ row.bytes += size;
189
+ }
190
+ this.db
191
+ .prepare(`UPDATE conversations SET bytes = ?, cut = ?, updated_at = ? WHERE instance_id = ?`)
192
+ .run(row.bytes, row.cut, now, instanceId);
193
+ this.db.exec("COMMIT");
194
+ } catch (err) {
195
+ this.db.exec("ROLLBACK");
196
+ throw err;
197
+ }
198
+ if (added.length > 0) for (const listener of this.listeners.get(instanceId) ?? []) listener(added);
199
+ return { kept, cut: row.cut === 1 };
200
+ }
201
+
202
+ /** A run's kept conversations, the first kept first. */
203
+ list(runId: string): KeptConversation[] {
204
+ const rows = this.db
205
+ .prepare(
206
+ `SELECT instance_id, agent, bytes, cut, swept FROM conversations WHERE run_id = ?
207
+ ORDER BY created_at, instance_id`,
208
+ )
209
+ .all(runId) as Array<{ instance_id: string; agent: string; bytes: number; cut: number; swept: string | null }>;
210
+ return rows.map((r) => ({
211
+ agent: r.agent,
212
+ instanceId: r.instance_id,
213
+ bytes: r.bytes,
214
+ cut: r.cut === 1,
215
+ ...(r.swept !== null ? { swept: SWEPT_NO_SPACE } : {}),
216
+ }));
217
+ }
218
+
219
+ /** One kept conversation of a run, or undefined when the run has none of that id. */
220
+ conversation(runId: string, instanceId: string): KeptConversation | undefined {
221
+ return this.list(runId).find((c) => c.instanceId === instanceId);
222
+ }
223
+
224
+ /** The entries of a conversation after `after` (-1: from the first), in order, at most `limit`. */
225
+ entries(instanceId: string, after: number, limit: number): ConversationEntry[] {
226
+ const rows = this.db
227
+ .prepare(`SELECT entry FROM entries WHERE instance_id = ? AND seq > ? ORDER BY seq LIMIT ?`)
228
+ .all(instanceId, after, limit) as Array<{ entry: string }>;
229
+ return rows.map((r) => JSON.parse(r.entry) as ConversationEntry);
230
+ }
231
+
232
+ /** The runs, of `runIds`, whose conversations the guard swept (ADR-0065). */
233
+ sweptRuns(runIds: readonly string[]): Set<string> {
234
+ const swept = new Set<string>();
235
+ const query = this.db.prepare(`SELECT 1 FROM conversations WHERE run_id = ? AND swept IS NOT NULL LIMIT 1`);
236
+ for (const runId of runIds) if (query.get(runId)) swept.add(runId);
237
+ return swept;
238
+ }
239
+
240
+ /** Hear the new entries of one conversation. Returns the unsubscribe. */
241
+ follow(instanceId: string, listener: EntryListener): () => void {
242
+ let set = this.listeners.get(instanceId);
243
+ if (!set) this.listeners.set(instanceId, (set = new Set()));
244
+ set.add(listener);
245
+ return () => {
246
+ set.delete(listener);
247
+ if (set.size === 0 && this.listeners.get(instanceId) === set) this.listeners.delete(instanceId);
248
+ };
249
+ }
250
+
251
+ /** Delete the conversations of runs that the retention sweep deleted (ADR-0065), and free their
252
+ * pages. */
253
+ deleteRuns(runIds: readonly string[]): void {
254
+ if (runIds.length === 0) return;
255
+ this.db.exec("BEGIN IMMEDIATE");
256
+ try {
257
+ const entries = this.db.prepare(
258
+ `DELETE FROM entries WHERE instance_id IN (SELECT instance_id FROM conversations WHERE run_id = ?)`,
259
+ );
260
+ const rows = this.db.prepare(`DELETE FROM conversations WHERE run_id = ?`);
261
+ for (const runId of runIds) {
262
+ entries.run(runId);
263
+ rows.run(runId);
264
+ }
265
+ this.db.exec("COMMIT");
266
+ } catch (err) {
267
+ this.db.exec("ROLLBACK");
268
+ throw err;
269
+ }
270
+ this.vacuum();
271
+ }
272
+
273
+ /** Each run that has a kept conversation, swept or not. */
274
+ runIds(): string[] {
275
+ const rows = this.db.prepare(`SELECT DISTINCT run_id FROM conversations ORDER BY run_id`).all() as Array<{
276
+ run_id: string;
277
+ }>;
278
+ return rows.map((r) => r.run_id);
279
+ }
280
+
281
+ /** The runs that have conversations with entries kept, the run whose first conversation is
282
+ * oldest first: the order in which the guard sweeps (ADR-0065). */
283
+ runsOldestFirst(): string[] {
284
+ const rows = this.db
285
+ .prepare(`SELECT run_id FROM conversations WHERE swept IS NULL GROUP BY run_id ORDER BY MIN(created_at), run_id`)
286
+ .all() as Array<{ run_id: string }>;
287
+ return rows.map((r) => r.run_id);
288
+ }
289
+
290
+ /** Delete the entries of a run's conversations for space (ADR-0065). The rows stay, marked
291
+ * swept, so the list says why the conversation is gone. */
292
+ sweepForSpace(runId: string): void {
293
+ this.db.exec("BEGIN IMMEDIATE");
294
+ try {
295
+ this.db
296
+ .prepare(`DELETE FROM entries WHERE instance_id IN (SELECT instance_id FROM conversations WHERE run_id = ?)`)
297
+ .run(runId);
298
+ this.db
299
+ .prepare(`UPDATE conversations SET swept = ?, bytes = 0, updated_at = ? WHERE run_id = ?`)
300
+ .run(SWEPT_NO_SPACE, new Date().toISOString(), runId);
301
+ this.db.exec("COMMIT");
302
+ } catch (err) {
303
+ this.db.exec("ROLLBACK");
304
+ throw err;
305
+ }
306
+ this.vacuum();
307
+ }
308
+
309
+ /** Give the freed pages back to the file system: the file, and the WAL beside it, get smaller. */
310
+ private vacuum(): void {
311
+ this.db.exec(`PRAGMA incremental_vacuum`);
312
+ this.db.exec(`PRAGMA wal_checkpoint(TRUNCATE)`);
313
+ }
314
+
315
+ private row(instanceId: string): ConversationRow | undefined {
316
+ return this.db
317
+ .prepare(`SELECT run_id, agent, bytes, cut, swept FROM conversations WHERE instance_id = ?`)
318
+ .get(instanceId) as ConversationRow | undefined;
319
+ }
320
+
321
+ close(): void {
322
+ this.listeners.clear();
323
+ this.db.close();
324
+ }
325
+ }
326
+
327
+ function capText(bytes: number): string {
328
+ return bytes % (1024 * 1024) === 0 ? `${bytes / (1024 * 1024)} MiB` : `${bytes} bytes`;
329
+ }
330
+
331
+ // ---- The space guard (ADR-0065) ------------------------------------------------------------------
332
+
333
+ /** Above this part of the volume, the guard sweeps the conversations of ended runs. */
334
+ export const GUARD_SWEEP_AT = 0.8;
335
+ /** Above this part, with no conversation of an ended run left, no entry is kept. */
336
+ export const GUARD_FULL_AT = 0.95;
337
+
338
+ /** What the guard reads and does. Injected, so a test sets the sizes. */
339
+ export type SpaceGuardPort = {
340
+ conversations: ConversationStore;
341
+ /** The bytes of the files on the state volume: both databases with their `-wal` files, the
342
+ * Assemblies and the Charts. */
343
+ used(): number;
344
+ /** The size of the state volume: the smaller of the claim and what `statfs` says. */
345
+ capacity(): number;
346
+ /** Whether a run is live. A live run's conversations are never swept for space. */
347
+ isLive(runId: string): Promise<boolean>;
348
+ /** Hears each run whose conversations went. */
349
+ onSwept?: (runId: string) => void;
350
+ };
351
+
352
+ /**
353
+ * One pass of the space guard (ADR-0065). Above 80% of the volume, it deletes the kept
354
+ * conversations of ended runs, the oldest run first, until the volume is below 80%. A live run's
355
+ * conversations are never swept. Above 95% with no ended run's conversation left, it sets the
356
+ * store full, and each open conversation is cut at its next entry; below that, the store keeps
357
+ * entries again. Answers the runs whose conversations went.
358
+ */
359
+ export async function guardSpace(port: SpaceGuardPort): Promise<string[]> {
360
+ const capacity = port.capacity();
361
+ if (!(capacity > 0)) return [];
362
+ const swept: string[] = [];
363
+ let used = port.used();
364
+ if (used > GUARD_SWEEP_AT * capacity) {
365
+ for (const runId of port.conversations.runsOldestFirst()) {
366
+ if (used < GUARD_SWEEP_AT * capacity) break;
367
+ if (await port.isLive(runId)) continue;
368
+ port.conversations.sweepForSpace(runId);
369
+ swept.push(runId);
370
+ port.onSwept?.(runId);
371
+ used = port.used();
372
+ }
373
+ }
374
+ port.conversations.full = used > GUARD_FULL_AT * capacity;
375
+ return swept;
376
+ }
377
+
378
+ /** The bytes of every file under `dir`, at any depth. A file that goes while it is read counts 0. */
379
+ export function bytesUnder(dir: string): number {
380
+ let total = 0;
381
+ let names: import("node:fs").Dirent[];
382
+ try {
383
+ names = readdirSync(dir, { withFileTypes: true });
384
+ } catch {
385
+ return 0;
386
+ }
387
+ for (const entry of names) {
388
+ const path = join(dir, entry.name);
389
+ if (entry.isDirectory()) total += bytesUnder(path);
390
+ else if (entry.isFile()) {
391
+ try {
392
+ total += statSync(path).size;
393
+ } catch {
394
+ // Gone between the read and the stat.
395
+ }
396
+ }
397
+ }
398
+ return total;
399
+ }
400
+
401
+ /** The size of the file system that holds `dir` (`statfs`), or Infinity when it cannot be read. */
402
+ export function fileSystemBytes(dir: string): number {
403
+ try {
404
+ const fs = statfsSync(dir);
405
+ return fs.blocks * fs.bsize;
406
+ } catch {
407
+ return Number.POSITIVE_INFINITY;
408
+ }
409
+ }
410
+
411
+ const QUANTITY = /^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$/;
412
+ const UNIT: Record<string, number> = {
413
+ "": 1,
414
+ Ki: 2 ** 10,
415
+ Mi: 2 ** 20,
416
+ Gi: 2 ** 30,
417
+ Ti: 2 ** 40,
418
+ Pi: 2 ** 50,
419
+ Ei: 2 ** 60,
420
+ k: 1e3,
421
+ M: 1e6,
422
+ G: 1e9,
423
+ T: 1e12,
424
+ P: 1e15,
425
+ E: 1e18,
426
+ };
427
+
428
+ /** A Kubernetes storage quantity (`"5Gi"`) in bytes, or undefined for one that does not parse. */
429
+ export function quantityBytes(quantity: string): number | undefined {
430
+ const m = QUANTITY.exec(quantity);
431
+ if (!m) return undefined;
432
+ return Number.parseFloat(m[1]!) * UNIT[m[2] ?? ""]!;
433
+ }
434
+
435
+ /**
436
+ * Delete the kept conversations whose run has no row in the run store, and answer those run ids.
437
+ * Such a conversation stays when a crash comes between the store's sweep and `deleteRuns`, or when
438
+ * a batch is placed just before the sweep deletes its row. The retention sweep never names that
439
+ * run again, so each sweep calls this (ADR-0065).
440
+ */
441
+ export async function deleteOrphanConversations(
442
+ conversations: ConversationStore,
443
+ runs: Pick<SnapshotStore, "findIdsByPrefix">,
444
+ ): Promise<string[]> {
445
+ const orphans: string[] = [];
446
+ for (const runId of conversations.runIds()) {
447
+ // The ids that share a prefix come sorted, so the run's own id, when it exists, is first.
448
+ const [first] = await runs.findIdsByPrefix(runId, 1);
449
+ if (first !== runId) orphans.push(runId);
450
+ }
451
+ conversations.deleteRuns(orphans);
452
+ return orphans;
453
+ }
package/src/custodian.ts CHANGED
@@ -467,8 +467,9 @@ function isIpLiteral(host: string): boolean {
467
467
  * converge by `jr2 up`, identical for every Harness pod of the Instance.
468
468
  *
469
469
  * control /run/jr2/custodian/control.sock
470
- * the four agent routes — the Menu, a pick, a delivery's poll and a
471
- * notice's acknowledgment — plain HTTP, to the Orchestrator's Service.
470
+ * the five agent routes — the Menu, a pick, a delivery's poll, a
471
+ * notice's acknowledgment and a batch of the kept conversation — plain
472
+ * HTTP, to the Orchestrator's Service.
472
473
  * They swap the control Stand-in for the control token. A Unix socket
473
474
  * in a folder that no confined process can write, so no process in
474
475
  * the domain can take this listener's place (ADR-0013, ADR-0076)
@@ -551,6 +552,11 @@ export function custodianBootstrap(manifest: HeldManifest, orchestrator: Orchest
551
552
  route("notices", "POST", "/agents/[^/]+/notices", {
552
553
  retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
553
554
  }),
555
+ // A batch of the conversation's entries (ADR-0023): the Orchestrator keeps each entry once,
556
+ // so a batch sent twice changes nothing, and any reset is retried.
557
+ route("conversation", "POST", "/agents/[^/]+/conversation", {
558
+ retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
559
+ }),
554
560
  ]),
555
561
  // The ask (ADR-0053), on loopback: `jr2-upload-pack` runs in the domain, and the fetch Stand-in
556
562
  // is public, so a process that takes this port gets nothing secret. Like a pick, it retries only