@north-light/crouter 0.3.233 → 0.3.235

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 (411) hide show
  1. package/dist/api/client.js +16 -15
  2. package/dist/api/dto/nodes.d.ts +11 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +1 -1
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +14 -17
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -11
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +8 -8
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +10 -11
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +2 -2
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +3 -3
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +7 -7
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +8 -8
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +6 -6
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +3 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +1 -1
  15. package/dist/clients/attach/chrome/ticket-panel.js +15 -2
  16. package/dist/clients/attach/overlays/graph.js +0 -3
  17. package/dist/clients/attach/overlays/mcp.js +1 -2
  18. package/dist/clients/attach/render/chat-view.js +0 -12
  19. package/dist/clients/attach/render/crtr-output.js +0 -2
  20. package/dist/clients/attach/render/tool-calls.js +0 -6
  21. package/dist/clients/attach/slash/dispatch.js +8 -11
  22. package/dist/clients/attach/viewer.js +535 -537
  23. package/dist/clients/conversation/projection.js +0 -2
  24. package/dist/clients/inbox/controller.js +22 -3
  25. package/dist/clients/inbox/review/companion-pane.js +0 -4
  26. package/dist/clients/inbox/review-adapter.js +0 -7
  27. package/dist/clients/inbox/tui/slots.d.ts +3 -3
  28. package/dist/clients/inbox/tui/types.d.ts +2 -2
  29. package/dist/commands/api-client.js +0 -8
  30. package/dist/commands/attention.js +1 -9
  31. package/dist/commands/canvas-browse.js +2 -1
  32. package/dist/commands/canvas-config.js +0 -10
  33. package/dist/commands/canvas-history.js +2 -1
  34. package/dist/commands/cron.js +10 -23
  35. package/dist/commands/dashboard.js +0 -2
  36. package/dist/commands/human/feedback.js +2 -1
  37. package/dist/commands/human/prompts.js +2 -5
  38. package/dist/commands/human/queue.js +2 -7
  39. package/dist/commands/human/request.js +0 -14
  40. package/dist/commands/human/review.js +2 -1
  41. package/dist/commands/human.js +1 -1
  42. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +4 -4
  43. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
  44. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
  45. package/dist/commands/memory/delete.js +1 -1
  46. package/dist/commands/memory/edit.js +6 -3
  47. package/dist/commands/memory/find.js +2 -2
  48. package/dist/commands/memory/lint.js +2 -1
  49. package/dist/commands/memory/list.d.ts +4 -0
  50. package/dist/commands/memory/list.js +20 -14
  51. package/dist/commands/memory/move.js +1 -1
  52. package/dist/commands/memory/read.d.ts +2 -7
  53. package/dist/commands/memory/read.js +5 -16
  54. package/dist/commands/memory/shared.d.ts +1 -0
  55. package/dist/commands/memory/shared.js +17 -8
  56. package/dist/commands/memory/write.js +8 -4
  57. package/dist/commands/node/bash.js +2 -1
  58. package/dist/commands/node/create.js +2 -1
  59. package/dist/commands/node/inspect.js +11 -10
  60. package/dist/commands/node/lifecycle.js +5 -13
  61. package/dist/commands/node/message.js +11 -6
  62. package/dist/commands/node/subscription.js +7 -2
  63. package/dist/commands/node/wait.js +9 -4
  64. package/dist/commands/node-context.js +2 -1
  65. package/dist/commands/node-lifecycle-revive.js +0 -2
  66. package/dist/commands/node-worktree.js +2 -1
  67. package/dist/commands/pkg/browse/actions.d.ts +1 -1
  68. package/dist/commands/pkg/browse/actions.js +4 -4
  69. package/dist/commands/pkg/browse/catalog.d.ts +1 -1
  70. package/dist/commands/pkg/browse/catalog.js +15 -15
  71. package/dist/commands/pkg/browse/command-view.d.ts +1 -1
  72. package/dist/commands/pkg/browse/command-view.js +3 -3
  73. package/dist/commands/pkg/browse/detail-panel.d.ts +1 -1
  74. package/dist/commands/pkg/browse/detail-panel.js +8 -8
  75. package/dist/commands/pkg/browse/doc-view.d.ts +1 -1
  76. package/dist/commands/pkg/browse/doc-view.js +3 -3
  77. package/dist/commands/pkg/browse/list-panel.d.ts +1 -1
  78. package/dist/commands/pkg/browse/list-panel.js +3 -3
  79. package/dist/commands/pkg/browse/model.d.ts +3 -3
  80. package/dist/commands/pkg/browse/shell.d.ts +2 -2
  81. package/dist/commands/pkg/browse/shell.js +13 -13
  82. package/dist/commands/pkg/plugin-manage.js +3 -2
  83. package/dist/commands/pkg/shared.js +0 -2
  84. package/dist/commands/pkg.js +1 -0
  85. package/dist/commands/profile/delete.js +2 -1
  86. package/dist/commands/profile/rename.js +1 -1
  87. package/dist/commands/push.js +2 -3
  88. package/dist/commands/revive.js +0 -2
  89. package/dist/commands/surface/node/placement.js +2 -1
  90. package/dist/commands/surface-edit.js +2 -1
  91. package/dist/commands/sys/config.js +1 -4
  92. package/dist/commands/sys/daemon.js +0 -10
  93. package/dist/commands/sys/doctor.js +3 -2
  94. package/dist/commands/sys/panels/broker-limits-panel.d.ts +2 -2
  95. package/dist/commands/sys/panels/broker-limits-panel.js +4 -4
  96. package/dist/commands/sys/panels/dependencies-panel.d.ts +2 -2
  97. package/dist/commands/sys/panels/dependencies-panel.js +2 -2
  98. package/dist/commands/sys/panels/keybindings-panel.d.ts +1 -1
  99. package/dist/commands/sys/panels/keybindings-panel.js +5 -5
  100. package/dist/commands/sys/panels/models-panel.d.ts +4 -4
  101. package/dist/commands/sys/panels/models-panel.js +11 -10
  102. package/dist/commands/sys/panels/plugins-panel.d.ts +2 -2
  103. package/dist/commands/sys/panels/plugins-panel.js +2 -2
  104. package/dist/commands/sys/panels/prefs-panel.d.ts +2 -2
  105. package/dist/commands/sys/panels/prefs-panel.js +4 -4
  106. package/dist/commands/sys/panels/profiles-panel.d.ts +1 -1
  107. package/dist/commands/sys/panels/profiles-panel.js +14 -13
  108. package/dist/commands/sys/panels/provider-panel.d.ts +3 -3
  109. package/dist/commands/sys/panels/provider-panel.js +8 -8
  110. package/dist/commands/sys/settings-shell.d.ts +3 -3
  111. package/dist/commands/sys/settings-shell.js +15 -15
  112. package/dist/commands/sys/setup-settings-runner.d.ts +3 -3
  113. package/dist/commands/sys/setup-settings-runner.js +5 -5
  114. package/dist/commands/sys/setup-wizard.d.ts +4 -4
  115. package/dist/commands/sys/setup-wizard.js +8 -8
  116. package/dist/commands/sys/support/prepare.js +4 -4
  117. package/dist/commands/sys/support/submit.js +2 -2
  118. package/dist/commands/sys/sync-deps.js +0 -2
  119. package/dist/commands/sys/sync-project-guidance.js +2 -1
  120. package/dist/commands/sys/sysprompt.js +2 -1
  121. package/dist/commands/sys.js +1 -0
  122. package/dist/core/__tests__/boot.test.js +0 -8
  123. package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +23 -1
  124. package/dist/core/__tests__/canvas-db-boundary.test.d.ts +1 -0
  125. package/dist/core/__tests__/canvas-db-boundary.test.js +46 -0
  126. package/dist/core/__tests__/canvas.test.js +1 -1
  127. package/dist/core/__tests__/child-death-wake.test.js +0 -2
  128. package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
  129. package/dist/core/__tests__/cli-help-contract.test.js +150 -0
  130. package/dist/core/__tests__/cron-armed-context-failure.test.d.ts +1 -0
  131. package/dist/core/__tests__/cron-armed-context-failure.test.js +145 -0
  132. package/dist/core/__tests__/daemon-wedge.test.js +0 -4
  133. package/dist/core/__tests__/dead-node-policy-table.test.js +0 -4
  134. package/dist/core/__tests__/fixtures/fake-engine.js +4 -14
  135. package/dist/core/__tests__/fixtures/fake-pi-host.js +5 -5
  136. package/dist/core/__tests__/focuses.test.js +0 -6
  137. package/dist/core/__tests__/fs-utils.test.d.ts +1 -0
  138. package/dist/core/__tests__/fs-utils.test.js +50 -0
  139. package/dist/core/__tests__/helpers/harness.js +1 -1
  140. package/dist/core/__tests__/inline-memory-refs.test.js +0 -2
  141. package/dist/core/__tests__/integration/broker-fork-seam.test.js +0 -2
  142. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +3 -16
  143. package/dist/core/__tests__/integration/command-plugins.test.js +21 -24
  144. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -6
  145. package/dist/core/__tests__/integration/flagship-lifecycle.test.js +0 -26
  146. package/dist/core/__tests__/integration/live-mutation-verbs.test.js +0 -4
  147. package/dist/core/__tests__/integration/live-mutation.test.js +1 -5
  148. package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +1 -5
  149. package/dist/core/__tests__/integration/revive.test.js +0 -12
  150. package/dist/core/__tests__/integration/subscription-delivery.test.js +0 -6
  151. package/dist/core/__tests__/integration/tmux-surface.test.js +24 -8
  152. package/dist/core/__tests__/integration/worktree-reap.test.js +0 -6
  153. package/dist/core/__tests__/lifecycle.test.js +0 -18
  154. package/dist/core/__tests__/memory-resolver-precedence.test.js +1 -1
  155. package/dist/core/__tests__/migration.test.js +2 -2
  156. package/dist/core/__tests__/on-read-dedup-resume.test.js +3 -3
  157. package/dist/core/__tests__/pid-identity-match.test.js +1 -1
  158. package/dist/core/__tests__/plugin-kinds.test.js +1 -1
  159. package/dist/core/__tests__/plugin-page-components.test.js +1 -1
  160. package/dist/core/__tests__/preview-registry-sync.test.js +42 -1
  161. package/dist/core/__tests__/respawn-throttle.test.js +0 -4
  162. package/dist/core/__tests__/revive-all.test.js +5 -5
  163. package/dist/core/__tests__/revive-parked-fresh.test.js +62 -11
  164. package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +0 -8
  165. package/dist/core/__tests__/seam/broker-attach-stream.test.js +0 -8
  166. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +0 -2
  167. package/dist/core/__tests__/tmux-driver-boundary.test.d.ts +1 -0
  168. package/dist/core/__tests__/tmux-driver-boundary.test.js +50 -0
  169. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
  170. package/dist/core/activity-signal.d.ts +7 -0
  171. package/dist/core/activity-signal.js +38 -0
  172. package/dist/core/auto-update.js +2 -1
  173. package/dist/core/bootstrap.js +2 -1
  174. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +1 -1
  175. package/dist/core/canvas/__tests__/remote-transport.test.js +1 -1
  176. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  177. package/dist/core/canvas/browse/app.js +3 -2
  178. package/dist/core/canvas/browse/model.d.ts +1 -1
  179. package/dist/core/canvas/browse/model.js +0 -4
  180. package/dist/core/canvas/canvas.d.ts +22 -0
  181. package/dist/core/canvas/canvas.js +61 -19
  182. package/dist/core/canvas/crons.js +0 -6
  183. package/dist/core/canvas/db.d.ts +14 -20
  184. package/dist/core/canvas/db.js +30 -1332
  185. package/dist/core/canvas/focuses.js +0 -4
  186. package/dist/core/canvas/history.js +2 -1
  187. package/dist/core/canvas/meta-file.js +18 -27
  188. package/dist/core/canvas/migrations.d.ts +20 -0
  189. package/dist/core/canvas/migrations.js +1339 -0
  190. package/dist/core/canvas/nav-model.js +1 -11
  191. package/dist/core/canvas/nav-render.js +0 -8
  192. package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
  193. package/dist/core/canvas/remote-canvas-source.js +9 -3
  194. package/dist/core/canvas/render-source.js +31 -13
  195. package/dist/core/command-manifests/manifest.js +0 -10
  196. package/dist/core/command-manifests/registry.d.ts +2 -0
  197. package/dist/core/command-manifests/schema.js +0 -4
  198. package/dist/core/command-plugins/compose.js +4 -2
  199. package/dist/core/command-plugins/discovery.d.ts +1 -0
  200. package/dist/core/command-plugins/discovery.js +1 -1
  201. package/dist/core/command-plugins/transport/http-invoke.js +0 -14
  202. package/dist/core/command.js +2 -6
  203. package/dist/core/config.js +2 -1
  204. package/dist/core/fault-classifier.js +8 -0
  205. package/dist/core/feed/inbox.d.ts +10 -0
  206. package/dist/core/feed/inbox.js +18 -12
  207. package/dist/core/fs-utils.d.ts +9 -2
  208. package/dist/core/fs-utils.js +7 -15
  209. package/dist/core/help.js +0 -8
  210. package/dist/core/host-exports/export.d.ts +2 -2
  211. package/dist/core/host-exports/export.js +9 -12
  212. package/dist/core/human/answer.d.ts +2 -2
  213. package/dist/core/human/convention.d.ts +2 -5
  214. package/dist/core/human/convention.js +3 -13
  215. package/dist/core/human/page-schema.d.ts +2 -2
  216. package/dist/core/human/page-schema.js +2 -2
  217. package/dist/core/human/root.js +6 -23
  218. package/dist/core/human/scan.js +24 -7
  219. package/dist/core/human/tickets.d.ts +4 -1
  220. package/dist/core/human/tickets.js +20 -6
  221. package/dist/core/io.js +0 -12
  222. package/dist/core/memory/identity.js +0 -4
  223. package/dist/core/memory/inline-ref-inventory.js +3 -2
  224. package/dist/core/memory/lint.js +3 -6
  225. package/dist/core/memory-resolver.d.ts +5 -0
  226. package/dist/core/memory-resolver.js +14 -12
  227. package/dist/core/pagination.d.ts +2 -0
  228. package/dist/core/pagination.js +2 -3
  229. package/dist/core/preview-registry.d.ts +14 -0
  230. package/dist/core/preview-registry.js +2 -4
  231. package/dist/core/preview-result-path.js +2 -1
  232. package/dist/core/profiles/manifest.d.ts +37 -5
  233. package/dist/core/profiles/manifest.js +44 -24
  234. package/dist/core/profiles/select.js +0 -4
  235. package/dist/core/review/signal.js +4 -23
  236. package/dist/core/review/ticket-filter.d.ts +3 -3
  237. package/dist/core/review/ticket-filter.js +4 -9
  238. package/dist/core/runtime/__tests__/engine-route.test.js +1 -1
  239. package/dist/core/runtime/__tests__/spawn-env.test.js +0 -8
  240. package/dist/core/runtime/bearings-render.d.ts +56 -0
  241. package/dist/core/runtime/bearings-render.js +241 -0
  242. package/dist/core/runtime/bearings.d.ts +2 -17
  243. package/dist/core/runtime/bearings.js +12 -198
  244. package/dist/core/runtime/broker/auth-reload.d.ts +39 -0
  245. package/dist/core/runtime/broker/auth-reload.js +149 -0
  246. package/dist/core/runtime/broker/engine-drive.d.ts +26 -0
  247. package/dist/core/runtime/broker/engine-drive.js +257 -0
  248. package/dist/core/runtime/broker/engine-routing.d.ts +143 -0
  249. package/dist/core/runtime/broker/engine-routing.js +211 -0
  250. package/dist/core/runtime/broker/event-projection.js +3 -2
  251. package/dist/core/runtime/broker/fault-retry.js +11 -4
  252. package/dist/core/runtime/broker/frame-client.d.ts +38 -0
  253. package/dist/core/runtime/broker/frame-client.js +129 -0
  254. package/dist/core/runtime/broker/frame-dispatch.d.ts +12 -189
  255. package/dist/core/runtime/broker/frame-dispatch.js +89 -1476
  256. package/dist/core/runtime/broker/frame-engine-control.d.ts +20 -0
  257. package/dist/core/runtime/broker/frame-engine-control.js +231 -0
  258. package/dist/core/runtime/broker/frame-memory-refs.d.ts +19 -0
  259. package/dist/core/runtime/broker/frame-memory-refs.js +41 -0
  260. package/dist/core/runtime/broker/frame-model-query.d.ts +13 -0
  261. package/dist/core/runtime/broker/frame-model-query.js +69 -0
  262. package/dist/core/runtime/broker/frame-read.d.ts +19 -0
  263. package/dist/core/runtime/broker/frame-read.js +116 -0
  264. package/dist/core/runtime/broker/frame-replies.d.ts +24 -0
  265. package/dist/core/runtime/broker/frame-replies.js +64 -0
  266. package/dist/core/runtime/broker/frame-session.d.ts +34 -0
  267. package/dist/core/runtime/broker/frame-session.js +203 -0
  268. package/dist/core/runtime/broker/rebind.js +50 -0
  269. package/dist/core/runtime/broker/turn-ignition.d.ts +42 -0
  270. package/dist/core/runtime/broker/turn-ignition.js +74 -0
  271. package/dist/core/runtime/broker-extension-render.js +9 -85
  272. package/dist/core/runtime/broker-protocol.js +0 -4
  273. package/dist/core/runtime/broker-sdk.js +2 -1
  274. package/dist/core/runtime/broker.d.ts +1 -1
  275. package/dist/core/runtime/broker.js +5 -23
  276. package/dist/core/runtime/fault.js +3 -2
  277. package/dist/core/runtime/headless-pi.js +3 -2
  278. package/dist/core/runtime/host.js +2 -3
  279. package/dist/core/runtime/kickoff.js +0 -4
  280. package/dist/core/runtime/launch-target.js +2 -1
  281. package/dist/core/runtime/launch.d.ts +2 -2
  282. package/dist/core/runtime/launch.js +20 -10
  283. package/dist/core/runtime/lifecycle.js +17 -26
  284. package/dist/core/runtime/managed-provider-cooling.d.ts +1 -1
  285. package/dist/core/runtime/naming.js +0 -2
  286. package/dist/core/runtime/nodes.js +2 -1
  287. package/dist/core/runtime/persona.js +0 -6
  288. package/dist/core/runtime/placement-tmux.js +2 -5
  289. package/dist/core/runtime/placement.js +4 -11
  290. package/dist/core/runtime/recap.js +4 -3
  291. package/dist/core/runtime/reopen.js +4 -7
  292. package/dist/core/runtime/reset.js +0 -4
  293. package/dist/core/runtime/resume-root.js +0 -2
  294. package/dist/core/runtime/revive.js +17 -13
  295. package/dist/core/runtime/session-list-cache.js +2 -2
  296. package/dist/core/runtime/shell-expansion.js +0 -2
  297. package/dist/core/runtime/spawn.js +0 -2
  298. package/dist/core/runtime/stop-guard.js +28 -16
  299. package/dist/core/runtime/stream-watchdog.d.ts +0 -1
  300. package/dist/core/runtime/stream-watchdog.js +13 -18
  301. package/dist/core/runtime/tmux-bindings.js +0 -2
  302. package/dist/core/runtime/tmux-driver.d.ts +12 -0
  303. package/dist/core/runtime/tmux-driver.js +18 -13
  304. package/dist/core/runtime/warm-pool.js +0 -4
  305. package/dist/core/scope.js +4 -3
  306. package/dist/core/spawn.js +2 -3
  307. package/dist/core/subscription-state.js +10 -17
  308. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +3 -3
  309. package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
  310. package/dist/core/substrate/frontmatter-validation.js +1 -1
  311. package/dist/core/substrate/render.js +0 -12
  312. package/dist/core/substrate/schema.js +0 -12
  313. package/dist/core/substrate/surface-match.js +0 -2
  314. package/dist/core/termrender/display.js +2 -1
  315. package/dist/core/tui/page-host.d.ts +3 -3
  316. package/dist/core/tui/page-host.js +4 -4
  317. package/dist/core/tui/panel.js +3 -3
  318. package/dist/core/wake.js +0 -12
  319. package/dist/core/worktree.js +0 -2
  320. package/dist/daemon/__tests__/reconciler-signature.test.d.ts +1 -0
  321. package/dist/daemon/__tests__/reconciler-signature.test.js +36 -0
  322. package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
  323. package/dist/daemon/api/__tests__/seam/api-server.test.js +2 -16
  324. package/dist/daemon/api/__tests__/seam/b10-attach-modelauth.test.js +3 -11
  325. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +0 -6
  326. package/dist/daemon/api/handlers/attach.js +2 -1
  327. package/dist/daemon/api/handlers/broker-ops.js +3 -6
  328. package/dist/daemon/api/handlers/canvas.js +12 -18
  329. package/dist/daemon/api/handlers/feedback-comments.js +0 -8
  330. package/dist/daemon/api/handlers/health.js +3 -2
  331. package/dist/daemon/api/handlers/human-requests.js +0 -10
  332. package/dist/daemon/api/handlers/human.js +1 -5
  333. package/dist/daemon/api/handlers/inbox.js +0 -18
  334. package/dist/daemon/api/handlers/memory.js +1 -1
  335. package/dist/daemon/api/handlers/messages.js +16 -20
  336. package/dist/daemon/api/handlers/nodes.js +23 -22
  337. package/dist/daemon/api/handlers/reports.js +1 -1
  338. package/dist/daemon/api/handlers/reviews.js +1 -6
  339. package/dist/daemon/api/handlers/worktree.js +6 -22
  340. package/dist/daemon/api/map.d.ts +4 -2
  341. package/dist/daemon/api/map.js +22 -8
  342. package/dist/daemon/api/router.js +0 -4
  343. package/dist/daemon/api/server.js +2 -5
  344. package/dist/daemon/boot-migrations.d.ts +14 -0
  345. package/dist/daemon/boot-migrations.js +31 -0
  346. package/dist/daemon/cron/armed-context.d.ts +21 -0
  347. package/dist/daemon/cron/armed-context.js +100 -0
  348. package/dist/daemon/cron/capture.d.ts +20 -0
  349. package/dist/daemon/cron/capture.js +45 -0
  350. package/dist/daemon/cron/disposition.d.ts +19 -0
  351. package/dist/daemon/cron/disposition.js +52 -0
  352. package/dist/daemon/cron/escalation.d.ts +12 -0
  353. package/dist/daemon/cron/escalation.js +124 -0
  354. package/dist/daemon/cron/held.d.ts +4 -0
  355. package/dist/daemon/cron/held.js +20 -0
  356. package/dist/daemon/cron/lease-recovery.d.ts +3 -0
  357. package/dist/daemon/cron/lease-recovery.js +119 -0
  358. package/dist/daemon/cron/live-runs.d.ts +11 -0
  359. package/dist/daemon/cron/live-runs.js +23 -0
  360. package/dist/daemon/cron/passes.d.ts +11 -0
  361. package/dist/daemon/cron/passes.js +78 -0
  362. package/dist/daemon/cron/settle.d.ts +28 -0
  363. package/dist/daemon/cron/settle.js +37 -0
  364. package/dist/daemon/cron/sinks.d.ts +2 -0
  365. package/dist/daemon/cron/sinks.js +100 -0
  366. package/dist/daemon/cron-run.d.ts +5 -41
  367. package/dist/daemon/cron-run.js +193 -882
  368. package/dist/daemon/crtrd-cli.js +2 -1
  369. package/dist/daemon/crtrd.js +22 -22
  370. package/dist/daemon/fleet.js +0 -4
  371. package/dist/daemon/human/deliver-action.js +2 -1
  372. package/dist/daemon/human/sweep.js +15 -4
  373. package/dist/daemon/manage.d.ts +2 -2
  374. package/dist/daemon/manage.js +4 -13
  375. package/dist/daemon/pidfile.js +2 -2
  376. package/dist/daemon/reconcilers/broker-supervision.js +27 -30
  377. package/dist/daemon/reconcilers/dormant-inbox.d.ts +1 -1
  378. package/dist/daemon/reconcilers/dormant-inbox.js +2 -1
  379. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  380. package/dist/daemon/reconcilers/pending-review-submit.d.ts +1 -1
  381. package/dist/daemon/reconcilers/pending-review-submit.js +2 -1
  382. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -1
  383. package/dist/daemon/reconcilers/storage-maintenance.js +2 -1
  384. package/dist/daemon/review/companion.js +2 -2
  385. package/dist/daemon/review/sweep.js +8 -3
  386. package/dist/migrations/001-surfaces-frontmatter.js +0 -6
  387. package/dist/migrations/corpus.js +2 -5
  388. package/dist/migrations/registry.js +15 -2
  389. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +0 -4
  390. package/dist/pi-extensions/broker-local.js +2 -1
  391. package/dist/pi-extensions/canvas-bash-valve.js +7 -20
  392. package/dist/pi-extensions/canvas-context-intro.js +2 -7
  393. package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
  394. package/dist/pi-extensions/canvas-goal-capture.js +2 -1
  395. package/dist/pi-extensions/canvas-inbox-watcher.js +5 -12
  396. package/dist/pi-extensions/canvas-passive-context.js +2 -5
  397. package/dist/pi-extensions/canvas-preview-result.js +2 -1
  398. package/dist/pi-extensions/canvas-prompt-scrub.js +2 -1
  399. package/dist/pi-extensions/canvas-recap.js +3 -16
  400. package/dist/pi-extensions/canvas-review-boundary.js +2 -5
  401. package/dist/pi-extensions/canvas-stophook.js +2 -26
  402. package/dist/pi-extensions/canvas-structured-output.js +2 -1
  403. package/dist/pi-extensions/canvas-tool-guide.js +2 -1
  404. package/dist/shared/__tests__/env-boundary.test.d.ts +1 -0
  405. package/dist/shared/__tests__/env-boundary.test.js +93 -0
  406. package/dist/shared/env.d.ts +68 -0
  407. package/dist/shared/env.js +171 -0
  408. package/package.json +3 -1
  409. package/runtime.lock.json +2 -2
  410. package/dist/core/canvas/render.d.ts +0 -47
  411. package/dist/core/canvas/render.js +0 -245
@@ -0,0 +1,1339 @@
1
+ // canvas.db's versioned schema chain — the FIRST of this repo's three
2
+ // migration homes. This module owns the v1..vN sequence gated by
3
+ // `PRAGMA user_version`: schema DDL, one-time data backfills, and the
4
+ // journaled on-disk-lane splice (`../../migrations/registry.ts`'s
5
+ // `journaled` entries, appended at the tail of `MIGRATIONS`). A step here
6
+ // runs inside `db.ts`'s per-open `migrate()`, exactly once per database,
7
+ // transactionally.
8
+ //
9
+ // The other two homes:
10
+ // - `../../migrations/registry.ts` — the `STATE_MIGRATIONS` registry for
11
+ // on-disk memory documents, profile manifests, and the corpus lane,
12
+ // driven by `crtr sys migrate`. Its registrants may import ONLY
13
+ // fs/frontmatter utilities — never canvas-db or canvas accessors — so
14
+ // the file stays legal in every CLI leaf's import graph. That is why
15
+ // this chain (which reads node meta, queries other tables mid-step, and
16
+ // builds shell commands from canvas data) is NOT a registrant there.
17
+ // - `../../daemon/boot-migrations.ts` — filesystem repairs that run
18
+ // unconditionally at every daemon boot, outside any version gate
19
+ // (idempotent by their own convergence, not `user_version`).
20
+ //
21
+ // `db.ts` itself keeps only connection management (`openDb`/`withCanvasWrite`
22
+ // /`closeDb`) and re-exports `migrate`/`MIGRATIONS` from here so external
23
+ // imports are unaffected by this split.
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { basename, join, resolve } from 'node:path';
26
+ import { crtrHome, isSafeNodeId, nodesRoot, nodeMetaPath } from './paths.js';
27
+ import { STATE_MIGRATIONS } from '../../migrations/registry.js';
28
+ import { customExtensionPaths } from './extensions.js';
29
+ import { updateJsonFileDurably } from './meta-file.js';
30
+ // Schema as a forward-only migration list
31
+ //
32
+ // The schema is the migration list: one place a schema change is expressed,
33
+ // one gate (`PRAGMA user_version`) that applies it. `migrate()` runs every
34
+ // pending step in order and bumps `user_version` after each, so a fresh db and
35
+ // the live fleet (all at `user_version 0`) converge on the same final shape.
36
+ // Migrations are append-only and forward-only — never edit a shipped step.
37
+ /** v1 — the baseline tables + indexes. `IF NOT EXISTS` makes this a no-op on
38
+ * any existing db (the live fleet already has these tables). */
39
+ function baselineSchema(db) {
40
+ db.exec(`
41
+ CREATE TABLE IF NOT EXISTS nodes (
42
+ node_id TEXT PRIMARY KEY,
43
+ name TEXT NOT NULL,
44
+ kind TEXT NOT NULL,
45
+ mode TEXT NOT NULL DEFAULT 'base',
46
+ lifecycle TEXT NOT NULL DEFAULT 'terminal',
47
+ status TEXT NOT NULL DEFAULT 'active',
48
+ cwd TEXT NOT NULL,
49
+ parent TEXT,
50
+ created TEXT NOT NULL
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS edges (
54
+ type TEXT NOT NULL, -- 'subscribes_to' | 'spawned_by'
55
+ from_id TEXT NOT NULL,
56
+ to_id TEXT NOT NULL,
57
+ active INTEGER NOT NULL DEFAULT 1,
58
+ created TEXT NOT NULL,
59
+ PRIMARY KEY (type, from_id, to_id)
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
63
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
64
+ CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
65
+ `);
66
+ }
67
+ /** A table's current columns, used to make additive migrations safe to re-run
68
+ * against a partially applied schema. Shared across every table this chain
69
+ * alters in place (`nodes`, `consult_outbox`, `crons`) instead of each step
70
+ * hand-rolling its own `PRAGMA table_info` + `Set` build. */
71
+ function tableColumns(db, table) {
72
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name));
73
+ }
74
+ function addColumnIfMissing(db, columns, name, ddl) {
75
+ if (columns.has(name))
76
+ return;
77
+ db.exec(ddl);
78
+ columns.add(name);
79
+ }
80
+ /** `nodes`-table convenience alias — every existing call site names it this
81
+ * way, and the table this chain adds columns to most is `nodes`. */
82
+ function nodeColumns(db) {
83
+ return tableColumns(db, 'nodes');
84
+ }
85
+ const addNodeColumnIfMissing = addColumnIfMissing;
86
+ /** v2 — the additive runtime columns: `intent, pi_pid, window, tmux_session`.
87
+ * `status` already lives in the baseline row, so it is NOT re-added here. All
88
+ * four default to NULL; v3 backfills them. */
89
+ function addRuntimeColumns(db) {
90
+ const columns = nodeColumns(db);
91
+ addNodeColumnIfMissing(db, columns, 'intent', `ALTER TABLE nodes ADD COLUMN intent TEXT;`);
92
+ addNodeColumnIfMissing(db, columns, 'pi_pid', `ALTER TABLE nodes ADD COLUMN pi_pid INTEGER;`);
93
+ addNodeColumnIfMissing(db, columns, 'window', `ALTER TABLE nodes ADD COLUMN window TEXT;`);
94
+ addNodeColumnIfMissing(db, columns, 'tmux_session', `ALTER TABLE nodes ADD COLUMN tmux_session TEXT;`);
95
+ }
96
+ /** v3 — DATA backfill. The runtime fields
97
+ * (`intent, pi_pid, window, tmux_session`) become authoritative in the row;
98
+ * copy each existing node's values out of its meta.json into the row columns
99
+ * once, so the version boundary loses no live state. `status` already mirrors
100
+ * the row, so it is not re-copied.
101
+ *
102
+ * LAYERING NOTE — a sanctioned exception: a *data* migration must read
103
+ * meta.json, which db.ts normally would not. Reading it
104
+ * directly here — via paths.ts, a one-time, clearly-labeled boot-time data
105
+ * migration — is the deliberate choice over splitting the `user_version`
106
+ * counter across two modules. Idempotent and gated: it runs exactly once at the
107
+ * v2→v3 boundary. An UPDATE for a node with no row yet hits 0 rows (harmless). */
108
+ function backfillRuntime(db) {
109
+ const root = nodesRoot();
110
+ if (!existsSync(root))
111
+ return;
112
+ const upd = db.prepare('UPDATE nodes SET intent = ?, pi_pid = ?, "window" = ?, tmux_session = ? WHERE node_id = ?');
113
+ for (const id of readdirSync(root)) {
114
+ const p = nodeMetaPath(id);
115
+ if (!existsSync(p))
116
+ continue;
117
+ let meta;
118
+ try {
119
+ meta = JSON.parse(readFileSync(p, 'utf8'));
120
+ }
121
+ catch {
122
+ continue; // a single unreadable meta never aborts the migration
123
+ }
124
+ upd.run(meta['intent'] ?? null, meta['pi_pid'] ?? null, meta['window'] ?? null, meta['tmux_session'] ?? null, id);
125
+ }
126
+ }
127
+ /** v4 — edges referential integrity (IRREVERSIBLE table rebuild). Rebuild the
128
+ * `edges` table so `from_id`/`to_id` are FOREIGN KEYs to `nodes(node_id)` with
129
+ * `ON DELETE CASCADE`: after this, deleting a node can NEVER orphan an edge —
130
+ * the schema GCs them, so prune (and any future delete) doesn't have to. The
131
+ * cargo-cult `PRAGMA foreign_keys = ON` (openDb) finally enforces something.
132
+ *
133
+ * GOTCHA — pre-existing orphan edges. Nothing ever deleted a node before this
134
+ * phase, but manual dir removals / failed spawns can have left `edges` whose
135
+ * endpoint has no `nodes` row. Copying those into the FK-constrained table
136
+ * would violate the constraint, so the rebuild runs with `foreign_keys = OFF`
137
+ * (it MUST be toggled in autocommit — a no-op inside a txn) and the
138
+ * INSERT…SELECT FILTERS orphans: only edges whose BOTH endpoints have a row
139
+ * survive. `PRAGMA foreign_key_check` then confirms the rebuilt table is clean,
140
+ * and a row-count assertion guards that every NON-orphan edge is preserved.
141
+ *
142
+ * CRASH-SAFETY — the whole rebuild is one transaction. A throw ROLLs it back to
143
+ * the pre-v4 state (the `edges_new` scratch table and all copies vanish) and
144
+ * `user_version` stays at 3, so the next open re-runs v4 cleanly: no half-state.
145
+ * Even a crash between COMMIT and the version bump is safe — re-running v4 over
146
+ * an already-rebuilt (clean) `edges` is idempotent in effect. */
147
+ function edgesForeignKeyCascade(db) {
148
+ // Degenerate db with no `edges` table (a real db always has it — v1 created it
149
+ // — but a hand-seeded fixture may not). Nothing to rebuild; create the
150
+ // FK-shaped table fresh so the schema still converges, then we're done.
151
+ const hasEdges = db
152
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'edges'")
153
+ .get() !== undefined;
154
+ if (!hasEdges) {
155
+ db.exec(`
156
+ CREATE TABLE edges (
157
+ type TEXT NOT NULL,
158
+ from_id TEXT NOT NULL,
159
+ to_id TEXT NOT NULL,
160
+ active INTEGER NOT NULL DEFAULT 1,
161
+ created TEXT NOT NULL,
162
+ PRIMARY KEY (type, from_id, to_id),
163
+ FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
164
+ FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
165
+ );
166
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
167
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
168
+ `);
169
+ return;
170
+ }
171
+ // FK enforcement must be toggled OUTSIDE a transaction (a no-op within one).
172
+ db.exec('PRAGMA foreign_keys = OFF;');
173
+ try {
174
+ // Non-orphan edges that MUST survive the rebuild (both endpoints present).
175
+ const expected = db
176
+ .prepare(`SELECT COUNT(*) AS n FROM edges
177
+ WHERE from_id IN (SELECT node_id FROM nodes)
178
+ AND to_id IN (SELECT node_id FROM nodes)`)
179
+ .get().n;
180
+ db.exec('BEGIN');
181
+ try {
182
+ db.exec(`
183
+ CREATE TABLE edges_new (
184
+ type TEXT NOT NULL,
185
+ from_id TEXT NOT NULL,
186
+ to_id TEXT NOT NULL,
187
+ active INTEGER NOT NULL DEFAULT 1,
188
+ created TEXT NOT NULL,
189
+ PRIMARY KEY (type, from_id, to_id),
190
+ FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
191
+ FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
192
+ );
193
+ INSERT INTO edges_new (type, from_id, to_id, active, created)
194
+ SELECT type, from_id, to_id, active, created FROM edges
195
+ WHERE from_id IN (SELECT node_id FROM nodes)
196
+ AND to_id IN (SELECT node_id FROM nodes);
197
+ DROP TABLE edges;
198
+ ALTER TABLE edges_new RENAME TO edges;
199
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(type, to_id);
200
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(type, from_id);
201
+ `);
202
+ // Row-count assertion: every non-orphan edge preserved across the rebuild.
203
+ const after = db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
204
+ if (after !== expected) {
205
+ throw new Error(`edges FK rebuild lost rows: expected ${expected} non-orphan edge(s), got ${after}`);
206
+ }
207
+ // Belt-and-suspenders: the rebuilt table must hold no FK violations (the
208
+ // orphan filter above should guarantee this).
209
+ const violations = db.prepare('PRAGMA foreign_key_check').all();
210
+ if (violations.length > 0) {
211
+ throw new Error(`edges FK rebuild left ${violations.length} foreign-key violation(s)`);
212
+ }
213
+ db.exec('COMMIT');
214
+ }
215
+ catch (e) {
216
+ db.exec('ROLLBACK');
217
+ throw e;
218
+ }
219
+ }
220
+ finally {
221
+ db.exec('PRAGMA foreign_keys = ON;');
222
+ }
223
+ }
224
+ /** v5 — additive runtime column `pane`: LOCATION's authoritative handle, the
225
+ * durable tmux `%pane_id` a node's pane is anchored on. Unlike the derived
226
+ * `window`/`tmux_session` cache (v2), the pane id survives a user
227
+ * `move-pane`/`join-pane`/`break-pane` and window renumbering, so a later step
228
+ * reconciles window/session FROM it and uses pane-existence for liveness.
229
+ * Defaults NULL — nothing reads it until the placement layer lands, so this
230
+ * observes no behavior change. Additive, forward-only. */
231
+ function addPaneColumn(db) {
232
+ const columns = nodeColumns(db);
233
+ addNodeColumnIfMissing(db, columns, 'pane', `ALTER TABLE nodes ADD COLUMN pane TEXT;`);
234
+ }
235
+ /** v6 — the `focuses` table: durable, PLURAL on-screen viewports, one row per
236
+ * viewport (Q7 widens canvas.db from "topology" to "topology + focuses"). Each
237
+ * row is anchored on the durable tmux `%pane_id`; `session` is a derived cache
238
+ * reconciled from the pane; `node_id` is UNIQUE so a node occupies at most one
239
+ * focus (Q5). The focuses table is the CANONICAL focus store — there is no
240
+ * focus.ptr file and no dual-write bridge; placement writes focus rows directly. */
241
+ function addFocusesTable(db) {
242
+ db.exec(`
243
+ CREATE TABLE IF NOT EXISTS focuses (
244
+ focus_id TEXT PRIMARY KEY, -- stable internal id for the viewport
245
+ pane TEXT, -- the durable %pane_id realizing the focus
246
+ session TEXT, -- derived cache of the user session (reconciled from pane)
247
+ node_id TEXT NOT NULL UNIQUE -- the node shown; UNIQUE → a node occupies <=1 focus
248
+ );
249
+ `);
250
+ }
251
+ /** v7 — the `wakeups` table: durable store for scheduled wake-ups (the
252
+ * scheduled-wakeups feature). One row per armed wake. `node_id` is the TARGET
253
+ * and the node-anchored cancel anchor — it carries a `ON DELETE CASCADE` FK to
254
+ * `nodes(node_id)` so a pruned/deleted node's node-anchored wakes are reaped in
255
+ * the same transaction (mirroring the edges v4 cascade; works because
256
+ * `PRAGMA foreign_keys = ON` is set at every open). NULL `node_id` = a
257
+ * canvas-detached wake (deferred spawn / spawn-cron) that survives.
258
+ *
259
+ * `owner_id` is the ARMER, set on EVERY row, and is deliberately a PLAIN indexed
260
+ * column with NO FK (design D1/D2): an FK on `owner_id` would cascade-drop a
261
+ * crashed-then-pruned armer's detached cron, violating Invariant E. A detached
262
+ * wake is never reaped by node-row cascade.
263
+ *
264
+ * `fire_at` is an ISO 8601 UTC string for the NEXT occurrence, so the per-tick
265
+ * due query is a lexicographic `<=` compare. The partial unique index
266
+ * `idx_wakeups_deadline` enforces ≤1 deadline per node. Additive, forward-only. */
267
+ function appendWakeupsTable(db) {
268
+ db.exec(`
269
+ CREATE TABLE IF NOT EXISTS wakeups (
270
+ wakeup_id TEXT PRIMARY KEY,
271
+ node_id TEXT, -- TARGET + node-anchored cancel anchor; NULL = canvas-detached
272
+ owner_id TEXT NOT NULL, -- ARMER; set on EVERY row. PLAIN column, NO FK (design D1/D2)
273
+ fire_at TEXT NOT NULL, -- ISO 8601 UTC; NEXT occurrence; lexicographic <=
274
+ kind TEXT NOT NULL, -- 'bare' | 'noted' | 'deadline' | 'spawn'
275
+ recur TEXT, -- NULL = one-shot; else the pinned recur JSON
276
+ payload TEXT, -- JSON per kind (shared contracts)
277
+ created TEXT NOT NULL,
278
+ FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
279
+ );
280
+ CREATE INDEX IF NOT EXISTS idx_wakeups_fire ON wakeups(fire_at); -- per-tick due query
281
+ CREATE INDEX IF NOT EXISTS idx_wakeups_node ON wakeups(node_id); -- by-node cancel / list
282
+ CREATE INDEX IF NOT EXISTS idx_wakeups_owner ON wakeups(owner_id); -- by-owner reap (ruling A)
283
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wakeups_deadline ON wakeups(node_id) WHERE kind = 'deadline';
284
+ `);
285
+ }
286
+ /** v9 — generalize the `wakeups` table (v7) into the `triggers` table (the
287
+ * unified node-trigger-grammar substrate). The old `kind` axis (bare/noted/
288
+ * deadline/spawn) splits into three orthogonal axes: `action` (what enacts:
289
+ * 'msg'|'new') x `action_mode` (how: 'inbox_message'|'fresh_revive'|
290
+ * 'deadline_message'|'node_birth') x `trigger_type` (when: 'at'|'every'|
291
+ * 'when'|'until') — so a daemon-owned predicate (`--when`) trigger can live in
292
+ * the same table as a clock trigger. Predicate-only columns (`when_script`
293
+ * through `last_stderr`) are additive null/idle for every migrated clock row.
294
+ *
295
+ * Migration mapping, preserving every existing `wakeup_id` as `trigger_id`
296
+ * (so an old wake's id stays cancelable): bare→fresh_revive, noted→
297
+ * inbox_message, deadline→deadline_message (trigger_type='until'), spawn→
298
+ * node_birth. `payload` JSON carries over unchanged — the old per-kind
299
+ * payload shapes (plus SpawnChildOpts) map 1:1 onto the intermediate row, so
300
+ * no JSON transform is needed. The `wakeups` table is then DROPPED — not kept
301
+ * as a parallel table. Additive, forward-only. */
302
+ function migrateWakeupsToTriggers(db) {
303
+ db.exec(`
304
+ CREATE TABLE IF NOT EXISTS triggers (
305
+ trigger_id TEXT PRIMARY KEY,
306
+ owner_id TEXT NOT NULL,
307
+ node_id TEXT, -- TARGET + node-anchored cancel anchor; NULL = canvas-detached
308
+ action TEXT NOT NULL, -- 'msg' | 'new'
309
+ action_mode TEXT NOT NULL, -- 'inbox_message' | 'fresh_revive' | 'deadline_message' | 'node_birth'
310
+ trigger_type TEXT NOT NULL, -- 'at' | 'every' | 'when' | 'until'
311
+ fire_at TEXT, -- clock triggers: NEXT occurrence, ISO 8601 UTC; lexicographic <=
312
+ recur TEXT, -- NULL = one-shot; else the recur JSON the daemon advances
313
+ payload TEXT, -- JSON action payload, discriminated by action_mode
314
+ when_script TEXT, -- predicate triggers: the --when bash source
315
+ eval_every TEXT, -- predicate triggers: evaluation cadence (recur-shaped JSON)
316
+ next_eval_at TEXT, -- predicate triggers: next evaluation instant
317
+ timeout_at TEXT, -- predicate triggers: wall-clock expiry
318
+ eval_state TEXT NOT NULL DEFAULT 'idle', -- 'idle' | 'running'
319
+ eval_started_at TEXT,
320
+ eval_pid INTEGER,
321
+ eval_lease_owner TEXT,
322
+ last_eval_at TEXT,
323
+ last_exit INTEGER,
324
+ last_stderr TEXT,
325
+ created TEXT NOT NULL,
326
+ FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE
327
+ );
328
+ CREATE INDEX IF NOT EXISTS idx_triggers_fire ON triggers(fire_at); -- clock per-tick due query
329
+ CREATE INDEX IF NOT EXISTS idx_triggers_node ON triggers(node_id); -- by-node cancel / list
330
+ CREATE INDEX IF NOT EXISTS idx_triggers_owner ON triggers(owner_id); -- by-owner reap
331
+ CREATE INDEX IF NOT EXISTS idx_triggers_eval ON triggers(eval_state, next_eval_at); -- predicate due query
332
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_deadline ON triggers(node_id) WHERE action_mode = 'deadline_message';
333
+
334
+ INSERT INTO triggers (trigger_id, owner_id, node_id, action, action_mode, trigger_type, fire_at, recur, payload, created)
335
+ SELECT
336
+ wakeup_id,
337
+ owner_id,
338
+ node_id,
339
+ CASE kind WHEN 'spawn' THEN 'new' ELSE 'msg' END,
340
+ CASE kind
341
+ WHEN 'bare' THEN 'fresh_revive'
342
+ WHEN 'noted' THEN 'inbox_message'
343
+ WHEN 'deadline' THEN 'deadline_message'
344
+ WHEN 'spawn' THEN 'node_birth'
345
+ END,
346
+ CASE WHEN kind = 'deadline' THEN 'until' WHEN recur IS NULL THEN 'at' ELSE 'every' END,
347
+ fire_at,
348
+ recur,
349
+ payload,
350
+ created
351
+ FROM wakeups;
352
+
353
+ DROP TABLE wakeups;
354
+ `);
355
+ }
356
+ /** v8 — additive identity column `host_kind`: which HOST launches + supervises a
357
+ * node — `'tmux'` (a pane) or `'broker'` (the headless in-process engine). It is
358
+ * durable IDENTITY mirrored from meta.json like `cwd` (NOT runtime like `pane`),
359
+ * so it survives an index rebuild via the `IDENTITY_KEYS`/`upsertRow` path.
360
+ * Defaults NULL ⇒ `'tmux'` (every existing node is tmux-hosted); no data
361
+ * backfill. Additive, forward-only. */
362
+ function addHostKindColumn(db) {
363
+ const columns = nodeColumns(db);
364
+ addNodeColumnIfMissing(db, columns, 'host_kind', `ALTER TABLE nodes ADD COLUMN host_kind TEXT;`);
365
+ }
366
+ /** v10 — the `canvas_meta` key/value table: a single durable place for
367
+ * small, cross-process canvas-wide scalars that aren't node rows. First user:
368
+ * the boot marker (boot.ts), which records the last-seen kernel boot identity
369
+ * so boot-liveness reconciliation is a single-writer SQLite transaction on
370
+ * this row rather than a race-prone JSON file. Additive, forward-only. */
371
+ function addCanvasMetaTable(db) {
372
+ db.exec(`
373
+ CREATE TABLE IF NOT EXISTS canvas_meta (
374
+ key TEXT PRIMARY KEY,
375
+ value TEXT NOT NULL
376
+ );
377
+ `);
378
+ }
379
+ /** v11 — additive identity column `profile_id`: the profile-directory id
380
+ * (`<slug>-<id>`) a node runs under, chosen once at spawn (inherited from the
381
+ * spawner, or `--profile`). Durable IDENTITY mirrored from meta.json like
382
+ * `cwd`/`host_kind` (NOT runtime like `pane`), so it survives an index rebuild
383
+ * via the `IDENTITY_KEYS`/`upsertRow` path. Defaults NULL for historical
384
+ * root/no-profile rows; new root conversations use the stable root profile
385
+ * id instead. No data backfill. Additive, forward-only. */
386
+ function addProfileIdColumn(db) {
387
+ const columns = nodeColumns(db);
388
+ addNodeColumnIfMissing(db, columns, 'profile_id', `ALTER TABLE nodes ADD COLUMN profile_id TEXT;`);
389
+ }
390
+ /** v12 — additive runtime column `pi_pid_identity`: the LAUNCH-time process-
391
+ * identity fingerprint (`pid.ts`'s `composeIdentity`) recorded alongside
392
+ * `pi_pid` (v2) by `recordPid`, so `headlessBrokerHost.teardown()` can refuse
393
+ * to signal a `pi_pid` the OS has since recycled for an unrelated process
394
+ * (see `NodeRuntime.pi_pid_identity`).
395
+ * Defaults NULL ⇒ no baseline ⇒ the guard fails open, exactly like a node
396
+ * booted before this column existed. No data backfill: an existing `pi_pid`
397
+ * with no recorded identity simply has no launch-time baseline to compare
398
+ * against. Additive, forward-only. */
399
+ function addPidIdentityColumn(db) {
400
+ const columns = nodeColumns(db);
401
+ addNodeColumnIfMissing(db, columns, 'pi_pid_identity', `ALTER TABLE nodes ADD COLUMN pi_pid_identity TEXT;`);
402
+ }
403
+ /** v13 — mutable controller-message wait state. It belongs with runtime state,
404
+ * not meta identity: both the waiting node and an incoming sender can settle it. */
405
+ function addMessageWaitColumn(db) {
406
+ const columns = nodeColumns(db);
407
+ addNodeColumnIfMissing(db, columns, 'waiting_for', `ALTER TABLE nodes ADD COLUMN waiting_for TEXT;`);
408
+ }
409
+ /** v14 — canonical final identity. Existing done rows receive their newest valid
410
+ * final report once; runtime never infers canonical state from report files. */
411
+ function addCanonicalFinalColumns(db) {
412
+ const columns = nodeColumns(db);
413
+ // Metadata-only migration harnesses can legitimately advance user_version on
414
+ // an otherwise empty database. There is no node index to repair in that case.
415
+ if (columns.size === 0)
416
+ return;
417
+ addNodeColumnIfMissing(db, columns, 'final_report', `ALTER TABLE nodes ADD COLUMN final_report TEXT;`);
418
+ addNodeColumnIfMissing(db, columns, 'finalized_at', `ALTER TABLE nodes ADD COLUMN finalized_at TEXT;`);
419
+ const rows = db.prepare("SELECT node_id FROM nodes WHERE status = 'done' AND final_report IS NULL").all();
420
+ const update = db.prepare('UPDATE nodes SET final_report = ?, finalized_at = ? WHERE node_id = ? AND final_report IS NULL');
421
+ for (const { node_id } of rows) {
422
+ const dir = join(nodesRoot(), node_id, 'reports');
423
+ if (!existsSync(dir))
424
+ continue;
425
+ let winner = null;
426
+ for (const name of readdirSync(dir)) {
427
+ if (!name.endsWith('.md'))
428
+ continue;
429
+ try {
430
+ const raw = readFileSync(join(dir, name), 'utf8');
431
+ const match = /^---\nnode: [^\n]+\nkind: final\nts: ([^\n]+)\n---\n/.exec(raw);
432
+ if (match === null || Number.isNaN(Date.parse(match[1])))
433
+ continue;
434
+ const candidate = { name, ts: match[1] };
435
+ if (winner === null || candidate.ts > winner.ts || (candidate.ts === winner.ts && candidate.name > winner.name))
436
+ winner = candidate;
437
+ }
438
+ catch { /* malformed historical reports are not canonical */ }
439
+ }
440
+ if (winner !== null)
441
+ update.run(winner.name, winner.ts, node_id);
442
+ }
443
+ }
444
+ /** v15 — canonicalize persisted launch metadata after a retired generation-root
445
+ * experiment. Canvas hooks are launch-owned; retain every genuine custom extra. */
446
+ function normalizePersistedLaunchExtensions(_db) {
447
+ const root = nodesRoot();
448
+ if (!existsSync(root))
449
+ return;
450
+ for (const id of readdirSync(root)) {
451
+ const path = nodeMetaPath(id);
452
+ if (!existsSync(path))
453
+ continue;
454
+ updateJsonFileDurably(path, (meta) => {
455
+ const launch = meta['launch'];
456
+ if (typeof launch !== 'object' || launch === null || Array.isArray(launch))
457
+ return false;
458
+ const extensions = launch.extensions;
459
+ if (!Array.isArray(extensions) || !extensions.every((value) => typeof value === 'string'))
460
+ return false;
461
+ const custom = customExtensionPaths(extensions);
462
+ if (custom.length === extensions.length)
463
+ return false;
464
+ launch.extensions = custom;
465
+ return true;
466
+ });
467
+ }
468
+ }
469
+ /** v17 — monotonically identifies each explicit wait arm. Delivery settlement
470
+ * uses this watermark as well as the controller identity. */
471
+ function addMessageWaitGenerationColumn(db) {
472
+ const columns = nodeColumns(db);
473
+ addNodeColumnIfMissing(db, columns, 'waiting_for_generation', `ALTER TABLE nodes ADD COLUMN waiting_for_generation INTEGER NOT NULL DEFAULT 0;`);
474
+ }
475
+ /** v18 — create the historical `consult_outbox` table. The old schema stays
476
+ * replayable for existing databases until its later migration removes it.
477
+ * No current user-facing flow creates rows. Its remaining diagnostics and
478
+ * retention readers keep armed/pending legacy rows from losing their node
479
+ * before startup or crash reconciliation terminalizes those rows. */
480
+ function addHumanWorkOutboxTable(db) {
481
+ db.exec(`
482
+ CREATE TABLE IF NOT EXISTS consult_outbox (
483
+ node_id TEXT PRIMARY KEY, -- legacy work node id
484
+ dir TEXT NOT NULL, -- legacy ticket directory
485
+ root TEXT NOT NULL, -- legacy registered root
486
+ request_id TEXT NOT NULL, -- legacy request id
487
+ status TEXT NOT NULL, -- armed | pending | acked | errored | canceled
488
+ final_report TEXT, -- legacy final report basename
489
+ updated_at TEXT NOT NULL
490
+ );
491
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_status ON consult_outbox(status);
492
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_dir ON consult_outbox(dir);
493
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_dir_request_status ON consult_outbox(dir, request_id, status);
494
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_dir_status ON consult_outbox(dir, status);
495
+ `);
496
+ }
497
+ /** v19 — generalize the physical `consult_outbox` ABI into human work without
498
+ * changing its table name. Existing rows and old literal follow-up inserts gain
499
+ * `follow_up` by default; Visual reserves namespaced statuses so old
500
+ * `armed`/`pending` reconcilers cannot select it. */
501
+ function addHumanWorkColumns(db) {
502
+ const columns = tableColumns(db, 'consult_outbox');
503
+ const add = (name, ddl) => addColumnIfMissing(db, columns, name, ddl);
504
+ add('work_kind', "ALTER TABLE consult_outbox ADD COLUMN work_kind TEXT NOT NULL DEFAULT 'follow_up';");
505
+ add('interaction_id', 'ALTER TABLE consult_outbox ADD COLUMN interaction_id TEXT;');
506
+ add('generation_id', 'ALTER TABLE consult_outbox ADD COLUMN generation_id TEXT;');
507
+ add('interaction_snapshot', 'ALTER TABLE consult_outbox ADD COLUMN interaction_snapshot TEXT;');
508
+ add('handler_command', 'ALTER TABLE consult_outbox ADD COLUMN handler_command TEXT;');
509
+ add('handler_args_json', 'ALTER TABLE consult_outbox ADD COLUMN handler_args_json TEXT;');
510
+ add('claim_token', 'ALTER TABLE consult_outbox ADD COLUMN claim_token TEXT;');
511
+ db.exec(`
512
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_human_work_status ON consult_outbox(work_kind, status);
513
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_human_work_dir_status ON consult_outbox(dir, work_kind, status);
514
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_human_work_request_status ON consult_outbox(dir, request_id, work_kind, status);
515
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_consult_outbox_visual_request ON consult_outbox(dir, request_id, work_kind) WHERE work_kind = 'visual';
516
+ `);
517
+ }
518
+ /** v20 — index `nodes(parent)` and `nodes(created)`. `listNodes()`'s
519
+ * `ORDER BY created` and the `children = rows.filter(r => r.parent === id)`
520
+ * child lookup (`daemon/api/handlers/nodes.ts`'s `edgesFor`) were both
521
+ * full-table scans under the daemon's single event loop; a live fleet's
522
+ * `GET /v1/nodes/:id` measured >15s under concurrent-viewer load. Additive,
523
+ * forward-only — `IF NOT EXISTS` makes re-running a no-op. */
524
+ function addNodeParentCreatedIndexes(db) {
525
+ db.exec(`
526
+ CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent);
527
+ CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created);
528
+ `);
529
+ }
530
+ /** v21 — the `crons` + `cron_runs` tables. One scheduling primitive: run this
531
+ * bash, then. Key contracts pinned by the spec:
532
+ * - `created_by` is provenance ONLY — no FK, never cascades; a lifelong job
533
+ * is not hostage to the window that typed it.
534
+ * - `anchor_node` is the OPT-IN node coupling, carrying the one FK
535
+ * (ON DELETE CASCADE); NULL = outlives every node on the canvas.
536
+ * - the partial unique index `idx_crons_deadline` is the ≤1-deadline-per-node
537
+ * backstop for the future `node wait deadline` sugar (cancel_on_wake rows).
538
+ * - execution context (cwd/env/profile) is SNAPSHOTTED at arm time, never
539
+ * resolved at run time.
540
+ * - `cron_runs` is a bounded ring (last 20 per cron, pruned by the DAO's
541
+ * recordCronRun).
542
+ * Additive and forward-only. */
543
+ function addCronsTables(db) {
544
+ db.exec(`
545
+ CREATE TABLE IF NOT EXISTS crons (
546
+ cron_id TEXT PRIMARY KEY,
547
+ name TEXT NOT NULL, -- agent-legible label; how it shows in list
548
+ created_by TEXT, -- provenance only, NO FK, never cascades
549
+ command TEXT NOT NULL, -- the bash
550
+
551
+ fire_at TEXT NOT NULL, -- next occurrence, ISO 8601 UTC; lexicographic <=
552
+ recur TEXT, -- NULL = one-shot; else recur JSON the daemon advances
553
+ tz TEXT, -- IANA zone for calendar cadence
554
+ expires_at TEXT, -- clock bound; row is deleted when passed
555
+
556
+ anchor_node TEXT, -- opt-in node coupling; NULL = outlives every node
557
+ cancel_on_wake INTEGER NOT NULL DEFAULT 0, -- deleted when anchor revives (deadline rule)
558
+
559
+ cwd TEXT NOT NULL, -- snapshotted at creation
560
+ env_json TEXT,
561
+ profile TEXT,
562
+ scope TEXT NOT NULL DEFAULT 'profile', -- 'profile' | 'global'
563
+
564
+ run_timeout_s INTEGER NOT NULL DEFAULT 300,
565
+ overlap TEXT NOT NULL DEFAULT 'skip', -- 'skip' | 'queue' | 'replace'
566
+ on_output TEXT NOT NULL DEFAULT 'on-failure',-- 'silent'|'on-failure'|'always'|'on-change'
567
+ sink TEXT NOT NULL, -- JSON: {node}|{spawn,kind,...}|{human}
568
+ tier TEXT NOT NULL DEFAULT 'normal',
569
+ last_output_hash TEXT, -- drives 'on-change'
570
+
571
+ state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'paused'
572
+ run_state TEXT NOT NULL DEFAULT 'idle', -- 'idle' | 'running'
573
+ run_pid INTEGER,
574
+ run_pid_identity TEXT, -- v23: launch-time pid fingerprint; a kill requires a MATCH
575
+ run_started_at TEXT,
576
+ run_lease_owner TEXT,
577
+ run_out_of_band INTEGER NOT NULL DEFAULT 0, -- v24: this lease is a manual cron run, not a scheduled fire
578
+
579
+ created TEXT NOT NULL,
580
+ updated TEXT NOT NULL,
581
+ FOREIGN KEY (anchor_node) REFERENCES nodes(node_id) ON DELETE CASCADE
582
+ );
583
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_crons_deadline ON crons(anchor_node) WHERE cancel_on_wake = 1;
584
+ CREATE INDEX IF NOT EXISTS idx_crons_fire ON crons(fire_at);
585
+
586
+ CREATE TABLE IF NOT EXISTS cron_runs ( -- bounded ring, last 20 per cron
587
+ run_id TEXT PRIMARY KEY,
588
+ cron_id TEXT NOT NULL,
589
+ started TEXT NOT NULL,
590
+ finished TEXT,
591
+ duration_ms INTEGER,
592
+ exit_code INTEGER, -- NULL while running; -1 for timeout kill
593
+ stdout_head TEXT, -- capped at 4KB
594
+ stderr_head TEXT,
595
+ delivered TEXT, -- what the sink did, or why it didn't
596
+ FOREIGN KEY (cron_id) REFERENCES crons(cron_id) ON DELETE CASCADE
597
+ );
598
+ CREATE INDEX IF NOT EXISTS idx_cron_runs_cron ON cron_runs(cron_id, started);
599
+ `);
600
+ }
601
+ function shellSingleQuote(value) {
602
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
603
+ }
604
+ function uniqueHeredocDelimiter(body, stem) {
605
+ let suffix = 0;
606
+ while (true) {
607
+ const delimiter = `${stem}_${suffix}`;
608
+ if (!body.split('\n').includes(delimiter))
609
+ return delimiter;
610
+ suffix++;
611
+ }
612
+ }
613
+ function heredocCommand(command, body, stem) {
614
+ const delimiter = uniqueHeredocDelimiter(body, stem);
615
+ return `${command} <<'${delimiter}'\n${body}\n${delimiter}`;
616
+ }
617
+ /** `payload` was a NULLABLE text column, so NULL, non-string, and legacy or
618
+ * hand-edited JSON are all shapes the retired schema declared legal. A schema
619
+ * migration must never fail on data shape, so anything unreadable degrades to
620
+ * an empty payload; the per-key readers below then decide whether what is left
621
+ * is enough to convert the row. */
622
+ function migrationPayload(raw) {
623
+ if (typeof raw !== 'string')
624
+ return {};
625
+ try {
626
+ const parsed = JSON.parse(raw);
627
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
628
+ return {};
629
+ return parsed;
630
+ }
631
+ catch {
632
+ return {};
633
+ }
634
+ }
635
+ function requiredString(payload, key, triggerId) {
636
+ const value = payload[key];
637
+ if (typeof value !== 'string' || value.trim() === '') {
638
+ throw new Error(`trigger ${triggerId} payload requires non-empty ${key}`);
639
+ }
640
+ return value;
641
+ }
642
+ function optionalString(payload, key) {
643
+ const value = payload[key];
644
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
645
+ }
646
+ /** The same reader over a `triggers` ROW rather than its payload: returns the
647
+ * column when it holds a usable string, `undefined` otherwise. */
648
+ function optionalColumn(row, key) {
649
+ const value = row[key];
650
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
651
+ }
652
+ function migratedMessageCommand(target, tier, body, triggerId) {
653
+ return heredocCommand(`crtr node message send --to ${shellSingleQuote(target)} --tier ${shellSingleQuote(tier)}`, body, `CRTR_MIGRATED_${triggerId.replace(/[^A-Za-z0-9]/g, '_')}`);
654
+ }
655
+ /** A body-less predicate message: the retired daemon filled the body from the
656
+ * gate's stdout at fire time (`p.body ?? outcome.stdout`), so the migrated
657
+ * script pipes the captured gate output into the send. */
658
+ function migratedGateStdoutMessageCommand(target, tier) {
659
+ return `printf '%s\\n' "$CRTR_GATE_STDOUT" | crtr node message send --to ${shellSingleQuote(target)} --tier ${shellSingleQuote(tier)}`;
660
+ }
661
+ function migratedSpawnCommand(payload, triggerId) {
662
+ const flags = [`crtr node new --kind ${shellSingleQuote(requiredString(payload, 'kind', triggerId))}`];
663
+ const strings = [
664
+ ['mode', 'mode'],
665
+ ['cwd', 'cwd'],
666
+ ['name', 'name'],
667
+ ['parent', 'parent'],
668
+ ['worktreeBase', 'worktree'],
669
+ ['forkFrom', 'fork-from'],
670
+ ['model', 'model'],
671
+ ['profile', 'profile'],
672
+ ['situationalContext', 'situational-context'],
673
+ ];
674
+ for (const [key, flag] of strings) {
675
+ const value = optionalString(payload, key);
676
+ if (value !== undefined)
677
+ flags.push(`--${flag} ${shellSingleQuote(value)}`);
678
+ }
679
+ if (payload['root'] === true)
680
+ flags.push('--root');
681
+ const prompt = requiredString(payload, 'prompt', triggerId);
682
+ return heredocCommand(flags.join(' '), prompt, `CRTR_MIGRATED_${triggerId.replace(/[^A-Za-z0-9]/g, '_')}`);
683
+ }
684
+ /** The `canvas_meta` key holding the v22 skip ledger: a JSON array of
685
+ * `{ trigger_id, reason }` for every retired schedule the migration could not
686
+ * express as a cron. Written only when at least one row is skipped. */
687
+ const V22_SKIPPED_META_KEY = 'migration.v22.skipped';
688
+ /** The execution-context cwd for a schedule whose armer is gone. `owner_id`
689
+ * carries NO foreign key by design (see v7), so a crashed-then-pruned armer's
690
+ * detached schedule is an EXPECTED row, not a corrupt one — it must still
691
+ * convert. Falls back to the oldest root node's cwd (the ambient canvas root),
692
+ * then to this process's cwd. */
693
+ function ambientMigrationCwd(db) {
694
+ const root = db
695
+ .prepare('SELECT cwd FROM nodes WHERE parent IS NULL ORDER BY created LIMIT 1')
696
+ .get();
697
+ return (root !== undefined ? optionalColumn(root, 'cwd') : undefined) ?? process.cwd();
698
+ }
699
+ /** Best-effort reap of in-flight predicate evaluations before the table that
700
+ * records them is dropped. The retired cancel path killed the evaluation's
701
+ * process GROUP; without this the migration would orphan a running gate
702
+ * script with nothing left on disk that names it. */
703
+ function reapRunningPredicateEvaluations(db) {
704
+ let running;
705
+ try {
706
+ running = db
707
+ .prepare("SELECT eval_pid FROM triggers WHERE eval_state = 'running' AND eval_pid IS NOT NULL")
708
+ .all();
709
+ }
710
+ catch {
711
+ return; // a drifted table shape has no evaluations we can name; never fatal
712
+ }
713
+ for (const row of running) {
714
+ const pid = row['eval_pid'];
715
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 1)
716
+ continue;
717
+ try {
718
+ process.kill(-pid, 'SIGTERM');
719
+ }
720
+ catch {
721
+ /* the evaluation already exited, or the group is gone — nothing to reap */
722
+ }
723
+ }
724
+ }
725
+ /** v22 — migrate each scheduled action into a shell cron, preserving its id and
726
+ * execution context, then remove the superseded table.
727
+ *
728
+ * SKIP-AND-RECORD, never throw. `migrate()` runs on every `openDb()`, so a
729
+ * migration that hard-fails on data shape would make EVERY crtr process fail to
730
+ * open the canvas forever. Rows the retired schema declared legal but this step
731
+ * cannot express (unknown action/timing pairs, a missing fire time, drifted
732
+ * JSON) are skipped, named in the `canvas_meta` ledger under
733
+ * `migration.v22.skipped`, and warned about — the schema always advances.
734
+ * `INSERT OR REPLACE` makes a re-run over already-converted ids converge rather
735
+ * than collide on the `cron_id` primary key. */
736
+ function migrateTriggersToCrons(db) {
737
+ const skipped = [];
738
+ // A db already wedged by an interrupted pre-atomicity run (crons committed,
739
+ // the table dropped, `user_version` still 21) re-runs this step with nothing
740
+ // left to convert; it must advance, not throw.
741
+ const hasTriggers = db
742
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'triggers'")
743
+ .get() !== undefined;
744
+ if (hasTriggers)
745
+ reapRunningPredicateEvaluations(db);
746
+ let rows = [];
747
+ if (hasTriggers) {
748
+ try {
749
+ rows = db.prepare('SELECT * FROM triggers ORDER BY created, trigger_id').all();
750
+ }
751
+ catch (err) {
752
+ // A column-drifted table (no `created`/`trigger_id`) must not brick every
753
+ // open either: record that its rows were unreadable and advance.
754
+ skipped.push({
755
+ trigger_id: '<unreadable table>',
756
+ reason: `triggers table could not be read: ${err instanceof Error ? err.message : String(err)}`,
757
+ });
758
+ }
759
+ }
760
+ const fallbackCwd = ambientMigrationCwd(db);
761
+ const insert = db.prepare(`
762
+ INSERT OR REPLACE INTO crons (
763
+ cron_id, name, created_by, command, fire_at, recur, tz, expires_at,
764
+ anchor_node, cancel_on_wake, cwd, env_json, profile, scope,
765
+ run_timeout_s, overlap, on_output, sink, tier, created, updated
766
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, 'profile', ?, 'skip', 'on-failure', '{}', ?, ?, ?)`);
767
+ for (const row of rows) {
768
+ const triggerId = optionalColumn(row, 'trigger_id');
769
+ if (triggerId === undefined) {
770
+ skipped.push({ trigger_id: '<unidentified row>', reason: 'row carries no trigger_id' });
771
+ continue;
772
+ }
773
+ try {
774
+ convertTriggerRow(db, insert, row, triggerId, fallbackCwd);
775
+ }
776
+ catch (err) {
777
+ skipped.push({ trigger_id: triggerId, reason: err instanceof Error ? err.message : String(err) });
778
+ }
779
+ }
780
+ if (skipped.length > 0) {
781
+ db.prepare('INSERT INTO canvas_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(V22_SKIPPED_META_KEY, JSON.stringify(skipped));
782
+ console.warn(`[canvas migration v22] ${skipped.length} retired schedule(s) could not be converted to crons and were dropped; ` +
783
+ `see canvas_meta['${V22_SKIPPED_META_KEY}']: ` +
784
+ skipped.map((entry) => `${entry.trigger_id} (${entry.reason})`).join('; '));
785
+ }
786
+ db.exec(`
787
+ DROP INDEX IF EXISTS idx_triggers_fire;
788
+ DROP INDEX IF EXISTS idx_triggers_node;
789
+ DROP INDEX IF EXISTS idx_triggers_owner;
790
+ DROP INDEX IF EXISTS idx_triggers_eval;
791
+ DROP INDEX IF EXISTS idx_triggers_deadline;
792
+ DROP TABLE IF EXISTS triggers;
793
+ `);
794
+ }
795
+ /** Convert ONE retired schedule row into a cron row. Throws only when the row
796
+ * cannot be expressed as a cron at all; the caller turns that into a recorded
797
+ * skip so the schema still advances. */
798
+ function convertTriggerRow(db, insert, row, triggerId, fallbackCwd) {
799
+ const ownerId = requiredString(row, 'owner_id', triggerId);
800
+ // A pruned armer is expected, not corrupt (no FK on `owner_id`, by design):
801
+ // keep the schedule and snapshot the ambient root cwd instead of its armer's.
802
+ const owner = db.prepare('SELECT cwd, profile_id FROM nodes WHERE node_id = ?').get(ownerId);
803
+ const cwd = (owner === undefined ? undefined : optionalColumn(owner, 'cwd')) ?? fallbackCwd;
804
+ const profile = owner === undefined ? null : optionalColumn(owner, 'profile_id') ?? null;
805
+ const nodeId = optionalColumn(row, 'node_id') ?? null;
806
+ const action = requiredString(row, 'action', triggerId);
807
+ const actionMode = requiredString(row, 'action_mode', triggerId);
808
+ const triggerType = requiredString(row, 'trigger_type', triggerId);
809
+ const payload = migrationPayload(row['payload']);
810
+ const created = optionalColumn(row, 'created') ?? new Date().toISOString();
811
+ const tier = actionMode === 'deadline_message' ? 'urgent' : optionalString(payload, 'tier') ?? 'normal';
812
+ const label = optionalString(payload, 'label');
813
+ let command;
814
+ let name;
815
+ // A `--when` schedule could legally omit its body: the retired daemon filled
816
+ // it from the gate's stdout at fire time. Such a row pipes the captured gate
817
+ // output into the send instead of carrying a heredoc.
818
+ let bodyFromGate = false;
819
+ if (action === 'msg' && (actionMode === 'inbox_message' || actionMode === 'deadline_message')) {
820
+ if (nodeId === null)
821
+ throw new Error('has no message target');
822
+ const body = optionalString(payload, 'body');
823
+ if (body !== undefined) {
824
+ command = migratedMessageCommand(nodeId, tier, body, triggerId);
825
+ }
826
+ else if (triggerType === 'when') {
827
+ bodyFromGate = true;
828
+ command = migratedGateStdoutMessageCommand(nodeId, tier);
829
+ }
830
+ else {
831
+ // Not expressible from a gate: synthesize the most informative body the
832
+ // row still holds rather than losing the enactment.
833
+ command = migratedMessageCommand(nodeId, tier, label ?? 'Scheduled message (no body was recorded when this schedule was armed).', triggerId);
834
+ }
835
+ name = label ?? (actionMode === 'deadline_message' ? 'deadline' : 'scheduled message');
836
+ }
837
+ else if (action === 'msg' && actionMode === 'fresh_revive') {
838
+ if (nodeId === null)
839
+ throw new Error('has no revive target');
840
+ command = `crtr node lifecycle revive ${shellSingleQuote(nodeId)} --fresh`;
841
+ name = label ?? 'scheduled fresh revive';
842
+ }
843
+ else if (action === 'new' && actionMode === 'node_birth') {
844
+ command = migratedSpawnCommand(payload, triggerId);
845
+ name = optionalString(payload, 'name') ?? `scheduled ${requiredString(payload, 'kind', triggerId)} node`;
846
+ }
847
+ else {
848
+ throw new Error(`has unsupported action ${action}/${actionMode}`);
849
+ }
850
+ let fireAt;
851
+ let recur;
852
+ let expiresAt = null;
853
+ if (triggerType === 'when') {
854
+ fireAt = requiredString(row, 'next_eval_at', triggerId);
855
+ // A when-row with no evaluation cadence would silently become a ONE-SHOT
856
+ // gate that evaporates on its first unsatisfied check; skip-and-record it.
857
+ recur = requiredString(row, 'eval_every', triggerId);
858
+ expiresAt = optionalColumn(row, 'timeout_at') ?? null;
859
+ const gate = requiredString(row, 'when_script', triggerId);
860
+ const open = bodyFromGate ? `if ! CRTR_GATE_STDOUT="$(\n${gate}\n)"; then` : `if ! (\n${gate}\n); then`;
861
+ // `&&`, not a newline: the run's exit status must be the ACTION's, so a
862
+ // failed enactment escalates through the cron's on-failure path and leaves
863
+ // the cron armed instead of silently self-cancelling on a lost message.
864
+ // The action is BRACE-GROUPED because it may be heredoc-shaped: a `&&`
865
+ // appended straight onto it would land on the heredoc's closing delimiter
866
+ // line, which then never terminates the document.
867
+ command = `${open}\n exit 0\nfi\n{\n${command}\n} && crtr cron cancel "$CRTR_CRON_ID"`;
868
+ }
869
+ else if (triggerType === 'at' || triggerType === 'every' || triggerType === 'until') {
870
+ fireAt = requiredString(row, 'fire_at', triggerId);
871
+ recur = triggerType === 'every' ? requiredString(row, 'recur', triggerId) : null;
872
+ }
873
+ else {
874
+ throw new Error(`has unknown timing type ${triggerType}`);
875
+ }
876
+ // `INSERT OR REPLACE` (the re-run convergence for a repeated `cron_id`) would
877
+ // ALSO silently evict a conflicting row on the ≤1-deadline-per-node partial
878
+ // index. Only drifted data can produce that shape, and it must be recorded,
879
+ // not resolved in silence: keep the earlier deadline, skip this one.
880
+ if (actionMode === 'deadline_message' && nodeId !== null) {
881
+ const rival = db
882
+ .prepare('SELECT cron_id FROM crons WHERE anchor_node = ? AND cancel_on_wake = 1 AND cron_id <> ?')
883
+ .get(nodeId, triggerId);
884
+ if (rival !== undefined) {
885
+ throw new Error(`node ${nodeId} already has a migrated deadline (${String(rival['cron_id'])}); only one is allowed`);
886
+ }
887
+ }
888
+ insert.run(triggerId, name, ownerId, command, fireAt, recur, expiresAt, nodeId, actionMode === 'deadline_message' ? 1 : 0, cwd, profile, 300,
889
+ // `tier` and `sink` are INERT for migrated rows: the delivery lives entirely
890
+ // inside `command` (which carries its own `--tier`), and `sink = '{}'`
891
+ // parses to "no sink armed". `tier` is preserved only as provenance of what
892
+ // the retired schedule would have sent.
893
+ tier, created, created);
894
+ }
895
+ /** v23 — pid-REUSE guard for the cron run lease: `crons.run_pid_identity` holds
896
+ * the `capturePidIdentities` fingerprint of the run's process, captured at lease
897
+ * acquisition beside `run_pid`. Every kill site (stale-lease recovery,
898
+ * `cancelCron`, `cancelCronsOnWake`, deadline displacement) re-captures the
899
+ * identity of the recorded pid and refuses to signal unless it MATCHES.
900
+ *
901
+ * Without it, a bare pid recorded by a daemon life that ended (SIGKILL, host
902
+ * reboot) is compared only with `isPidAlive` — after a reboot the pid comes
903
+ * from a dead kernel's pid space, a low pid is likely recycled, and the run's
904
+ * own timeout is trivially exceeded, so recovery SIGTERMs an unrelated user
905
+ * process group. Rows written before this column exist have NULL identity and
906
+ * therefore fall to the fail-closed path (release the lease, never signal). */
907
+ function addCronRunPidIdentityColumn(db) {
908
+ const columns = tableColumns(db, 'crons');
909
+ // A metadata-only harness db may have no `crons` table at all (PRAGMA returns
910
+ // no rows); there is nothing to alter and the version must still advance.
911
+ if (columns.size === 0 || columns.has('run_pid_identity'))
912
+ return;
913
+ db.exec('ALTER TABLE crons ADD COLUMN run_pid_identity TEXT;');
914
+ }
915
+ /** v24 — mark an OUT-OF-BAND run lease, and drop the never-implemented
916
+ * `crons.queued` column.
917
+ *
918
+ * `run_out_of_band` is set by `acquireCronRunLease` when the run is a `cron run`
919
+ * (the manual verify verb) rather than a scheduled fire. The overlap pass
920
+ * selects on `run_state = 'running'` alone, so without this marker a manual run
921
+ * that happens to straddle a real occurrence gets that occurrence advanced past
922
+ * (`overlap=skip` — a scheduled fire silently lost to a verification) or gets
923
+ * itself SIGTERMed (`overlap=replace`). The overlap pass now excludes it.
924
+ *
925
+ * `queued` was written by nothing and read by nothing: `overlap='queue'` is
926
+ * implemented purely by LEAVING `fire_at` past-due, so the depth is structural
927
+ * (one occurrence per row) and needs no counter. A column documenting a policy
928
+ * it does not implement misleads the next reader, so it goes. */
929
+ function addCronOutOfBandColumn(db) {
930
+ const columns = tableColumns(db, 'crons');
931
+ // A metadata-only harness db may have no `crons` table at all (PRAGMA returns
932
+ // no rows); there is nothing to alter and the version must still advance.
933
+ if (columns.size === 0)
934
+ return;
935
+ if (!columns.has('run_out_of_band')) {
936
+ db.exec('ALTER TABLE crons ADD COLUMN run_out_of_band INTEGER NOT NULL DEFAULT 0;');
937
+ }
938
+ if (columns.has('queued'))
939
+ db.exec('ALTER TABLE crons DROP COLUMN queued;');
940
+ }
941
+ /** v25 — the `warm_pool` table: the spare-node pool behind `prefer_warm`.
942
+ *
943
+ * A spare IS a real canvas node (its broker is already booted against a real
944
+ * node dir); the row here is the marker that it has not been handed to anyone
945
+ * yet. Membership in this table is what hides a node from every listing
946
+ * (`listNodes`/`rosterNodes`/`listResumableRoots`) — an unclaimed spare must
947
+ * never surface as a phantom empty conversation in a user's sidebar. Claiming
948
+ * is exactly the DELETE of this row, taken under the canvas write-lock, so two
949
+ * concurrent creates can never be handed the same spare.
950
+ *
951
+ * `recipe_key` is the frozen-tuple digest a claim must match exactly (see
952
+ * `runtime/warm-pool.ts`); the FK cascade reaps the marker when the node row
953
+ * goes away. */
954
+ function addWarmPoolTable(db) {
955
+ db.exec(`
956
+ CREATE TABLE IF NOT EXISTS warm_pool (
957
+ node_id TEXT PRIMARY KEY REFERENCES nodes(node_id) ON DELETE CASCADE,
958
+ recipe_key TEXT NOT NULL, -- frozen launch tuple this spare was minted for
959
+ created TEXT NOT NULL -- ISO 8601, mint time (oldest spare claimed first)
960
+ );
961
+ CREATE INDEX IF NOT EXISTS idx_warm_pool_recipe ON warm_pool(recipe_key, created);
962
+ `);
963
+ }
964
+ /** v26 — narrow `consult_outbox` for the humanloop absorption into
965
+ * `src/core/human/` (absorb-humanloop
966
+ * design, "The human-work receipt table"; Landing 2 Part E task E2). The
967
+ * five-column trust binding (`handler_command`, `handler_args_json`,
968
+ * `claim_token`, `generation_id`, `interaction_snapshot`) existed only to let
969
+ * crtrd re-verify that a Visual event arrived through crouter's own
970
+ * registered handler argv — that registry is deleted in this landing, so
971
+ * there is nothing left to bind. `dir`/`root` collapse into `ticket_node_id`,
972
+ * the bridge node id a ticket's directory is derived from at every call site
973
+ * (`ticketDir(nodeId)` in `src/core/human/root.ts`) — `basename(dir)` IS that
974
+ * node id today, already relied on as such at
975
+ * `src/daemon/api/handlers/human.ts:290` (`const jobId = basename(dir)`) and
976
+ * `src/core/canvas/attention.ts:21`.
977
+ *
978
+ * THIS IS A TABLE REBUILD, NOT `ALTER TABLE … DROP COLUMN`: six of the nine
979
+ * existing indexes are on `dir` (`idx_consult_outbox_dir`,
980
+ * `idx_consult_outbox_dir_request_status`, `idx_consult_outbox_dir_status`
981
+ * from v18; `idx_consult_outbox_human_work_dir_status`,
982
+ * `idx_consult_outbox_human_work_request_status`, and the partial-unique
983
+ * `idx_consult_outbox_visual_request` from v19), and SQLite refuses
984
+ * `DROP COLUMN` on any indexed column. `ticket_node_id` cannot be a SQL
985
+ * DEFAULT either — SQLite has no `basename()` — so it is computed per row in
986
+ * JS: read every existing row through a prepared SELECT, insert each into a
987
+ * freshly created `consult_outbox_new` with `ticket_node_id = basename(dir)`,
988
+ * drop the old table (dropping its indexes with it), rename the new table
989
+ * into place, and recreate exactly five indexes on the narrowed shape. Every
990
+ * row is carried, including terminal ones, EXCEPT a row whose legacy `dir` is
991
+ * not a canonical direct child of its recorded `root`, or whose basename is
992
+ * not a safe node id — either would create an unaddressable or aliased ticket
993
+ * id once `root` is discarded. A warning names how many are dropped.
994
+ *
995
+ * THERE IS NO ROLLBACK. A sealed older runtime generation still running
996
+ * against this canvas.db fails on the Visual lane (`INSERT INTO
997
+ * consult_outbox (… dir, root, handler_command …)`) once this has run —
998
+ * acceptable because this migration lands WITH a generation roll
999
+ * (`npm run build && npm run install-runtime && crtr sys daemon restart`),
1000
+ * never alongside one. Not self-transactional: like every ordinary migration
1001
+ * it runs inside `migrate()`'s per-step `BEGIN IMMEDIATE`, so an interrupted
1002
+ * run leaves `consult_outbox` exactly as it was (SQLite DDL is transactional)
1003
+ * and a re-open resumes cleanly at the same step. */
1004
+ function narrowHumanWorkOutbox(db) {
1005
+ const rows = db
1006
+ .prepare('SELECT node_id, dir, root, request_id, work_kind, status, final_report, updated_at FROM consult_outbox')
1007
+ .all();
1008
+ db.exec(`
1009
+ CREATE TABLE consult_outbox_new (
1010
+ node_id TEXT PRIMARY KEY,
1011
+ ticket_node_id TEXT NOT NULL,
1012
+ request_id TEXT NOT NULL,
1013
+ work_kind TEXT NOT NULL DEFAULT 'follow_up',
1014
+ status TEXT NOT NULL,
1015
+ final_report TEXT,
1016
+ updated_at TEXT NOT NULL
1017
+ );
1018
+ `);
1019
+ const insert = db.prepare(`
1020
+ INSERT INTO consult_outbox_new (node_id, ticket_node_id, request_id, work_kind, status, final_report, updated_at)
1021
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
1022
+ let dropped = 0;
1023
+ for (const row of rows) {
1024
+ const ticketNodeId = basename(row.dir);
1025
+ const directChild = row.root === resolve(row.root) && row.dir === join(row.root, ticketNodeId);
1026
+ if (!directChild || !isSafeNodeId(ticketNodeId)) {
1027
+ dropped += 1;
1028
+ continue;
1029
+ }
1030
+ insert.run(row.node_id, ticketNodeId, row.request_id, row.work_kind, row.status, row.final_report, row.updated_at);
1031
+ }
1032
+ if (dropped > 0) {
1033
+ console.warn(`[canvas migration v26] dropped ${dropped} consult_outbox row(s) whose legacy ticket path cannot safely map to a ticket node id`);
1034
+ }
1035
+ db.exec(`
1036
+ DROP TABLE consult_outbox;
1037
+ ALTER TABLE consult_outbox_new RENAME TO consult_outbox;
1038
+
1039
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_status ON consult_outbox(status);
1040
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_human_work_status ON consult_outbox(work_kind, status);
1041
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_ticket_status ON consult_outbox(ticket_node_id, work_kind, status);
1042
+ CREATE INDEX IF NOT EXISTS idx_consult_outbox_ticket_request_status ON consult_outbox(ticket_node_id, request_id, work_kind, status);
1043
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_consult_outbox_visual_request ON consult_outbox(ticket_node_id, request_id, work_kind) WHERE work_kind = 'visual';
1044
+ `);
1045
+ }
1046
+ /** v27 — the canonical daemon-owned review record. */
1047
+ function addReviewsTable(db) {
1048
+ db.exec(`
1049
+ CREATE TABLE IF NOT EXISTS reviews (
1050
+ review_id TEXT PRIMARY KEY,
1051
+ idempotency_key TEXT NOT NULL UNIQUE,
1052
+ origin_kind TEXT NOT NULL CHECK (origin_kind IN ('ticket', 'inline')),
1053
+ origin_node_id TEXT NOT NULL,
1054
+ bridge_node_id TEXT,
1055
+ companion_node_id TEXT NOT NULL UNIQUE,
1056
+ review_dir TEXT NOT NULL,
1057
+ file TEXT NOT NULL,
1058
+ origin_session_file TEXT NOT NULL,
1059
+ origin_session_id TEXT NOT NULL,
1060
+ origin_leaf_id TEXT NOT NULL,
1061
+ model_spec TEXT NOT NULL,
1062
+ branch_file TEXT NOT NULL,
1063
+ baseline_sha256 TEXT NOT NULL,
1064
+ baseline_bytes INTEGER NOT NULL,
1065
+ anchor_base_sha256 TEXT NOT NULL,
1066
+ anchor_base_bytes BLOB NOT NULL,
1067
+ state TEXT NOT NULL CHECK (state IN ('binding', 'open', 'approved', 'canceled')),
1068
+ created TEXT NOT NULL,
1069
+ captured_at TEXT NOT NULL,
1070
+ opened_at TEXT,
1071
+ output_path TEXT,
1072
+ subtitle TEXT,
1073
+ approved_at TEXT,
1074
+ approved_sha256 TEXT,
1075
+ changed INTEGER,
1076
+ comments_total INTEGER,
1077
+ comments_unresolved INTEGER,
1078
+ result_json TEXT,
1079
+ canceled_at TEXT,
1080
+ cancel_reason TEXT,
1081
+ cancel_actor TEXT,
1082
+ delivery_entry_id TEXT,
1083
+ delivered_at TEXT,
1084
+ delivery_error TEXT,
1085
+ projected_at TEXT,
1086
+ projection_error TEXT
1087
+ );
1088
+ CREATE INDEX IF NOT EXISTS idx_reviews_state ON reviews(state);
1089
+ CREATE INDEX IF NOT EXISTS idx_reviews_origin ON reviews(origin_node_id);
1090
+ CREATE INDEX IF NOT EXISTS idx_reviews_file ON reviews(file);
1091
+ CREATE INDEX IF NOT EXISTS idx_reviews_bridge ON reviews(bridge_node_id);
1092
+ `);
1093
+ }
1094
+ /** v28 — current review-comment projection and append-only comment audit. */
1095
+ function addReviewCommentTables(db) {
1096
+ db.exec(`
1097
+ CREATE TABLE IF NOT EXISTS review_comments (
1098
+ comment_id TEXT PRIMARY KEY,
1099
+ review_id TEXT NOT NULL,
1100
+ status TEXT NOT NULL CHECK (status IN ('open', 'resolved', 'deleted')),
1101
+ revision INTEGER NOT NULL,
1102
+ text TEXT NOT NULL,
1103
+ anchor_line INTEGER NOT NULL,
1104
+ anchor_end_line INTEGER NOT NULL,
1105
+ anchor_col_start INTEGER,
1106
+ anchor_col_end INTEGER,
1107
+ anchor_quote TEXT,
1108
+ anchor_line_text TEXT NOT NULL,
1109
+ anchor_doc_hash TEXT NOT NULL,
1110
+ author_kind TEXT NOT NULL CHECK (author_kind IN ('human', 'node')),
1111
+ author_node_id TEXT,
1112
+ updated_by_kind TEXT NOT NULL CHECK (updated_by_kind IN ('human', 'node')),
1113
+ updated_by_node_id TEXT,
1114
+ created_at TEXT NOT NULL,
1115
+ updated_at TEXT NOT NULL,
1116
+ resolved_at TEXT,
1117
+ deleted_at TEXT,
1118
+ created_seq INTEGER NOT NULL
1119
+ );
1120
+ CREATE TABLE IF NOT EXISTS review_comment_events (
1121
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
1122
+ review_id TEXT NOT NULL,
1123
+ comment_id TEXT NOT NULL,
1124
+ kind TEXT NOT NULL CHECK (kind IN ('created', 'edited', 'resolved', 'reopened', 'deleted', 'reanchored', 'notified', 'notify_failed')),
1125
+ state_change INTEGER NOT NULL,
1126
+ revision_after INTEGER,
1127
+ actor_kind TEXT NOT NULL CHECK (actor_kind IN ('human', 'node', 'system')),
1128
+ actor_node_id TEXT,
1129
+ at TEXT NOT NULL,
1130
+ operation_id TEXT NOT NULL,
1131
+ payload TEXT
1132
+ );
1133
+ CREATE INDEX IF NOT EXISTS idx_review_comments_review_line_seq
1134
+ ON review_comments(review_id, anchor_line, created_seq);
1135
+ CREATE INDEX IF NOT EXISTS idx_review_comments_review_status
1136
+ ON review_comments(review_id, status);
1137
+ CREATE INDEX IF NOT EXISTS idx_review_comment_events_review_seq
1138
+ ON review_comment_events(review_id, seq);
1139
+ CREATE INDEX IF NOT EXISTS idx_review_comment_events_comment_seq
1140
+ ON review_comment_events(comment_id, seq);
1141
+ `);
1142
+ }
1143
+ /** v29 — durable review-comment anchor disposition. v28 is already released, so this corrects both fresh and migrated databases without rewriting its historical schema. */
1144
+ function addReviewCommentAnchorState(db) {
1145
+ db.exec(`
1146
+ ALTER TABLE review_comments
1147
+ ADD COLUMN anchor_state TEXT NOT NULL DEFAULT 'live' CHECK (anchor_state IN ('live', 'detached'));
1148
+ `);
1149
+ }
1150
+ /** v30 — remove the retired human-work receipt table after every runtime reader is gone. */
1151
+ function dropConsultOutbox(db) {
1152
+ db.exec('DROP TABLE IF EXISTS consult_outbox');
1153
+ }
1154
+ /** v31 — a human's submit intent recorded on a still-open review, so the
1155
+ * approval can wait for the companion to stop working. Deliberately a column
1156
+ * and not a new `state` value: through the wait the review IS open — comments
1157
+ * still land, ranges still remap, and cancellation still applies. */
1158
+ function addReviewSubmitRequested(db) {
1159
+ db.exec('ALTER TABLE reviews ADD COLUMN submit_requested_at TEXT');
1160
+ }
1161
+ /** v32 — held crons (the exit-75 owed-gate disposition). `held` marks a row
1162
+ * whose last scheduled run exited 75 ("owed but not currently eligible"):
1163
+ * the occurrence was not spent and the row waits for an eligibility poke or
1164
+ * its natural backstop slot. `last_poke_at` records the most recent
1165
+ * `POST /v1/crons/poke` receipt on EVERY row, closing the race where a poke
1166
+ * lands while a gate check is in flight (the settling run compares its start
1167
+ * against it and re-dues instead of parking). Deliberately not part of the
1168
+ * due query: a held row is non-due by `fire_at`, never by a new predicate. */
1169
+ function addCronHeldColumns(db) {
1170
+ db.exec(`
1171
+ ALTER TABLE crons ADD COLUMN held INTEGER NOT NULL DEFAULT 0;
1172
+ ALTER TABLE crons ADD COLUMN last_poke_at TEXT;
1173
+ `);
1174
+ }
1175
+ /** v33 — durable, daemon-owned attempts to deliver settled human completions. */
1176
+ function addHumanActionDeliveries(db) {
1177
+ db.exec(`
1178
+ CREATE TABLE human_action_deliveries (
1179
+ request_id TEXT PRIMARY KEY,
1180
+ state TEXT NOT NULL,
1181
+ attempt INTEGER NOT NULL DEFAULT 0,
1182
+ argv_json TEXT NOT NULL,
1183
+ cwd TEXT NOT NULL,
1184
+ completion_json TEXT NOT NULL,
1185
+ next_attempt_at INTEGER NOT NULL,
1186
+ claimed_at INTEGER,
1187
+ claim_owner TEXT,
1188
+ accepted_at INTEGER,
1189
+ permanent_failed_at INTEGER,
1190
+ last_failure_json TEXT,
1191
+ created_at INTEGER NOT NULL,
1192
+ updated_at INTEGER NOT NULL
1193
+ );
1194
+ CREATE INDEX idx_human_action_deliveries_due ON human_action_deliveries(state, next_attempt_at);
1195
+ `);
1196
+ }
1197
+ /** v34 — index the exact Pi session file coordinate used to recover fork
1198
+ * exposure provenance without hydrating every node meta. Meta remains authoritative;
1199
+ * normal identity upserts keep this projection current after the one-time backfill. */
1200
+ function addNodeSessionFileIndex(db) {
1201
+ const columns = nodeColumns(db);
1202
+ addNodeColumnIfMissing(db, columns, 'pi_session_file', 'ALTER TABLE nodes ADD COLUMN pi_session_file TEXT;');
1203
+ for (const entry of readdirSync(nodesRoot(), { withFileTypes: true })) {
1204
+ if (!entry.isDirectory())
1205
+ continue;
1206
+ try {
1207
+ const meta = JSON.parse(readFileSync(nodeMetaPath(entry.name), 'utf8'));
1208
+ if (typeof meta.pi_session_file === 'string') {
1209
+ db.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?').run(meta.pi_session_file, entry.name);
1210
+ }
1211
+ }
1212
+ catch {
1213
+ // Corrupt node metadata is ignored by the ordinary canvas rebuild too.
1214
+ }
1215
+ }
1216
+ db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
1217
+ }
1218
+ /** The journaled lane of the state-migration registry, adapted to this chain's
1219
+ * step shape. Registry order is preserved, so these versions stay stable as
1220
+ * long as the registry is append-only (same contract as the local list). */
1221
+ const JOURNALED_STATE_MIGRATIONS = STATE_MIGRATIONS
1222
+ .filter((m) => m.lane === 'journaled')
1223
+ .map((m) => (db) => m.apply(db, crtrHome()));
1224
+ /** The ordered migration list. Index `i` is migration version `i + 1`; the db's
1225
+ * `user_version` tracks how many have been applied. Append only. Journaled
1226
+ * entries from `src/migrations/registry.ts` follow the local steps, so a new
1227
+ * registry migration lands as the next version. */
1228
+ export const MIGRATIONS = [
1229
+ /* v1 */ baselineSchema,
1230
+ /* v2 */ addRuntimeColumns,
1231
+ /* v3 */ backfillRuntime,
1232
+ /* v4 */ edgesForeignKeyCascade,
1233
+ /* v5 */ addPaneColumn,
1234
+ /* v6 */ addFocusesTable,
1235
+ /* v7 */ appendWakeupsTable,
1236
+ /* v8 */ addHostKindColumn,
1237
+ /* v9 */ migrateWakeupsToTriggers,
1238
+ /* v10 */ addCanvasMetaTable,
1239
+ /* v11 */ addProfileIdColumn,
1240
+ /* v12 */ addPidIdentityColumn,
1241
+ /* v13 */ addMessageWaitColumn,
1242
+ /* v14 */ addCanonicalFinalColumns,
1243
+ /* v15 */ normalizePersistedLaunchExtensions,
1244
+ // Repair databases that reached v15 through the concurrent launch-metadata
1245
+ // migration before canonical finalization joined the ordered mainline.
1246
+ /* v16 */ addCanonicalFinalColumns,
1247
+ /* v17 */ addMessageWaitGenerationColumn,
1248
+ /* v18 */ addHumanWorkOutboxTable,
1249
+ /* v19 */ addHumanWorkColumns,
1250
+ /* v20 */ addNodeParentCreatedIndexes,
1251
+ /* v21 */ addCronsTables,
1252
+ /* v22 */ migrateTriggersToCrons,
1253
+ /* v23 */ addCronRunPidIdentityColumn,
1254
+ /* v24 */ addCronOutOfBandColumn,
1255
+ /* v25 */ addWarmPoolTable,
1256
+ /* v26 */ narrowHumanWorkOutbox,
1257
+ /* v27 */ addReviewsTable,
1258
+ /* v28 */ addReviewCommentTables,
1259
+ /* v29 */ addReviewCommentAnchorState,
1260
+ /* v30 */ dropConsultOutbox,
1261
+ /* v31 */ addReviewSubmitRequested,
1262
+ /* v32 */ addCronHeldColumns,
1263
+ /* v33 */ addHumanActionDeliveries,
1264
+ /* v34 */ addNodeSessionFileIndex,
1265
+ ...JOURNALED_STATE_MIGRATIONS,
1266
+ ];
1267
+ /** Migration indexes that manage their OWN transaction and therefore must not
1268
+ * be wrapped by `migrate()` — `node:sqlite` rejects a nested BEGIN. Index 3
1269
+ * (v4, the edges FK rebuild) both BEGINs internally and toggles
1270
+ * `PRAGMA foreign_keys`, which is a no-op inside a transaction. Its docblock
1271
+ * documents its own crash-safety: a partial rebuild rolls back and a re-run
1272
+ * over an already-rebuilt table is idempotent. (Entering `migrate()` from
1273
+ * INSIDE a caller's transaction with `user_version` still at 3 would make that
1274
+ * internal BEGIN nest and throw — pre-existing and unreachable from `openDb()`,
1275
+ * which migrates in autocommit.) */
1276
+ const SELF_TRANSACTIONAL_MIGRATIONS = new Set([3]);
1277
+ function currentUserVersion(db) {
1278
+ return db.prepare('PRAGMA user_version').get().user_version;
1279
+ }
1280
+ function applyMigration(db, v) {
1281
+ MIGRATIONS[v](db);
1282
+ // `user_version` takes no bound parameters; v is a controlled integer.
1283
+ db.exec(`PRAGMA user_version = ${v + 1}`);
1284
+ }
1285
+ /** Bring `db` up to the latest schema version. Reads `user_version`, runs each
1286
+ * pending migration in order, and bumps `user_version` after each so the work
1287
+ * is gated and idempotent: re-running is a no-op once `user_version` reaches
1288
+ * `MIGRATIONS.length`. Forward-only.
1289
+ *
1290
+ * ATOMIC AND RESTARTABLE: each step AND its `user_version` bump run inside one
1291
+ * `BEGIN IMMEDIATE`/`COMMIT` (SQLite journals `user_version` with the
1292
+ * transaction, so a rollback restores it too). An interrupted or failing step
1293
+ * therefore leaves the db exactly at the previous version with none of its
1294
+ * writes committed — a half-applied step can never wedge every later open. The
1295
+ * write lock also serializes concurrent openers; the version is re-read after
1296
+ * acquiring it so a step another process just applied is skipped rather than
1297
+ * re-run. */
1298
+ export function migrate(db) {
1299
+ if (currentUserVersion(db) >= MIGRATIONS.length)
1300
+ return;
1301
+ // Pending work: a step now holds the write lock for its whole duration, and
1302
+ // the fs-heavy legacy steps (v3/v14/v15) can outlast the ordinary 5s busy
1303
+ // timeout. Let concurrent openers WAIT for the migration rather than fail.
1304
+ const priorTimeout = db.prepare('PRAGMA busy_timeout').get().timeout;
1305
+ db.exec('PRAGMA busy_timeout = 60000;');
1306
+ try {
1307
+ runPendingMigrations(db);
1308
+ }
1309
+ finally {
1310
+ db.exec(`PRAGMA busy_timeout = ${priorTimeout};`);
1311
+ }
1312
+ }
1313
+ function runPendingMigrations(db) {
1314
+ for (;;) {
1315
+ const v = currentUserVersion(db);
1316
+ if (v >= MIGRATIONS.length)
1317
+ return;
1318
+ // Already inside a caller's transaction (or a self-transactional step):
1319
+ // run bare rather than nesting a BEGIN node:sqlite would reject.
1320
+ if (db.isTransaction || SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
1321
+ applyMigration(db, v);
1322
+ continue;
1323
+ }
1324
+ db.exec('BEGIN IMMEDIATE');
1325
+ try {
1326
+ // Another process may have applied this step while we waited on the lock.
1327
+ if (currentUserVersion(db) === v)
1328
+ applyMigration(db, v);
1329
+ db.exec('COMMIT');
1330
+ }
1331
+ catch (err) {
1332
+ try {
1333
+ db.exec('ROLLBACK');
1334
+ }
1335
+ catch { /* transaction was already closed */ }
1336
+ throw err;
1337
+ }
1338
+ }
1339
+ }