@crouton-kit/crouter 0.3.38 → 0.3.40

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 (30) hide show
  1. package/dist/core/hearth/provider.d.ts +8 -0
  2. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.d.ts +1 -0
  3. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +362 -0
  4. package/dist/core/hearth/providers/blaxel-home.d.ts +45 -0
  5. package/dist/core/hearth/providers/blaxel-home.js +290 -21
  6. package/dist/core/hearth/providers/blaxel.d.ts +5 -0
  7. package/dist/core/hearth/providers/blaxel.js +86 -4
  8. package/dist/core/hearth/providers/types.d.ts +16 -0
  9. package/dist/hearth/control-plane/__tests__/error-serialization.test.d.ts +1 -0
  10. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +29 -0
  11. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +1 -0
  12. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +44 -0
  13. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +1 -0
  14. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +230 -0
  15. package/dist/hearth/control-plane/db.js +27 -0
  16. package/dist/hearth/control-plane/hearth-target.d.ts +23 -0
  17. package/dist/hearth/control-plane/hearth-target.js +68 -0
  18. package/dist/hearth/control-plane/registry.d.ts +6 -1
  19. package/dist/hearth/control-plane/registry.js +7 -0
  20. package/dist/hearth/control-plane/relay.d.ts +4 -0
  21. package/dist/hearth/control-plane/relay.js +72 -3
  22. package/dist/hearth/control-plane/secrets.d.ts +14 -0
  23. package/dist/hearth/control-plane/secrets.js +21 -0
  24. package/dist/hearth/control-plane/server.js +140 -4
  25. package/dist/hearth/control-plane/serving.d.ts +15 -0
  26. package/dist/hearth/control-plane/serving.js +106 -0
  27. package/dist/hearth/control-plane/types.d.ts +20 -0
  28. package/dist/hearth/control-plane/wake.d.ts +8 -2
  29. package/dist/hearth/control-plane/wake.js +241 -3
  30. package/package.json +1 -1
@@ -53,10 +53,14 @@
53
53
  // handed to the dispatcher, which today simply reports "not implemented yet"
54
54
  // without contacting any home.
55
55
  import { createServer } from 'node:http';
56
+ import { createHash, timingSafeEqual } from 'node:crypto';
57
+ import { hearthHomeImageRefForVersion } from '../../core/hearth/config.js';
56
58
  import { getControlPlaneDb } from './db.js';
57
59
  import { resolveHome } from './registry.js';
60
+ import { setHearthTarget } from './hearth-target.js';
58
61
  import { resolveTenantSession, resolveTenantWriteAuth, swapQueryTokenForCookie, tenantSessionAuthRedactedMessage } from './session.js';
59
- import { redactSecrets } from './secrets.js';
62
+ import { redactSecrets, resolveCpAdminToken } from './secrets.js';
63
+ import { beginOAuth, endOAuth } from './serving.js';
60
64
  import { handleWebhookRequest, isWebhookPath, isWebhookRequest } from './register.js';
61
65
  const notImplementedRelay = {
62
66
  proxyHttp(_req, res) {
@@ -89,6 +93,106 @@ function redirectChat(res, target) {
89
93
  function respondHealthz(res) {
90
94
  sendText(res, 200, 'ok\n');
91
95
  }
96
+ const ADMIN_HEARTH_IMAGE_PATH = '/__admin/hearth-image';
97
+ // The auto-upgrade notify body is tiny ({template_version, image_ref}); cap
98
+ // hard so a malformed/oversized POST can't buffer unbounded.
99
+ const ADMIN_BODY_MAX_BYTES = 4 * 1024;
100
+ const STRICT_SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
101
+ /** Fixed-length digest of a UTF-8 string — always 32 bytes (sha256), so
102
+ * comparing two digests never branches on the ORIGINAL strings' lengths. */
103
+ function sha256(value) {
104
+ return createHash('sha256').update(value, 'utf8').digest();
105
+ }
106
+ /** Constant-time bearer check against the configured CP admin token. Fails
107
+ * CLOSED: no `Authorization: Bearer` header, or no token configured on the
108
+ * CP, or a mismatch — all return false without leaking which. Compares
109
+ * sha256 digests (always 32 bytes each) rather than the raw token buffers,
110
+ * so there is no length-inequality short-circuit that would leak the
111
+ * configured token's length before `timingSafeEqual` runs — equal plaintext
112
+ * still implies equal digest, and every digest is the same size regardless
113
+ * of what was presented. */
114
+ function isAdminAuthorized(req) {
115
+ const configured = resolveCpAdminToken();
116
+ if (configured === null || configured === '')
117
+ return false;
118
+ const header = req.headers.authorization;
119
+ if (typeof header !== 'string')
120
+ return false;
121
+ const match = /^Bearer[ ]+(.+)$/.exec(header.trim());
122
+ if (match === null)
123
+ return false;
124
+ return timingSafeEqual(sha256(match[1]), sha256(configured));
125
+ }
126
+ function readRequestBody(req, maxBytes) {
127
+ return new Promise((resolve, reject) => {
128
+ const chunks = [];
129
+ let total = 0;
130
+ let tooLarge = false;
131
+ req.on('data', (chunk) => {
132
+ total += chunk.length;
133
+ if (total > maxBytes) {
134
+ tooLarge = true;
135
+ req.destroy();
136
+ return;
137
+ }
138
+ chunks.push(chunk);
139
+ });
140
+ req.on('end', () => resolve({ body: Buffer.concat(chunks), tooLarge }));
141
+ req.on('error', (error) => {
142
+ if (tooLarge) {
143
+ resolve({ body: Buffer.alloc(0), tooLarge: true });
144
+ return;
145
+ }
146
+ reject(error instanceof Error ? error : new Error(String(error)));
147
+ });
148
+ });
149
+ }
150
+ /** `POST /__admin/hearth-image` — the image-build workflow's push-notify
151
+ * (design §2-C). Bearer-authed against the CP admin token (constant-time),
152
+ * BEFORE and outside the session/registry gate (it is a machine ingress, not
153
+ * a browser lane). Validates the body is strict semver + `image_ref ===
154
+ * hearthHomeImageRefForVersion(template_version)` (the notify cannot register
155
+ * an image name that doesn't match its version), then idempotently upserts
156
+ * the single global target row — a re-post of the same version is a no-op. It
157
+ * never contacts a home; the actual roll happens lazily on each home's next
158
+ * wake (wake.ts). */
159
+ async function respondAdminHearthImage(req, res, db) {
160
+ if (!isAdminAuthorized(req)) {
161
+ sendText(res, 401, 'admin authorization required\n');
162
+ return;
163
+ }
164
+ const { body, tooLarge } = await readRequestBody(req, ADMIN_BODY_MAX_BYTES);
165
+ if (tooLarge) {
166
+ sendText(res, 413, 'request body too large\n');
167
+ return;
168
+ }
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(body.toString('utf8'));
172
+ }
173
+ catch {
174
+ sendText(res, 400, 'body must be valid JSON\n');
175
+ return;
176
+ }
177
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
178
+ sendText(res, 400, 'body must be a JSON object with template_version and image_ref\n');
179
+ return;
180
+ }
181
+ const record = parsed;
182
+ const templateVersion = typeof record.template_version === 'string' ? record.template_version.trim() : '';
183
+ const imageRef = typeof record.image_ref === 'string' ? record.image_ref.trim() : '';
184
+ if (!STRICT_SEMVER_RE.test(templateVersion)) {
185
+ sendText(res, 400, 'template_version must be strict semver (X.Y.Z)\n');
186
+ return;
187
+ }
188
+ const expectedImageRef = hearthHomeImageRefForVersion(templateVersion);
189
+ if (imageRef !== expectedImageRef) {
190
+ sendText(res, 400, `image_ref must equal ${expectedImageRef} for template_version ${templateVersion}\n`);
191
+ return;
192
+ }
193
+ const target = setHearthTarget({ target_template_version: templateVersion, target_image_ref: imageRef, source: 'image-workflow' }, db);
194
+ sendText(res, 200, `hearth target = ${target.target_template_version} (${target.target_image_ref})\n`);
195
+ }
92
196
  /** `?token=` is intentionally not accepted as durable auth (`session.ts`); the
93
197
  * one allowed query-token use is the single-use M0 handoff swap-to-cookie,
94
198
  * attempted here only when no durable cookie/bearer session is already
@@ -128,12 +232,14 @@ function respondRoot(req, res, db, config, store) {
128
232
  }
129
233
  redirectChat(res, target);
130
234
  }
235
+ const MODEL_AUTH_START_PATH = '/__hearth/model-auth/anthropic/start';
236
+ const MODEL_AUTH_FINISH_PATH = '/__hearth/model-auth/anthropic/finish';
131
237
  const DYNAMIC_ROUTES = [
132
238
  { method: 'POST', path: '/__crtr/source', auth: 'write' },
133
239
  { method: 'GET', path: '/__crtr/events', auth: 'read' },
134
240
  { method: 'GET', path: '/__hearth/model-auth/anthropic/status', auth: 'read' },
135
- { method: 'POST', path: '/__hearth/model-auth/anthropic/start', auth: 'write' },
136
- { method: 'POST', path: '/__hearth/model-auth/anthropic/finish', auth: 'write' },
241
+ { method: 'POST', path: MODEL_AUTH_START_PATH, auth: 'write' },
242
+ { method: 'POST', path: MODEL_AUTH_FINISH_PATH, auth: 'write' },
137
243
  ];
138
244
  const DYNAMIC_ROUTE_PATHS = new Set(DYNAMIC_ROUTES.map((route) => route.path));
139
245
  function findDynamicRoute(method, pathname) {
@@ -177,6 +283,13 @@ function isRelayableViewOrAssetPath(pathname) {
177
283
  * (or, for `auth: 'write'`, an untrusted Origin) or an unresolved home
178
284
  * rejects before the relay dispatcher (and therefore before any
179
285
  * wake/provider/home contact) is ever invoked. */
286
+ /** OAuth-in-progress marker: the CP's own proxied `start`/`finish` dispatch
287
+ * is the seam — mark a window open the moment a
288
+ * `start` is about to be proxied (regardless of what the guest does with
289
+ * it), close it once the `finish` proxy call resolves (success OR failure;
290
+ * a failed finish still ends the CP-visible window — the bounded TTL in
291
+ * `serving.ts` is the backstop for a `start` that never gets a `finish` at
292
+ * all, e.g. an abandoned browser tab). */
180
293
  async function respondDynamicLane(req, res, db, config, store, relay, auth) {
181
294
  const session = auth === 'write'
182
295
  ? resolveTenantWriteAuth(req, { store, publicOrigin: config.publicOrigin })
@@ -190,7 +303,18 @@ async function respondDynamicLane(req, res, db, config, store, relay, auth) {
190
303
  sendText(res, 404, 'home not found\n');
191
304
  return;
192
305
  }
193
- await relay.proxyHttp(req, res, home);
306
+ const pathname = requestUrl(req).pathname;
307
+ const isOAuthStart = pathname === MODEL_AUTH_START_PATH;
308
+ const isOAuthFinish = pathname === MODEL_AUTH_FINISH_PATH;
309
+ if (isOAuthStart)
310
+ beginOAuth(home.home_id);
311
+ try {
312
+ await relay.proxyHttp(req, res, home);
313
+ }
314
+ finally {
315
+ if (isOAuthFinish)
316
+ endOAuth(home.home_id);
317
+ }
194
318
  }
195
319
  async function routeControlPlaneRequest(req, res, db, config, store, relay) {
196
320
  const url = requestUrl(req);
@@ -215,6 +339,18 @@ async function routeControlPlaneRequest(req, res, db, config, store, relay) {
215
339
  await handleWebhookRequest(req, res);
216
340
  return;
217
341
  }
342
+ // Admin push-notify lane. Authenticated by the CP admin bearer token inside
343
+ // the handler, so — like the webhook lane — it sits BEFORE and outside the
344
+ // session/registry gate. A wrong method on the admin path is a 405, never a
345
+ // fall-through to the relay.
346
+ if (url.pathname === ADMIN_HEARTH_IMAGE_PATH) {
347
+ if (req.method !== 'POST') {
348
+ sendText(res, 405, 'method not allowed\n');
349
+ return;
350
+ }
351
+ await respondAdminHearthImage(req, res, db);
352
+ return;
353
+ }
218
354
  if (DYNAMIC_ROUTE_PATHS.has(url.pathname)) {
219
355
  const route = findDynamicRoute(req.method, url.pathname);
220
356
  if (route === undefined) {
@@ -0,0 +1,15 @@
1
+ import type { HomeId } from './types.js';
2
+ export declare function beginHttp(id: HomeId): void;
3
+ export declare function endHttp(id: HomeId): void;
4
+ export declare function beginWs(id: HomeId): void;
5
+ export declare function endWs(id: HomeId): void;
6
+ export declare function beginOAuth(id: HomeId): void;
7
+ export declare function endOAuth(id: HomeId): void;
8
+ /** Is `id` currently serving — any active relay HTTP request, any open relay
9
+ * WebSocket bridge, or a live (non-stale) OAuth start->finish window? This is
10
+ * the ONLY signal `wake.ts`'s `homeIsServing` consults — it does not depend
11
+ * on `resolveRunning`/`WARM_VERDICT_TTL_MS` at all. */
12
+ export declare function isHomeServing(id: HomeId): boolean;
13
+ /** Test/smoke-script hygiene only — production never needs this (one CP
14
+ * process, S2, and every counter self-clears on its own end/dec path). */
15
+ export declare function resetServingForTests(): void;
@@ -0,0 +1,106 @@
1
+ // control-plane/serving.ts — CP-layer active-session tracker, independent of
2
+ // the wake path's warm-verdict TTL.
3
+ //
4
+ // `wake.ts`'s `resolveRunning`/`WARM_VERDICT_TTL_MS` answers "did we recently
5
+ // confirm this home is up" for exactly 10s — a cheap fast-path cache, not a
6
+ // live-session signal. A destructive roll must never fire while a home has
7
+ // an open browser/broker WebSocket, an in-flight HTTP request, or an open
8
+ // OAuth start->finish window, no matter how long that session has been
9
+ // running (design §3's "no active turn / no in-flight OAuth" safety
10
+ // invariant).
11
+ //
12
+ // Three independent live-count maps, keyed by HomeId, are bumped directly at
13
+ // the relay's HTTP/WS lifecycle seams and at the CP's OAuth start/finish
14
+ // dispatch — NOT derived from any cache/TTL. A home is "serving" whenever
15
+ // any one of them is non-zero. Single CP process (S2), in-process only, no
16
+ // persistence — a CP restart naturally drops to zero, which is correct (a
17
+ // fresh process has no in-flight anything).
18
+ const httpInflight = new Map();
19
+ const wsOpen = new Map();
20
+ const oauthInProgress = new Map();
21
+ // Timestamp of the most recent `beginOAuth` call for this home — the TTL
22
+ // clock for the bounded-safety rule below. Refreshed on every `beginOAuth`,
23
+ // so each new `start` gets its own full TTL window regardless of how stale
24
+ // an earlier, still-uncompleted start's marker had become. Cleared once the
25
+ // counter returns to 0 (no window is open, nothing to bound).
26
+ const oauthStartedAt = new Map();
27
+ // Bounded safety — "do not let a dropped OAuth pin a home un-rollable
28
+ // forever": a `start` with no matching `finish` (browser tab closed
29
+ // mid-flow, network drop, abandoned auth) must not hold `isHomeServing` true
30
+ // indefinitely. Chose a TTL over a "clear on idle" scheme because idle here
31
+ // has no other natural signal to key off (no heartbeat on the OAuth lane)
32
+ // and a TTL is simplest to reason about / test. 5 minutes is generous for a
33
+ // real human completing an OAuth redirect+approve+callback round trip (the
34
+ // M0 in-guest flow is a single localhost redirect, seconds in the happy
35
+ // path) while still bounding the worst case to a single-digit-minutes window
36
+ // rather than forever.
37
+ const OAUTH_MARKER_TTL_MS = 5 * 60_000;
38
+ function inc(map, id) {
39
+ map.set(id, (map.get(id) ?? 0) + 1);
40
+ }
41
+ function dec(map, id) {
42
+ const current = map.get(id) ?? 0;
43
+ const next = current - 1;
44
+ if (next <= 0) {
45
+ map.delete(id);
46
+ }
47
+ else {
48
+ map.set(id, next);
49
+ }
50
+ }
51
+ export function beginHttp(id) {
52
+ inc(httpInflight, id);
53
+ }
54
+ export function endHttp(id) {
55
+ dec(httpInflight, id);
56
+ }
57
+ export function beginWs(id) {
58
+ inc(wsOpen, id);
59
+ }
60
+ export function endWs(id) {
61
+ dec(wsOpen, id);
62
+ }
63
+ export function beginOAuth(id) {
64
+ // Every real OAuth `start` is a fresh auth window and earns its own full
65
+ // TTL, not just the first one since the counter hit 0 — otherwise a stale,
66
+ // abandoned start's marker would age out from under a brand-new retry that
67
+ // shares its counter, letting that retry's own wake see `!isHomeServing`.
68
+ oauthStartedAt.set(id, Date.now());
69
+ inc(oauthInProgress, id);
70
+ }
71
+ export function endOAuth(id) {
72
+ dec(oauthInProgress, id);
73
+ if ((oauthInProgress.get(id) ?? 0) === 0)
74
+ oauthStartedAt.delete(id);
75
+ }
76
+ /** Is an OAuth window open for `id` right now, per the counter, and still
77
+ * within the bounded-safety TTL of the MOST RECENT `beginOAuth`? A marker
78
+ * past TTL is treated as stale (does NOT count as serving) so a dropped
79
+ * `start` can't pin a home un-rollable forever; the counter itself is left
80
+ * alone (an eventual late `finish` still decrements it correctly, and `dec`
81
+ * floors at 0 regardless), and a later `beginOAuth` on the same counter
82
+ * refreshes the timestamp so its own window is judged live. */
83
+ function oauthMarkerLive(id) {
84
+ const count = oauthInProgress.get(id) ?? 0;
85
+ if (count <= 0)
86
+ return false;
87
+ const startedAt = oauthStartedAt.get(id);
88
+ if (startedAt === undefined)
89
+ return true; // defensive: count>0 with no timestamp — treat as live
90
+ return Date.now() - startedAt < OAUTH_MARKER_TTL_MS;
91
+ }
92
+ /** Is `id` currently serving — any active relay HTTP request, any open relay
93
+ * WebSocket bridge, or a live (non-stale) OAuth start->finish window? This is
94
+ * the ONLY signal `wake.ts`'s `homeIsServing` consults — it does not depend
95
+ * on `resolveRunning`/`WARM_VERDICT_TTL_MS` at all. */
96
+ export function isHomeServing(id) {
97
+ return (httpInflight.get(id) ?? 0) > 0 || (wsOpen.get(id) ?? 0) > 0 || oauthMarkerLive(id);
98
+ }
99
+ /** Test/smoke-script hygiene only — production never needs this (one CP
100
+ * process, S2, and every counter self-clears on its own end/dec path). */
101
+ export function resetServingForTests() {
102
+ httpInflight.clear();
103
+ wsOpen.clear();
104
+ oauthInProgress.clear();
105
+ oauthStartedAt.clear();
106
+ }
@@ -64,3 +64,23 @@ export interface TenantSession {
64
64
  tenant_id: TenantId;
65
65
  user_id: UserId;
66
66
  }
67
+ /** The single global auto-upgrade target row (`hearth_target`, db.ts v9).
68
+ * `channel` is always `'home'` (single-tenant, one image line). The DESIRED
69
+ * crtr version/image the fleet rolls onto; `home.template_version` is the
70
+ * ACTUAL running version. `last_bad_target_version`, when set, is a release
71
+ * whose live roll already failed — the wake-path stale predicate excludes it
72
+ * so no home re-rolls onto it until the target advances. */
73
+ export interface HearthTarget {
74
+ channel: 'home';
75
+ target_template_version: string;
76
+ target_image_ref: string;
77
+ published_at: string;
78
+ source: string;
79
+ last_bad_target_version: string | null;
80
+ }
81
+ export interface SetHearthTargetInput {
82
+ target_template_version: string;
83
+ target_image_ref: string;
84
+ source: string;
85
+ published_at?: string;
86
+ }
@@ -1,7 +1,8 @@
1
1
  import { type HomeBackend } from '../../core/hearth/provider.js';
2
2
  import type { M0Config } from '../../core/hearth/types.js';
3
3
  import { type ControlPlaneConfig } from './config.js';
4
- import { getHomeById, refreshProviderPointers, setHomeStatus, touchReady, touchWake } from './registry.js';
4
+ import { getHearthTarget, recordBadTarget } from './hearth-target.js';
5
+ import { getHomeById, refreshProviderPointers, setHealthVerdict, setHomeStatus, touchReady, touchWake } from './registry.js';
5
6
  import { resolveSecret } from './secrets.js';
6
7
  import type { HomeId, HomeStatus } from './types.js';
7
8
  export interface WakeResult {
@@ -40,8 +41,11 @@ declare function probeNodeReady(previewEndpoint: string, target: string, relayTo
40
41
  * never reassigns these; only ad hoc verification scripts do. */
41
42
  export declare const wakeInternals: {
42
43
  getHomeById: typeof getHomeById;
44
+ getHearthTarget: typeof getHearthTarget;
45
+ recordBadTarget: typeof recordBadTarget;
43
46
  refreshProviderPointers: typeof refreshProviderPointers;
44
47
  setHomeStatus: typeof setHomeStatus;
48
+ setHealthVerdict: typeof setHealthVerdict;
45
49
  touchWake: typeof touchWake;
46
50
  touchReady: typeof touchReady;
47
51
  resolveSecret: typeof resolveSecret;
@@ -64,10 +68,12 @@ export declare function invalidateWarmVerdict(home_id: HomeId): void;
64
68
  /** Clear all warm verdicts and in-flight wake coalescing. Test/smoke-script
65
69
  * hygiene only — production never needs this (one CP process, S2). */
66
70
  export declare function resetWakeCacheForTests(): void;
71
+ export declare function serializeError(error: unknown): string;
67
72
  /** Bring a home up and ready, returning the route to relay to once it is.
68
73
  * Reads the registry, resolves the relay token, calls the provider
69
74
  * wake/resume seam, ready-probes `/node/<home_agent_target>`, refreshes
70
- * provider pointers on a recreate, records wake/ready timing, and returns
75
+ * provider pointers on a recreate, records wake/ready timing, clears the
76
+ * health verdict to `healthy` on success, and returns
71
77
  * `{ preview_endpoint, relayToken }`. Throws on a ready-probe timeout
72
78
  * (wake-failure UX is deferred — gap note 2 — a throw is the accepted
73
79
  * cutover behavior).
@@ -31,10 +31,12 @@
31
31
  import { setTimeout as delay } from 'node:timers/promises';
32
32
  import { WebSocket } from 'ws';
33
33
  import { general } from '../../core/errors.js';
34
- import { loadM0Config } from '../../core/hearth/config.js';
34
+ import { hearthHomeImageRefForVersion, loadM0Config } from '../../core/hearth/config.js';
35
35
  import { getHomeBackend } from '../../core/hearth/provider.js';
36
36
  import { loadControlPlaneConfig } from './config.js';
37
- import { getHomeById, refreshProviderPointers, setHomeStatus, touchReady, touchWake } from './registry.js';
37
+ import { getHearthTarget, recordBadTarget } from './hearth-target.js';
38
+ import { getHomeById, refreshProviderPointers, setHealthVerdict, setHomeStatus, touchReady, touchWake } from './registry.js';
39
+ import { isHomeServing as isHomeServingNow } from './serving.js';
38
40
  import { resolveSecret } from './secrets.js';
39
41
  // Individual probe attempt timeout — bounded short so a dead route fails
40
42
  // fast and the retry loop gets multiple attempts inside the overall window,
@@ -199,8 +201,11 @@ async function probeNodeReady(previewEndpoint, target, relayToken, windowMs) {
199
201
  * never reassigns these; only ad hoc verification scripts do. */
200
202
  export const wakeInternals = {
201
203
  getHomeById,
204
+ getHearthTarget,
205
+ recordBadTarget,
202
206
  refreshProviderPointers,
203
207
  setHomeStatus,
208
+ setHealthVerdict,
204
209
  touchWake,
205
210
  touchReady,
206
211
  resolveSecret,
@@ -237,6 +242,223 @@ export function resetWakeCacheForTests() {
237
242
  warmVerdicts.clear();
238
243
  inflightWakes.clear();
239
244
  }
245
+ /** Is this home currently serving, so a destructive roll would interrupt it?
246
+ * Delegates entirely to `serving.ts`'s independent live-session counters,
247
+ * bumped at the relay's HTTP/WS lifecycle seams and the CP's OAuth
248
+ * start/finish dispatch — never derived from any TTL/cache. `home.status` is
249
+ * intentionally NOT consulted: a suspended home has no live counters anyway
250
+ * (nothing to bump them), so this stays a single source of truth. */
251
+ function homeIsServing(home) {
252
+ return isHomeServingNow(home.home_id);
253
+ }
254
+ /** Is this home due to roll onto the registered target? Stale = it isn't
255
+ * already on the target version AND the target is not a known-bad release.
256
+ * The bad-target clause is the ONLY brake in the zero-touch loop (roadmap
257
+ * decision 1: never auto-advance onto a release that already failed a roll):
258
+ * once `recordBadTarget` poisons the current target, no home rolls onto it
259
+ * until a NEW release advances the target past it.
260
+ *
261
+ * NOTE (design divergence, flagged): design §3's literal snippet compared
262
+ * `home.template_version !== last_bad_target_version`, which would THRASH —
263
+ * after a failed roll a home sits on the PRIOR version, so that clause stays
264
+ * true and it would re-roll onto the same broken target forever. The roadmap's
265
+ * plain-English brake and design §5's own comment both mean `target !==
266
+ * last_bad`; that is what is implemented here. */
267
+ function isHomeStale(home, target) {
268
+ if (home.template_version === null)
269
+ return false;
270
+ if (home.template_version === target.target_template_version)
271
+ return false;
272
+ if (target.last_bad_target_version !== null && target.target_template_version === target.last_bad_target_version)
273
+ return false;
274
+ return true;
275
+ }
276
+ // Baked crtr path (deploy/hearth-blaxel/Dockerfile symlink) — target it
277
+ // directly for the in-guest version assert rather than a bare PATH lookup,
278
+ // exactly as blaxel-bootstrap.ts does, so a stale legacy binary earlier on
279
+ // PATH can't answer instead.
280
+ const BAKED_CRTR_PATH = '/usr/local/bin/crtr';
281
+ /** Assert what crtr the recreated guest is ACTUALLY running (design §4 step 7)
282
+ * by reading `crtr --json sys version` in-guest — stronger than trusting the
283
+ * provider's config version, since it proves the booted binary. Parses the
284
+ * `.version` field from `{ "version": "X.Y.Z" }`. The same drift-guard env
285
+ * vars the bootstrap uses keep this a pure read (no bootstrap/auto-init/
286
+ * auto-update side effects). */
287
+ async function assertGuestVersion(backend, sandboxName) {
288
+ const result = await backend.exec(sandboxName, {
289
+ command: `CRTR_NO_BOOTSTRAP=1 CRTR_NO_AUTO_INIT=1 CRTR_NO_AUTO_UPDATE=1 ${BAKED_CRTR_PATH} --json sys version`,
290
+ timeoutMs: 20_000,
291
+ });
292
+ // A parseable stdout is not proof the command itself succeeded — require a
293
+ // clean exit before trusting anything it printed.
294
+ if (result.exitCode !== 0 || result.timedOut === true) {
295
+ throw general(`in-guest crtr sys version did not exit cleanly (exitCode ${result.exitCode}, timedOut ${result.timedOut})`, {
296
+ sandboxName,
297
+ exitCode: result.exitCode,
298
+ timedOut: result.timedOut,
299
+ stdout: result.stdout.slice(0, 200),
300
+ stderr: result.stderr.slice(0, 200),
301
+ });
302
+ }
303
+ const stdout = result.stdout.trim();
304
+ try {
305
+ const parsed = JSON.parse(stdout);
306
+ if (typeof parsed.version === 'string' && parsed.version.trim() !== '')
307
+ return parsed.version.trim();
308
+ }
309
+ catch {
310
+ /* fall through to a tolerant field extraction below */
311
+ }
312
+ const match = /"version"\s*:\s*"([^"]+)"/.exec(stdout);
313
+ if (match !== null && match[1].trim() !== '')
314
+ return match[1].trim();
315
+ throw general(`in-guest crtr sys version returned no parseable .version (exit ${result.exitCode})`, {
316
+ sandboxName,
317
+ stdout: stdout.slice(0, 200),
318
+ });
319
+ }
320
+ /** Structured HIGH-SEVERITY alert lines. The CP is a standalone Fly process
321
+ * and cannot `crtr push`; alerting is a distinct `level: 'error'` stderr line
322
+ * with an `alert` marker that ops log-alerting keys on. (Wiring these to a
323
+ * real notification channel is future work — the marker is the seam.) */
324
+ function alertRollRolledBack(home, target, restoredVersion) {
325
+ process.stderr.write(`${JSON.stringify({
326
+ level: 'error',
327
+ alert: 'hearth.roll.rolled_back',
328
+ message: `Hearth auto-upgrade roll FAILED and was rolled back — release ${target.target_template_version} is broken`,
329
+ home_id: home.home_id,
330
+ bad_target_version: target.target_template_version,
331
+ restored_version: restoredVersion,
332
+ })}\n`);
333
+ }
334
+ export function serializeError(error) {
335
+ if (error instanceof Error)
336
+ return error.message;
337
+ try {
338
+ // `JSON.stringify` returns `undefined` (does not throw) for `undefined`, a function, or a
339
+ // symbol — falling through to that would OMIT the `error` field from the incident alert's
340
+ // JSON line entirely, which is worse than the "[object Object]" bug this rider fixes.
341
+ const json = JSON.stringify(error);
342
+ return json === undefined ? String(error) : json;
343
+ }
344
+ catch {
345
+ return String(error);
346
+ }
347
+ }
348
+ function alertRollIncident(home, target, priorVersion, error) {
349
+ process.stderr.write(`${JSON.stringify({
350
+ level: 'error',
351
+ alert: 'hearth.roll.incident',
352
+ message: `Hearth auto-upgrade roll AND rollback both failed — home ${home.home_id} is DEGRADED, manual recovery needed`,
353
+ home_id: home.home_id,
354
+ bad_target_version: target.target_template_version,
355
+ prior_version: priorVersion,
356
+ error: serializeError(error),
357
+ })}\n`);
358
+ }
359
+ /** Fall back onto the PRIOR pinned image after a failed roll (design §5). The
360
+ * volume was never touched by the roll, so this is deterministic: recreate on
361
+ * the prior image name (still in the registry — images are never pruned),
362
+ * revive, ready-probe, assert `crtr sys version === priorVersion`. On success,
363
+ * ALERT (the release is broken) — the bad target is already poisoned by
364
+ * `rollHome`'s catch (the roll's own failure is what poisons the target, not
365
+ * rollback's success, so the brake is set even when rollback ALSO fails).
366
+ * On rollback also failing: mark the home
367
+ * `unknown`/`degraded` and alert — a genuine incident (the volume is intact,
368
+ * so manual recovery is always possible). */
369
+ async function rollbackHome(args) {
370
+ const { home, target, descriptor, priorImageRef, priorVersion, relayToken, backend, readyTimeoutMs } = args;
371
+ try {
372
+ const refresh = await backend.recreateOnImage(descriptor, priorImageRef, priorVersion);
373
+ const previewEndpoint = requireHomeField(refresh.previewEndpoint ?? null, 'preview_endpoint', home.home_id);
374
+ const ready = await wakeInternals.probeNodeReady(previewEndpoint, descriptor.homeAgentTarget, relayToken, readyTimeoutMs);
375
+ if (!ready)
376
+ throw general(`rollback ready-probe timed out for ${home.home_id}`, { home_id: home.home_id });
377
+ // Assert against the sandbox the recreate actually reports, not a stale name captured at
378
+ // entry — under generation naming (recreate-race-fix.md §3.5) the pre-recreate sandbox is
379
+ // destroyed, so exec-ing into anything else would target a gone/wrong generation.
380
+ const asserted = await assertGuestVersion(backend, refresh.providerSandboxId);
381
+ if (asserted !== priorVersion) {
382
+ throw general(`rollback version mismatch: expected ${priorVersion}, got ${asserted}`, { home_id: home.home_id, expected: priorVersion, asserted });
383
+ }
384
+ wakeInternals.refreshProviderPointers(home.home_id, {
385
+ provider_sandbox_id: refresh.providerSandboxId,
386
+ preview_endpoint: previewEndpoint,
387
+ template_version: priorVersion,
388
+ status: 'running',
389
+ });
390
+ wakeInternals.setHealthVerdict(home.home_id, 'healthy');
391
+ alertRollRolledBack(home, target, priorVersion);
392
+ return { preview_endpoint: previewEndpoint, relayToken };
393
+ }
394
+ catch (rollbackError) {
395
+ wakeInternals.setHomeStatus(home.home_id, 'unknown');
396
+ wakeInternals.setHealthVerdict(home.home_id, 'degraded');
397
+ alertRollIncident(home, target, priorVersion, rollbackError);
398
+ throw general(`hearth roll AND rollback both failed for ${home.home_id}`, {
399
+ home_id: home.home_id,
400
+ target: target.target_template_version,
401
+ priorVersion,
402
+ });
403
+ }
404
+ }
405
+ /** CP-driven destroy→recreate of a stale home onto the registered target
406
+ * image, reattaching the same durable volume (design §4). Captures rollback
407
+ * anchors first, marks the home `restoring` (so a concurrent wake coalesces
408
+ * rather than races), then recreates on the target image, ready-probes, and
409
+ * ASSERTS the in-guest `crtr sys version` equals the target before committing
410
+ * pointers + `health_verdict = healthy`. A roll never writes
411
+ * `home_agent_target` (canvas.db is reattached byte-identical;
412
+ * `refreshProviderPointers` has no such field). Any of recreate/probe/assert
413
+ * failing falls back to the prior image (§5). Returns the route so the
414
+ * triggering wake serves the just-upgraded home in the same call. */
415
+ async function rollHome(home, target, relayToken, descriptor) {
416
+ const priorVersion = requireHomeField(home.template_version, 'template_version', home.home_id);
417
+ const priorImageRef = hearthHomeImageRefForVersion(priorVersion);
418
+ const m0 = wakeInternals.loadM0Config();
419
+ const backend = wakeInternals.getHomeBackend(m0);
420
+ const cpConfig = wakeInternals.loadControlPlaneConfig();
421
+ wakeInternals.setHomeStatus(home.home_id, 'restoring');
422
+ invalidateWarmVerdict(home.home_id);
423
+ const startedAt = wakeInternals.now();
424
+ try {
425
+ const refresh = await backend.recreateOnImage(descriptor, target.target_image_ref, target.target_template_version);
426
+ const previewEndpoint = requireHomeField(refresh.previewEndpoint ?? null, 'preview_endpoint', home.home_id);
427
+ const ready = await wakeInternals.probeNodeReady(previewEndpoint, descriptor.homeAgentTarget, relayToken, cpConfig.readyTimeoutMs);
428
+ if (!ready)
429
+ throw general(`roll ready-probe timed out for ${home.home_id}`, { home_id: home.home_id });
430
+ // Assert against the sandbox the recreate actually reports (recreate-race-fix.md §3.5) —
431
+ // never the row's pre-recreate `provider_sandbox_id`, which under generation naming is
432
+ // already destroyed by the time this runs.
433
+ const asserted = await assertGuestVersion(backend, refresh.providerSandboxId);
434
+ if (asserted !== target.target_template_version) {
435
+ throw general(`roll version mismatch: expected ${target.target_template_version}, got ${asserted}`, {
436
+ home_id: home.home_id,
437
+ expected: target.target_template_version,
438
+ asserted,
439
+ });
440
+ }
441
+ wakeInternals.refreshProviderPointers(home.home_id, {
442
+ provider_sandbox_id: refresh.providerSandboxId,
443
+ preview_endpoint: previewEndpoint,
444
+ template_version: asserted,
445
+ status: 'running',
446
+ });
447
+ wakeInternals.setHealthVerdict(home.home_id, 'healthy');
448
+ wakeInternals.touchWake(home.home_id, { at: new Date(startedAt).toISOString(), latency_ms: wakeInternals.now() - startedAt });
449
+ wakeInternals.touchReady(home.home_id);
450
+ return { preview_endpoint: previewEndpoint, relayToken };
451
+ }
452
+ catch {
453
+ // The target is known-bad the INSTANT its own roll fails, independent of
454
+ // whether rollback then recovers — poison it here, before delegating to
455
+ // rollbackHome, so a roll-fails+rollback-also-fails home doesn't leave the
456
+ // brake unset (which would let a later wake, of this home or any other,
457
+ // re-roll onto the same broken target).
458
+ wakeInternals.recordBadTarget(target.target_template_version);
459
+ return rollbackHome({ home, target, descriptor, priorImageRef, priorVersion, relayToken, backend, readyTimeoutMs: cpConfig.readyTimeoutMs });
460
+ }
461
+ }
240
462
  async function runEnsureAwake(home_id) {
241
463
  const home = wakeInternals.getHomeById(home_id);
242
464
  if (home === null)
@@ -245,6 +467,16 @@ async function runEnsureAwake(home_id) {
245
467
  const relayToken = wakeInternals.resolveSecret(relayAuthRef);
246
468
  const target = requireHomeField(home.home_agent_target, 'home_agent_target', home_id);
247
469
  const descriptor = buildDescriptor(home, relayToken);
470
+ // Auto-upgrade lazy roll (design §3/§4): if a newer image target is
471
+ // registered, this home is stale, and it is NOT currently serving, roll it
472
+ // onto the target image now instead of the normal wake. At wake-entry an
473
+ // idle home is definitionally safe to tear down; a serving home doesn't roll
474
+ // this pass (it rolls on its next cold wake). This is the ONLY new branch —
475
+ // the normal wake path below is unchanged.
476
+ const hearthTarget = wakeInternals.getHearthTarget();
477
+ if (hearthTarget !== null && isHomeStale(home, hearthTarget) && !homeIsServing(home)) {
478
+ return rollHome(home, hearthTarget, relayToken, descriptor);
479
+ }
248
480
  const startedAt = wakeInternals.now();
249
481
  const m0 = wakeInternals.loadM0Config();
250
482
  const refresh = await wakeInternals.getHomeBackend(m0).wake(descriptor);
@@ -277,12 +509,18 @@ async function runEnsureAwake(home_id) {
277
509
  const readyAt = new Date().toISOString();
278
510
  wakeInternals.touchWake(home_id, { at: new Date(startedAt).toISOString(), latency_ms: wakeInternals.now() - startedAt });
279
511
  wakeInternals.touchReady(home_id, readyAt);
512
+ // A successful ready-probe means the home is healthy right now, regardless of what the
513
+ // verdict was before this wake (e.g. left `degraded` by an earlier failed rollback) —
514
+ // clear it here the same way rollHome/rollbackHome clear it on their own success, or a
515
+ // stale degraded/unknown verdict never heals on the normal wake path.
516
+ wakeInternals.setHealthVerdict(home_id, 'healthy');
280
517
  return { preview_endpoint: resolvedPreviewEndpoint, relayToken };
281
518
  }
282
519
  /** Bring a home up and ready, returning the route to relay to once it is.
283
520
  * Reads the registry, resolves the relay token, calls the provider
284
521
  * wake/resume seam, ready-probes `/node/<home_agent_target>`, refreshes
285
- * provider pointers on a recreate, records wake/ready timing, and returns
522
+ * provider pointers on a recreate, records wake/ready timing, clears the
523
+ * health verdict to `healthy` on success, and returns
286
524
  * `{ preview_endpoint, relayToken }`. Throws on a ready-probe timeout
287
525
  * (wake-failure UX is deferred — gap note 2 — a throw is the accepted
288
526
  * cutover behavior).