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