@north-light/crouter 0.3.232 → 0.3.234

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