@crouton-kit/crouter 0.3.40 → 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 -362
  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 -60
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -405
  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,36 +0,0 @@
1
- export type ScheduleFireState = 'pending' | 'waking' | 'delivered' | 'failed';
2
- export interface ScheduleFireRow {
3
- fire_id: string;
4
- schedule_id: string;
5
- home_id: string;
6
- tenant_id: string;
7
- user_id: string;
8
- scheduled_for: string;
9
- state: ScheduleFireState;
10
- attempts: number;
11
- last_error: string | null;
12
- delivered_at: string | null;
13
- created_at: string;
14
- updated_at: string;
15
- }
16
- export interface ClaimFireInput {
17
- fire_id?: string;
18
- schedule_id: string;
19
- scheduled_for: string;
20
- now?: string;
21
- }
22
- export interface ClaimFireResult {
23
- fire: ScheduleFireRow;
24
- claimed: boolean;
25
- }
26
- export interface SetFireStateOptions {
27
- last_error?: string | null;
28
- delivered_at?: string | null;
29
- increment_attempts?: boolean;
30
- now?: string;
31
- }
32
- export declare function claimFire(input: ClaimFireInput): ClaimFireResult;
33
- export declare function getFireById(fireId: string): ScheduleFireRow | null;
34
- export declare function getFireByScheduleSlot(scheduleId: string, scheduledFor: string): ScheduleFireRow | null;
35
- export declare function setFireState(fireId: string, state: ScheduleFireState, options?: SetFireStateOptions): void;
36
- export declare function selectResumableFires(limit?: number): ScheduleFireRow[];
@@ -1,73 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { getControlPlaneDb } from '../db.js';
3
- function nowIso() {
4
- return new Date().toISOString();
5
- }
6
- function scheduleIdentity(scheduleId) {
7
- const row = getControlPlaneDb()
8
- .prepare('SELECT home_id, tenant_id, user_id FROM schedule WHERE schedule_id = ?')
9
- .get(scheduleId);
10
- if (row === undefined)
11
- throw new Error(`schedule not found: ${scheduleId}`);
12
- return row;
13
- }
14
- function fireFrom(row) {
15
- return {
16
- fire_id: row['fire_id'],
17
- schedule_id: row['schedule_id'],
18
- home_id: row['home_id'],
19
- tenant_id: row['tenant_id'],
20
- user_id: row['user_id'],
21
- scheduled_for: row['scheduled_for'],
22
- state: row['state'],
23
- attempts: Number(row['attempts']),
24
- last_error: row['last_error'] ?? null,
25
- delivered_at: row['delivered_at'] ?? null,
26
- created_at: row['created_at'],
27
- updated_at: row['updated_at'],
28
- };
29
- }
30
- export function claimFire(input) {
31
- const fireId = input.fire_id ?? `fire-${randomUUID()}`;
32
- const stamped = input.now ?? nowIso();
33
- const identity = scheduleIdentity(input.schedule_id);
34
- const result = getControlPlaneDb()
35
- .prepare(`INSERT OR IGNORE INTO schedule_fire (
36
- fire_id, schedule_id, home_id, tenant_id, user_id, scheduled_for,
37
- state, attempts, last_error, delivered_at, created_at, updated_at
38
- ) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, ?, ?)`)
39
- .run(fireId, input.schedule_id, identity.home_id, identity.tenant_id, identity.user_id, input.scheduled_for, stamped, stamped);
40
- const fire = getFireByScheduleSlot(input.schedule_id, input.scheduled_for);
41
- if (fire === null)
42
- throw new Error(`failed to claim schedule fire ${input.schedule_id} at ${input.scheduled_for}`);
43
- return { fire, claimed: result.changes === 1 };
44
- }
45
- export function getFireById(fireId) {
46
- const row = getControlPlaneDb().prepare('SELECT * FROM schedule_fire WHERE fire_id = ?').get(fireId);
47
- return row === undefined ? null : fireFrom(row);
48
- }
49
- export function getFireByScheduleSlot(scheduleId, scheduledFor) {
50
- const row = getControlPlaneDb()
51
- .prepare('SELECT * FROM schedule_fire WHERE schedule_id = ? AND scheduled_for = ?')
52
- .get(scheduleId, scheduledFor);
53
- return row === undefined ? null : fireFrom(row);
54
- }
55
- export function setFireState(fireId, state, options = {}) {
56
- const stamped = options.now ?? nowIso();
57
- const deliveredAt = options.delivered_at === undefined ? (state === 'delivered' ? stamped : null) : options.delivered_at;
58
- getControlPlaneDb()
59
- .prepare(`UPDATE schedule_fire
60
- SET state = ?,
61
- attempts = attempts + ?,
62
- last_error = ?,
63
- delivered_at = COALESCE(?, delivered_at),
64
- updated_at = ?
65
- WHERE fire_id = ?`)
66
- .run(state, options.increment_attempts === true ? 1 : 0, options.last_error ?? null, deliveredAt, stamped, fireId);
67
- }
68
- export function selectResumableFires(limit = 100) {
69
- const rows = getControlPlaneDb()
70
- .prepare("SELECT * FROM schedule_fire WHERE state IN ('pending', 'waking') ORDER BY scheduled_for LIMIT ?")
71
- .all(limit);
72
- return rows.map(fireFrom);
73
- }
@@ -1,7 +0,0 @@
1
- /**
2
- * Compute the next fire instant strictly after `after` for `recurrence` in
3
- * `timezone`, returned as an ISO-8601 UTC string. `after` is an ISO string
4
- * (typically the just-fired `next_fire_at`, so the advanced slot is strictly
5
- * in the future and the scan loop cannot re-select it).
6
- */
7
- export declare function computeNextFire(recurrence: string, timezone: string, after: string): string;
@@ -1,58 +0,0 @@
1
- // control-plane/scheduler/recurrence.ts — next-fire computation for a
2
- // `schedule.recurrence` (master plan P5.4). Timezone-aware, using the vetted
3
- // `cron-parser` rather than a hand-rolled cron implementation.
4
- //
5
- // Two recurrence forms are accepted:
6
- // - a standard 5- or 6-field cron expression ("*/5 * * * *", "0 9 * * 1-5"),
7
- // evaluated in `timezone`;
8
- // - a fixed interval "@every <n><unit>" where unit is s/m/h/d
9
- // ("@every 30m", "@every 90s"), timezone-independent.
10
- // Anything else throws — an unparseable recurrence is an operator error, not
11
- // something to silently skip.
12
- import { CronExpressionParser } from 'cron-parser';
13
- import { general } from '../../../core/errors.js';
14
- const INTERVAL_UNIT_MS = {
15
- s: 1_000,
16
- m: 60_000,
17
- h: 3_600_000,
18
- d: 86_400_000,
19
- };
20
- function parseIntervalMs(spec) {
21
- const match = /^@every\s+(\d+)\s*([smhd])$/.exec(spec.trim());
22
- if (match === null)
23
- return null;
24
- const quantity = Number(match[1]);
25
- const unitMs = INTERVAL_UNIT_MS[match[2]];
26
- const totalMs = quantity * unitMs;
27
- if (totalMs <= 0)
28
- return null;
29
- return totalMs;
30
- }
31
- /**
32
- * Compute the next fire instant strictly after `after` for `recurrence` in
33
- * `timezone`, returned as an ISO-8601 UTC string. `after` is an ISO string
34
- * (typically the just-fired `next_fire_at`, so the advanced slot is strictly
35
- * in the future and the scan loop cannot re-select it).
36
- */
37
- export function computeNextFire(recurrence, timezone, after) {
38
- const afterDate = new Date(after);
39
- if (Number.isNaN(afterDate.getTime())) {
40
- throw general(`invalid "after" instant for recurrence computation: ${after}`, { after });
41
- }
42
- const intervalMs = parseIntervalMs(recurrence);
43
- if (intervalMs !== null) {
44
- return new Date(afterDate.getTime() + intervalMs).toISOString();
45
- }
46
- let interval;
47
- try {
48
- interval = CronExpressionParser.parse(recurrence, { currentDate: afterDate, tz: timezone });
49
- }
50
- catch (error) {
51
- throw general(`invalid cron recurrence: ${recurrence}`, {
52
- recurrence,
53
- timezone,
54
- error: error instanceof Error ? error.message : String(error),
55
- });
56
- }
57
- return interval.next().toDate().toISOString();
58
- }
@@ -1,38 +0,0 @@
1
- import { advanceNextFire, type ScheduleRow } from './schedule-store.js';
2
- import { claimFire, setFireState, type ScheduleFireRow } from './fire-store.js';
3
- import { computeNextFire } from './recurrence.js';
4
- import { buildTriggerEnvelope } from '../triggers/envelope.js';
5
- import { type DeliverTriggerResult } from '../triggers/deliver.js';
6
- export interface SchedulerLoopDeps {
7
- selectDueSchedules: (now: string) => ScheduleRow[];
8
- getScheduleById: (scheduleId: string) => ScheduleRow | null;
9
- claimFire: typeof claimFire;
10
- setFireState: typeof setFireState;
11
- advanceNextFire: typeof advanceNextFire;
12
- selectResumableFires: () => ScheduleFireRow[];
13
- computeNextFire: typeof computeNextFire;
14
- deliverTrigger: (input: {
15
- home_id: string;
16
- source_ref: string;
17
- envelope: ReturnType<typeof buildTriggerEnvelope>;
18
- tier: 'urgent';
19
- metadata?: Record<string, unknown>;
20
- }) => Promise<DeliverTriggerResult>;
21
- now: () => string;
22
- intervalMs: number;
23
- }
24
- /** Production defaults — every dep resolves to the real store/chokepoint. A
25
- * test injects fakes for all of them. */
26
- export declare function defaultSchedulerLoopDeps(): SchedulerLoopDeps;
27
- /** One full scan pass: restart-recovery first, then due-schedule firing. */
28
- export declare function runSchedulerScan(deps: SchedulerLoopDeps): Promise<void>;
29
- export interface SchedulerLoopHandle {
30
- stop: () => void;
31
- }
32
- /**
33
- * Start the scan loop: run one pass immediately, then every `intervalMs`.
34
- * Passes never overlap (a `running` guard skips a tick still in flight).
35
- * Returns a handle whose `stop()` clears the interval — no timer is left
36
- * armed after `stop()`.
37
- */
38
- export declare function startSchedulerLoop(deps?: SchedulerLoopDeps): SchedulerLoopHandle;
@@ -1,138 +0,0 @@
1
- // control-plane/scheduler/scan-loop.ts — the always-on cron scan loop
2
- // (master plan P5.4 / part-plan T-C1). On an interval (and once at startup):
3
- //
4
- // 1. RESTART RECOVERY FIRST: `selectResumableFires()` returns any fire left
5
- // in `pending|waking` by a CP that crashed mid-fire; re-drive each to a
6
- // terminal state. The unique `(schedule_id, scheduled_for)` slot means a
7
- // crashed fire is never re-CLAIMED — it is re-DELIVERED (at-least-once;
8
- // the home dedupes on `trigger_id == fire_id`), so a mid-fire restart
9
- // yields exactly one delivery, never a double.
10
- // 2. `selectDueSchedules(now)`: for each due schedule, `claimFire` the slot.
11
- // A won claim advances `next_fire_at` (so the slot cannot be re-selected)
12
- // then delivers; a lost claim (another pass owns it) is skipped.
13
- //
14
- // Delivery ALWAYS goes through the shared `deliverTrigger` chokepoint with
15
- // tier `urgent` (R-TRIG-3) and `trigger_id == fire_id` (R-CRON-5). On failure
16
- // the fire is marked `failed` with the error and an incremented attempt count;
17
- // there is NO backoff/retry schedule (part-plan gap 3) — the schedule's next
18
- // natural `next_fire_at` tick is the only re-fire.
19
- import { advanceNextFire, getScheduleById, selectDueSchedules } from './schedule-store.js';
20
- import { claimFire, selectResumableFires, setFireState } from './fire-store.js';
21
- import { computeNextFire } from './recurrence.js';
22
- import { buildTriggerEnvelope } from '../triggers/envelope.js';
23
- import { deliverTrigger } from '../triggers/deliver.js';
24
- const DEFAULT_SCAN_INTERVAL_MS = 15_000;
25
- /** Production defaults — every dep resolves to the real store/chokepoint. A
26
- * test injects fakes for all of them. */
27
- export function defaultSchedulerLoopDeps() {
28
- return {
29
- selectDueSchedules,
30
- getScheduleById,
31
- claimFire,
32
- setFireState,
33
- advanceNextFire,
34
- selectResumableFires,
35
- computeNextFire,
36
- deliverTrigger: (input) => deliverTrigger(input),
37
- now: () => new Date().toISOString(),
38
- intervalMs: DEFAULT_SCAN_INTERVAL_MS,
39
- };
40
- }
41
- /** Deliver one already-claimed fire. Marks `waking`, builds the cron envelope
42
- * (trigger_id == fire_id), delivers through the chokepoint at `urgent`, and
43
- * records the terminal fire state. Never throws — a delivery failure is
44
- * recorded on the fire, not propagated, so one bad fire cannot abort the
45
- * whole scan. */
46
- async function deliverFire(deps, fire, schedule) {
47
- deps.setFireState(fire.fire_id, 'waking');
48
- const envelope = buildTriggerEnvelope({
49
- source: 'cron',
50
- trigger_id: fire.fire_id,
51
- payload: schedule.delivery_payload,
52
- received_at: deps.now(),
53
- });
54
- try {
55
- const result = await deps.deliverTrigger({
56
- home_id: fire.home_id,
57
- source_ref: fire.fire_id,
58
- envelope,
59
- tier: 'urgent',
60
- metadata: { schedule_id: schedule.schedule_id, label: schedule.label },
61
- });
62
- if (result.outcome === 'delivered') {
63
- deps.setFireState(fire.fire_id, 'delivered');
64
- }
65
- else {
66
- deps.setFireState(fire.fire_id, 'failed', {
67
- last_error: result.reason ?? result.outcome,
68
- increment_attempts: true,
69
- });
70
- }
71
- }
72
- catch (error) {
73
- deps.setFireState(fire.fire_id, 'failed', {
74
- last_error: error instanceof Error ? error.message : String(error),
75
- increment_attempts: true,
76
- });
77
- }
78
- }
79
- /** Re-drive a fire left `pending|waking` by a crashed CP. Loads its schedule
80
- * for the payload and re-delivers; a schedule deleted out from under a
81
- * stranded fire marks the fire `failed` rather than looping forever. */
82
- async function resumeFire(deps, fire) {
83
- const schedule = deps.getScheduleById(fire.schedule_id);
84
- if (schedule === null) {
85
- deps.setFireState(fire.fire_id, 'failed', { last_error: 'schedule deleted before fire completed', increment_attempts: true });
86
- return;
87
- }
88
- await deliverFire(deps, fire, schedule);
89
- }
90
- /** One full scan pass: restart-recovery first, then due-schedule firing. */
91
- export async function runSchedulerScan(deps) {
92
- for (const fire of deps.selectResumableFires()) {
93
- await resumeFire(deps, fire);
94
- }
95
- const now = deps.now();
96
- for (const schedule of deps.selectDueSchedules(now)) {
97
- const claim = deps.claimFire({ schedule_id: schedule.schedule_id, scheduled_for: schedule.next_fire_at });
98
- if (!claim.claimed)
99
- continue; // another pass owns this slot.
100
- // Advance the durable timer BEFORE delivery so the slot cannot be
101
- // re-selected on the next scan even if delivery is slow.
102
- const nextFireAt = deps.computeNextFire(schedule.recurrence, schedule.timezone, schedule.next_fire_at);
103
- deps.advanceNextFire(schedule.schedule_id, nextFireAt, schedule.next_fire_at);
104
- await deliverFire(deps, claim.fire, schedule);
105
- }
106
- }
107
- /**
108
- * Start the scan loop: run one pass immediately, then every `intervalMs`.
109
- * Passes never overlap (a `running` guard skips a tick still in flight).
110
- * Returns a handle whose `stop()` clears the interval — no timer is left
111
- * armed after `stop()`.
112
- */
113
- export function startSchedulerLoop(deps = defaultSchedulerLoopDeps()) {
114
- let running = false;
115
- let stopped = false;
116
- const tick = () => {
117
- if (running || stopped)
118
- return;
119
- running = true;
120
- void runSchedulerScan(deps)
121
- .catch((error) => {
122
- process.stderr.write(`${JSON.stringify({ level: 'error', message: 'hearth scheduler scan failed', error: error instanceof Error ? error.message : String(error) })}\n`);
123
- })
124
- .finally(() => {
125
- running = false;
126
- });
127
- };
128
- const timer = setInterval(tick, deps.intervalMs);
129
- // Do not keep the process alive solely for the scan timer.
130
- timer.unref?.();
131
- tick();
132
- return {
133
- stop: () => {
134
- stopped = true;
135
- clearInterval(timer);
136
- },
137
- };
138
- }
@@ -1,32 +0,0 @@
1
- export interface ScheduleRow {
2
- schedule_id: string;
3
- tenant_id: string;
4
- user_id: string;
5
- home_id: string;
6
- label: string;
7
- recurrence: string;
8
- timezone: string;
9
- delivery_payload: unknown;
10
- next_fire_at: string;
11
- last_fired_at: string | null;
12
- enabled: boolean;
13
- created_at: string;
14
- updated_at: string;
15
- }
16
- export interface CreateScheduleInput {
17
- schedule_id?: string;
18
- home_id: string;
19
- label: string;
20
- recurrence: string;
21
- timezone: string;
22
- delivery_payload: unknown;
23
- next_fire_at: string;
24
- enabled?: boolean;
25
- now?: string;
26
- }
27
- export declare function createSchedule(input: CreateScheduleInput): ScheduleRow;
28
- export declare function getScheduleById(scheduleId: string): ScheduleRow | null;
29
- export declare function selectDueSchedules(now: string, limit?: number): ScheduleRow[];
30
- export declare function listSchedulesForUser(tenantId: string, userId: string): ScheduleRow[];
31
- export declare function advanceNextFire(scheduleId: string, nextFireAt: string, lastFiredAt: string, now?: string): void;
32
- export declare function setScheduleEnabled(scheduleId: string, enabled: boolean, now?: string): void;
@@ -1,66 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { getControlPlaneDb } from '../db.js';
3
- function nowIso() {
4
- return new Date().toISOString();
5
- }
6
- function homeIdentity(homeId) {
7
- const row = getControlPlaneDb().prepare('SELECT tenant_id, user_id FROM home WHERE home_id = ?').get(homeId);
8
- if (row === undefined)
9
- throw new Error(`home not found: ${homeId}`);
10
- return row;
11
- }
12
- function scheduleFrom(row) {
13
- return {
14
- schedule_id: row['schedule_id'],
15
- tenant_id: row['tenant_id'],
16
- user_id: row['user_id'],
17
- home_id: row['home_id'],
18
- label: row['label'],
19
- recurrence: row['recurrence'],
20
- timezone: row['timezone'],
21
- delivery_payload: JSON.parse(row['delivery_payload']),
22
- next_fire_at: row['next_fire_at'],
23
- last_fired_at: row['last_fired_at'] ?? null,
24
- enabled: Number(row['enabled']) !== 0,
25
- created_at: row['created_at'],
26
- updated_at: row['updated_at'],
27
- };
28
- }
29
- export function createSchedule(input) {
30
- const scheduleId = input.schedule_id ?? `sch-${randomUUID()}`;
31
- const stamped = input.now ?? nowIso();
32
- const identity = homeIdentity(input.home_id);
33
- getControlPlaneDb()
34
- .prepare(`INSERT INTO schedule (
35
- schedule_id, tenant_id, user_id, home_id, label, recurrence, timezone,
36
- delivery_payload, next_fire_at, last_fired_at, enabled, created_at, updated_at
37
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
38
- .run(scheduleId, identity.tenant_id, identity.user_id, input.home_id, input.label, input.recurrence, input.timezone, JSON.stringify(input.delivery_payload), input.next_fire_at, input.enabled === false ? 0 : 1, stamped, stamped);
39
- return getScheduleById(scheduleId);
40
- }
41
- export function getScheduleById(scheduleId) {
42
- const row = getControlPlaneDb().prepare('SELECT * FROM schedule WHERE schedule_id = ?').get(scheduleId);
43
- return row === undefined ? null : scheduleFrom(row);
44
- }
45
- export function selectDueSchedules(now, limit = 100) {
46
- const rows = getControlPlaneDb()
47
- .prepare('SELECT * FROM schedule WHERE enabled = 1 AND next_fire_at <= ? ORDER BY next_fire_at LIMIT ?')
48
- .all(now, limit);
49
- return rows.map(scheduleFrom);
50
- }
51
- export function listSchedulesForUser(tenantId, userId) {
52
- const rows = getControlPlaneDb()
53
- .prepare('SELECT * FROM schedule WHERE tenant_id = ? AND user_id = ? ORDER BY next_fire_at')
54
- .all(tenantId, userId);
55
- return rows.map(scheduleFrom);
56
- }
57
- export function advanceNextFire(scheduleId, nextFireAt, lastFiredAt, now = nowIso()) {
58
- getControlPlaneDb()
59
- .prepare('UPDATE schedule SET next_fire_at = ?, last_fired_at = ?, updated_at = ? WHERE schedule_id = ?')
60
- .run(nextFireAt, lastFiredAt, now, scheduleId);
61
- }
62
- export function setScheduleEnabled(scheduleId, enabled, now = nowIso()) {
63
- getControlPlaneDb()
64
- .prepare('UPDATE schedule SET enabled = ?, updated_at = ? WHERE schedule_id = ?')
65
- .run(enabled ? 1 : 0, now, scheduleId);
66
- }
@@ -1,31 +0,0 @@
1
- /** Resolve a `relay_auth_ref`/`channel_secret_ref` handle to its live secret
2
- * value. Never reads the CP DB — the DB holds only the handle string; this
3
- * is the only path that turns it into a value (S4/S5 in the registry/relay
4
- * part-plan). Lookup order: an explicit per-handle env override
5
- * (`CRTR_HEARTH_CP_SECRET_<SANITIZED_HANDLE>`), then the secrets file
6
- * (`CRTR_HEARTH_CP_SECRETS_FILE`, default beside the CP data dir). Throws on
7
- * an unknown handle — the thrown message never includes a resolved value,
8
- * only the handle name. */
9
- export declare function resolveSecret(handle: string, env?: NodeJS.ProcessEnv): string;
10
- /** The secret HANDLE for the CP admin bearer token that gates the
11
- * `POST /__admin/*` ingress (the image workflow's push-notify, design
12
- * §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
13
- * handle: the per-handle env override
14
- * `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
15
- * key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
16
- * and the deployed Fly CP must carry the SAME value. */
17
- export declare const HEARTH_CP_ADMIN_TOKEN_HANDLE = "HEARTH_CP_ADMIN_TOKEN";
18
- /** Resolve the CP admin bearer token, or `null` when it is not configured.
19
- * Unlike `resolveSecret` (which throws on an unknown handle), this returns
20
- * `null` so the admin route can FAIL CLOSED — a CP with no admin token
21
- * configured rejects every `/__admin/*` request rather than crashing the
22
- * request handler. Never logs the value. */
23
- export declare function resolveCpAdminToken(env?: NodeJS.ProcessEnv): string | null;
24
- /** Redact every secret value resolved so far this process out of a log
25
- * line/message. CP code building a log line from data that may carry a
26
- * resolved secret (e.g. an upstream request trace) MUST pass it through this
27
- * before writing it — never log a resolved value directly. */
28
- export declare function redactSecrets(message: string): string;
29
- /** Test-only: clear the in-memory file cache + redaction set so a test run
30
- * with a different env/file doesn't see a prior run's cached state. */
31
- export declare function resetSecretsCacheForTests(): void;
@@ -1,134 +0,0 @@
1
- // CP secret-handle resolution (P1.3). The `home` table (and the channel
2
- // tables that follow it) stores secret material only as an opaque HANDLE
3
- // (`relay_auth_ref`, `channel_secret_ref`) — never the secret itself (R-CP-8).
4
- // This module is the one seam that turns a handle into a live value, sourced
5
- // from the process env or a flat JSON file for the dogfood cutover (M0 used
6
- // Fly secrets behind the same shape). Dogfood may keep that file beside the
7
- // CP SQLite data volume — that is operational indirection for ops
8
- // convenience, not a host-compromise security boundary.
9
- import { existsSync, readFileSync } from 'node:fs';
10
- import { homedir } from 'node:os';
11
- import { join, resolve } from 'node:path';
12
- import { general } from '../../core/errors.js';
13
- const ENV = {
14
- // Explicit override: a flat JSON file mapping handle -> secret value.
15
- secretsFile: 'CRTR_HEARTH_CP_SECRETS_FILE',
16
- // Where the CP keeps its SQLite data (db.ts will read the same root); the
17
- // secrets file defaults to living beside it when no explicit file is set.
18
- dataDir: 'CRTR_HEARTH_CP_DATA_DIR',
19
- };
20
- const SECRETS_FILE_NAME = 'secrets.json';
21
- function expandHomePath(input) {
22
- if (input.startsWith('~'))
23
- return join(homedir(), input.slice(1));
24
- return resolve(input);
25
- }
26
- function defaultSecretsFilePath(env) {
27
- const dataDir = env[ENV.dataDir]?.trim();
28
- const base = dataDir !== undefined && dataDir !== '' ? expandHomePath(dataDir) : expandHomePath('~/.crouter/hearth-control-plane');
29
- return join(base, SECRETS_FILE_NAME);
30
- }
31
- function secretsFilePath(env) {
32
- const override = env[ENV.secretsFile]?.trim();
33
- return override !== undefined && override !== '' ? expandHomePath(override) : defaultSecretsFilePath(env);
34
- }
35
- function sanitizeHandleForEnvName(handle) {
36
- return handle.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_');
37
- }
38
- function perHandleEnvName(handle) {
39
- return `CRTR_HEARTH_CP_SECRET_${sanitizeHandleForEnvName(handle)}`;
40
- }
41
- let fileCache = null;
42
- function loadSecretsFile(env) {
43
- const path = secretsFilePath(env);
44
- if (fileCache !== null && fileCache.path === path)
45
- return fileCache.values;
46
- if (!existsSync(path)) {
47
- fileCache = { path, values: {} };
48
- return fileCache.values;
49
- }
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(readFileSync(path, 'utf8'));
53
- }
54
- catch {
55
- throw general(`invalid secrets file (not valid JSON): ${path}`, { path });
56
- }
57
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
58
- throw general(`secrets file must be a flat JSON object of handle -> value: ${path}`, { path });
59
- }
60
- const values = {};
61
- for (const [key, value] of Object.entries(parsed)) {
62
- if (typeof value === 'string' && value !== '')
63
- values[key] = value;
64
- }
65
- fileCache = { path, values };
66
- return values;
67
- }
68
- // Every value resolved this process, kept only to redact them back out of
69
- // log lines. Never logged or persisted itself.
70
- const resolvedValues = new Set();
71
- /** Resolve a `relay_auth_ref`/`channel_secret_ref` handle to its live secret
72
- * value. Never reads the CP DB — the DB holds only the handle string; this
73
- * is the only path that turns it into a value (S4/S5 in the registry/relay
74
- * part-plan). Lookup order: an explicit per-handle env override
75
- * (`CRTR_HEARTH_CP_SECRET_<SANITIZED_HANDLE>`), then the secrets file
76
- * (`CRTR_HEARTH_CP_SECRETS_FILE`, default beside the CP data dir). Throws on
77
- * an unknown handle — the thrown message never includes a resolved value,
78
- * only the handle name. */
79
- export function resolveSecret(handle, env = process.env) {
80
- const trimmed = handle.trim();
81
- if (trimmed === '')
82
- throw general('secret handle must not be empty');
83
- const envOverride = env[perHandleEnvName(trimmed)];
84
- if (envOverride !== undefined && envOverride.trim() !== '') {
85
- resolvedValues.add(envOverride);
86
- return envOverride;
87
- }
88
- const fromFile = loadSecretsFile(env)[trimmed];
89
- if (fromFile !== undefined) {
90
- resolvedValues.add(fromFile);
91
- return fromFile;
92
- }
93
- throw general(`unknown secret handle: ${trimmed}`, { handle: trimmed });
94
- }
95
- /** The secret HANDLE for the CP admin bearer token that gates the
96
- * `POST /__admin/*` ingress (the image workflow's push-notify, design
97
- * §2-C). Resolves through the SAME `resolveSecret` seam as every other CP
98
- * handle: the per-handle env override
99
- * `CRTR_HEARTH_CP_SECRET_HEARTH_CP_ADMIN_TOKEN`, else the `HEARTH_CP_ADMIN_TOKEN`
100
- * key in the CP secrets file. The GitHub repo secret `HEARTH_CP_ADMIN_TOKEN`
101
- * and the deployed Fly CP must carry the SAME value. */
102
- export const HEARTH_CP_ADMIN_TOKEN_HANDLE = 'HEARTH_CP_ADMIN_TOKEN';
103
- /** Resolve the CP admin bearer token, or `null` when it is not configured.
104
- * Unlike `resolveSecret` (which throws on an unknown handle), this returns
105
- * `null` so the admin route can FAIL CLOSED — a CP with no admin token
106
- * configured rejects every `/__admin/*` request rather than crashing the
107
- * request handler. Never logs the value. */
108
- export function resolveCpAdminToken(env = process.env) {
109
- try {
110
- return resolveSecret(HEARTH_CP_ADMIN_TOKEN_HANDLE, env);
111
- }
112
- catch {
113
- return null;
114
- }
115
- }
116
- /** Redact every secret value resolved so far this process out of a log
117
- * line/message. CP code building a log line from data that may carry a
118
- * resolved secret (e.g. an upstream request trace) MUST pass it through this
119
- * before writing it — never log a resolved value directly. */
120
- export function redactSecrets(message) {
121
- let redacted = message;
122
- for (const value of resolvedValues) {
123
- if (value === '')
124
- continue;
125
- redacted = redacted.split(value).join('[redacted]');
126
- }
127
- return redacted;
128
- }
129
- /** Test-only: clear the in-memory file cache + redaction set so a test run
130
- * with a different env/file doesn't see a prior run's cached state. */
131
- export function resetSecretsCacheForTests() {
132
- fileCache = null;
133
- resolvedValues.clear();
134
- }
@@ -1,27 +0,0 @@
1
- import { type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
2
- import type { Socket } from 'node:net';
3
- import type { DatabaseSync } from 'node:sqlite';
4
- import { type TenantSessionStore } from './session.js';
5
- import type { ControlPlaneConfig } from './config.js';
6
- import type { ResolvedHome } from './types.js';
7
- /** The CP→home relay boundary. P4.1 ships only the gate (session+registry
8
- * resolution) in front of this seam; the real implementation (ensureAwake,
9
- * HTTP/WS forwarding to `preview_endpoint`, relay-token injection) lands in
10
- * P4.2/P4.3 and plugs in by passing a `RelayDispatcher` to
11
- * `createControlPlaneServer`. */
12
- export interface RelayDispatcher {
13
- proxyHttp(req: IncomingMessage, res: ServerResponse, home: ResolvedHome): void | Promise<void>;
14
- proxyUpgrade(req: IncomingMessage, socket: Socket, head: Buffer, home: ResolvedHome): void;
15
- }
16
- export interface ControlPlaneRouteDeps {
17
- db?: DatabaseSync;
18
- sessionStore?: TenantSessionStore;
19
- relay?: RelayDispatcher;
20
- }
21
- /** Build the CP HTTP+WS server. No registry seeding and no idle timers are
22
- * started here or anywhere reachable from this constructor (master plan
23
- * P4.1 / S3 — the provider owns idle->suspend, not the CP). `deps` lets
24
- * callers inject the db handle, a session store, and a relay dispatcher —
25
- * `main.ts` uses the real env-resolved defaults; tests/smoke checks inject
26
- * fakes for all three without touching global env state. */
27
- export declare function createControlPlaneServer(config: ControlPlaneConfig, deps?: ControlPlaneRouteDeps): HttpServer;