@crouton-kit/crouter 0.3.39 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -1,30 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- /** Resolve the CP SQLite db file path: an explicit `CRTR_HEARTH_CP_DB_PATH`
3
- * override, else `<dataDir>/control-plane.db` where `dataDir` follows the
4
- * same `CRTR_HEARTH_CP_DATA_DIR` convention `secrets.ts` uses. Deliberately
5
- * independent of `canvasDbPath()` / `CRTR_HOME` — the control plane is not a
6
- * canvas node and must not share or collide with a home's canvas db. */
7
- export declare function controlPlaneDbPath(env?: NodeJS.ProcessEnv): string;
8
- /** The ordered migration list. Index `i` is migration version `i + 1`; the
9
- * db's `user_version` tracks how many have been applied. Append only. */
10
- export declare const MIGRATIONS: ReadonlyArray<(db: DatabaseSync) => void>;
11
- /** Bring `db` up to the latest schema version. Reads `user_version`, runs
12
- * each pending migration in order, and bumps `user_version` after each so
13
- * the work is gated and idempotent: re-running is a no-op once
14
- * `user_version` reaches `MIGRATIONS.length`. Forward-only. */
15
- export declare function migrate(db: DatabaseSync): void;
16
- /** Open (or reuse) the CP db at `path`, initializing WAL + schema on first
17
- * open. This is the SOLE function in the codebase that may construct a
18
- * `DatabaseSync` against the CP db file — every CP store goes through this
19
- * (directly or via `getControlPlaneDb()`) and never opens its own
20
- * connection. Keyed by path so tests with distinct temp dbs get independent
21
- * handles, exactly like `src/core/canvas/db.ts`'s `openDb()`. */
22
- export declare function openControlPlaneDb(path: string): DatabaseSync;
23
- /** The cached handle accessor every CP store (registry, scheduler, ingress)
24
- * imports — resolves the db path the standard way and opens/reuses the one
25
- * connection for it. Never construct a second `DatabaseSync` against the CP
26
- * db elsewhere in the codebase. */
27
- export declare function getControlPlaneDb(env?: NodeJS.ProcessEnv): DatabaseSync;
28
- /** Close and forget the handle for `path` (defaults to the standard
29
- * resolved path). Mainly for tests. */
30
- export declare function closeControlPlaneDb(path?: string): void;
@@ -1,561 +0,0 @@
1
- // control-plane.db — the CP's own SQLite store, separate from canvas.db. This
2
- // module is the SOLE owner of the cached `DatabaseSync` connection and the
3
- // migration gate: every other CP store (registry, scheduler, ingress) imports
4
- // `getControlPlaneDb()` from here rather than opening a second connection
5
- // (master plan decision 9 / orchestration brief non-negotiable). It lives on
6
- // the CP's own data volume — NOT canvas home, NOT `canvasDbPath()` — because
7
- // the control plane is a separate always-on process from any home/node.
8
- //
9
- // Mirrors `src/core/canvas/db.ts` exactly: `DatabaseSync` + WAL, schema
10
- // expressed as a forward-only `MIGRATIONS` list gated by `PRAGMA user_version`.
11
- // Migrations are append-only — never edit a shipped step.
12
- import { DatabaseSync } from 'node:sqlite';
13
- import { existsSync, mkdirSync } from 'node:fs';
14
- import { homedir } from 'node:os';
15
- import { dirname, join, resolve } from 'node:path';
16
- const ENV = {
17
- // Same data-dir convention `secrets.ts` (P1.3) uses, so the CP's SQLite db
18
- // and its secrets file default to living side by side on one data volume.
19
- dataDir: 'CRTR_HEARTH_CP_DATA_DIR',
20
- // Explicit override for the db file itself, for deployments that want the
21
- // db on a different mount than the rest of the CP data dir.
22
- dbPath: 'CRTR_HEARTH_CP_DB_PATH',
23
- };
24
- const DB_FILE_NAME = 'control-plane.db';
25
- function expandHomePath(input) {
26
- if (input.startsWith('~'))
27
- return join(homedir(), input.slice(1));
28
- return resolve(input);
29
- }
30
- function defaultDataDir(env) {
31
- const dataDir = env[ENV.dataDir]?.trim();
32
- return dataDir !== undefined && dataDir !== ''
33
- ? expandHomePath(dataDir)
34
- : expandHomePath('~/.crouter/hearth-control-plane');
35
- }
36
- /** Resolve the CP SQLite db file path: an explicit `CRTR_HEARTH_CP_DB_PATH`
37
- * override, else `<dataDir>/control-plane.db` where `dataDir` follows the
38
- * same `CRTR_HEARTH_CP_DATA_DIR` convention `secrets.ts` uses. Deliberately
39
- * independent of `canvasDbPath()` / `CRTR_HOME` — the control plane is not a
40
- * canvas node and must not share or collide with a home's canvas db. */
41
- export function controlPlaneDbPath(env = process.env) {
42
- const override = env[ENV.dbPath]?.trim();
43
- if (override !== undefined && override !== '')
44
- return expandHomePath(override);
45
- return join(defaultDataDir(env), DB_FILE_NAME);
46
- }
47
- // --- Schema as a forward-only migration list ------------------------------
48
- /** v1 — the `home` registry table (design.md §5.1). `home_id` is the stable
49
- * PK, independent of provider sandbox/node churn; `schedule` and
50
- * `ingress_route` (added by later sibling migrations against this same
51
- * handle) FK to it. Every secret is stored as a HANDLE
52
- * (`relay_auth_ref`) — never a model credential, never an inline value
53
- * (R-REG-3 / R-CP-8). `IF NOT EXISTS` makes this a no-op on re-open. */
54
- function homeTable(db) {
55
- db.exec(`
56
- CREATE TABLE IF NOT EXISTS home (
57
- home_id TEXT PRIMARY KEY,
58
- tenant_id TEXT NOT NULL,
59
- user_id TEXT NOT NULL,
60
- provider TEXT NOT NULL DEFAULT 'blaxel',
61
- provider_sandbox_id TEXT,
62
- provider_volume_id TEXT,
63
- preview_endpoint TEXT,
64
- home_agent_target TEXT,
65
- relay_auth_ref TEXT,
66
- status TEXT NOT NULL DEFAULT 'provisioning',
67
- template_version TEXT,
68
- last_ready_at TEXT,
69
- last_wake_at TEXT,
70
- last_suspend_at TEXT,
71
- last_wake_latency_ms INTEGER,
72
- health_verdict TEXT NOT NULL DEFAULT 'unknown',
73
- created_at TEXT NOT NULL,
74
- updated_at TEXT NOT NULL
75
- );
76
-
77
- CREATE INDEX IF NOT EXISTS idx_home_tenant_user ON home(tenant_id, user_id);
78
- CREATE INDEX IF NOT EXISTS idx_home_provider_sandbox ON home(provider_sandbox_id);
79
- `);
80
- }
81
- /** v2 — cron schedule definitions. `next_fire_at` is the durable timer the CP
82
- * scan loop selects on; home agents keep no cron scheduler state. */
83
- function scheduleTable(db) {
84
- db.exec(`
85
- CREATE TABLE IF NOT EXISTS schedule (
86
- schedule_id TEXT PRIMARY KEY,
87
- tenant_id TEXT NOT NULL,
88
- user_id TEXT NOT NULL,
89
- home_id TEXT NOT NULL,
90
- label TEXT NOT NULL,
91
- recurrence TEXT NOT NULL,
92
- timezone TEXT NOT NULL,
93
- delivery_payload TEXT NOT NULL,
94
- next_fire_at TEXT NOT NULL,
95
- last_fired_at TEXT,
96
- enabled INTEGER NOT NULL DEFAULT 1,
97
- created_at TEXT NOT NULL,
98
- updated_at TEXT NOT NULL,
99
- FOREIGN KEY (home_id) REFERENCES home(home_id) ON DELETE CASCADE,
100
- CHECK (enabled IN (0, 1))
101
- );
102
-
103
- CREATE INDEX IF NOT EXISTS idx_schedule_next_fire_at ON schedule(next_fire_at);
104
- CREATE INDEX IF NOT EXISTS idx_schedule_tenant_user ON schedule(tenant_id, user_id);
105
- CREATE INDEX IF NOT EXISTS idx_schedule_home ON schedule(home_id);
106
- `);
107
- }
108
- /** v3 — cron fire idempotency ledger. The unique slot key is the load-bearing
109
- * restart/double-scan guard: one schedule can own only one fire per instant. */
110
- function scheduleFireTable(db) {
111
- db.exec(`
112
- CREATE TABLE IF NOT EXISTS schedule_fire (
113
- fire_id TEXT PRIMARY KEY,
114
- schedule_id TEXT NOT NULL,
115
- home_id TEXT NOT NULL,
116
- tenant_id TEXT NOT NULL,
117
- user_id TEXT NOT NULL,
118
- scheduled_for TEXT NOT NULL,
119
- state TEXT NOT NULL DEFAULT 'pending',
120
- attempts INTEGER NOT NULL DEFAULT 0,
121
- last_error TEXT,
122
- delivered_at TEXT,
123
- created_at TEXT NOT NULL,
124
- updated_at TEXT NOT NULL,
125
- FOREIGN KEY (schedule_id) REFERENCES schedule(schedule_id) ON DELETE CASCADE,
126
- FOREIGN KEY (home_id) REFERENCES home(home_id) ON DELETE CASCADE,
127
- UNIQUE (schedule_id, scheduled_for),
128
- CHECK (state IN ('pending', 'waking', 'delivered', 'failed')),
129
- CHECK (attempts >= 0)
130
- );
131
-
132
- CREATE INDEX IF NOT EXISTS idx_schedule_fire_schedule ON schedule_fire(schedule_id);
133
- CREATE INDEX IF NOT EXISTS idx_schedule_fire_home ON schedule_fire(home_id);
134
- CREATE INDEX IF NOT EXISTS idx_schedule_fire_resumable ON schedule_fire(state, scheduled_for);
135
- CREATE INDEX IF NOT EXISTS idx_schedule_fire_tenant_user ON schedule_fire(tenant_id, user_id);
136
- `);
137
- }
138
- /** v4 — public ingress demux routes. `(channel, routing_key)` is deliberately
139
- * globally unique because public webhook/phone/email addresses are global. */
140
- function ingressRouteTable(db) {
141
- db.exec(`
142
- CREATE TABLE IF NOT EXISTS ingress_route (
143
- route_id TEXT PRIMARY KEY,
144
- tenant_id TEXT NOT NULL,
145
- user_id TEXT NOT NULL,
146
- home_id TEXT NOT NULL,
147
- channel TEXT NOT NULL,
148
- routing_key TEXT NOT NULL,
149
- routing_key_kind TEXT NOT NULL,
150
- channel_secret_ref TEXT,
151
- delivery_template TEXT NOT NULL,
152
- enabled INTEGER NOT NULL DEFAULT 1,
153
- created_at TEXT NOT NULL,
154
- updated_at TEXT NOT NULL,
155
- FOREIGN KEY (home_id) REFERENCES home(home_id) ON DELETE CASCADE,
156
- UNIQUE (channel, routing_key),
157
- CHECK (enabled IN (0, 1))
158
- );
159
-
160
- CREATE INDEX IF NOT EXISTS idx_ingress_route_lookup ON ingress_route(channel, routing_key);
161
- CREATE INDEX IF NOT EXISTS idx_ingress_route_tenant_user ON ingress_route(tenant_id, user_id);
162
- CREATE INDEX IF NOT EXISTS idx_ingress_route_home ON ingress_route(home_id);
163
- `);
164
- }
165
- /** v5 — webhook replay/idempotency ledger. Stores delivery identity and hashes
166
- * only; raw public webhook bodies belong in the transient request path. */
167
- function webhookDeliveryTable(db) {
168
- db.exec(`
169
- CREATE TABLE IF NOT EXISTS webhook_delivery (
170
- delivery_id TEXT PRIMARY KEY,
171
- route_id TEXT NOT NULL,
172
- tenant_id TEXT NOT NULL,
173
- user_id TEXT NOT NULL,
174
- home_id TEXT NOT NULL,
175
- replay_key TEXT NOT NULL,
176
- replay_window_start TEXT NOT NULL,
177
- payload_sha256 TEXT,
178
- signature_sha256 TEXT,
179
- state TEXT NOT NULL DEFAULT 'received',
180
- received_at TEXT NOT NULL,
181
- delivered_at TEXT,
182
- last_error TEXT,
183
- created_at TEXT NOT NULL,
184
- updated_at TEXT NOT NULL,
185
- FOREIGN KEY (route_id) REFERENCES ingress_route(route_id) ON DELETE CASCADE,
186
- FOREIGN KEY (home_id) REFERENCES home(home_id) ON DELETE CASCADE,
187
- UNIQUE (route_id, replay_key, replay_window_start),
188
- CHECK (state IN ('received', 'delivered', 'rejected', 'failed'))
189
- );
190
-
191
- CREATE INDEX IF NOT EXISTS idx_webhook_delivery_route ON webhook_delivery(route_id, received_at);
192
- CREATE INDEX IF NOT EXISTS idx_webhook_delivery_home ON webhook_delivery(home_id, received_at);
193
- CREATE INDEX IF NOT EXISTS idx_webhook_delivery_tenant_user ON webhook_delivery(tenant_id, user_id);
194
- `);
195
- }
196
- /** v6 — append-only trigger delivery audit. Stores operational metadata and
197
- * content hashes; no public payload body and no secret literal columns. */
198
- function triggerAuditTable(db) {
199
- db.exec(`
200
- CREATE TABLE IF NOT EXISTS trigger_audit (
201
- audit_id TEXT PRIMARY KEY,
202
- tenant_id TEXT NOT NULL,
203
- user_id TEXT NOT NULL,
204
- home_id TEXT NOT NULL,
205
- source TEXT NOT NULL,
206
- source_ref TEXT NOT NULL,
207
- trigger_id TEXT NOT NULL,
208
- tier TEXT NOT NULL,
209
- outcome TEXT NOT NULL,
210
- reason TEXT,
211
- payload_sha256 TEXT,
212
- metadata_json TEXT,
213
- at TEXT NOT NULL,
214
- FOREIGN KEY (home_id) REFERENCES home(home_id) ON DELETE CASCADE,
215
- CHECK (source IN ('cron', 'webhook')),
216
- CHECK (tier IN ('critical', 'urgent', 'normal', 'deferred')),
217
- CHECK (outcome IN ('delivered', 'rejected', 'failed'))
218
- );
219
-
220
- CREATE INDEX IF NOT EXISTS idx_trigger_audit_home_at ON trigger_audit(home_id, at);
221
- CREATE INDEX IF NOT EXISTS idx_trigger_audit_tenant_user_at ON trigger_audit(tenant_id, user_id, at);
222
- CREATE INDEX IF NOT EXISTS idx_trigger_audit_trigger ON trigger_audit(trigger_id);
223
- `);
224
- }
225
- /** v7 — DB-level consistency guards for denormalized tenant/user/home keys.
226
- * Stores derive these values from parents; these triggers keep direct SQL from
227
- * making tenant/user columns untrustworthy query or security boundaries. */
228
- function tenantIdentityTriggers(db) {
229
- db.exec(`
230
- CREATE TRIGGER IF NOT EXISTS trg_schedule_identity_insert
231
- BEFORE INSERT ON schedule
232
- FOR EACH ROW
233
- WHEN NOT EXISTS (
234
- SELECT 1 FROM home
235
- WHERE home_id = NEW.home_id AND tenant_id = NEW.tenant_id AND user_id = NEW.user_id
236
- )
237
- BEGIN
238
- SELECT RAISE(ABORT, 'schedule tenant/user/home mismatch');
239
- END;
240
-
241
- CREATE TRIGGER IF NOT EXISTS trg_schedule_identity_update
242
- BEFORE UPDATE OF home_id, tenant_id, user_id ON schedule
243
- FOR EACH ROW
244
- WHEN NOT EXISTS (
245
- SELECT 1 FROM home
246
- WHERE home_id = NEW.home_id AND tenant_id = NEW.tenant_id AND user_id = NEW.user_id
247
- )
248
- BEGIN
249
- SELECT RAISE(ABORT, 'schedule tenant/user/home mismatch');
250
- END;
251
-
252
- CREATE TRIGGER IF NOT EXISTS trg_schedule_fire_identity_insert
253
- BEFORE INSERT ON schedule_fire
254
- FOR EACH ROW
255
- WHEN NOT EXISTS (
256
- SELECT 1 FROM schedule
257
- WHERE schedule_id = NEW.schedule_id
258
- AND home_id = NEW.home_id
259
- AND tenant_id = NEW.tenant_id
260
- AND user_id = NEW.user_id
261
- )
262
- BEGIN
263
- SELECT RAISE(ABORT, 'schedule_fire schedule identity mismatch');
264
- END;
265
-
266
- CREATE TRIGGER IF NOT EXISTS trg_schedule_fire_identity_update
267
- BEFORE UPDATE OF schedule_id, home_id, tenant_id, user_id ON schedule_fire
268
- FOR EACH ROW
269
- WHEN NOT EXISTS (
270
- SELECT 1 FROM schedule
271
- WHERE schedule_id = NEW.schedule_id
272
- AND home_id = NEW.home_id
273
- AND tenant_id = NEW.tenant_id
274
- AND user_id = NEW.user_id
275
- )
276
- BEGIN
277
- SELECT RAISE(ABORT, 'schedule_fire schedule identity mismatch');
278
- END;
279
-
280
- CREATE TRIGGER IF NOT EXISTS trg_ingress_route_identity_insert
281
- BEFORE INSERT ON ingress_route
282
- FOR EACH ROW
283
- WHEN NOT EXISTS (
284
- SELECT 1 FROM home
285
- WHERE home_id = NEW.home_id AND tenant_id = NEW.tenant_id AND user_id = NEW.user_id
286
- )
287
- BEGIN
288
- SELECT RAISE(ABORT, 'ingress_route tenant/user/home mismatch');
289
- END;
290
-
291
- CREATE TRIGGER IF NOT EXISTS trg_ingress_route_identity_update
292
- BEFORE UPDATE OF home_id, tenant_id, user_id ON ingress_route
293
- FOR EACH ROW
294
- WHEN NOT EXISTS (
295
- SELECT 1 FROM home
296
- WHERE home_id = NEW.home_id AND tenant_id = NEW.tenant_id AND user_id = NEW.user_id
297
- )
298
- BEGIN
299
- SELECT RAISE(ABORT, 'ingress_route tenant/user/home mismatch');
300
- END;
301
-
302
- CREATE TRIGGER IF NOT EXISTS trg_webhook_delivery_identity_insert
303
- BEFORE INSERT ON webhook_delivery
304
- FOR EACH ROW
305
- WHEN NOT EXISTS (
306
- SELECT 1 FROM ingress_route
307
- WHERE route_id = NEW.route_id
308
- AND home_id = NEW.home_id
309
- AND tenant_id = NEW.tenant_id
310
- AND user_id = NEW.user_id
311
- )
312
- BEGIN
313
- SELECT RAISE(ABORT, 'webhook_delivery route identity mismatch');
314
- END;
315
-
316
- CREATE TRIGGER IF NOT EXISTS trg_webhook_delivery_identity_update
317
- BEFORE UPDATE OF route_id, home_id, tenant_id, user_id ON webhook_delivery
318
- FOR EACH ROW
319
- WHEN NOT EXISTS (
320
- SELECT 1 FROM ingress_route
321
- WHERE route_id = NEW.route_id
322
- AND home_id = NEW.home_id
323
- AND tenant_id = NEW.tenant_id
324
- AND user_id = NEW.user_id
325
- )
326
- BEGIN
327
- SELECT RAISE(ABORT, 'webhook_delivery route identity mismatch');
328
- END;
329
-
330
- CREATE TRIGGER IF NOT EXISTS trg_trigger_audit_identity_insert
331
- BEFORE INSERT ON trigger_audit
332
- FOR EACH ROW
333
- WHEN (
334
- NEW.source = 'cron'
335
- AND NOT EXISTS (
336
- SELECT 1 FROM schedule_fire
337
- WHERE fire_id = NEW.source_ref
338
- AND home_id = NEW.home_id
339
- AND tenant_id = NEW.tenant_id
340
- AND user_id = NEW.user_id
341
- )
342
- ) OR (
343
- NEW.source = 'webhook'
344
- AND NOT EXISTS (
345
- SELECT 1 FROM ingress_route
346
- WHERE route_id = NEW.source_ref
347
- AND home_id = NEW.home_id
348
- AND tenant_id = NEW.tenant_id
349
- AND user_id = NEW.user_id
350
- )
351
- )
352
- BEGIN
353
- SELECT RAISE(ABORT, 'trigger_audit source identity mismatch');
354
- END;
355
-
356
- CREATE TRIGGER IF NOT EXISTS trg_trigger_audit_identity_update
357
- BEFORE UPDATE OF source, source_ref, home_id, tenant_id, user_id ON trigger_audit
358
- FOR EACH ROW
359
- WHEN (
360
- NEW.source = 'cron'
361
- AND NOT EXISTS (
362
- SELECT 1 FROM schedule_fire
363
- WHERE fire_id = NEW.source_ref
364
- AND home_id = NEW.home_id
365
- AND tenant_id = NEW.tenant_id
366
- AND user_id = NEW.user_id
367
- )
368
- ) OR (
369
- NEW.source = 'webhook'
370
- AND NOT EXISTS (
371
- SELECT 1 FROM ingress_route
372
- WHERE route_id = NEW.source_ref
373
- AND home_id = NEW.home_id
374
- AND tenant_id = NEW.tenant_id
375
- AND user_id = NEW.user_id
376
- )
377
- )
378
- BEGIN
379
- SELECT RAISE(ABORT, 'trigger_audit source identity mismatch');
380
- END;
381
- `);
382
- }
383
- /** v8 — parent-identity update guards. v7 validates a child row's own
384
- * denormalized tenant/user/home tuple against its immediate parent on
385
- * INSERT/UPDATE, but said nothing about editing the PARENT side: nothing
386
- * stopped `UPDATE home SET tenant_id = ...` (or schedule/ingress_route/
387
- * schedule_fire identity columns) out from under existing children, which
388
- * would strand them with stale denormalized keys without tripping any
389
- * v7 check (those only fire on the child table). These triggers reject an
390
- * identity-column update on a parent row whenever a dependent child row
391
- * already exists, so identity is write-once after first child creation;
392
- * non-identity columns (status, timestamps, next_fire_at, metadata, etc.)
393
- * are untouched by these triggers and remain freely updatable. Each guard
394
- * checks only its immediate denormalizing child(ren) — sufficient because
395
- * v7 already keeps every deeper descendant's copy in lockstep with that
396
- * immediate child for as long as the immediate child row exists. */
397
- function parentIdentityUpdateGuards(db) {
398
- db.exec(`
399
- CREATE TRIGGER IF NOT EXISTS trg_home_identity_update_guard
400
- BEFORE UPDATE OF tenant_id, user_id ON home
401
- FOR EACH ROW
402
- WHEN (NEW.tenant_id IS NOT OLD.tenant_id OR NEW.user_id IS NOT OLD.user_id)
403
- AND (
404
- EXISTS (SELECT 1 FROM schedule WHERE home_id = OLD.home_id)
405
- OR EXISTS (SELECT 1 FROM ingress_route WHERE home_id = OLD.home_id)
406
- OR EXISTS (SELECT 1 FROM trigger_audit WHERE home_id = OLD.home_id)
407
- )
408
- BEGIN
409
- SELECT RAISE(ABORT, 'home identity update blocked: dependent schedule/ingress_route/trigger_audit rows exist');
410
- END;
411
-
412
- CREATE TRIGGER IF NOT EXISTS trg_schedule_identity_update_guard
413
- BEFORE UPDATE OF home_id, tenant_id, user_id ON schedule
414
- FOR EACH ROW
415
- WHEN (
416
- NEW.home_id IS NOT OLD.home_id
417
- OR NEW.tenant_id IS NOT OLD.tenant_id
418
- OR NEW.user_id IS NOT OLD.user_id
419
- )
420
- AND EXISTS (SELECT 1 FROM schedule_fire WHERE schedule_id = OLD.schedule_id)
421
- BEGIN
422
- SELECT RAISE(ABORT, 'schedule identity update blocked: dependent schedule_fire rows exist');
423
- END;
424
-
425
- CREATE TRIGGER IF NOT EXISTS trg_ingress_route_identity_update_guard
426
- BEFORE UPDATE OF home_id, tenant_id, user_id ON ingress_route
427
- FOR EACH ROW
428
- WHEN (
429
- NEW.home_id IS NOT OLD.home_id
430
- OR NEW.tenant_id IS NOT OLD.tenant_id
431
- OR NEW.user_id IS NOT OLD.user_id
432
- )
433
- AND (
434
- EXISTS (SELECT 1 FROM webhook_delivery WHERE route_id = OLD.route_id)
435
- OR EXISTS (
436
- SELECT 1 FROM trigger_audit WHERE source = 'webhook' AND source_ref = OLD.route_id
437
- )
438
- )
439
- BEGIN
440
- SELECT RAISE(ABORT, 'ingress_route identity update blocked: dependent webhook_delivery/trigger_audit rows exist');
441
- END;
442
-
443
- CREATE TRIGGER IF NOT EXISTS trg_schedule_fire_identity_update_guard
444
- BEFORE UPDATE OF schedule_id, home_id, tenant_id, user_id ON schedule_fire
445
- FOR EACH ROW
446
- WHEN (
447
- NEW.schedule_id IS NOT OLD.schedule_id
448
- OR NEW.home_id IS NOT OLD.home_id
449
- OR NEW.tenant_id IS NOT OLD.tenant_id
450
- OR NEW.user_id IS NOT OLD.user_id
451
- )
452
- AND EXISTS (
453
- SELECT 1 FROM trigger_audit WHERE source = 'cron' AND source_ref = OLD.fire_id
454
- )
455
- BEGIN
456
- SELECT RAISE(ABORT, 'schedule_fire identity update blocked: dependent trigger_audit rows exist');
457
- END;
458
- `);
459
- }
460
- /** v9 — the single global auto-upgrade target (design.md §2/§9). ONE row,
461
- * `channel = 'home'`, holding the DESIRED crtr image line every home rolls
462
- * onto: `target_template_version` is the semver the fleet should be running,
463
- * `target_image_ref` its immutable Blaxel image name (must equal
464
- * `hearthHomeImageRefForVersion(target_template_version)` — the admin route
465
- * enforces that). `last_bad_target_version` is the ONLY brake in the
466
- * zero-touch loop: a version recorded here already failed a live roll for
467
- * the fleet, so the wake-path stale predicate excludes it until the target
468
- * advances to a newer release. There is deliberately NO `roll_armed` column
469
- * — the loop is full zero-touch (§9.1), so there is no per-release human arm
470
- * gate to persist. `home.template_version` stays the ACTUAL running version;
471
- * this row is the target. Single-tenant: one image line for all homes, so no
472
- * per-home target column. */
473
- function hearthTargetTable(db) {
474
- db.exec(`
475
- CREATE TABLE IF NOT EXISTS hearth_target (
476
- channel TEXT PRIMARY KEY DEFAULT 'home',
477
- target_template_version TEXT NOT NULL,
478
- target_image_ref TEXT NOT NULL,
479
- published_at TEXT NOT NULL,
480
- source TEXT NOT NULL,
481
- last_bad_target_version TEXT,
482
- CHECK (channel = 'home')
483
- );
484
- `);
485
- }
486
- /** The ordered migration list. Index `i` is migration version `i + 1`; the
487
- * db's `user_version` tracks how many have been applied. Append only. */
488
- export const MIGRATIONS = [
489
- /* v1 */ homeTable,
490
- /* v2 */ scheduleTable,
491
- /* v3 */ scheduleFireTable,
492
- /* v4 */ ingressRouteTable,
493
- /* v5 */ webhookDeliveryTable,
494
- /* v6 */ triggerAuditTable,
495
- /* v7 */ tenantIdentityTriggers,
496
- /* v8 */ parentIdentityUpdateGuards,
497
- /* v9 */ hearthTargetTable,
498
- ];
499
- /** Bring `db` up to the latest schema version. Reads `user_version`, runs
500
- * each pending migration in order, and bumps `user_version` after each so
501
- * the work is gated and idempotent: re-running is a no-op once
502
- * `user_version` reaches `MIGRATIONS.length`. Forward-only. */
503
- export function migrate(db) {
504
- let v = db.prepare('PRAGMA user_version').get().user_version;
505
- for (; v < MIGRATIONS.length; v++) {
506
- MIGRATIONS[v](db);
507
- // `user_version` takes no bound parameters; v is a controlled integer.
508
- db.exec(`PRAGMA user_version = ${v + 1}`);
509
- }
510
- }
511
- const handles = new Map();
512
- /** Open (or reuse) the CP db at `path`, initializing WAL + schema on first
513
- * open. This is the SOLE function in the codebase that may construct a
514
- * `DatabaseSync` against the CP db file — every CP store goes through this
515
- * (directly or via `getControlPlaneDb()`) and never opens its own
516
- * connection. Keyed by path so tests with distinct temp dbs get independent
517
- * handles, exactly like `src/core/canvas/db.ts`'s `openDb()`. */
518
- export function openControlPlaneDb(path) {
519
- const existing = handles.get(path);
520
- if (existing) {
521
- try {
522
- existing.prepare('PRAGMA database_list').all();
523
- return existing;
524
- }
525
- catch {
526
- try {
527
- existing.close();
528
- }
529
- catch {
530
- /* ignore */
531
- }
532
- handles.delete(path);
533
- }
534
- }
535
- const dir = dirname(path);
536
- if (!existsSync(dir))
537
- mkdirSync(dir, { recursive: true });
538
- const db = new DatabaseSync(path);
539
- db.exec('PRAGMA journal_mode = WAL;');
540
- db.exec('PRAGMA foreign_keys = ON;');
541
- db.exec('PRAGMA busy_timeout = 5000;');
542
- migrate(db);
543
- handles.set(path, db);
544
- return db;
545
- }
546
- /** The cached handle accessor every CP store (registry, scheduler, ingress)
547
- * imports — resolves the db path the standard way and opens/reuses the one
548
- * connection for it. Never construct a second `DatabaseSync` against the CP
549
- * db elsewhere in the codebase. */
550
- export function getControlPlaneDb(env = process.env) {
551
- return openControlPlaneDb(controlPlaneDbPath(env));
552
- }
553
- /** Close and forget the handle for `path` (defaults to the standard
554
- * resolved path). Mainly for tests. */
555
- export function closeControlPlaneDb(path = controlPlaneDbPath()) {
556
- const db = handles.get(path);
557
- if (db) {
558
- db.close();
559
- handles.delete(path);
560
- }
561
- }
@@ -1,23 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import type { HearthTarget, SetHearthTargetInput } from './types.js';
3
- /** Read the single global target row, or `null` when no image has ever been
4
- * registered (fresh CP). A `null` means the wake path treats every home as
5
- * up to date (nothing to roll onto yet). */
6
- export declare function getHearthTarget(db?: DatabaseSync): HearthTarget | null;
7
- /** Upsert the global target to a newly-registered image. IDEMPOTENT: a
8
- * re-post of the version already recorded is a no-op (the row, including its
9
- * `published_at` and any `last_bad_target_version` brake, is left exactly as
10
- * is) — the image workflow can fire the notify more than once for one
11
- * release without churning state. Advancing to a DIFFERENT version rewrites
12
- * the target/image/published_at/source but deliberately preserves
13
- * `last_bad_target_version` (a brake recorded for an older release is
14
- * historical and harmless once the target has moved past it; the wake
15
- * predicate keys off `target !== last_bad`). */
16
- export declare function setHearthTarget(input: SetHearthTargetInput, db?: DatabaseSync): HearthTarget;
17
- /** Record a version whose live roll failed for the fleet (§5.4). After this,
18
- * the wake-path stale predicate excludes the current target (target ===
19
- * last_bad_target_version → no home rolls) until a NEW release advances the
20
- * target past it. This is the ONLY brake in the zero-touch loop (roadmap
21
- * decision 1: never auto-advance onto a release that already failed a roll).
22
- * Returns `null` if no target row exists (nothing to brake). */
23
- export declare function recordBadTarget(badVersion: string, db?: DatabaseSync): HearthTarget | null;
@@ -1,68 +0,0 @@
1
- // control-plane/hearth-target.ts — the CP store for the single global
2
- // auto-upgrade target row (`hearth_target`, db.ts v9). One row, `channel =
3
- // 'home'`. This module is the ONLY reader/writer of that row; the admin
4
- // notify route (server.ts) upserts it, the wake path (wake.ts) reads it to
5
- // decide whether a home is stale, and the rollback path records a bad target
6
- // through it. It goes through the shared CP db handle (`getControlPlaneDb`)
7
- // exactly like `registry.ts` — never a second connection.
8
- import { getControlPlaneDb } from './db.js';
9
- const TARGET_COLUMNS = [
10
- 'channel',
11
- 'target_template_version',
12
- 'target_image_ref',
13
- 'published_at',
14
- 'source',
15
- 'last_bad_target_version',
16
- ];
17
- const SELECT_TARGET = `SELECT ${TARGET_COLUMNS.join(', ')} FROM hearth_target WHERE channel = 'home'`;
18
- function nowIso() {
19
- return new Date().toISOString();
20
- }
21
- /** Read the single global target row, or `null` when no image has ever been
22
- * registered (fresh CP). A `null` means the wake path treats every home as
23
- * up to date (nothing to roll onto yet). */
24
- export function getHearthTarget(db = getControlPlaneDb()) {
25
- const row = db.prepare(SELECT_TARGET).get();
26
- if (row === undefined || row === null)
27
- return null;
28
- return row;
29
- }
30
- /** Upsert the global target to a newly-registered image. IDEMPOTENT: a
31
- * re-post of the version already recorded is a no-op (the row, including its
32
- * `published_at` and any `last_bad_target_version` brake, is left exactly as
33
- * is) — the image workflow can fire the notify more than once for one
34
- * release without churning state. Advancing to a DIFFERENT version rewrites
35
- * the target/image/published_at/source but deliberately preserves
36
- * `last_bad_target_version` (a brake recorded for an older release is
37
- * historical and harmless once the target has moved past it; the wake
38
- * predicate keys off `target !== last_bad`). */
39
- export function setHearthTarget(input, db = getControlPlaneDb()) {
40
- const existing = getHearthTarget(db);
41
- if (existing !== null && existing.target_template_version === input.target_template_version) {
42
- return existing;
43
- }
44
- const published_at = input.published_at ?? nowIso();
45
- db.prepare(`
46
- INSERT INTO hearth_target (channel, target_template_version, target_image_ref, published_at, source, last_bad_target_version)
47
- VALUES ('home', ?, ?, ?, ?, NULL)
48
- ON CONFLICT(channel) DO UPDATE SET
49
- target_template_version = excluded.target_template_version,
50
- target_image_ref = excluded.target_image_ref,
51
- published_at = excluded.published_at,
52
- source = excluded.source
53
- `).run(input.target_template_version, input.target_image_ref, published_at, input.source);
54
- return getHearthTarget(db);
55
- }
56
- /** Record a version whose live roll failed for the fleet (§5.4). After this,
57
- * the wake-path stale predicate excludes the current target (target ===
58
- * last_bad_target_version → no home rolls) until a NEW release advances the
59
- * target past it. This is the ONLY brake in the zero-touch loop (roadmap
60
- * decision 1: never auto-advance onto a release that already failed a roll).
61
- * Returns `null` if no target row exists (nothing to brake). */
62
- export function recordBadTarget(badVersion, db = getControlPlaneDb()) {
63
- const existing = getHearthTarget(db);
64
- if (existing === null)
65
- return null;
66
- db.prepare(`UPDATE hearth_target SET last_bad_target_version = ? WHERE channel = 'home'`).run(badVersion);
67
- return getHearthTarget(db);
68
- }