@crouton-kit/crouter 0.3.40 → 0.3.42
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/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +720 -747
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
- package/dist/core/hearth/providers/blaxel-home.js +0 -405
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /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
|
-
}
|