@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
@@ -5,7 +5,8 @@
5
5
  // (WAL, `synchronous=NORMAL`), and the write stays synchronous so the row is on disk before the
6
6
  // Machine's next side effect runs. `markLost` records a run whose live state could not be
7
7
  // re-hydrated (e.g. its flue handle is gone), keeping any snapshot the row had; a non-live row
8
- // lives a week, then `sweep` deletes it.
8
+ // lives for `runs.retain`, then `sweep` deletes it. `ended` lists the non-live rows for
9
+ // `GET /runs` off columns that `save` copies out of the blob, so a listing parses no snapshot.
9
10
 
10
11
  import { DatabaseSync } from "node:sqlite";
11
12
  import type { StoredSnapshot } from "./durability.ts";
@@ -14,7 +15,7 @@ export interface SnapshotStore {
14
15
  init(): Promise<void>;
15
16
  load(runId: string): Promise<StoredSnapshot | null>;
16
17
  /** The `live` runs, in insertion order — what `restore()` reads (ADR-0065). Terminal rows are
17
- * filtered by the store, not the host, so a boot never parses a week of finished runs. */
18
+ * filtered by the store, not the host, so a boot never parses the kept history. */
18
19
  live(): Promise<StoredSnapshot[]>;
19
20
  /**
20
21
  * The `live` runs by the Assembly hash their blob names (ADR-0069), each list in insertion order:
@@ -25,12 +26,13 @@ export interface SnapshotStore {
25
26
  */
26
27
  liveAssemblies(): Promise<Map<string, string[]>>;
27
28
  /**
28
- * Delete every non-live row last written before `olderThan`, and answer how many (ADR-0065). A
29
+ * Delete every non-live row last written before `olderThan`, and answer their run ids, so that
30
+ * the run's kept conversations go with it (ADR-0065, ADR-0023). A
29
31
  * `live` row is never swept, whatever its age: a run parked on a Gate for a month is restored.
30
32
  * A swept id is gone as if it never ran — `read()` answers undefined, and its abbreviations are
31
33
  * free again (ADR-0009).
32
34
  */
33
- sweep(olderThan: Date): Promise<number>;
35
+ sweep(olderThan: Date): Promise<string[]>;
34
36
  /**
35
37
  * Persisted run ids sharing a prefix, sorted, capped at `limit` — the store half of abbreviated
36
38
  * run ids (ADR-0009). Includes `lost` rows: the ambiguity set is the ids that EXIST, not the ones
@@ -39,12 +41,27 @@ export interface SnapshotStore {
39
41
  */
40
42
  findIdsByPrefix(prefix: string, limit: number): Promise<string[]>;
41
43
  /**
42
- * The run whose blob carries the idempotency key `effect` (`dispatchedBy.effect`, ADR-0071), in
43
- * any status, or undefined: what answers a re-sent dispatch with the run it already started. A
44
- * row `markLost` wrote with no save before it has no snapshot, and so no key; a swept row is gone
45
- * with its key.
44
+ * The run of `workflow` whose blob carries the idempotency key `key` (`dispatchedBy.effect`,
45
+ * ADR-0071), in any status, or undefined: what answers a re-sent dispatch with the run it already
46
+ * started. The key is scoped to the callee Workflow, so the same key in two Workflows is two runs.
47
+ * A row `markLost` wrote with no save before it has no snapshot, and so no key; a swept row is
48
+ * gone with its key.
46
49
  */
47
- findByDispatch(effect: string): Promise<string | undefined>;
50
+ findByDispatch(workflow: string, key: string): Promise<string | undefined>;
51
+ /**
52
+ * The non-live runs that `query` selects, newest start first, at most `limit` (ADR-0009): what
53
+ * `GET /runs` lists of the history. Read off the summary columns, never by parsing a snapshot. A
54
+ * row with no snapshot (`markLost` with no save before it) is not listed: it names no Workflow,
55
+ * and `read()` does not know it either.
56
+ */
57
+ ended(query: EndedQuery): Promise<EndedRow[]>;
58
+ /**
59
+ * The `live` rows that `query` selects (its `statuses` are not read), in the order of `ended`, at
60
+ * most `limit`: what `GET /runs` lists of a live run that the registry does not hold — a restore
61
+ * that a boot deferred (ADR-0069) or that failed and waits for the next boot (ADR-0030). The host
62
+ * leaves out the ids it hosts. Its `endedAt` is the row's last write.
63
+ */
64
+ liveRows(query: EndedQuery): Promise<EndedRow[]>;
48
65
  save(runId: string, snapshot: unknown, status?: string): Promise<void>;
49
66
  /**
50
67
  * Record a run whose live state could not be re-hydrated. An upsert: a row that was saved KEEPS
@@ -65,31 +82,212 @@ export interface SnapshotStore {
65
82
  * existed. It keeps the row's idempotency key too (ADR-0071).
66
83
  */
67
84
  markDrifted(runId: string, reason: string): Promise<void>;
85
+ /**
86
+ * Claim a Hook's delivery for the fan-in (ADR-0011): when the hook log holds no delivery of this
87
+ * Hook with this id OR this digest, it writes a `pending` line and answers the line's id, which
88
+ * `writeHookLine` finishes; else it answers `undefined`, a repeat, and writes nothing. The check and the write are one synchronous step, so of two deliveries
89
+ * that come at once, one claims. Only the lines of a claimed delivery count: a handshake, a
90
+ * `payload-refused` line and an `unreadable` line never make a repeat, so a payload that the parse
91
+ * refused is parsed again when the service sends it again.
92
+ */
93
+ claimHookDelivery(hook: string, delivery: string, digest: string): number | undefined;
94
+ /** Write one line of the hook log (ADR-0011), or finish the `pending` line of `claimed`. */
95
+ writeHookLine(line: HookLogLine, claimed?: number): void;
96
+ /** The hook log of one Hook, newest first, at most `limit` lines. */
97
+ hookLog(hook: string, limit: number): HookLogLine[];
98
+ /**
99
+ * The head of each standing Workflow (ADR-0017): its newest run, live or not, and whether it is
100
+ * paused. Synchronous, as `writeStanding` is, so a hand-over's start, its end and the head that
101
+ * names the new run are one step.
102
+ */
103
+ standingHeads(): StandingHead[];
104
+ /** Write the head of one standing Workflow. The sweep never deletes the run a head names:
105
+ * a paused Pool keeps its held items in that run's blob for as long as it is paused. */
106
+ writeStanding(head: StandingHead): void;
107
+ /**
108
+ * Keep the Machine an ended run ran on (ADR-0069), as structure with its Charts compiled, under
109
+ * `key`: a digest of the kept doc. One copy for each key, so a thousand runs of one Workflow on
110
+ * one Assembly keep one copy; the run's row holds only the key. Synchronous, so the terminal save and this are one step.
111
+ */
112
+ keepMachine(runId: string, key: string, doc: unknown): void;
113
+ /** The Machine that {@link keepMachine} kept for an ended run, with the run's Workflow name (one
114
+ * copy can serve runs of two names: a Chart wrapper and its named Chart), or undefined. */
115
+ keptMachine(runId: string): { workflow: string; doc: unknown } | undefined;
68
116
  close(): Promise<void>;
69
117
  }
70
118
 
119
+ /**
120
+ * The head of a standing Workflow (ADR-0017): `run` is its newest run, `since` when that run
121
+ * started, `streak` how many runs in a row ended `error`, `failed` or at the bound with no run
122
+ * started by an item that the run took itself, and `paused` why jr2 starts no run of it: a Caller
123
+ * cancelled the run (`cancel`), or, after 3 hand-overs from such runs, the next one ended `error`
124
+ * or `failed` (`errors`) or at its bound (`bound`).
125
+ */
126
+ export type StandingHead = {
127
+ workflow: string;
128
+ run: string;
129
+ since: string;
130
+ streak: number;
131
+ paused?: "cancel" | "errors" | "bound";
132
+ };
133
+
134
+ /**
135
+ * One line of the hook log (ADR-0011): what the door did with one delivery of one Hook, and when.
136
+ * `kind` says which line it is: `delivered` (the fan-in ran; `outcome` holds what it did), `seen` (a
137
+ * repeat), `hold-full` (the fan-in ran and every Source was full, ADR-0017: the line does not claim
138
+ * the id, so a send again is heard), `no-live-run` (the fan-in ran, and a standing Pool that hears the
139
+ * kind and key has no live run and is not paused: 503, and the line does not claim the id either),
140
+ * `handshake`, `payload-refused` (with zod's `issues`), `unreadable` (the kind could not
141
+ * read the request: `error`) or `follow-up` (the follow-up post of an answer that a person reads
142
+ * failed, or went to a url outside the kind's `posts`: `post` and `error`; its `status` is 0). A
143
+ * `reply` that threw is in the `replyErrors` of its delivery's `detail`. `status` is the status that
144
+ * the rules give, which is the status of the answer unless a person reads the answer: that answer is
145
+ * 200 (ADR-0011).
146
+ */
147
+ export type HookLogLine = {
148
+ hook: string;
149
+ at: string;
150
+ kind:
151
+ | "pending"
152
+ | "delivered"
153
+ | "seen"
154
+ | "hold-full"
155
+ | "no-live-run"
156
+ | "handshake"
157
+ | "payload-refused"
158
+ | "unreadable"
159
+ | "too-large"
160
+ | "follow-up";
161
+ status: number;
162
+ delivery?: string;
163
+ digest?: string;
164
+ /** The event key, when the kind read one. */
165
+ key?: string;
166
+ /** What the line says: the outcome, zod's issues, the error. */
167
+ detail?: unknown;
168
+ };
169
+
170
+ /** What `ended` selects (ADR-0009). Each field left out selects every row. */
171
+ export type EndedQuery = {
172
+ /** The statuses to list; absent: every status that is not `live`. */
173
+ statuses?: readonly string[];
174
+ /** The Workflows to list. A name also opens its Charts, `<name>/<chart>`, as a scope grant does
175
+ * (ADR-0078). Absent: every Workflow. */
176
+ workflows?: readonly string[];
177
+ /** The one Workflow to list, by its exact name. */
178
+ workflow?: string;
179
+ /** List only the runs that started at or after this ISO-8601 time. */
180
+ since?: string;
181
+ /** List only the runs after this one in the order: an older start, or the same start and a
182
+ * smaller run id. The cursor of a page. */
183
+ after?: { startedAt: string; runId: string };
184
+ limit: number;
185
+ };
186
+
187
+ /** One non-live run as `ended` reads it: the columns, and the summary that `save` copied out of
188
+ * the blob. `endedAt` is the row's terminal write. */
189
+ export type EndedRow = {
190
+ runId: string;
191
+ workflow: string;
192
+ status: string;
193
+ /** The store's reason (`drifted`, `lost`), else the Failure's reason, else the fault. */
194
+ reason?: string;
195
+ startedAt: string;
196
+ endedAt: string;
197
+ startedBy?: unknown;
198
+ assembly?: string;
199
+ dispatchedBy?: unknown;
200
+ };
201
+
202
+ /** The part of a blob that a listing shows (ADR-0009), copied into the `summary` column by `save`. */
203
+ type RowSummary = {
204
+ startedBy?: unknown;
205
+ assembly?: string;
206
+ dispatchedBy?: unknown;
207
+ failure?: string;
208
+ fault?: string;
209
+ };
210
+
211
+ /** The listing fields of a blob: its Workflow (null when it names none), its start, and the
212
+ * summary. A blob with no `startedAt` (written before the field) takes `fallback`. */
213
+ function listingOf(
214
+ snapshot: unknown,
215
+ fallback: string,
216
+ ): { workflow: string | null; startedAt: string; summary: string } {
217
+ const blob = (typeof snapshot === "object" && snapshot !== null ? snapshot : {}) as Record<string, unknown>;
218
+ const text = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
219
+ const failure = blob.failure as { reason?: unknown } | undefined;
220
+ const summary: RowSummary = {
221
+ ...(blob.startedBy !== undefined ? { startedBy: blob.startedBy } : {}),
222
+ ...(text(blob.assembly) !== undefined ? { assembly: text(blob.assembly) } : {}),
223
+ ...(blob.dispatchedBy !== undefined ? { dispatchedBy: blob.dispatchedBy } : {}),
224
+ ...(text(failure?.reason) !== undefined ? { failure: text(failure?.reason) } : {}),
225
+ ...(text(blob.fault) !== undefined ? { fault: text(blob.fault) } : {}),
226
+ };
227
+ return {
228
+ workflow: text(blob.workflow) ?? null,
229
+ startedAt: text(blob.startedAt) ?? fallback,
230
+ summary: JSON.stringify(summary),
231
+ };
232
+ }
233
+
234
+ /** The kinds of line that make a repeat: a delivery that the door claimed. */
235
+ const CLAIMED_KINDS = ["pending", "delivered", "seen"];
236
+
71
237
  type Row = {
72
238
  run_id: string;
73
239
  status: string;
74
240
  snapshot: string | null;
75
241
  reason: string | null;
76
242
  updated_at: string;
243
+ started_at: string | null;
77
244
  };
78
245
 
246
+ /** A row as `load` and `live` give it. */
247
+ function storedOf(row: Row): StoredSnapshot {
248
+ const stored: StoredSnapshot = {
249
+ runId: row.run_id,
250
+ status: row.status,
251
+ snapshot: row.snapshot === null ? null : JSON.parse(row.snapshot),
252
+ };
253
+ if (row.reason !== null) stored.reason = row.reason;
254
+ if (row.started_at !== null) stored.startedAt = row.started_at;
255
+ return stored;
256
+ }
257
+
79
258
  /** The lookup `findByDispatch` runs, exported so a test can ask the planner about THIS statement,
80
- * not a copy of it: the column is indexed, so the plan is a seek. */
259
+ * not a copy of it: the pair of columns is indexed, so the plan is a seek. */
81
260
  export const FIND_BY_DISPATCH_SQL = `SELECT run_id FROM machine_snapshots
82
- WHERE dispatch_effect = ? ORDER BY rowid LIMIT 1`;
261
+ WHERE dispatch_workflow = ? AND dispatch_effect = ? ORDER BY rowid LIMIT 1`;
83
262
 
84
- /** The idempotency key a blob carries at `dispatchedBy.effect` (ADR-0071), or null. */
263
+ /** The idempotency key a blob carries (ADR-0071): `dispatchedBy.key`, else `dispatchedBy.effect`, or null. */
85
264
  function dispatchEffectOf(snapshot: unknown): string | null {
86
265
  if (typeof snapshot !== "object" || snapshot === null) return null;
87
266
  const by = (snapshot as { dispatchedBy?: unknown }).dispatchedBy;
88
267
  if (typeof by !== "object" || by === null) return null;
268
+ // A Pool's item id is the key when there is one (ADR-0017); else the caller's ledger row id.
269
+ const key = (by as { key?: unknown }).key;
270
+ if (typeof key === "string") return key;
89
271
  const effect = (by as { effect?: unknown }).effect;
90
272
  return typeof effect === "string" ? effect : null;
91
273
  }
92
274
 
275
+ /** Whether a blob carries its run's start (`startedAt`). */
276
+ function hasStartedAt(snapshot: unknown): boolean {
277
+ return (
278
+ typeof snapshot === "object" &&
279
+ snapshot !== null &&
280
+ typeof (snapshot as { startedAt?: unknown }).startedAt === "string"
281
+ );
282
+ }
283
+
284
+ /** The Workflow a blob names (`workflow`): the scope of its idempotency key (ADR-0071), or null. */
285
+ function dispatchWorkflowOf(snapshot: unknown): string | null {
286
+ if (typeof snapshot !== "object" || snapshot === null) return null;
287
+ const workflow = (snapshot as { workflow?: unknown }).workflow;
288
+ return typeof workflow === "string" ? workflow : null;
289
+ }
290
+
93
291
  export class SqliteSnapshotStore implements SnapshotStore {
94
292
  private readonly db: DatabaseSync;
95
293
 
@@ -115,45 +313,144 @@ export class SqliteSnapshotStore implements SnapshotStore {
115
313
  // its own when `save` writes the row, and the column is indexed. Not an expression index over
116
314
  // `json_extract(snapshot, …)`: that runs on every row and THROWS on a blob SQLite's JSON parser
117
315
  // refuses (nested about a thousand deep), so one such row would fail its own save and every
118
- // later `init()` over the volume. A column also costs no JSON parse a save.
316
+ // later `init()` over the volume. A column also costs no JSON parse a save. The key is scoped
317
+ // to the callee Workflow, so the Workflow is copied out too, and the index is on the pair.
119
318
  const columns = this.db.prepare(`PRAGMA table_info(machine_snapshots)`).all() as Array<{ name: string }>;
120
319
  if (!columns.some((c) => c.name === "dispatch_effect")) {
121
320
  this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN dispatch_effect TEXT`);
122
321
  }
123
- this.db.exec(`CREATE INDEX IF NOT EXISTS machine_snapshots_dispatch_effect ON machine_snapshots(dispatch_effect)`);
322
+ if (!columns.some((c) => c.name === "dispatch_workflow")) {
323
+ this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN dispatch_workflow TEXT`);
324
+ this.fillDispatchWorkflow();
325
+ }
326
+ // The listing columns (ADR-0009): the Workflow, the start and the summary, copied out of the
327
+ // blob by `save`, so `ended` lists the history with no JSON parse. `listed_at` is the
328
+ // `updated_at` of the save that wrote them. A row whose two times differ was written by a store
329
+ // that does not know the columns — a volume from before them, or an older image in the Kit bump
330
+ // window (ADR-0069) — and each boot fills those rows again, in one transaction (SQLite's DDL is
331
+ // transactional): a process that stops part of the way leaves no column and no row half done,
332
+ // so the next boot does it again.
333
+ this.db.exec("BEGIN IMMEDIATE");
334
+ try {
335
+ for (const name of ["workflow", "started_at", "summary", "listed_at"]) {
336
+ if (!columns.some((c) => c.name === name)) {
337
+ this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN ${name} TEXT`);
338
+ }
339
+ }
340
+ this.fillListing();
341
+ this.db.exec("COMMIT");
342
+ } catch (err) {
343
+ this.db.exec("ROLLBACK");
344
+ throw err;
345
+ }
346
+ this.db.exec(
347
+ `CREATE INDEX IF NOT EXISTS machine_snapshots_started ON machine_snapshots(started_at DESC, run_id DESC)`,
348
+ );
349
+ this.db.exec(`DROP INDEX IF EXISTS machine_snapshots_dispatch_effect`);
350
+ this.db.exec(
351
+ `CREATE INDEX IF NOT EXISTS machine_snapshots_dispatch_key ON machine_snapshots(dispatch_workflow, dispatch_effect)`,
352
+ );
353
+ // The hook log (ADR-0011): one row per line, kept for `runs.retain` like a finished run (ADR-0065).
354
+ this.db.exec(`
355
+ CREATE TABLE IF NOT EXISTS hook_log (
356
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
357
+ hook TEXT NOT NULL,
358
+ at TEXT NOT NULL,
359
+ kind TEXT NOT NULL,
360
+ status INTEGER NOT NULL,
361
+ delivery TEXT,
362
+ digest TEXT,
363
+ key TEXT,
364
+ detail TEXT
365
+ )
366
+ `);
367
+ this.db.exec(`CREATE INDEX IF NOT EXISTS hook_log_delivery ON hook_log(hook, delivery)`);
368
+ this.db.exec(`CREATE INDEX IF NOT EXISTS hook_log_digest ON hook_log(hook, digest)`);
369
+ this.db.exec(`CREATE INDEX IF NOT EXISTS hook_log_at ON hook_log(at)`);
370
+ // The head of each standing Workflow (ADR-0017): one row for each, kept while the Workflow is.
371
+ this.db.exec(`
372
+ CREATE TABLE IF NOT EXISTS standing (
373
+ workflow TEXT PRIMARY KEY,
374
+ run TEXT NOT NULL,
375
+ since TEXT NOT NULL,
376
+ streak INTEGER NOT NULL DEFAULT 0,
377
+ paused TEXT
378
+ )
379
+ `);
380
+ // The Machine each ended run ran on (ADR-0069): one row for each distinct doc, and the run's
381
+ // row names it. The run sweep deletes a doc that no kept row names.
382
+ this.db.exec(`CREATE TABLE IF NOT EXISTS run_machines (key TEXT PRIMARY KEY, doc TEXT NOT NULL)`);
383
+ const now = this.db.prepare(`PRAGMA table_info(machine_snapshots)`).all() as Array<{ name: string }>;
384
+ if (!now.some((c) => c.name === "machine_key")) {
385
+ this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN machine_key TEXT`);
386
+ }
387
+ }
388
+
389
+ /** A volume a store before the Workflow column wrote: copy the Workflow out of each keyed row, so
390
+ * its key keeps answering. The blob is parsed here, not by `json_extract`, which refuses a blob
391
+ * nested about a thousand deep. */
392
+ private fillDispatchWorkflow(): void {
393
+ const rows = this.db
394
+ .prepare(`SELECT run_id, snapshot FROM machine_snapshots WHERE dispatch_effect IS NOT NULL`)
395
+ .all() as Array<{ run_id: string; snapshot: string | null }>;
396
+ const update = this.db.prepare(`UPDATE machine_snapshots SET dispatch_workflow = ? WHERE run_id = ?`);
397
+ for (const row of rows) {
398
+ if (row.snapshot === null) continue;
399
+ let blob: unknown;
400
+ try {
401
+ blob = JSON.parse(row.snapshot);
402
+ } catch {
403
+ continue; // A blob that does not parse names no Workflow, and restore reports it.
404
+ }
405
+ update.run(dispatchWorkflowOf(blob), row.run_id);
406
+ }
407
+ }
408
+
409
+ /** The rows whose listing columns a store that does not know them left out of date: copy them
410
+ * out of each blob again. A row whose blob does not parse keeps no Workflow, and is not listed. A
411
+ * blob with no `startedAt` keeps the start that the row has. */
412
+ private fillListing(): void {
413
+ const rows = this.db
414
+ .prepare(
415
+ `SELECT run_id, snapshot, updated_at FROM machine_snapshots
416
+ WHERE snapshot IS NOT NULL AND listed_at IS NOT updated_at`,
417
+ )
418
+ .all() as Array<{ run_id: string; snapshot: string; updated_at: string | null }>;
419
+ const update = this.db.prepare(
420
+ `UPDATE machine_snapshots SET workflow = ?,
421
+ started_at = CASE WHEN ? THEN ? ELSE COALESCE(started_at, ?) END,
422
+ summary = ?, listed_at = updated_at WHERE run_id = ?`,
423
+ );
424
+ for (const row of rows) {
425
+ let blob: unknown;
426
+ try {
427
+ blob = JSON.parse(row.snapshot);
428
+ } catch {
429
+ continue;
430
+ }
431
+ const listing = listingOf(blob, row.updated_at ?? new Date().toISOString());
432
+ const own = hasStartedAt(blob) ? 1 : 0;
433
+ update.run(listing.workflow, own, listing.startedAt, listing.startedAt, listing.summary, row.run_id);
434
+ }
124
435
  }
125
436
 
126
437
  async load(runId: string): Promise<StoredSnapshot | null> {
127
438
  const row = this.db
128
- .prepare(`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots WHERE run_id = ?`)
439
+ .prepare(
440
+ `SELECT run_id, status, snapshot, reason, updated_at, started_at FROM machine_snapshots WHERE run_id = ?`,
441
+ )
129
442
  .get(runId) as Row | undefined;
130
- if (!row) return null;
131
-
132
- const stored: StoredSnapshot = {
133
- runId: row.run_id,
134
- status: row.status,
135
- snapshot: row.snapshot === null ? null : JSON.parse(row.snapshot),
136
- };
137
- if (row.reason !== null) stored.reason = row.reason;
138
- return stored;
443
+ return row ? storedOf(row) : null;
139
444
  }
140
445
 
141
446
  async live(): Promise<StoredSnapshot[]> {
142
447
  const rows = this.db
143
448
  .prepare(
144
- `SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots
449
+ `SELECT run_id, status, snapshot, reason, updated_at, started_at FROM machine_snapshots
145
450
  WHERE status = 'live' ORDER BY rowid`,
146
451
  )
147
452
  .all() as Row[];
148
- return rows.map((row) => {
149
- const stored: StoredSnapshot = {
150
- runId: row.run_id,
151
- status: row.status,
152
- snapshot: row.snapshot === null ? null : JSON.parse(row.snapshot),
153
- };
154
- if (row.reason !== null) stored.reason = row.reason;
155
- return stored;
156
- });
453
+ return rows.map(storedOf);
157
454
  }
158
455
 
159
456
  async liveAssemblies(): Promise<Map<string, string[]>> {
@@ -189,24 +486,119 @@ export class SqliteSnapshotStore implements SnapshotStore {
189
486
  return rows.map((row) => row.run_id);
190
487
  }
191
488
 
192
- async findByDispatch(effect: string): Promise<string | undefined> {
193
- const row = this.db.prepare(FIND_BY_DISPATCH_SQL).get(effect) as { run_id: string } | undefined;
489
+ async findByDispatch(workflow: string, key: string): Promise<string | undefined> {
490
+ const row = this.db.prepare(FIND_BY_DISPATCH_SQL).get(workflow, key) as { run_id: string } | undefined;
194
491
  return row?.run_id;
195
492
  }
196
493
 
197
494
  async save(runId: string, snapshot: unknown, status = "live"): Promise<void> {
495
+ const now = new Date().toISOString();
496
+ const listing = listingOf(snapshot, now);
497
+ // A blob with no `startedAt` keeps the start that the row has: its first save's time.
198
498
  this.db
199
499
  .prepare(
200
- `INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at, dispatch_effect)
201
- VALUES (?, ?, ?, NULL, ?, ?)
500
+ `INSERT INTO machine_snapshots
501
+ (run_id, status, snapshot, reason, updated_at, dispatch_effect, dispatch_workflow, workflow, started_at, summary, listed_at)
502
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
202
503
  ON CONFLICT(run_id) DO UPDATE SET
203
504
  status = excluded.status,
204
505
  snapshot = excluded.snapshot,
205
506
  reason = NULL,
206
507
  updated_at = excluded.updated_at,
207
- dispatch_effect = excluded.dispatch_effect`,
508
+ dispatch_effect = excluded.dispatch_effect,
509
+ dispatch_workflow = excluded.dispatch_workflow,
510
+ workflow = excluded.workflow,
511
+ started_at = CASE WHEN ? THEN excluded.started_at ELSE COALESCE(started_at, excluded.started_at) END,
512
+ summary = excluded.summary,
513
+ listed_at = excluded.listed_at`,
514
+ )
515
+ .run(
516
+ runId,
517
+ status,
518
+ JSON.stringify(snapshot),
519
+ now,
520
+ dispatchEffectOf(snapshot),
521
+ dispatchWorkflowOf(snapshot),
522
+ listing.workflow,
523
+ listing.startedAt,
524
+ listing.summary,
525
+ now,
526
+ hasStartedAt(snapshot) ? 1 : 0,
527
+ );
528
+ }
529
+
530
+ async ended(query: EndedQuery): Promise<EndedRow[]> {
531
+ return this.listRows(`status != 'live'`, query);
532
+ }
533
+
534
+ async liveRows(query: EndedQuery): Promise<EndedRow[]> {
535
+ const { statuses: _statuses, ...rest } = query;
536
+ return this.listRows(`status = 'live'`, rest);
537
+ }
538
+
539
+ private listRows(status: string, query: EndedQuery): EndedRow[] {
540
+ const where = [status, `workflow IS NOT NULL`, `started_at IS NOT NULL`];
541
+ const args: Array<string | number> = [];
542
+ if (query.statuses !== undefined) {
543
+ if (query.statuses.length === 0) return [];
544
+ where.push(`status IN (${query.statuses.map(() => "?").join(", ")})`);
545
+ args.push(...query.statuses);
546
+ }
547
+ if (query.workflows !== undefined) {
548
+ if (query.workflows.length === 0) return [];
549
+ const marks = query.workflows.map(() => "?").join(", ");
550
+ // A grant of a Chart wrapper opens its Charts, `<wrapper>/<chart>` (ADR-0078).
551
+ where.push(
552
+ `(workflow IN (${marks}) OR (instr(workflow, '/') > 0 AND substr(workflow, 1, instr(workflow, '/') - 1) IN (${marks})))`,
553
+ );
554
+ args.push(...query.workflows, ...query.workflows);
555
+ }
556
+ if (query.workflow !== undefined) {
557
+ where.push(`workflow = ?`);
558
+ args.push(query.workflow);
559
+ }
560
+ if (query.since !== undefined) {
561
+ where.push(`started_at >= ?`);
562
+ args.push(query.since);
563
+ }
564
+ if (query.after !== undefined) {
565
+ where.push(`(started_at < ? OR (started_at = ? AND run_id < ?))`);
566
+ args.push(query.after.startedAt, query.after.startedAt, query.after.runId);
567
+ }
568
+ const rows = this.db
569
+ .prepare(
570
+ `SELECT run_id, workflow, status, reason, started_at, updated_at, summary FROM machine_snapshots
571
+ WHERE ${where.join(" AND ")} ORDER BY started_at DESC, run_id DESC LIMIT ?`,
208
572
  )
209
- .run(runId, status, JSON.stringify(snapshot), new Date().toISOString(), dispatchEffectOf(snapshot));
573
+ .all(...args, query.limit) as Array<{
574
+ run_id: string;
575
+ workflow: string;
576
+ status: string;
577
+ reason: string | null;
578
+ started_at: string;
579
+ updated_at: string;
580
+ summary: string | null;
581
+ }>;
582
+ return rows.map((row) => {
583
+ let summary: RowSummary = {};
584
+ try {
585
+ summary = row.summary === null ? {} : (JSON.parse(row.summary) as RowSummary);
586
+ } catch {
587
+ // A summary that does not parse lists the columns alone.
588
+ }
589
+ const reason = row.reason ?? summary.failure ?? summary.fault;
590
+ return {
591
+ runId: row.run_id,
592
+ workflow: row.workflow,
593
+ status: row.status,
594
+ ...(reason !== undefined ? { reason } : {}),
595
+ startedAt: row.started_at,
596
+ endedAt: row.updated_at,
597
+ ...(summary.startedBy !== undefined ? { startedBy: summary.startedBy } : {}),
598
+ ...(summary.assembly !== undefined ? { assembly: summary.assembly } : {}),
599
+ ...(summary.dispatchedBy !== undefined ? { dispatchedBy: summary.dispatchedBy } : {}),
600
+ };
601
+ });
210
602
  }
211
603
 
212
604
  async markLost(runId: string, reason: string): Promise<void> {
@@ -217,7 +609,8 @@ export class SqliteSnapshotStore implements SnapshotStore {
217
609
  ON CONFLICT(run_id) DO UPDATE SET
218
610
  status = 'lost',
219
611
  reason = excluded.reason,
220
- updated_at = excluded.updated_at`,
612
+ updated_at = excluded.updated_at,
613
+ listed_at = CASE WHEN listed_at IS updated_at THEN excluded.updated_at ELSE listed_at END`,
221
614
  )
222
615
  .run(runId, reason, new Date().toISOString());
223
616
  }
@@ -225,18 +618,142 @@ export class SqliteSnapshotStore implements SnapshotStore {
225
618
  /** An UPDATE, not an upsert: a drifted run is one we already have a snapshot for, and the whole
226
619
  * point is to keep it. A row that is not there is not a drifted run — nothing to record. */
227
620
  async markDrifted(runId: string, reason: string): Promise<void> {
621
+ const now = new Date().toISOString();
228
622
  this.db
229
- .prepare(`UPDATE machine_snapshots SET status = 'drifted', reason = ?, updated_at = ? WHERE run_id = ?`)
230
- .run(reason, new Date().toISOString(), runId);
623
+ .prepare(
624
+ // The blob is not touched, so listing columns that were current stay current.
625
+ `UPDATE machine_snapshots SET status = 'drifted', reason = ?, updated_at = ?,
626
+ listed_at = CASE WHEN listed_at IS updated_at THEN ? ELSE listed_at END WHERE run_id = ?`,
627
+ )
628
+ .run(reason, now, now, runId);
231
629
  }
232
630
 
233
631
  /** `updated_at` is an ISO-8601 UTC string, so a string comparison is a time comparison. A
234
- * terminal row's `updated_at` is its terminal write — the age the week counts from. */
235
- async sweep(olderThan: Date): Promise<number> {
632
+ * terminal row's `updated_at` is its terminal write — the age `runs.retain` counts from. The hook
633
+ * log's lines go by the same time (ADR-0011); the count is of runs alone. */
634
+ async sweep(olderThan: Date): Promise<string[]> {
635
+ // The run a standing head names stays (ADR-0017): a paused Pool's held items are in its blob.
636
+ const swept = this.db
637
+ .prepare(
638
+ `DELETE FROM machine_snapshots WHERE status != 'live' AND updated_at < ?
639
+ AND run_id NOT IN (SELECT run FROM standing) RETURNING run_id`,
640
+ )
641
+ .all(olderThan.toISOString()) as Array<{ run_id: string }>;
642
+ this.db.prepare(`DELETE FROM hook_log WHERE at < ?`).run(olderThan.toISOString());
643
+ if (swept.length) {
644
+ this.db
645
+ .prepare(
646
+ `DELETE FROM run_machines WHERE key NOT IN
647
+ (SELECT machine_key FROM machine_snapshots WHERE machine_key IS NOT NULL)`,
648
+ )
649
+ .run();
650
+ }
651
+ return swept.map((r) => r.run_id);
652
+ }
653
+
654
+ keepMachine(runId: string, key: string, doc: unknown): void {
655
+ this.db.prepare(`INSERT OR IGNORE INTO run_machines (key, doc) VALUES (?, ?)`).run(key, JSON.stringify(doc));
656
+ this.db.prepare(`UPDATE machine_snapshots SET machine_key = ? WHERE run_id = ?`).run(key, runId);
657
+ }
658
+
659
+ keptMachine(runId: string): { workflow: string; doc: unknown } | undefined {
660
+ const row = this.db
661
+ .prepare(
662
+ `SELECT s.workflow, m.doc FROM machine_snapshots s JOIN run_machines m ON m.key = s.machine_key
663
+ WHERE s.run_id = ?`,
664
+ )
665
+ .get(runId) as { workflow: string | null; doc: string } | undefined;
666
+ return row?.workflow ? { workflow: row.workflow, doc: JSON.parse(row.doc) } : undefined;
667
+ }
668
+
669
+ claimHookDelivery(hook: string, delivery: string, digest: string): number | undefined {
670
+ const kinds = CLAIMED_KINDS.map(() => "?").join(", ");
671
+ const held = this.db
672
+ .prepare(`SELECT 1 FROM hook_log WHERE hook = ? AND (delivery = ? OR digest = ?) AND kind IN (${kinds}) LIMIT 1`)
673
+ .get(hook, delivery, digest, ...CLAIMED_KINDS);
674
+ if (held) return undefined;
236
675
  const result = this.db
237
- .prepare(`DELETE FROM machine_snapshots WHERE status != 'live' AND updated_at < ?`)
238
- .run(olderThan.toISOString());
239
- return Number(result.changes);
676
+ .prepare(`INSERT INTO hook_log (hook, at, kind, status, delivery, digest) VALUES (?, ?, 'pending', 0, ?, ?)`)
677
+ .run(hook, new Date().toISOString(), delivery, digest);
678
+ return Number(result.lastInsertRowid);
679
+ }
680
+
681
+ writeHookLine(line: HookLogLine, claimed?: number): void {
682
+ const detail = line.detail === undefined ? null : JSON.stringify(line.detail);
683
+ if (claimed !== undefined) {
684
+ this.db
685
+ .prepare(`UPDATE hook_log SET kind = ?, status = ?, key = ?, detail = ? WHERE id = ?`)
686
+ .run(line.kind, line.status, line.key ?? null, detail, claimed);
687
+ return;
688
+ }
689
+ this.db
690
+ .prepare(
691
+ `INSERT INTO hook_log (hook, at, kind, status, delivery, digest, key, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
692
+ )
693
+ .run(
694
+ line.hook,
695
+ line.at,
696
+ line.kind,
697
+ line.status,
698
+ line.delivery ?? null,
699
+ line.digest ?? null,
700
+ line.key ?? null,
701
+ detail,
702
+ );
703
+ }
704
+
705
+ hookLog(hook: string, limit: number): HookLogLine[] {
706
+ const rows = this.db
707
+ .prepare(
708
+ `SELECT hook, at, kind, status, delivery, digest, key, detail FROM hook_log WHERE hook = ? ORDER BY id DESC LIMIT ?`,
709
+ )
710
+ .all(hook, limit) as Array<{
711
+ hook: string;
712
+ at: string;
713
+ kind: HookLogLine["kind"];
714
+ status: number;
715
+ delivery: string | null;
716
+ digest: string | null;
717
+ key: string | null;
718
+ detail: string | null;
719
+ }>;
720
+ return rows.map((r) => ({
721
+ hook: r.hook,
722
+ at: r.at,
723
+ kind: r.kind,
724
+ status: r.status,
725
+ ...(r.delivery !== null ? { delivery: r.delivery } : {}),
726
+ ...(r.digest !== null ? { digest: r.digest } : {}),
727
+ ...(r.key !== null ? { key: r.key } : {}),
728
+ ...(r.detail !== null ? { detail: JSON.parse(r.detail) as unknown } : {}),
729
+ }));
730
+ }
731
+
732
+ standingHeads(): StandingHead[] {
733
+ const rows = this.db.prepare(`SELECT workflow, run, since, streak, paused FROM standing`).all() as Array<{
734
+ workflow: string;
735
+ run: string;
736
+ since: string;
737
+ streak: number;
738
+ paused: string | null;
739
+ }>;
740
+ return rows.map((r) => ({
741
+ workflow: r.workflow,
742
+ run: r.run,
743
+ since: r.since,
744
+ streak: Number(r.streak),
745
+ ...(r.paused === "cancel" || r.paused === "errors" || r.paused === "bound" ? { paused: r.paused } : {}),
746
+ }));
747
+ }
748
+
749
+ writeStanding(head: StandingHead): void {
750
+ this.db
751
+ .prepare(
752
+ `INSERT INTO standing (workflow, run, since, streak, paused) VALUES (?, ?, ?, ?, ?)
753
+ ON CONFLICT(workflow) DO UPDATE SET
754
+ run = excluded.run, since = excluded.since, streak = excluded.streak, paused = excluded.paused`,
755
+ )
756
+ .run(head.workflow, head.run, head.since, head.streak, head.paused ?? null);
240
757
  }
241
758
 
242
759
  async close(): Promise<void> {